From 365f11990da64f2b4c4c0074a3e681b19f377863 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 16:48:48 +0800 Subject: [PATCH 01/21] Authenticate to the backend with our own session token Swap the PHPSESSID for a backend-issued token once, kept in GM storage, and send only the token after that. The PHPSESSID is sent again only when the backend asks for it (SessionRequired), on re-exchange, or when the notification socket connects before we have a token, in which case the socket fetches one for everyone. Concurrent requests share one exchange; an invalid token is replaced transparently; logging out revokes it. Needs XMOJ-Script-dev/XMOJ-bbs#75 deployed first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 320 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 232 insertions(+), 88 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index e46a9b37..f9d8e81d 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1851,6 +1851,136 @@ let clearCredential = async () => { } } }; +// Backend auth. We swap the xmoj PHPSESSID for a token of our own once, so +// requests don't hand the backend the user's cookie or make it check that +// cookie against xmoj, which is slow. The token stands for the user, not +// for the PHPSESSID, so it survives xmoj's daily logouts and our auto-login. +// It lives in GM storage, which the page itself can't read. +let BackendTokenRequest = null; +let GetPHPSESSID = () => { + let Session = ""; + let Temp = document.cookie.split(";"); + for (let i = 0; i < Temp.length; i++) { + if (Temp[i].includes("PHPSESSID")) { + Session = Temp[i].split("=")[1]; + break; + } + } + return Session; +}; +let GetPHPSESSIDOrReset = () => { + let Session = GetPHPSESSID(); + if (Session === "") { //The cookie is httpOnly + GM.cookie.set({ + name: 'PHPSESSID', + value: (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)).substring(0, 28), + path: "/" + }) + .then(() => { + console.log('Reset PHPSESSID successfully.'); + location.reload(); //Refresh the page to auth with the new PHPSESSID + }) + .catch((error) => { + console.error(error); + }); + } + return Session; +}; +let GetStoredBackendToken = () => { + let Stored = GM_getValue("BackendToken", null); + if (Stored && Stored.Token && CurrentUsername && Stored.Username === CurrentUsername) { + return Stored.Token; + } + return ""; +}; +let StoreBackendToken = (Token) => { + GM_setValue("BackendToken", Token ? {"Token": Token, "Username": CurrentUsername} : null); +}; +// The one place requests reach the backend. OnResult gets the parsed JSON, +// OnFail a message for the user. +let PostAPI = (Action, Authentication, Data, Timeout, StrictStatus, OnResult, OnFail) => { + let DataString = JSON.stringify({ + "Authentication": Authentication, "Data": Data, "Version": GM_info.script.version, "DebugMode": UtilityEnabled("DebugMode") + }); + if (UtilityEnabled("DebugMode")) { + console.log("Sent for", Action + ":", DataString); + } + GM_xmlhttpRequest({ + method: "POST", + url: (UtilityEnabled("SuperDebug") ? "http://127.0.0.1:8787/" : "https://api.xmoj-script.uk/") + Action, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + "XMOJ-UserID": CurrentUsername, + "XMOJ-Script-Version": GM_info.script.version, + "DebugMode": UtilityEnabled("DebugMode") + }, + data: DataString, + timeout: Timeout, + onerror: () => OnFail("网络错误,请重试"), + ontimeout: () => OnFail("请求超时,请重试"), + onabort: () => OnFail("请求已取消,请重试"), + onload: (Response) => { + if (UtilityEnabled("DebugMode")) { + console.log("Received for", Action + ":", Response.responseText); + } + if (StrictStatus && (Response.status < 200 || Response.status >= 300)) { + OnFail("请求失败(HTTP " + Response.status + "),请重试"); + return; + } + let result; + try { + result = JSON.parse(Response.responseText); + } catch (Error) { + console.log(Response.responseText); + OnFail("服务器响应异常,请重试"); + return; + } + OnResult(result); + } + }); +}; +let ExchangeBackendToken = () => { + return new Promise((resolve, reject) => { + if (!CurrentUsername) { + reject("用户未登录"); + return; + } + let Session = GetPHPSESSIDOrReset(); + if (Session === "") { + reject("用户未登录"); + return; + } + PostAPI("Login", {"SessionID": Session, "Username": CurrentUsername}, {}, 15000, true, (Response) => { + if (Response.Success) { + StoreBackendToken(Response.Data.Token); + resolve(Response.Data.Token); + } else { + reject(Response.Message); + } + }, reject); + }); +}; +// Resolves to our token, exchanging the PHPSESSID for one if we have none. +// Everyone waiting at once shares one exchange, including one the +// notification socket is doing; if that one comes back empty, we fall +// back to Login. +let GetBackendToken = () => { + let Token = GetStoredBackendToken(); + if (Token !== "") { + return Promise.resolve(Token); + } + if (BackendTokenRequest) { + return BackendTokenRequest.then((Token) => Token !== "" ? Token : GetBackendToken()); + } + BackendTokenRequest = ExchangeBackendToken().finally(() => { + BackendTokenRequest = null; + }); + return BackendTokenRequest; +}; +// These are answered before the backend looks at Authentication, and are +// needed on pages where the user isn't logged in. +const UnauthenticatedActions = ["GetNotice", "GetAddOnScript"]; let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { let completed = false; let Fail = (message) => { @@ -1859,78 +1989,43 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { if (ErrorCallBack) ErrorCallBack(message); else console.error("[XMOJ-Script] Request " + Action + ": " + message); }; - try { - let Session = ""; - let Temp = document.cookie.split(";"); - for (let i = 0; i < Temp.length; i++) { - if (Temp[i].includes("PHPSESSID")) { - Session = Temp[i].split("=")[1]; - } - } - if (Session === "") { //The cookie is httpOnly - GM.cookie.set({ - name: 'PHPSESSID', - value: (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)).substring(0, 28), - path: "/" - }) - .then(() => { - console.log('Reset PHPSESSID successfully.'); - location.reload(); //Refresh the page to auth with the new PHPSESSID - }) - .catch((error) => { - console.error(error); - }); - } - let PostData = { - "Authentication": { - "SessionID": Session, "Username": CurrentUsername, - }, "Data": Data, "Version": GM_info.script.version, "DebugMode": UtilityEnabled("DebugMode") - }; - let DataString = JSON.stringify(PostData); - if (UtilityEnabled("DebugMode")) { - console.log("Sent for", Action + ":", DataString); - } - GM_xmlhttpRequest({ - method: "POST", - url: (UtilityEnabled("SuperDebug") ? "http://127.0.0.1:8787/" : "https://api.xmoj-script.uk/") + Action, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache", - "XMOJ-UserID": CurrentUsername, - "XMOJ-Script-Version": GM_info.script.version, - "DebugMode": UtilityEnabled("DebugMode") - }, - data: DataString, - // Preserve the stashed general request limit and faster badge retries. - timeout: ErrorCallBack ? 15000 : 30000, - onerror: () => Fail("网络错误,请重试"), - ontimeout: () => Fail("请求超时,请重试"), - onabort: () => Fail("请求已取消,请重试"), - onload: (Response) => { - if (completed) return; - if (UtilityEnabled("DebugMode")) { - console.log("Received for", Action + ":", Response.responseText); - } - if (ErrorCallBack && (Response.status < 200 || Response.status >= 300)) { - Fail("请求失败(HTTP " + Response.status + "),请重试"); + let Timeout = ErrorCallBack ? 15000 : 30000; + let Send = (Authentication, Retried) => { + PostAPI(Action, Authentication, Data, Timeout, !!ErrorCallBack, (result) => { + if (completed) return; + if (!result.Success && result.Data) { + // The backend forgot the token (expired, or logged out elsewhere). + if (result.Data.TokenInvalid && !Retried.Token) { + StoreBackendToken(""); + GetBackendToken().then((Token) => Send({"Token": Token}, {...Retried, Token: true}), Fail); return; } - let result; - try { - result = JSON.parse(Response.responseText); - } catch (Error) { - console.log(Response.responseText); - Fail("服务器响应异常,请重试"); + // This request has to read xmoj as us, so it needs the cookie + // after all. Only these requests ever get it. + if (result.Data.SessionRequired && !Retried.Session) { + let Session = GetPHPSESSIDOrReset(); + if (Session === "") { + Fail("用户未登录"); + return; + } + Send({"Token": Authentication.Token, "SessionID": Session}, {...Retried, Session: true}); return; } - completed = true; - try { - if (CallBack) CallBack(result); - } catch (Error) { - console.error(Error); - } } - }); + completed = true; + try { + if (CallBack) CallBack(result); + } catch (Error) { + console.error(Error); + } + }, Fail); + }; + try { + if (UnauthenticatedActions.includes(Action)) { + Send({}, {Token: true, Session: true}); + return; + } + GetBackendToken().then((Token) => Send({"Token": Token}, {}), Fail); } catch (e) { Fail("请求失败,请重试"); console.error(e); @@ -1939,6 +2034,17 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { } } }; +// Forgets the token here and on the backend. Never trades the PHPSESSID for +// a token just to throw it away. +let RevokeBackendToken = (CallBack) => { + let Token = GetStoredBackendToken(); + StoreBackendToken(""); + if (Token === "") { + CallBack(); + return; + } + PostAPI("Logout", {"Token": Token}, {}, 2000, false, () => CallBack(), () => CallBack()); +}; let SyncSettingsToCloud = (CallBack) => { if (!CurrentUsername) { if (CallBack) CallBack({ Success: false, Message: "用户未登录" }); @@ -1999,17 +2105,7 @@ let NotificationSocketReconnectDelay = 1000; let NotificationSocketPingInterval = null; let NotificationSocketReconnectTimer = null; -function GetPHPSESSID() { - let Session = ""; - let Temp = document.cookie.split(";"); - for (let i = 0; i < Temp.length; i++) { - if (Temp[i].includes("PHPSESSID")) { - Session = Temp[i].split("=")[1]; - break; - } - } - return Session; -} +let NotificationSocketTokenFailures = 0; function ConnectNotificationSocket() { try { @@ -2019,23 +2115,46 @@ function ConnectNotificationSocket() { NotificationSocketReconnectTimer = null; } - let Session = GetPHPSESSID(); - if (Session === "") { - if (UtilityEnabled("DebugMode")) { - console.log("WebSocket: PHPSESSID not available, skipping connection"); - } + let Token = GetStoredBackendToken(); + if (Token === "" && BackendTokenRequest) { + // Someone is already getting us a token; connect with it instead of + // minting a second one. + BackendTokenRequest.then(() => ConnectNotificationSocket(), () => ConnectNotificationSocket()); return; } - - let wsUrl = (UtilityEnabled("SuperDebug") ? "ws://127.0.0.1:8787" : "wss://api.xmoj-script.uk") + "/ws/notifications?SessionID=" + Session; + let wsUrl = (UtilityEnabled("SuperDebug") ? "ws://127.0.0.1:8787" : "wss://api.xmoj-script.uk") + "/ws/notifications?"; + let ResolveMintedToken = null; + if (Token !== "") { + wsUrl += "Token=" + Token; + } else { + let Session = GetPHPSESSID(); + if (Session === "") { + if (UtilityEnabled("DebugMode")) { + console.log("WebSocket: PHPSESSID not available, skipping connection"); + } + return; + } + // No token yet: the socket gets one for everyone. Requests made + // meanwhile wait for it rather than running their own Login. + wsUrl += "SessionID=" + Session + "&IssueToken=1"; + BackendTokenRequest = new Promise((resolve) => { + ResolveMintedToken = resolve; + setTimeout(() => resolve(""), 15000); + }).finally(() => { + BackendTokenRequest = null; + }); + } if (UtilityEnabled("DebugMode")) { - console.log("WebSocket: Connecting to", wsUrl); + console.log("WebSocket: Connecting", Token !== "" ? "with token" : "with PHPSESSID"); } + let Opened = false; NotificationSocket = new WebSocket(wsUrl); NotificationSocket.onopen = () => { + Opened = true; + NotificationSocketTokenFailures = 0; if (UtilityEnabled("DebugMode")) { console.log("WebSocket: Connected successfully"); } @@ -2059,7 +2178,12 @@ function ConnectNotificationSocket() { }; NotificationSocket.onmessage = (event) => { - HandleNotificationMessage(event); + let notification = HandleNotificationMessage(event); + // An older backend sends no token; resolve anyway so nobody waits + // out the timeout before falling back to Login. + if (ResolveMintedToken && notification && notification.type === 'connected') { + ResolveMintedToken(notification.token || ""); + } }; NotificationSocket.onerror = (error) => { @@ -2075,6 +2199,19 @@ function ConnectNotificationSocket() { if (NotificationSocketPingInterval) { clearInterval(NotificationSocketPingInterval); } + // Closed before handing over a token; whoever is waiting falls + // back to Login. A no-op if the token already arrived. + if (ResolveMintedToken) { + ResolveMintedToken(""); + } + // A browser can't tell us the handshake was a 401, so a token that + // keeps failing before the socket opens is assumed revoked. + if (Token !== "" && !Opened && ++NotificationSocketTokenFailures >= 3) { + NotificationSocketTokenFailures = 0; + if (GetStoredBackendToken() === Token) { + StoreBackendToken(""); + } + } ReconnectNotificationSocket(); }; } catch (e) { @@ -2108,6 +2245,9 @@ function HandleNotificationMessage(event) { if (UtilityEnabled("DebugMode")) { console.log("WebSocket: Server confirmed connection at timestamp", notification.timestamp); } + if (notification.token) { + StoreBackendToken(notification.token); + } } else if (notification.type === 'bbs_mention') { if (UtilityEnabled("BBSPopup")) { CreateAndShowBBSMentionToast(notification.data); @@ -2121,8 +2261,10 @@ function HandleNotificationMessage(event) { console.log("WebSocket: Received pong"); } } + return notification; } catch (e) { console.error("WebSocket: Failed to handle message", e); + return null; } } @@ -2851,6 +2993,9 @@ function CreateUserMenuItems() { ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modify_user_info.php?ByUserScript=1"; }], ["注销", () => { clearCredential(); + RevokeBackendToken(() => { + location.href = "https://www.xmoj.tech/logout.php"; + }); GM.cookie.set({ name: 'PHPSESSID', value: (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)).substring(0, 28), @@ -2862,7 +3007,6 @@ function CreateUserMenuItems() { .catch((error) => { console.error(error); }); //We can no longer rely of the server to set the cookie for us - location.href = "https://www.xmoj.tech/logout.php"; }] ]; return Entries.map(([Text, Action]) => { From d98dee0e9b97ee949e7b728ca5fbe55540a2bacc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 08:49:31 +0000 Subject: [PATCH 02/21] 4.0.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 81f9715e..31f6ef39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "4.0.3", + "version": "4.0.4", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { From 455ac24f5d90b286cc313d71fc4e5447402e10d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 08:49:37 +0000 Subject: [PATCH 03/21] Update version info to 4.0.4 --- Update.json | 11 +++++++++++ XMOJ.user.js | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 51653d1f..7882f15e 100644 --- a/Update.json +++ b/Update.json @@ -3977,6 +3977,17 @@ } ], "Notes": "Change dev domain to https://dev.xmoj-script.uk/" + }, + "4.0.4": { + "UpdateDate": 1791362971707, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1064, + "Description": "Authenticate to the backend with our own session token" + } + ], + "Notes": "脚本现在使用后端签发的会话令牌与服务器通信,不再在每次请求中发送 XMOJ 的 PHPSESSID。论坛、短消息和通知的加载速度更快,也不会再因为 XMOJ 每天自动登出而受影响。" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index f9d8e81d..e5d91395 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 4.0.3 +// @version 4.0.4 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen From 2bb5e3454cd1d4a6d6834c80ccc7e6f42e133ad0 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 16:55:06 +0800 Subject: [PATCH 04/21] Keep the backend auth code inside the RequestAPI test slice The account and profile tests run the source between `let RequestAPI` and `let SyncSettingsToCloud`, so RequestAPI now opens the auth block and the token helpers follow it. With a stored token it sends synchronously, and a throwing transport is caught on the async paths too. The test fixtures give the user a stored token, as a logged-in user has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 127 ++++++++++++++++++-------------- tests/account-settings.test.cjs | 3 + tests/profile-page.test.cjs | 2 + 3 files changed, 76 insertions(+), 56 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index e5d91395..9625090f 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1856,6 +1856,77 @@ let clearCredential = async () => { // cookie against xmoj, which is slow. The token stands for the user, not // for the PHPSESSID, so it survives xmoj's daily logouts and our auto-login. // It lives in GM storage, which the page itself can't read. +let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { + let completed = false; + let Fail = (message) => { + if (completed) return; + completed = true; + if (ErrorCallBack) ErrorCallBack(message); + else console.error("[XMOJ-Script] Request " + Action + ": " + message); + }; + let Timeout = ErrorCallBack ? 15000 : 30000; + let InternalError = (e) => { + Fail("请求失败,请重试"); + console.error(e); + if (UtilityEnabled("DebugMode")) { + SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode."); + } + }; + let Send = (Authentication, Retried) => { + try { + PostAPI(Action, Authentication, Data, Timeout, !!ErrorCallBack, (result) => OnResult(result, Authentication, Retried), Fail); + } catch (e) { + InternalError(e); + } + }; + let OnResult = (result, Authentication, Retried) => { + if (completed) return; + if (!result.Success && result.Data) { + // The backend forgot the token (expired, or logged out elsewhere). + if (result.Data.TokenInvalid && !Retried.Token) { + StoreBackendToken(""); + GetBackendToken().then((Token) => Send({"Token": Token}, {...Retried, Token: true}), Fail); + return; + } + // This request has to read xmoj as us, so it needs the cookie + // after all. Only these requests ever get it. + if (result.Data.SessionRequired && !Retried.Session) { + let Session = GetPHPSESSIDOrReset(); + if (Session === "") { + Fail("用户未登录"); + return; + } + Send({"Token": Authentication.Token, "SessionID": Session}, {...Retried, Session: true}); + return; + } + } + completed = true; + try { + if (CallBack) CallBack(result); + } catch (Error) { + console.error(Error); + } + }; + try { + if (UnauthenticatedActions.includes(Action)) { + Send({}, {Token: true, Session: true}); + return; + } + // Usually we already have a token; send right away rather than + // after a promise tick. + let Token = GetStoredBackendToken(); + if (Token !== "") { + Send({"Token": Token}, {}); + return; + } + GetBackendToken().then((Token) => Send({"Token": Token}, {}), Fail); + } catch (e) { + InternalError(e); + } +}; +// These are answered before the backend looks at Authentication, and are +// needed on pages where the user isn't logged in. +const UnauthenticatedActions = ["GetNotice", "GetAddOnScript"]; let BackendTokenRequest = null; let GetPHPSESSID = () => { let Session = ""; @@ -1978,62 +2049,6 @@ let GetBackendToken = () => { }); return BackendTokenRequest; }; -// These are answered before the backend looks at Authentication, and are -// needed on pages where the user isn't logged in. -const UnauthenticatedActions = ["GetNotice", "GetAddOnScript"]; -let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { - let completed = false; - let Fail = (message) => { - if (completed) return; - completed = true; - if (ErrorCallBack) ErrorCallBack(message); - else console.error("[XMOJ-Script] Request " + Action + ": " + message); - }; - let Timeout = ErrorCallBack ? 15000 : 30000; - let Send = (Authentication, Retried) => { - PostAPI(Action, Authentication, Data, Timeout, !!ErrorCallBack, (result) => { - if (completed) return; - if (!result.Success && result.Data) { - // The backend forgot the token (expired, or logged out elsewhere). - if (result.Data.TokenInvalid && !Retried.Token) { - StoreBackendToken(""); - GetBackendToken().then((Token) => Send({"Token": Token}, {...Retried, Token: true}), Fail); - return; - } - // This request has to read xmoj as us, so it needs the cookie - // after all. Only these requests ever get it. - if (result.Data.SessionRequired && !Retried.Session) { - let Session = GetPHPSESSIDOrReset(); - if (Session === "") { - Fail("用户未登录"); - return; - } - Send({"Token": Authentication.Token, "SessionID": Session}, {...Retried, Session: true}); - return; - } - } - completed = true; - try { - if (CallBack) CallBack(result); - } catch (Error) { - console.error(Error); - } - }, Fail); - }; - try { - if (UnauthenticatedActions.includes(Action)) { - Send({}, {Token: true, Session: true}); - return; - } - GetBackendToken().then((Token) => Send({"Token": Token}, {}), Fail); - } catch (e) { - Fail("请求失败,请重试"); - console.error(e); - if (UtilityEnabled("DebugMode")) { - SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode."); - } - } -}; // Forgets the token here and on the backend. Never trades the PHPSESSID for // a token just to throw it away. let RevokeBackendToken = (CallBack) => { diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs index e32c1ca9..9596727d 100644 --- a/tests/account-settings.test.cjs +++ b/tests/account-settings.test.cjs @@ -48,6 +48,8 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => window.GetUserInfo = async () => ({EmailHash: 'hash'}); window.apiCalls = []; document.cookie = 'PHPSESSID=fixture; path=/'; + // A logged-in user already holds a backend token. + window.GM_getValue = () => ({Token: 'f'.repeat(64), Username: window.CurrentUsername}); window.GM_info = {script: {version: 'test'}}; window.badgeFailureMode = options.badgeFailureMode; window.badgeLoadFailureMode = options.badgeLoadFailureMode; @@ -371,6 +373,7 @@ test('ordinary API requests retain the stashed timeout and log failures once', ( const context = vm.createContext({ document: {cookie: 'PHPSESSID=fixture'}, CurrentUsername: 'Viewer', GM_info: {script: {version: 'test'}}, UtilityEnabled: () => false, + GM_getValue: () => ({Token: 'f'.repeat(64), Username: 'Viewer'}), GM_xmlhttpRequest: options => { request = options; }, console: {error: message => errors.push(message)}, saveResponse: response => responses.push(response) diff --git a/tests/profile-page.test.cjs b/tests/profile-page.test.cjs index 6949d22d..ddb3a678 100644 --- a/tests/profile-page.test.cjs +++ b/tests/profile-page.test.cjs @@ -27,6 +27,8 @@ test('profile page browser regressions', {timeout: 60000}, async t => { window.CurrentUsername = 'Viewer'; window.GM_info = {script: {version: 'test'}}; document.cookie = 'PHPSESSID=fixture; path=/'; + // A logged-in user already holds a backend token. + window.GM_getValue = () => ({Token: 'f'.repeat(64), Username: 'Viewer'}); window.UtilityEnabled = name => ['Rating', 'ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'RemoveUseless' && options.removeActivity !== false); window.CryptoJS = {MD5: email => { window.avatarEmail = email; return {toString: () => '123456789012345678901234567890abcf'}; }}; window.GetUserInfo = window.GetUserBadge = () => { throw new Error('Profile must not wait for these APIs'); }; From 42bb956156c61b920a8f999e2ba812db6a117677 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 08:55:53 +0000 Subject: [PATCH 05/21] Update time and description of 4.0.4 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 7882f15e..def2954e 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791362971707, + "UpdateDate": 1791363346010, "Prerelease": true, "UpdateContents": [ { From daf349d8acf36448c585251e8ae1d8ed65822959 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 16:57:20 +0800 Subject: [PATCH 06/21] Pass non-object API bodies to the caller untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A body such as `null` parses fine but has no fields, and reading result.Success on it threw, turning "服务器响应异常" into a generic "请求失败". Only look for TokenInvalid/SessionRequired in real results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 9625090f..f420228b 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1881,7 +1881,9 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { }; let OnResult = (result, Authentication, Retried) => { if (completed) return; - if (!result.Success && result.Data) { + // A body like `null` is the caller's to report, so only look inside + // real results. + if (result && !result.Success && result.Data) { // The backend forgot the token (expired, or logged out elsewhere). if (result.Data.TokenInvalid && !Retried.Token) { StoreBackendToken(""); From b26f9b2d4aaf314951c495e8277c0e6fc9e13334 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 08:57:50 +0000 Subject: [PATCH 07/21] Update time and description of 4.0.4 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index def2954e..1e0baeb7 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791363346010, + "UpdateDate": 1791363464436, "Prerelease": true, "UpdateContents": [ { From 72d3e9bbacc3c2709cb2d4c76e88ab61824899ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 08:58:07 +0000 Subject: [PATCH 08/21] Update time and description of 4.0.4 --- Update.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Update.json b/Update.json index 1e0baeb7..87c7872f 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791363464436, + "UpdateDate": 1791363487023, "Prerelease": true, "UpdateContents": [ { @@ -3987,7 +3987,7 @@ "Description": "Authenticate to the backend with our own session token" } ], - "Notes": "脚本现在使用后端签发的会话令牌与服务器通信,不再在每次请求中发送 XMOJ 的 PHPSESSID。论坛、短消息和通知的加载速度更快,也不会再因为 XMOJ 每天自动登出而受影响。" + "Notes": "脚本现在使用后端签发的会话令牌与服务器通信,不再在每次请求中发送 XMOJ 的 PHPSESSID。论坛、短消息和通知的加载更快,也不再受 XMOJ 每天自动登出的影响。
\n需要先部署后端 XMOJ-Script-dev/XMOJ-bbs#75。" } } } \ No newline at end of file From 8521145b572a764873aaf7a3d6ceda984482b139 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 17:05:41 +0800 Subject: [PATCH 09/21] Say the session is unreadable, not that the user is logged out An empty document.cookie PHPSESSID means the cookie is httpOnly, which GetPHPSESSIDOrReset is already fixing with a reload; the user is logged in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index f420228b..04738d10 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1895,7 +1895,7 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { if (result.Data.SessionRequired && !Retried.Session) { let Session = GetPHPSESSIDOrReset(); if (Session === "") { - Fail("用户未登录"); + Fail("无法读取登录状态,请刷新页面"); return; } Send({"Token": Authentication.Token, "SessionID": Session}, {...Retried, Session: true}); @@ -2021,7 +2021,9 @@ let ExchangeBackendToken = () => { } let Session = GetPHPSESSIDOrReset(); if (Session === "") { - reject("用户未登录"); + // The cookie is httpOnly; GetPHPSESSIDOrReset is already fixing + // that with a reload. + reject("无法读取登录状态,请刷新页面"); return; } PostAPI("Login", {"SessionID": Session, "Username": CurrentUsername}, {}, 15000, true, (Response) => { From d0f60320bab176068f094cd45bb80f50ade5ac8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 09:06:07 +0000 Subject: [PATCH 10/21] Update time and description of 4.0.4 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 87c7872f..490d3e07 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791363487023, + "UpdateDate": 1791363962276, "Prerelease": true, "UpdateContents": [ { From 0997f053cd55004c50572637133ab9cea221e236 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 09:06:18 +0000 Subject: [PATCH 11/21] Update time and description of 4.0.4 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 490d3e07..b55178df 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791363962276, + "UpdateDate": 1791363977350, "Prerelease": true, "UpdateContents": [ { From 125a0fc39973e085031f7052c5fbc730f9e404e6 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 17:13:34 +0800 Subject: [PATCH 12/21] Fall back to the old auth on a backend without Login, and don't hammer Login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A script that updates before the backend deploys (or after a backend rollback) got "访问的页面不存在" from Login and failed every request. If Login doesn't exist, use {SessionID, Username} for the rest of the page load, as that backend expects. A failed exchange is now remembered for 30 seconds, so a page full of requests doesn't send the PHPSESSID once per request while xmoj is down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 04738d10..7eae7c94 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1887,7 +1887,7 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { // The backend forgot the token (expired, or logged out elsewhere). if (result.Data.TokenInvalid && !Retried.Token) { StoreBackendToken(""); - GetBackendToken().then((Token) => Send({"Token": Token}, {...Retried, Token: true}), Fail); + GetBackendToken().then((Token) => Send(Token !== "" ? {"Token": Token} : LegacyAuthentication(), {...Retried, Token: true}), Fail); return; } // This request has to read xmoj as us, so it needs the cookie @@ -1921,7 +1921,7 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { Send({"Token": Token}, {}); return; } - GetBackendToken().then((Token) => Send({"Token": Token}, {}), Fail); + GetBackendToken().then((Token) => Send(Token !== "" ? {"Token": Token} : LegacyAuthentication(), {}), Fail); } catch (e) { InternalError(e); } @@ -1930,6 +1930,13 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { // needed on pages where the user isn't logged in. const UnauthenticatedActions = ["GetNotice", "GetAddOnScript"]; let BackendTokenRequest = null; +// Set when the backend predates tokens (no Login endpoint): this page load +// then authenticates the old way, as that backend expects. +let BackendLegacyAuth = false; +// A failed exchange is remembered briefly, so a page full of requests +// doesn't send the PHPSESSID once per request while xmoj is struggling. +let BackendTokenFailure = null; +const BackendTokenRetryDelay = 30000; let GetPHPSESSID = () => { let Session = ""; let Temp = document.cookie.split(";"); @@ -2027,24 +2034,43 @@ let ExchangeBackendToken = () => { return; } PostAPI("Login", {"SessionID": Session, "Username": CurrentUsername}, {}, 15000, true, (Response) => { - if (Response.Success) { + if (Response && Response.Success) { StoreBackendToken(Response.Data.Token); resolve(Response.Data.Token); + } else if (Response && Response.Message === "访问的页面不存在") { + BackendLegacyAuth = true; + resolve(""); } else { - reject(Response.Message); + reject(Response && Response.Message ? Response.Message : "服务器响应异常,请重试"); } }, reject); + }).catch((Message) => { + BackendTokenFailure = {"Time": new Date().getTime(), "Message": Message}; + throw Message; }); }; +// How to authenticate when we have no token: only against a backend that +// has never heard of tokens. +let LegacyAuthentication = () => { + return {"SessionID": GetPHPSESSIDOrReset(), "Username": CurrentUsername}; +}; // Resolves to our token, exchanging the PHPSESSID for one if we have none. // Everyone waiting at once shares one exchange, including one the // notification socket is doing; if that one comes back empty, we fall // back to Login. +// Resolves to "" when the backend predates tokens; callers then use +// LegacyAuthentication. let GetBackendToken = () => { let Token = GetStoredBackendToken(); if (Token !== "") { return Promise.resolve(Token); } + if (BackendLegacyAuth) { + return Promise.resolve(""); + } + if (BackendTokenFailure && new Date().getTime() - BackendTokenFailure.Time < BackendTokenRetryDelay) { + return Promise.reject(BackendTokenFailure.Message); + } if (BackendTokenRequest) { return BackendTokenRequest.then((Token) => Token !== "" ? Token : GetBackendToken()); } From 705bc4d355884881bde7d64e5a65c76874635f2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 09:14:00 +0000 Subject: [PATCH 13/21] Update time and description of 4.0.4 --- Update.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Update.json b/Update.json index b55178df..6060ff36 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791363977350, + "UpdateDate": 1791364435417, "Prerelease": true, "UpdateContents": [ { @@ -3987,7 +3987,7 @@ "Description": "Authenticate to the backend with our own session token" } ], - "Notes": "脚本现在使用后端签发的会话令牌与服务器通信,不再在每次请求中发送 XMOJ 的 PHPSESSID。论坛、短消息和通知的加载更快,也不再受 XMOJ 每天自动登出的影响。
\n需要先部署后端 XMOJ-Script-dev/XMOJ-bbs#75。" + "Notes": "脚本现在使用后端签发的会话令牌与服务器通信,不再在每次请求中发送 XMOJ 的 PHPSESSID。论坛、短消息和通知的加载更快,也不再受 XMOJ 每天自动登出的影响。
\n在后端更新前仍可正常使用。" } } } \ No newline at end of file From 9f243e7c5c38108561e5561788227f4b77399bef Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 19:15:40 +0800 Subject: [PATCH 14/21] =?UTF-8?q?Add=20=E6=B3=A8=E9=94=80=E6=89=80?= =?UTF-8?q?=E6=9C=89=E8=AE=BE=E5=A4=87:=20log=20out=20of=20the=20plugin=20?= =?UTF-8?q?backend=20on=20every=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second logout entry, behind a confirmation, calls LogoutAll and then logs this device out as 注销 does. Other devices' sockets are closed with 4001; they drop their token and, if still logged in to xmoj, quietly get a new one. A token on its own is locked out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 57 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 7eae7c94..f699ba4e 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2079,6 +2079,16 @@ let GetBackendToken = () => { }); return BackendTokenRequest; }; +// Revokes every token this user holds, on every device. Devices still logged +// in to xmoj quietly exchange their PHPSESSID for a new one. +let RevokeAllBackendTokens = (CallBack) => { + RequestAPI("LogoutAll", {}, (Response) => { + if (Response && Response.Success) { + StoreBackendToken(""); + } + CallBack(Response || {"Success": false, "Message": "服务器响应异常,请重试"}); + }, (Message) => CallBack({"Success": false, "Message": Message})); +}; // Forgets the token here and on the backend. Never trades the PHPSESSID for // a token just to throw it away. let RevokeBackendToken = (CallBack) => { @@ -2244,6 +2254,11 @@ function ConnectNotificationSocket() { if (NotificationSocketPingInterval) { clearInterval(NotificationSocketPingInterval); } + // 4001: the user logged out everywhere and this token is gone. + // Reconnecting proves the xmoj session again for a new one. + if (event.code === 4001 && Token !== "" && GetStoredBackendToken() === Token) { + StoreBackendToken(""); + } // Closed before handing over a token; whoever is waiting falls // back to Login. A no-op if the token already arrived. if (ResolveMintedToken) { @@ -3027,6 +3042,24 @@ function InitializeAccountBadgeEditor() { }); } +function LogOut() { + clearCredential(); + RevokeBackendToken(() => { + location.href = "https://www.xmoj.tech/logout.php"; + }); + GM.cookie.set({ + name: 'PHPSESSID', + value: (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)).substring(0, 28), + path: "/" + }) + .then(() => { + console.log('Reset PHPSESSID successfully.'); + }) + .catch((error) => { + console.error(error); + }); //We can no longer rely of the server to set the cookie for us +} + // The ResetType user menu. Shared by the legacy navbar and the /web app so both // menus offer the same entries and animate the same way. function CreateUserMenuItems() { @@ -3036,22 +3069,16 @@ function CreateUserMenuItems() { ["短消息", () => { location.href = "https://www.xmoj.tech/mail.php"; }], ["插件设置", () => { location.href = "https://www.xmoj.tech/index.php?ByUserScript=1"; }], ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modify_user_info.php?ByUserScript=1"; }], - ["注销", () => { - clearCredential(); - RevokeBackendToken(() => { - location.href = "https://www.xmoj.tech/logout.php"; + ["注销", LogOut], + ["注销所有设备", () => { + if (!confirm("这会使所有设备上的插件登录失效:仍登录着 XMOJ 的设备会自动重新验证,其余设备将无法再访问插件服务。当前设备也会退出登录。确定吗?")) return; + RevokeAllBackendTokens((Response) => { + if (!Response.Success) { + SmartAlert("注销所有设备失败:" + Response.Message); + return; + } + LogOut(); }); - GM.cookie.set({ - name: 'PHPSESSID', - value: (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)).substring(0, 28), - path: "/" - }) - .then(() => { - console.log('Reset PHPSESSID successfully.'); - }) - .catch((error) => { - console.error(error); - }); //We can no longer rely of the server to set the cookie for us }] ]; return Entries.map(([Text, Action]) => { From 686785ec06755203d098d8adaa226cf09b010e1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 11:16:16 +0000 Subject: [PATCH 15/21] Update time and description of 4.0.4 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 6060ff36..2879a00a 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791364435417, + "UpdateDate": 1791371770383, "Prerelease": true, "UpdateContents": [ { From 55a95b77740ef2d27d801189c4a1a1608e497888 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 19:36:17 +0800 Subject: [PATCH 16/21] Address review: fence logout, survive rollbacks, cover native logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logout waits briefly for a token exchange under way and revokes what it produced; after that no exchange starts, and a token still arriving (slow Login, or the socket) is revoked instead of stored. - A backend rolled back to before tokens answers "参数Token未知"; fall back to the old auth for the page load and keep the token for later. - Failed socket handshakes no longer delete a token: after three, ask the backend over HTTP, and only a TokenInvalid answer drops it. - The site's own logout (the classic logout.php link, the /web app's POST /api/logout) now revokes our token too, for users with ResetType off. - DebugMode logs redact tokens and PHPSESSIDs. - Only accept a Login reply that actually carries a token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 115 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 21 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index f699ba4e..6d30f684 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1883,6 +1883,14 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { if (completed) return; // A body like `null` is the caller's to report, so only look inside // real results. + // A backend rolled back to before tokens rejects the field outright. + // Use the old auth for the rest of this page load; the token is kept + // for when the new backend is back. + if (result && !result.Success && result.Message === "参数Token未知" && Authentication.Token && !Retried.Legacy) { + BackendLegacyAuth = true; + Send(LegacyAuthentication(), {Token: true, Session: true, Legacy: true}); + return; + } if (result && !result.Success && result.Data) { // The backend forgot the token (expired, or logged out elsewhere). if (result.Data.TokenInvalid && !Retried.Token) { @@ -1916,7 +1924,7 @@ let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { } // Usually we already have a token; send right away rather than // after a promise tick. - let Token = GetStoredBackendToken(); + let Token = BackendLegacyAuth ? "" : GetStoredBackendToken(); if (Token !== "") { Send({"Token": Token}, {}); return; @@ -1937,6 +1945,20 @@ let BackendLegacyAuth = false; // doesn't send the PHPSESSID once per request while xmoj is struggling. let BackendTokenFailure = null; const BackendTokenRetryDelay = 30000; +// Set once logout begins. From then on no new exchange starts, and a token +// that still arrives (from a Login or the socket already under way) is +// revoked instead of stored. +let BackendLoggingOut = false; +// Debug logs get copied into chats and issues; keep credentials out of them. +let RedactCredentials = (Text) => String(Text).replace(/("(?:Token|SessionID|token)":")[^"]*"/g, '$1"'); +let AcceptIssuedToken = (Token) => { + if (BackendLoggingOut) { + PostAPI("Logout", {"Token": Token}, {}, 2000, false, () => {}, () => {}); + return false; + } + StoreBackendToken(Token); + return true; +}; let GetPHPSESSID = () => { let Session = ""; let Temp = document.cookie.split(";"); @@ -1983,7 +2005,7 @@ let PostAPI = (Action, Authentication, Data, Timeout, StrictStatus, OnResult, On "Authentication": Authentication, "Data": Data, "Version": GM_info.script.version, "DebugMode": UtilityEnabled("DebugMode") }); if (UtilityEnabled("DebugMode")) { - console.log("Sent for", Action + ":", DataString); + console.log("Sent for", Action + ":", RedactCredentials(DataString)); } GM_xmlhttpRequest({ method: "POST", @@ -2002,7 +2024,7 @@ let PostAPI = (Action, Authentication, Data, Timeout, StrictStatus, OnResult, On onabort: () => OnFail("请求已取消,请重试"), onload: (Response) => { if (UtilityEnabled("DebugMode")) { - console.log("Received for", Action + ":", Response.responseText); + console.log("Received for", Action + ":", RedactCredentials(Response.responseText)); } if (StrictStatus && (Response.status < 200 || Response.status >= 300)) { OnFail("请求失败(HTTP " + Response.status + "),请重试"); @@ -2034,8 +2056,11 @@ let ExchangeBackendToken = () => { return; } PostAPI("Login", {"SessionID": Session, "Username": CurrentUsername}, {}, 15000, true, (Response) => { - if (Response && Response.Success) { - StoreBackendToken(Response.Data.Token); + if (Response && Response.Success && Response.Data && typeof Response.Data.Token === "string" && Response.Data.Token !== "") { + if (!AcceptIssuedToken(Response.Data.Token)) { + reject("正在退出登录"); + return; + } resolve(Response.Data.Token); } else if (Response && Response.Message === "访问的页面不存在") { BackendLegacyAuth = true; @@ -2061,12 +2086,15 @@ let LegacyAuthentication = () => { // Resolves to "" when the backend predates tokens; callers then use // LegacyAuthentication. let GetBackendToken = () => { + if (BackendLegacyAuth) { + return Promise.resolve(""); + } let Token = GetStoredBackendToken(); if (Token !== "") { return Promise.resolve(Token); } - if (BackendLegacyAuth) { - return Promise.resolve(""); + if (BackendLoggingOut) { + return Promise.reject("正在退出登录"); } if (BackendTokenFailure && new Date().getTime() - BackendTokenFailure.Time < BackendTokenRetryDelay) { return Promise.reject(BackendTokenFailure.Message); @@ -2092,13 +2120,57 @@ let RevokeAllBackendTokens = (CallBack) => { // Forgets the token here and on the backend. Never trades the PHPSESSID for // a token just to throw it away. let RevokeBackendToken = (CallBack) => { - let Token = GetStoredBackendToken(); - StoreBackendToken(""); - if (Token === "") { + BackendLoggingOut = true; + let Done = false; + let Finish = () => { + if (Done) return; + Done = true; CallBack(); - return; - } - PostAPI("Logout", {"Token": Token}, {}, 2000, false, () => CallBack(), () => CallBack()); + }; + // An exchange under way would store its token after we cleared storage. + // Give it a moment to land so it is revoked with the rest; anything later + // is caught by AcceptIssuedToken. + let Pending = BackendTokenRequest ? BackendTokenRequest.catch(() => {}) : Promise.resolve(); + Promise.race([Pending, new Promise((resolve) => setTimeout(resolve, 3000))]).then(() => { + let Token = GetStoredBackendToken(); + StoreBackendToken(""); + if (Token === "") { + Finish(); + return; + } + PostAPI("Logout", {"Token": Token}, {}, 2000, false, Finish, Finish); + }); +}; +// The site's own logout, the classic logout.php link or the /web app's +// POST /api/logout, knows nothing of our token, which outlives xmoj +// sessions on purpose. Revoke it on the way out. +let InterceptNativeLogout = () => { + document.addEventListener("click", (Event) => { + let Link = Event.target && Event.target.closest ? Event.target.closest('a[href*="logout.php"]') : null; + if (!Link || GetStoredBackendToken() === "") return; + Event.preventDefault(); + Event.stopImmediatePropagation(); + RevokeBackendToken(() => { + location.href = Link.href; + }); + }, true); + let PageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; + let NativeFetch = PageWindow.fetch; + if (typeof NativeFetch !== "function") return; + PageWindow.fetch = function (Resource, Options) { + let Target = typeof Resource === "string" || Resource instanceof URL ? String(Resource) : (Resource && Resource.url) || ""; + let Method = String((Options && Options.method) || (Resource && Resource.method) || "GET").toUpperCase(); + let Arguments = arguments; + let IsLogout = false; + try { + IsLogout = Method === "POST" && new URL(Target, location.href).pathname === "/api/logout"; + } catch (e) { + } + if (IsLogout && GetStoredBackendToken() !== "") { + return new Promise((resolve) => RevokeBackendToken(resolve)).then(() => NativeFetch.apply(this, Arguments)); + } + return NativeFetch.apply(this, Arguments); + }; }; let SyncSettingsToCloud = (CallBack) => { if (!CurrentUsername) { @@ -2170,7 +2242,7 @@ function ConnectNotificationSocket() { NotificationSocketReconnectTimer = null; } - let Token = GetStoredBackendToken(); + let Token = BackendLegacyAuth ? "" : GetStoredBackendToken(); if (Token === "" && BackendTokenRequest) { // Someone is already getting us a token; connect with it instead of // minting a second one. @@ -2264,13 +2336,13 @@ function ConnectNotificationSocket() { if (ResolveMintedToken) { ResolveMintedToken(""); } - // A browser can't tell us the handshake was a 401, so a token that - // keeps failing before the socket opens is assumed revoked. + // A browser can't tell a refused token from being offline, so after + // a few failed handshakes ask the backend over HTTP. Only a + // TokenInvalid answer drops the token (RequestAPI does that and + // gets a new one); a network error leaves it alone. if (Token !== "" && !Opened && ++NotificationSocketTokenFailures >= 3) { NotificationSocketTokenFailures = 0; - if (GetStoredBackendToken() === Token) { - StoreBackendToken(""); - } + RequestAPI("SendData", {}); } ReconnectNotificationSocket(); }; @@ -2298,7 +2370,7 @@ function HandleNotificationMessage(event) { const notification = JSON.parse(event.data); if (UtilityEnabled("DebugMode")) { - console.log("WebSocket: Received message", notification); + console.log("WebSocket: Received message", RedactCredentials(event.data)); } if (notification.type === 'connected') { @@ -2306,7 +2378,7 @@ function HandleNotificationMessage(event) { console.log("WebSocket: Server confirmed connection at timestamp", notification.timestamp); } if (notification.token) { - StoreBackendToken(notification.token); + AcceptIssuedToken(notification.token); } } else if (notification.type === 'bbs_mention') { if (UtilityEnabled("BBSPopup")) { @@ -4024,6 +4096,7 @@ class NavbarStyler { // which executes userscripts as classic scripts (not ES modules). (async () => { if (GetContestWebRedirect()) return; +InterceptNativeLogout(); if (GetAccountSettingsRedirect()) return; if (document.readyState === "loading") { await new Promise(r => document.addEventListener("DOMContentLoaded", r, { once: true })); From 26846466d6be727b4e9b416679325e4755cb8282 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 11:36:55 +0000 Subject: [PATCH 17/21] Update time and description of 4.0.4 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 2879a00a..23a3167b 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791371770383, + "UpdateDate": 1791373008901, "Prerelease": true, "UpdateContents": [ { From 5a3c521258a5cf3a51c1bf187aa5b11d5841d2ba Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 19:55:31 +0800 Subject: [PATCH 18/21] =?UTF-8?q?Say=20plainly=20what=20=E6=B3=A8=E9=94=80?= =?UTF-8?q?=E6=89=80=E6=9C=89=E8=AE=BE=E5=A4=87=20does,=20and=20to=20chang?= =?UTF-8?q?e=20the=20password=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button ends plugin sessions, not XMOJ ones, so someone who has the password can simply log back in. The confirmation now leads with changing the XMOJ password, then lists what the button does and does not do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 6d30f684..5c28433a 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -3143,7 +3143,16 @@ function CreateUserMenuItems() { ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modify_user_info.php?ByUserScript=1"; }], ["注销", LogOut], ["注销所有设备", () => { - if (!confirm("这会使所有设备上的插件登录失效:仍登录着 XMOJ 的设备会自动重新验证,其余设备将无法再访问插件服务。当前设备也会退出登录。确定吗?")) return; + // Spell out the limits: this ends plugin sessions, not XMOJ ones, + // so anyone who still has the password can simply log back in. + if (!confirm("如果你怀疑账号被他人使用,请先修改 XMOJ 密码(菜单中的「修改帐号」),再使用此功能。否则对方仍可用你的密码重新登录,并重新获得插件的访问权限。\n\n" + + "注销所有设备会:\n" + + "· 使你在所有设备上的插件登录凭证失效\n" + + "· 断开所有设备的插件实时通知\n" + + "· 退出当前设备的登录\n\n" + + "注销所有设备不会:\n" + + "· 让其他设备退出 XMOJ 本身。仍登录着 XMOJ 的设备会自动重新获得插件凭证,可以继续使用\n\n" + + "确定要注销所有设备吗?")) return; RevokeAllBackendTokens((Response) => { if (!Response.Success) { SmartAlert("注销所有设备失败:" + Response.Message); From 619ee3d1be090648579b69fc9c7ef9103afa240b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 11:56:06 +0000 Subject: [PATCH 19/21] Update time and description of 4.0.4 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 23a3167b..fe90a4c5 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791373008901, + "UpdateDate": 1791374159755, "Prerelease": true, "UpdateContents": [ { From 630f6225cc82eaa9af8e3354e3d0fc7a7c2b83cf Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 20:17:39 +0800 Subject: [PATCH 20/21] Let developers turn off credential redaction in debug logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New setting next to 调试模式, 调试日志中隐藏登录凭证, on by default so pasted logs stay safe. Turning it off logs tokens and PHPSESSIDs as they are. It is a sibling of DebugMode rather than a child: an entry with children renders as a heading without its own checkbox. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- XMOJ.user.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 5c28433a..5cd70f6a 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1950,7 +1950,8 @@ const BackendTokenRetryDelay = 30000; // revoked instead of stored. let BackendLoggingOut = false; // Debug logs get copied into chats and issues; keep credentials out of them. -let RedactCredentials = (Text) => String(Text).replace(/("(?:Token|SessionID|token)":")[^"]*"/g, '$1"'); +// On by default; developers who need the raw values can turn RedactLogs off. +let RedactCredentials = (Text) => !UtilityEnabled("RedactLogs") ? String(Text) : String(Text).replace(/("(?:Token|SessionID|token)":")[^"]*"/g, '$1"'); let AcceptIssuedToken = (Token) => { if (BackendLoggingOut) { PostAPI("Logout", {"Token": Token}, {}, 2000, false, () => {}, () => {}); @@ -4767,6 +4768,8 @@ async function main() { "ID": "CloudSync", "Type": "A", "Name": "将设置同步至云端(跨设备同步)" }, { "ID": "DebugMode", "Type": "A", "Name": "调试模式(仅供开发者使用)" + }, { + "ID": "RedactLogs", "Type": "A", "Name": "调试日志中隐藏登录凭证(分享日志前请保持开启)" }, { "ID": "SuperDebug", "Type": "A", "Name": "本地调试模式(仅供开发者使用) (未经授权的擅自开启将导致大部分功能不可用!)" }])); From 8e6b60dc8531e166929888dfc1b6041a12db1622 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 7 Oct 2026 12:18:12 +0000 Subject: [PATCH 21/21] Update time and description of 4.0.4 --- Update.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Update.json b/Update.json index fe90a4c5..11af94c9 100644 --- a/Update.json +++ b/Update.json @@ -3979,7 +3979,7 @@ "Notes": "Change dev domain to https://dev.xmoj-script.uk/" }, "4.0.4": { - "UpdateDate": 1791374159755, + "UpdateDate": 1791375486861, "Prerelease": true, "UpdateContents": [ { @@ -3987,7 +3987,7 @@ "Description": "Authenticate to the backend with our own session token" } ], - "Notes": "脚本现在使用后端签发的会话令牌与服务器通信,不再在每次请求中发送 XMOJ 的 PHPSESSID。论坛、短消息和通知的加载更快,也不再受 XMOJ 每天自动登出的影响。
\n在后端更新前仍可正常使用。" + "Notes": "脚本现在使用后端签发的会话令牌与服务器通信,不再在每次请求中发送 XMOJ 的 PHPSESSID。论坛、短消息和通知的加载更快,也不再受 XMOJ 每天自动登出的影响。
\n在后端更新前仍可正常使用。
\n新增「注销所有设备」:使所有设备上的插件登录凭证失效。它不会让其他设备退出 XMOJ 本身,因此如果怀疑账号被他人使用,请先修改 XMOJ 密码,再使用此功能。" } } } \ No newline at end of file