diff --git a/standalone/src/auth.js b/standalone/src/auth.js index 9742131..9f293e1 100644 --- a/standalone/src/auth.js +++ b/standalone/src/auth.js @@ -59,7 +59,7 @@ export const auth = new Elysia({ const hashedToken = await Bun.password.hash(session_token); await db` - INSERT INTO sessions (token, created, expires) + INSERT INTO ${db("sessions")} (token, created, expires) VALUES (${hashedToken}, ${created}, ${expires}) `; @@ -87,7 +87,7 @@ export const authBeforeHandle = async ({ set, headers }) => { return { success: false, error: "Unauthorized. Invalid bot token." }; } - const apiKey = await db`SELECT * FROM api_keys WHERE id = ${id}`.then( + const apiKey = await db`SELECT * FROM ${db("api_keys")} WHERE id = ${id}`.then( (rows) => rows[0] ); @@ -121,7 +121,7 @@ export const authBeforeHandle = async ({ set, headers }) => { ); const [validToken] = await db` - SELECT * FROM sessions WHERE token = ${hash} AND expires > ${Date.now()} LIMIT 1 + SELECT * FROM ${db("sessions")} WHERE token = ${hash} AND expires > ${Date.now()} LIMIT 1 `; if (!validToken) { diff --git a/standalone/src/cap.js b/standalone/src/cap.js index 886e622..5f77a55 100644 --- a/standalone/src/cap.js +++ b/standalone/src/cap.js @@ -29,11 +29,11 @@ export const capServer = new Elysia({ const cap = new Cap({ noFSState: true, }); - const [_keyConfig] = await db`SELECT (config) FROM keys WHERE siteKey = ${params.siteKey}`; + const [_keyConfig] = await db`SELECT config FROM ${db("keys")} WHERE siteKey = ${params.siteKey} LIMIT 1`; if (!_keyConfig) { set.status = 404; - return { error: "Invalid site key or secret" }; + return { error: "Site key not found" }; } const keyConfig = JSON.parse(_keyConfig.config); @@ -45,7 +45,7 @@ export const capServer = new Elysia({ }); await db` - INSERT INTO challenges (siteKey, token, data, expires) + INSERT INTO ${db("challenges")} (siteKey, token, data, expires) VALUES (${params.siteKey}, ${challenge.token}, ${Object.values(challenge.challenge).join(",")}, ${challenge.expires}) `; @@ -53,11 +53,11 @@ export const capServer = new Elysia({ }) .post("/:siteKey/redeem", async ({ body, set, params }) => { const [challenge] = await db` - SELECT * FROM challenges WHERE siteKey = ${params.siteKey} AND token = ${body.token} + SELECT * FROM ${db("challenges")} WHERE siteKey = ${params.siteKey} AND token = ${body.token} LIMIT 1 `; try { - await db`DELETE FROM challenges WHERE siteKey = ${params.siteKey} AND token = ${body.token}`; + await db`DELETE FROM ${db("challenges")} WHERE siteKey = ${params.siteKey} AND token = ${body.token}`; } catch { set.status = 404; return { error: "Challenge not found" }; @@ -92,18 +92,26 @@ export const capServer = new Elysia({ } await db` - INSERT INTO tokens (siteKey, token, expires) + INSERT INTO ${db("tokens")} (siteKey, token, expires) VALUES (${params.siteKey}, ${token}, ${expires}) `; const now = Math.floor(Date.now() / 1000); const hourlyBucket = Math.floor(now / 3600) * 3600; - await db` - INSERT INTO solutions (siteKey, bucket, count) - VALUES (${params.siteKey}, ${hourlyBucket}, 1) - ON CONFLICT (siteKey, bucket) - DO UPDATE SET count = count + 1 - `; + if (db.provider === "sqlite" || db.provider === "postgres") { + await db` + INSERT INTO solutions (siteKey, bucket, count) + VALUES (${params.siteKey}, ${hourlyBucket}, 1) + ON CONFLICT (siteKey, bucket) + DO UPDATE SET count = solutions.count + 1 + `; + } else { // Mysql is special... + await db` + INSERT INTO ${db("solutions")} (siteKey, bucket, count) + VALUES (${params.siteKey}, ${hourlyBucket}, 1) + ON DUPLICATE KEY UPDATE count = ${db("solutions")}.count + 1 + `; + } return { success: true, diff --git a/standalone/src/db.js b/standalone/src/db.js index 2253216..544ea71 100644 --- a/standalone/src/db.js +++ b/standalone/src/db.js @@ -1,6 +1,6 @@ import fs from "node:fs"; import { join } from "node:path"; -import { SQL } from "bun"; +import { SQL, sql } from "bun"; fs.mkdirSync(process.env.DATA_PATH || "./.data", { recursive: true, @@ -11,70 +11,124 @@ let db; async function initDb() { const dbUrl = process.env.DB_URL || `sqlite://${join(process.env.DATA_PATH || "./.data", "db.sqlite")}`; - db = new SQL(dbUrl); - - await db`create table if not exists sessions ( - token text primary key not null, - expires integer not null, - created integer not null + db = new SQL({ + url: dbUrl, + bigint: true, + }); + + const isSqlite = db.options.adapter === "sqlite"; + const isPostgres = db.options.adapter === "postgres"; + const changeIntToBigInt = async (tbl, col) => { + if (isSqlite) return; // Irrelevant in SQLite. + if (isPostgres) { + await db`alter table ${db(tbl)} alter column ${db(col)} type bigint`.simple(); + } else { // Mysql needs modify column syntax... + await db`alter table ${db(tbl)} modify column ${db(col)} bigint`.simple(); + } + }; + + let siteKeyColType, tokenColType, apiTokenIdColType, apiTokenHashColType; + if (isSqlite || isPostgres) { + siteKeyColType = tokenColType = apiTokenIdColType = apiTokenHashColType = sql`text`; + } else { + // MySQL requires a prefix length for indices, thus cannot index `text`. + // The maximum size for an index, however, is 3072 bytes (or 767 characters using utf8mb4). + // However, combined keys also count towards this limit. + + siteKeyColType = sql`varchar(31)`; // Site keys are quite short. Generated as 5 bytes hex (10 chars). + tokenColType = sql`varchar(255)`; // Used in combination with site keys. Generated as 25 bytes hex. + apiTokenIdColType = sql`varchar(63)`; // IDs are generated as 16 bytes hex (32 chars). + apiTokenHashColType = sql`varchar(255)`; // Tokens are 32 bytes base64 encoded, then hashed using Bun's algorithm. + + // Combinations: + // - solutions (siteKey + bucket): siteKey (31 chars => 124 bytes) + bucket (bigint => 8 bytes) = 132 bytes + // - challenges (siteKey + token): siteKey (31 chars => 124 bytes) + token (255 chars => 1020 bytes) = 1144 bytes + // - tokens (siteKey + token): siteKey (31 chars => 124 bytes) + token (255 chars => 1020 bytes) = 1144 bytes + // - api_keys (id + tokenHash): id (63 chars => 252 bytes) + tokenHash (255 chars => 1020 bytes) = 1272 bytes + } + + await db`create table if not exists ${db("sessions")} ( + token ${tokenColType} primary key not null, + expires bigint not null, + created bigint not null )`.simple(); + await changeIntToBigInt("sessions", "expires"); + await changeIntToBigInt("sessions", "created"); - await db`create table if not exists keys ( - siteKey text primary key not null, + await db`create table if not exists ${db("keys")} ( + siteKey ${siteKeyColType} primary key not null, name text not null, secretHash text not null, config text not null, - created integer not null + created bigint not null )`.simple(); + await changeIntToBigInt("keys", "created"); - await db`create table if not exists solutions ( - siteKey text not null, - bucket integer not null, + await db`create table if not exists ${db("solutions")} ( + siteKey ${siteKeyColType} not null, + bucket bigint not null, count integer default 0, primary key (siteKey, bucket) )`.simple(); + await changeIntToBigInt("solutions", "bucket"); - await db`create table if not exists challenges ( - siteKey text not null, - token text not null, + await db`create table if not exists ${db("challenges")} ( + siteKey ${siteKeyColType} not null, + token ${tokenColType} not null, data text not null, - expires integer not null, + expires bigint not null, primary key (siteKey, token) )`.simple(); + await changeIntToBigInt("challenges", "expires"); - await db`create table if not exists tokens ( - siteKey text not null, - token text not null, - expires integer not null, + await db`create table if not exists ${db("tokens")} ( + siteKey ${siteKeyColType} not null, + token ${tokenColType} not null, + expires bigint not null, primary key (siteKey, token) )`.simple(); + await changeIntToBigInt("tokens", "expires"); - await db`create table if not exists api_keys ( - id text not null, + await db`create table if not exists ${db("api_keys")} ( + id ${apiTokenIdColType} not null, name text not null, - tokenHash text not null, - created integer not null, + tokenHash ${apiTokenHashColType} not null, + created bigint not null, primary key (id, tokenHash) )`.simple(); + await changeIntToBigInt("api_keys", "created"); - setInterval(async () => { - const now = Date.now(); - - await db`delete from sessions where expires < ${now}`; - await db`delete from tokens where expires < ${now}`; - await db`delete from challenges where expires < ${now}`; - }, 60 * 1000); + setInterval(periodicCleanup, 60 * 1000); + await periodicCleanup(); + return db; +} +async function periodicCleanup() { const now = Date.now(); - await db`delete from sessions where expires < ${now}`; - await db`delete from tokens where expires < ${now}`; - await db`delete from challenges where expires < ${now}`; - - return db; + await db`delete from ${db("sessions")} where expires < ${now}`; + await db`delete from ${db("tokens")} where expires < ${now}`; + await db`delete from ${db("challenges")} where expires < ${now}`; } db = await initDb(); -export { db }; +const maxSafeIntegerBigInt = BigInt(Number.MAX_SAFE_INTEGER); +const minSafeIntegerBigInt = BigInt(Number.MIN_SAFE_INTEGER); +const numberFromDb = (value) => { + if (typeof value === "number") return value; + if (typeof value === "string" && /^-?\d+$/.test(value)) { + const bigIntValue = BigInt(value); + if (bigIntValue <= maxSafeIntegerBigInt && bigIntValue >= minSafeIntegerBigInt) { + return Number(bigIntValue); + } + } + return Number(value); +} +const dateFromDb = (value) => { + if (value instanceof Date) return value; + return new Date(numberFromDb(value)); +}; + +export { db, numberFromDb, dateFromDb }; diff --git a/standalone/src/server.js b/standalone/src/server.js index 96ff407..53297bb 100644 --- a/standalone/src/server.js +++ b/standalone/src/server.js @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { Elysia, t } from "elysia"; import { rateLimit } from "elysia-rate-limit"; import { authBeforeHandle } from "./auth.js"; -import { db } from "./db.js"; +import {dateFromDb, db, numberFromDb} from "./db.js"; import { ratelimitGenerator } from "./ratelimit.js"; const keyDefaults = { @@ -39,20 +39,21 @@ export const server = new Elysia({ const currentStart = now - day; const previousStart = now - 2 * day; - const keys = await db`SELECT * FROM keys ORDER BY created DESC`; + const keys = await db`SELECT * FROM ${db("keys")} ORDER BY created DESC`; return await Promise.all( keys.map(async (key) => { const [currentResult] = await db` - SELECT SUM(count) as total FROM solutions WHERE siteKey = ${key.sitekey || key.siteKey} AND bucket >= ${currentStart} + SELECT SUM(count) as total FROM ${db("solutions")} + WHERE siteKey = ${key.sitekey || key.siteKey} AND bucket >= ${currentStart} `; const [previousResult] = await db` - SELECT SUM(count) as total FROM solutions + SELECT SUM(count) as total FROM ${db("solutions")} WHERE siteKey = ${key.sitekey || key.siteKey} AND bucket >= ${previousStart} AND bucket < ${currentStart} `; - const current = currentResult?.total || 0; - const previous = previousResult?.total || 0; + const current = numberFromDb(currentResult?.total || 0); + const previous = numberFromDb(previousResult?.total || 0); let change = 0; let direction = ""; @@ -69,7 +70,7 @@ export const server = new Elysia({ return { siteKey: key.sitekey || key.siteKey, name: key.name, - created: key.created, + created: numberFromDb(key.created), solvesLast24h: current, difference: { value: change.toFixed(2), @@ -96,7 +97,7 @@ export const server = new Elysia({ .replace(/=+$/, ""); await db` - INSERT INTO keys (siteKey, name, secretHash, config, created) + INSERT INTO ${db("keys")} (siteKey, name, secretHash, config, created) VALUES (${siteKey}, ${body?.name || siteKey}, ${await Bun.password.hash(secretKey)}, ${JSON.stringify(keyDefaults)}, ${Date.now()}) `; @@ -117,7 +118,7 @@ export const server = new Elysia({ .get( "/keys/:siteKey", async ({ params, query }) => { - const [key] = await db`SELECT * FROM keys WHERE siteKey = ${params.siteKey}`; + const [key] = await db`SELECT * FROM ${db("keys")} WHERE siteKey = ${params.siteKey}`; if (!key) { return { success: false, error: "Key not found" }; } @@ -173,7 +174,7 @@ export const server = new Elysia({ if (chartDuration === "yesterday") { historyData = await db` SELECT bucket, SUM(count) as count - FROM solutions + FROM ${db("solutions")} WHERE siteKey = ${params.siteKey} AND bucket >= ${startTime} AND bucket < ${startTime + 86400} GROUP BY bucket ORDER BY bucket @@ -182,7 +183,7 @@ export const server = new Elysia({ const currentHourBucket = Math.floor(now / 3600) * 3600; historyData = await db` SELECT bucket, SUM(count) as count - FROM solutions + FROM ${db("solutions")} WHERE siteKey = ${params.siteKey} AND bucket >= ${startTime} AND bucket <= ${currentHourBucket} GROUP BY bucket ORDER BY bucket @@ -190,7 +191,7 @@ export const server = new Elysia({ } else if (dataQuery.type === "aggregate") { historyData = await db` SELECT (bucket / 86400) * 86400 as bucket, SUM(count) as count - FROM solutions + FROM ${db("solutions")} WHERE siteKey = ${params.siteKey} AND bucket >= ${startTime} GROUP BY (bucket / 86400) * 86400 ORDER BY bucket @@ -198,7 +199,7 @@ export const server = new Elysia({ } else { historyData = await db` SELECT bucket, SUM(count) as count - FROM solutions + FROM ${db("solutions")} WHERE siteKey = ${params.siteKey} AND bucket >= ${startTime} GROUP BY bucket ORDER BY bucket @@ -218,7 +219,7 @@ export const server = new Elysia({ : 91; const completeData = []; const dataMap = new Map( - historyData.map((item) => [item.bucket, item.count]) + historyData.map((item) => [numberFromDb(item.bucket), numberFromDb(item.count)]) ); const currentDayStart = Math.floor(now / 86400) * 86400; @@ -235,7 +236,7 @@ export const server = new Elysia({ } else if (chartDuration === "today") { const completeData = []; const dataMap = new Map( - historyData.map((item) => [item.bucket, item.count]) + historyData.map((item) => [numberFromDb(item.bucket), numberFromDb(item.count)]) ); const currentHour = Math.floor(now / 3600); @@ -253,15 +254,16 @@ export const server = new Elysia({ } const [currentSolvesResult] = await db` - SELECT SUM(count) as total FROM solutions WHERE siteKey = ${params.siteKey} AND bucket >= ${currentStart} + SELECT SUM(count) as total FROM ${db("solutions")} + WHERE siteKey = ${params.siteKey} AND bucket >= ${currentStart} `; - const currentSolves = currentSolvesResult?.total || 0; + const currentSolves = numberFromDb(currentSolvesResult?.total || 0); return { key: { siteKey: key.sitekey || key.siteKey, name: key.name, - created: key.created, + created: numberFromDb(key.created), config: JSON.parse(key.config), }, stats: { @@ -298,7 +300,7 @@ export const server = new Elysia({ .put( "/keys/:siteKey/config", async ({ params, body }) => { - const [key] = await db`SELECT * FROM keys WHERE siteKey = ${params.siteKey}`; + const [key] = await db`SELECT * FROM ${db("keys")} WHERE siteKey = ${params.siteKey} LIMIT 1`; if (!key) { return { success: false, error: "Key not found" }; } @@ -312,7 +314,9 @@ export const server = new Elysia({ saltSize, }; - await db`UPDATE keys SET name = ${config.name || key.name}, config = ${JSON.stringify(config)} WHERE siteKey = ${params.siteKey}`; + await db`UPDATE ${db("keys")} + SET name = ${config.name || key.name}, config = ${JSON.stringify(config)} + WHERE siteKey = ${params.siteKey}`; return { success: true }; }, @@ -331,14 +335,14 @@ export const server = new Elysia({ .delete( "/keys/:siteKey", async ({ params, set }) => { - const [key] = await db`SELECT * FROM keys WHERE siteKey = ${params.siteKey}`; + const [key] = await db`SELECT * FROM ${db("keys")} WHERE siteKey = ${params.siteKey}`; if (!key) { set.status = 404; return { success: false, error: "Key not found" }; } - await db`DELETE FROM keys WHERE siteKey = ${params.siteKey}`; - await db`DELETE FROM solutions WHERE siteKey = ${params.siteKey}`; + await db`DELETE FROM ${db("keys")} WHERE siteKey = ${params.siteKey}`; + await db`DELETE FROM ${db("solutions")} WHERE siteKey = ${params.siteKey}`; return { success: true }; }, @@ -354,7 +358,7 @@ export const server = new Elysia({ .post( "/keys/:siteKey/rotate-secret", async ({ params }) => { - const [key] = await db`SELECT * FROM keys WHERE siteKey = ${params.siteKey}`; + const [key] = await db`SELECT * FROM ${db("keys")} WHERE siteKey = ${params.siteKey} LIMIT 1`; if (!key) { return { success: false, error: "Key not found" }; } @@ -364,7 +368,9 @@ export const server = new Elysia({ .replace(/\//g, "") .replace(/=+$/, ""); - await db`UPDATE keys SET secretHash = ${await Bun.password.hash(newSecretKey)} WHERE siteKey = ${params.siteKey}`; + await db`UPDATE ${db("keys")} + SET secretHash = ${await Bun.password.hash(newSecretKey)} + WHERE siteKey = ${params.siteKey}`; return { secretKey: newSecretKey, }; @@ -381,11 +387,11 @@ export const server = new Elysia({ .get( "/settings/sessions", async () => { - const sessions = await db`SELECT * FROM sessions`; + const sessions = await db`SELECT * FROM ${db("sessions")}`; return sessions.map((session) => ({ token: session.token.slice(-14), - expires: new Date(session.expires).toISOString(), - created: new Date(session.created).toISOString(), + expires: dateFromDb(session.expires).toISOString(), + created: dateFromDb(session.created).toISOString(), })); }, { @@ -397,12 +403,11 @@ export const server = new Elysia({ .get( "/settings/apikeys", async () => { - const apikeys = await db`SELECT * FROM api_keys`; - + const apikeys = await db`SELECT * FROM ${db("api_keys")}`; return apikeys.map((key) => ({ name: key.name, id: key.id, - created: new Date(key.created).toISOString(), + created: dateFromDb(key.created).toISOString(), })); }, { @@ -424,7 +429,7 @@ export const server = new Elysia({ const name = body.name; await db` - INSERT INTO api_keys (id, name, tokenHash, created) + INSERT INTO ${db("api_keys")} (id, name, tokenHash, created) VALUES (${id}, ${name}, ${await Bun.password.hash(token)}, ${Date.now()}) `; return { @@ -443,12 +448,12 @@ export const server = new Elysia({ .delete( "/settings/apikeys/:id", async ({ params, set }) => { - const [key] = await db`SELECT * FROM api_keys WHERE id = ${params.id}`; + const [key] = await db`SELECT * FROM ${db("api_keys")} WHERE id = ${params.id} LIMIT 1`; if (!key) { set.status = 404; return { success: false, error: "API key not found" }; } - await db`DELETE FROM api_keys WHERE id = ${params.id}`; + await db`DELETE FROM ${db("api_keys")} WHERE id = ${params.id}`; return { success: true }; }, { @@ -491,7 +496,7 @@ export const server = new Elysia({ // body.session are the last characters of the session token // e.g. body.session = (...)8KdbcHjqxWPR6Q - const sessionRows = await db`SELECT token FROM sessions WHERE token LIKE ${'%' + body.session}`; + const sessionRows = await db`SELECT token FROM ${db("sessions")} WHERE token LIKE ${'%' + body.session}`; const sessionRow = sessionRows[0]; if (!sessionRow) { @@ -501,7 +506,7 @@ export const server = new Elysia({ session = sessionRow.token; } - await db`DELETE FROM sessions WHERE token = ${session}`; + await db`DELETE FROM ${db("sessions")} WHERE token = ${session}`; return { success: true }; }, diff --git a/standalone/src/siteverify.js b/standalone/src/siteverify.js index f510c37..a2018a9 100644 --- a/standalone/src/siteverify.js +++ b/standalone/src/siteverify.js @@ -38,7 +38,7 @@ export const siteverifyServer = new Elysia({ set.headers["X-RateLimit-Limit"] = "1"; set.headers["X-RateLimit-Remaining"] = "0"; set.headers["X-RateLimit-Reset"] = Math.ceil(unblockTime / 1000).toString(); - return { error: "You were temporarily for using an invalid secret key. Please try again later." }; + return { error: "You were temporarily blocked for using an invalid secret key. Please try again later." }; } const sitekey = params.siteKey; @@ -49,11 +49,11 @@ export const siteverifyServer = new Elysia({ return { error: "Missing required parameters" }; } - const [keyData] = await db`SELECT * FROM keys WHERE siteKey = ${sitekey}`; - const keyHash = keyData?.secretHash; - if (!keyHash || !secret) { + const [keyData] = await db`SELECT * FROM ${db("keys")} WHERE siteKey = ${sitekey}`; + const keyHash = keyData?.secretHash || keyData?.secrethash; + if (!keyHash) { set.status = 404; - return { error: "Invalid site key or secret" }; + return { error: "Site key not found" }; } const isValidSecret = await Bun.password.verify(secret, keyHash); @@ -61,10 +61,10 @@ export const siteverifyServer = new Elysia({ if (!isValidSecret) { blockedIPs.set(ip, now + 250); set.status = 403; - return { error: "Invalid site key or secret" }; + return { error: "Invalid secret" }; } - const [token] = await db`SELECT * FROM tokens WHERE siteKey = ${params.siteKey} AND token = ${response}`; + const [token] = await db`SELECT * FROM ${db("tokens")} WHERE siteKey = ${params.siteKey} AND token = ${response}`; if (!token) { set.status = 404; @@ -72,11 +72,11 @@ export const siteverifyServer = new Elysia({ } if (token.expires < Date.now()) { - await db`DELETE FROM tokens WHERE siteKey = ${params.siteKey} AND token = ${response}`; + await db`DELETE FROM ${db("tokens")} WHERE siteKey = ${params.siteKey} AND token = ${response}`; set.status = 403; return { error: "Token expired" }; } - await db`DELETE FROM tokens WHERE siteKey = ${params.siteKey} AND token = ${response}`; + await db`DELETE FROM ${db("tokens")} WHERE siteKey = ${params.siteKey} AND token = ${response}`; return { success: true }; });