feat(admin): make user rate limits configurable via admin UI

Add rate_limit_settings table and corresponding admin permissions
Replace static user rate limits with dynamic in-memory counters
Add interface in admin panel to configure rate limit policies
This commit is contained in:
2026-05-03 22:11:41 +08:00
parent b0e2464c24
commit deb0b54e71
7 changed files with 491 additions and 153 deletions

View File

@@ -177,6 +177,18 @@ CREATE TABLE IF NOT EXISTS ai_moderation_cache (
CHECK (length(model) BETWEEN 1 AND 120)
);
CREATE TABLE IF NOT EXISTS rate_limit_settings (
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO rate_limit_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;
INSERT INTO permissions (key, name, description, category, system_permission)
VALUES
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
@@ -200,6 +212,8 @@ VALUES
('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true),
('admin.ai-moderation.read', 'View AI moderation settings', 'View AI moderation configuration.', 'AI moderation', true),
('admin.ai-moderation.update', 'Update AI moderation settings', 'Edit AI moderation configuration.', 'AI moderation', true),
('admin.rate-limits.read', 'View rate limits', 'View user rate limit settings.', 'Rate limits', true),
('admin.rate-limits.update', 'Update rate limits', 'Edit user rate limit settings.', 'Rate limits', true),
('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true),
('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true),
('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
@@ -284,6 +298,8 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'admin.wordings.update',
'admin.ai-moderation.read',
'admin.ai-moderation.update',
'admin.rate-limits.read',
'admin.rate-limits.update',
'admin.config.read',
'admin.config.create',
'admin.config.update',
@@ -345,6 +361,16 @@ JOIN permissions p ON p.key = ANY (ARRAY[
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'admin.rate-limits.read',
'admin.rate-limits.update'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r

View File

@@ -229,10 +229,27 @@ function serverMessage(
}
type RateLimitCheck = ReturnType<typeof app.createRateLimit>;
type RateLimitResult = Awaited<ReturnType<RateLimitCheck>>;
type RateLimitPolicy = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite';
type RateLimitedRequest = FastifyRequest & {
rateLimitUserId?: number;
type RateLimitPolicySettings = {
maxRequests: number;
timeWindowSeconds: number;
cooldownSeconds: number;
};
type RateLimitPolicySettingsMap = Record<RateLimitPolicy, RateLimitPolicySettings>;
type RateLimitFailure = {
max: number;
ttlInSeconds: number;
isBanned?: boolean;
};
type RateLimitSettingsRow = {
settings: unknown;
updatedAt: Date | string;
updatedBy: { id: number; displayName: string } | null;
};
type PublicRateLimitSettings = {
policies: RateLimitPolicySettingsMap;
updatedAt: Date | string | null;
updatedBy: { id: number; displayName: string } | null;
};
function hashRateLimitPart(value: string): string {
@@ -249,14 +266,6 @@ function emailRateLimitPart(request: FastifyRequest): string {
return hashRateLimitPart(email || 'missing-email');
}
function userRateLimitPart(request: FastifyRequest): string {
return String((request as RateLimitedRequest).rateLimitUserId ?? 'anonymous');
}
function userRouteRateLimitKey(scope: string, request: FastifyRequest): string {
return `${scope}:user:${userRateLimitPart(request)}:route:${routeRateLimitPart(request)}`;
}
function ipRouteRateLimitKey(scope: string, request: FastifyRequest): string {
return `${scope}:ip:${hashRateLimitPart(request.ip)}:route:${routeRateLimitPart(request)}`;
}
@@ -291,139 +300,198 @@ const protectedRouteIpRateLimit = app.createRateLimit({
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('protected', request)
});
const writeRouteIpRateLimit = app.createRateLimit({
max: 90,
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('write', request)
});
const userRouteWriteRateLimit = app.createRateLimit({
max: 30,
timeWindow: '10 minutes',
keyGenerator: (request) => userRouteRateLimitKey('write', request)
});
const fetchRouteIpRateLimit = app.createRateLimit({
max: 60,
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('fetch', request)
});
const userRouteFetchRateLimit = app.createRateLimit({
max: 30,
timeWindow: '10 minutes',
keyGenerator: (request) => userRouteRateLimitKey('fetch', request)
});
const userRateLimitPolicies: Record<RateLimitPolicy, RateLimitCheck[]> = {
accountWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 20,
timeWindow: '1 hour',
keyGenerator: (request) => `account-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '5 seconds',
keyGenerator: (request) => `account-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
adminWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 120,
timeWindow: '1 hour',
keyGenerator: (request) => `admin-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '2 seconds',
keyGenerator: (request) => `admin-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
communityReaction: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 120,
timeWindow: '1 hour',
keyGenerator: (request) => `community-reaction:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '1 second',
keyGenerator: (request) => `community-reaction-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
communityWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 60,
timeWindow: '1 hour',
keyGenerator: (request) => `community-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '5 seconds',
keyGenerator: (request) => `community-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
fetch: [
fetchRouteIpRateLimit,
app.createRateLimit({
max: 60,
timeWindow: '10 minutes',
keyGenerator: (request) => `fetch:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '1 second',
keyGenerator: (request) => `fetch-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteFetchRateLimit
],
upload: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 20,
timeWindow: '1 hour',
keyGenerator: (request) => `upload:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '30 seconds',
keyGenerator: (request) => `upload-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
wikiWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 120,
timeWindow: '1 hour',
keyGenerator: (request) => `wiki-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '2 seconds',
keyGenerator: (request) => `wiki-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
]
const rateLimitPolicyKeys: RateLimitPolicy[] = [
'accountWrite',
'adminWrite',
'communityReaction',
'communityWrite',
'fetch',
'upload',
'wikiWrite'
];
const defaultUserRateLimitSettings: RateLimitPolicySettingsMap = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
communityReaction: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 1 },
communityWrite: { maxRequests: 60, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
fetch: { maxRequests: 60, timeWindowSeconds: 10 * 60, cooldownSeconds: 1 },
upload: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 30 },
wikiWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }
};
const rateLimitSettingsCacheTtlMs = 30_000;
const minRateLimitWindowSeconds = 60;
const maxRateLimitWindowSeconds = 24 * 60 * 60;
const maxRateLimitRequests = 5_000;
const maxRateLimitCooldownSeconds = 60 * 60;
let rateLimitSettingsCache: { settings: RateLimitPolicySettingsMap; expiresAt: number } | null = null;
let lastRateLimitSweepAt = 0;
const userRateLimitWindows = new Map<string, { count: number; resetAt: number }>();
const userRateLimitCooldowns = new Map<string, number>();
function cloneRateLimitSettings(settings: RateLimitPolicySettingsMap): RateLimitPolicySettingsMap {
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [policy, { ...settings[policy] }])
) as RateLimitPolicySettingsMap;
}
function cleanRateLimitInteger(value: unknown, fallback: number, min: number, max: number): number {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
return fallback;
}
return numeric;
}
function cleanRateLimitPolicySettings(value: unknown, fallback: RateLimitPolicySettings): RateLimitPolicySettings {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
maxRequests: cleanRateLimitInteger(raw.maxRequests, fallback.maxRequests, 1, maxRateLimitRequests),
timeWindowSeconds: cleanRateLimitInteger(
raw.timeWindowSeconds,
fallback.timeWindowSeconds,
minRateLimitWindowSeconds,
maxRateLimitWindowSeconds
),
cooldownSeconds: cleanRateLimitInteger(raw.cooldownSeconds, fallback.cooldownSeconds, 0, maxRateLimitCooldownSeconds)
};
}
function normalizeRateLimitSettings(value: unknown): RateLimitPolicySettingsMap {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
cleanRateLimitPolicySettings(raw[policy], defaultUserRateLimitSettings[policy])
])
) as RateLimitPolicySettingsMap;
}
function publicRateLimitSettings(row: RateLimitSettingsRow | null, settings: RateLimitPolicySettingsMap): PublicRateLimitSettings {
return {
policies: cloneRateLimitSettings(settings),
updatedAt: row?.updatedAt ?? null,
updatedBy: row?.updatedBy ?? null
};
}
async function rateLimitSettingsRow(): Promise<RateLimitSettingsRow | null> {
const result = await pool.query<RateLimitSettingsRow>(
`
SELECT
s.settings,
s.updated_at AS "updatedAt",
CASE
WHEN updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
END AS "updatedBy"
FROM rate_limit_settings s
LEFT JOIN users updated_user ON updated_user.id = s.updated_by_user_id
WHERE s.id = true
`
);
return result.rows[0] ?? null;
}
async function runtimeRateLimitSettings(): Promise<RateLimitPolicySettingsMap> {
const now = Date.now();
if (rateLimitSettingsCache && rateLimitSettingsCache.expiresAt > now) {
return rateLimitSettingsCache.settings;
}
const row = await rateLimitSettingsRow();
const settings = normalizeRateLimitSettings(row?.settings);
rateLimitSettingsCache = { settings, expiresAt: now + rateLimitSettingsCacheTtlMs };
return settings;
}
async function getRateLimitSettings(): Promise<PublicRateLimitSettings> {
const row = await rateLimitSettingsRow();
return publicRateLimitSettings(row, normalizeRateLimitSettings(row?.settings));
}
async function updateRateLimitSettings(payload: Record<string, unknown>, userId: number): Promise<PublicRateLimitSettings> {
const policies = payload.policies && typeof payload.policies === 'object' ? payload.policies : payload;
const settings = normalizeRateLimitSettings(policies);
await pool.query(
`
INSERT INTO rate_limit_settings (id, settings, updated_by_user_id, updated_at)
VALUES (true, $1::jsonb, $2, now())
ON CONFLICT (id)
DO UPDATE SET settings = EXCLUDED.settings,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()
`,
[JSON.stringify(settings), userId]
);
rateLimitSettingsCache = { settings, expiresAt: Date.now() + rateLimitSettingsCacheTtlMs };
return getRateLimitSettings();
}
function sweepUserRateLimitEntries(now: number): void {
if (now - lastRateLimitSweepAt < 60_000) {
return;
}
lastRateLimitSweepAt = now;
for (const [key, bucket] of userRateLimitWindows.entries()) {
if (bucket.resetAt <= now) {
userRateLimitWindows.delete(key);
}
}
for (const [key, resetAt] of userRateLimitCooldowns.entries()) {
if (resetAt <= now) {
userRateLimitCooldowns.delete(key);
}
}
}
function checkUserPolicyRateLimit(userId: number, policy: RateLimitPolicy, settings: RateLimitPolicySettings): RateLimitFailure | null {
const now = Date.now();
sweepUserRateLimitEntries(now);
const cooldownKey = `${policy}:user:${userId}:cooldown`;
const cooldownResetAt = userRateLimitCooldowns.get(cooldownKey) ?? 0;
if (cooldownResetAt > now) {
return { max: 1, ttlInSeconds: Math.ceil((cooldownResetAt - now) / 1000) };
}
const windowKey = `${policy}:user:${userId}:window`;
const windowResetMs = settings.timeWindowSeconds * 1000;
const existingBucket = userRateLimitWindows.get(windowKey);
const bucket =
existingBucket && existingBucket.resetAt > now
? existingBucket
: { count: 0, resetAt: now + windowResetMs };
if (bucket.count >= settings.maxRequests) {
userRateLimitWindows.set(windowKey, bucket);
return { max: settings.maxRequests, ttlInSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
}
bucket.count += 1;
userRateLimitWindows.set(windowKey, bucket);
if (settings.cooldownSeconds > 0) {
userRateLimitCooldowns.set(cooldownKey, now + settings.cooldownSeconds * 1000);
}
return null;
}
async function sendRateLimited(
request: FastifyRequest,
reply: FastifyReply,
result: Extract<RateLimitResult, { isAllowed: false }>
result: RateLimitFailure
): Promise<false> {
const retryAfter = Math.max(1, result.ttlInSeconds);
reply.header('retry-after', retryAfter);
reply.header('x-ratelimit-limit', result.max);
reply.header('x-ratelimit-remaining', 0);
reply.header('x-ratelimit-reset', retryAfter);
reply.code(result.isBanned ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') });
reply.code(result.isBanned === true ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') });
return false;
}
@@ -456,8 +524,9 @@ async function enforceUserRateLimits(
user: AuthUser,
policy: RateLimitPolicy
): Promise<boolean> {
(request as RateLimitedRequest).rateLimitUserId = user.id;
return enforceRateLimits(request, reply, userRateLimitPolicies[policy]);
const settings = (await runtimeRateLimitSettings())[policy];
const result = checkUserPolicyRateLimit(user.id, policy, settings);
return result ? sendRateLimited(request, reply, result) : true;
}
function badRequest(message: string): Error & { statusCode: number } {
@@ -1450,6 +1519,19 @@ app.put('/api/admin/ai-moderation', async (request, reply) => {
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.rate-limits.read');
return user ? getRateLimitSettings() : undefined;
});
app.put('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.rate-limits.update', 'adminWrite');
if (!user) {
return;
}
return updateRateLimitSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) {
@@ -1463,7 +1545,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
});
app.post('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'adminWrite');
if (!user) {
return;
}
@@ -1477,7 +1559,7 @@ app.post('/api/admin/config/:type', async (request, reply) => {
});
app.put('/api/admin/config/:type/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'adminWrite');
if (!user) {
return;
}
@@ -1489,7 +1571,7 @@ app.put('/api/admin/config/:type/order', async (request, reply) => {
});
app.put('/api/admin/config/:type/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite');
if (!user) {
return;
}
@@ -1502,7 +1584,7 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => {
});
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'adminWrite');
if (!user) {
return;
}