diff --git a/standalone/bun.lock b/standalone/bun.lock index 0abdb69..fd7cc10 100644 --- a/standalone/bun.lock +++ b/standalone/bun.lock @@ -8,6 +8,7 @@ "@elysiajs/cors": "^1.4.0", "@elysiajs/static": "^1.4.4", "@elysiajs/swagger": "^1.3.1", + "@tursodatabase/database": "^0.2.2", "elysia": "^1.4.12", "elysia-rate-limit": "4.4.0", }, @@ -39,6 +40,18 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tursodatabase/database": ["@tursodatabase/database@0.2.2", "", { "dependencies": { "@tursodatabase/database-common": "^0.2.2" }, "optionalDependencies": { "@tursodatabase/database-darwin-arm64": "0.2.2", "@tursodatabase/database-linux-arm64-gnu": "0.2.2", "@tursodatabase/database-linux-x64-gnu": "0.2.2", "@tursodatabase/database-win32-x64-msvc": "0.2.2" } }, "sha512-BlvoyiwIWIIQA65KEYlCqLDpRKIOM1AN99tBMjz1B+ydO9h0cDb+lCbfBf6f7+Lk696KB1ODpePiF5EfGm9qpw=="], + + "@tursodatabase/database-common": ["@tursodatabase/database-common@0.2.2", "", {}, "sha512-cSNpms6MIaRj29B37XzIu9yGbea0HSGDupZs8QrMxR3rKqgHJIfY04ysqDD/4lS8TNIImPlPZrZgqQz+b/FoKQ=="], + + "@tursodatabase/database-darwin-arm64": ["@tursodatabase/database-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9hfG9gncCnCU8fnYcw5w8iUheV8GVHBA1kS9VWyGxtFrlaajpNv3WVKM67BznacvtGhz35EpAGoG5ayxpEbQKA=="], + + "@tursodatabase/database-linux-arm64-gnu": ["@tursodatabase/database-linux-arm64-gnu@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-u3VFXFzTZd+5RxxyQVClxMHVfj6aRxV0AbdoTLJ4flUao5YMebL4p5w30vozl8wNzlSq7g4ocmCiG71btofDGg=="], + + "@tursodatabase/database-linux-x64-gnu": ["@tursodatabase/database-linux-x64-gnu@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-tAu9ZQBP3sZjaeQEOyEAQoHz/oqONBQwrtE/lYZQhqpR8nZ0DrKsYUIiu4lBOcWcofwtJmOgTVM8WYayJOm4HA=="], + + "@tursodatabase/database-win32-x64-msvc": ["@tursodatabase/database-win32-x64-msvc@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-C1Z1xOUSln1/qtzR60xZYHgmfrQIhNm0glgqsnZt2wQI354dDtQGZH/oG/tKWCHrvtFvWCbnd9DEZif/VCK56A=="], + "@types/node": ["@types/node@24.0.10", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA=="], "@types/react": ["@types/react@19.1.11", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ=="], diff --git a/standalone/package.json b/standalone/package.json index 9c2c20b..8f97281 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -1,6 +1,6 @@ { "name": "cap-standalone", - "version": "2.0.13", + "version": "2.0.14", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev": "bun run --watch src/index.js", @@ -49,6 +49,7 @@ "@elysiajs/cors": "^1.4.0", "@elysiajs/static": "^1.4.4", "@elysiajs/swagger": "^1.3.1", + "@tursodatabase/database": "^0.2.2", "elysia": "^1.4.12", "elysia-rate-limit": "4.4.0" }, diff --git a/standalone/src/auth.js b/standalone/src/auth.js index 9a0ff84..9846a3d 100644 --- a/standalone/src/auth.js +++ b/standalone/src/auth.js @@ -6,147 +6,140 @@ import { ratelimitGenerator } from "./ratelimit.js"; const { ADMIN_KEY } = process.env; -const loginQuery = db.query(` +const loginQuery = db.prepare(` INSERT INTO sessions (token, created, expires) - VALUES ($token, $created, $expires) + VALUES (?, ?, ?) `); -const getValidTokenQuery = db.query(` - SELECT * FROM sessions WHERE token = $token AND expires > $now LIMIT 1 +const getValidTokenQuery = db.prepare(` + SELECT * FROM sessions WHERE token = ? AND expires > ? LIMIT 1 `); if (!ADMIN_KEY) throw new Error("auth: Admin key missing. Please add one"); if (ADMIN_KEY.length < 30) - throw new Error( - "auth: Admin key too short. Please use one that's at least 30 characters", - ); + throw new Error( + "auth: Admin key too short. Please use one that's at least 30 characters" + ); export const auth = new Elysia({ - prefix: "/auth", + prefix: "/auth", }) - .use( - rateLimit({ - duration: 30_000, - max: 20_000, - scoping: "scoped", - generator: ratelimitGenerator, - }), - ) - .post("/login", async ({ body, set, cookie }) => { - const { admin_key } = body; + .use( + rateLimit({ + duration: 30_000, + max: 20_000, + scoping: "scoped", + generator: ratelimitGenerator, + }) + ) + .post("/login", async ({ body, set, cookie }) => { + const { admin_key } = body; - const a = Buffer.from(admin_key, "utf8"); - const b = Buffer.from(ADMIN_KEY, "utf8"); + const a = Buffer.from(admin_key, "utf8"); + const b = Buffer.from(ADMIN_KEY, "utf8"); - if (!a || !b || a.length !== b.length) { - set.status = 401; - return { success: false }; - } + if (!a || !b || a.length !== b.length) { + set.status = 401; + return { success: false }; + } - if (!timingSafeEqual(a, b)) { - set.status = 401; - return { success: false }; - } + if (!timingSafeEqual(a, b)) { + set.status = 401; + return { success: false }; + } - if (admin_key !== ADMIN_KEY) { - // as a last check, in case an attacker somehow bypasses - // timingSafeEqual, we're checking AGAIN to see if the tokens - // are right. + if (admin_key !== ADMIN_KEY) { + // as a last check, in case an attacker somehow bypasses + // timingSafeEqual, we're checking AGAIN to see if the tokens + // are right. - // yes, this is vulnerable to timing attacks, but those are - // hard to execute and literally just accepting an invalid token - // is worse. + // yes, this is vulnerable to timing attacks, but those are + // hard to execute and literally just accepting an invalid token + // is worse. - set.status = 401; - return { success: false }; - } + set.status = 401; + return { success: false }; + } - const session_token = randomBytes(30).toString("hex"); - const expires = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days - const created = Date.now(); + const session_token = randomBytes(30).toString("hex"); + const expires = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days + const created = Date.now(); - const hashedToken = await Bun.password.hash(session_token); + const hashedToken = await Bun.password.hash(session_token); - loginQuery.run({ - $token: hashedToken, - $created: created, - $expires: expires, - }); + loginQuery.run(hashedToken, created, expires); - cookie.cap_authed.set({ - value: "yes", - expires: new Date(expires), - }); + cookie.cap_authed.set({ + value: "yes", + expires: new Date(expires), + }); - return { success: true, session_token, hashed_token: hashedToken, expires }; - }); + return { success: true, session_token, hashed_token: hashedToken, expires }; + }); export const authBeforeHandle = async ({ set, headers }) => { - const { authorization } = headers; + const { authorization } = headers; - set.headers["X-Content-Type-Options"] = "nosniff"; - set.headers["X-Frame-Options"] = "DENY"; - set.headers["X-XSS-Protection"] = "1; mode=block"; + set.headers["X-Content-Type-Options"] = "nosniff"; + set.headers["X-Frame-Options"] = "DENY"; + set.headers["X-XSS-Protection"] = "1; mode=block"; - if (authorization?.startsWith("Bot ")) { - const botToken = authorization.replace("Bot ", "").trim(); - const [id, token] = botToken.split("_"); + if (authorization?.startsWith("Bot ")) { + const botToken = authorization.replace("Bot ", "").trim(); + const [id, token] = botToken.split("_"); - if (!id || !token) { - set.status = 401; - return { success: false, error: "Unauthorized. Invalid bot token." }; - } + if (!id || !token) { + set.status = 401; + return { success: false, error: "Unauthorized. Invalid bot token." }; + } - const apiKey = db.query(`SELECT * FROM api_keys WHERE id = $id`).get({ - $id: id, - }); + const apiKey = await db + .prepare(`SELECT * FROM api_keys WHERE id = ?`) + .get(id); - if (!apiKey) { - set.status = 401; - return { - success: false, - error: "Unauthorized. Deleted or non-existent bot token.", - }; - } + if (!apiKey || !apiKey.tokenHash) { + set.status = 401; + return { + success: false, + error: "Unauthorized. Deleted or non-existent bot token.", + }; + } - if (!(await Bun.password.verify(token, apiKey.tokenHash))) { - set.status = 401; - return { success: false, error: "Unauthorized. Invalid bot token." }; - } + if (!(await Bun.password.verify(token, apiKey.tokenHash))) { + set.status = 401; + return { success: false, error: "Unauthorized. Invalid bot token." }; + } - return; - } + return; + } - if (!authorization || !authorization.startsWith("Bearer ")) { - set.status = 401; - return { - success: false, - error: - "Unauthorized. An API key or session token is required to use this endpoint.", - }; - } + if (!authorization || !authorization.startsWith("Bearer ")) { + set.status = 401; + return { + success: false, + error: + "Unauthorized. An API key or session token is required to use this endpoint.", + }; + } - const { token, hash } = JSON.parse( - atob(authorization.replace("Bearer ", "").trim()), - ); + const { token, hash } = JSON.parse( + atob(authorization.replace("Bearer ", "").trim()) + ); - const validToken = getValidTokenQuery.get({ - $token: hash, - $now: Date.now(), - }); + const validToken = await getValidTokenQuery.get(hash, Date.now()); - if (!validToken) { - set.status = 401; - return { - success: false, - error: "Unauthorized. An invalid session token was used.", - }; - } + if (!validToken) { + set.status = 401; + return { + success: false, + error: "Unauthorized. An invalid session token was used.", + }; + } - if (!(await Bun.password.verify(token, validToken.token))) { - set.status = 401; - return { - success: false, - error: "Unauthorized. An invalid session token was used.", - }; - } + if (!(await Bun.password.verify(token, validToken.token))) { + set.status = 401; + return { + success: false, + error: "Unauthorized. An invalid session token was used.", + }; + } }; diff --git a/standalone/src/cap.js b/standalone/src/cap.js index 930d955..4df2952 100644 --- a/standalone/src/cap.js +++ b/standalone/src/cap.js @@ -6,27 +6,27 @@ import { rateLimit } from "elysia-rate-limit"; import { db } from "./db.js"; import { ratelimitGenerator } from "./ratelimit.js"; -const getSitekeyConfigQuery = db.query( +const getSitekeyConfigQuery = db.prepare( `SELECT (config) FROM keys WHERE siteKey = ?`, ); -const insertChallengeQuery = db.query(` +const insertChallengeQuery = db.prepare(` INSERT INTO challenges (siteKey, token, data, expires) VALUES (?, ?, ?, ?) `); -const getChallengeQuery = db.query(` +const getChallengeQuery = db.prepare(` SELECT * FROM challenges WHERE siteKey = ? AND token = ? `); -const deleteChallengeQuery = db.query(` +const deleteChallengeQuery = db.prepare(` DELETE FROM challenges WHERE siteKey = ? AND token = ? `); -const insertTokenQuery = db.query(` +const insertTokenQuery = db.prepare(` INSERT INTO tokens (siteKey, token, expires) VALUES (?, ?, ?) `); -const upsertSolutionQuery = db.query(` +const upsertSolutionQuery = db.prepare(` INSERT INTO solutions (siteKey, bucket, count) VALUES (?, ?, 1) ON CONFLICT (siteKey, bucket) @@ -56,7 +56,7 @@ export const capServer = new Elysia({ const cap = new Cap({ noFSState: true, }); - const _keyConfig = getSitekeyConfigQuery.get(params.siteKey); + const _keyConfig = await getSitekeyConfigQuery.get(params.siteKey); if (!_keyConfig) { set.status = 404; @@ -81,7 +81,7 @@ export const capServer = new Elysia({ return challenge; }) .post("/:siteKey/redeem", async ({ body, set, params }) => { - const challenge = getChallengeQuery.get(params.siteKey, body.token); + const challenge = await getChallengeQuery.get(params.siteKey, body.token); try { deleteChallengeQuery.run(params.siteKey, body.token); diff --git a/standalone/src/db.js b/standalone/src/db.js index 266abc0..8e9e9ba 100644 --- a/standalone/src/db.js +++ b/standalone/src/db.js @@ -1,77 +1,96 @@ -import { Database } from "bun:sqlite"; import fs from "node:fs"; import { join } from "node:path"; fs.mkdirSync(process.env.DATA_PATH || "./.data", { - recursive: true, + recursive: true, }); -const db = new Database(join(process.env.DATA_PATH || "./.data", "db.sqlite")); +let db; -db.query( - `create table if not exists sessions ( - token text primary key not null, - expires integer not null, - created integer not null - )`, -).run(); +async function initDb() { + const connector = process.env.DB_CONNECTOR; -db.query( - `create table if not exists keys ( - siteKey text primary key not null, - name text not null, - secretHash text not null, - config text not null, - created integer not null - )`, -).run(); + if (connector === "turso") { + const { Database } = await import("bun:sqlite"); -db.query( - `create table if not exists solutions ( - siteKey text not null, - bucket integer not null, - count integer default 0, - primary key (siteKey, bucket) - )`, -).run(); + db = new Database(join(process.env.DATA_PATH || "./.data", "db.sqlite")); + } else if (!connector || connector === "bun-sqlite") { + const { connect } = await import("@tursodatabase/database"); -db.query( - `create table if not exists challenges ( - siteKey text not null, - token text not null, - data text not null, - expires integer not null, - primary key (siteKey, token) - )`, -).run(); + db = await connect(join(process.env.DATA_PATH || "./.data", "db.sqlite")); + } else { + throw new Error(`Unsupported DB connector "${connector}"`); + } -db.query( - `create table if not exists tokens ( - siteKey text not null, - token text not null, - expires integer not null, - primary key (siteKey, token) - )`, -).run(); + db.exec( + `create table if not exists sessions ( + token text primary key not null, + expires integer not null, + created integer not null + )` + ); -db.query( - `create table if not exists api_keys ( - id text not null, - name text not null, - tokenHash text not null, - created integer not null, - primary key (id, tokenHash) - )`, -).run(); + db.exec( + `create table if not exists keys ( + siteKey text primary key not null, + name text not null, + secretHash text not null, + config text not null, + created integer not null + )` + ); -setInterval(() => { - db.query("delete from sessions where expires < ?").run(Date.now()); - db.query("delete from tokens where expires < ?").run(Date.now()); - db.query("delete from challenges where expires < ?").run(Date.now()); -}, 60 * 1000); + db.exec( + `create table if not exists solutions ( + siteKey text not null, + bucket integer not null, + count integer default 0, + primary key (siteKey, bucket) + )` + ); -db.query("delete from sessions where expires < ?").run(Date.now()); -db.query("delete from tokens where expires < ?").run(Date.now()); -db.query("delete from challenges where expires < ?").run(Date.now()); + db.exec( + `create table if not exists challenges ( + siteKey text not null, + token text not null, + data text not null, + expires integer not null, + primary key (siteKey, token) + )` + ); + + db.exec( + `create table if not exists tokens ( + siteKey text not null, + token text not null, + expires integer not null, + primary key (siteKey, token) + )` + ); + + db.exec( + `create table if not exists api_keys ( + id text not null, + name text not null, + tokenHash text not null, + created integer not null, + primary key (id, tokenHash) + )` + ); + + setInterval(() => { + db.prepare("delete from sessions where expires < ?").run(Date.now()); + db.prepare("delete from tokens where expires < ?").run(Date.now()); + db.prepare("delete from challenges where expires < ?").run(Date.now()); + }, 60 * 1000); + + db.prepare("delete from sessions where expires < ?").run(Date.now()); + db.prepare("delete from tokens where expires < ?").run(Date.now()); + db.prepare("delete from challenges where expires < ?").run(Date.now()); + + return db; +} + +db = await initDb(); export { db }; diff --git a/standalone/src/server.js b/standalone/src/server.js index 342141d..90f9df0 100644 --- a/standalone/src/server.js +++ b/standalone/src/server.js @@ -6,559 +6,568 @@ import { db } from "./db.js"; import { ratelimitGenerator } from "./ratelimit.js"; const keyDefaults = { - difficulty: 4, - challengeCount: 80, - saltSize: 32, + difficulty: 4, + challengeCount: 80, + saltSize: 32, }; -const get24hSolvesQuery = db.query( - `SELECT SUM(count) as total FROM solutions WHERE siteKey = ? AND bucket >= ?`, +const get24hSolvesQuery = db.prepare( + `SELECT SUM(count) as total FROM solutions WHERE siteKey = ? AND bucket >= ?` ); -const get24hPreviousQuery = db.query(` +const get24hPreviousQuery = db.prepare(` SELECT SUM(count) as total FROM solutions WHERE siteKey = ? AND bucket >= ? AND bucket < ? `); -const getKeysQuery = db.query(`SELECT * FROM keys ORDER BY created DESC`); +const getKeysQuery = db.prepare(`SELECT * FROM keys ORDER BY created DESC`); export const server = new Elysia({ - prefix: "/server", - detail: { - security: [ - { - apiKey: [], - }, - ], - }, + prefix: "/server", + detail: { + security: [ + { + apiKey: [], + }, + ], + }, }) - .use( - rateLimit({ - scoping: "scoped", - max: 150, - duration: 10_000, - generator: ratelimitGenerator, - }), - ) - .onBeforeHandle(authBeforeHandle) - .get( - "/keys", - () => { - const now = Math.floor(Date.now() / 1000); - const day = 24 * 60 * 60; + .use( + rateLimit({ + scoping: "scoped", + max: 150, + duration: 10_000, + generator: ratelimitGenerator, + }) + ) + .onBeforeHandle(authBeforeHandle) + .get( + "/keys", + async () => { + const now = Math.floor(Date.now() / 1000); + const day = 24 * 60 * 60; - const currentStart = now - day; - const previousStart = now - 2 * day; + const currentStart = now - day; + const previousStart = now - 2 * day; - return getKeysQuery.all().map((key) => { - const current = - get24hSolvesQuery.get(key.siteKey, currentStart).total || 0; + const keys = await getKeysQuery.all(); - const previous = - get24hPreviousQuery.get(key.siteKey, previousStart, currentStart) - .total || 0; + return await Promise.all( + keys.map(async (key) => { + const currentResult = await get24hSolvesQuery.get( + key.sitekey || key.siteKey, + currentStart + ); + const previousResult = await get24hPreviousQuery.get( + key.sitekey || key.siteKey, + previousStart, + currentStart + ); - let change = 0; - let direction = ""; + const current = currentResult?.total || 0; + const previous = previousResult?.total || 0; - if (previous > 0) { - change = ((current - previous) / previous) * 100; - direction = - current > previous ? "up" : current < previous ? "down" : ""; - } else if (current > 0) { - change = 100; - direction = "up"; - } + let change = 0; + let direction = ""; - return { - siteKey: key.siteKey, - name: key.name, - created: key.created, - solvesLast24h: current, - difference: { - value: change.toFixed(2), - direction, - }, - }; - }); - }, - { - detail: { - tags: ["Keys"], - }, - }, - ) - .post( - "/keys", - async ({ body }) => { - const siteKey = randomBytes(5).toString("hex"); - const secretKey = randomBytes(40) - .toString("base64") - .replace(/\+/g, "") - .replace(/\//g, "") - .replace(/=+$/, ""); + if (previous > 0) { + change = ((current - previous) / previous) * 100; + direction = + current > previous ? "up" : current < previous ? "down" : ""; + } else if (current > 0) { + change = 100; + direction = "up"; + } - db.query( - `INSERT INTO keys (siteKey, name, secretHash, config, created) VALUES (?, ?, ?, ?, ?)`, - ).run( - siteKey, - body?.name || siteKey, - await Bun.password.hash(secretKey), - JSON.stringify(keyDefaults), - Date.now(), - ); + return { + siteKey: key.sitekey || key.siteKey, + name: key.name, + created: key.created, + solvesLast24h: current, + difference: { + value: change.toFixed(2), + direction, + }, + }; + }) + ); + }, + { + detail: { + tags: ["Keys"], + }, + } + ) + .post( + "/keys", + async ({ body }) => { + const siteKey = randomBytes(5).toString("hex"); + const secretKey = randomBytes(40) + .toString("base64") + .replace(/\+/g, "") + .replace(/\//g, "") + .replace(/=+$/, ""); - return { - siteKey, - secretKey, - }; - }, - { - body: t.Object({ - name: t.Optional(t.String()), - }), - detail: { - tags: ["Keys"], - }, - }, - ) - .get( - "/keys/:siteKey", - ({ params, query }) => { - const key = db - .query(`SELECT * FROM keys WHERE siteKey = ?`) - .get(params.siteKey); - if (!key) { - return { success: false, error: "Key not found" }; - } + db.prepare( + `INSERT INTO keys (siteKey, name, secretHash, config, created) VALUES (?, ?, ?, ?, ?)` + ).run( + siteKey, + body?.name || siteKey, + await Bun.password.hash(secretKey), + JSON.stringify(keyDefaults), + Date.now() + ); - const chartDuration = query.chartDuration || "today"; + return { + siteKey, + secretKey, + }; + }, + { + body: t.Object({ + name: t.Optional(t.String()), + }), + detail: { + tags: ["Keys"], + }, + } + ) + .get( + "/keys/:siteKey", + async ({ params, query }) => { + const key = await db + .prepare(`SELECT * FROM keys WHERE siteKey = ?`) + .get(params.siteKey); + if (!key) { + return { success: false, error: "Key not found" }; + } - const now = Math.floor(Date.now() / 1000); - const day = 24 * 60 * 60; + const chartDuration = query.chartDuration || "today"; - const currentStart = now - day; - let dataQuery; - let bucketSize; - let startTime; + const now = Math.floor(Date.now() / 1000); + const day = 24 * 60 * 60; - switch (chartDuration) { - case "today": - bucketSize = 3600; - startTime = Math.floor(Date.now() / 1000 / 86400) * 86400; - dataQuery = db.query(` + const currentStart = now - day; + let dataQuery; + let bucketSize; + let startTime; + + switch (chartDuration) { + case "today": + bucketSize = 3600; + startTime = Math.floor(Date.now() / 1000 / 86400) * 86400; + dataQuery = db.prepare(` SELECT bucket, SUM(count) as count FROM solutions WHERE siteKey = ? AND bucket >= ? AND bucket <= ? GROUP BY bucket ORDER BY bucket `); - break; - case "yesterday": - bucketSize = 3600; - startTime = Math.floor(Date.now() / 1000 / 86400) * 86400 - 86400; - dataQuery = db.query(` + break; + case "yesterday": + bucketSize = 3600; + startTime = Math.floor(Date.now() / 1000 / 86400) * 86400 - 86400; + dataQuery = db.prepare(` SELECT bucket, SUM(count) as count FROM solutions WHERE siteKey = ? AND bucket >= ? AND bucket < ? GROUP BY bucket ORDER BY bucket `); - break; - case "last7days": - bucketSize = 86400; - startTime = Math.floor((now - 7 * 86400) / 86400) * 86400; - dataQuery = db.query(` + break; + case "last7days": + bucketSize = 86400; + startTime = Math.floor((now - 7 * 86400) / 86400) * 86400; + dataQuery = db.prepare(` SELECT (bucket / 86400) * 86400 as bucket, SUM(count) as count FROM solutions WHERE siteKey = ? AND bucket >= ? GROUP BY (bucket / 86400) * 86400 ORDER BY bucket `); - break; - case "last28days": - bucketSize = 86400; - startTime = Math.floor((now - 28 * 86400) / 86400) * 86400; - dataQuery = db.query(` + break; + case "last28days": + bucketSize = 86400; + startTime = Math.floor((now - 28 * 86400) / 86400) * 86400; + dataQuery = db.prepare(` SELECT (bucket / 86400) * 86400 as bucket, SUM(count) as count FROM solutions WHERE siteKey = ? AND bucket >= ? GROUP BY (bucket / 86400) * 86400 ORDER BY bucket `); - break; - case "last91days": - bucketSize = 86400; - startTime = Math.floor((now - 91 * 86400) / 86400) * 86400; - dataQuery = db.query(` + break; + case "last91days": + bucketSize = 86400; + startTime = Math.floor((now - 91 * 86400) / 86400) * 86400; + dataQuery = db.prepare(` SELECT (bucket / 86400) * 86400 as bucket, SUM(count) as count FROM solutions WHERE siteKey = ? AND bucket >= ? GROUP BY (bucket / 86400) * 86400 ORDER BY bucket `); - break; - case "alltime": - bucketSize = 86400; - startTime = 0; - dataQuery = db.query(` + break; + case "alltime": + bucketSize = 86400; + startTime = 0; + dataQuery = db.prepare(` SELECT (bucket / 86400) * 86400 as bucket, SUM(count) as count FROM solutions WHERE siteKey = ? AND bucket >= ? GROUP BY (bucket / 86400) * 86400 ORDER BY bucket `); - break; - default: - bucketSize = 3600; - startTime = currentStart; - dataQuery = db.query(` + break; + default: + bucketSize = 3600; + startTime = currentStart; + dataQuery = db.prepare(` SELECT bucket, SUM(count) as count FROM solutions WHERE siteKey = ? AND bucket >= ? GROUP BY bucket ORDER BY bucket `); - } + } - let historyData; - if (chartDuration === "yesterday") { - historyData = dataQuery.all( - params.siteKey, - startTime, - startTime + 86400, - ); - } else if (chartDuration === "today") { - const currentHourBucket = Math.floor(now / 3600) * 3600; - historyData = dataQuery.all( - params.siteKey, - startTime, - currentHourBucket, - ); - } else { - historyData = dataQuery.all(params.siteKey, startTime); - } + let historyData; + if (chartDuration === "yesterday") { + historyData = await dataQuery.all( + params.siteKey, + startTime, + startTime + 86400 + ); + } else if (chartDuration === "today") { + const currentHourBucket = Math.floor(now / 3600) * 3600; + historyData = await dataQuery.all( + params.siteKey, + startTime, + currentHourBucket + ); + } else { + historyData = await dataQuery.all(params.siteKey, startTime); + } - if ( - chartDuration === "last7days" || - chartDuration === "last28days" || - chartDuration === "last91days" - ) { - const days = - chartDuration === "last7days" - ? 7 - : chartDuration === "last28days" - ? 28 - : 91; - const completeData = []; - const dataMap = new Map( - historyData.map((item) => [item.bucket, item.count]), - ); + if ( + chartDuration === "last7days" || + chartDuration === "last28days" || + chartDuration === "last91days" + ) { + const days = + chartDuration === "last7days" + ? 7 + : chartDuration === "last28days" + ? 28 + : 91; + const completeData = []; + const dataMap = new Map( + historyData.map((item) => [item.bucket, item.count]) + ); - const currentDayStart = Math.floor(now / 86400) * 86400; + const currentDayStart = Math.floor(now / 86400) * 86400; - for (let i = 0; i < days; i++) { - const dayBucket = currentDayStart - (days - 1 - i) * 86400; - completeData.push({ - bucket: dayBucket, - count: dataMap.get(dayBucket) || 0, - }); - } + for (let i = 0; i < days; i++) { + const dayBucket = currentDayStart - (days - 1 - i) * 86400; + completeData.push({ + bucket: dayBucket, + count: dataMap.get(dayBucket) || 0, + }); + } - historyData = completeData; - } else if (chartDuration === "today") { - const completeData = []; - const dataMap = new Map( - historyData.map((item) => [item.bucket, item.count]), - ); + historyData = completeData; + } else if (chartDuration === "today") { + const completeData = []; + const dataMap = new Map( + historyData.map((item) => [item.bucket, item.count]) + ); - const currentHour = Math.floor(now / 3600); - const startHour = Math.floor(startTime / 3600); + const currentHour = Math.floor(now / 3600); + const startHour = Math.floor(startTime / 3600); - for (let hour = startHour; hour <= currentHour; hour++) { - const hourBucket = hour * 3600; - completeData.push({ - bucket: hourBucket, - count: dataMap.get(hourBucket) || 0, - }); - } + for (let hour = startHour; hour <= currentHour; hour++) { + const hourBucket = hour * 3600; + completeData.push({ + bucket: hourBucket, + count: dataMap.get(hourBucket) || 0, + }); + } - historyData = completeData; - } + historyData = completeData; + } - const currentSolves = - get24hSolvesQuery.get(params.siteKey, currentStart).total || 0; + const currentSolves = + (await get24hSolvesQuery.get(params.siteKey, currentStart))?.total || 0; - return { - key: { - siteKey: key.siteKey, - name: key.name, - created: key.created, - config: JSON.parse(key.config), - }, - stats: { - solvesLast24h: currentSolves, - }, - chartData: { - duration: chartDuration, - bucketSize, - data: historyData, - }, - }; - }, - { - params: t.Object({ - siteKey: t.String(), - }), - query: t.Object({ - chartDuration: t.Optional( - t.Union([ - t.Literal("today"), - t.Literal("yesterday"), - t.Literal("last7days"), - t.Literal("last28days"), - t.Literal("last91days"), - t.Literal("alltime"), - ]), - ), - }), - detail: { - tags: ["Keys"], - }, - }, - ) - .put( - "/keys/:siteKey/config", - async ({ params, body }) => { - const key = db - .query(`SELECT * FROM keys WHERE siteKey = ?`) - .get(params.siteKey); - if (!key) { - return { success: false, error: "Key not found" }; - } + return { + key: { + siteKey: key.sitekey || key.siteKey, + name: key.name, + created: key.created, + config: JSON.parse(key.config), + }, + stats: { + solvesLast24h: currentSolves, + }, + chartData: { + duration: chartDuration, + bucketSize, + data: historyData, + }, + }; + }, + { + params: t.Object({ + siteKey: t.String(), + }), + query: t.Object({ + chartDuration: t.Optional( + t.Union([ + t.Literal("today"), + t.Literal("yesterday"), + t.Literal("last7days"), + t.Literal("last28days"), + t.Literal("last91days"), + t.Literal("alltime"), + ]) + ), + }), + detail: { + tags: ["Keys"], + }, + } + ) + .put( + "/keys/:siteKey/config", + async ({ params, body }) => { + const key = await db + .prepare(`SELECT * FROM keys WHERE siteKey = ?`) + .get(params.siteKey); + if (!key) { + return { success: false, error: "Key not found" }; + } - const { name, difficulty, challengeCount, saltSize } = body; - const config = { - ...keyDefaults, - name, - difficulty, - challengeCount, - saltSize, - }; + const { name, difficulty, challengeCount, saltSize } = body; + const config = { + ...keyDefaults, + name, + difficulty, + challengeCount, + saltSize, + }; - db.query(`UPDATE keys SET name = ?, config = ? WHERE siteKey = ?`).run( - config.name || key.name, - JSON.stringify(config), - params.siteKey, - ); + db.prepare(`UPDATE keys SET name = ?, config = ? WHERE siteKey = ?`).run( + config.name || key.name, + JSON.stringify(config), + params.siteKey + ); - return { success: true }; - }, - { - body: t.Object({ - name: t.Optional(t.String()), - difficulty: t.Optional(t.Number()), - challengeCount: t.Optional(t.Number()), - saltSize: t.Optional(t.Number()), - }), - detail: { - tags: ["Keys"], - }, - }, - ) - .delete( - "/keys/:siteKey", - ({ params, set }) => { - const key = db - .query(`SELECT * FROM keys WHERE siteKey = ?`) - .get(params.siteKey); - if (!key) { - set.status = 404; - return { success: false, error: "Key not found" }; - } + return { success: true }; + }, + { + body: t.Object({ + name: t.Optional(t.String()), + difficulty: t.Optional(t.Number()), + challengeCount: t.Optional(t.Number()), + saltSize: t.Optional(t.Number()), + }), + detail: { + tags: ["Keys"], + }, + } + ) + .delete( + "/keys/:siteKey", + async ({ params, set }) => { + const key = await db + .prepare(`SELECT * FROM keys WHERE siteKey = ?`) + .get(params.siteKey); + if (!key) { + set.status = 404; + return { success: false, error: "Key not found" }; + } - db.query(`DELETE FROM keys WHERE siteKey = ?`).run(params.siteKey); - db.query(`DELETE FROM solutions WHERE siteKey = ?`).run(params.siteKey); + db.prepare(`DELETE FROM keys WHERE siteKey = ?`).run(params.siteKey); + db.prepare(`DELETE FROM solutions WHERE siteKey = ?`).run(params.siteKey); - return { success: true }; - }, - { - params: t.Object({ - siteKey: t.String(), - }), - detail: { - tags: ["Keys"], - }, - }, - ) - .post( - "/keys/:siteKey/rotate-secret", - async ({ params }) => { - const key = db - .query(`SELECT * FROM keys WHERE siteKey = ?`) - .get(params.siteKey); - if (!key) { - return { success: false, error: "Key not found" }; - } - const newSecretKey = randomBytes(40) - .toString("base64") - .replace(/\+/g, "") - .replace(/\//g, "") - .replace(/=+$/, ""); + return { success: true }; + }, + { + params: t.Object({ + siteKey: t.String(), + }), + detail: { + tags: ["Keys"], + }, + } + ) + .post( + "/keys/:siteKey/rotate-secret", + async ({ params }) => { + const key = await db + .prepare(`SELECT * FROM keys WHERE siteKey = ?`) + .get(params.siteKey); + if (!key) { + return { success: false, error: "Key not found" }; + } + const newSecretKey = randomBytes(40) + .toString("base64") + .replace(/\+/g, "") + .replace(/\//g, "") + .replace(/=+$/, ""); - db.query(`UPDATE keys SET secretHash = ? WHERE siteKey = ?`).run( - await Bun.password.hash(newSecretKey), - params.siteKey, - ); - return { - secretKey: newSecretKey, - }; - }, - { - params: t.Object({ - siteKey: t.String(), - }), - detail: { - tags: ["Keys"], - }, - }, - ) - .get( - "/settings/sessions", - () => { - const sessions = db.query(`SELECT * FROM sessions`).all(); - return sessions.map((session) => ({ - token: session.token.slice(-14), - expires: new Date(session.expires).toISOString(), - created: new Date(session.created).toISOString(), - })); - }, - { - detail: { - tags: ["Settings"], - }, - }, - ) - .get( - "/settings/apikeys", - () => { - const apikeys = db.query(`SELECT * FROM api_keys`).all(); + db.prepare(`UPDATE keys SET secretHash = ? WHERE siteKey = ?`).run( + await Bun.password.hash(newSecretKey), + params.siteKey + ); + return { + secretKey: newSecretKey, + }; + }, + { + params: t.Object({ + siteKey: t.String(), + }), + detail: { + tags: ["Keys"], + }, + } + ) + .get( + "/settings/sessions", + async () => { + const sessions = await db.prepare(`SELECT * FROM sessions`).all(); + return sessions.map((session) => ({ + token: session.token.slice(-14), + expires: new Date(session.expires).toISOString(), + created: new Date(session.created).toISOString(), + })); + }, + { + detail: { + tags: ["Settings"], + }, + } + ) + .get( + "/settings/apikeys", + async () => { + const apikeys = await db.prepare(`SELECT * FROM api_keys`).all(); - return apikeys.map((key) => ({ - name: key.name, - id: key.id, - created: new Date(key.created).toISOString(), - })); - }, - { - detail: { - tags: ["Settings"], - }, - }, - ) - .post( - "/settings/apikeys", - async ({ body }) => { - const id = randomBytes(16).toString("hex"); - const token = randomBytes(32) - .toString("base64") - .replace(/\+/g, "") - .replace(/\//g, "") - .replace(/=+$/, ""); + return apikeys.map((key) => ({ + name: key.name, + id: key.id, + created: new Date(key.created).toISOString(), + })); + }, + { + detail: { + tags: ["Settings"], + }, + } + ) + .post( + "/settings/apikeys", + async ({ body }) => { + const id = randomBytes(16).toString("hex"); + const token = randomBytes(32) + .toString("base64") + .replace(/\+/g, "") + .replace(/\//g, "") + .replace(/=+$/, ""); - const name = body.name; + const name = body.name; - db.query( - `INSERT INTO api_keys (id, name, tokenHash, created) VALUES (?, ?, ?, ?)`, - ).run(id, name, await Bun.password.hash(token), Date.now()); + db.prepare( + `INSERT INTO api_keys (id, name, tokenHash, created) VALUES (?, ?, ?, ?)` + ).run(id, name, await Bun.password.hash(token), Date.now()); + return { + apiKey: `${id}_${token}`, + }; + }, + { + body: t.Object({ + name: t.String(), + }), + detail: { + tags: ["Settings"], + }, + } + ) + .delete( + "/settings/apikeys/:id", + async ({ params, set }) => { + const key = await db + .prepare(`SELECT * FROM api_keys WHERE id = ?`) + .get(params.id); + if (!key) { + set.status = 404; + return { success: false, error: "API key not found" }; + } + db.prepare(`DELETE FROM api_keys WHERE id = ?`).run(params.id); + return { success: true }; + }, + { + params: t.Object({ + id: t.String(), + }), + detail: { + tags: ["Settings"], + }, + } + ) + .get( + "/about", + async () => { + const pkg = await import("../package.json", { assert: { type: "json" } }); - return { - apiKey: `${id}_${token}`, - }; - }, - { - body: t.Object({ - name: t.String(), - }), - detail: { - tags: ["Settings"], - }, - }, - ) - .delete( - "/settings/apikeys/:id", - ({ params, set }) => { - const key = db - .query(`SELECT * FROM api_keys WHERE id = ?`) - .get(params.id); - if (!key) { - set.status = 404; - return { success: false, error: "API key not found" }; - } - db.query(`DELETE FROM api_keys WHERE id = ?`).run(params.id); - return { success: true }; - }, - { - params: t.Object({ - id: t.String(), - }), - detail: { - tags: ["Settings"], - }, - }, - ) - .get( - "/about", - async () => { - const pkg = await import("../package.json", { assert: { type: "json" } }); + return { + bun: Bun.version, + ver: pkg.default.version, + }; + }, + {} + ) + .post( + "/logout", + async ({ body, headers, set }) => { + const { authorization } = headers; + if (!authorization) { + set.status = 401; + return { success: false, error: "Unauthorized" }; + } - return { - bun: Bun.version, - ver: pkg.default.version, - }; - }, - {}, - ) - .post( - "/logout", - async ({ body, headers, set }) => { - const { authorization } = headers; - if (!authorization) { - set.status = 401; - return { success: false, error: "Unauthorized" }; - } + const { hash } = JSON.parse( + atob(authorization.replace("Bearer ", "").trim()) + ); - const { hash } = JSON.parse( - atob(authorization.replace("Bearer ", "").trim()), - ); + let session = hash; - let session = hash; + if (body.session) { + // body.session are the last characters of the session token + // e.g. body.session = (...)8KdbcHjqxWPR6Q - if (body.session) { - // body.session are the last characters of the session token - // e.g. body.session = (...)8KdbcHjqxWPR6Q + const sessionRow = await db + .prepare(`SELECT token FROM sessions WHERE token LIKE ?`) + .get(`%${body.session}`); - const sessionRow = db - .query(`SELECT token FROM sessions WHERE token LIKE ?`) - .get(`%${body.session}`); + if (!sessionRow) { + set.status = 404; + return { success: false, error: "Session not found" }; + } + session = sessionRow.token; + } - if (!sessionRow) { - set.status = 404; - return { success: false, error: "Session not found" }; - } - session = sessionRow.token; - } + db.prepare(`DELETE FROM sessions WHERE token = ?`).run(session); - db.query(`DELETE FROM sessions WHERE token = ?`).run(session); - - return { success: true }; - }, - { - body: t.Optional( - t.Object({ - session: t.Optional(t.String()), - }), - ), - detail: { - tags: ["Settings"], - }, - }, - ); + return { success: true }; + }, + { + body: t.Optional( + t.Object({ + session: t.Optional(t.String()), + }) + ), + detail: { + tags: ["Settings"], + }, + } + ); diff --git a/standalone/src/siteverify.js b/standalone/src/siteverify.js index 0559207..1d3565e 100644 --- a/standalone/src/siteverify.js +++ b/standalone/src/siteverify.js @@ -4,14 +4,14 @@ import { Elysia } from "elysia"; import { db } from "./db.js"; import { ratelimitGenerator } from "./ratelimit.js"; -const getSitekeyWithSecretQuery = db.query( +const getSitekeyWithSecretQuery = db.prepare( `SELECT * FROM keys WHERE siteKey = ?`, ); -const getTokenQuery = db.query(` +const getTokenQuery = db.prepare(` SELECT * FROM tokens WHERE siteKey = ? AND token = ? `); -const deleteTokenQuery = db.query(` +const deleteTokenQuery = db.prepare(` DELETE FROM tokens WHERE siteKey = ? AND token = ? `); @@ -60,8 +60,8 @@ export const siteverifyServer = new Elysia({ return { error: "Missing required parameters" }; } - const keyHash = getSitekeyWithSecretQuery.get(sitekey)?.secretHash; - if (!keyHash) { + const keyHash = (await getSitekeyWithSecretQuery.get(sitekey))?.secretHash; + if (!keyHash || !secret) { set.status = 404; return { error: "Invalid site key or secret" }; } @@ -74,7 +74,7 @@ export const siteverifyServer = new Elysia({ return { error: "Invalid site key or secret" }; } - const token = getTokenQuery.get(params.siteKey, response); + const token = await getTokenQuery.get(params.siteKey, response); if (!token) { set.status = 404;