diff --git a/client/src/javascript/actions/TorrentActions.ts b/client/src/javascript/actions/TorrentActions.ts index 3be4c8b2..b0f5ae14 100644 --- a/client/src/javascript/actions/TorrentActions.ts +++ b/client/src/javascript/actions/TorrentActions.ts @@ -1,9 +1,8 @@ import axios, {CancelToken} from 'axios'; import download from 'js-file-download'; +import type {AddTorrentByFileOptions, AddTorrentByURLOptions} from '@shared/schema/api/torrents'; import type { - AddTorrentByFileOptions, - AddTorrentByURLOptions, CheckTorrentsOptions, CreateTorrentOptions, DeleteTorrentsOptions, 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 1c71665d..d7868c59 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx @@ -145,13 +145,13 @@ class AddTorrentsByFile extends Component url !== ''); - if (urls.length === 0 || formData.destination == null) { + if (urls[0] == null || formData.destination == null) { this.setState({isAddingTorrents: false}); return; } @@ -67,7 +67,7 @@ class AddTorrentsByURL extends Component = ({children}: {chi const callback = (data: string) => { filesData.push(data); - if (filesData.length === files.length) { + if (filesData.length === files.length && filesData[0] != null) { TorrentActions.addTorrentsByFiles({ - files: filesData, + files: filesData as [string, ...string[]], destination: SettingStore.floodSettings.torrentDestination || SettingStore.clientSettings?.directoryDefault || '', isBasePath: false, diff --git a/client/src/javascript/util/userPreferences.ts b/client/src/javascript/util/userPreferences.ts index c1effa03..d07fa67e 100644 --- a/client/src/javascript/util/userPreferences.ts +++ b/client/src/javascript/util/userPreferences.ts @@ -9,7 +9,7 @@ export const saveAddTorrentsUserPreferences = ({start, destination}: {start?: bo changedSettings.startTorrentsOnLoad = start; } - if (destination != null) { + if (destination != null && destination !== '') { changedSettings.torrentDestination = destination; } @@ -17,5 +17,7 @@ export const saveAddTorrentsUserPreferences = ({start, destination}: {start?: bo }; export const saveDeleteTorrentsUserPreferences = ({deleteData}: {deleteData?: boolean}) => { - SettingActions.saveSetting('deleteTorrentData', deleteData); + if (deleteData != null) { + SettingActions.saveSetting('deleteTorrentData', deleteData); + } }; diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts index d2a9033e..111aa5ed 100644 --- a/server/routes/api/auth.ts +++ b/server/routes/api/auth.ts @@ -5,7 +5,6 @@ import rateLimit from 'express-rate-limit'; import type {Response} from 'express'; -import ajaxUtil from '../../util/ajaxUtil'; import { authAuthenticationSchema, authRegistrationSchema, @@ -13,6 +12,7 @@ import { AuthVerificationPreloadConfigs, } from '../../../shared/schema/api/auth'; import config from '../../../config'; +import {getResponseFn, validationError} from '../../util/ajaxUtil'; import requireAdmin from '../../middleware/requireAdmin'; import services from '../../services'; import Users from '../../models/Users'; @@ -73,13 +73,6 @@ const sendAuthenticationResponse = ( res.json(response); }; -const validationError = (res: Response, err: Error) => { - res.status(422).json({ - message: 'Validation error.', - error: err, - }); -}; - const preloadConfigs: AuthVerificationPreloadConfigs = { authMethod: config.authMethod, pollInterval: config.torrentClientPollInterval, @@ -185,14 +178,14 @@ router.post('/regis services.bootstrapServicesForUser(user); if (req.query.cookie === 'false') { - ajaxUtil.getResponseFn(res)({username: user.username}); + getResponseFn(res)({username: user.username}); return; } sendAuthenticationResponse(res, credentials); }, (err) => { - ajaxUtil.getResponseFn(res)({username: credentials.username}, err); + getResponseFn(res)({username: credentials.username}, err); }, ); }); @@ -303,7 +296,7 @@ router.use('/users', (_req, res, next) => { * @return {Array} 200 - success response - application/json */ router.get('/users', (_req, res) => { - Users.listUsers(ajaxUtil.getResponseFn(res)); + Users.listUsers(getResponseFn(res)); }); /** @@ -317,7 +310,7 @@ router.get('/users', (_req, res) => { * @return {{username: string}} 200 - success response - application/json */ router.delete('/users/:username', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); Users.removeUser(req.params.username, (id, err) => { if (err || id == null) { callback(null, err || new Error()); diff --git a/server/routes/api/client.ts b/server/routes/api/client.ts index 88bc0810..85fa4bb9 100644 --- a/server/routes/api/client.ts +++ b/server/routes/api/client.ts @@ -3,7 +3,7 @@ import express from 'express'; import type {ClientSettings} from '@shared/types/ClientSettings'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; -import ajaxUtil from '../../util/ajaxUtil'; +import {getResponseFn} from '../../util/ajaxUtil'; import requireAdmin from '../../middleware/requireAdmin'; // Those settings don't require administrator access. @@ -39,7 +39,7 @@ router.get('/connection-test', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.get('/settings', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.getClientSettings() @@ -71,7 +71,7 @@ router.patch('/settings', (req, res, next) => { } }); router.patch('/settings', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.setClientSettings(req.body) diff --git a/server/routes/api/feed-monitor.ts b/server/routes/api/feed-monitor.ts index 4a925c64..0156d653 100644 --- a/server/routes/api/feed-monitor.ts +++ b/server/routes/api/feed-monitor.ts @@ -3,7 +3,7 @@ import express from 'express'; import type {AddFeedOptions, AddRuleOptions, ModifyFeedOptions} from '@shared/types/api/feed-monitor'; import {accessDeniedError, isAllowedPath, sanitizePath} from '../../util/fileUtil'; -import ajaxUtil from '../../util/ajaxUtil'; +import {getResponseFn} from '../../util/ajaxUtil'; const router = express.Router(); @@ -16,7 +16,7 @@ const router = express.Router(); * @return {Error} 500 - failure response - application/json */ router.get('/', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.feedService .getAll() @@ -38,7 +38,7 @@ router.get('/', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.delete<{id: string}>('/:id', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.feedService .removeItem(req.params.id) @@ -60,7 +60,7 @@ router.delete<{id: string}>('/:id', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.get<{id?: string}>('/feeds/:id?', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.feedService .getFeeds(req.params.id) @@ -82,7 +82,7 @@ router.get<{id?: string}>('/feeds/:id?', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.put('/feeds', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.feedService .addFeed(req.body) @@ -105,7 +105,7 @@ router.put('/feeds', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.patch<{id: string}, unknown, ModifyFeedOptions>('/feeds/:id', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.feedService .modifyFeed(req.params.id, req.body) @@ -128,7 +128,7 @@ router.patch<{id: string}, unknown, ModifyFeedOptions>('/feeds/:id', (req, res) * @return {Error} 500 - failure response - application/json */ router.get<{id: string}, unknown, ModifyFeedOptions, {search: string}>('/feeds/:id/items', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.feedService .getItems(req.params.id, req.query.search) @@ -149,7 +149,7 @@ router.get<{id: string}, unknown, ModifyFeedOptions, {search: string}>('/feeds/: * @return {Error} 500 - failure response - application/json */ router.get('/rules', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.feedService .getRules() @@ -171,7 +171,7 @@ router.get('/rules', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.put('/rules', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); let sanitizedPath: string | null = null; try { diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 040c8763..7285279e 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -7,13 +7,13 @@ import type {NotificationFetchOptions} from '@shared/types/Notification'; import type {SetFloodSettingsOptions} from '@shared/types/api/index'; import appendUserServices from '../../middleware/appendUserServices'; -import ajaxUtil from '../../util/ajaxUtil'; import authRoutes from './auth'; import clientRoutes from './client'; import clientActivityStream from '../../middleware/clientActivityStream'; import eventStream from '../../middleware/eventStream'; import feedMonitorRoutes from './feed-monitor'; import {getDirectoryList} from '../../util/fileUtil'; +import {getResponseFn} from '../../util/ajaxUtil'; import torrentsRoutes from './torrents'; const router = express.Router(); @@ -40,7 +40,7 @@ router.use('/torrents', torrentsRoutes); router.get('/activity-stream', eventStream, clientActivityStream); router.get('/directory-list', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); getDirectoryList(req.query.path) .then((data) => { callback(data); @@ -51,15 +51,15 @@ router.get('/directory-list', (req, r }); router.get('/history', (req, res) => { - req.services?.historyService.getHistory(req.query, ajaxUtil.getResponseFn(res)); + req.services?.historyService.getHistory(req.query, getResponseFn(res)); }); router.get('/notifications', (req, res) => { - req.services?.notificationService.getNotifications(req.query, ajaxUtil.getResponseFn(res)); + req.services?.notificationService.getNotifications(req.query, getResponseFn(res)); }); router.delete('/notifications', (req, res) => { - req.services?.notificationService.clearNotifications(ajaxUtil.getResponseFn(res)); + req.services?.notificationService.clearNotifications(getResponseFn(res)); }); /** @@ -71,7 +71,7 @@ router.delete('/notifications', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.get('/settings', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.settingService .get(null) @@ -92,8 +92,8 @@ router.get('/settings', (req, res) => { * @return {Partial} 200 - success response - application/json * @return {Error} 500 - failure response - application/json */ -router.get('/settings/:property', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); +router.get<{property: keyof FloodSettings}>('/settings/:property', (req, res) => { + const callback = getResponseFn(res); req.services?.settingService .get(req.params.property) @@ -115,7 +115,7 @@ router.get('/settings/:property', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.patch('/settings', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.settingService .set(req.body) diff --git a/server/routes/api/torrents.test.ts b/server/routes/api/torrents.test.ts index 8e504012..444d0c5f 100644 --- a/server/routes/api/torrents.test.ts +++ b/server/routes/api/torrents.test.ts @@ -11,9 +11,8 @@ import {getAuthToken} from './auth'; import {getTempPath} from '../../models/TemporaryStorage'; import paths from '../../../shared/config/paths'; +import type {AddTorrentByFileOptions, AddTorrentByURLOptions} from '../../../shared/schema/api/torrents'; import type { - AddTorrentByFileOptions, - AddTorrentByURLOptions, CreateTorrentOptions, MoveTorrentsOptions, SetTorrentsTrackersOptions, @@ -34,9 +33,9 @@ jest.setTimeout(20000); const torrentFiles = [ path.join(paths.appSrc, 'fixtures/single.torrent'), path.join(paths.appSrc, 'fixtures/multi.torrent'), -].map((torrentPath) => Buffer.from(fs.readFileSync(torrentPath)).toString('base64')); +].map((torrentPath) => Buffer.from(fs.readFileSync(torrentPath)).toString('base64')) as [string, ...string[]]; -const torrentURLs = [ +const torrentURLs: [string, ...string[]] = [ 'https://releases.ubuntu.com/20.04/ubuntu-20.04.1-live-server-amd64.iso.torrent', 'https://flood.js.org/api/test-cookie', ]; @@ -82,6 +81,21 @@ describe('POST /api/torrents/add-urls', () => { start: false, }; + it('Adds torrents via URLs with incorrect options', (done) => { + request + .post('/api/torrents/add-urls') + .send({...addTorrentByURLOptions, nonExistingOption: 1}) + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(422) + .expect('Content-Type', /json/) + .end((err, _res) => { + if (err) done(err); + + done(); + }); + }); + it('Adds torrents to disallowed path via URLs', (done) => { request .post('/api/torrents/add-urls') @@ -166,6 +180,21 @@ describe('POST /api/torrents/add-files', () => { start: false, }; + it('Adds torrents via files with incorrect options', (done) => { + request + .post('/api/torrents/add-files') + .send({...addTorrentByFileOptions, destination: []}) + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(422) + .expect('Content-Type', /json/) + .end((err, _res) => { + if (err) done(err); + + done(); + }); + }); + it('Adds torrents to disallowed path via files', (done) => { request .post('/api/torrents/add-files') diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index a022154c..e43521eb 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -6,9 +6,8 @@ import path from 'path'; import sanitize from 'sanitize-filename'; import tar from 'tar'; -import { - AddTorrentByFileOptions, - AddTorrentByURLOptions, +import type {AddTorrentByFileOptions, AddTorrentByURLOptions} from '@shared/schema/api/torrents'; +import type { CheckTorrentsOptions, CreateTorrentOptions, DeleteTorrentsOptions, @@ -21,10 +20,25 @@ import { StopTorrentsOptions, } from '@shared/types/api/torrents'; +import {addTorrentByFileSchema, addTorrentByURLSchema} from '../../../shared/schema/api/torrents'; import {accessDeniedError, isAllowedPath, sanitizePath} from '../../util/fileUtil'; -import ajaxUtil from '../../util/ajaxUtil'; +import {getResponseFn, validationError} from '../../util/ajaxUtil'; import {getTempPath} from '../../models/TemporaryStorage'; +const getDestination = (destination: string): string | undefined => { + let sanitizedPath: string | null = null; + try { + sanitizedPath = sanitizePath(destination); + if (!isAllowedPath(sanitizedPath)) { + return undefined; + } + } catch (e) { + return undefined; + } + + return sanitizedPath; +}; + const router = express.Router(); /** @@ -36,7 +50,7 @@ const router = express.Router(); * @return {Error} 500 - failure response - application/json */ router.get('/', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.torrentService .fetchTorrentList() @@ -62,22 +76,34 @@ router.get('/', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.post('/add-urls', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); - let sanitizedPath: string | null = null; - try { - sanitizedPath = sanitizePath(req.body.destination); - if (!isAllowedPath(sanitizedPath)) { - callback(null, accessDeniedError()); - return; - } - } catch (e) { - callback(null, e); + const parsedResult = addTorrentByURLSchema.safeParse(req.body); + + if (!parsedResult.success) { + validationError(res, parsedResult.error); + return; + } + + const {urls, cookies, destination, tags, isBasePath, isCompleted, start} = parsedResult.data; + + const finalDestination = getDestination(destination); + + if (finalDestination == null) { + callback(null, accessDeniedError()); return; } req.services?.clientGatewayService - ?.addTorrentsByURL({...req.body, destination: sanitizedPath}) + ?.addTorrentsByURL({ + urls, + cookies: cookies != null ? cookies : {}, + destination: finalDestination, + tags: tags ?? [], + isBasePath: isBasePath ?? false, + isCompleted: isCompleted ?? false, + start: start ?? false, + }) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -98,22 +124,33 @@ router.post('/add-urls', (req, res) => * @return {Error} 500 - failure response - application/json */ router.post('/add-files', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); - let sanitizedPath: string | null = null; - try { - sanitizedPath = sanitizePath(req.body.destination); - if (!isAllowedPath(sanitizedPath)) { - callback(null, accessDeniedError()); - return; - } - } catch (e) { - callback(null, e); + const parsedResult = addTorrentByFileSchema.safeParse(req.body); + + if (!parsedResult.success) { + validationError(res, parsedResult.error); + return; + } + + const {files, destination, tags, isBasePath, isCompleted, start} = parsedResult.data; + + const finalDestination = getDestination(destination); + + if (finalDestination == null) { + callback(null, accessDeniedError()); return; } req.services?.clientGatewayService - ?.addTorrentsByFile({...req.body, destination: sanitizedPath}) + ?.addTorrentsByFile({ + files, + destination: finalDestination, + tags: tags ?? [], + isBasePath: isBasePath ?? false, + isCompleted: isCompleted ?? false, + start: start ?? false, + }) .then((response) => { req.services?.torrentService.fetchTorrentList(); return response; @@ -135,7 +172,7 @@ router.post('/add-files', (req, res) */ router.post('/create', async (req, res) => { const {name, sourcePath, trackers, comment, infoSource, isPrivate, tags, start} = req.body; - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); if (typeof sourcePath !== 'string') { callback(null, accessDeniedError()); @@ -184,7 +221,7 @@ router.post('/create', async (req, res) ?.addTorrentsByFile({ files: [torrent.toString('base64')], destination: fs.lstatSync(sanitizedPath).isDirectory() ? sanitizedPath : path.dirname(sanitizedPath), - tags, + tags: tags ?? [], isBasePath: true, isCompleted: true, start: start || false, @@ -207,7 +244,7 @@ router.post('/create', async (req, res) * @return {Error} 500 - failure response - application/json */ router.post('/start', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.startTorrents(req.body) @@ -231,7 +268,7 @@ router.post('/start', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.post('/stop', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.stopTorrents(req.body) @@ -255,7 +292,7 @@ router.post('/stop', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.post('/check-hash', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.checkTorrents(req.body) @@ -279,7 +316,7 @@ router.post('/check-hash', (req, res) => * @return {Error} 500 - failure response - application/json */ router.post('/move', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); let sanitizedPath: string | null = null; try { @@ -315,7 +352,7 @@ router.post('/move', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.post('/delete', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.removeTorrents(req.body) @@ -339,7 +376,7 @@ router.post('/delete', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.patch('/priority', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.setTorrentsPriority(req.body) @@ -363,7 +400,7 @@ router.patch('/priority', (req, re * @return {Error} 500 - failure response - application/json */ router.patch('/tags', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.setTorrentsTags(req.body) @@ -387,7 +424,7 @@ router.patch('/tags', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.patch('/trackers', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.setTorrentsTrackers(req.body) @@ -424,7 +461,7 @@ router.patch('/trackers', (req, re * @param {string} hash.path */ router.get('/:hash/contents', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.getTorrentContents(req.params.hash) @@ -445,7 +482,7 @@ router.get('/:hash/contents', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.patch<{hash: string}, unknown, SetTorrentContentsPropertiesOptions>('/:hash/contents', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.setTorrentContentsPriority(req.params.hash, req.body) @@ -518,7 +555,7 @@ router.get('/:hash/contents/:indices/data', (req, res) => { * @param {string} hash.path */ router.get('/:hash/details', async (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); try { const contents = req.services?.clientGatewayService?.getTorrentContents(req.params.hash); @@ -545,7 +582,7 @@ router.get('/:hash/details', async (req, res) => { */ router.get('/:hash/mediainfo', async (req, res) => { const {hash} = req.params; - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); const {torrentService} = req.services || {}; if (typeof hash !== 'string' || torrentService == null) { @@ -595,7 +632,7 @@ router.get('/:hash/mediainfo', async (req, res) => { * @param {string} hash.path */ router.get('/:hash/peers', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.getTorrentPeers(req.params.hash) @@ -615,7 +652,7 @@ router.get('/:hash/peers', (req, res) => { * @return {Error} 500 - failure response - application/json */ router.get('/:hash/trackers', (req, res) => { - const callback = ajaxUtil.getResponseFn(res); + const callback = getResponseFn(res); req.services?.clientGatewayService ?.getTorrentTrackers(req.params.hash) diff --git a/server/services/Transmission/clientGatewayService.ts b/server/services/Transmission/clientGatewayService.ts index 6c1699a3..fcafc01f 100644 --- a/server/services/Transmission/clientGatewayService.ts +++ b/server/services/Transmission/clientGatewayService.ts @@ -1,15 +1,7 @@ import geoip from 'geoip-country'; -import type {ClientSettings} from '@shared/types/ClientSettings'; -import type {TorrentContent} from '@shared/types/TorrentContent'; -import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; -import type {TorrentPeer} from '@shared/types/TorrentPeer'; -import type {TorrentTracker} from '@shared/types/TorrentTracker'; -import type {TransferSummary} from '@shared/types/TransferData'; -import type {TransmissionConnectionSettings} from '@shared/schema/ClientConnectionSettings'; +import type {AddTorrentByFileOptions, AddTorrentByURLOptions} from '@shared/schema/api/torrents'; import type { - AddTorrentByFileOptions, - AddTorrentByURLOptions, CheckTorrentsOptions, DeleteTorrentsOptions, MoveTorrentsOptions, @@ -20,6 +12,13 @@ import type { StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; +import type {ClientSettings} from '@shared/types/ClientSettings'; +import type {TorrentContent} from '@shared/types/TorrentContent'; +import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; +import type {TorrentPeer} from '@shared/types/TorrentPeer'; +import type {TorrentTracker} from '@shared/types/TorrentTracker'; +import type {TransferSummary} from '@shared/types/TransferData'; +import type {TransmissionConnectionSettings} from '@shared/schema/ClientConnectionSettings'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; import ClientGatewayService from '../interfaces/clientGatewayService'; @@ -34,7 +33,7 @@ import {TransmissionPriority, TransmissionTorrentsSetArguments} from './types/Tr class TransmissionClientGatewayService extends ClientGatewayService { clientRequestManager = new ClientRequestManager(this.user.client as TransmissionConnectionSettings); - async addTorrentsByFile({files, destination, tags, start}: AddTorrentByFileOptions): Promise { + async addTorrentsByFile({files, destination, tags, start}: Required): Promise { const addedTorrents: Array = ( await Promise.all( files.map(async (file) => { @@ -48,12 +47,12 @@ class TransmissionClientGatewayService extends ClientGatewayService { ) ).filter((hash) => hash != null) as Array; - if (tags?.length) { + if (tags.length > 0) { await this.setTorrentsTags({hashes: addedTorrents, tags}); } } - async addTorrentsByURL({urls, cookies, destination, tags, start}: AddTorrentByURLOptions): Promise { + async addTorrentsByURL({urls, cookies, destination, tags, start}: Required): Promise { const addedTorrents: Array = ( await Promise.all( urls.map(async (url) => { @@ -62,7 +61,7 @@ class TransmissionClientGatewayService extends ClientGatewayService { (await this.clientRequestManager .addTorrent({ filename: url, - cookies: cookies?.[domain] != null ? `${cookies[domain].join('; ')};` : undefined, + cookies: cookies[domain] != null ? `${cookies[domain].join('; ')};` : undefined, 'download-dir': destination, paused: !start, }) @@ -73,7 +72,7 @@ class TransmissionClientGatewayService extends ClientGatewayService { ) ).filter((hash) => hash != null) as Array; - if (tags?.length) { + if (tags.length > 0) { await this.setTorrentsTags({hashes: addedTorrents, tags}); } } diff --git a/server/services/feedService.ts b/server/services/feedService.ts index 560e9ca1..350da6b3 100644 --- a/server/services/feedService.ts +++ b/server/services/feedService.ts @@ -250,9 +250,12 @@ class FeedService extends BaseService { await this.services?.clientGatewayService ?.addTorrentsByURL({ urls, + cookies: {}, destination, - start, tags, + start, + isBasePath: false, + isCompleted: false, }) .then(() => { this.db.update({_id: feedID}, {$inc: {count: 1}}, {upsert: true}); diff --git a/server/services/interfaces/clientGatewayService.ts b/server/services/interfaces/clientGatewayService.ts index e60e5da8..d5fb49ec 100644 --- a/server/services/interfaces/clientGatewayService.ts +++ b/server/services/interfaces/clientGatewayService.ts @@ -1,12 +1,5 @@ -import type {ClientSettings} from '@shared/types/ClientSettings'; -import type {TorrentContent} from '@shared/types/TorrentContent'; -import type {TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; -import type {TorrentPeer} from '@shared/types/TorrentPeer'; -import type {TorrentTracker} from '@shared/types/TorrentTracker'; -import type {TransferSummary} from '@shared/types/TransferData'; +import type {AddTorrentByFileOptions, AddTorrentByURLOptions} from '@shared/schema/api/torrents'; import type { - AddTorrentByFileOptions, - AddTorrentByURLOptions, CheckTorrentsOptions, DeleteTorrentsOptions, MoveTorrentsOptions, @@ -17,7 +10,13 @@ import type { StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; +import type {ClientSettings} from '@shared/types/ClientSettings'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; +import type {TorrentContent} from '@shared/types/TorrentContent'; +import type {TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; +import type {TorrentPeer} from '@shared/types/TorrentPeer'; +import type {TorrentTracker} from '@shared/types/TorrentTracker'; +import type {TransferSummary} from '@shared/types/TransferData'; import BaseService from '../BaseService'; import config from '../../../config'; @@ -36,18 +35,18 @@ abstract class ClientGatewayService extends BaseService} options - An object of options... * @return {Promise} - Rejects with error. */ - abstract addTorrentsByFile(options: AddTorrentByFileOptions): Promise; + abstract addTorrentsByFile(options: Required): Promise; /** * Adds torrents by URL * - * @param {AddTorrentByURLOptions} options - An object of options... + * @param {Required} options - An object of options... * @return {Promise} - Rejects with error. */ - abstract addTorrentsByURL(options: AddTorrentByURLOptions): Promise; + abstract addTorrentsByURL(options: Required): Promise; /** * Checks torrents diff --git a/server/services/qBittorrent/clientGatewayService.ts b/server/services/qBittorrent/clientGatewayService.ts index 1a33c86d..1505045d 100644 --- a/server/services/qBittorrent/clientGatewayService.ts +++ b/server/services/qBittorrent/clientGatewayService.ts @@ -1,13 +1,5 @@ -import type {ClientSettings} from '@shared/types/ClientSettings'; -import type {QBittorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings'; -import type {TorrentContent} from '@shared/types/TorrentContent'; -import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; -import type {TorrentPeer} from '@shared/types/TorrentPeer'; -import type {TorrentTracker} from '@shared/types/TorrentTracker'; -import type {TransferSummary} from '@shared/types/TransferData'; +import type {AddTorrentByFileOptions, AddTorrentByURLOptions} from '@shared/schema/api/torrents'; import type { - AddTorrentByFileOptions, - AddTorrentByURLOptions, CheckTorrentsOptions, DeleteTorrentsOptions, MoveTorrentsOptions, @@ -18,6 +10,13 @@ import type { StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; +import type {ClientSettings} from '@shared/types/ClientSettings'; +import type {QBittorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings'; +import type {TorrentContent} from '@shared/types/TorrentContent'; +import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; +import type {TorrentPeer} from '@shared/types/TorrentPeer'; +import type {TorrentTracker} from '@shared/types/TorrentTracker'; +import type {TransferSummary} from '@shared/types/TransferData'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; import ClientGatewayService from '../interfaces/clientGatewayService'; @@ -37,7 +36,7 @@ class QBittorrentClientGatewayService extends ClientGatewayService { clientRequestManager = new ClientRequestManager(this.user.client as QBittorrentConnectionSettings); cachedProperties: Record> = {}; - async addTorrentsByFile({files, destination, isBasePath, start}: AddTorrentByFileOptions): Promise { + async addTorrentsByFile({files, destination, isBasePath, start}: Required): Promise { const fileBuffers = files.map((file) => { return Buffer.from(file, 'base64'); }); @@ -53,7 +52,7 @@ class QBittorrentClientGatewayService extends ClientGatewayService { .then(this.processClientRequestSuccess, this.processClientRequestError); } - async addTorrentsByURL({urls, destination, isBasePath, start}: AddTorrentByURLOptions): Promise { + async addTorrentsByURL({urls, destination, isBasePath, start}: Required): Promise { // TODO: qBittorrent does not have capability to add tags during add torrents. return this.clientRequestManager diff --git a/server/services/rTorrent/clientGatewayService.ts b/server/services/rTorrent/clientGatewayService.ts index 40afaafa..abd80f45 100644 --- a/server/services/rTorrent/clientGatewayService.ts +++ b/server/services/rTorrent/clientGatewayService.ts @@ -4,16 +4,8 @@ import {moveSync} from 'fs-extra'; import path from 'path'; import sanitize from 'sanitize-filename'; -import type {ClientSettings} from '@shared/types/ClientSettings'; -import type {RTorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings'; -import type {TorrentContent} from '@shared/types/TorrentContent'; -import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; -import type {TorrentPeer} from '@shared/types/TorrentPeer'; -import type {TorrentTracker} from '@shared/types/TorrentTracker'; -import type {TransferSummary} from '@shared/types/TransferData'; +import type {AddTorrentByFileOptions, AddTorrentByURLOptions} from '@shared/schema/api/torrents'; import type { - AddTorrentByFileOptions, - AddTorrentByURLOptions, CheckTorrentsOptions, DeleteTorrentsOptions, MoveTorrentsOptions, @@ -24,6 +16,13 @@ import type { StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; +import type {ClientSettings} from '@shared/types/ClientSettings'; +import type {RTorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings'; +import type {TorrentContent} from '@shared/types/TorrentContent'; +import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; +import type {TorrentPeer} from '@shared/types/TorrentPeer'; +import type {TorrentTracker} from '@shared/types/TorrentTracker'; +import type {TransferSummary} from '@shared/types/TransferData'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; import {createDirectory} from '../../util/fileUtil'; @@ -61,18 +60,26 @@ class RTorrentClientGatewayService extends ClientGatewayService { isBasePath, isCompleted, start, - }: AddTorrentByFileOptions): Promise { - if (!Array.isArray(files)) { - return Promise.reject(); - } - + }: Required): Promise { const torrentPaths = await Promise.all( files.map(async (file) => { return saveBufferToTempFile(Buffer.from(file, 'base64'), 'torrent'); }), ); - return this.addTorrentsByURL({urls: torrentPaths, destination, tags, isBasePath, isCompleted, start}); + if (torrentPaths[0] != null) { + return this.addTorrentsByURL({ + urls: torrentPaths as [string, ...string[]], + cookies: {}, + destination, + tags, + isBasePath, + isCompleted, + start, + }); + } + + return Promise.reject(); } async addTorrentsByURL({ @@ -83,7 +90,7 @@ class RTorrentClientGatewayService extends ClientGatewayService { isBasePath, isCompleted, start, - }: AddTorrentByURLOptions): Promise { + }: Required): Promise { await createDirectory(destination); const torrentPaths: Array = ( @@ -93,11 +100,9 @@ class RTorrentClientGatewayService extends ClientGatewayService { const domain = url.split('/')[2]; // TODO: properly handle error and let frontend know - const torrentPath = await fetchURLToTempFile( - url, - cookies ? cookies[domain] : undefined, - 'torrent', - ).catch((e) => console.error(e)); + const torrentPath = await fetchURLToTempFile(url, cookies[domain], 'torrent').catch((e) => + console.error(e), + ); if (typeof torrentPath === 'string') { return torrentPath; @@ -130,7 +135,7 @@ class RTorrentClientGatewayService extends ClientGatewayService { additionalCalls.push(`${isBasePath ? 'd.directory_base.set' : 'd.directory.set'}="${destination}"`); - if (Array.isArray(tags)) { + if (tags.length > 0) { additionalCalls.push(`d.custom1.set=${encodeTags(tags)}`); } diff --git a/server/util/ajaxUtil.ts b/server/util/ajaxUtil.ts index 9af05886..87e339a9 100644 --- a/server/util/ajaxUtil.ts +++ b/server/util/ajaxUtil.ts @@ -1,21 +1,24 @@ import type {Response} from 'express'; -const ajaxUtil = { - getResponseFn: (res: Response) => (data: D, error?: Error | string) => { - if (error) { - if (process.env.NODE_ENV === 'development') { - console.trace(error); - } - - if (typeof error === 'string') { - res.status(500).json(Error(error)); - } - - res.status(500).json(error); - } else { - res.json(data); - } - }, +export const validationError = (res: Response, err: Error) => { + res.status(422).json({ + message: 'Validation error.', + error: err, + }); }; -export default ajaxUtil; +export const getResponseFn = (res: Response) => (data: D, error?: Error | string) => { + if (error) { + if (process.env.NODE_ENV === 'development') { + console.trace(error); + } + + if (typeof error === 'string') { + res.status(500).json(Error(error)); + } + + res.status(500).json(error); + } else { + res.json(data); + } +}; diff --git a/server/util/feedUtil.ts b/server/util/feedUtil.ts index 5e2f1c61..5bf8db90 100644 --- a/server/util/feedUtil.ts +++ b/server/util/feedUtil.ts @@ -2,10 +2,11 @@ import type {FeedItem} from 'feedsub'; import regEx from '../../shared/util/regEx'; -import type {AddTorrentByURLOptions} from '../../shared/types/api/torrents'; +import type {AddTorrentByURLOptions} from '../../shared/schema/api/torrents'; import type {Rule} from '../../shared/types/Feed'; -interface PendingDownloadItems extends AddTorrentByURLOptions { +interface PendingDownloadItems + extends Required> { matchTitle: string; ruleID: string; ruleLabel: string; @@ -69,9 +70,9 @@ export const getFeedItemsMatchingRules = ( torrentUrls.every((url) => matchedItem.urls.includes(url)), ); - if (!isAlreadyDownloaded) { + if (!isAlreadyDownloaded && torrentUrls[0] != null) { matchedItems.push({ - urls: torrentUrls, + urls: torrentUrls as [string, ...string[]], tags: rule.tags, matchTitle: feedItem.title as string, ruleID: rule._id, diff --git a/shared/constants/defaultFloodSettings.ts b/shared/constants/defaultFloodSettings.ts index a3980a7e..cf82d465 100644 --- a/shared/constants/defaultFloodSettings.ts +++ b/shared/constants/defaultFloodSettings.ts @@ -66,8 +66,9 @@ const defaultFloodSettings: Readonly = { download: [1024, 10240, 102400, 512000, 1048576, 2097152, 5242880, 10485760, 0], upload: [1024, 10240, 102400, 512000, 1048576, 2097152, 5242880, 10485760, 0], }, - startTorrentsOnLoad: false, mountPoints: [], + deleteTorrentData: true, + startTorrentsOnLoad: true, }; export default defaultFloodSettings; diff --git a/shared/schema/api/torrents.ts b/shared/schema/api/torrents.ts new file mode 100644 index 00000000..4c6d6a22 --- /dev/null +++ b/shared/schema/api/torrents.ts @@ -0,0 +1,41 @@ +import {array, boolean, object, record, string} from 'zod'; + +import type {infer as zodInfer} from 'zod'; + +// POST /api/torrents/add-urls +export const addTorrentByURLSchema = object({ + // URLs to download torrents from + urls: array(string().url()).nonempty(), + // Cookies to attach to requests, arrays of strings in the format "name=value" with domain as key + cookies: record(array(string())).optional(), + // Path of destination + destination: string(), + // Tags + tags: array(string()).optional(), + // Whether destination is the base path [default: false] + isBasePath: boolean().optional(), + // Whether destination contains completed contents [default: false] + isCompleted: boolean().optional(), + // Whether to start torrent [default: false] + start: boolean().optional(), +}); + +export type AddTorrentByURLOptions = zodInfer; + +// POST /api/torrents/add-files +export const addTorrentByFileSchema = object({ + // Torrent files in base64 + files: array(string()).nonempty(), + // Path of destination + destination: string(), + // Tags + tags: array(string()).optional(), + // Whether destination is the base path [default: false] + isBasePath: boolean().optional(), + // Whether destination contains completed contents [default: false] + isCompleted: boolean().optional(), + // Whether to start torrent [default: false] + start: boolean().optional(), +}); + +export type AddTorrentByFileOptions = zodInfer; diff --git a/shared/types/FloodSettings.ts b/shared/types/FloodSettings.ts index df283782..7bb17a31 100644 --- a/shared/types/FloodSettings.ts +++ b/shared/types/FloodSettings.ts @@ -22,12 +22,16 @@ export interface FloodSettings { download: Array; upload: Array; }; - startTorrentsOnLoad: boolean; mountPoints: Array; - // Below: default setting is not specified + // Last selection state of "Delete data" toggle + deleteTorrentData: boolean; + + // Last selection state of "Start Torrent" toggle + startTorrentsOnLoad: boolean; + + // Last used download destination torrentDestination?: string; - deleteTorrentData?: boolean; } export type FloodSetting = keyof FloodSettings; diff --git a/shared/types/api/torrents.ts b/shared/types/api/torrents.ts index c13e90ce..5f5f9d6e 100644 --- a/shared/types/api/torrents.ts +++ b/shared/types/api/torrents.ts @@ -1,43 +1,6 @@ import type {TorrentPriority, TorrentProperties} from '../Torrent'; import type {TorrentContentPriority} from '../TorrentContent'; -// POST /api/torrents/add-urls -export interface AddTorrentByURLOptions { - // URLs to download torrents from - urls: Array; - // Cookies to attach to requests - cookies?: { - // An array of strings in the format "name=value"; - [domain: string]: Array; - }; - // Path of destination - destination: string; - // Tags - tags?: Array; - // Whether destination is the base path [default: false] - isBasePath?: boolean; - // Whether destination contains completed contents [default: false] - isCompleted?: boolean; - // Whether to start torrent [default: false] - start?: boolean; -} - -// POST /api/torrents/add-files -export interface AddTorrentByFileOptions { - // Torrent files in base64 - files: Array; - // Path of destination - destination: string; - // Tags - tags?: Array; - // Whether destination is the base path [default: false] - isBasePath?: boolean; - // Whether destination contains completed contents [default: false] - isCompleted?: boolean; - // Whether to start torrent [default: false] - start?: boolean; -} - // POST /api/torrents/create export interface CreateTorrentOptions { // Name of the torrent: