565 lines
13 KiB
JavaScript
565 lines
13 KiB
JavaScript
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 { ratelimitGenerator } from "./ratelimit.js";
|
|
|
|
const keyDefaults = {
|
|
difficulty: 4,
|
|
challengeCount: 50,
|
|
saltSize: 32,
|
|
};
|
|
|
|
const get24hSolvesQuery = db.query(
|
|
`SELECT SUM(count) as total FROM solutions WHERE siteKey = ? AND bucket >= ?`,
|
|
);
|
|
const get24hPreviousQuery = db.query(`
|
|
SELECT SUM(count) as total FROM solutions
|
|
WHERE siteKey = ? AND bucket >= ? AND bucket < ?
|
|
`);
|
|
const getKeysQuery = db.query(`SELECT * FROM keys ORDER BY created DESC`);
|
|
|
|
export const server = new Elysia({
|
|
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;
|
|
|
|
const currentStart = now - day;
|
|
const previousStart = now - 2 * day;
|
|
|
|
return getKeysQuery.all().map((key) => {
|
|
const current =
|
|
get24hSolvesQuery.get(key.siteKey, currentStart).total || 0;
|
|
|
|
const previous =
|
|
get24hPreviousQuery.get(key.siteKey, previousStart, currentStart)
|
|
.total || 0;
|
|
|
|
let change = 0;
|
|
let direction = "";
|
|
|
|
if (previous > 0) {
|
|
change = ((current - previous) / previous) * 100;
|
|
direction =
|
|
current > previous ? "up" : current < previous ? "down" : "";
|
|
} else if (current > 0) {
|
|
change = 100;
|
|
direction = "up";
|
|
}
|
|
|
|
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(/=+$/, "");
|
|
|
|
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,
|
|
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" };
|
|
}
|
|
|
|
const chartDuration = query.chartDuration || "today";
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const day = 24 * 60 * 60;
|
|
|
|
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.query(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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);
|
|
}
|
|
|
|
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;
|
|
|
|
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]),
|
|
);
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
historyData = completeData;
|
|
}
|
|
|
|
const currentSolves =
|
|
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" };
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
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" };
|
|
}
|
|
|
|
db.query(`DELETE FROM keys WHERE siteKey = ?`).run(params.siteKey);
|
|
db.query(`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(/=+$/, "");
|
|
|
|
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();
|
|
|
|
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;
|
|
|
|
db.query(
|
|
`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",
|
|
({ 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" };
|
|
}
|
|
|
|
const { hash } = JSON.parse(
|
|
atob(authorization.replace("Bearer ", "").trim()),
|
|
);
|
|
|
|
let session = hash;
|
|
|
|
if (body.session) {
|
|
// body.session are the last characters of the session token
|
|
// e.g. body.session = (...)8KdbcHjqxWPR6Q
|
|
|
|
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;
|
|
}
|
|
|
|
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"],
|
|
},
|
|
},
|
|
);
|