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.
205 lines
6.3 KiB
Plaintext
205 lines
6.3 KiB
Plaintext
---
|
|
export const prerender = false;
|
|
|
|
import AdminLayout from '../../layouts/AdminLayout.astro';
|
|
import { getSession, SESSION_COOKIE } from '../../lib/auth';
|
|
import { hasCredentials } from '../../lib/webauthn';
|
|
|
|
// Redirect if already logged in
|
|
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
|
|
const session = sessionId ? getSession(sessionId) : undefined;
|
|
if (session) {
|
|
return Astro.redirect('/admin');
|
|
}
|
|
|
|
const registrationMode = !hasCredentials();
|
|
---
|
|
|
|
<AdminLayout title="Login">
|
|
<div class="login-wrap">
|
|
<h1>admin access</h1>
|
|
|
|
{registrationMode ? (
|
|
<div class="card">
|
|
<h2>Register your security key</h2>
|
|
<p class="info-text">
|
|
No admin credentials are registered yet. Connect your YubiKey and click the button
|
|
below to register it as the admin credential for this site.
|
|
</p>
|
|
<p class="warning-text">
|
|
Do this immediately after deployment — once registered, no further registrations
|
|
will be accepted without resetting the database.
|
|
</p>
|
|
<button id="register-btn" class="btn btn-primary" type="button">
|
|
Register YubiKey
|
|
</button>
|
|
<p id="register-status" class="status-text" aria-live="polite"></p>
|
|
</div>
|
|
) : (
|
|
<div class="card">
|
|
<h2>Login with your security key</h2>
|
|
<p class="info-text">
|
|
Insert your YubiKey and click the button below to authenticate.
|
|
</p>
|
|
<button id="login-btn" class="btn btn-primary" type="button">
|
|
Authenticate with YubiKey
|
|
</button>
|
|
<p id="login-status" class="status-text" aria-live="polite"></p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AdminLayout>
|
|
|
|
{registrationMode ? (
|
|
<script>
|
|
import { startRegistration } from '@simplewebauthn/browser';
|
|
|
|
const btn = document.getElementById('register-btn') as HTMLButtonElement;
|
|
const status = document.getElementById('register-status') as HTMLParagraphElement;
|
|
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
status.textContent = 'Requesting registration options...';
|
|
|
|
try {
|
|
const optRes = await fetch('/api/admin/webauthn/register-options');
|
|
if (!optRes.ok) {
|
|
const err = await optRes.json();
|
|
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
const options = await optRes.json();
|
|
|
|
status.textContent = 'Touch your YubiKey when it flashes...';
|
|
let credential;
|
|
try {
|
|
credential = await startRegistration(options);
|
|
} catch (e: any) {
|
|
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
status.textContent = 'Verifying credential...';
|
|
const verifyRes = await fetch('/api/admin/webauthn/register-verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(credential),
|
|
});
|
|
const result = await verifyRes.json();
|
|
|
|
if (result.verified) {
|
|
status.textContent = 'Success! Redirecting to login...';
|
|
setTimeout(() => { window.location.href = '/admin/login'; }, 1000);
|
|
} else {
|
|
status.textContent = `Registration failed: ${result.error ?? 'Unknown error'}`;
|
|
btn.disabled = false;
|
|
}
|
|
} catch (e: any) {
|
|
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
</script>
|
|
) : (
|
|
<script>
|
|
import { startAuthentication } from '@simplewebauthn/browser';
|
|
|
|
const btn = document.getElementById('login-btn') as HTMLButtonElement;
|
|
const status = document.getElementById('login-status') as HTMLParagraphElement;
|
|
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
status.textContent = 'Requesting authentication options...';
|
|
|
|
try {
|
|
const optRes = await fetch('/api/admin/webauthn/login-options');
|
|
if (!optRes.ok) {
|
|
const err = await optRes.json();
|
|
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
const options = await optRes.json();
|
|
|
|
status.textContent = 'Touch your YubiKey when it flashes...';
|
|
let credential;
|
|
try {
|
|
credential = await startAuthentication(options);
|
|
} catch (e: any) {
|
|
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
status.textContent = 'Verifying...';
|
|
const verifyRes = await fetch('/api/admin/webauthn/login-verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(credential),
|
|
});
|
|
const result = await verifyRes.json();
|
|
|
|
if (result.verified) {
|
|
status.textContent = 'Authenticated! Redirecting...';
|
|
window.location.href = '/admin';
|
|
} else {
|
|
status.textContent = `Authentication failed: ${result.error ?? 'Unknown error'}`;
|
|
btn.disabled = false;
|
|
}
|
|
} catch (e: any) {
|
|
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
</script>
|
|
)}
|
|
|
|
<style>
|
|
.login-wrap {
|
|
max-width: 480px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h1 {
|
|
margin-bottom: var(--space-md);
|
|
}
|
|
|
|
.info-text {
|
|
color: var(--color-text-dim);
|
|
font-size: 0.88rem;
|
|
margin-bottom: var(--space-sm);
|
|
}
|
|
|
|
.warning-text {
|
|
color: var(--color-peach);
|
|
font-size: 0.82rem;
|
|
margin-bottom: var(--space-md);
|
|
padding: var(--space-xs) var(--space-sm);
|
|
border-left: 2px solid var(--color-peach);
|
|
}
|
|
|
|
.btn {
|
|
display: inline-block;
|
|
padding: 8px 20px;
|
|
border-radius: 4px;
|
|
border: 1px solid currentColor;
|
|
background: none;
|
|
font-family: inherit;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.btn:hover { opacity: 0.8; }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.btn-primary { color: var(--color-accent); }
|
|
|
|
.status-text {
|
|
margin-top: var(--space-sm);
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-dim);
|
|
min-height: 1.4em;
|
|
}
|
|
</style>
|