mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-17 07:20:51 +08:00
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
256 lines
7.1 KiB
TypeScript
256 lines
7.1 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 validationErrors from '../middlewares/validationErrors';
|
|
import { serveEnv } from '../config/serverEnv';
|
|
import { shareStore } from '../shared/store';
|
|
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
|
import { AuthInfo } from '../data/system';
|
|
import path from 'path';
|
|
import { t } from '../shared/i18n';
|
|
import { AppScope } from '../data/open';
|
|
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
|
import protectedPathCase from '../middlewares/protectedPathCase';
|
|
|
|
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
|
const setting = value?.trim();
|
|
if (!setting) {
|
|
return 'loopback';
|
|
}
|
|
if (setting === 'true' || setting === 'false') {
|
|
return setting === 'true';
|
|
}
|
|
if (/^\d+$/.test(setting)) {
|
|
return Number(setting);
|
|
}
|
|
return setting;
|
|
}
|
|
|
|
export default ({ app }: { app: Application }) => {
|
|
// Security: Enable strict routing to prevent case-insensitive path bypass
|
|
app.set('case sensitive routing', true);
|
|
app.set('strict routing', true);
|
|
app.set('trust proxy', resolveTrustProxy());
|
|
app.use(cors());
|
|
|
|
// Security: Reject case variations under protected API namespaces before
|
|
// authentication checks can interpret the request differently from routing.
|
|
app.use(protectedPathCase);
|
|
|
|
// Rewrite URLs to strip baseUrl prefix if configured
|
|
// This allows the rest of the app to work without baseUrl awareness
|
|
if (config.baseUrl) {
|
|
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
|
|
}
|
|
|
|
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
|
app.use(
|
|
`${config.api.prefix}/static`,
|
|
express.static(config.uploadPath, {
|
|
setHeaders: (res) => {
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
|
|
},
|
|
}),
|
|
);
|
|
|
|
const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
|
|
['/user/login', '/user/init', '/user/two-factor/login'].map(
|
|
(route) => `${prefix}${route}`,
|
|
),
|
|
);
|
|
app.use(
|
|
credentialPaths,
|
|
bodyParser.json({ limit: '16kb' }),
|
|
bodyParser.urlencoded({ limit: '16kb', extended: false }),
|
|
);
|
|
|
|
app.use(bodyParser.json({ limit: '50mb' }));
|
|
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
|
|
const frontendPath = path.join(config.rootPath, 'static/dist');
|
|
app.use(express.static(frontendPath));
|
|
|
|
app.use(
|
|
expressjwt({
|
|
secret: config.jwt.secret,
|
|
algorithms: ['HS384'],
|
|
}).unless({
|
|
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
|
|
}),
|
|
);
|
|
|
|
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 pathLower = req.path.toLowerCase();
|
|
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
|
|
return next();
|
|
}
|
|
|
|
const headerToken = getToken(req);
|
|
if (pathLower.startsWith('/open/')) {
|
|
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 = pathLower.match(/\/open\/([a-z]+)\/*/);
|
|
const key = keyMatch && keyMatch[1];
|
|
|
|
if (!doc.scopes.includes(key as AppScope)) {
|
|
const err = new UnauthorizedError('credentials_bad_scheme', {
|
|
message: t('暂无权限'),
|
|
});
|
|
return next(err);
|
|
}
|
|
|
|
if (
|
|
!currentToken ||
|
|
currentToken.expiration < Math.round(Date.now() / 1000)
|
|
) {
|
|
const err = new UnauthorizedError('invalid_token', {
|
|
message: t('Token 已失效'),
|
|
});
|
|
return next(err);
|
|
}
|
|
|
|
return next();
|
|
}
|
|
}
|
|
|
|
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
|
|
if (
|
|
!headerToken &&
|
|
originPath &&
|
|
config.apiWhiteList.includes(originPath)
|
|
) {
|
|
return next();
|
|
}
|
|
|
|
const authInfo = await shareStore.getAuthInfo();
|
|
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
|
return next();
|
|
}
|
|
|
|
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
|
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
|
|
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
|
next(err);
|
|
});
|
|
|
|
app.use(async (req, res, next) => {
|
|
const pathLower = req.path.toLowerCase();
|
|
if (
|
|
![
|
|
'/api/user/init',
|
|
'/api/user/notification/init',
|
|
'/open/user/init',
|
|
'/open/user/notification/init',
|
|
].includes(pathLower)
|
|
) {
|
|
return next();
|
|
}
|
|
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
|
|
|
let isInitialized = !isDefaultAuthInfo(authInfo);
|
|
|
|
if (isInitialized) {
|
|
return res.send({ code: 450, message: t('未知错误') });
|
|
} else {
|
|
return next();
|
|
}
|
|
});
|
|
|
|
app.use(rewrite('/open/*', '/api/$1'));
|
|
app.use(config.api.prefix, routes());
|
|
|
|
app.get('*', (req, res, next) => {
|
|
// Don't serve index.html for API routes
|
|
if (
|
|
req.path === config.api.prefix ||
|
|
req.path.startsWith(`${config.api.prefix}/`)
|
|
) {
|
|
return res.status(404).json({ code: 404, message: 'Not Found' });
|
|
}
|
|
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(validationErrors);
|
|
app.use(uniqueConstraintError);
|
|
|
|
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,
|
|
});
|
|
},
|
|
);
|
|
};
|