diff --git a/client/src/javascript/actions/TorrentActions.ts b/client/src/javascript/actions/TorrentActions.ts index 16f9a34e..bed20880 100644 --- a/client/src/javascript/actions/TorrentActions.ts +++ b/client/src/javascript/actions/TorrentActions.ts @@ -1,9 +1,11 @@ import axios from 'axios'; +import download from 'js-file-download'; import type { AddTorrentByFileOptions, AddTorrentByURLOptions, CheckTorrentsOptions, + CreateTorrentOptions, DeleteTorrentsOptions, MoveTorrentsOptions, SetTorrentContentsPropertiesOptions, @@ -70,6 +72,25 @@ const TorrentActions = { }, ), + createTorrent: (options: CreateTorrentOptions) => + axios.post(`${baseURI}api/torrents/create`, options, {responseType: 'blob'}).then( + (response) => { + AppDispatcher.dispatchServerAction({ + type: 'CLIENT_ADD_TORRENT_SUCCESS', + data: { + count: 1, + destination: '', + }, + }); + download(response.data, (options.name || `${Date.now()}`).concat('.torrent')); + }, + () => { + AppDispatcher.dispatchServerAction({ + type: 'CLIENT_ADD_TORRENT_ERROR', + }); + }, + ), + deleteTorrents: (options: DeleteTorrentsOptions) => axios .post(`${baseURI}api/torrents/delete`, options) diff --git a/client/src/javascript/components/general/filesystem/FilesystemBrowser.js b/client/src/javascript/components/general/filesystem/FilesystemBrowser.js index a637984f..240b906c 100644 --- a/client/src/javascript/components/general/filesystem/FilesystemBrowser.js +++ b/client/src/javascript/components/general/filesystem/FilesystemBrowser.js @@ -66,11 +66,9 @@ class FilesystemBrowser extends React.PureComponent { return `${directory}${separator}${nextDirectorySegment}`; } - handleDirectoryClick = (directory) => { - const nextDirectory = this.getNewDestination(directory); - - if (this.props.onDirectorySelection) { - this.props.onDirectorySelection(nextDirectory); + handleItemClick = (item, isDirectory = true) => { + if (this.props.onItemSelection) { + this.props.onItemSelection(this.getNewDestination(item), isDirectory); } }; @@ -87,18 +85,18 @@ class FilesystemBrowser extends React.PureComponent { directory = directoryArr.join(separator); - if (this.props.onDirectorySelection) { - this.props.onDirectorySelection(directory); + if (this.props.onItemSelection) { + this.props.onItemSelection(directory); } }; render() { - const {directories, errorResponse, files = [], hasParent} = this.state; + const {selectable} = this.props; + const {directories, errorResponse, files = []} = this.state; let errorMessage = null; let listItems = null; let parentDirectory = null; let shouldShowDirectoryList = true; - let shouldForceShowParentDirectory = false; if (directories == null) { shouldShowDirectoryList = false; @@ -112,10 +110,6 @@ class FilesystemBrowser extends React.PureComponent { if (errorResponse && errorResponse.data && errorResponse.data.code && MESSAGES[errorResponse.data.code]) { shouldShowDirectoryList = false; - if (errorResponse.data.code === 'EACCES') { - shouldForceShowParentDirectory = true; - } - errorMessage = (
{this.props.intl.formatMessage(MESSAGES[errorResponse.data.code])} @@ -123,37 +117,41 @@ class FilesystemBrowser extends React.PureComponent { ); } - if (hasParent || shouldForceShowParentDirectory) { - parentDirectory = ( -
  • - - {this.props.intl.formatMessage({ - id: 'filesystem.parent.directory', - })} -
  • - ); - } + parentDirectory = ( +
  • + + {this.props.intl.formatMessage({ + id: 'filesystem.parent.directory', + })} +
  • + ); if (shouldShowDirectoryList) { const directoryList = directories.map((directory, index) => (
  • this.handleDirectoryClick(directory)}> + onClick={selectable !== 'files' ? () => this.handleItemClick(directory) : undefined}> {directory}
  • )); const filesList = files.map((file, index) => ( - // TODO: Find a better key - // eslint-disable-next-line react/no-array-index-key -
  • +
  • this.handleItemClick(file, false) : undefined}> {file}
  • diff --git a/client/src/javascript/components/general/filesystem/TorrentDestination.tsx b/client/src/javascript/components/general/filesystem/FilesystemBrowserTextbox.tsx similarity index 82% rename from client/src/javascript/components/general/filesystem/TorrentDestination.tsx rename to client/src/javascript/components/general/filesystem/FilesystemBrowserTextbox.tsx index 7380ba72..651353dd 100644 --- a/client/src/javascript/components/general/filesystem/TorrentDestination.tsx +++ b/client/src/javascript/components/general/filesystem/FilesystemBrowserTextbox.tsx @@ -8,26 +8,28 @@ import Search from '../../icons/Search'; import SettingsStore from '../../../stores/SettingsStore'; import UIStore from '../../../stores/UIStore'; -interface TorrentDestinationProps extends WrappedComponentProps { +interface FilesystemBrowserTextboxProps extends WrappedComponentProps { id: string; label?: React.ReactNode; + selectable?: 'directories' | 'files'; suggested?: string; + basePathToggle?: boolean; onChange?: (destination: string) => void; } -interface TorrentDestinationStates { +interface FilesystemBrowserTextboxStates { destination: string; isDirectoryListOpen: boolean; } -class TorrentDestination extends React.Component { +class FilesystemBrowserTextbox extends React.Component { contextMenuInstanceRef: ContextMenu | null = null; contextMenuNodeRef: HTMLDivElement | null = null; textboxRef: HTMLInputElement | null = null; - constructor(props: TorrentDestinationProps) { + constructor(props: FilesystemBrowserTextboxProps) { super(props); const destination: string = @@ -49,7 +51,7 @@ class TorrentDestination extends React.Component { + handleItemSelection = (destination: string, isDirectory = true) => { if (this.textboxRef != null) { this.textboxRef.value = destination; } - this.setState({destination}); + this.setState({destination, isDirectoryListOpen: isDirectory}); }; handleDocumentClick = () => { @@ -138,6 +140,14 @@ class TorrentDestination extends React.Component + + + + + ) : null; + return ( @@ -179,20 +189,17 @@ class TorrentDestination extends React.Component - - - - - + {basePathToggle} ); } } -export default injectIntl(TorrentDestination, {forwardRef: true}); +export default injectIntl(FilesystemBrowserTextbox, {forwardRef: true}); diff --git a/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx b/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx index accaca70..5c9aa9c4 100644 --- a/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx +++ b/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx @@ -4,6 +4,18 @@ import {FormElementAddon, FormRow, FormRowGroup, Textbox} from '../../../ui'; import AddMini from '../../icons/AddMini'; import RemoveMini from '../../icons/RemoveMini'; +export const getTextArray = (formData: Record, id: string) => { + return Object.keys(formData).reduce((accumulator: Array, formItemKey: string) => { + if (formItemKey.startsWith(id)) { + const text = formData[formItemKey]; + if (text != null) { + accumulator.push(text); + } + } + return accumulator; + }, []); +}; + interface TextboxRepeaterProps { defaultValues?: Array<{id: number; value: string}>; id: number | string; diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByCreation.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByCreation.tsx new file mode 100644 index 00000000..d48e5e76 --- /dev/null +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByCreation.tsx @@ -0,0 +1,150 @@ +import {injectIntl, WrappedComponentProps} from 'react-intl'; +import React from 'react'; + +import {Checkbox, Form, FormRow, Textbox} from '../../../ui'; + +import AddTorrentsActions from './AddTorrentsActions'; + +import SettingsStore from '../../../stores/SettingsStore'; +import TagSelect from '../../general/form-elements/TagSelect'; +import TextboxRepeater, {getTextArray} from '../../general/form-elements/TextboxRepeater'; +import TorrentActions from '../../../actions/TorrentActions'; +import FilesystemBrowserTextbox from '../../general/filesystem/FilesystemBrowserTextbox'; + +type AddTorrentsByCreationFormData = { + [trackers: string]: string; +} & { + name: string; + sourcePath: string; + comment: string; + infoSource: string; + isPrivate: boolean; + start: boolean; + tags: string; +}; + +interface AddTorrentsByCreationStates { + isCreatingTorrents: boolean; + trackerTextboxes: Array<{id: number; value: string}>; +} + +class AddTorrentsByCreation extends React.Component { + formRef: Form | null = null; + + constructor(props: WrappedComponentProps) { + super(props); + + this.state = { + isCreatingTorrents: false, + trackerTextboxes: [{id: 0, value: ''}], + }; + } + + handleAddTorrents = () => { + if (this.formRef == null) { + return; + } + + const formData = this.formRef.getFormData() as Partial; + this.setState({isCreatingTorrents: true}); + + if (formData.sourcePath == null) { + return; + } + + TorrentActions.createTorrent({ + name: formData.name, + sourcePath: formData.sourcePath, + trackers: getTextArray(formData, 'trackers'), + comment: formData.comment, + infoSource: formData.infoSource, + isPrivate: formData.isPrivate || false, + start: formData.start || false, + tags: formData.tags != null ? formData.tags.split(',') : undefined, + }); + + SettingsStore.setFloodSetting('startTorrentsOnLoad', Boolean(formData.start)); + }; + + render() { + return ( +
    { + this.formRef = ref; + }}> + + + + + + + + + + + + + + {this.props.intl.formatMessage({id: 'torrents.create.is.private.label'})} + + + + + + + + ); + } +} + +export default injectIntl(AddTorrentsByCreation, {forwardRef: true}); diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx index 4f7b68da..9ab8fbb2 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx @@ -7,10 +7,10 @@ import AddTorrentsActions from './AddTorrentsActions'; import CloseIcon from '../../icons/Close'; import FileIcon from '../../icons/File'; import FilesIcon from '../../icons/Files'; +import FilesystemBrowserTextbox from '../../general/filesystem/FilesystemBrowserTextbox'; import SettingsStore from '../../../stores/SettingsStore'; import TagSelect from '../../general/form-elements/TagSelect'; import TorrentActions from '../../../actions/TorrentActions'; -import TorrentDestination from '../../general/filesystem/TorrentDestination'; interface AddTorrentsByFileFormData { destination: string; @@ -171,11 +171,13 @@ class AddTorrentsByFile extends React.Component {this.getFileDropzone()} - ; - return Object.keys(formData).reduce((accumulator: Array, formItemKey: string) => { - if (/^urls/.test(formItemKey)) { - const url = formData[formItemKey]; - if (url != null) { - accumulator.push(url); - } - } - - return accumulator; - }, []); - } - handleAddTorrents = () => { if (this.formRef == null) { return; @@ -72,7 +53,7 @@ class AddTorrentsByURL extends React.Component - ; @@ -25,6 +26,12 @@ class AddTorrentsModal extends React.Component { id: 'torrents.add.tab.file.title', }), }, + 'by-creation': { + content: AddTorrentsByCreation, + label: this.props.intl.formatMessage({ + id: 'torrents.add.tab.create.title', + }), + }, }; return ( diff --git a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx index 3acd0f82..badf2264 100644 --- a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx +++ b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx @@ -20,9 +20,9 @@ import Edit from '../../icons/Edit'; import Checkmark from '../../icons/Checkmark'; import Close from '../../icons/Close'; import FeedsStore, {FeedsStoreClass} from '../../../stores/FeedsStore'; +import FilesystemBrowserTextbox from '../../general/filesystem/FilesystemBrowserTextbox'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; import TagSelect from '../../general/form-elements/TagSelect'; -import TorrentDestination from '../../general/filesystem/TorrentDestination'; import * as validators from '../../../util/validators'; import type {Feeds, Rule, Rules} from '../../../stores/FeedsStore'; @@ -267,12 +267,14 @@ class DownloadRulesTab extends React.Component - { return this.handleFormSubmit((formData as unknown) as MoveTorrentsOptions); }}> - +
    diff --git a/client/src/javascript/i18n/strings.compiled.json b/client/src/javascript/i18n/strings.compiled.json index bd783644..01a055ae 100644 --- a/client/src/javascript/i18n/strings.compiled.json +++ b/client/src/javascript/i18n/strings.compiled.json @@ -1559,6 +1559,12 @@ "value": "Start Torrent" } ], + "torrents.add.tab.create.title": [ + { + "type": 0, + "value": "Create" + } + ], "torrents.add.tab.file.browse": [ { "type": 0, @@ -1601,6 +1607,72 @@ "value": "Torrents" } ], + "torrents.create.base.name.input.placeholder": [ + { + "type": 0, + "value": "Optional base file or directory name of the torrent" + } + ], + "torrents.create.base.name.label": [ + { + "type": 0, + "value": "Base Name" + } + ], + "torrents.create.comment.input.placeholder": [ + { + "type": 0, + "value": "Optional comment in torrent file" + } + ], + "torrents.create.comment.label": [ + { + "type": 0, + "value": "Comment" + } + ], + "torrents.create.info.source.input.placeholder": [ + { + "type": 0, + "value": "Optional source entry in infohash" + } + ], + "torrents.create.info.source.label": [ + { + "type": 0, + "value": "Info Source" + } + ], + "torrents.create.is.private.label": [ + { + "type": 0, + "value": "Private" + } + ], + "torrents.create.source.path.label": [ + { + "type": 0, + "value": "Source" + } + ], + "torrents.create.tags.input.placeholder": [ + { + "type": 0, + "value": "Tags in Flood. Not added to created torrent." + } + ], + "torrents.create.tracker.input.placeholder": [ + { + "type": 0, + "value": "Tracker URL" + } + ], + "torrents.create.trackers.label": [ + { + "type": 0, + "value": "Trackers" + } + ], "torrents.destination.base_path": [ { "type": 0, diff --git a/client/src/javascript/i18n/strings.json b/client/src/javascript/i18n/strings.json index 55810fea..5aba887d 100644 --- a/client/src/javascript/i18n/strings.json +++ b/client/src/javascript/i18n/strings.json @@ -218,8 +218,20 @@ "torrents.add.tab.file.title": "By File", "torrents.add.tab.url.input.placeholder": "Torrent URL or Magnet Link", "torrents.add.tab.url.title": "By URL", + "torrents.add.tab.create.title": "Create", "torrents.add.torrents.label": "Torrents", "torrents.add.tags": "Tags", + "torrents.create.source.path.label": "Source", + "torrents.create.trackers.label": "Trackers", + "torrents.create.tracker.input.placeholder": "Tracker URL", + "torrents.create.base.name.label": "Base Name", + "torrents.create.base.name.input.placeholder": "Optional base file or directory name of the torrent", + "torrents.create.comment.label": "Comment", + "torrents.create.comment.input.placeholder": "Optional comment in torrent file", + "torrents.create.info.source.label": "Info Source", + "torrents.create.info.source.input.placeholder": "Optional source entry in infohash", + "torrents.create.is.private.label": "Private", + "torrents.create.tags.input.placeholder": "Tags in Flood. Not added to created torrent.", "torrents.destination.base_path": "Use as Base Path", "torrents.details.actions.pause": "Pause", "torrents.details.actions.start": "Start", diff --git a/client/src/sass/components/_filesystem.scss b/client/src/sass/components/_filesystem.scss index fddbe339..4cb59945 100644 --- a/client/src/sass/components/_filesystem.scss +++ b/client/src/sass/components/_filesystem.scss @@ -9,25 +9,26 @@ $filesystem--directory-list--parent--border-color: lighten($filesystem--director list-style: none; &__item { + opacity: 0.5; padding: $spacing--xx-small $spacing--small; transition: color 0.25s; white-space: nowrap; &--parent, - &--directory { + &--selectable { + opacity: 1; cursor: pointer; transition: background $speed--x-fast, color $speed--x-fast; user-select: none; &:hover { color: $filesystem--directory-list--foreground--hover; + background: $filesystem--directory-list--background--hover; } } - &--directory { - &:hover { - background: $filesystem--directory-list--background--hover; - } + &--message { + opacity: 1; } &--parent { @@ -44,10 +45,6 @@ $filesystem--directory-list--parent--border-color: lighten($filesystem--director margin-bottom: 0; } } - - &--file { - opacity: 0.5; - } } .icon { diff --git a/client/src/sass/style.scss.d.ts b/client/src/sass/style.scss.d.ts index 21ccb563..666fa579 100644 --- a/client/src/sass/style.scss.d.ts +++ b/client/src/sass/style.scss.d.ts @@ -264,8 +264,8 @@ declare const styles: { readonly 'filesystem__directory-list': string; readonly 'filesystem__directory-list__item': string; readonly 'filesystem__directory-list__item--parent': string; - readonly 'filesystem__directory-list__item--directory': string; - readonly 'filesystem__directory-list__item--file': string; + readonly 'filesystem__directory-list__item--selectable': string; + readonly 'filesystem__directory-list__item--message': string; readonly 'floating-action__button': string; readonly 'floating-action__button--search': string; readonly 'floating-action__group--on-textbox': string; diff --git a/package-lock.json b/package-lock.json index 9d2d3053..def41a8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1982,6 +1982,15 @@ "integrity": "sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw==", "dev": true }, + "@types/create-torrent": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@types/create-torrent/-/create-torrent-4.4.0.tgz", + "integrity": "sha512-ED4MMoZeSQvNt6IhIiCunXy27Yl25fXf3SENKX4FBU4d1dYDC/rF7wDGir8G829YjUln8vbw0Hqp3T1xWr3mKA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/css-modules-loader-core": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@types/css-modules-loader-core/-/css-modules-loader-core-1.1.0.tgz", @@ -4188,6 +4197,28 @@ "inherits": "~2.0.0" } }, + "block-stream2": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.0.0.tgz", + "integrity": "sha512-1oI+RHHUEo64xomy1ozLgVJetFlHkIfQfJzTBQrj6xWnEMEPooeo2fZoqFjp0yzfHMBrgxwgh70tKp6T17+i3g==", + "dev": true, + "requires": { + "readable-stream": "^3.4.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -5593,6 +5624,39 @@ "sha.js": "^2.4.8" } }, + "create-torrent": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/create-torrent/-/create-torrent-4.4.2.tgz", + "integrity": "sha512-FRxgYty6AF00xrYKMtpQ14ZJlst+i7mmUhcN4do7TTjktEntqAzfriaOIV6xk27t9GLTtraFnaTxsGgnyFA2eA==", + "dev": true, + "requires": { + "bencode": "^2.0.0", + "block-stream2": "^2.0.0", + "filestream": "^5.0.0", + "is-file": "^1.0.0", + "junk": "^3.1.0", + "minimist": "^1.1.0", + "multistream": "^4.0.0", + "once": "^1.3.0", + "piece-length": "^2.0.1", + "readable-stream": "^3.0.2", + "run-parallel": "^1.0.0", + "simple-sha1": "^3.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -8404,6 +8468,29 @@ "integrity": "sha512-u4AYWPgbI5GBhs6id1KdImZWn5yfyFrrQ8OWZdN7ZMfA8Bf4HcO0BGo9bmUIEV8yrp8I1xVfJ/dn90GtFNNJcg==", "dev": true }, + "filestream": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/filestream/-/filestream-5.0.0.tgz", + "integrity": "sha512-5H3RqSaJp12THfZiNWodYM7TiKfQvrpX+EIOrB1XvCceTys4yvfEIl8wDp+/yI8qj6Bxym8m0NYWwVXDAet/+A==", + "dev": true, + "requires": { + "readable-stream": "^3.4.0", + "typedarray-to-buffer": "^3.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -10303,6 +10390,12 @@ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", "dev": true }, + "is-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-file/-/is-file-1.0.0.tgz", + "integrity": "sha1-KKRM+9nT2xkwRfIrZfzo7fliBZY=", + "dev": true + }, "is-finite": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", @@ -11633,6 +11726,12 @@ "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", "dev": true }, + "js-file-download": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", + "integrity": "sha512-rML+NkoD08p5Dllpjo0ffy4jRHeY6Zsapvr/W86N7E0yuzAO6qa5X9+xog6zQNlH102J7IXljNY2FtS6Lj3ucg==", + "dev": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11880,6 +11979,12 @@ "object.assign": "^4.1.0" } }, + "junk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", + "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", + "dev": true + }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -12830,6 +12935,28 @@ "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=", "dev": true }, + "multistream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.0.0.tgz", + "integrity": "sha512-t0C8MAtH/d3Y+5nooEtUMWli92lVw9Jhx4uOhRl5GAwS5vc+YTmp/VXNJNsCBAMeEyK/6zhbk6x9JE3AiCvo4g==", + "dev": true, + "requires": { + "readable-stream": "^3.4.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -13991,6 +14118,12 @@ "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", "dev": true }, + "piece-length": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/piece-length/-/piece-length-2.0.1.tgz", + "integrity": "sha512-dBILiDmm43y0JPISWEmVGKBETQjwJe6mSU9GND+P9KW0SJGUwoU/odyH1nbalOP9i8WSYuqf1lQnaj92Bhw+Ug==", + "dev": true + }, "pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -16462,6 +16595,12 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "queue-microtask": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.1.4.tgz", + "integrity": "sha512-eY/4Obve9cE5FK8YvC1cJsm5cr7XvAurul8UtBDJ2PR1p5NmAwHtvAt5ftcLtwYRCUKNhxCneZZlxmUDFoSeKA==", + "dev": true + }, "raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -17756,6 +17895,12 @@ "aproba": "^1.1.1" } }, + "rusha": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/rusha/-/rusha-0.8.13.tgz", + "integrity": "sha1-mghOe4YLF7/zAVuSxnpqM2GRUTo=", + "dev": true + }, "rxjs": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz", @@ -18349,6 +18494,16 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "simple-sha1": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/simple-sha1/-/simple-sha1-3.0.1.tgz", + "integrity": "sha512-q7ehqWfHc1VhOm7sW099YDZ4I0yYX7rqyhqqhHV1IYeUTjPOhHyD3mXvv8k2P+rO7+7c8R4/D+8ffzC9BE7Cqg==", + "dev": true, + "requires": { + "queue-microtask": "^1.1.2", + "rusha": "^0.8.1" + } + }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", diff --git a/package.json b/package.json index 87ad76d9..3d3a884a 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@types/clipboard": "^2.0.1", "@types/compression": "^1.7.0", "@types/cookie-parser": "^1.4.2", + "@types/create-torrent": "^4.4.0", "@types/d3": "^5.16.3", "@types/debug": "^4.1.5", "@types/express": "^4.17.8", @@ -93,6 +94,7 @@ "clipboard": "^2.0.6", "compression": "^1.7.4", "cookie-parser": "^1.4.5", + "create-torrent": "^4.4.2", "css-loader": "^4.3.0", "d3-array": "^2.8.0", "d3-scale": "^3.2.3", @@ -127,6 +129,7 @@ "http-errors": "^1.8.0", "jest": "^26.5.2", "joi": "^17.2.1", + "js-file-download": "^0.4.12", "jsdoc": "^3.6.5", "jsonwebtoken": "^8.5.1", "lodash": "^4.17.20", diff --git a/server/models/TemporaryStorage.ts b/server/models/TemporaryStorage.ts index a9945ecf..e07d2f26 100644 --- a/server/models/TemporaryStorage.ts +++ b/server/models/TemporaryStorage.ts @@ -3,18 +3,12 @@ import path from 'path'; import {tempPath} from '../../config'; -class TemporaryStorage { - constructor() { - fs.mkdirSync(tempPath, {recursive: true}); - } +fs.mkdirSync(tempPath, {recursive: true}); - static deleteFile(filename: string): void { - fs.unlinkSync(TemporaryStorage.getTempPath(filename)); - } +export const getTempPath = (filename: string): string => { + return path.join(tempPath, filename); +}; - static getTempPath(filename: string): string { - return path.join(tempPath, filename); - } -} - -export default new TemporaryStorage(); +export const deleteFile = (filename: string): void => { + fs.unlinkSync(getTempPath(filename)); +}; diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index 5d07cc0e..c11ebff8 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -1,9 +1,14 @@ +import createTorrent from 'create-torrent'; import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import sanitize from 'sanitize-filename'; import { AddTorrentByFileOptions, AddTorrentByURLOptions, CheckTorrentsOptions, + CreateTorrentOptions, DeleteTorrentsOptions, MoveTorrentsOptions, SetTorrentContentsPropertiesOptions, @@ -13,8 +18,10 @@ import { StopTorrentsOptions, } from '@shared/types/api/torrents'; +import {accessDeniedError, isAllowedPath, sanitizePath} from '../../util/fileUtil'; import ajaxUtil from '../../util/ajaxUtil'; import client from '../../models/client'; +import {getTempPath} from '../../models/TemporaryStorage'; import mediainfo from '../../util/mediainfo'; import settings from '../../models/settings'; @@ -74,6 +81,68 @@ router.post('/add-files', (req, res) }); }); +/** + * POST /api/torrents/create + * @summary Creates a torrent + * @tags Torrents + * @security AuthenticatedUser + * @param {CreateTorrentOptions} request.body.required - options - application/json + * @return {object} 200 - success response - application/x-bittorrent + * @return {Error} 500 - failure response - application/json + */ +router.post('/create', async (req, res) => { + const {name, sourcePath, trackers, comment, infoSource, isPrivate} = req.body; + const callback = ajaxUtil.getResponseFn(res); + + if (typeof sourcePath !== 'string') { + callback(null, accessDeniedError()); + return; + } + + const sanitizedPath = sanitizePath(sourcePath); + if (!isAllowedPath(sanitizedPath)) { + callback(null, accessDeniedError()); + return; + } + + const torrentFileName = sanitize(name || sanitizedPath.split(path.sep).pop() || `${Date.now()}`).concat('.torrent'); + const torrentPath = getTempPath(torrentFileName); + + createTorrent( + sanitizedPath, + { + name, + comment, + createdBy: 'Flood - flood.js.org', + private: isPrivate, + announceList: [trackers], + info: infoSource + ? { + source: infoSource, + } + : undefined, + }, + (err, torrent) => { + if (err) { + callback(null, err); + return; + } + + fs.writeFile(torrentPath, torrent, (writeErr) => { + if (writeErr) { + callback(null, writeErr); + return; + } + + res.attachment(torrentFileName); + res.download(torrentPath); + + // TODO: add created torrent. + }); + }, + ); +}); + /** * POST /api/torrents/start * @summary Starts torrents. diff --git a/shared/types/api/torrents.ts b/shared/types/api/torrents.ts index a137a6db..add3a13e 100644 --- a/shared/types/api/torrents.ts +++ b/shared/types/api/torrents.ts @@ -28,6 +28,28 @@ export interface AddTorrentByFileOptions { start: boolean; } +// POST /api/torrents/create +export interface CreateTorrentOptions { + // Name of the torrent: + // For multi-file torrents, this becomes the base directory + // For single-file torrents, this becomes the filename + name?: string; + // Path to the file of folder + sourcePath: string; + // Trackers + trackers: Array; + // Optional comment in torrent file + comment?: string; + // Optional source entry in infohash + infoSource?: string; + // Whether the torrent is private + isPrivate: boolean; + // Whether to start torrent + start?: boolean; + // Tags, not added to torrent file + tags?: Array; +} + // POST /api/torrents/check-hash export interface CheckTorrentsOptions { // An array of string representing hashes of torrents to be checked