165 lines
6.5 KiB
TypeScript
165 lines
6.5 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import { createHmac, randomBytes } from "crypto";
|
|
import sql from "../db";
|
|
import { createRateLimiter } from "../utils/rate-limit";
|
|
|
|
const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key";
|
|
|
|
function redir(to: string) {
|
|
return new Response(
|
|
`<!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta http-equiv="refresh" content="0;url=${to}"><meta name="robots" content="noindex"><style>html,body{background:#0a0a0a;margin:0;height:100%}</style></head><body></body></html>`,
|
|
{ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
|
|
);
|
|
}
|
|
|
|
// ── Per-IP rate limiting for auth endpoints ───────────────────────────
|
|
const checkAuthRateLimit = createRateLimiter();
|
|
|
|
function generateKey(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
function hashEmail(email: string): string {
|
|
return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex");
|
|
}
|
|
|
|
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
|
|
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
|
|
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
|
|
|
|
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
|
|
if (apiKey) {
|
|
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
|
|
return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export { resolveKey };
|
|
|
|
export function requireAuth(app: Elysia) {
|
|
return app
|
|
.derive(async ({ headers, cookie, set }) => {
|
|
const authHeader = headers["authorization"] ?? "";
|
|
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
|
const cookieKey = cookie?.pingql_key?.value;
|
|
|
|
const key = bearer || cookieKey;
|
|
if (!key) {
|
|
set.status = 401;
|
|
return { accountId: null as string | null, keyId: null as string | null };
|
|
}
|
|
|
|
const resolved = await resolveKey(key);
|
|
if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId };
|
|
|
|
set.status = 401;
|
|
return { accountId: null as string | null, keyId: null as string | null };
|
|
})
|
|
.onBeforeHandle(({ accountId, set }) => {
|
|
if (!accountId) {
|
|
set.status = 401;
|
|
return { error: "Invalid or missing account key" };
|
|
}
|
|
});
|
|
}
|
|
|
|
const COOKIE_OPTS = {
|
|
httpOnly: true,
|
|
secure: process.env.COOKIE_SECURE !== "false",
|
|
sameSite: "none" as const,
|
|
path: "/",
|
|
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
};
|
|
|
|
export const account = new Elysia({ prefix: "/account" })
|
|
|
|
.post("/login", async ({ body, cookie, set, request, error }) => {
|
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
if (!checkAuthRateLimit(ip, 10)) return error(429, { error: "Too many login attempts. Try again later." });
|
|
|
|
const key = (body.key as string)?.trim();
|
|
if (!key) { set.status = 400; return { error: "Key required" }; }
|
|
|
|
const resolved = await resolveKey(key);
|
|
if (!resolved) {
|
|
set.status = 401;
|
|
if ((body as any)._form) return redir("/dashboard?error=invalid");
|
|
return { error: "Invalid account key" };
|
|
}
|
|
|
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
|
if ((body as any)._form) return redir("/dashboard/home");
|
|
return { ok: true };
|
|
}, { detail: { hide: true } })
|
|
|
|
.get("/logout", ({ cookie }) => {
|
|
cookie.pingql_key.set({ value: "", ...COOKIE_OPTS, maxAge: 0 });
|
|
return redir("/dashboard");
|
|
}, { detail: { hide: true } })
|
|
|
|
.post("/register", async ({ body, cookie, request, set, error }) => {
|
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
if (!checkAuthRateLimit(ip, 5)) return error(429, { error: "Too many registrations. Try again later." });
|
|
|
|
const key = generateKey();
|
|
const emailHash = (body as any).email ? hashEmail((body as any).email) : null;
|
|
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
|
|
|
// Form submission → redirect to welcome page showing the key
|
|
if ((body as any)._form) return redir(`/dashboard/welcome?key=${encodeURIComponent(key)}`);
|
|
|
|
return { key, email_registered: !!emailHash };
|
|
})
|
|
|
|
.use(requireAuth)
|
|
|
|
.get("/settings", async ({ accountId }) => {
|
|
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
|
|
const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
|
return {
|
|
account_id: acc.id,
|
|
has_email: !!acc.email_hash,
|
|
created_at: acc.created_at,
|
|
api_keys: keys,
|
|
};
|
|
})
|
|
|
|
.post("/email", async ({ accountId, body }) => {
|
|
const emailHash = (body as any).email ? hashEmail((body as any).email) : null;
|
|
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
|
if ((body as any)._form) return redir("/dashboard/settings");
|
|
return { ok: true };
|
|
})
|
|
|
|
.post("/reset-key", async ({ accountId, cookie, body }) => {
|
|
const key = generateKey();
|
|
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
|
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
|
if ((body as any)?._form) return redir("/dashboard/settings");
|
|
return { key, message: "Primary key rotated. Your old key is now invalid." };
|
|
})
|
|
|
|
.post("/keys", async ({ accountId, body }) => {
|
|
const key = generateKey();
|
|
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${(body as any).label}) RETURNING id`;
|
|
if ((body as any)._form) return redir("/dashboard/settings");
|
|
return { key, id: created.id, label: (body as any).label };
|
|
})
|
|
|
|
.post("/keys/:id/delete", async ({ accountId, params }) => {
|
|
await sql`DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId}`;
|
|
return redir("/dashboard/settings");
|
|
})
|
|
|
|
.delete("/keys/:id", async ({ accountId, params, error }) => {
|
|
const [deleted] = await sql`
|
|
DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
|
`;
|
|
if (!deleted) return error(404, { error: "Key not found" });
|
|
return { deleted: true };
|
|
});
|