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 (
+
+ );
+ }
+}
+
+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