From a22f08eb176989d0757930bed928556cf9d409b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:03:24 +0000 Subject: [PATCH 1/8] Initial plan From bdf85cb951db54ec4b6df5e1060bced77654e1d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:13:37 +0000 Subject: [PATCH 2/8] Add user settings sync backend (GetUserSettings / SetUserSettings) Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com> Agent-Logs-Url: https://github.com/XMOJ-Script-dev/XMOJ-bbs/sessions/2b760a63-8dcf-43de-baf9-6333ab2fabc8 --- Source/Process.ts | 50 +++++++++++++++ migrations/0004_add_user_settings.sql | 6 ++ test/process.test.js | 90 +++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 migrations/0004_add_user_settings.sql diff --git a/Source/Process.ts b/Source/Process.ts index 892c9a3..df7e68e 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -1492,6 +1492,56 @@ export class Process { const responseJSON = await response.json(); return new Result(true, "获得统计数据成功", responseJSON); }, + SetUserSettings: async (Data: object): Promise => { + ThrowErrorIfFailed(this.CheckParams(Data, { + "Settings": "string" + })); + let SettingsObject: object; + try { + SettingsObject = JSON.parse(Data["Settings"]); + } catch (_) { + return new Result(false, "设置格式有误"); + } + if (typeof SettingsObject !== "object" || Array.isArray(SettingsObject) || SettingsObject === null) { + return new Result(false, "设置格式有误"); + } + const SettingsString = Data["Settings"]; + if (ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("user_settings", { + user_id: this.Username + }))["TableSize"] === 0) { + ThrowErrorIfFailed(await this.XMOJDatabase.Insert("user_settings", { + user_id: this.Username, + settings: SettingsString + })); + } else { + ThrowErrorIfFailed(await this.XMOJDatabase.Update("user_settings", { + settings: SettingsString + }, { + user_id: this.Username + })); + } + return new Result(true, "保存设置成功"); + }, + GetUserSettings: async (Data: object): Promise => { + ThrowErrorIfFailed(this.CheckParams(Data, {})); + const SettingsData = ThrowErrorIfFailed(await this.XMOJDatabase.Select("user_settings", ["settings"], { + user_id: this.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, "设置数据损坏"); + } + return new Result(true, "获得设置成功", { + "Settings": SettingsObject + }); + }, LastOnline: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, { "Username": "string" diff --git a/migrations/0004_add_user_settings.sql b/migrations/0004_add_user_settings.sql new file mode 100644 index 0000000..6c62c06 --- /dev/null +++ b/migrations/0004_add_user_settings.sql @@ -0,0 +1,6 @@ +-- Migration number: 0004 2026-03-22 + +CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY NOT NULL, + settings TEXT NOT NULL DEFAULT '{}' +); diff --git a/test/process.test.js b/test/process.test.js index 58cf9d4..6c50bf5 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -399,3 +399,93 @@ test('AddBBSMention pushes full mention payload for websocket clients', async () }); assert.strictEqual(typeof pushedBody.notification.data.MentionTime, 'number'); }); + +test('SetUserSettings creates new settings row when none exists', async () => { + const insertedRows = []; + const proc = createProcess({ + db: { + GetTableSize: async () => new Result(true, '', { TableSize: 0 }), + Insert: async (table, data) => { + insertedRows.push({ table, data }); + return new Result(true, '', { InsertID: 1 }); + } + } + }); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: '{"Discussion":"true","Theme":"dark"}' }); + assert.ok(result.Success); + assert.strictEqual(result.Message, '保存设置成功'); + assert.strictEqual(insertedRows.length, 1); + assert.strictEqual(insertedRows[0].table, 'user_settings'); + assert.strictEqual(insertedRows[0].data.user_id, 'testuser'); + assert.strictEqual(insertedRows[0].data.settings, '{"Discussion":"true","Theme":"dark"}'); +}); + +test('SetUserSettings updates existing settings row', async () => { + const updatedRows = []; + const proc = createProcess({ + db: { + GetTableSize: async () => new Result(true, '', { TableSize: 1 }), + Update: async (table, data, where) => { + updatedRows.push({ table, data, where }); + return new Result(true, ''); + } + } + }); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: '{"Discussion":"false"}' }); + assert.ok(result.Success); + assert.strictEqual(result.Message, '保存设置成功'); + assert.strictEqual(updatedRows.length, 1); + assert.strictEqual(updatedRows[0].table, 'user_settings'); + assert.strictEqual(updatedRows[0].data.settings, '{"Discussion":"false"}'); + assert.deepStrictEqual(updatedRows[0].where, { user_id: 'testuser' }); +}); + +test('SetUserSettings fails with invalid JSON', async () => { + const proc = createProcess(); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: 'not-json' }); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置格式有误'); +}); + +test('SetUserSettings fails when Settings is an array', async () => { + const proc = createProcess(); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: '["a","b"]' }); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置格式有误'); +}); + +test('GetUserSettings returns empty object when no settings stored', async () => { + const proc = createProcess({ + db: { + Select: async () => new Result(true, '', []) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.ok(result.Success); + assert.strictEqual(result.Message, '获得设置成功'); + assert.deepStrictEqual(result.Data.Settings, {}); +}); + +test('GetUserSettings returns stored settings', async () => { + const proc = createProcess({ + db: { + Select: async () => new Result(true, '', [{ settings: '{"Discussion":"true","Theme":"dark"}' }]) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.ok(result.Success); + assert.strictEqual(result.Message, '获得设置成功'); + assert.deepStrictEqual(result.Data.Settings, { Discussion: 'true', Theme: 'dark' }); +}); + +test('GetUserSettings fails when stored settings JSON is corrupted', async () => { + const proc = createProcess({ + db: { + Select: async () => new Result(true, '', [{ settings: 'corrupted{json' }]) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置数据损坏'); +}); + From 179e6d0a913690f92d886044407926645eddc260 Mon Sep 17 00:00:00 2001 From: Shan Wenxiao Date: Sun, 22 Mar 2026 22:21:44 +0800 Subject: [PATCH 3/8] fix race conditions Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shan Wenxiao --- Source/Process.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index df7e68e..78f6de8 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -1506,19 +1506,25 @@ export class Process { return new Result(false, "设置格式有误"); } const SettingsString = Data["Settings"]; - if (ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("user_settings", { - user_id: this.Username - }))["TableSize"] === 0) { + try { + // Try to insert first. If a unique/primary key constraint is hit (row already exists), + // fall back to updating the existing row. This avoids a non-atomic check-then-insert flow. ThrowErrorIfFailed(await this.XMOJDatabase.Insert("user_settings", { user_id: this.Username, settings: SettingsString })); - } else { - ThrowErrorIfFailed(await this.XMOJDatabase.Update("user_settings", { - settings: SettingsString - }, { - user_id: this.Username - })); + } catch (e) { + if (e instanceof Error && /UNIQUE|constraint|duplicate/i.test(e.message)) { + // Row for this user_id already exists, perform an update instead. + ThrowErrorIfFailed(await this.XMOJDatabase.Update("user_settings", { + settings: SettingsString + }, { + user_id: this.Username + })); + } else { + // Propagate non-uniqueness errors. + throw e; + } } return new Result(true, "保存设置成功"); }, From aee704def65122ff95dd0477477cd19509b5076b Mon Sep 17 00:00:00 2001 From: Shan Wenxiao Date: Sun, 22 Mar 2026 22:22:44 +0800 Subject: [PATCH 4/8] length limit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shan Wenxiao --- Source/Process.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index 78f6de8..9977080 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -1493,19 +1493,31 @@ export class Process { return new Result(true, "获得统计数据成功", responseJSON); }, SetUserSettings: async (Data: object): Promise => { + // Enforce a maximum allowed size for the settings payload to avoid + // excessively large entries being written to D1. + const MAX_SETTINGS_LENGTH = 10000; + ThrowErrorIfFailed(this.CheckParams(Data, { "Settings": "string" })); + + const SettingsString = Data["Settings"]; + if (typeof SettingsString !== "string") { + return new Result(false, "设置格式有误"); + } + if (SettingsString.length > MAX_SETTINGS_LENGTH) { + return new Result(false, "设置内容过大"); + } + let SettingsObject: object; try { - SettingsObject = JSON.parse(Data["Settings"]); + SettingsObject = JSON.parse(SettingsString); } catch (_) { return new Result(false, "设置格式有误"); } if (typeof SettingsObject !== "object" || Array.isArray(SettingsObject) || SettingsObject === null) { return new Result(false, "设置格式有误"); } - const SettingsString = Data["Settings"]; try { // Try to insert first. If a unique/primary key constraint is hit (row already exists), // fall back to updating the existing row. This avoids a non-atomic check-then-insert flow. From 4f9f8560202b961e1f13fc7c33361841d5cc9a0e Mon Sep 17 00:00:00 2001 From: Shan Wenxiao Date: Sun, 22 Mar 2026 22:23:11 +0800 Subject: [PATCH 5/8] limit style Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shan Wenxiao --- Source/Process.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Source/Process.ts b/Source/Process.ts index 9977080..ac57425 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -1556,6 +1556,9 @@ export class Process { } catch (_) { return new Result(false, "设置数据损坏"); } + if (typeof SettingsObject !== "object" || Array.isArray(SettingsObject) || SettingsObject === null) { + return new Result(false, "设置数据损坏"); + } return new Result(true, "获得设置成功", { "Settings": SettingsObject }); From aa33c016a9f4b1b56d42b85de20e6fc9ccd39408 Mon Sep 17 00:00:00 2001 From: Shan Wenxiao Date: Sun, 22 Mar 2026 22:23:40 +0800 Subject: [PATCH 6/8] better test Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shan Wenxiao --- test/process.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/process.test.js b/test/process.test.js index 6c50bf5..e8a6f70 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -489,3 +489,14 @@ test('GetUserSettings fails when stored settings JSON is corrupted', async () => assert.strictEqual(result.Message, '设置数据损坏'); }); +test('GetUserSettings fails when stored settings JSON is valid but not an object', async () => { + const proc = createProcess({ + db: { + // settings is valid JSON (an array) but not an object + Select: async () => new Result(true, '', [{ settings: '["a","b"]' }]) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置数据损坏'); +}); From 791dc79c6c7f2c63f2ef4010830856f491037070 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:30:56 +0000 Subject: [PATCH 7/8] Fix SetUserSettings test to match insert-first upsert pattern Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com> Agent-Logs-Url: https://github.com/XMOJ-Script-dev/XMOJ-bbs/sessions/57c977ec-c427-4454-ab97-b298830f3cd3 --- test/process.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/process.test.js b/test/process.test.js index e8a6f70..1ada256 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -424,7 +424,10 @@ test('SetUserSettings updates existing settings row', async () => { const updatedRows = []; const proc = createProcess({ db: { - GetTableSize: async () => new Result(true, '', { TableSize: 1 }), + Insert: async () => { + // Simulate a primary key conflict (row already exists) + throw new Error('UNIQUE constraint failed: user_settings.user_id'); + }, Update: async (table, data, where) => { updatedRows.push({ table, data, where }); return new Result(true, ''); From 267d528c51e7c1fe9a5a9aa44cddd6da1b76b81f Mon Sep 17 00:00:00 2001 From: Shan Wenxiao Date: Sat, 28 Mar 2026 09:31:23 +0800 Subject: [PATCH 8/8] fix duplicate-key fallback Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Signed-off-by: Shan Wenxiao --- Source/Process.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Process.ts b/Source/Process.ts index ac57425..38fc031 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -1526,7 +1526,8 @@ export class Process { settings: SettingsString })); } catch (e) { - if (e instanceof Error && /UNIQUE|constraint|duplicate/i.test(e.message)) { + const errorMessage = e instanceof Error ? e.message : (typeof e === "object" && e !== null && "Message" in e ? String((e as { Message?: unknown }).Message) : String(e)); + if (/UNIQUE|constraint|duplicate/i.test(errorMessage)) { // Row for this user_id already exists, perform an update instead. ThrowErrorIfFailed(await this.XMOJDatabase.Update("user_settings", { settings: SettingsString