Complete admin panel for content management: - SQLite database with better-sqlite3, seed script from content.ts - Simple password auth with HMAC-signed cookies (Edge + Node compatible) - 9 section editors: meta, hero, about, team, classes, schedule, pricing, FAQ, contact - Team CRUD with image upload and drag reorder - Schedule editor with Google Calendar-style visual timeline (colored blocks, overlap detection, click-to-add) - All public components refactored to accept data props from DB (with fallback to static content) - Middleware protecting /admin/* and /api/admin/* routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
54 lines
1.4 KiB
TypeScript
54 lines
1.4 KiB
TypeScript
/**
|
|
* Edge-compatible auth helpers (for middleware).
|
|
* Uses Web Crypto API instead of Node.js crypto.
|
|
*/
|
|
|
|
const COOKIE_NAME = "bh-admin-token";
|
|
|
|
function getSecret(): string {
|
|
const secret = process.env.AUTH_SECRET;
|
|
if (!secret) throw new Error("AUTH_SECRET is not set");
|
|
return secret;
|
|
}
|
|
|
|
function base64urlEncode(buf: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(buf);
|
|
let binary = "";
|
|
for (const b of bytes) binary += String.fromCharCode(b);
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
|
|
async function hmacSign(data: string, secret: string): Promise<string> {
|
|
const encoder = new TextEncoder();
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
encoder.encode(secret),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"]
|
|
);
|
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
return base64urlEncode(sig);
|
|
}
|
|
|
|
export async function verifyToken(token: string): Promise<boolean> {
|
|
try {
|
|
const [data, sig] = token.split(".");
|
|
if (!data || !sig) return false;
|
|
|
|
const expectedSig = await hmacSign(data, getSecret());
|
|
if (sig !== expectedSig) return false;
|
|
|
|
const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as {
|
|
role: string;
|
|
exp: number;
|
|
};
|
|
|
|
return payload.role === "admin" && payload.exp > Date.now();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export { COOKIE_NAME };
|