refactor: use fastify (#661)

This commit is contained in:
Trim21
2023-12-06 19:49:22 +08:00
committed by GitHub
parent 6d059544b6
commit a570469756
12 changed files with 1234 additions and 2300 deletions

2979
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -81,6 +81,8 @@
"@emotion/babel-plugin": "^11.11.0",
"@emotion/css": "^11.11.0",
"@emotion/react": "^11.11.1",
"@fastify/express": "^2.3.0",
"@fastify/static": "^6.10.2",
"@lingui/loader": "^3.17.2",
"@lingui/react": "^3.17.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
@@ -92,7 +94,6 @@
"@types/cookie-parser": "^1.4.3",
"@types/create-torrent": "^5.0.0",
"@types/d3": "^7.4.0",
"@types/debug": "^4.1.8",
"@types/express": "^4.17.17",
"@types/fs-extra": "^9.0.13",
"@types/geoip-country": "^4.0.0",
@@ -104,7 +105,7 @@
"@types/node": "^12.20.55",
"@types/parse-torrent": "^5.8.4",
"@types/passport": "^1.0.12",
"@types/passport-jwt": "^3.0.8",
"@types/passport-jwt": "^3.0.9",
"@types/react": "^18.2.11",
"@types/react-dom": "^18.2.4",
"@types/react-measure": "^2.0.8",
@@ -136,8 +137,6 @@
"d3-scale": "^4.0.2",
"d3-selection": "^3.0.0",
"d3-shape": "^3.2.0",
"debug": "^4.3.4",
"esbuild-jest": "^0.5.0",
"eslint": "^8.42.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
@@ -149,6 +148,8 @@
"express-rate-limit": "^6.7.0",
"fast-json-patch": "^3.1.1",
"fast-sort": "^3.4.0",
"fastify": "^4.21.0",
"fastify-type-provider-zod": "^1.1.9",
"feedsub": "^0.7.8",
"file-loader": "^6.2.0",
"form-data": "^4.0.0",
@@ -159,6 +160,7 @@
"html-webpack-plugin": "^5.5.3",
"http-errors": "^2.0.0",
"jest": "^28.1.3",
"jest-esbuild": "^0.3.0",
"js-file-download": "^0.4.12",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
@@ -176,7 +178,6 @@
"postcss": "^8.4.24",
"postcss-loader": "^7.3.3",
"prettier": "^2.8.8",
"promise": "^8.3.0",
"react": "^18.2.0",
"react-dev-utils": "^12.0.1",
"react-dom": "^18.2.0",

View File

@@ -3,7 +3,7 @@ module.exports = {
transform: {
// transform ESM only package to CommonJS
'^.+\\.(t|j)sx?$': [
'esbuild-jest',
'jest-esbuild',
{
format: 'cjs',
},

View File

@@ -1,129 +0,0 @@
import bodyParser from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import express from 'express';
import fs from 'fs';
import morgan from 'morgan';
import passport from 'passport';
import path from 'path';
import {Strategy} from 'passport-jwt';
import type {Request} from 'express';
import {authTokenSchema} from '@shared/schema/Auth';
import paths from '@shared/config/paths';
import type {UserInDatabase} from '@shared/schema/Auth';
import apiRoutes from './routes/api';
import config from '../config';
import Users from './models/Users';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface User extends UserInDatabase {}
}
}
Users.bootstrapServicesForAllUsers();
const app = express();
const servedPath = config.baseURI.endsWith('/') ? config.baseURI : `${config.baseURI}/`;
// Remove Express header
if (process.env.NODE_ENV !== 'development') {
app.disable('x-powered-by');
}
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(morgan('dev'));
if (config.serveAssets !== false) {
// Disable ETag
app.set('etag', false);
// Enable compression
app.use(compression());
// Static assets
app.use(servedPath, express.static(paths.appDist));
// Client app routes, serve index.html and client js will figure it out
const html = fs.readFileSync(path.join(paths.appDist, 'index.html'), {
encoding: 'utf8',
});
// Prohibit caching of index.html as browser can use a fully cached asset
// tree in some cases, which defeats cache busting by asset hashes.
const headers = {
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
};
app.get(`${servedPath}login`, (_req, res) => {
res.set(headers);
res.send(html);
});
app.get(`${servedPath}register`, (_req, res) => {
res.set(headers);
res.send(html);
});
app.get(`${servedPath}overview`, (_req, res) => {
res.set(headers);
res.send(html);
});
} else {
// no-op res.flush() as compression is not handled by Express
app.use((_req, res, next) => {
res.flush = () => {
// do nothing.
};
next();
});
}
app.use(passport.initialize());
app.use(bodyParser.json({limit: '50mb'}));
app.use(bodyParser.urlencoded({extended: false, limit: '50mb'}));
app.use(cookieParser());
passport.use(
new Strategy(
{
jwtFromRequest: (req: Request) => req?.cookies?.jwt,
secretOrKey: config.secret,
},
(payload, callback) => {
const parsedResult = authTokenSchema.safeParse(payload);
if (!parsedResult.success) {
callback(parsedResult.error, false);
return;
}
Users.lookupUser(parsedResult.data.username).then(
(user) => {
if (user?.timestamp <= parsedResult.data.iat + 10) {
callback(null, user);
} else {
callback(new Error(), false);
}
},
(err) => {
callback(err, false);
},
);
},
),
);
app.use(`${servedPath}api`, apiRoutes);
export default app;

7
server/bin/start.ts Executable file → Normal file
View File

@@ -4,6 +4,7 @@ import chalk from 'chalk';
import enforcePrerequisites from './enforce-prerequisites';
import migrateData from './migrations/run';
import startWebServer from './web-server';
if (process.env.NODE_ENV == 'production') {
// Catch unhandled rejections and exceptions
@@ -26,11 +27,7 @@ if (process.env.NODE_ENV == 'production') {
enforcePrerequisites()
.then(migrateData)
.then(() => {
// We do this because we don't want the side effects of importing server functions before migration is completed.
const startWebServer = require('./web-server').default; // eslint-disable-line @typescript-eslint/no-var-requires
return startWebServer();
})
.then(startWebServer)
.catch((error) => {
console.log(chalk.red('Failed to start Flood:'));
console.trace(error);

View File

@@ -1,106 +1,51 @@
import chalk from 'chalk';
import debug from 'debug';
import fastify from 'fastify';
import fs from 'fs';
import http from 'http';
import https from 'https';
import app from '../app';
import type {FastifyInstance} from 'fastify';
import type {Http2SecureServer} from 'http2';
import type {Server} from 'http';
import config from '../../config';
import constructRoutes from '../routes';
import packageJSON from '../../package.json';
const debugFloodServer = debug('flood:server');
const startWebServer = async () => {
const {ssl = false, floodServerHost: host, floodServerPort: port} = config;
// Normalize a port into a number, string, or false.
const normalizePort = (val: string | number): string | number => {
const port = parseInt(val as string, 10);
let instance: FastifyInstance<Http2SecureServer> | FastifyInstance<Server>;
// Named pipe.
if (Number.isNaN(port)) {
return val;
}
// Port number.
if (port >= 0) {
return port;
}
console.error('Unexpected port or pipe');
process.exit(1);
};
const startWebServer = () => {
const port = normalizePort(config.floodServerPort);
const host = config.floodServerHost;
const useSSL = config.ssl ?? false;
app.set('port', port);
app.set('host', host);
// Create HTTP or HTTPS server.
let server: http.Server | https.Server;
if (useSSL) {
if (ssl) {
if (!config.sslKey || !config.sslCert) {
console.error('Cannot start HTTPS server, `sslKey` or `sslCert` is missing in config.js.');
process.exit(1);
}
server = https.createServer(
{
instance = fastify({
bodyLimit: 100 * 1024 * 1024,
trustProxy: 'loopback',
http2: true,
https: {
allowHTTP1: true,
key: fs.readFileSync(config.sslKey),
cert: fs.readFileSync(config.sslCert),
},
app,
);
} else {
server = http.createServer(app);
}
const handleError = (error: NodeJS.ErrnoException) => {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`;
// Handle specific listen errors with friendly messages.
switch (error.code) {
case 'EACCES':
console.error(`${bind} requires elevated privileges`);
process.exit(1);
case 'EADDRINUSE':
console.error(`${bind} is already in use`);
process.exit(1);
default:
throw error;
}
};
// Event listener for HTTP server "listening" event.
const handleListening = () => {
const addr = server.address();
if (addr == null) {
console.error('Unable to get listening address.');
process.exit(1);
}
const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`;
debugFloodServer(`Listening on ${bind}`);
};
// Listen on provided port, on all network interfaces.
if (typeof port === 'string') {
server.listen(port);
} else {
server.listen(port, host);
}
server.on('error', handleError);
server.on('listening', handleListening);
process.on('exit', () => {
server.close();
});
} else {
instance = fastify({
bodyLimit: 100 * 1024 * 1024,
trustProxy: 'loopback',
});
}
const address = chalk.underline(typeof port === 'string' ? port : `${useSSL ? 'https' : 'http'}://${host}:${port}`);
await constructRoutes(instance as FastifyInstance);
if (typeof port === 'string') {
await instance.listen({path: port});
} else {
await instance.listen({port, host});
}
const address = chalk.underline(`${ssl ? 'https' : 'http'}://${host}:${port}`);
console.log(chalk.green(`Flood server ${packageJSON.version} starting on ${address}\n`));

View File

@@ -1,9 +1,10 @@
import crypto from 'crypto';
import fastify from 'fastify';
import supertest from 'supertest';
import {AccessLevel} from '../../../shared/schema/constants/Auth';
import app from '../../app';
import constructRoutes from '..';
import {getAuthToken} from '../../util/authUtil';
import type {
@@ -13,8 +14,6 @@ import type {
} from '../../../shared/schema/api/auth';
import type {ClientConnectionSettings} from '../../../shared/schema/ClientConnectionSettings';
const request = supertest(app);
const testConnectionSettings: ClientConnectionSettings = {
client: 'rTorrent',
type: 'socket',
@@ -38,6 +37,19 @@ const testNonAdminUser = {
} as const;
let testNonAdminUserToken = '';
const app = fastify({disableRequestLogging: true, logger: false});
let request: supertest.SuperTest<supertest.Test>;
beforeAll(async () => {
await constructRoutes(app);
await app.ready();
request = supertest(app.server);
});
afterAll(async () => {
await app.close();
});
describe('GET /api/auth/verify (initial)', () => {
it('Verify without credential', (done) => {
request

View File

@@ -1,11 +1,23 @@
import fastify from 'fastify';
import supertest from 'supertest';
import app from '../../app';
import constructRoutes from '..';
import {getAuthToken} from '../../util/authUtil';
import type {ClientSettings} from '../../../shared/types/ClientSettings';
const request = supertest(app);
const app = fastify({disableRequestLogging: true, logger: true});
let request: supertest.SuperTest<supertest.Test>;
beforeAll(async () => {
await constructRoutes(app);
await app.ready();
request = supertest(app.server);
});
afterAll(async () => {
await app.close();
});
const authToken = `jwt=${getAuthToken('_config')}`;

View File

@@ -1,14 +1,26 @@
import fastify from 'fastify';
import fs from 'fs';
import supertest from 'supertest';
import app from '../../app';
import constructRoutes from '..';
import {getAuthToken} from '../../util/authUtil';
import {getTempPath} from '../../models/TemporaryStorage';
import type {AddFeedOptions, AddRuleOptions, ModifyFeedOptions} from '../../../shared/types/api/feed-monitor';
import type {Feed, Rule} from '../../../shared/types/Feed';
const request = supertest(app);
const app = fastify({disableRequestLogging: true, logger: false});
let request: supertest.SuperTest<supertest.Test>;
beforeAll(async () => {
await constructRoutes(app);
await app.ready();
request = supertest(app.server);
});
afterAll(async () => {
await app.close();
});
const authToken = `jwt=${getAuthToken('_config')}`;

View File

@@ -1,11 +1,23 @@
import fastify from 'fastify';
import supertest from 'supertest';
import app from '../../app';
import constructRoutes from '..';
import {getAuthToken} from '../../util/authUtil';
import type {FloodSettings} from '../../../shared/types/FloodSettings';
const request = supertest(app);
const app = fastify({disableRequestLogging: true, logger: false});
let request: supertest.SuperTest<supertest.Test>;
beforeAll(async () => {
await constructRoutes(app);
await app.ready();
request = supertest(app.server);
});
afterAll(async () => {
await app.close();
});
const authToken = `jwt=${getAuthToken('_config')}`;

View File

@@ -1,14 +1,13 @@
import axios from 'axios';
import crypto from 'crypto';
import fastify from 'fastify';
import fs from 'fs';
import MockAdapter from 'axios-mock-adapter';
import os from 'os';
import path from 'path';
import readline from 'readline';
import stream from 'stream';
import supertest from 'supertest';
import app from '../../app';
import constructRoutes from '..';
import {getAuthToken} from '../../util/authUtil';
import {getTempPath} from '../../models/TemporaryStorage';
import paths from '../../../shared/config/paths';
@@ -20,9 +19,20 @@ import type {TorrentList} from '../../../shared/types/Torrent';
import type {TorrentStatus} from '../../../shared/constants/torrentStatusMap';
import type {TorrentTracker} from '../../../shared/types/TorrentTracker';
const request = supertest(app);
const app = fastify({bodyLimit: 100 * 1024 * 1024 * 1024, disableRequestLogging: true, forceCloseConnections: true});
let request: supertest.SuperTest<supertest.Test>;
const activityStream = new stream.PassThrough();
const authToken = `jwt=${getAuthToken('_config')}`;
const rl = readline.createInterface({input: activityStream});
beforeAll(async () => {
console.time('before all');
await constructRoutes(app);
await app.ready();
request = supertest(app.server);
request.get('/api/activity-stream').send().set('Cookie', [authToken]).pipe(activityStream);
});
const tempDirectory = getTempPath('download');
@@ -33,19 +43,9 @@ const torrentFiles = [
path.join(paths.appSrc, 'fixtures/multi.torrent'),
].map((torrentPath) => Buffer.from(fs.readFileSync(torrentPath)).toString('base64')) as [string, ...string[]];
const mock = new MockAdapter(axios, {onNoMatch: 'passthrough'});
mock
.onGet('https://www.torrents/single.torrent')
.reply(200, fs.readFileSync(path.join(paths.appSrc, 'fixtures/single.torrent')));
mock
.onGet('https://www.torrents/multi.torrent')
.reply(200, fs.readFileSync(path.join(paths.appSrc, 'fixtures/multi.torrent')));
const torrentURLs: [string, ...string[]] = [
'https://www.torrents/single.torrent',
'https://www.torrents/multi.torrent',
'https://releases.ubuntu.com/focal/ubuntu-20.04.6-live-server-amd64.iso.torrent',
'https://flood.js.org/api/test-cookie',
];
const torrentCookies = {
@@ -58,12 +58,8 @@ const testTrackers = [
`http://${crypto.randomBytes(8).toString('hex')}.com/announce.php?key=test`,
];
const torrentHashes: string[] = [];
const createdTorrentHashes: string[] = [];
const activityStream = new stream.PassThrough();
const rl = readline.createInterface({input: activityStream});
request.get('/api/activity-stream').send().set('Cookie', [authToken]).pipe(activityStream);
let torrentHash = '';
let createdTorrentHash = '';
const watchTorrentList = (op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'): Promise<void> => {
return new Promise((resolve) => {
@@ -79,23 +75,6 @@ const watchTorrentList = (op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | '
});
};
beforeAll((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();
});
});
describe('POST /api/torrents/add-urls', () => {
const addTorrentByURLOptions: AddTorrentByURLOptions = {
urls: torrentURLs,
@@ -199,7 +178,7 @@ describe('POST /api/torrents/add-urls', () => {
: ['stopped', 'inactive'];
expect(torrent.status).toEqual(expect.arrayContaining(expectedStatuses));
torrentHashes.push(torrent.hash);
torrentHash = torrent.hash;
}),
);
@@ -208,31 +187,6 @@ describe('POST /api/torrents/add-urls', () => {
});
});
describe('POST /api/torrents/delete', () => {
const torrentDeleted = watchTorrentList('remove');
it('Deletes added torrents', (done) => {
request
.post('/api/torrents/delete')
.send({hashes: torrentHashes, deleteData: true})
.set('Cookie', [authToken])
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', /json/)
.end((err, _res) => {
if (err) done(err);
Promise.race([torrentDeleted, new Promise((r) => setTimeout(r, 1000 * 15))])
.then(async () => {
// Wait a while
await new Promise((r) => setTimeout(r, 1000 * 3));
})
.then(() => {
done();
});
});
});
});
describe('POST /api/torrents/add-files', () => {
const addTorrentByFileOptions: AddTorrentByFileOptions = {
files: torrentFiles,
@@ -415,7 +369,7 @@ describe('POST /api/torrents/create', () => {
await Promise.all(
addedTorrents.map(async (torrent) => {
createdTorrentHashes.push(torrent.hash);
createdTorrentHash = torrent.hash;
expect(torrent.isPrivate).toBe(false);
if (process.argv.includes('--trurl')) {
@@ -435,7 +389,7 @@ describe('POST /api/torrents/create', () => {
describe('PATCH /api/torrents/trackers', () => {
it('Sets single tracker', (done) => {
const setTrackersOptions: SetTorrentsTrackersOptions = {
hashes: [torrentHashes[0]],
hashes: [torrentHash],
trackers: [testTrackers[0]],
};
@@ -455,7 +409,7 @@ describe('PATCH /api/torrents/trackers', () => {
it('Sets multiple trackers', (done) => {
const setTrackersOptions: SetTorrentsTrackersOptions = {
hashes: [torrentHashes[0]],
hashes: [torrentHash],
trackers: testTrackers,
};
@@ -475,7 +429,7 @@ describe('PATCH /api/torrents/trackers', () => {
it('GET /api/torrents/{hash}/trackers', (done) => {
request
.get(`/api/torrents/${torrentHashes[0]}/trackers`)
.get(`/api/torrents/${torrentHash}/trackers`)
.send()
.set('Cookie', [authToken])
.set('Accept', 'application/json')
@@ -497,7 +451,7 @@ describe('PATCH /api/torrents/trackers', () => {
describe('GET /api/torrents/{hash}/contents', () => {
it('Gets contents of torrents', (done) => {
request
.get(`/api/torrents/${torrentHashes[0]}/contents`)
.get(`/api/torrents/${torrentHash}/contents`)
.send()
.set('Cookie', [authToken])
.set('Accept', 'application/json')
@@ -520,7 +474,7 @@ describe('POST /api/torrents/move', () => {
it('Moves torrent', (done) => {
const moveTorrentsOptions: MoveTorrentsOptions = {
hashes: [createdTorrentHashes[createdTorrentHashes.length - 1]],
hashes: [createdTorrentHash],
destination: destDirectory,
moveFiles: true,
isBasePath: true,
@@ -558,7 +512,7 @@ describe('POST /api/torrents/move', () => {
expect(res.body.torrents == null).toBe(false);
const torrentList: TorrentList = res.body.torrents;
const torrent = torrentList[createdTorrentHashes[createdTorrentHashes.length - 1]];
const torrent = torrentList[createdTorrentHash];
expect(torrent).not.toBe(null);
expect(torrent.directory.startsWith(destDirectory)).toBe(true);

137
server/routes/index.ts Normal file
View File

@@ -0,0 +1,137 @@
import fs from 'fs';
import path from 'path';
import type {FastifyInstance} from 'fastify';
import paths from '@shared/config/paths';
import {Strategy} from 'passport-jwt';
import apiRoutes from './api';
import config from '../../config';
import Users from '../models/Users';
import {authTokenSchema, UserInDatabase} from '@shared/schema/Auth';
import express from 'express';
import morgan from 'morgan';
import compression from 'compression';
import passport from 'passport';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import {fastifyExpress} from '@fastify/express';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface User extends UserInDatabase {}
}
}
const constructRoutes = async (fastify: FastifyInstance) => {
await Users.bootstrapServicesForAllUsers();
const app = express();
const servedPath = config.baseURI.endsWith('/') ? config.baseURI : `${config.baseURI}/`;
// Remove Express header
if (process.env.NODE_ENV !== 'development') {
app.disable('x-powered-by');
}
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
if (config.serveAssets !== false) {
// Disable ETag
app.set('etag', false);
// Enable compression
app.use(compression());
// Static assets
app.use(servedPath, express.static(paths.appDist));
// Client app routes, serve index.html and client js will figure it out
const html = fs.readFileSync(path.join(paths.appDist, 'index.html'), {
encoding: 'utf8',
});
// Prohibit caching of index.html as browser can use a fully cached asset
// tree in some cases, which defeats cache busting by asset hashes.
const headers = {
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
};
app.get(`${servedPath}login`, (_req, res) => {
res.set(headers);
res.send(html);
});
app.get(`${servedPath}register`, (_req, res) => {
res.set(headers);
res.send(html);
});
app.get(`${servedPath}overview`, (_req, res) => {
res.set(headers);
res.send(html);
});
} else {
// no-op res.flush() as compression is not handled by Express
app.use((_req, res, next) => {
res.flush = () => {
// do nothing.
};
next();
});
}
app.use(passport.initialize());
app.use(bodyParser.json({limit: '50mb'}));
app.use(bodyParser.urlencoded({extended: false, limit: '50mb'}));
app.use(cookieParser());
passport.use(
new Strategy(
{
jwtFromRequest: (req: express.Request) => req?.cookies?.jwt,
secretOrKey: config.secret,
},
(payload, callback) => {
const parsedResult = authTokenSchema.safeParse(payload);
if (!parsedResult.success) {
callback(parsedResult.error, false);
return;
}
Users.lookupUser(parsedResult.data.username).then(
(user) => {
if (user?.timestamp <= parsedResult.data.iat + 10) {
callback(null, user);
} else {
callback(new Error(), false);
}
},
(err) => {
callback(err, false);
},
);
},
),
);
app.use(`${servedPath}api`, apiRoutes);
await fastify.register(fastifyExpress);
fastify.use(app);
return app;
};
export default constructRoutes;