From 79a7c9c6f00b1a2476b376bd2aa8d769cb669d17 Mon Sep 17 00:00:00 2001 From: Jesse Chan Date: Thu, 8 Oct 2020 19:29:13 +0800 Subject: [PATCH] server: migrate add torrents functions to clientGatewayService --- server/constants/rTorrentMethodCall.ts | 2 +- server/models/ClientRequest.js | 108 -------------- server/models/client.js | 70 --------- server/routes/api/client.ts | 14 -- server/routes/api/torrents.ts | 53 +++++-- server/services/clientGatewayService.ts | 187 ++++++++++++++++++------ server/services/clientRequestManager.ts | 8 +- server/util/fileUtil.ts | 22 +-- server/util/torrentPropertiesUtil.ts | 14 ++ 9 files changed, 211 insertions(+), 267 deletions(-) diff --git a/server/constants/rTorrentMethodCall.ts b/server/constants/rTorrentMethodCall.ts index cb7515f5..b4e07bca 100644 --- a/server/constants/rTorrentMethodCall.ts +++ b/server/constants/rTorrentMethodCall.ts @@ -6,7 +6,7 @@ export interface MethodCallConfig { export type MethodCallConfigs = Readonly>; -export type MultiMethodCalls = Array<{methodName: string; params: Array}>; +export type MultiMethodCalls = Array<{methodName: string; params: Array}>; export const defaultTransformer = (value: string): string => { return value; diff --git a/server/models/ClientRequest.js b/server/models/ClientRequest.js index 17311319..cf3dd380 100644 --- a/server/models/ClientRequest.js +++ b/server/models/ClientRequest.js @@ -6,26 +6,6 @@ import util from 'util'; import {clientSettingsMap} from '../../shared/constants/clientSettingsMap'; import rTorrentPropMap from '../util/rTorrentPropMap'; -const addTagsToRequest = (tagsArr, requestParameters) => { - if (tagsArr && tagsArr.length) { - const tags = tagsArr - .reduce((accumulator, currentTag) => { - const tag = encodeURIComponent(currentTag.trim()); - - if (tag !== '' && accumulator.indexOf(tag) === -1) { - accumulator.push(tag); - } - - return accumulator; - }, []) - .join(','); - - requestParameters.push(`d.custom1.set="${tags}"`); - } - - return requestParameters; -}; - const getEnsuredArray = (item) => { if (!util.isArray(item)) { return [item]; @@ -111,62 +91,6 @@ class ClientRequest { this.clientRequestManager.methodCall('system.multicall', [this.requests]).then(handleSuccess).catch(handleError); } - // TODO: Separate these and add support for additional clients. - // rTorrent method calls. - addFiles(options) { - const {files, path: destinationPath, isBasePath, start, tags: tagsArr} = options; - - files.forEach((file) => { - const methodCall = start ? 'load.raw_start' : 'load.raw'; - const timeAdded = Math.floor(Date.now() / 1000); - let parameters = ['', Buffer.from(file, 'base64')]; - - if (destinationPath) { - if (isBasePath) { - parameters.push(`d.directory_base.set="${destinationPath}"`); - } else { - parameters.push(`d.directory.set="${destinationPath}"`); - } - } - - parameters = addTagsToRequest(tagsArr, parameters); - - // parameters.push(`d.custom.set=x-filename,${file.originalname}`); - parameters.push(`d.custom.set=addtime,${timeAdded}`); - - this.requests.push(getMethodCall(methodCall, parameters)); - }); - } - - addURLs(options) { - const {path: destinationPath, isBasePath, start, tags: tagsArr} = options; - const urls = getEnsuredArray(options.urls); - - urls.forEach((url) => { - let methodCall = 'load.start'; - let parameters = ['', url]; - const timeAdded = Math.floor(Date.now() / 1000); - - if (destinationPath) { - if (isBasePath) { - parameters.push(`d.directory_base.set="${destinationPath}"`); - } else { - parameters.push(`d.directory.set="${destinationPath}"`); - } - } - - parameters = addTagsToRequest(tagsArr, parameters); - - parameters.push(`d.custom.set=addtime,${timeAdded}`); - - if (!start) { - methodCall = 'load.normal'; - } - - this.requests.push(getMethodCall(methodCall, parameters)); - }); - } - fetchSettings(options) { let {requestedSettings} = options; @@ -194,38 +118,6 @@ class ClientRequest { this.requests.push(getMethodCall('t.multicall', trackerParams)); } - getTorrentList(options) { - this.requests.push(getMethodCall('d.multicall2', options.props)); - } - - getTransferData() { - Object.keys(rTorrentPropMap.transferData).forEach((key) => { - this.requests.push(getMethodCall(rTorrentPropMap.transferData[key])); - }); - } - - listMethods(options) { - const args = getEnsuredArray(options.args); - this.requests.push(getMethodCall(options.method, [args])); - } - - setDownloadPath(options) { - const hashes = getEnsuredArray(options.hashes); - - let pathMethod; - if (options.isBasePath) { - pathMethod = 'd.directory_base.set'; - } else { - pathMethod = 'd.directory.set'; - } - - hashes.forEach((hash) => { - this.requests.push(getMethodCall(pathMethod, [hash, options.path])); - this.requests.push(getMethodCall('d.open', [hash])); - this.requests.push(getMethodCall('d.close', [hash])); - }); - } - setFilePriority(options) { const indices = getEnsuredArray(options.indices); const hashes = getEnsuredArray(options.hashes); diff --git a/server/models/client.js b/server/models/client.js index 30d162aa..44abba64 100644 --- a/server/models/client.js +++ b/server/models/client.js @@ -4,77 +4,15 @@ import sanitize from 'sanitize-filename'; import {series} from 'async'; import tar from 'tar-stream'; -import {accessDeniedError, createDirectory, isAllowedPath, sanitizePath} from '../util/fileUtil'; import ClientRequest from './ClientRequest'; import clientResponseUtil from '../util/clientResponseUtil'; import {clientSettingsBiMap} from '../../shared/constants/clientSettingsMap'; -import settings from './settings'; import torrentFilePropsMap from '../../shared/constants/torrentFilePropsMap'; import torrentPeerPropsMap from '../../shared/constants/torrentPeerPropsMap'; import torrentFileUtil from '../util/torrentFileUtil'; import torrentTrackerPropsMap from '../../shared/constants/torrentTrackerPropsMap'; const client = { - addFiles(user, services, options, callback) { - const {destination: destinationPath, files, isBasePath, start, tags} = options; - - const resolvedPath = sanitizePath(destinationPath); - if (!isAllowedPath(resolvedPath)) { - callback(null, accessDeniedError()); - return; - } - - createDirectory({path: resolvedPath}); - - // Each torrent is sent individually because rTorrent accepts a total - // filesize of 524 kilobytes or less. This allows the user to send many - // torrent files reliably. - files.forEach((file, index) => { - const fileRequest = new ClientRequest(user, services); - fileRequest.addFiles({ - files: [file], - path: resolvedPath, - isBasePath, - start, - tags, - }); - - // Set the callback for only the last request. - if (index === files.length - 1) { - fileRequest.onComplete((response, error) => { - services.torrentService.fetchTorrentList(); - callback(response, error); - }); - } - - fileRequest.send(); - }); - - settings.set(user, {id: 'startTorrentsOnLoad', data: start === 'true' || start === true}); - }, - - addUrls(user, services, data, callback) { - const {urls, destination, isBasePath, start, tags} = data; - const request = new ClientRequest(user, services); - const resolvedPath = sanitizePath(destination); - if (!isAllowedPath(resolvedPath)) { - callback(null, accessDeniedError()); - return; - } - createDirectory({path: resolvedPath}); - request.addURLs({ - urls, - path: resolvedPath, - isBasePath, - start, - tags, - }); - request.onComplete(callback); - request.send(); - - settings.set(user, {id: 'startTorrentsOnLoad', data: start}); - }, - downloadFiles(user, services, hash, fileString, res) { try { const selectedTorrent = services.torrentService.getTorrent(hash); @@ -209,14 +147,6 @@ const client = { request.send(); }, - listMethods(user, services, method, args, callback) { - const request = new ClientRequest(user, services); - - request.listMethods({method, args}); - request.onComplete(callback); - request.send(); - }, - setFilePriority(user, services, hashes, data, callback) { const {indices, priority} = data; const request = new ClientRequest(user, services); diff --git a/server/routes/api/client.ts b/server/routes/api/client.ts index 893c60df..6e7cfe45 100644 --- a/server/routes/api/client.ts +++ b/server/routes/api/client.ts @@ -39,18 +39,4 @@ router.put('/settings/speed-limits', (req, res) => { client.setSpeedLimits(req.user, req.services, req.body, ajaxUtil.getResponseFn(res)); }); -router.get('/rtorrent-methods', (req, res) => { - const {type} = req.query; - const {args} = req.query; - let method = 'system.listMethods'; - - if (type === 'help') { - method = 'system.methodHelp'; - } else if (type === 'signature') { - method = 'system.methodSignature'; - } - - client.listMethods(req.user, req.services, method, args, ajaxUtil.getResponseFn(res)); -}); - export default router; diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index 84c1c50f..041d5fb3 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -14,6 +14,7 @@ import { import ajaxUtil from '../../util/ajaxUtil'; import client from '../../models/client'; import mediainfo from '../../util/mediainfo'; +import settings from '../../models/settings'; const router = express.Router(); @@ -27,7 +28,21 @@ const router = express.Router(); * @return {Error} 500 - failure response - application/json */ router.post('/add-urls', (req, res) => { - client.addUrls(req.user, req.services, req.body, ajaxUtil.getResponseFn(res)); + const callback = ajaxUtil.getResponseFn(res); + + req.services?.clientGatewayService + .addTorrentsByURL(req.body) + .then((response) => { + if (req.user != null) { + settings.set(req.user, [{id: 'startTorrentsOnLoad', data: req.body.start === true}]); + } + req.services?.torrentService.fetchTorrentList(); + return response; + }) + .then(callback) + .catch((err) => { + callback(null, err); + }); }); /** @@ -40,7 +55,21 @@ router.post('/add-urls', (req, res) => * @return {Error} 500 - failure response - application/json */ router.post('/add-files', (req, res) => { - client.addFiles(req.user, req.services, req.body, ajaxUtil.getResponseFn(res)); + const callback = ajaxUtil.getResponseFn(res); + + req.services?.clientGatewayService + .addTorrentsByFile(req.body) + .then((response) => { + if (req.user != null) { + settings.set(req.user, [{id: 'startTorrentsOnLoad', data: req.body.start === true}]); + } + req.services?.torrentService.fetchTorrentList(); + return response; + }) + .then(callback) + .catch((err) => { + callback(null, err); + }); }); /** @@ -53,11 +82,10 @@ router.post('/add-files', (req, res) * @return {Error} 500 - failure response - application/json */ router.post('/start', (req, res) => { - const {hashes} = req.body; const callback = ajaxUtil.getResponseFn(res); req.services?.clientGatewayService - .startTorrents({hashes}) + .startTorrents(req.body) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -78,11 +106,10 @@ router.post('/start', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.post('/stop', (req, res) => { - const {hashes} = req.body; const callback = ajaxUtil.getResponseFn(res); req.services?.clientGatewayService - .stopTorrents({hashes}) + .stopTorrents(req.body) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -103,11 +130,10 @@ router.post('/stop', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.post('/check-hash', (req, res) => { - const {hashes} = req.body; const callback = ajaxUtil.getResponseFn(res); req.services?.clientGatewayService - .checkTorrents({hashes}) + .checkTorrents(req.body) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -128,11 +154,10 @@ router.post('/check-hash', (req, res) => * @return {Error} 500 - failure response - application/json */ router.post('/move', (req, res) => { - const {hashes, destination, moveFiles, isBasePath, isCheckHash} = req.body; const callback = ajaxUtil.getResponseFn(res); req.services?.clientGatewayService - .moveTorrents({hashes, destination, moveFiles, isBasePath, isCheckHash}) + .moveTorrents(req.body) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -153,11 +178,10 @@ router.post('/move', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.post('/delete', (req, res) => { - const {hashes, deleteData} = req.body; const callback = ajaxUtil.getResponseFn(res); req.services?.clientGatewayService - .removeTorrents({hashes, deleteData}) + .removeTorrents(req.body) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -178,11 +202,10 @@ router.post('/delete', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.patch('/priority', (req, res) => { - const {hashes, priority} = req.body; const callback = ajaxUtil.getResponseFn(res); req.services?.clientGatewayService - .setTorrentsPriority({hashes, priority}) + .setTorrentsPriority(req.body) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -258,7 +281,7 @@ router.patch('/:hash/contents', (req, res) => { * @return {object} 200 - contents archived in .tar - application/x-tar */ router.get('/:hash/contents/:indices/data', (req, res) => { - client.downloadFiles(req.user, req.services, req.params.hash, req.params.indices, res); + client.downloadFiles(req.services, req.params.hash, req.params.indices, res); }); /** diff --git a/server/services/clientGatewayService.ts b/server/services/clientGatewayService.ts index def8e8ed..5b7ee1fe 100644 --- a/server/services/clientGatewayService.ts +++ b/server/services/clientGatewayService.ts @@ -6,6 +6,8 @@ import type {Credentials} from '@shared/types/Auth'; import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; import type {TransferSummary} from '@shared/types/TransferData'; import type { + AddTorrentByFileOptions, + AddTorrentByURLOptions, CheckTorrentsOptions, DeleteTorrentsOptions, MoveTorrentsOptions, @@ -14,8 +16,9 @@ import type { StopTorrentsOptions, } from '@shared/types/Action'; -import {accessDeniedError, isAllowedPath, sanitizePath} from '../util/fileUtil'; +import {accessDeniedError, createDirectory, isAllowedPath, sanitizePath} from '../util/fileUtil'; import BaseService from './BaseService'; +import {encodeTags} from '../util/torrentPropertiesUtil'; import fileListMethodCallConfigs from '../constants/fileListMethodCallConfigs'; import scgiUtil from '../util/scgiUtil'; @@ -71,6 +74,86 @@ class ClientGatewayService extends BaseService { this.torrentListReducers.push(reducer); } + /** + * Adds torrents by file + * + * @param {AddTorrentByFileOptions} options - An object of options... + * @return {Promise} - Resolves with RPC call response or rejects with error. + */ + async addTorrentsByFile({files, destination, tags, isBasePath, start}: AddTorrentByFileOptions) { + const destinationPath = sanitizePath(destination); + + if (!isAllowedPath(destinationPath)) { + throw accessDeniedError(); + } + + createDirectory(destinationPath); + + // Each torrent is sent individually because rTorrent might have small + // XMLRPC request size limit. This allows the user to send files reliably. + return Promise.all( + files.map(async (file) => { + const additionalCalls: Array = []; + + additionalCalls.push(`${isBasePath ? 'd.directory_base.set' : 'd.directory.set'}="${destinationPath}"`); + + if (Array.isArray(tags)) { + additionalCalls.push(`d.custom1.set=${encodeTags(tags)}`); + } + + additionalCalls.push(`d.custom.set=addtime,${Date.now() / 1000}`); + + return ( + this.services?.clientRequestManager + .methodCall( + start ? 'load.raw_start' : 'load.raw', + ['', Buffer.from(file, 'base64')].concat(additionalCalls), + ) + .then(this.processClientRequestSuccess, this.processClientRequestError) || Promise.reject() + ); + }), + ); + } + + /** + * Adds torrents by URL + * + * @param {AddTorrentByURLOptions} options - An object of options... + * @return {Promise} - Resolves with RPC call response or rejects with error. + */ + async addTorrentsByURL({urls, destination, tags, isBasePath, start}: AddTorrentByURLOptions) { + const destinationPath = sanitizePath(destination); + + if (!isAllowedPath(destinationPath)) { + throw accessDeniedError(); + } + + createDirectory(destinationPath); + + const methodCalls: MultiMethodCalls = urls.map((url) => { + const additionalCalls: Array = []; + + additionalCalls.push(`${isBasePath ? 'd.directory_base.set' : 'd.directory.set'}="${destinationPath}"`); + + if (Array.isArray(tags)) { + additionalCalls.push(`d.custom1.set=${encodeTags(tags)}`); + } + + additionalCalls.push(`d.custom.set=addtime,${Date.now() / 1000}`); + + return { + methodName: start ? 'load.start' : 'load.normal', + params: ['', url].concat(additionalCalls), + }; + }, []); + + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) || Promise.reject() + ); + } + /** * Checks torrents * @@ -87,9 +170,11 @@ class ClientGatewayService extends BaseService { return accumulator; }, []); - return this.services?.clientRequestManager - .methodCall('system.multicall', [methodCalls]) - .then(this.processClientRequestSuccess, this.processClientRequestError); + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) || Promise.reject() + ); } /** @@ -137,7 +222,7 @@ class ClientGatewayService extends BaseService { const baseFileName = this.services?.torrentService.getTorrent(hash).baseFilename; if (sourceBasePath == null || baseFileName == null) { - return; + throw new Error(); } const destinationFilePath = sanitizePath(path.join(resolvedPath, baseFileName)); @@ -199,46 +284,48 @@ class ClientGatewayService extends BaseService { return accumulator; }, []); - return this.services?.clientRequestManager.methodCall('system.multicall', [methodCalls]).then((response) => { - if (deleteData === true) { - const torrentCount = hashes.length; - const filesToDelete = hashes.reduce((accumulator, _hash, hashIndex) => { - const fileList = (response as string[][][][][])[hashIndex][0]; - const directoryBase = (response as string[][])[hashIndex + torrentCount][0]; + return ( + this.services?.clientRequestManager.methodCall('system.multicall', [methodCalls]).then((response) => { + if (deleteData === true) { + const torrentCount = hashes.length; + const filesToDelete = hashes.reduce((accumulator, _hash, hashIndex) => { + const fileList = (response as string[][][][][])[hashIndex][0]; + const directoryBase = (response as string[][])[hashIndex + torrentCount][0]; - const torrentFilesToDelete = fileList.reduce((fileListAccumulator, file) => { - // We only look at the first path component returned because - // if it's a directory within the torrent, then we'll remove - // the entire directory. - const filePath = path.join(directoryBase, file[0][0]); + const torrentFilesToDelete = fileList.reduce((fileListAccumulator, file) => { + // We only look at the first path component returned because + // if it's a directory within the torrent, then we'll remove + // the entire directory. + const filePath = path.join(directoryBase, file[0][0]); - // filePath might be a directory, so it may have already been - // added. If not, we add it. - if (!fileListAccumulator.includes(filePath)) { - fileListAccumulator.push(filePath); - } + // filePath might be a directory, so it may have already been + // added. If not, we add it. + if (!fileListAccumulator.includes(filePath)) { + fileListAccumulator.push(filePath); + } - return fileListAccumulator; + return fileListAccumulator; + }, [] as Array); + + return accumulator.concat(torrentFilesToDelete); }, [] as Array); - return accumulator.concat(torrentFilesToDelete); - }, [] as Array); - - filesToDelete.forEach((file) => { - try { - if (fs.lstatSync(file).isDirectory()) { - fs.rmdirSync(file, {recursive: true}); - } else { - fs.unlinkSync(file); + filesToDelete.forEach((file) => { + try { + if (fs.lstatSync(file).isDirectory()) { + fs.rmdirSync(file, {recursive: true}); + } else { + fs.unlinkSync(file); + } + } catch (error) { + console.error(`Error deleting file: ${file}\n${error}`); } - } catch (error) { - console.error(`Error deleting file: ${file}\n${error}`); - } - }); - } + }); + } - return response; - }, this.processClientRequestError); + return response; + }, this.processClientRequestError) || Promise.reject() + ); } /** @@ -262,9 +349,11 @@ class ClientGatewayService extends BaseService { return accumulator; }, []); - return this.services?.clientRequestManager - .methodCall('system.multicall', [methodCalls]) - .then(this.processClientRequestSuccess, this.processClientRequestError); + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) || Promise.reject() + ); } /** @@ -288,9 +377,11 @@ class ClientGatewayService extends BaseService { return accumulator; }, []); - return this.services?.clientRequestManager - .methodCall('system.multicall', [methodCalls]) - .then(this.processClientRequestSuccess, this.processClientRequestError); + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) || Promise.reject() + ); } /** @@ -314,9 +405,11 @@ class ClientGatewayService extends BaseService { return accumulator; }, []); - return this.services?.clientRequestManager - .methodCall('system.multicall', [methodCalls]) - .then(this.processClientRequestSuccess, this.processClientRequestError); + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) || Promise.reject() + ); } /** diff --git a/server/services/clientRequestManager.ts b/server/services/clientRequestManager.ts index 5ed1c9ef..d27030f1 100644 --- a/server/services/clientRequestManager.ts +++ b/server/services/clientRequestManager.ts @@ -3,12 +3,14 @@ import scgiUtil from '../util/scgiUtil'; import type {MultiMethodCalls} from '../constants/rTorrentMethodCall'; +type MethodCallParameters = Array; + class ClientRequestManager extends BaseService { isRequestPending = false; lastResponseTimestamp = 0; pendingRequests: Array<{ methodName: string; - parameters: Array; + parameters: MethodCallParameters; resolve: (value?: Record) => void; reject: (error?: NodeJS.ErrnoException) => void; }> = []; @@ -49,7 +51,7 @@ class ClientRequestManager extends BaseService { this.sendMethodCall(nextRequest.methodName, nextRequest.parameters).then(nextRequest.resolve, nextRequest.reject); } - sendMethodCall(methodName: string, parameters: Array) { + sendMethodCall(methodName: string, parameters: MethodCallParameters) { const connectionMethod = { host: this.user.host, port: this.user.port, @@ -68,7 +70,7 @@ class ClientRequestManager extends BaseService { ); } - methodCall(methodName: string, parameters: Array) { + methodCall(methodName: string, parameters: MethodCallParameters) { // We only allow one request at a time. if (this.isRequestPending) { return new Promise( diff --git a/server/util/fileUtil.ts b/server/util/fileUtil.ts index a1bf93bc..5f3be8c1 100644 --- a/server/util/fileUtil.ts +++ b/server/util/fileUtil.ts @@ -4,6 +4,12 @@ import path from 'path'; import config from '../../config'; +export const accessDeniedError = () => { + const error = new Error() as NodeJS.ErrnoException; + error.code = 'EACCES'; + return error; +}; + export const isAllowedPath = (resolvedPath: string) => { if (config.allowedPaths == null) { return true; @@ -17,20 +23,18 @@ export const isAllowedPath = (resolvedPath: string) => { }; export const sanitizePath = (input: string) => { + if (typeof input !== 'string') { + throw accessDeniedError(); + } + // eslint-disable-next-line no-control-regex const controlRe = /[\x00-\x1f\x80-\x9f]/g; return path.resolve(input).replace(controlRe, ''); }; -export const accessDeniedError = () => { - const error = new Error() as NodeJS.ErrnoException; - error.code = 'EACCES'; - return error; -}; - -export const createDirectory = (options: {path: string}) => { - if (options.path) { - fs.mkdir(options.path, {recursive: true}, (error) => { +export const createDirectory = (directoryPath: string) => { + if (directoryPath) { + fs.mkdir(directoryPath, {recursive: true}, (error) => { if (error) { console.trace('Error creating directory.', error); } diff --git a/server/util/torrentPropertiesUtil.ts b/server/util/torrentPropertiesUtil.ts index 8c47696d..0848d179 100644 --- a/server/util/torrentPropertiesUtil.ts +++ b/server/util/torrentPropertiesUtil.ts @@ -99,3 +99,17 @@ export const hasTorrentFinished = ( return false; }; + +export const encodeTags = (tags: TorrentProperties['tags']): string => { + return tags + .reduce((accumulator: Array, currentTag) => { + const tag = encodeURIComponent(currentTag.trim()); + + if (tag !== '' && accumulator.indexOf(tag) === -1) { + accumulator.push(tag); + } + + return accumulator; + }, []) + .join(','); +};