server: redirect to a portable link for /data endpoint

This commit is contained in:
Jesse Chan
2021-01-22 23:39:38 +08:00
parent e3122a683d
commit 1d1a478391
4 changed files with 91 additions and 5 deletions
+49
View File
@@ -1,5 +1,8 @@
import express from 'express';
import passport from 'passport';
import rateLimit from 'express-rate-limit';
import {contentTokenSchema} from '@shared/schema/api/torrents';
import type {FloodSettings} from '@shared/types/FloodSettings';
import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes';
@@ -12,6 +15,7 @@ import clientRoutes from './client';
import clientActivityStream from '../../middleware/clientActivityStream';
import eventStream from '../../middleware/eventStream';
import feedMonitorRoutes from './feed-monitor';
import {getAuthToken, verifyToken} from '../../util/authUtil';
import {getDirectoryList} from '../../util/fileUtil';
import {getResponseFn} from '../../util/ajaxUtil';
import torrentsRoutes from './torrents';
@@ -20,6 +24,51 @@ const router = express.Router();
router.use('/auth', authRoutes);
// Special routes that may bypass authentication when conditions matched
/**
* GET /api/torrents/{hash}/contents/{indices}/data
* @summary Gets downloaded data of contents of a torrent. Allows unauthenticated
* access if a valid content token is found in the query.
* @see torrents.ts
*/
router.get<{hash: string; indices: string}, unknown, unknown, {token: string}>(
'/torrents/:hash/contents/:indices/data',
rateLimit({
windowMs: 5 * 60 * 1000,
max: 60,
}),
async (req, _res, next) => {
const {token} = req.query;
if (typeof token === 'string' && token !== '') {
const payload = await verifyToken(token).catch(() => undefined);
if (payload != null) {
const parsedResult = contentTokenSchema.safeParse(payload);
if (parsedResult.success) {
const {username, hash: authorizedHash, indices: authorizedIndices, iat} = parsedResult.data;
if (
typeof username === 'string' &&
typeof authorizedHash === 'string' &&
typeof authorizedIndices === 'string'
) {
const {hash: requestedHash, indices: requestedIndices} = req.params;
if (requestedHash === authorizedHash && requestedIndices === authorizedIndices) {
req.cookies = {jwt: getAuthToken(username, iat)};
}
}
}
}
}
next();
},
);
// All subsequent routes need authentication
router.use('/', passport.authenticate('jwt', {session: false}), appendUserServices);
+14 -1
View File
@@ -11,6 +11,7 @@ import tar, {Pack} from 'tar-fs';
import type {
AddTorrentByFileOptions,
AddTorrentByURLOptions,
ContentToken,
SetTorrentsTagsOptions,
} from '@shared/schema/api/torrents';
import type {
@@ -35,6 +36,7 @@ import {
import {accessDeniedError, fileNotFoundError, isAllowedPath, sanitizePath} from '../../util/fileUtil';
import {getResponseFn, validationError} from '../../util/ajaxUtil';
import {getTempPath} from '../../models/TemporaryStorage';
import {getToken} from '../../util/authUtil';
const getDestination = async (
services: Express.Request['services'],
@@ -670,7 +672,7 @@ router.patch<{hash: string}, unknown, SetTorrentContentsPropertiesOptions>('/:ha
* @param {string} indices.path - 'all' or indices of selected contents separated by ','
* @return {object} 200 - contents archived in .tar - application/x-tar
*/
router.get(
router.get<{hash: string; indices: string}, unknown, unknown, {token: string}>(
'/:hash/contents/:indices/data',
// This operation is resource-intensive
// Limit each IP to 60 requests every 5 minutes
@@ -681,6 +683,17 @@ router.get(
(req, res) => {
const {hash, indices: stringIndices} = req.params;
if (req.user != null && req.query.token == null) {
res.redirect(
`?token=${getToken<ContentToken>({
username: req.user.username,
hash,
indices: stringIndices,
})}`,
);
return;
}
const selectedTorrent = req.services?.torrentService.getTorrent(hash);
if (!selectedTorrent) {
res.status(404).json({error: 'Torrent not found.'});
+14 -3
View File
@@ -1,6 +1,8 @@
import {CookieOptions} from 'express';
import jwt from 'jsonwebtoken';
import type {AuthToken} from '@shared/schema/Auth';
import config from '../../config';
const EXPIRATION_SECONDS = 60 * 60 * 24 * 7; // one week
@@ -11,12 +13,21 @@ export const getCookieOptions = (): CookieOptions => ({
sameSite: 'strict',
});
export const getAuthToken = (username: string): string =>
jwt.sign({username}, config.secret, {
export const getAuthToken = (username: string, iat?: number): string => {
const authTokenPayload: Partial<AuthToken> = {
username,
};
if (iat != null) {
authTokenPayload.iat = iat;
}
return jwt.sign(authTokenPayload, config.secret, {
expiresIn: EXPIRATION_SECONDS,
});
};
export const getToken = (payload: Record<string, unknown>) =>
export const getToken = <T extends Record<string, unknown>>(payload: Omit<T, 'iat' | 'exp'>) =>
jwt.sign(payload, config.secret, {
expiresIn: EXPIRATION_SECONDS,
});
+14 -1
View File
@@ -1,4 +1,4 @@
import {array, boolean, object, record, string} from 'zod';
import {array, boolean, number, object, record, string} from 'zod';
import {noComma} from '../../util/regEx';
import type {infer as zodInfer} from 'zod';
@@ -62,3 +62,16 @@ export const setTorrentsTagsSchema = object({
});
export type SetTorrentsTagsOptions = zodInfer<typeof setTorrentsTagsSchema>;
// GET /api/torrents/{hash}/contents/{indices}/data
export const contentTokenSchema = object({
username: string(),
hash: string(),
indices: string(),
// issued at
iat: number(),
// expiration
exp: number(),
});
export type ContentToken = zodInfer<typeof contentTokenSchema>;