Add guestbook with WebAuthn admin and SQLite

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.
This commit is contained in:
2026-03-07 20:21:39 +01:00
parent 915594e83e
commit 88e00e5d41
26 changed files with 2327 additions and 45 deletions
+69
View File
@@ -0,0 +1,69 @@
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,
};
}
+79
View File
@@ -0,0 +1,79 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const dbPath = resolve(process.env.DB_PATH ?? './data/guestbook.db');
// Ensure directory exists
mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
// WAL mode improves concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS guestbook_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
message TEXT NOT NULL,
website TEXT,
ip_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
moderated_at TEXT,
moderation_note TEXT
);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL,
user_name TEXT NOT NULL,
backed_up INTEGER NOT NULL DEFAULT 0,
transports TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_used_at TEXT
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id TEXT PRIMARY KEY,
challenge TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entry_id INTEGER,
admin_session TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_entries_status ON guestbook_entries(status);
CREATE INDEX IF NOT EXISTS idx_entries_created ON guestbook_entries(created_at);
CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON rate_limit(ip_hash, created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON admin_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_challenges_expires ON webauthn_challenges(expires_at);
`);
export default db;
+125
View File
@@ -0,0 +1,125 @@
import db from './db';
export interface GuestbookEntry {
id: number;
display_name: string;
message: string;
website: string | null;
ip_hash: string | null;
status: 'pending' | 'approved' | 'rejected' | 'spam';
created_at: string;
moderated_at: string | null;
moderation_note: string | null;
}
export interface SubmitData {
display_name: string;
message: string;
website: string | null;
ip_hash: string | null;
}
const PAGE_SIZE = 20;
export function getApprovedEntries(page = 1): { entries: GuestbookEntry[]; total: number; hasMore: boolean } {
const offset = (page - 1) * PAGE_SIZE;
const entries = db
.prepare(
`SELECT id, display_name, message, website, created_at
FROM guestbook_entries
WHERE status = 'approved'
ORDER BY created_at DESC
LIMIT ? OFFSET ?`
)
.all(PAGE_SIZE, offset) as GuestbookEntry[];
const { total } = db
.prepare(`SELECT COUNT(*) as total FROM guestbook_entries WHERE status = 'approved'`)
.get() as { total: number };
return { entries, total, hasMore: offset + PAGE_SIZE < total };
}
export function getPendingEntries(): GuestbookEntry[] {
return db
.prepare(
`SELECT * FROM guestbook_entries WHERE status = 'pending' ORDER BY created_at ASC`
)
.all() as GuestbookEntry[];
}
export function getRecentModerated(limit = 30): GuestbookEntry[] {
return db
.prepare(
`SELECT * FROM guestbook_entries
WHERE status IN ('approved', 'rejected', 'spam')
ORDER BY moderated_at DESC
LIMIT ?`
)
.all(limit) as GuestbookEntry[];
}
export function submitEntry(data: SubmitData): number {
const result = db
.prepare(
`INSERT INTO guestbook_entries (display_name, message, website, ip_hash)
VALUES (?, ?, ?, ?)`
)
.run(data.display_name, data.message, data.website, data.ip_hash);
return result.lastInsertRowid as number;
}
export function moderateEntry(
id: number,
status: 'approved' | 'rejected' | 'spam',
note: string | null,
sessionId: string
): boolean {
const result = db
.prepare(
`UPDATE guestbook_entries
SET status = ?, moderated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), moderation_note = ?
WHERE id = ? AND status = 'pending'`
)
.run(status, note, id);
if (result.changes > 0) {
db.prepare(
`INSERT INTO audit_log (action, entry_id, admin_session, note) VALUES (?, ?, ?, ?)`
).run(`moderate:${status}`, id, sessionId, note);
}
return result.changes > 0;
}
export function checkRateLimit(ipHash: string): boolean {
// Clean up old entries (>1 hour)
db.prepare(
`DELETE FROM rate_limit WHERE created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')`
).run();
const { count } = db
.prepare(`SELECT COUNT(*) as count FROM rate_limit WHERE ip_hash = ?`)
.get(ipHash) as { count: number };
// Allow max 3 submissions per hour per IP hash
return count < 3;
}
export function recordSubmission(ipHash: string): void {
db.prepare(`INSERT INTO rate_limit (ip_hash) VALUES (?)`).run(ipHash);
}
export function isDuplicateSubmission(displayName: string, message: string): boolean {
// Check for exact duplicate in last 24 hours (regardless of status)
const row = db
.prepare(
`SELECT id FROM guestbook_entries
WHERE display_name = ? AND message = ?
AND created_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')`
)
.get(displayName, message);
return row !== undefined;
}
+120
View File
@@ -0,0 +1,120 @@
// Lightweight spam detection and input sanitization
const MAX_NAME_LENGTH = 60;
const MAX_MESSAGE_LENGTH = 1000;
const MAX_WEBSITE_LENGTH = 200;
// Patterns that strongly suggest spam
const SPAM_PATTERNS = [
/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto|investment|forex)\b/i,
/\b(click here|buy now|free money|earn \$|make money)\b/i,
/(https?:\/\/[^\s]{0,10}){3,}/i, // 3+ URLs in message
];
// Basic URL validation for the optional website field
const SAFE_URL_PATTERN = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(\/[^\s]*)?$/i;
export interface ValidationError {
field: string;
message: string;
}
export function sanitizeText(input: string): string {
// Normalize whitespace: collapse runs of spaces/tabs, normalize line endings
return input
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function stripHtml(input: string): string {
// Remove anything that looks like an HTML tag
return input.replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/gi, (match) => {
const entities: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
return entities[match] ?? match;
});
}
export function validateWebsite(url: string): string | null {
if (!url || url.trim() === '') return null;
const cleaned = url.trim();
if (cleaned.length > MAX_WEBSITE_LENGTH) return null;
if (!SAFE_URL_PATTERN.test(cleaned)) return null;
// Block localhost and private ranges in URLs
if (/localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\./i.test(cleaned)) return null;
return cleaned;
}
export function validateEntry(data: {
display_name: string;
message: string;
website: string;
consent: string;
honeypot: string;
}): ValidationError[] {
const errors: ValidationError[] = [];
// Honeypot check: must be empty (bots fill it in)
if (data.honeypot && data.honeypot.trim() !== '') {
errors.push({ field: 'honeypot', message: 'Spam detected' });
return errors;
}
// Consent required
if (!data.consent || data.consent !== 'yes') {
errors.push({ field: 'consent', message: 'You must consent to public posting' });
}
// Display name validation
const name = stripHtml(sanitizeText(data.display_name));
if (!name || name.length < 1) {
errors.push({ field: 'display_name', message: 'Please enter a display name' });
} else if (name.length > MAX_NAME_LENGTH) {
errors.push({ field: 'display_name', message: `Name must be ${MAX_NAME_LENGTH} characters or less` });
}
// Message validation
const message = stripHtml(sanitizeText(data.message));
if (!message || message.length < 1) {
errors.push({ field: 'message', message: 'Please enter a message' });
} else if (message.length > MAX_MESSAGE_LENGTH) {
errors.push({ field: 'message', message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less` });
}
return errors;
}
export function scoreSpam(data: { display_name: string; message: string; website: string | null }): number {
let score = 0;
const combined = `${data.display_name} ${data.message} ${data.website ?? ''}`;
for (const pattern of SPAM_PATTERNS) {
if (pattern.test(combined)) score += 30;
}
// Lots of URLs in message is suspicious
const urlCount = (data.message.match(/https?:\/\//gi) ?? []).length;
if (urlCount >= 2) score += 20 * urlCount;
// All-caps message is a mild spam signal
const upperRatio = (data.message.match(/[A-Z]/g) ?? []).length / Math.max(data.message.length, 1);
if (upperRatio > 0.6 && data.message.length > 10) score += 15;
return score;
}
export function isLikelySpam(data: { display_name: string; message: string; website: string | null }): boolean {
return scoreSpam(data) >= 50;
}
+205
View File
@@ -0,0 +1,205 @@
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
isoBase64URL,
} from '@simplewebauthn/server';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import db from './db';
import { generateId } from './auth';
function getRpConfig() {
return {
rpName: process.env.WEBAUTHN_RP_NAME ?? 'Cozy Den',
rpID: process.env.WEBAUTHN_RP_ID ?? 'localhost',
origin: process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4321',
};
}
export interface StoredCredential {
id: number;
credential_id: string;
public_key: Buffer;
counter: number;
user_id: string;
user_name: string;
backed_up: number;
transports: string | null;
created_at: string;
last_used_at: string | null;
}
export function hasCredentials(): boolean {
const row = db.prepare(`SELECT id FROM webauthn_credentials LIMIT 1`).get();
return row !== undefined;
}
export function getStoredCredentials(): StoredCredential[] {
return db.prepare(`SELECT * FROM webauthn_credentials`).all() as StoredCredential[];
}
export function saveChallenge(challengeId: string, challenge: string, type: 'registration' | 'authentication'): void {
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 min
db.prepare(
`INSERT OR REPLACE INTO webauthn_challenges (id, challenge, type, expires_at) VALUES (?, ?, ?, ?)`
).run(challengeId, challenge, type, expiresAt);
}
export function consumeChallenge(challengeId: string, type: 'registration' | 'authentication'): string | null {
const row = db
.prepare(
`SELECT challenge FROM webauthn_challenges
WHERE id = ? AND type = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
)
.get(challengeId, type) as { challenge: string } | undefined;
if (!row) return null;
db.prepare(`DELETE FROM webauthn_challenges WHERE id = ?`).run(challengeId);
return row.challenge;
}
export async function createRegistrationOptions(challengeId: string) {
const { rpName, rpID } = getRpConfig();
const userId = 'admin';
const existingCredentials = getStoredCredentials().map((c) => ({
id: isoBase64URL.toBuffer(c.credential_id),
type: 'public-key' as const,
transports: c.transports ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) : undefined,
}));
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: Buffer.from(userId),
userName: 'admin',
userDisplayName: 'Cozy Den Admin',
attestationType: 'none',
excludeCredentials: existingCredentials,
authenticatorSelection: {
authenticatorAttachment: 'cross-platform', // security key (YubiKey)
residentKey: 'discouraged',
userVerification: 'discouraged',
},
timeout: 60000,
});
saveChallenge(challengeId, options.challenge, 'registration');
return options;
}
export async function verifyRegistration(
credential: RegistrationResponseJSON,
challengeId: string
): Promise<{ verified: boolean; error?: string }> {
const { rpID, origin } = getRpConfig();
const expectedChallenge = consumeChallenge(challengeId, 'registration');
if (!expectedChallenge) {
return { verified: false, error: 'Challenge expired or not found' };
}
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
if (verification.verified && verification.registrationInfo) {
const { credentialID, credentialPublicKey, counter, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const transports = credential.response.transports ?? [];
db.prepare(
`INSERT INTO webauthn_credentials
(credential_id, public_key, counter, user_id, user_name, backed_up, transports)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(
isoBase64URL.fromBuffer(credentialID),
Buffer.from(credentialPublicKey),
counter,
'admin',
'admin',
credentialBackedUp ? 1 : 0,
transports.length > 0 ? JSON.stringify(transports) : null
);
}
return { verified: verification.verified };
}
export async function createAuthenticationOptions(challengeId: string) {
const { rpID } = getRpConfig();
const allowCredentials = getStoredCredentials().map((c) => ({
id: isoBase64URL.toBuffer(c.credential_id),
type: 'public-key' as const,
transports: c.transports ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) : undefined,
}));
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: 'discouraged',
timeout: 60000,
});
saveChallenge(challengeId, options.challenge, 'authentication');
return options;
}
export async function verifyAuthentication(
credential: AuthenticationResponseJSON,
challengeId: string
): Promise<{ verified: boolean; userId?: string; error?: string }> {
const { rpID, origin } = getRpConfig();
const expectedChallenge = consumeChallenge(challengeId, 'authentication');
if (!expectedChallenge) {
return { verified: false, error: 'Challenge expired or not found' };
}
const credentialId = credential.id;
const stored = db
.prepare(`SELECT * FROM webauthn_credentials WHERE credential_id = ?`)
.get(credentialId) as StoredCredential | undefined;
if (!stored) {
return { verified: false, error: 'Credential not found' };
}
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: isoBase64URL.toBuffer(stored.credential_id),
credentialPublicKey: Uint8Array.from(stored.public_key),
counter: stored.counter,
transports: stored.transports
? (JSON.parse(stored.transports) as AuthenticatorTransportFuture[])
: undefined,
},
requireUserVerification: false,
});
if (verification.verified && verification.authenticationInfo) {
db.prepare(
`UPDATE webauthn_credentials
SET counter = ?, last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE credential_id = ?`
).run(verification.authenticationInfo.newCounter, credentialId);
}
return { verified: verification.verified, userId: stored.user_id };
}