diff --git a/client/src/javascript/actions/ClientActions.ts b/client/src/javascript/actions/ClientActions.ts index cc018a88..5d3e1c50 100644 --- a/client/src/javascript/actions/ClientActions.ts +++ b/client/src/javascript/actions/ClientActions.ts @@ -2,7 +2,6 @@ import axios from 'axios'; import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; -import type {TransferDirection} from '@shared/types/TransferData'; import AppDispatcher from '../dispatcher/AppDispatcher'; import ConfigStore from '../stores/ConfigStore'; @@ -52,32 +51,6 @@ const ClientActions = { }, ), - setThrottle: (direction: TransferDirection, throttle: number) => - axios - .put(`${baseURI}api/client/settings/speed-limits`, { - direction, - throttle, - }) - .then((json) => json.data) - .then( - (transferData) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_THROTTLE_SUCCESS', - data: { - transferData, - }, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_THROTTLE_ERROR', - data: { - error, - }, - }); - }, - ), - testClientConnectionSettings: (connectionSettings: ClientConnectionSettings) => axios.post(`${baseURI}api/client/connection-test`, connectionSettings).then((json) => json.data), diff --git a/client/src/javascript/components/general/form-elements/Dropdown.tsx b/client/src/javascript/components/general/form-elements/Dropdown.tsx index 01c35abe..d329888c 100644 --- a/client/src/javascript/components/general/form-elements/Dropdown.tsx +++ b/client/src/javascript/components/general/form-elements/Dropdown.tsx @@ -7,23 +7,23 @@ import uniqueId from 'lodash/uniqueId'; import UIActions from '../../../actions/UIActions'; import UIStore from '../../../stores/UIStore'; -export interface DropdownItem { +export interface DropdownItem { className?: string; displayName: React.ReactNode; selectable: boolean; selected?: boolean; - property?: string; + property?: T; value?: number | null; } -type DropdownItems = Array; +type DropdownItems = Array>; -interface DropdownProps { +interface DropdownProps { header: React.ReactNode; trigger?: React.ReactNode; dropdownButtonClass?: string; - menuItems: Array; - handleItemSelect: (item: DropdownItem) => void; + menuItems: Array>; + handleItemSelect: (item: DropdownItem) => void; onOpen?: () => void; dropdownWrapperClass?: string; @@ -47,7 +47,7 @@ const METHODS_TO_BIND = [ 'handleKeyPress', ] as const; -class Dropdown extends React.Component { +class Dropdown extends React.Component, DropdownStates> { id = uniqueId('dropdown_'); static defaultProps = { @@ -59,14 +59,14 @@ class Dropdown extends React.Component { noWrap: false, }; - constructor(props: DropdownProps) { + constructor(props: DropdownProps) { super(props); this.state = { isOpen: false, }; - METHODS_TO_BIND.forEach((methodName: T) => { + METHODS_TO_BIND.forEach((methodName: M) => { this[methodName] = this[methodName].bind(this); }); @@ -111,7 +111,7 @@ class Dropdown extends React.Component { } } - handleItemSelect(item: DropdownItem) { + handleItemSelect(item: DropdownItem) { this.closeDropdown(); this.props.handleItemSelect(item); } @@ -136,7 +136,7 @@ class Dropdown extends React.Component { ); } - private getDropdownMenu(items: Array) { + private getDropdownMenu(items: Array>) { // TODO: Rewrite this function, wtf was I thinking const arrayMethod = this.props.direction === 'up' ? 'unshift' : 'push'; const content = [ @@ -165,7 +165,7 @@ class Dropdown extends React.Component { ); } - private getDropdownMenuItems(listItems: DropdownItems) { + private getDropdownMenuItems(listItems: DropdownItems) { return listItems.map((property, index) => { const classes = classnames('dropdown__item menu__item', property.className, { 'is-selectable': property.selectable !== false, diff --git a/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx b/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx index c721fad9..53aaee9f 100644 --- a/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx +++ b/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx @@ -4,7 +4,6 @@ import sortedIndex from 'lodash/sortedIndex'; import type {TransferDirection} from '@shared/types/TransferData'; -import ClientActions from '../../actions/ClientActions'; import connectStores from '../../util/connectStores'; import Dropdown from '../general/form-elements/Dropdown'; import LimitsIcon from '../icons/Limits'; @@ -36,9 +35,13 @@ const MESSAGES = defineMessages({ }); class SpeedLimitDropdown extends React.Component { - static handleItemSelect(item: DropdownItem) { + static handleItemSelect(item: DropdownItem) { if (item.value != null) { - ClientActions.setThrottle(item.property as TransferDirection, item.value); + if (item.property === 'download') { + SettingsStore.setClientSetting('throttleGlobalDownMax', item.value); + } else if (item.property === 'upload') { + SettingsStore.setClientSetting('throttleGlobalUpMax', item.value); + } } } @@ -79,7 +82,7 @@ class SpeedLimitDropdown extends React.Component { return ; } - getSpeedList(direction: TransferDirection): Array { + getSpeedList(direction: TransferDirection): Array> { const heading = { className: `dropdown__label dropdown__label--${direction}`, ...(direction === 'download' @@ -93,7 +96,7 @@ class SpeedLimitDropdown extends React.Component { const currentThrottle: Record = this.props.currentThrottles || {download: 0, upload: 0}; const speeds: number[] = (this.props.speedLimits != null && this.props.speedLimits[direction]) || [0]; - const items: Array = speeds.map((bytes) => { + const items: Array> = speeds.map((bytes) => { let selected = false; // Check if the current throttle setting exists in the preset speeds list. diff --git a/client/src/javascript/constants/EventTypes.ts b/client/src/javascript/constants/EventTypes.ts index 97e358b6..ca43e03c 100644 --- a/client/src/javascript/constants/EventTypes.ts +++ b/client/src/javascript/constants/EventTypes.ts @@ -19,8 +19,6 @@ const eventTypes = [ 'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS', 'CLIENT_SET_FILE_PRIORITY_ERROR', 'CLIENT_SET_FILE_PRIORITY_SUCCESS', - 'CLIENT_SET_THROTTLE_ERROR', - 'CLIENT_SET_THROTTLE_SUCCESS', 'CLIENT_SET_TORRENT_PRIORITY_ERROR', 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS', 'CLIENT_MOVE_TORRENTS_REQUEST_ERROR', diff --git a/client/src/javascript/constants/ServerActions.ts b/client/src/javascript/constants/ServerActions.ts index aef57bcb..8098e67f 100644 --- a/client/src/javascript/constants/ServerActions.ts +++ b/client/src/javascript/constants/ServerActions.ts @@ -20,7 +20,6 @@ const errorTypes = [ 'CLIENT_FETCH_TORRENT_DETAILS_ERROR', 'CLIENT_SET_FILE_PRIORITY_ERROR', 'CLIENT_SET_TAXONOMY_ERROR', - 'CLIENT_SET_THROTTLE_ERROR', 'CLIENT_SET_TORRENT_PRIORITY_ERROR', 'CLIENT_SET_TRACKER_ERROR', 'CLIENT_SETTINGS_FETCH_REQUEST_ERROR', @@ -45,7 +44,6 @@ const successTypes = [ 'CLIENT_CONNECTION_TEST_SUCCESS', 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS', 'CLIENT_SET_TAXONOMY_SUCCESS', - 'CLIENT_SET_THROTTLE_SUCCESS', 'CLIENT_SET_TRACKER_SUCCESS', 'CLIENT_START_TORRENT_SUCCESS', 'CLIENT_STOP_TORRENT_SUCCESS', diff --git a/client/src/javascript/stores/SettingsStore.ts b/client/src/javascript/stores/SettingsStore.ts index c78ad31a..9f70aefb 100644 --- a/client/src/javascript/stores/SettingsStore.ts +++ b/client/src/javascript/stores/SettingsStore.ts @@ -206,9 +206,6 @@ SettingsStore.dispatcherID = AppDispatcher.register((payload) => { case 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS': SettingsStore.handleClientSettingsFetchSuccess(action.data); break; - case 'CLIENT_SET_THROTTLE_SUCCESS': - ClientActions.fetchSettings(); - break; case 'SETTINGS_FETCH_REQUEST_ERROR': SettingsStore.handleSettingsFetchError(); break; diff --git a/client/src/javascript/stores/TransferDataStore.ts b/client/src/javascript/stores/TransferDataStore.ts index 6a750a28..773f2f1f 100644 --- a/client/src/javascript/stores/TransferDataStore.ts +++ b/client/src/javascript/stores/TransferDataStore.ts @@ -41,14 +41,6 @@ class TransferDataStoreClass extends BaseStore { return this.transferRates; } - handleSetThrottleSuccess() { - this.emit('CLIENT_SET_THROTTLE_SUCCESS'); - } - - handleSetThrottleError() { - this.emit('CLIENT_SET_THROTTLE_ERROR'); - } - handleFetchTransferHistoryError() { this.emit('CLIENT_TRANSFER_HISTORY_REQUEST_ERROR'); } @@ -94,12 +86,6 @@ TransferDataStore.dispatcherID = AppDispatcher.register((payload) => { case 'TRANSFER_SUMMARY_FULL_UPDATE': TransferDataStore.handleTransferSummaryFullUpdate(action.data); break; - case 'CLIENT_SET_THROTTLE_SUCCESS': - TransferDataStore.handleSetThrottleSuccess(); - break; - case 'CLIENT_SET_THROTTLE_ERROR': - TransferDataStore.handleSetThrottleError(); - break; case 'TRANSFER_HISTORY_FULL_UPDATE': TransferDataStore.handleFetchTransferHistorySuccess(action.data); break; diff --git a/jest.config.js b/jest.config.js index b7c34e21..28775dc8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,6 @@ module.exports = { verbose: true, + collectCoverage: true, + coverageProvider: 'v8', projects: ['/server/.jest/auth.config.js', '/server/.jest/test.config.js'], }; diff --git a/package-lock.json b/package-lock.json index 45c32ab2..34e0f684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2629,6 +2629,15 @@ "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", "dev": true }, + "@types/minipass": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/minipass/-/minipass-2.2.0.tgz", + "integrity": "sha512-wuzZksN4w4kyfoOv/dlpov4NOunwutLA/q7uc00xU02ZyUY+aoM5PWIXEKBMnm0NHd4a+N71BMjq+x7+2Af1fg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/morgan": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.1.tgz", @@ -2906,12 +2915,13 @@ "integrity": "sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==", "dev": true }, - "@types/tar-stream": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-2.1.0.tgz", - "integrity": "sha512-s1UQxQUVMHbSkCC0X4qdoiWgHF8DoyY1JjQouFsnk/8ysoTdBaiCHud/exoAZzKDbzAXVc+ah6sczxGVMAohFw==", + "@types/tar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-4.0.3.tgz", + "integrity": "sha512-Z7AVMMlkI8NTWF0qGhC4QIX0zkV/+y0J8x7b/RsHrN0310+YNjoJd8UrApCiGBCWtKjxS9QhNqLi2UJNToh5hA==", "dev": true, "requires": { + "@types/minipass": "*", "@types/node": "*" } }, @@ -4200,46 +4210,6 @@ } } }, - "bl": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", - "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==", - "dev": true, - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - }, - "dependencies": { - "buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", - "dev": true, - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "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" - } - } - } - }, "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", @@ -8868,12 +8838,6 @@ "readable-stream": "^2.0.0" } }, - "fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true - }, "fs-extra": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz", @@ -13155,6 +13119,17 @@ "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "dev": true }, + "tar": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", + "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", + "dev": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.12", + "inherits": "2" + } + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -19620,39 +19595,30 @@ "dev": true }, "tar": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", - "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz", + "integrity": "sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==", "dev": true, "requires": { - "block-stream": "*", - "fstream": "^1.0.12", - "inherits": "2" - } - }, - "tar-stream": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz", - "integrity": "sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw==", - "dev": true, - "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.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" - } + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true } } }, diff --git a/package.json b/package.json index 1285eadc..ce2c7a1e 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@types/react-transition-group": "^4.4.0", "@types/spdy": "^3.4.4", "@types/supertest": "^2.0.10", - "@types/tar-stream": "^2.1.0", + "@types/tar": "^4.0.3", "@typescript-eslint/eslint-plugin": "^4.4.0", "@typescript-eslint/parser": "^4.4.0", "@vercel/ncc": "^0.24.1", @@ -173,7 +173,7 @@ "spdy": "^4.0.2", "style-loader": "^1.3.0", "supertest": "^5.0.0", - "tar-stream": "^2.1.4", + "tar": "^6.0.5", "terser-webpack-plugin": "^4.2.1", "ts-jest": "^26.4.1", "ts-node-dev": "^1.0.0-pre.63", diff --git a/server/.jest/auth.config.js b/server/.jest/auth.config.js index 05fb5d69..07694693 100644 --- a/server/.jest/auth.config.js +++ b/server/.jest/auth.config.js @@ -1,8 +1,8 @@ module.exports = { preset: 'ts-jest/presets/js-with-babel', - roots: ['..'], - testMatch: ['/../routes/api/auth.test.ts'], - setupFilesAfterEnv: ['/auth.setup.js'], + rootDir: './../', + testMatch: ['/routes/api/auth.test.ts'], + setupFilesAfterEnv: ['/.jest/auth.setup.js'], globals: { 'ts-jest': { isolatedModules: true, diff --git a/server/.jest/auth.setup.js b/server/.jest/auth.setup.js index e4cf4043..09d1988c 100644 --- a/server/.jest/auth.setup.js +++ b/server/.jest/auth.setup.js @@ -3,7 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -const temporaryRuntimeDirectory = path.resolve(os.tmpdir(), crypto.randomBytes(12).toString('hex')); +const temporaryRuntimeDirectory = path.resolve(os.tmpdir(), `flood.test.${crypto.randomBytes(12).toString('hex')}`); process.argv = ['node', 'flood']; process.argv.push('--rundir', temporaryRuntimeDirectory); diff --git a/server/.jest/test.config.js b/server/.jest/test.config.js index ba540508..b74f4a60 100644 --- a/server/.jest/test.config.js +++ b/server/.jest/test.config.js @@ -1,8 +1,8 @@ module.exports = { preset: 'ts-jest/presets/js-with-babel', - roots: ['..'], + rootDir: './../', testPathIgnorePatterns: ['auth.test.ts'], - setupFilesAfterEnv: ['/test.setup.js'], + setupFilesAfterEnv: ['/.jest/test.setup.js'], globals: { 'ts-jest': { isolatedModules: true, diff --git a/server/.jest/test.setup.js b/server/.jest/test.setup.js index 4dcf74b9..997da3b3 100644 --- a/server/.jest/test.setup.js +++ b/server/.jest/test.setup.js @@ -4,7 +4,7 @@ import os from 'os'; import path from 'path'; import {spawn} from 'child_process'; -const temporaryRuntimeDirectory = path.resolve(os.tmpdir(), crypto.randomBytes(12).toString('hex')); +const temporaryRuntimeDirectory = path.resolve(os.tmpdir(), `flood.test.${crypto.randomBytes(12).toString('hex')}`); const rTorrentSession = path.join(temporaryRuntimeDirectory, '.session'); const rTorrentSocket = path.join(temporaryRuntimeDirectory, 'rtorrent.sock'); diff --git a/server/models/ClientRequest.js b/server/models/ClientRequest.js deleted file mode 100644 index a8f60111..00000000 --- a/server/models/ClientRequest.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * This file is deprecated in favor of clientGatewayService. - */ -import util from 'util'; - -const getEnsuredArray = (item) => { - if (!util.isArray(item)) { - return [item]; - } - return item; -}; - -const getMethodCall = (methodName, params) => { - params = params || []; - return {methodName, params}; -}; - -class ClientRequest { - constructor(user, services, options) { - options = options || {}; - - this.services = services; - this.user = user; - this.clientRequestManager = this.services.clientRequestManager; - - this.onCompleteFn = null; - this.postProcessFn = null; - this.requests = []; - - if (options.onComplete) { - this.onCompleteFn = options.onComplete; - } - - if (options.postProcess) { - this.postProcessFn = options.postProcess; - } - - if (options.name) { - this.name = options.name; - } - } - - clearRequestQueue() { - this.requests = []; - } - - handleError(error) { - if (error.code === 'ECONNREFUSED') { - console.error( - `Connection refused at ${error.address}${error.port ? `:${error.port}` : ''}. ` + - 'Check these values in config.js and ensure that rTorrent is running.', - ); - } - - this.clearRequestQueue(); - - if (this.onCompleteFn) { - this.onCompleteFn(null, error); - } - } - - handleSuccess(data) { - let response = data; - - this.clearRequestQueue(); - - if (this.postProcessFn) { - response = this.postProcessFn(data); - } - - if (this.onCompleteFn) { - this.onCompleteFn(response); - } - } - - onComplete(fn) { - this.onCompleteFn = fn; - } - - postProcess(fn) { - this.postProcessFn = fn; - } - - send() { - const handleSuccess = this.handleSuccess.bind(this); - const handleError = this.handleError.bind(this); - - this.clientRequestManager.methodCall('system.multicall', [this.requests]).then(handleSuccess).catch(handleError); - } - - setTracker(options) { - const existingTrackerIndex = 0; - const {tracker} = options; - - getEnsuredArray(options.hashes).forEach((hash) => { - // Disable existing tracker - this.requests.push(getMethodCall('t.disable', [`${hash}:t${existingTrackerIndex}`])); - // Insert new tracker - this.requests.push(getMethodCall('d.tracker.insert', [hash, `${existingTrackerIndex}`, tracker])); - // Save full session to apply tracker change - this.requests.push(getMethodCall('d.save_full_session', [hash])); - }); - } - - setThrottle(options) { - let methodName = 'throttle.global_down.max_rate.set'; - if (options.direction === 'upload') { - methodName = 'throttle.global_up.max_rate.set'; - } - this.requests.push(getMethodCall(methodName, ['', options.throttle])); - } - - getSessionPath() { - this.requests.push(getMethodCall('session.path')); - } -} - -export default ClientRequest; diff --git a/server/models/client.js b/server/models/client.js deleted file mode 100644 index 94ffb72d..00000000 --- a/server/models/client.js +++ /dev/null @@ -1,131 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import sanitize from 'sanitize-filename'; -import {series} from 'async'; -import tar from 'tar-stream'; - -import ClientRequest from './ClientRequest'; -import torrentFileUtil from '../util/torrentFileUtil'; - -const client = { - downloadFiles(services, hash, fileString, res) { - try { - const selectedTorrent = services.torrentService.getTorrent(hash); - if (!selectedTorrent) return res.status(404).json({error: 'Torrent not found.'}); - - services.clientGatewayService.getTorrentContents(hash).then((contents) => { - if (!contents) return res.status(404).json({error: 'Torrent contents not found'}); - - let files; - if (!fileString || fileString === 'all') { - files = contents.files.map((x, i) => `${i}`); - } else { - files = fileString.split(','); - } - - const filePathsToDownload = this.findFilesByIndices(files, contents).map((file) => - path.join(selectedTorrent.directory, file.path), - ); - - if (filePathsToDownload.length === 1) { - const file = filePathsToDownload[0]; - if (!fs.existsSync(file)) return res.status(404).json({error: 'File not found.'}); - - res.attachment(path.basename(file)); - return res.download(file); - } - - res.attachment(`${selectedTorrent.name}.tar`); - - const pack = tar.pack(); - pack.pipe(res); - - const tasks = filePathsToDownload.map((filePath) => { - const filename = path.basename(filePath); - - return (next) => { - fs.stat(filePath, (err, stats) => { - if (err) return next(err); - - const stream = fs.createReadStream(filePath); - const entry = pack.entry( - { - name: filename, - size: stats.size, - }, - next, - ); - stream.pipe(entry); - }); - }; - }); - - series(tasks, (error) => { - if (error) res.status(500).json(error); - - pack.finalize(); - }); - }); - } catch (error) { - res.status(500).json(error); - } - }, - - findFilesByIndices(indices, fileTree = {}) { - const {directories, files = []} = fileTree; - - let selectedFiles = files.filter((file) => indices.includes(`${file.index}`)); - - if (directories != null) { - selectedFiles = selectedFiles.concat( - Object.keys(directories).reduce( - (accumulator, directory) => accumulator.concat(this.findFilesByIndices(indices, directories[directory])), - [], - ), - ); - } - - return selectedFiles; - }, - - setSpeedLimits(user, services, data, callback) { - const request = new ClientRequest(user, services); - - request.setThrottle({ - direction: data.direction, - throttle: data.throttle, - }); - request.onComplete(callback); - request.send(); - }, - - setTracker(user, services, data, callback) { - const request = new ClientRequest(user, services); - - request.getSessionPath(); - request.setTracker(data); - request.postProcess((response) => { - // Modify tracker URL in torrent files - const {tracker, hashes} = data; - const sessionPath = `${response.shift()}`; - - if (typeof sessionPath === 'string') { - // Deduplicate hashes via Set() to avoid file ops on the same files - [...new Set(hashes)].forEach((hash) => { - const torrent = path.join(sessionPath, sanitize(`${hash}.torrent`)); - torrentFileUtil.setTracker(torrent, tracker); - }); - } - - return response; - }); - request.onComplete((response, error) => { - // Fetch the latest torrent list to re-index trackerURI. - services.torrentService.fetchTorrentList(); - callback(response, error); - }); - request.send(); - }, -}; - -export default client; diff --git a/server/routes/api/client.test.ts b/server/routes/api/client.test.ts new file mode 100644 index 00000000..9c860616 --- /dev/null +++ b/server/routes/api/client.test.ts @@ -0,0 +1,69 @@ +import supertest from 'supertest'; + +import app from '../../app'; +import {getAuthToken} from './auth'; + +import type {ClientSettings} from '../../../shared/types/ClientSettings'; + +const request = supertest(app); + +const authToken = `jwt=${getAuthToken('_config')}`; + +describe('GET /api/client/connection-test', () => { + it('Checks connection status', (done) => { + request + .get('/api/client/connection-test') + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) done(err); + + expect(res.body).toMatchObject({isConnected: true}); + + done(); + }); + }); +}); + +const settings: Partial = { + throttleGlobalDownMax: 100, + throttleGlobalUpMax: 100, +}; + +describe('PATCH /api/client/settings', () => { + it('Sets client settings', (done) => { + request + .patch('/api/client/settings') + .send(settings) + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, _res) => { + if (err) done(err); + done(); + }); + }); +}); + +describe('GET /api/client/settings', () => { + it('Gets all client settings', (done) => { + request + .get('/api/client/settings') + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) done(err); + + expect(res.body).toMatchObject(settings); + + done(); + }); + }); +}); diff --git a/server/routes/api/client.ts b/server/routes/api/client.ts index 17ffbfa0..3c463ddc 100644 --- a/server/routes/api/client.ts +++ b/server/routes/api/client.ts @@ -1,12 +1,15 @@ import express from 'express'; import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings'; +import type {ClientSettings} from '@shared/types/ClientSettings'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; import ajaxUtil from '../../util/ajaxUtil'; -import client from '../../models/client'; import requireAdmin from '../../middleware/requireAdmin'; +// Those settings don't require administrator access. +const SAFE_CLIENT_SETTINGS: Array = ['throttleGlobalDownMax', 'throttleGlobalUpMax']; + const router = express.Router(); /** @@ -28,21 +31,6 @@ router.get('/connection-test', (req, res) => { }); }); -/** - * PUT /api/client/settings/speed-limits - * @summary Sets speed limits of the torrent client - * @tags Client - * @security User - */ -router.put('/settings/speed-limits', (req, res) => { - client.setSpeedLimits(req.user, req.services, req.body, ajaxUtil.getResponseFn(res)); -}); - -// Some settings are sensitive (e.g. can open undesired ports on the instance or make the -// instance send unsanctioned requests to another machine). So administrator access is required. -// TODO: separate sensitive settings from unsensitive ones. -router.use('/', requireAdmin); - /** * POST /api/client/connection-test * @summary Tests connection to the torrent client with supplied new settings @@ -52,6 +40,7 @@ router.use('/', requireAdmin); * @return {{isConnected: true}} 200 - success response - application/json * @return {{isConnected: false}} 500 - failure response - application/json */ +router.post('/connection-test', requireAdmin); router.post('/connection-test', (req, res) => { req.services?.clientGatewayService .testGateway(req.body) @@ -67,7 +56,7 @@ router.post('/connection-test', (req * GET /api/client/settings * @summary Gets settings of torrent client managed by Flood. * @tags Client - * @security AuthenticatedUser + * @security User * @return {ClientSettings} 200 - success response - application/json * @return {Error} 500 - failure response - application/json */ @@ -84,11 +73,25 @@ router.get('/settings', (req, res) => { * PATCH /api/client/settings * @summary Sets settings of torrent client managed by Flood. * @tags Client - * @security AuthenticatedUser + * @security User - safe settings + * @security Administrator - sensitive settings * @param {SetClientSettingsOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json */ +router.patch('/settings', (req, res, next) => { + if ( + Object.keys(req.body).some((key) => { + return !SAFE_CLIENT_SETTINGS.includes(key as keyof ClientSettings); + }) + ) { + // Some settings are sensitive (e.g. can open undesired ports on the instance or make the + // instance send unsanctioned requests to another machine). So administrator access is required. + requireAdmin(req, res, next); + } else { + next(); + } +}); router.patch('/settings', (req, res) => { const callback = ajaxUtil.getResponseFn(res); diff --git a/server/routes/api/torrents.test.ts b/server/routes/api/torrents.test.ts index 15567eac..722395a2 100644 --- a/server/routes/api/torrents.test.ts +++ b/server/routes/api/torrents.test.ts @@ -1,11 +1,13 @@ +import crypto from 'crypto'; import fs from 'fs'; import readline from 'readline'; import stream from 'stream'; import supertest from 'supertest'; -import type {AddTorrentByURLOptions} from '../../../shared/types/api/torrents'; +import type {AddTorrentByURLOptions, SetTorrentsTrackersOptions} from '../../../shared/types/api/torrents'; import type {TorrentList, TorrentProperties} from '../../../shared/types/Torrent'; import type {TorrentStatus} from '../../../shared/constants/torrentStatusMap'; +import type {TorrentTracker} from '../../../shared/types/TorrentTracker'; import app from '../../app'; import {getAuthToken} from './auth'; @@ -22,6 +24,12 @@ fs.mkdirSync(tempDirectory, {recursive: true}); jest.setTimeout(20000); +let torrentHash = ''; + +const activityStream = new stream.PassThrough(); +const rl = readline.createInterface({input: activityStream}); +request.get('/api/activity-stream').send().set('Cookie', [authToken]).pipe(activityStream); + describe('POST /api/torrents/add-urls', () => { const addTorrentByURLOptions: AddTorrentByURLOptions = { urls: ['https://releases.ubuntu.com/20.04/ubuntu-20.04.1-live-server-amd64.iso.torrent'], @@ -31,13 +39,7 @@ describe('POST /api/torrents/add-urls', () => { start: false, }; - const activityStream = new stream.PassThrough(); - const rl = readline.createInterface({input: activityStream}); - - const req = request.get('/api/activity-stream').send().set('Cookie', [authToken]); - const torrentAdded = new Promise((resolve) => { - req.pipe(activityStream); rl.on('line', (input) => { if (input.includes('TORRENT_LIST_DIFF_CHANGE')) { resolve(); @@ -85,8 +87,78 @@ describe('POST /api/torrents/add-urls', () => { : ['stopped', 'inactive']; expect(torrent.status).toEqual(expect.arrayContaining(expectedStatuses)); + torrentHash = torrent.hash; + done(); }); }); }); }); + +describe('PATCH /api/torrents/trackers', () => { + const testTrackers = [ + `https://${crypto.randomBytes(8).toString('hex')}.com/announce`, + `http://${crypto.randomBytes(8).toString('hex')}.com/announce?key=test`, + `http://${crypto.randomBytes(8).toString('hex')}.com/announce.php?key=test`, + ]; + + it('Sets single tracker', (done) => { + const setTrackersOptions: SetTorrentsTrackersOptions = { + hashes: [torrentHash], + trackers: [testTrackers[0]], + }; + + request + .patch('/api/torrents/trackers') + .send(setTrackersOptions) + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, _res) => { + if (err) done(err); + + done(); + }); + }); + + it('Sets multiple trackers', (done) => { + const setTrackersOptions: SetTorrentsTrackersOptions = { + hashes: [torrentHash], + trackers: testTrackers, + }; + + request + .patch('/api/torrents/trackers') + .send(setTrackersOptions) + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, _res) => { + if (err) done(err); + + done(); + }); + }); + + it('GET /api/torrents/{hash}/trackers', (done) => { + request + .get(`/api/torrents/${torrentHash}/trackers`) + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) done(err); + + const trackers: Array = res.body; + expect(trackers.filter((tracker) => testTrackers.includes(tracker.url)).length).toBeGreaterThanOrEqual( + testTrackers.length, + ); + + done(); + }); + }); +}); diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index ed433eed..fa73a238 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -3,6 +3,7 @@ import express from 'express'; import fs from 'fs'; import path from 'path'; import sanitize from 'sanitize-filename'; +import tar from 'tar'; import { AddTorrentByFileOptions, @@ -14,13 +15,13 @@ import { SetTorrentContentsPropertiesOptions, SetTorrentsPriorityOptions, SetTorrentsTagsOptions, + SetTorrentsTrackersOptions, StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; -import {accessDeniedError, isAllowedPath, sanitizePath} from '../../util/fileUtil'; +import {accessDeniedError, findFilesByIndices, 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'; @@ -49,7 +50,7 @@ router.get('/', (req, res) => { * POST /api/torrents/add-urls * @summary Adds torrents by URLs. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {AddTorrentByURLOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -73,7 +74,7 @@ router.post('/add-urls', (req, res) => * POST /api/torrents/add-files * @summary Adds torrents by files. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {AddTorrentByFileOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -97,7 +98,7 @@ router.post('/add-files', (req, res) * POST /api/torrents/create * @summary Creates a torrent * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {CreateTorrentOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/x-bittorrent * @return {Error} 500 - failure response - application/json @@ -159,7 +160,7 @@ router.post('/create', async (req, res) * POST /api/torrents/start * @summary Starts torrents. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {StartTorrentsOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -183,7 +184,7 @@ router.post('/start', (req, res) => { * POST /api/torrents/stop * @summary Stops torrents. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {StopTorrentsOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -207,7 +208,7 @@ router.post('/stop', (req, res) => { * POST /api/torrents/check-hash * @summary Hash checks torrents. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {CheckTorrentsOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -231,7 +232,7 @@ router.post('/check-hash', (req, res) => * POST /api/torrents/move * @summary Moves torrents to specified destination path. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {MoveTorrentsOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -255,7 +256,7 @@ router.post('/move', (req, res) => { * POST /api/torrents/delete * @summary Removes torrents from Flood. Optionally deletes data of torrents. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {DeleteTorrentsOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -279,7 +280,7 @@ router.post('/delete', (req, res) => { * PATCH /api/torrents/priority * @summary Sets priority of torrents. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {SetTorrentsPriorityOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -303,7 +304,7 @@ router.patch('/priority', (req, re * PATCH /api/torrents/tags * @summary Sets tags of torrents. * @tags Torrents - * @security AuthenticatedUser + * @security User * @param {SetTorrentsTagsOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json * @return {Error} 500 - failure response - application/json @@ -324,13 +325,27 @@ router.patch('/tags', (req, res) => { }); /** - * PATCH /api/torrents/tracker - * @summary Sets tracker of torrents. + * PATCH /api/torrents/trackers + * @summary Sets trackers of torrents. * @tags Torrents - * @security AuthenticatedUser + * @security User + * @param {SetTorrentsTrackersOptions} request.body.required - options - application/json + * @return {object} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json */ -router.patch('/tracker', (req, res) => { - client.setTracker(req.user, req.services, req.body, ajaxUtil.getResponseFn(res)); +router.patch('/trackers', (req, res) => { + const callback = ajaxUtil.getResponseFn(res); + + req.services?.clientGatewayService + .setTorrentsTrackers(req.body) + .then((response) => { + req.services?.torrentService.fetchTorrentList(); + return response; + }) + .then(callback) + .catch((err) => { + callback(null, err); + }); }); /** @@ -344,7 +359,7 @@ router.patch('/tracker', (req, res) => { * GET /api/torrents/{hash} * @summary Gets information of a torrent. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path - Hash of a torrent */ @@ -352,7 +367,7 @@ router.patch('/tracker', (req, res) => { * GET /api/torrents/{hash}/contents * @summary Gets the list of contents of a torrent and their properties. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path */ router.get('/:hash/contents', (req, res) => { @@ -370,7 +385,7 @@ router.get('/:hash/contents', (req, res) => { * PATCH /api/torrents/{hash}/contents * @summary Sets properties of contents of a torrent. Only priority can be set for now. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path * @param {SetTorrentContentsPropertiesOptions} request.body.required - options - application/json * @return {object} 200 - success response - application/json @@ -391,13 +406,45 @@ router.patch<{hash: string}, unknown, SetTorrentContentsPropertiesOptions>('/:ha * GET /api/torrents/{hash}/contents/{indices}/data * @summary Gets downloaded data of contents of a torrent. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path * @param {string} indices.path - 'all' or indices of selected contents separated by ',' * @return {object} 200 - contents archived in .tar - application/x-tar */ router.get('/:hash/contents/:indices/data', (req, res) => { - client.downloadFiles(req.services, req.params.hash, req.params.indices, res); + const {hash, indices: stringIndices} = req.params; + try { + const selectedTorrent = req.services?.torrentService.getTorrent(hash); + if (!selectedTorrent) return res.status(404).json({error: 'Torrent not found.'}); + + return req.services?.clientGatewayService.getTorrentContents(hash).then((contents) => { + if (!contents || !contents.files) return res.status(404).json({error: 'Torrent contents not found'}); + + let indices: Array; + if (!stringIndices || stringIndices === 'all') { + indices = contents.files.map((x) => x.index); + } else { + indices = stringIndices.split(',').map((value) => Number(value)); + } + + const filePathsToDownload = findFilesByIndices(indices, contents).map((file) => + path.join(selectedTorrent.directory, file.path), + ); + + if (filePathsToDownload.length === 1) { + const file = filePathsToDownload[0]; + if (!fs.existsSync(file)) return res.status(404).json({error: 'File not found.'}); + + res.attachment(path.basename(file)); + return res.download(file); + } + + res.attachment(`${selectedTorrent.name}.tar`); + return tar.c({}, filePathsToDownload).pipe(res); + }); + } catch (error) { + return res.status(500).json(error); + } }); /** @@ -405,7 +452,7 @@ router.get('/:hash/contents/:indices/data', (req, res) => { * GET /api/torrents/{hash}/details * @summary Gets details of a torrent. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path */ router.get('/:hash/details', async (req, res) => { @@ -430,7 +477,7 @@ router.get('/:hash/details', async (req, res) => { * GET /api/torrents/{hash}/mediainfo * @summary Gets mediainfo output of a torrent. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path */ router.get('/:hash/mediainfo', (req, res) => { @@ -441,7 +488,7 @@ router.get('/:hash/mediainfo', (req, res) => { * GET /api/torrents/{hash}/peers * @summary Gets the list of peers of a torrent. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path */ router.get('/:hash/peers', (req, res) => { @@ -459,8 +506,10 @@ router.get('/:hash/peers', (req, res) => { * GET /api/torrents/{hash}/trackers * @summary Gets the list of trackers of a torrent. * @tags Torrent - * @security AuthenticatedUser + * @security User * @param {string} hash.path + * @return {Array} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json */ router.get('/:hash/trackers', (req, res) => { const callback = ajaxUtil.getResponseFn(res); diff --git a/server/services/feedService.js b/server/services/feedService.js index 361d892f..cadcb875 100644 --- a/server/services/feedService.js +++ b/server/services/feedService.js @@ -2,7 +2,6 @@ import path from 'path'; import Datastore from 'nedb'; import BaseService from './BaseService'; -import client from '../models/client'; import config from '../../config'; import Feed from '../models/Feed'; import regEx from '../../shared/util/regEx'; @@ -248,23 +247,22 @@ class FeedService extends BaseService { }; itemsToDownload.forEach((item, index) => { - client.addUrls( - this.user, - this.services, - { + this.services.clientGatewayService + .addTorrentsByURL({ urls: item.urls, destination: item.destination, + isBasePath: false, start: item.startOnLoad, tags: item.tags, - }, - () => { + }) + .then(() => { if (index === itemsToDownload.length - 1) { lastAddUrlCallback(); } this.db.update({_id: item.ruleID}, {$inc: {count: 1}}, {upsert: true}); - }, - ); + }) + .catch(console.error); }); }) .catch(console.error); diff --git a/server/services/rTorrent/clientGatewayService.ts b/server/services/rTorrent/clientGatewayService.ts index e562aaf8..a9c9daf7 100644 --- a/server/services/rTorrent/clientGatewayService.ts +++ b/server/services/rTorrent/clientGatewayService.ts @@ -2,6 +2,7 @@ import path from 'path'; import fs from 'fs'; import geoip from 'geoip-country'; import {moveSync} from 'fs-extra'; +import sanitize from 'sanitize-filename'; import type {ClientSettings} from '@shared/types/ClientSettings'; import type {RTorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings'; @@ -19,6 +20,7 @@ import type { SetTorrentContentsPropertiesOptions, SetTorrentsPriorityOptions, SetTorrentsTagsOptions, + SetTorrentsTrackersOptions, StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; @@ -29,6 +31,7 @@ import BaseService from '../BaseService'; import {getFileTreeFromPathsArr} from './util/fileTreeUtil'; import scgiUtil from './util/scgiUtil'; import {getMethodCalls, processMethodCallResponse} from './util/rTorrentMethodCallUtil'; +import torrentFileUtil from '../../util/torrentFileUtil'; import { encodeTags, getTorrentETAFromProperties, @@ -443,6 +446,67 @@ class ClientGatewayService extends BaseService { ); } + /** + * Sets trackers of torrents + * + * @param {SetTorrentsTrackersOptions} options - An object of options... + * @return {Promise} - Resolves with RPC call response or rejects with error. + */ + async setTorrentsTrackers({hashes, trackers}: SetTorrentsTrackersOptions) { + const methodCalls = hashes.reduce( + (accumulator: MultiMethodCalls, hash) => { + // Disable existing trackers + accumulator.push({ + methodName: 't.multicall', + params: [hash, '', 't.disable='], + }); + + // Insert new trackers + trackers.forEach((tracker) => { + accumulator.push({ + methodName: 'd.tracker.insert', + params: [hash, '0', tracker], + }); + }); + + // Save full session to apply tracker change + accumulator.push({ + methodName: 'd.save_full_session', + params: [hash], + }); + + return accumulator; + }, + [ + { + methodName: 'session.path', + params: [], + }, + ], + ); + + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) + .then(async (response: string[][]) => { + const [session] = response.shift() as string[]; + + if (typeof session === 'string') { + // Deduplicate hashes via Set() to avoid file ops on the same files + await Promise.all( + [...new Set(hashes)].map(async (hash) => { + const torrent = path.join(session, sanitize(`${hash}.torrent`)); + return torrentFileUtil.setTrackers(torrent, trackers); + }), + ); + } + + return response; + }) || Promise.reject() + ); + } + /** * Sets priority of contents of a torrent * @param {string} hash - Hash of the torrent. diff --git a/server/services/rTorrent/constants/methodCallConfigs/torrentTracker.ts b/server/services/rTorrent/constants/methodCallConfigs/torrentTracker.ts index e966f1df..01b62504 100644 --- a/server/services/rTorrent/constants/methodCallConfigs/torrentTracker.ts +++ b/server/services/rTorrent/constants/methodCallConfigs/torrentTracker.ts @@ -1,4 +1,4 @@ -import {stringTransformer, numberTransformer} from '../../util/rTorrentMethodCallUtil'; +import {booleanTransformer, numberTransformer, stringTransformer} from '../../util/rTorrentMethodCallUtil'; const torrentTrackerMethodCallConfigs = { id: { @@ -25,6 +25,10 @@ const torrentTrackerMethodCallConfigs = { methodCall: 't.normal_interval=', transformValue: numberTransformer, }, + isEnabled: { + methodCall: 't.is_enabled=', + transformValue: booleanTransformer, + }, } as const; export default torrentTrackerMethodCallConfigs; diff --git a/server/util/fileUtil.ts b/server/util/fileUtil.ts index 17bda2c9..d582d9aa 100644 --- a/server/util/fileUtil.ts +++ b/server/util/fileUtil.ts @@ -2,6 +2,8 @@ import fs from 'fs'; import {homedir} from 'os'; import path from 'path'; +import {TorrentContent, TorrentContentTree} from '@shared/types/TorrentContent'; + import config from '../../config'; export const accessDeniedError = () => { @@ -48,6 +50,24 @@ export const createDirectory = (directoryPath: string) => { } }; +export const findFilesByIndices = (indices: Array, fileTree: TorrentContentTree): TorrentContent[] => { + const {directories, files = []} = fileTree; + + let selectedFiles = files.filter((file) => indices.includes(file.index)); + + if (directories != null) { + selectedFiles = selectedFiles.concat( + Object.keys(directories).reduce( + (accumulator: TorrentContent[], directory) => + accumulator.concat(findFilesByIndices(indices, directories[directory])), + [], + ), + ); + } + + return selectedFiles; +}; + export const getDirectoryList = async (inputPath: string) => { if (typeof inputPath !== 'string') { throw fileNotFoundError(); diff --git a/server/util/torrentFileUtil.ts b/server/util/torrentFileUtil.ts index 980fa81e..baeceda0 100644 --- a/server/util/torrentFileUtil.ts +++ b/server/util/torrentFileUtil.ts @@ -1,24 +1,27 @@ import bencode from 'bencode'; import fs from 'fs'; -const setTracker = (torrent: string, tracker: string) => { - fs.readFile(torrent, (err, data) => { - if (err) { - return; - } +import type {TorrentFile} from '@shared/types/TorrentFile'; - const torrentData = bencode.decode(data); +const setTrackers = async (torrent: string, trackers: Array) => { + const torrentData: TorrentFile = bencode.decode(fs.readFileSync(torrent)); - if (torrentData.announce != null) { - torrentData.announce = Buffer.from(tracker); + torrentData.announce = Buffer.from(trackers[0]); - fs.writeFileSync(torrent, bencode.encode(torrentData)); - } - }); + if (trackers.length > 1 || torrentData['announce-list'] != null) { + torrentData['announce-list'] = []; + torrentData['announce-list'].push( + trackers.map((tracker) => { + return Buffer.from(tracker); + }), + ); + } + + return fs.writeFileSync(torrent, bencode.encode(torrentData)); }; const torrentFileUtil = { - setTracker, + setTrackers, }; export default torrentFileUtil; diff --git a/shared/types/TorrentFile.ts b/shared/types/TorrentFile.ts new file mode 100644 index 00000000..2a9fc8f8 --- /dev/null +++ b/shared/types/TorrentFile.ts @@ -0,0 +1,66 @@ +// Strings are Buffers from bencode data structure point of view. +// Timestamp is in second. + +export interface TorrentFile { + announce: Buffer; // main tracker + 'announce-list'?: Array>; // multi tracker torrent + comment?: Buffer; + 'created by': Buffer; + 'creation date': number; // timestamp + encoding?: Buffer; + info: { + length?: number; // single file torrent + files?: Array<{ + length: number; + path: Array; + }>; // multi file torrent + name: Buffer; + 'piece length': number; + pieces: Buffer; // hash tree, NOT string + private?: 0 | 1; + source?: Buffer; + }; +} + +export interface LibTorrentResume { + bitfield: number; + files: Array<{ + completed: number; // number of completed pieces + mtime: number; // timestamp + priority: 0 | 1 | 2; // off | normal | high + }>; + peers?: Array<{ + failed: 0 | 1; + inet: Buffer; // encoded IP address, NOT string + last: number; // timestamp + }>; + trackers?: { + [url: string]: { + enabled: 0 | 1; + }; + }; + 'uncertain_pieces.timestamp': number; // timestamp +} + +export interface RTorrentSession { + chunks_done: number; + chunks_wanted: number; + complete: 0 | 1; + directory: Buffer; + // 0: No hashing is happening. + // 1: The very first hash check is occurring. + // 2: The torrent is in the middle of hashing due to the finish event. + // 3: A rehash is occurring. + hashing: 0 | 1 | 2 | 3; + state: 0 | 1; + state_changed: number; // timestamp + state_counter: number; + tied_to_file: Buffer; + 'timestamp.finished': number; // timestamp + 'timestamp.started': number; // timestamp +} + +export interface RTorrentFile extends TorrentFile { + libtorrent_resume?: LibTorrentResume; + rtorrent?: RTorrentSession; +} diff --git a/shared/types/TorrentTracker.ts b/shared/types/TorrentTracker.ts index 454b9dfd..917ac780 100644 --- a/shared/types/TorrentTracker.ts +++ b/shared/types/TorrentTracker.ts @@ -6,4 +6,5 @@ export interface TorrentTracker { group: number; minInterval: number; normalInterval: number; + isEnabled: boolean; } diff --git a/shared/types/api/torrents.ts b/shared/types/api/torrents.ts index add3a13e..c4558c1d 100644 --- a/shared/types/api/torrents.ts +++ b/shared/types/api/torrents.ts @@ -110,6 +110,14 @@ export interface SetTorrentsTagsOptions { tags: TorrentProperties['tags']; } +// PATCH /api/torrents/trackers +export interface SetTorrentsTrackersOptions { + // An array of string representing hashes of torrents to operate on + hashes: Array; + // URLs of trackers to be added to the torrents + trackers: Array; +} + // PATCH /api/torrents/{hash}/contents export interface SetTorrentContentsPropertiesOptions { // An array of number representing indices of contents of a torrent