diff --git a/package-lock.json b/package-lock.json index ce51c3d..e109f00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,7 @@ "cheerio": "^1.1.2", "crypto-js": "^4.2.0", "h3": "^1.12.0", - "sanitize-html": "^2.13.0", - "sqlstring": "^2.3.3" + "sanitize-html": "^2.13.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240903.0", @@ -6720,15 +6719,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/server/middleware/0.rate-limit.ts b/server/middleware/0.rate-limit.ts deleted file mode 100644 index 58a5018..0000000 --- a/server/middleware/0.rate-limit.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* Auto-mounted rate limiter: runs before 1.auth.ts */ -declare const defineEventHandler: any; -declare function readBody(event: any): Promise; -declare function createError(options: { statusCode: number; message: string }): any; - -const CAPACITY = 10; // max 10 ops (reduced from 30) -const REFILL_PER_SEC = 2; // 2 tokens per second (reduced from 10) -// Use KV for distributed rate limiting when available -type KVBinding = { get: (key: string) => Promise; put: (key: string, value: string, options?: any) => Promise }; - -export default defineEventHandler(async (event: any) => { - if (event.method !== 'POST') return; - // Rate limit by IP address to prevent per-user DoS attacks - const ip = event.node?.req?.headers?.['cf-connecting-ip']; - if (!ip) { - throw createError({ statusCode: 429, message: '无法确定请求来源,请求被拒绝' }); - } - const key = ip; - const now = Date.now(); - const kv: KVBinding | undefined = event.context?.cloudflare?.env?.RATE_LIMIT_KV; - if (kv) { - const raw = await kv.get(`rl:${key}`); - const state = raw ? JSON.parse(raw) as { tokens: number; last: number } : { tokens: CAPACITY, last: now }; - const elapsedSec = (now - state.last) / 1000; - state.tokens = Math.min(CAPACITY, state.tokens + elapsedSec * REFILL_PER_SEC); - state.last = now; - if (state.tokens < 1) { - throw createError({ statusCode: 429, message: '请求过于频繁,请稍后重试' }); - } - state.tokens -= 1; - await kv.put(`rl:${key}`, JSON.stringify(state), { expirationTtl: 300 }); // 5 min TTL - return; - } - // Fallback: global in-memory token bucket without timers; cleaned on access - // WARNING: In-memory storage only works within a single Cloudflare isolate. - // Each cold-start or isolate change resets the bucket. Deploy with RATE_LIMIT_KV for distributed protection. - const globalBuckets: Map = (globalThis as any).__rlBuckets || ((globalThis as any).__rlBuckets = new Map()); - if (!(globalThis as any).__rlBucketsWarningLogged && !kv) { - // Log warning once to avoid spam - (globalThis as any).__rlBucketsWarningLogged = true; - console.warn('RATE LIMITING WARNING: No KV binding available. Rate limiting using in-memory storage will not work across isolates.'); - } - const TTL_MS = 5 * 60 * 1000; - // Opportunistically clean up only the accessed key if stale - let st = globalBuckets.get(key); - if (st && (now - st.last > TTL_MS)) { - globalBuckets.delete(key); - st = undefined; - } - st = st || { tokens: CAPACITY, last: now }; - const elapsed = (now - st.last) / 1000; - st.tokens = Math.min(CAPACITY, st.tokens + elapsed * REFILL_PER_SEC); - st.last = now; - if (st.tokens < 1) { - throw createError({ statusCode: 429, message: '请求过于频繁,请稍后重试' }); - } - st.tokens -= 1; - globalBuckets.set(key, st); -}); diff --git a/server/plugins/scheduled.ts b/server/plugins/scheduled.ts index 1898bb1..03f4cf8 100644 --- a/server/plugins/scheduled.ts +++ b/server/plugins/scheduled.ts @@ -16,6 +16,7 @@ */ import { Database } from "~/utils/database"; +import { Output } from "~/utils/output"; // Time constants matching auth.ts const MILLISECONDS_PER_SECOND = 1000; @@ -31,7 +32,7 @@ export default defineNitroPlugin((nitroApp: any) => { const { env, context } = event; let XMOJDatabase = new Database(env.DB); - context.waitUntil((async () => { + const cleanup = async () => { await XMOJDatabase.Delete("short_message", { "send_time": { "Operator": "<=", @@ -48,6 +49,10 @@ export default defineNitroPlugin((nitroApp: any) => { "Value": new Date().getTime() - SESSION_EXPIRY_MS } }); - })()); + }; + + context.waitUntil(cleanup().catch((err: any) => { + Output.Error("Scheduled cleanup failed: " + (err?.message || String(err))); + })); }); }); diff --git a/server/routes/DeletePost.ts b/server/routes/DeletePost.ts index e5f5c3f..35f6810 100644 --- a/server/routes/DeletePost.ts +++ b/server/routes/DeletePost.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; import { CheckParams } from "~/utils/checkParams"; import { IsAdminAsync } from "~/utils/auth"; diff --git a/server/routes/EditBadge.ts b/server/routes/EditBadge.ts index fdd5149..369577c 100644 --- a/server/routes/EditBadge.ts +++ b/server/routes/EditBadge.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; import { CheckParams } from "~/utils/checkParams"; import { IsAdminAsync, DenyEditAsync } from "~/utils/auth"; diff --git a/server/routes/EditReply.ts b/server/routes/EditReply.ts index a33cdbb..265a56f 100644 --- a/server/routes/EditReply.ts +++ b/server/routes/EditReply.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; import { CheckParams } from "~/utils/checkParams"; import { IsAdminAsync, IsSilencedAsync } from "~/utils/auth"; diff --git a/server/routes/GetBBSMentionList.ts b/server/routes/GetBBSMentionList.ts index cac4f52..22b7b2f 100644 --- a/server/routes/GetBBSMentionList.ts +++ b/server/routes/GetBBSMentionList.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; export default eventHandler(async (event) => { diff --git a/server/routes/GetBadge.ts b/server/routes/GetBadge.ts index 2a20148..d5cac0f 100644 --- a/server/routes/GetBadge.ts +++ b/server/routes/GetBadge.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; import { CheckParams } from "~/utils/checkParams"; diff --git a/server/routes/GetBoards.ts b/server/routes/GetBoards.ts index f500c45..63d956c 100644 --- a/server/routes/GetBoards.ts +++ b/server/routes/GetBoards.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; const DEFAULT_LIMIT = 50; diff --git a/server/routes/GetMail.ts b/server/routes/GetMail.ts index 0f321ee..ac0a42a 100644 --- a/server/routes/GetMail.ts +++ b/server/routes/GetMail.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; import { CheckParams } from "~/utils/checkParams"; import { sanitizeRichText } from "~/utils/sanitize"; diff --git a/server/routes/GetMailList.ts b/server/routes/GetMailList.ts index af4198d..d9cb03d 100644 --- a/server/routes/GetMailList.ts +++ b/server/routes/GetMailList.ts @@ -1,4 +1,20 @@ -/* Copyright header omitted */ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; import { decryptMessage } from "~/utils/messageEncryption"; import CryptoJS from "crypto-js"; diff --git a/server/routes/GetUserSettings.ts b/server/routes/GetUserSettings.ts new file mode 100644 index 0000000..5e3eb1e --- /dev/null +++ b/server/routes/GetUserSettings.ts @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + +import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; +import { Output } from "~/utils/output"; + +export default eventHandler(async (event) => { + try { + const { auth } = event.context; + + const SettingsData: any[] = ThrowErrorIfFailed( + await auth.database.Select("user_settings", ["settings"], { + user_id: auth.username + }) + ); + + if (SettingsData.length === 0) { + return new Result(true, "获得设置成功", { Settings: {} }); + } + + let SettingsObject: object; + try { + SettingsObject = JSON.parse(SettingsData[0]["settings"]); + } catch (_) { + return new Result(false, "设置数据损坏"); + } + if (typeof SettingsObject !== "object" || Array.isArray(SettingsObject) || SettingsObject === null) { + return new Result(false, "设置数据损坏"); + } + + return new Result(true, "获得设置成功", { Settings: SettingsObject }); + } catch (error) { + if (error instanceof Result) return error; + const errorMsg = error instanceof Error ? error.message : String(error); + Output.Error("GetUserSettings error: " + errorMsg); + return new Result(false, "获得设置失败: " + errorMsg); + } +}); diff --git a/server/routes/SetUserSettings.ts b/server/routes/SetUserSettings.ts new file mode 100644 index 0000000..1576cd9 --- /dev/null +++ b/server/routes/SetUserSettings.ts @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + +import { Result, ThrowErrorIfFailed } from "~/utils/resultUtils"; +import { CheckParams } from "~/utils/checkParams"; +import { Output } from "~/utils/output"; + +const MAX_SETTINGS_LENGTH = 10000; + +export default eventHandler(async (event) => { + try { + const { auth } = event.context; + const body = await readBody(event); + const { Data } = body || {}; + + ThrowErrorIfFailed(CheckParams(Data, { + "Settings": "string" + })); + + const SettingsString: string = Data["Settings"]; + if (SettingsString.length > MAX_SETTINGS_LENGTH) { + return new Result(false, "设置内容过大"); + } + + let SettingsObject: object; + try { + SettingsObject = JSON.parse(SettingsString); + } catch (_) { + return new Result(false, "设置格式有误"); + } + if (typeof SettingsObject !== "object" || Array.isArray(SettingsObject) || SettingsObject === null) { + return new Result(false, "设置格式有误"); + } + + const existingSize = ThrowErrorIfFailed( + await auth.database.GetTableSize("user_settings", { user_id: auth.username }) + )["TableSize"]; + + if (existingSize === 0) { + ThrowErrorIfFailed(await auth.database.Insert("user_settings", { + user_id: auth.username, + settings: SettingsString + })); + } else { + ThrowErrorIfFailed(await auth.database.Update("user_settings", { + settings: SettingsString + }, { + user_id: auth.username + })); + } + + return new Result(true, "保存设置成功"); + } catch (error) { + if (error instanceof Result) return error; + const errorMsg = error instanceof Error ? error.message : String(error); + Output.Error("SetUserSettings error: " + errorMsg); + return new Result(false, "保存设置失败: " + errorMsg); + } +}); diff --git a/server/utils/database.ts b/server/utils/database.ts index 5e5c1d9..9799c29 100644 --- a/server/utils/database.ts +++ b/server/utils/database.ts @@ -26,7 +26,8 @@ let readonly = false; // set to true to allow maintenance const ALLOWED_TABLES = [ 'bbs_post', 'bbs_reply', 'bbs_board', 'bbs_mention', 'bbs_lock', 'badge', 'phpsessid', 'mail', 'image', 'std', 'std_answer', 'short_message', - 'bbs_admin', 'bbs_silenced', 'bbs_deny_message', 'bbs_deny_badge_edit', 'short_message_mention' + 'bbs_admin', 'bbs_silenced', 'bbs_deny_message', 'bbs_deny_badge_edit', 'short_message_mention', + 'user_settings' ]; const ALLOWED_COLUMNS: Record = { @@ -46,7 +47,8 @@ const ALLOWED_COLUMNS: Record = { 'bbs_silenced': ['user_id', 'silenced_until'], 'bbs_deny_message': ['user_id'], 'bbs_deny_badge_edit': ['user_id'], - 'short_message_mention': ['mail_mention_id', 'message_id', 'to_user_id', 'from_user_id', 'mail_mention_time'] + 'short_message_mention': ['mail_mention_id', 'message_id', 'to_user_id', 'from_user_id', 'mail_mention_time'], + 'user_settings': ['user_id', 'settings'] }; function validateTableName(table: string): void {