统一前后端代码格式化配置
This commit is contained in:
@@ -1,51 +1,44 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { createRequestId, logInfo } from "../utils/logger.js";
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { createRequestId, logInfo } from '../utils/logger.js'
|
||||
|
||||
export function accessLogMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const startedAt = Date.now();
|
||||
const requestId = resolveRequestId(req);
|
||||
export function accessLogMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const startedAt = Date.now()
|
||||
const requestId = resolveRequestId(req)
|
||||
|
||||
req.requestId = requestId;
|
||||
res.setHeader("X-Request-Id", requestId);
|
||||
req.requestId = requestId
|
||||
res.setHeader('X-Request-Id', requestId)
|
||||
|
||||
res.on("finish", () => {
|
||||
res.on('finish', () => {
|
||||
if (shouldSkipAccessLog(req.originalUrl, res.statusCode)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
logInfo("[http/access]", "request completed", {
|
||||
logInfo('[http/access]', 'request completed', {
|
||||
requestId,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
ip: req.ip,
|
||||
forwardedFor: String(req.headers["x-forwarded-for"] || ""),
|
||||
userAgent: String(req.headers["user-agent"] || ""),
|
||||
referer: String(req.headers.referer || ""),
|
||||
contentLength: Number(res.getHeader("content-length") || 0),
|
||||
actorUserId: req.adminSession?.userId || "",
|
||||
actorUsername: req.adminSession?.username || "",
|
||||
actorRole: req.adminSession?.role || "",
|
||||
});
|
||||
});
|
||||
forwardedFor: String(req.headers['x-forwarded-for'] || ''),
|
||||
userAgent: String(req.headers['user-agent'] || ''),
|
||||
referer: String(req.headers.referer || ''),
|
||||
contentLength: Number(res.getHeader('content-length') || 0),
|
||||
actorUserId: req.adminSession?.userId || '',
|
||||
actorUsername: req.adminSession?.username || '',
|
||||
actorRole: req.adminSession?.role || '',
|
||||
})
|
||||
})
|
||||
|
||||
next();
|
||||
next()
|
||||
}
|
||||
|
||||
function resolveRequestId(req: Request): string {
|
||||
const fromHeader = String(req.headers["x-request-id"] || "").trim();
|
||||
return fromHeader || createRequestId("req");
|
||||
const fromHeader = String(req.headers['x-request-id'] || '').trim()
|
||||
return fromHeader || createRequestId('req')
|
||||
}
|
||||
|
||||
function shouldSkipAccessLog(originalUrl: string, statusCode: number): boolean {
|
||||
const pathname = String(originalUrl || "").split("?")[0] || "";
|
||||
return (
|
||||
["/health", "/health/live", "/health/ready"].includes(pathname) &&
|
||||
Number(statusCode) < 400
|
||||
);
|
||||
const pathname = String(originalUrl || '').split('?')[0] || ''
|
||||
return ['/health', '/health/live', '/health/ready'].includes(pathname) && Number(statusCode) < 400
|
||||
}
|
||||
|
||||
@@ -1,43 +1,32 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { RuntimeConfig } from "../types/runtime-config.js";
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import type { RuntimeConfig } from '../types/runtime-config.js'
|
||||
|
||||
export function createCorsMiddleware(
|
||||
config: RuntimeConfig
|
||||
config: RuntimeConfig,
|
||||
): (req: Request, res: Response, next: NextFunction) => void {
|
||||
const allowedOrigins = config.cors.allowedOrigins;
|
||||
const isWildcard =
|
||||
allowedOrigins.length === 1 && allowedOrigins[0] === "*";
|
||||
const allowedOrigins = config.cors.allowedOrigins
|
||||
const isWildcard = allowedOrigins.length === 1 && allowedOrigins[0] === '*'
|
||||
|
||||
return function corsMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
return function corsMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (isWildcard) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
} else {
|
||||
const origin = req.headers.origin;
|
||||
const origin = req.headers.origin
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
);
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
);
|
||||
res.setHeader("Access-Control-Max-Age", "86400");
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Max-Age', '86400')
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,113 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import {
|
||||
createRateLimitMiddleware,
|
||||
resetRateLimitBucketsForTest,
|
||||
} from "./rate-limit.js";
|
||||
import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js'
|
||||
|
||||
test("createRateLimitMiddleware allows requests within the window", () => {
|
||||
resetRateLimitBucketsForTest();
|
||||
test('createRateLimitMiddleware allows requests within the window', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: "test:allow",
|
||||
scope: 'test:allow',
|
||||
windowMs: 60_000,
|
||||
max: 2,
|
||||
});
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
let nextCount = 0;
|
||||
})
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
let nextCount = 0
|
||||
const next: NextFunction = () => {
|
||||
nextCount += 1;
|
||||
};
|
||||
nextCount += 1
|
||||
}
|
||||
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next)
|
||||
limiter(req, res, next)
|
||||
|
||||
assert.equal(nextCount, 2);
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
assert.equal(nextCount, 2)
|
||||
assert.equal(res.statusCode, 200)
|
||||
})
|
||||
|
||||
test("createRateLimitMiddleware blocks requests over the limit", () => {
|
||||
resetRateLimitBucketsForTest();
|
||||
test('createRateLimitMiddleware blocks requests over the limit', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: "test:block",
|
||||
scope: 'test:block',
|
||||
windowMs: 60_000,
|
||||
max: 1,
|
||||
});
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
let nextCount = 0;
|
||||
})
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
let nextCount = 0
|
||||
const next: NextFunction = () => {
|
||||
nextCount += 1;
|
||||
};
|
||||
nextCount += 1
|
||||
}
|
||||
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next)
|
||||
limiter(req, res, next)
|
||||
|
||||
assert.equal(nextCount, 1);
|
||||
assert.equal(res.statusCode, 429);
|
||||
assert.equal(res.headers["Retry-After"], "60");
|
||||
assert.equal(res.body.errorCode, "rate_limited");
|
||||
});
|
||||
assert.equal(nextCount, 1)
|
||||
assert.equal(res.statusCode, 429)
|
||||
assert.equal(res.headers['Retry-After'], '60')
|
||||
assert.equal(res.body.errorCode, 'rate_limited')
|
||||
})
|
||||
|
||||
test("createRateLimitMiddleware supports custom limit responses", () => {
|
||||
resetRateLimitBucketsForTest();
|
||||
test('createRateLimitMiddleware supports custom limit responses', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: "test:custom",
|
||||
scope: 'test:custom',
|
||||
windowMs: 60_000,
|
||||
max: 1,
|
||||
onLimit: (_req, res) => {
|
||||
res.status(200).json({ code: 429, message: "limited" });
|
||||
res.status(200).json({ code: 429, message: 'limited' })
|
||||
},
|
||||
});
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
})
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
limiter(req, res, () => {});
|
||||
limiter(req, res, () => {});
|
||||
limiter(req, res, () => {})
|
||||
limiter(req, res, () => {})
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.deepEqual(res.body, { code: 429, message: "limited" });
|
||||
});
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body, { code: 429, message: 'limited' })
|
||||
})
|
||||
|
||||
function createMockRequest(): Request {
|
||||
return {
|
||||
ip: "127.0.0.1",
|
||||
originalUrl: "/test",
|
||||
ip: '127.0.0.1',
|
||||
originalUrl: '/test',
|
||||
headers: {},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
remoteAddress: '127.0.0.1',
|
||||
},
|
||||
} as Request;
|
||||
} as Request
|
||||
}
|
||||
|
||||
function createMockResponse(): Response & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: any;
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
} {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {} as Record<string, string>,
|
||||
body: null as any,
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
this.headers[name] = value
|
||||
return this
|
||||
},
|
||||
status(statusCode: number) {
|
||||
this.statusCode = statusCode;
|
||||
return this;
|
||||
this.statusCode = statusCode
|
||||
return this
|
||||
},
|
||||
json(body: any) {
|
||||
this.body = body;
|
||||
return this;
|
||||
this.body = body
|
||||
return this
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return res as Response & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: any;
|
||||
};
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { buildErrorPayload, createHttpError } from "../utils/http.js";
|
||||
import { logWarn } from "../utils/logger.js";
|
||||
import { buildErrorPayload, createHttpError } from '../utils/http.js'
|
||||
import { logWarn } from '../utils/logger.js'
|
||||
|
||||
type RateLimitKeyResolver = (req: Request) => string;
|
||||
type RateLimitKeyResolver = (req: Request) => string
|
||||
|
||||
type RateLimitExceededContext = {
|
||||
scope: string;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
scope: string
|
||||
retryAfterSeconds: number
|
||||
}
|
||||
|
||||
type RateLimitOptions = {
|
||||
scope: string;
|
||||
windowMs: number;
|
||||
max: number;
|
||||
key?: RateLimitKeyResolver;
|
||||
onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void;
|
||||
};
|
||||
scope: string
|
||||
windowMs: number
|
||||
max: number
|
||||
key?: RateLimitKeyResolver
|
||||
onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void
|
||||
}
|
||||
|
||||
type RateLimitBucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const buckets = new Map<string, RateLimitBucket>();
|
||||
let lastCleanupAt = 0;
|
||||
const buckets = new Map<string, RateLimitBucket>()
|
||||
let lastCleanupAt = 0
|
||||
|
||||
export function createRateLimitMiddleware({
|
||||
scope,
|
||||
@@ -33,90 +33,91 @@ export function createRateLimitMiddleware({
|
||||
key = defaultRateLimitKey,
|
||||
onLimit,
|
||||
}: RateLimitOptions) {
|
||||
const normalizedScope = String(scope || "default").trim() || "default";
|
||||
const normalizedWindowMs = Math.max(1000, Number(windowMs || 0));
|
||||
const normalizedMax = Math.max(1, Number(max || 0));
|
||||
const normalizedScope = String(scope || 'default').trim() || 'default'
|
||||
const normalizedWindowMs = Math.max(1000, Number(windowMs || 0))
|
||||
const normalizedMax = Math.max(1, Number(max || 0))
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const now = Date.now();
|
||||
cleanupExpiredBuckets(now);
|
||||
const now = Date.now()
|
||||
cleanupExpiredBuckets(now)
|
||||
|
||||
const bucketKey = `${normalizedScope}:${key(req)}`;
|
||||
const current = buckets.get(bucketKey);
|
||||
const bucket = current && current.resetAt > now
|
||||
? current
|
||||
: { count: 0, resetAt: now + normalizedWindowMs };
|
||||
const bucketKey = `${normalizedScope}:${key(req)}`
|
||||
const current = buckets.get(bucketKey)
|
||||
const bucket =
|
||||
current && current.resetAt > now ? current : { count: 0, resetAt: now + normalizedWindowMs }
|
||||
|
||||
bucket.count += 1;
|
||||
buckets.set(bucketKey, bucket);
|
||||
bucket.count += 1
|
||||
buckets.set(bucketKey, bucket)
|
||||
|
||||
if (bucket.count <= normalizedMax) {
|
||||
next();
|
||||
return;
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
||||
res.setHeader("Retry-After", String(retryAfterSeconds));
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
|
||||
res.setHeader('Retry-After', String(retryAfterSeconds))
|
||||
|
||||
logWarn("[rate-limit]", "请求触发限流", {
|
||||
logWarn('[rate-limit]', '请求触发限流', {
|
||||
scope: normalizedScope,
|
||||
ip: req.ip,
|
||||
originalUrl: req.originalUrl,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
})
|
||||
|
||||
if (onLimit) {
|
||||
onLimit(req, res, {
|
||||
scope: normalizedScope,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const error = createHttpError("请求过于频繁,请稍后再试", {
|
||||
const error = createHttpError('请求过于频繁,请稍后再试', {
|
||||
statusCode: 429,
|
||||
errorCode: "rate_limited",
|
||||
});
|
||||
res.status(429).json(buildErrorPayload(error, "请求过于频繁,请稍后再试"));
|
||||
};
|
||||
errorCode: 'rate_limited',
|
||||
})
|
||||
res.status(429).json(buildErrorPayload(error, '请求过于频繁,请稍后再试'))
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRateLimitBucketsForTest(): void {
|
||||
buckets.clear();
|
||||
lastCleanupAt = 0;
|
||||
buckets.clear()
|
||||
lastCleanupAt = 0
|
||||
}
|
||||
|
||||
export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver {
|
||||
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.body?.[fieldName])].join(":");
|
||||
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.body?.[fieldName])].join(':')
|
||||
}
|
||||
|
||||
export function getParamRateLimitKey(paramName: string): RateLimitKeyResolver {
|
||||
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.params?.[paramName])].join(":");
|
||||
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.params?.[paramName])].join(':')
|
||||
}
|
||||
|
||||
function defaultRateLimitKey(req: Request): string {
|
||||
return normalizeKeyPart(
|
||||
req.ip ||
|
||||
String(req.headers["x-forwarded-for"] || "").split(",")[0] ||
|
||||
String(req.headers['x-forwarded-for'] || '').split(',')[0] ||
|
||||
req.socket.remoteAddress ||
|
||||
"unknown",
|
||||
);
|
||||
'unknown',
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeKeyPart(value: unknown): string {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return normalized || "unknown";
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return normalized || 'unknown'
|
||||
}
|
||||
|
||||
function cleanupExpiredBuckets(now: number): void {
|
||||
if (now - lastCleanupAt < 60_000) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
lastCleanupAt = now;
|
||||
lastCleanupAt = now
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
buckets.delete(key);
|
||||
buckets.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user