qinglong/back/loaders/express.ts
copilot-swe-agent[bot] e56bdc8e81 Apply code review suggestions: improve clarity and simplify logic
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:57:49 +00:00

209 lines
6.2 KiB
TypeScript

import express, { Request, Response, NextFunction, Application } from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';
import routes from '../api';
import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback');
app.use(cors());
app.get(`${config.baseUrl}${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.baseUrl}${config.api.prefix}/static`, express.static(config.uploadPath));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
const frontendPath = path.join(config.rootPath, 'static/dist');
// Serve frontend static files at baseUrl (or root if baseUrl is empty)
app.use(config.baseUrl || '/', express.static(frontendPath));
// Create base-URL-aware whitelist for JWT
// When baseUrl is empty, paths remain as-is (e.g., '/api/user/login')
// When baseUrl is set, paths are prefixed (e.g., '/qinglong/api/user/login')
const jwtWhitelist = config.apiWhiteList.map(path => `${config.baseUrl}${path}`);
// Helper to escape special regex characters
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Exclude non-API/non-open paths from JWT requirement
// When baseUrl is set: exclude paths that don't start with baseUrl/api/ or baseUrl/open/
// When baseUrl is empty: exclude paths that don't start with /api/ or /open/
const jwtExcludePattern = config.baseUrl
? `^(?!${escapeRegex(config.baseUrl)}/(api|open)/)`
: '^(?!/(api|open)/)';
const jwtExcludeRegex = new RegExp(jwtExcludePattern);
app.use(
expressjwt({
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...jwtWhitelist, jwtExcludeRegex],
}),
);
app.use((req: Request, res, next) => {
if (!req.headers) {
req.platform = 'desktop';
} else {
const platform = getPlatform(req.headers['user-agent'] || '');
req.platform = platform;
}
return next();
});
app.use(async (req: Request, res, next) => {
const apiPath = `${config.baseUrl}/api/`;
const openPath = `${config.baseUrl}/open/`;
if (![openPath, apiPath].some((x) => req.path.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (req.path.startsWith(openPath)) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) &&
currentToken &&
currentToken.expiration >= Math.round(Date.now() / 1000)
) {
return next();
}
}
}
// req.path already includes the full path with baseUrl
// Previous logic used req.baseUrl (Express mount path) which is empty in our case
// since middleware is not mounted on a sub-router
// e.g., when request is /qinglong/api/user/login, req.path=/qinglong/api/user/login
const originPath = req.path;
if (
!headerToken &&
originPath &&
jwtWhitelist.includes(originPath)
) {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken
? 'jwt malformed'
: 'No authorization token was found';
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
});
app.use(async (req, res, next) => {
const initPaths = [`${config.baseUrl}/api/user/init`, `${config.baseUrl}/api/user/notification/init`];
if (!initPaths.includes(req.path)) {
return next();
}
const authInfo =
(await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']);
let isInitialized = true;
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
if (isInitialized) {
return res.send({ code: 450, message: '未知错误' });
} else {
return next();
}
});
app.use(rewrite(`${config.baseUrl}/open/*`, `${config.baseUrl}/api/$1`));
app.use(`${config.baseUrl}${config.api.prefix}`, routes());
app.get('*', (req, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
}
});
});
app.use(errors());
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name === 'UnauthorizedError') {
return res
.status(err.status)
.send({ code: 401, message: err.message })
.end();
}
return next(err);
},
);
app.use(
(
err: Error & { errors: any[] },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name.includes('Sequelize')) {
return res
.status(500)
.send({
code: 400,
message: `${err.message}`,
errors: err.errors,
})
.end();
}
return next(err);
},
);
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
res.status(err.status || 500);
res.json({
code: err.status || 500,
message: err.message,
});
},
);
};