standalone@2.0.14: experimental turso support with DB_CONNECTOR=turso
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user