Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions standalone/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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})
`;

Expand Down Expand Up @@ -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]
);

Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 20 additions & 12 deletions standalone/src/cap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -45,19 +45,19 @@ 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})
`;

return challenge;
})
.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" };
Expand Down Expand Up @@ -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,
Expand Down
130 changes: 92 additions & 38 deletions standalone/src/db.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 };
Loading