standalone@2.0.14: experimental turso support with DB_CONNECTOR=turso

This commit is contained in:
tiago
2025-10-25 13:36:56 +01:00
parent a7ad6ed5f7
commit 3a521a3af6
7 changed files with 686 additions and 651 deletions
+13
View File
@@ -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=="],
+2 -1
View File
@@ -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"
},
+102 -109
View File
@@ -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.",
};
}
};
+8 -8
View File
@@ -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);
+80 -61
View File
@@ -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 };
+475 -466
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -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;