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
+200
View File
@@ -0,0 +1,200 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} — Cozy Den Admin</title>
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<header>
<div class="header-inner">
<span class="site-name">~/admin</span>
<nav>
<a href="/admin">moderation</a>
<span aria-hidden="true">·</span>
<a href="/guestbook">public view</a>
<span aria-hidden="true">·</span>
<form method="post" action="/api/admin/logout" style="display:inline">
<button type="submit" class="logout-btn">logout</button>
</form>
</nav>
</div>
</header>
<main>
<slot />
</main>
</body>
</html>
<style is:global>
:root {
--color-bg: #1e1e2e;
--color-bg-light: #313244;
--color-surface: #45475a;
--color-text: #cdd6f4;
--color-text-dim: #a6adc8;
--color-accent: #cba6f7;
--color-accent-bright: #f5c2e7;
--color-warm: #f38ba8;
--color-green: #a6e3a1;
--color-peach: #fab387;
--font-body: "JetBrains Mono", "Fira Code", monospace;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 1.5rem;
--space-lg: 2rem;
}
@media (prefers-color-scheme: light) {
:root {
--color-bg: #f6efe6;
--color-bg-light: #efe1cf;
--color-surface: #d8c2a8;
--color-text: #35251a;
--color-text-dim: #6b5442;
--color-accent: #8b5e3c;
--color-accent-bright: #a16c45;
--color-warm: #b6794f;
--color-green: #3f7c47;
--color-peach: #b5693e;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
}
body { min-height: 100vh; line-height: 1.6; }
a {
color: var(--color-accent-bright);
text-decoration: none;
}
a:hover { text-decoration: underline; }
a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
header {
background: var(--color-bg-light);
border-bottom: 1px solid var(--color-surface);
padding: var(--space-xs) var(--space-md);
}
.header-inner {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.site-name {
color: var(--color-accent);
font-weight: bold;
}
nav {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.logout-btn {
background: none;
border: none;
color: var(--color-warm);
font-family: inherit;
font-size: inherit;
cursor: pointer;
padding: 0;
text-decoration: none;
}
.logout-btn:hover { text-decoration: underline; }
main {
max-width: 900px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md);
}
h1, h2, h3 { color: var(--color-accent-bright); line-height: 1.2; }
h1 { font-size: 1.4rem; margin-bottom: var(--space-md); }
h2 { font-size: 1.1rem; margin-bottom: var(--space-sm); }
.card {
background: var(--color-bg-light);
border: 1px solid var(--color-surface);
border-radius: 6px;
padding: var(--space-md);
margin-bottom: var(--space-sm);
}
.btn {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
border: 1px solid currentColor;
background: none;
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
text-decoration: none;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.8; }
.btn-approve { color: var(--color-green); }
.btn-reject { color: var(--color-text-dim); }
.btn-spam { color: var(--color-warm); }
.btn-primary { color: var(--color-accent); }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.78rem;
font-weight: bold;
}
.badge-pending { background: var(--color-peach); color: #1e1e2e; }
.badge-approved { background: var(--color-green); color: #1e1e2e; }
.badge-rejected { background: var(--color-surface); color: var(--color-text-dim); }
.badge-spam { background: var(--color-warm); color: #1e1e2e; }
.meta { color: var(--color-text-dim); font-size: 0.82rem; }
.message-text { white-space: pre-wrap; word-break: break-word; }
.section-gap { margin-top: var(--space-lg); }
.alert {
padding: var(--space-sm);
border-radius: 4px;
margin-bottom: var(--space-md);
border: 1px solid;
}
.alert-error { border-color: var(--color-warm); color: var(--color-warm); }
.alert-success { border-color: var(--color-green); color: var(--color-green); }
input, textarea, select {
background: var(--color-bg);
border: 1px solid var(--color-surface);
border-radius: 4px;
color: var(--color-text);
font-family: inherit;
font-size: inherit;
padding: 4px 8px;
}
input:focus, textarea:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
</style>