88e00e5d41
Introduce server-rendered guestbook and moderation portal. Persist data in SQLite (better-sqlite3); add WebAuthn YubiKey admin auth, rate-limiting, spam heuristics, and sanitization. Switch Docker image to run Node/standalone Astro (remove nginx), update docker-compose, Dockerfile, astro.config, and package.json. Add .env.example, docs/guestbook.md, gitignore updates, layouts, API routes, and supporting lib/components/pages for the feature.
70 lines
1.9 KiB
TypeScript
70 lines
1.9 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto';
|
|
import db from './db';
|
|
|
|
function getSecretKey(): string {
|
|
const key = process.env.SECRET_KEY;
|
|
if (!key) throw new Error('SECRET_KEY environment variable is required');
|
|
return key;
|
|
}
|
|
|
|
export function hashIP(ip: string): string {
|
|
return createHash('sha256').update(getSecretKey() + ':' + ip).digest('hex').slice(0, 16);
|
|
}
|
|
|
|
export function generateId(): string {
|
|
return randomBytes(32).toString('hex');
|
|
}
|
|
|
|
export interface AdminSession {
|
|
id: string;
|
|
user_id: string;
|
|
created_at: string;
|
|
expires_at: string;
|
|
}
|
|
|
|
export function createSession(userId: string): string {
|
|
const sessionId = generateId();
|
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
db.prepare(
|
|
`INSERT INTO admin_sessions (id, user_id, expires_at) VALUES (?, ?, ?)`
|
|
).run(sessionId, userId, expiresAt);
|
|
|
|
return sessionId;
|
|
}
|
|
|
|
export function getSession(sessionId: string): AdminSession | undefined {
|
|
return db
|
|
.prepare(
|
|
`SELECT * FROM admin_sessions
|
|
WHERE id = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
|
)
|
|
.get(sessionId) as AdminSession | undefined;
|
|
}
|
|
|
|
export function deleteSession(sessionId: string): void {
|
|
db.prepare(`DELETE FROM admin_sessions WHERE id = ?`).run(sessionId);
|
|
}
|
|
|
|
export function cleanExpiredSessions(): void {
|
|
db.prepare(
|
|
`DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
|
).run();
|
|
db.prepare(
|
|
`DELETE FROM webauthn_challenges WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
|
).run();
|
|
}
|
|
|
|
export const SESSION_COOKIE = 'admin_session';
|
|
export const CHALLENGE_COOKIE = 'webauthn_challenge';
|
|
|
|
export function sessionCookieOptions(maxAge: number) {
|
|
return {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'strict' as const,
|
|
path: '/',
|
|
maxAge,
|
|
};
|
|
}
|