From 341c4b3eeeccd3e22833b415310c8fa9fb42cd2e Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 16:46:36 +0800 Subject: [PATCH 1/3] Issue our own session tokens instead of checking PHPSESSIDs per request Clients swap a verified PHPSESSID for a token once (Login), then send only the token. Checking it is a D1 lookup, so requests no longer wait on slow fetches to xmoj, and we stop receiving the user's cookie on every call. Tokens stand for the user, so they survive xmoj's daily logouts; they expire after 30 days unused or on Logout. Scrapes that must read xmoj as the user call RequireSession, which asks the client to retry once with its PHPSESSID. GetStd caches scores so it rarely needs one, and IfUserExist reads the public userinfo.php without a cookie. The notification socket accepts a token, and can mint one for a client that connects with its PHPSESSID and IssueToken=1. Legacy {SessionID, Username} auth is unchanged for old clients. Also fixes the auth retry loop, which tested Data.Success (always undefined) and so ran CheckToken three times on every request. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- CLAUDE.md | 11 +- Source/NotificationManager.ts | 6 +- Source/Process.ts | 124 ++++++++++++++-- Source/SessionToken.ts | 84 +++++++++++ Source/index.ts | 42 ++++-- migrations/0006_add_session_token.sql | 18 +++ test/index.test.js | 81 ++++++++++ test/process.test.js | 203 ++++++++++++++++++++++++++ 8 files changed, 545 insertions(+), 24 deletions(-) create mode 100644 Source/SessionToken.ts create mode 100644 migrations/0006_add_session_token.sql diff --git a/CLAUDE.md b/CLAUDE.md index 106e82f..b5dd373 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,8 +91,11 @@ NewEndpoint: async (Data: object): Promise => { No routing configuration needed - pathname automatically maps to function name. #### Authentication -- Uses `CheckToken` which validates session ID against XMOJ website -- Session tokens are hashed (SHA3) and cached in `phpsessid` table +- `CheckToken` accepts either a backend-issued `{Token}` (current clients) or a legacy `{SessionID, Username}` (old clients) +- `Login` swaps a verified PHPSESSID for a token (`Source/SessionToken.ts`); tokens are stored as SHA3 hashes in `session_token`, expire after 30 days unused, and are revoked by `Logout`. The username always comes from the token row +- Legacy PHPSESSIDs are validated against the XMOJ website and cached as SHA3 hashes in the `phpsessid` table +- Token requests carry no PHPSESSID. Code that must scrape xmoj as the user calls `this.RequireSession()` first, which answers `SessionRequired`; the client then retries once with `{Token, SessionID}`. Keep such scrapes rare, since we want to see users' PHPSESSIDs as little as possible +- `problem_score` caches users' best scores so `GetStd` doesn't need to scrape (and so doesn't need a session) once a score has cleared the threshold - Admin users are hardcoded in `AdminUserList` array - Use `this.IsAdmin()`, `this.IsSilenced()`, `this.DenyMessage()`, `this.DenyEdit()` for permission checks @@ -117,7 +120,9 @@ Tables use snake_case naming: - `short_message`: Encrypted private messages between users - `badge`: User badges with custom colors and content - `std_answer`: Standard solution codes for problems -- `phpsessid`: Cached session tokens +- `phpsessid`: Cached hashes of verified PHPSESSIDs (legacy auth) +- `session_token`: Hashes of backend-issued session tokens +- `problem_score`: Best score per user and problem, scraped from XMOJ ### Testing Conventions diff --git a/Source/NotificationManager.ts b/Source/NotificationManager.ts index 9891829..a911b55 100644 --- a/Source/NotificationManager.ts +++ b/Source/NotificationManager.ts @@ -164,9 +164,13 @@ export class NotificationManager { }); this.addSession(userId, server); + // Only set when the worker minted a token for a client that connected + // with its PHPSESSID. It is handed over once and never stored here. + const issuedToken = url.searchParams.get("issuedToken"); server.send(JSON.stringify({ type: "connected", - timestamp: Date.now() + timestamp: Date.now(), + ...(issuedToken ? {token: issuedToken} : {}) })); return new Response(null, {status: 101, webSocket: client}); diff --git a/Source/Process.ts b/Source/Process.ts index 6747f1a..8ac5fff 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -25,6 +25,7 @@ import * as sqlstring from 'sqlstring'; // @ts-ignore import CryptoJS from "crypto-js"; import {AnalyticsEngineDataset, D1Database, D1DatabaseSession, DurableObjectNamespace, KVNamespace} from "@cloudflare/workers-types"; +import {IssueSessionToken, ResolveSessionToken, RevokeSessionToken} from "./SessionToken"; interface Environment { API_TOKEN: string; @@ -241,18 +242,25 @@ export class Process { private readonly shortMessageEncryptKey_v1: string; private readonly API_TOKEN: string; private Username: string; - private SessionID: string; + // Empty unless this request carried a verified PHPSESSID. Token requests + // only bring one when RequireSession asked for it. + private SessionID: string = ""; + private SessionToken: string = ""; private readonly RemoteIP: string; private XMOJDatabase: Database; private readonly logs: AnalyticsEngineDataset; private readonly notifications: DurableObjectNamespace; private readonly notificationPushToken: string; private RequestData: Request; - private Fetch = async (RequestURL: URL): Promise => { + private Fetch = async (RequestURL: URL, WithSession: boolean = true): Promise => { Output.Log("Fetch: " + RequestURL.toString()); + const Headers: Record = {}; + if (WithSession && this.SessionID !== "") { + Headers["Cookie"] = "PHPSESSID=" + this.SessionID; + } const RequestData = new Request(RequestURL, { headers: { - "Cookie": "PHPSESSID=" + this.SessionID, + ...Headers, "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", "accept": "*/*", "accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", @@ -286,6 +294,47 @@ export class Process { return new Result(true, "参数检测通过"); } public CheckToken = async (Data: object): Promise => { + if (Data["Token"] !== undefined) { + return await this.CheckSessionToken(Data); + } + return await this.CheckSessionID(Data); + } + // Token requests never send the username: it comes from the token row. A + // PHPSESSID rides along only when RequireSession asked for one, and then it + // must belong to the same user, or a token could borrow someone else's cookie. + public CheckSessionToken = async (Data: object): Promise => { + const Checklist = {"Token": "string"}; + if (Data["SessionID"] !== undefined) { + Checklist["SessionID"] = "string"; + } + ThrowErrorIfFailed(this.CheckParams(Data, Checklist)); + const TokenUsername = await ResolveSessionToken(this.XMOJDatabase, Data["Token"]); + if (TokenUsername === "") { + return new Result(false, "令牌已失效", {"TokenInvalid": true}); + } + if (Data["SessionID"] !== undefined) { + const SessionResult = await this.CheckSessionID({ + "SessionID": Data["SessionID"], + "Username": TokenUsername + }); + if (!SessionResult.Success) { + this.SessionID = ""; + return SessionResult; + } + } + this.SessionToken = Data["Token"]; + this.Username = TokenUsername; + return new Result(true, "令牌匹配"); + } + // Scrapes that need a logged-in xmoj session call this first. Token clients + // answer SessionRequired by retrying once with their PHPSESSID, so we only see + // it when it is actually going to be used. + public RequireSession = (): void => { + if (this.SessionID === "") { + throw new Result(false, "需要会话", {"SessionRequired": true}); + } + } + public CheckSessionID = async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, { "SessionID": "string", "Username": "string" @@ -355,12 +404,15 @@ export class Process { } if (ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("phpsessid", { user_id: Username + }))["TableSize"] > 0 || ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("session_token", { + user_id: Username }))["TableSize"] > 0) { return new Result(true, "用户检查成功", { "Exist": true }); } - return await this.Fetch(new URL("https://www.xmoj.tech/userinfo.php?user=" + Username)) + // userinfo.php is public, so this never needs the caller's cookie. + return await this.Fetch(new URL("https://www.xmoj.tech/userinfo.php?user=" + Username), false) .then((Response) => { return Response.text(); }).then((Response) => { @@ -475,8 +527,36 @@ export class Process { return 0; }); } - public GetProblemScoreChecker = async (ProblemID: number): Promise => { - return await this.GetProblemScore(ProblemID); + // Scores only go up, so a cached score that already clears RequiredScore is + // final. Anything less needs a fresh scrape of status.php, which needs a login. + public GetProblemScoreChecker = async (ProblemID: number, RequiredScore: number): Promise => { + const Cached = ThrowErrorIfFailed(await this.XMOJDatabase.Select("problem_score", ["score"], { + user_id: this.Username, + problem_id: ProblemID + })) as Array>; + const CachedScore: number = Cached.length === 0 ? 0 : Cached[0]["score"]; + if (CachedScore >= RequiredScore) { + return CachedScore; + } + this.RequireSession(); + const Score = await this.GetProblemScore(ProblemID); + // The cache is an optimisation; a failed or racing write only costs a + // re-scrape next time. + if (Score > CachedScore) { + if (Cached.length === 0) { + await this.XMOJDatabase.Insert("problem_score", { + user_id: this.Username, + problem_id: ProblemID, + score: Score + }); + } else { + await this.XMOJDatabase.Update("problem_score", {score: Score}, { + user_id: this.Username, + problem_id: ProblemID + }); + } + } + return Score; } public processCppString(inputStr: string) { @@ -1380,9 +1460,12 @@ export class Process { } return new Result(true, "此题已经有人上传标程"); } - if (await this.GetProblemScoreChecker(ProblemID) !== 100) { + if (await this.GetProblemScoreChecker(ProblemID, 100) < 100) { return new Result(false, "没有权限上传此标程"); } + // A cached score skips the check above, but the scrapes below still + // read xmoj as this user. + this.RequireSession(); let StdCode: string = ""; let PageIndex: number = 0; while (StdCode === "") { @@ -1475,6 +1558,22 @@ export class Process { await RebuildStdList(this.XMOJDatabase, this.kv); return new Result(true, "标程上传成功"); }, + // Swaps a verified PHPSESSID for a token of ours, so later requests need + // neither the cookie nor a round-trip to xmoj. + Login: async (Data: object): Promise => { + ThrowErrorIfFailed(this.CheckParams(Data, {})); + if (this.SessionID === "") { + return new Result(false, "登录需要会话"); + } + return new Result(true, "登录成功", await IssueSessionToken(this.XMOJDatabase, this.Username)); + }, + Logout: async (Data: object): Promise => { + ThrowErrorIfFailed(this.CheckParams(Data, {})); + if (this.SessionToken !== "") { + await RevokeSessionToken(this.XMOJDatabase, this.SessionToken); + } + return new Result(true, "登出成功"); + }, GetStdList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { @@ -1495,7 +1594,7 @@ export class Process { ThrowErrorIfFailed(this.CheckParams(Data, { "ProblemID": "number" })); - if (await this.GetProblemScoreChecker(Data["ProblemID"]) < 50) { + if (await this.GetProblemScoreChecker(Data["ProblemID"], 50) < 50) { return new Result(false, "没有权限获取此标程"); } const Std = ThrowErrorIfFailed(await this.XMOJDatabase.Select("std_answer", ["std_code"], { @@ -1963,14 +2062,17 @@ export class Process { "Version": "string", "DebugMode": "boolean" })); + // A legacy check can fail on a slow xmoj fetch, so it gets one retry. A + // token check is a database lookup and its answer will not change. let TokenFailedCount = 0; while (true) { - if ((await this.CheckToken(RequestJSON["Authentication"])).Data["Success"]) { + const TokenResult = await this.CheckToken(RequestJSON["Authentication"]); + if (TokenResult.Success) { break; } TokenFailedCount++; - if (TokenFailedCount >= 2) { - ThrowErrorIfFailed(await this.CheckToken(RequestJSON["Authentication"])); + if (TokenFailedCount >= 2 || RequestJSON["Authentication"]["Token"] !== undefined) { + ThrowErrorIfFailed(TokenResult); break; } } diff --git a/Source/SessionToken.ts b/Source/SessionToken.ts new file mode 100644 index 0000000..34e3b0d --- /dev/null +++ b/Source/SessionToken.ts @@ -0,0 +1,84 @@ +/* + * 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 . + */ + +// Session tokens are issued by us in exchange for a verified xmoj.tech +// PHPSESSID. They stand for the user, not for a PHPSESSID: xmoj logs users out +// at least once a day and the script logs them back in, and none of that should +// cost a slow round-trip to xmoj or another look at the user's cookie. A token +// only dies when it goes unused for SessionTokenLifetime or the user logs out. + +import {ThrowErrorIfFailed} from "./Result"; +import {Database} from "./Database"; +// @ts-ignore +import CryptoJS from "crypto-js"; + +export const SessionTokenLifetime = 1000 * 60 * 60 * 24 * 30; +// last_used only needs to be accurate to well within the lifetime; refreshing +// it on every request would turn every read into a D1 write. +const LastUsedResolution = 1000 * 60 * 60 * 24; + +export const IsValidSessionToken = (Token: string): boolean => { + return /^[0-9a-f]{64}$/.test(Token); +}; + +// Only the hash is stored, so a leaked table cannot be replayed. +export const HashSessionToken = (Token: string): string => { + return CryptoJS.SHA3(Token).toString(); +}; + +export const IssueSessionToken = async (XMOJDatabase: Database, Username: string): Promise<{ Token: string; ExpireTime: number }> => { + const Bytes = new Uint8Array(32); + crypto.getRandomValues(Bytes); + const Token = Array.from(Bytes, (Byte) => Byte.toString(16).padStart(2, "0")).join(""); + const Now = new Date().getTime(); + ThrowErrorIfFailed(await XMOJDatabase.Insert("session_token", { + token_hash: HashSessionToken(Token), + user_id: Username, + create_time: Now, + last_used: Now + })); + return {Token, ExpireTime: Now + SessionTokenLifetime}; +}; + +// Returns the user the token was issued to, or "" if it is unknown or expired. +export const ResolveSessionToken = async (XMOJDatabase: Database, Token: string): Promise => { + if (!IsValidSessionToken(Token)) { + return ""; + } + const TokenHash = HashSessionToken(Token); + const Rows = ThrowErrorIfFailed(await XMOJDatabase.Select("session_token", ["user_id", "last_used"], { + token_hash: TokenHash + })) as Array>; + if (Rows.length === 0) { + return ""; + } + const Now = new Date().getTime(); + if (Rows[0]["last_used"] + SessionTokenLifetime <= Now) { + await XMOJDatabase.Delete("session_token", {token_hash: TokenHash}); + return ""; + } + if (Rows[0]["last_used"] + LastUsedResolution <= Now) { + await XMOJDatabase.Update("session_token", {last_used: Now}, {token_hash: TokenHash}); + } + return Rows[0]["user_id"]; +}; + +export const RevokeSessionToken = async (XMOJDatabase: Database, Token: string): Promise => { + ThrowErrorIfFailed(await XMOJDatabase.Delete("session_token", { + token_hash: HashSessionToken(Token) + })); +}; diff --git a/Source/index.ts b/Source/index.ts index d4f3a31..7fdb3bc 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -18,6 +18,7 @@ import {Process, RebuildStdList} from "./Process"; import {Database} from "./Database"; import {NotificationManager} from "./NotificationManager"; +import {IssueSessionToken, ResolveSessionToken, SessionTokenLifetime} from "./SessionToken"; import type {D1Database, KVNamespace, AnalyticsEngineDataset, DurableObjectNamespace, Ai} from "@cloudflare/workers-types"; interface Environment { @@ -128,15 +129,27 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr const url = new URL(RequestData.url); if (url.pathname === "/ws/notifications") { + // A token is a database lookup. The PHPSESSID path costs a fetch to xmoj + // and is kept for old clients and for new ones that have no token yet; + // those ask for one with IssueToken=1 so the next connect needs neither. + const token = url.searchParams.get("Token") || ""; const sessionID = url.searchParams.get("SessionID") || ""; - if (sessionID === "") { - return new Response("Missing SessionID", {status: 401}); - } - if (!isValidSessionID(sessionID)) { - return new Response("Invalid SessionID", {status: 401}); + let userId = ""; + let issuedToken = ""; + if (token !== "") { + userId = await ResolveSessionToken(new Database(Environment.DB), token); + } else { + if (sessionID === "") { + return new Response("Missing SessionID", {status: 401}); + } + if (!isValidSessionID(sessionID)) { + return new Response("Invalid SessionID", {status: 401}); + } + userId = await ValidateSession(sessionID); + if (userId !== "" && url.searchParams.get("IssueToken") === "1") { + issuedToken = (await IssueSessionToken(new Database(Environment.DB), userId)).Token; + } } - - const userId = await ValidateSession(sessionID); if (userId === "") { return new Response("Unauthorized", {status: 401}); } @@ -146,6 +159,11 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr const forwardURL = new URL(RequestData.url); forwardURL.searchParams.set("userId", userId); forwardURL.searchParams.delete("SessionID"); + forwardURL.searchParams.delete("Token"); + forwardURL.searchParams.delete("IssueToken"); + if (issuedToken !== "") { + forwardURL.searchParams.set("issuedToken", issuedToken); + } return await notificationStub.fetch(new Request(forwardURL.toString(), RequestData)); } @@ -154,8 +172,8 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr }; // Workers' automatic invocation logs are off (see wrangler.toml) because they -// record full URLs and headers, and the notification WebSocket carries the -// session ID in its query string. This is our replacement: one line per request +// record full URLs and headers, and the notification WebSocket carries a +// session ID or token in its query string. This is our replacement: one line per request // with what we need for debugging, and never the query string or headers. export const RequestLogLine = (RequestData: Request, Status: number, Duration: number, Failure?: unknown) => { const Cf: any = (RequestData as any).cf || {}; @@ -208,6 +226,12 @@ export default { "Value": new Date().getTime() - 1000 * 60 * 60 * 24 * 5 } }); + await XMOJDatabase.Delete("session_token", { + "last_used": { + "Operator": "<=", + "Value": new Date().getTime() - SessionTokenLifetime + } + }); // Reconcile the std list cache against the database. One scan per day // bounds any drift - from a dropped KV write or two uploads racing - to // 24 hours, instead of it persisting forever as it does today. diff --git a/migrations/0006_add_session_token.sql b/migrations/0006_add_session_token.sql new file mode 100644 index 0000000..ce8f8c6 --- /dev/null +++ b/migrations/0006_add_session_token.sql @@ -0,0 +1,18 @@ +-- Migration number: 0006 2026-10-07 + +CREATE TABLE IF NOT EXISTS session_token ( + token_hash TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + create_time INTEGER NOT NULL, + last_used INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS session_token_user_id ON session_token (user_id); + +-- Best score a user has reached on a problem. Scores only go up, so once a +-- score clears a threshold it never needs to be scraped from xmoj again. +CREATE TABLE IF NOT EXISTS problem_score ( + user_id TEXT NOT NULL, + problem_id INTEGER NOT NULL, + score INTEGER NOT NULL, + PRIMARY KEY (user_id, problem_id) +); diff --git a/test/index.test.js b/test/index.test.js index 2c479f8..e58861d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -26,3 +26,84 @@ test('RequestLogLine leaves error unset on success', () => { const line = RequestLogLine(new Request('https://api.xmoj-script.uk/GetNotice'), 200, 1); assert.strictEqual(line.error, undefined); }); + +// ---- Notification WebSocket auth ---- + +const Worker = require('../Source/index.ts').default; +const { HashSessionToken } = require('../Source/SessionToken.ts'); + +// Answers SELECTs on session_token with `tokenRows` and records every query. +function wsEnvironment(tokenRows = []) { + const queries = []; + const forwarded = []; + const DB = { + withSession() { return this; }, + prepare(q) { + return { + bind(...args) { + return { + all: async () => { + queries.push({ q, args }); + if (q.startsWith('SELECT') && q.includes('session_token')) { + return { results: tokenRows, meta: {} }; + } + return { results: [], meta: { last_row_id: 1, changes: 1 } }; + } + }; + } + }; + } + }; + const NOTIFICATIONS = { + idFromName: (name) => name, + get: () => ({ + fetch: async (request) => { + forwarded.push(new URL(request.url)); + return new Response('forwarded'); + } + }) + }; + return { env: { DB, NOTIFICATIONS }, queries, forwarded }; +} + +// profile.php embeds the logged-in user as user_id=' +const ProfilePage = (user) => new Response("" + user + ""); + +test('WebSocket with a token resolves the user without touching xmoj', async (t) => { + const xmoj = t.mock.method(global, 'fetch', async () => ProfilePage('mallory')); + const { env, forwarded } = wsEnvironment([{ user_id: 'alice', last_used: Date.now() }]); + const token = 'b'.repeat(64); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?Token=' + token), env); + assert.strictEqual(xmoj.mock.calls.length, 0); + assert.strictEqual(forwarded[0].searchParams.get('userId'), 'alice'); + assert.strictEqual(forwarded[0].searchParams.get('Token'), null, 'the token is not passed on'); +}); + +test('WebSocket with an unknown token is unauthorized', async () => { + const { env, forwarded } = wsEnvironment([]); + const response = await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?Token=' + 'c'.repeat(64)), env); + assert.strictEqual(response.status, 401); + assert.strictEqual(forwarded.length, 0); +}); + +test('WebSocket with a PHPSESSID and IssueToken=1 mints a token for the connected message', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, queries, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&IssueToken=1'), env); + const issued = forwarded[0].searchParams.get('issuedToken'); + assert.match(issued, /^[0-9a-f]{64}$/); + assert.strictEqual(forwarded[0].searchParams.get('SessionID'), null); + assert.strictEqual(forwarded[0].searchParams.get('IssueToken'), null); + const insert = queries.find((query) => query.q.startsWith('INSERT INTO `session_token`')); + assert.ok(insert.args.includes(HashSessionToken(issued))); + assert.ok(insert.args.includes('alice')); +}); + +test('WebSocket with only a PHPSESSID behaves as before', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, queries, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123'), env); + assert.strictEqual(forwarded[0].searchParams.get('userId'), 'alice'); + assert.strictEqual(forwarded[0].searchParams.get('issuedToken'), null); + assert.strictEqual(queries.length, 0, 'no token minted'); +}); diff --git a/test/process.test.js b/test/process.test.js index 3168f44..5eb7e4e 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -1295,3 +1295,206 @@ test('EditBadge refuses the edit when there is no AI at all', async () => { assert.strictEqual(result.Success, false); assert.strictEqual(result.Message, '内容审核服务暂时不可用,请稍后重试'); }); + +// ---- Session tokens ---- + +const { HashSessionToken } = require('../Source/SessionToken.ts'); +const ValidToken = 'a'.repeat(64); + +// Select stub that answers per table, so token lookups and score lookups can +// be told apart from everything else. +function selectByTable(tables) { + return test.mock.fn(async (table) => new Result(true, '', tables[table] || [])); +} + +test('CheckToken with a token takes the username from the token row', async () => { + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }) } + }); + proc.Username = ''; + proc.SessionID = ''; + const result = await proc.CheckToken({ Token: ValidToken }); + assert.ok(result.Success); + assert.strictEqual(proc.Username, 'alice'); + assert.strictEqual(proc.SessionID, '', 'a token alone brings no PHPSESSID'); + assert.strictEqual(proc.Fetch.mock.calls.length, 0, 'no round-trip to xmoj'); +}); + +test('CheckToken rejects a username sent alongside a token', async () => { + const proc = createProcess(); + await assert.rejects(proc.CheckToken({ Token: ValidToken, Username: 'mallory' }), (thrown) => thrown.Success === false); +}); + +test('CheckToken reports an unknown token as invalid', async () => { + const proc = createProcess({ db: { Select: selectByTable({}) } }); + const result = await proc.CheckToken({ Token: ValidToken }); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Data.TokenInvalid, true); +}); + +test('CheckToken reports a malformed token as invalid without a lookup', async () => { + const select = selectByTable({}); + const proc = createProcess({ db: { Select: select } }); + const result = await proc.CheckToken({ Token: 'not-a-token' }); + assert.strictEqual(result.Data.TokenInvalid, true); + assert.strictEqual(select.mock.calls.length, 0); +}); + +test('CheckToken expires and deletes a token unused for 30 days', async () => { + const del = test.mock.fn(async () => new Result(true, '')); + const proc = createProcess({ + db: { + Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() - 1000 * 60 * 60 * 24 * 31 }] }), + Delete: del + } + }); + const result = await proc.CheckToken({ Token: ValidToken }); + assert.strictEqual(result.Data.TokenInvalid, true); + assert.strictEqual(del.mock.calls[0].arguments[0], 'session_token'); +}); + +test('CheckToken refreshes last_used at most once a day', async () => { + const update = test.mock.fn(async () => new Result(true, '')); + const fresh = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() - 1000 * 60 }] }), Update: update } + }); + await fresh.CheckToken({ Token: ValidToken }); + assert.strictEqual(update.mock.calls.length, 0); + const stale = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() - 1000 * 60 * 60 * 25 }] }), Update: update } + }); + await stale.CheckToken({ Token: ValidToken }); + assert.strictEqual(update.mock.calls.length, 1); + assert.strictEqual(update.mock.calls[0].arguments[0], 'session_token'); +}); + +test('CheckToken refuses a PHPSESSID that belongs to someone else', async () => { + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }) }, + fetch: async () => new Response("user_id=mallory'") + }); + const result = await proc.CheckToken({ Token: ValidToken, SessionID: 'malloryssession' }); + assert.strictEqual(result.Success, false); + assert.strictEqual(proc.SessionID, '', 'the borrowed cookie must not be kept'); +}); + +test('CheckToken accepts the token owner\'s own PHPSESSID', async () => { + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }) }, + fetch: async () => new Response("user_id=alice'") + }); + const result = await proc.CheckToken({ Token: ValidToken, SessionID: 'alicessession' }); + assert.ok(result.Success); + assert.strictEqual(proc.Username, 'alice'); + assert.strictEqual(proc.SessionID, 'alicessession'); +}); + +test('GetStd asks for a session only when the score is not cached', async () => { + const proc = createProcess({ db: { Select: selectByTable({}) } }); + proc.SessionID = ''; + await assert.rejects(proc.ProcessFunctions['GetStd']({ ProblemID: 1000 }), (thrown) => { + assert.strictEqual(thrown.Data.SessionRequired, true); + return true; + }); + assert.strictEqual(proc.Fetch.mock.calls.length, 0); +}); + +test('GetStd serves a cached passing score without a session or a scrape', async () => { + const proc = createProcess({ + db: { + Select: selectByTable({ + problem_score: [{ score: 100 }], + std_answer: [{ std_code: 'int main(){}' }] + }) + } + }); + proc.SessionID = ''; + const result = await proc.ProcessFunctions['GetStd']({ ProblemID: 1000 }); + assert.ok(result.Success); + assert.strictEqual(proc.Fetch.mock.calls.length, 0); +}); + +test('A scraped score is cached for next time', async () => { + const insert = test.mock.fn(async () => new Result(true, '', { InsertID: 1 })); + const proc = createProcess({ db: { Select: selectByTable({}), Insert: insert } }); + proc.GetProblemScore = async () => 100; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); + assert.strictEqual(insert.mock.calls[0].arguments[0], 'problem_score'); + assert.deepStrictEqual(insert.mock.calls[0].arguments[1], { user_id: 'testuser', problem_id: 1000, score: 100 }); +}); + +test('IfUserExist never sends the caller\'s cookie', async () => { + const proc = createProcess({ + db: { GetTableSize: async () => new Result(true, '', { TableSize: 0 }) }, + fetch: async () => new Response('some content') + }); + await proc.IfUserExist('someone'); + assert.strictEqual(proc.Fetch.mock.calls[0].arguments[1], false); +}); + +test('IfUserExist counts users who only have a session token', async () => { + const proc = createProcess({ + db: { GetTableSize: async (table) => new Result(true, '', { TableSize: table === 'session_token' ? 1 : 0 }) } + }); + const result = await proc.IfUserExist('alice'); + assert.strictEqual(result.Data.Exist, true); + assert.strictEqual(proc.Fetch.mock.calls.length, 0); +}); + +test('Login issues a token and stores only its hash', async () => { + const insert = test.mock.fn(async () => new Result(true, '', { InsertID: 1 })); + const proc = createProcess({ db: { Insert: insert } }); + const result = await proc.ProcessFunctions['Login']({}); + assert.ok(result.Success); + assert.match(result.Data.Token, /^[0-9a-f]{64}$/); + const [table, row] = insert.mock.calls[0].arguments; + assert.strictEqual(table, 'session_token'); + assert.strictEqual(row.user_id, 'testuser'); + assert.strictEqual(row.token_hash, HashSessionToken(result.Data.Token)); + assert.ok(!JSON.stringify(row).includes(result.Data.Token)); +}); + +test('Login refuses a request that only carried a token', async () => { + const proc = createProcess(); + proc.SessionID = ''; + const result = await proc.ProcessFunctions['Login']({}); + assert.strictEqual(result.Success, false); +}); + +test('Logout revokes the token the request came with', async () => { + const del = test.mock.fn(async () => new Result(true, '')); + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }), Delete: del } + }); + await proc.CheckToken({ Token: ValidToken }); + await proc.ProcessFunctions['Logout']({}); + assert.strictEqual(del.mock.calls[0].arguments[0], 'session_token'); + assert.deepStrictEqual(del.mock.calls[0].arguments[1], { token_hash: HashSessionToken(ValidToken) }); +}); + +function apiRequest(path, authentication) { + return new Request('https://api.xmoj-script.uk/' + path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ Authentication: authentication, Data: {}, Version: '4.0.1', DebugMode: false }) + }); +} + +test('Process checks a token once and passes TokenInvalid to the client', async () => { + const select = selectByTable({}); + const proc = createProcess({ db: { Select: select }, req: apiRequest('GetBoards', { Token: ValidToken }) }); + const body = await (await proc.Process()).json(); + assert.strictEqual(body.Success, false); + assert.strictEqual(body.Data.TokenInvalid, true); + assert.strictEqual(select.mock.calls.length, 1, 'no retry for a database answer'); +}); + +test('Process checks a valid legacy session once, not three times', async () => { + const proc = createProcess({ + db: { Select: async () => new Result(true, '', [{ user_id: 'testuser', create_time: Date.now() }]) }, + req: apiRequest('GetBoards', { SessionID: 'testsession', Username: 'testuser' }) + }); + const check = test.mock.method(proc, 'CheckToken'); + await proc.Process(); + assert.strictEqual(check.mock.calls.length, 1); +}); From 4ae41ef7d06c62386e8116334d9256a3f261c173 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 17:51:36 +0800 Subject: [PATCH 2/3] Address review: mint only on handshakes, drop spoofed tokens, race-safe score cache - Only mint a token over the WebSocket for a real Upgrade request. - Never forward a client-supplied issuedToken to the Durable Object. - Caching a score never fails the request; losing the insert race raises the cached score instead of erroring on the primary key. - Index session_token.last_used for the daily cleanup. - Drop ExpireTime from Login: the lifetime slides, so it went stale. - Tests match token lookups on the bound hash. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- CLAUDE.md | 2 +- Source/Process.ts | 18 ++++++++++------ Source/SessionToken.ts | 6 ++++-- Source/index.ts | 8 ++++++- migrations/0006_add_session_token.sql | 2 ++ test/index.test.js | 30 +++++++++++++++++++++------ test/process.test.js | 29 ++++++++++++++++++++++++++ 7 files changed, 79 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b5dd373..af16d8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ No routing configuration needed - pathname automatically maps to function name. #### Authentication - `CheckToken` accepts either a backend-issued `{Token}` (current clients) or a legacy `{SessionID, Username}` (old clients) -- `Login` swaps a verified PHPSESSID for a token (`Source/SessionToken.ts`); tokens are stored as SHA3 hashes in `session_token`, expire after 30 days unused, and are revoked by `Logout`. The username always comes from the token row +- `Login` (in `Source/Process.ts`) swaps a verified PHPSESSID for a token issued by `Source/SessionToken.ts`; tokens are stored as SHA3 hashes in `session_token`, expire after 30 days unused, and are revoked by `Logout`. The username always comes from the token row - Legacy PHPSESSIDs are validated against the XMOJ website and cached as SHA3 hashes in the `phpsessid` table - Token requests carry no PHPSESSID. Code that must scrape xmoj as the user calls `this.RequireSession()` first, which answers `SessionRequired`; the client then retries once with `{Token, SessionID}`. Keep such scrapes rare, since we want to see users' PHPSESSIDs as little as possible - `problem_score` caches users' best scores so `GetStd` doesn't need to scrape (and so doesn't need a session) once a score has cleared the threshold diff --git a/Source/Process.ts b/Source/Process.ts index 8ac5fff..024ea12 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -540,19 +540,25 @@ export class Process { } this.RequireSession(); const Score = await this.GetProblemScore(ProblemID); - // The cache is an optimisation; a failed or racing write only costs a - // re-scrape next time. + // The cache is an optimisation, so a failed write must not fail the + // request. Two requests can both see no row; the loser's insert hits the + // primary key and falls back to raising the score, never lowering it. if (Score > CachedScore) { + let Inserted = false; if (Cached.length === 0) { - await this.XMOJDatabase.Insert("problem_score", { + Inserted = await this.XMOJDatabase.Insert("problem_score", { user_id: this.Username, problem_id: ProblemID, score: Score - }); - } else { + }).then((InsertResult) => InsertResult.Success, () => false); + } + if (!Inserted) { await this.XMOJDatabase.Update("problem_score", {score: Score}, { user_id: this.Username, - problem_id: ProblemID + problem_id: ProblemID, + score: {Operator: "<", Value: Score} + }).catch((Error) => { + Output.Warn("Caching problem score failed: " + JSON.stringify(Error)); }); } } diff --git a/Source/SessionToken.ts b/Source/SessionToken.ts index 34e3b0d..802d4cc 100644 --- a/Source/SessionToken.ts +++ b/Source/SessionToken.ts @@ -40,7 +40,9 @@ export const HashSessionToken = (Token: string): string => { return CryptoJS.SHA3(Token).toString(); }; -export const IssueSessionToken = async (XMOJDatabase: Database, Username: string): Promise<{ Token: string; ExpireTime: number }> => { +// No expiry is handed out: the lifetime slides with use, so any fixed time +// would go stale. +export const IssueSessionToken = async (XMOJDatabase: Database, Username: string): Promise<{ Token: string }> => { const Bytes = new Uint8Array(32); crypto.getRandomValues(Bytes); const Token = Array.from(Bytes, (Byte) => Byte.toString(16).padStart(2, "0")).join(""); @@ -51,7 +53,7 @@ export const IssueSessionToken = async (XMOJDatabase: Database, Username: string create_time: Now, last_used: Now })); - return {Token, ExpireTime: Now + SessionTokenLifetime}; + return {Token}; }; // Returns the user the token was issued to, or "" if it is unknown or expired. diff --git a/Source/index.ts b/Source/index.ts index 7fdb3bc..726b2ad 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -146,7 +146,10 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr return new Response("Invalid SessionID", {status: 401}); } userId = await ValidateSession(sessionID); - if (userId !== "" && url.searchParams.get("IssueToken") === "1") { + // Only mint for a real WebSocket handshake; anything else gets a 426 + // from the Durable Object and would never see the token. + if (userId !== "" && url.searchParams.get("IssueToken") === "1" && + RequestData.headers.get("Upgrade") === "websocket") { issuedToken = (await IssueSessionToken(new Database(Environment.DB), userId)).Token; } } @@ -161,6 +164,9 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr forwardURL.searchParams.delete("SessionID"); forwardURL.searchParams.delete("Token"); forwardURL.searchParams.delete("IssueToken"); + // The Durable Object trusts issuedToken as ours, so never pass on one + // the client supplied. + forwardURL.searchParams.delete("issuedToken"); if (issuedToken !== "") { forwardURL.searchParams.set("issuedToken", issuedToken); } diff --git a/migrations/0006_add_session_token.sql b/migrations/0006_add_session_token.sql index ce8f8c6..fbc75b6 100644 --- a/migrations/0006_add_session_token.sql +++ b/migrations/0006_add_session_token.sql @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS session_token ( last_used INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS session_token_user_id ON session_token (user_id); +-- The daily cleanup deletes by last_used. +CREATE INDEX IF NOT EXISTS session_token_last_used ON session_token (last_used); -- Best score a user has reached on a problem. Scores only go up, so once a -- score clears a threshold it never needs to be scraped from xmoj again. diff --git a/test/index.test.js b/test/index.test.js index e58861d..44d656a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -32,8 +32,10 @@ test('RequestLogLine leaves error unset on success', () => { const Worker = require('../Source/index.ts').default; const { HashSessionToken } = require('../Source/SessionToken.ts'); -// Answers SELECTs on session_token with `tokenRows` and records every query. -function wsEnvironment(tokenRows = []) { +// Answers SELECTs on session_token from `tokens`, a map of token to row, +// matched on the hash the query binds, and records every query. +function wsEnvironment(tokens = {}) { + const rowsByHash = Object.fromEntries(Object.entries(tokens).map(([token, row]) => [HashSessionToken(token), row])); const queries = []; const forwarded = []; const DB = { @@ -45,7 +47,8 @@ function wsEnvironment(tokenRows = []) { all: async () => { queries.push({ q, args }); if (q.startsWith('SELECT') && q.includes('session_token')) { - return { results: tokenRows, meta: {} }; + const row = rowsByHash[args[0]]; + return { results: row ? [row] : [], meta: {} }; } return { results: [], meta: { last_row_id: 1, changes: 1 } }; } @@ -71,8 +74,8 @@ const ProfilePage = (user) => new Response(" { const xmoj = t.mock.method(global, 'fetch', async () => ProfilePage('mallory')); - const { env, forwarded } = wsEnvironment([{ user_id: 'alice', last_used: Date.now() }]); const token = 'b'.repeat(64); + const { env, forwarded } = wsEnvironment({ [token]: { user_id: 'alice', last_used: Date.now() } }); await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?Token=' + token), env); assert.strictEqual(xmoj.mock.calls.length, 0); assert.strictEqual(forwarded[0].searchParams.get('userId'), 'alice'); @@ -80,7 +83,7 @@ test('WebSocket with a token resolves the user without touching xmoj', async (t) }); test('WebSocket with an unknown token is unauthorized', async () => { - const { env, forwarded } = wsEnvironment([]); + const { env, forwarded } = wsEnvironment({ ['b'.repeat(64)]: { user_id: 'alice', last_used: Date.now() } }); const response = await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?Token=' + 'c'.repeat(64)), env); assert.strictEqual(response.status, 401); assert.strictEqual(forwarded.length, 0); @@ -89,7 +92,7 @@ test('WebSocket with an unknown token is unauthorized', async () => { test('WebSocket with a PHPSESSID and IssueToken=1 mints a token for the connected message', async (t) => { t.mock.method(global, 'fetch', async () => ProfilePage('alice')); const { env, queries, forwarded } = wsEnvironment(); - await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&IssueToken=1'), env); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&IssueToken=1', { headers: { Upgrade: 'websocket' } }), env); const issued = forwarded[0].searchParams.get('issuedToken'); assert.match(issued, /^[0-9a-f]{64}$/); assert.strictEqual(forwarded[0].searchParams.get('SessionID'), null); @@ -107,3 +110,18 @@ test('WebSocket with only a PHPSESSID behaves as before', async (t) => { assert.strictEqual(forwarded[0].searchParams.get('issuedToken'), null); assert.strictEqual(queries.length, 0, 'no token minted'); }); + +test('WebSocket mints no token for a request that is not a WebSocket handshake', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, queries, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&IssueToken=1'), env); + assert.strictEqual(queries.length, 0); + assert.strictEqual(forwarded[0].searchParams.get('issuedToken'), null); +}); + +test('WebSocket drops an issuedToken the client supplied', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&issuedToken=' + 'd'.repeat(64)), env); + assert.strictEqual(forwarded[0].searchParams.get('issuedToken'), null); +}); diff --git a/test/process.test.js b/test/process.test.js index 5eb7e4e..749fcaf 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -1498,3 +1498,32 @@ test('Process checks a valid legacy session once, not three times', async () => await proc.Process(); assert.strictEqual(check.mock.calls.length, 1); }); + +test('Losing the race to cache a score raises the cached one instead of failing the request', async () => { + const update = test.mock.fn(async () => new Result(true, '', { Changes: 1 })); + const proc = createProcess({ + db: { + Select: selectByTable({}), + Insert: async () => { throw new Result(false, 'UNIQUE constraint failed'); }, + Update: update + } + }); + proc.GetProblemScore = async () => 100; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); + const [table, data, condition] = update.mock.calls[0].arguments; + assert.strictEqual(table, 'problem_score'); + assert.deepStrictEqual(data, { score: 100 }); + assert.deepStrictEqual(condition.score, { Operator: '<', Value: 100 }, 'never lowers a cached score'); +}); + +test('A failed score cache write does not fail the request', async () => { + const proc = createProcess({ + db: { + Select: selectByTable({}), + Insert: async () => { throw new Result(false, 'db down'); }, + Update: async () => { throw new Result(false, 'db down'); } + } + }); + proc.GetProblemScore = async () => 100; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); +}); From 27147c97232b84dbc4e6c75abeb5ba46e7e8e0a8 Mon Sep 17 00:00:00 2001 From: boomzero Date: Wed, 7 Oct 2026 17:57:05 +0800 Subject: [PATCH 3/3] Trust a higher score cached by a racing request If our conditional update changes nothing, a concurrent request already cached a score at least as high, from a newer scrape. Return that rather than our stale one, so an eligible user isn't refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- Source/Process.ts | 16 ++++++++++++++-- test/process.test.js | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index 024ea12..e8c7ea1 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -553,13 +553,25 @@ export class Process { }).then((InsertResult) => InsertResult.Success, () => false); } if (!Inserted) { - await this.XMOJDatabase.Update("problem_score", {score: Score}, { + const Changes = await this.XMOJDatabase.Update("problem_score", {score: Score}, { user_id: this.Username, problem_id: ProblemID, score: {Operator: "<", Value: Score} - }).catch((Error) => { + }).then((UpdateResult) => UpdateResult.Data["Changes"], (Error) => { Output.Warn("Caching problem score failed: " + JSON.stringify(Error)); + return -1; }); + // Nothing changed: a request racing us already cached at least this + // much, and its scrape is newer than ours, so trust it. + if (Changes === 0) { + const Latest = await this.XMOJDatabase.Select("problem_score", ["score"], { + user_id: this.Username, + problem_id: ProblemID + }).then((SelectResult) => SelectResult.Data as Array>, () => []); + if (Latest.length > 0) { + return Math.max(Score, Latest[0]["score"]); + } + } } } return Score; diff --git a/test/process.test.js b/test/process.test.js index 749fcaf..3e7d3f4 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -1527,3 +1527,17 @@ test('A failed score cache write does not fail the request', async () => { proc.GetProblemScore = async () => 100; assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); }); + +test('A score cached by a racing request wins over our older, lower scrape', async () => { + let selects = 0; + const proc = createProcess({ + db: { + // Empty before the scrape; by the time we write, a racer cached 100. + Select: async () => new Result(true, '', selects++ === 0 ? [] : [{ score: 100 }]), + Insert: async () => { throw new Result(false, 'UNIQUE constraint failed'); }, + Update: async () => new Result(true, '', { Changes: 0 }) + } + }); + proc.GetProblemScore = async () => 40; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); +});