For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a page-specific guestbook comment system to every page of hellahung.com, with moderation, verified admin posts, Turnstile CAPTCHA, and bot-friendly SSR.
Architecture: Cloudflare Pages Functions provide the API layer, backed by a D1 SQLite database. The guestbook UI is injected into both Eleventy layouts (base.njk and themed.njk) via a shared Nunjucks partial. A Pages middleware intercepts bot requests to server-render the first page of comments as static HTML for SEO.
Tech Stack: Cloudflare Pages Functions (Workers), D1 (SQLite), Turnstile CAPTCHA, Eleventy (Nunjucks), vanilla JavaScript
functions/
_middleware.js # Bot detection + SSR comment injection
api/
comments.js # GET list + POST submit comments
replies.js # POST submit replies
admin/
login.js # POST authenticate admin
pending.js # GET pending comments/replies
moderate.js # POST approve/reject
post.js # POST as verified name
names.js # GET/POST/DELETE restricted names
passwords.js # GET/POST/DELETE admin passwords
lib/
auth.js # Password hashing + session token helpers
db.js # Shared D1 query helpers
sanitize.js # HTML escaping for user input
turnstile.js # Turnstile verification helper
db/
schema.sql # D1 table definitions
seed.sql # Initial restricted names + admin password
_includes/
guestbook.njk # Guestbook HTML partial
admin.html # Admin dashboard page (Eleventy page)
guestbook.css # Guestbook styles (separate file)
guestbook.js # Guestbook client-side JavaScript
wrangler.toml # Cloudflare D1 binding config
Why this structure:
functions/lib/ keeps shared logic DRY across endpointsguestbook.css and guestbook.js are separate files (not inline) so they can be passthrough-copied and cached by the browser — they're loaded on every pageadmin.html is a regular Eleventy page so it gets the same layout/build treatmentdb/ holds SQL files for easy review and re-running during developmentFiles:
Create: wrangler.toml
Create: db/schema.sql
Create: db/seed.sql
Modify: .gitignore
Modify: eleventy.config.js
[ ] Step 1: Create wrangler.toml
name = "hellahung"
compatibility_date = "2024-09-23"
[[d1_databases]]
binding = "DB"
database_name = "hellahung-comments"
database_id = "" # Will be filled after `wrangler d1 create`
db/schema.sqlCREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_slug TEXT NOT NULL,
name TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL,
name TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS restricted_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS admin_passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_sessions (
token TEXT PRIMARY KEY,
admin_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (admin_id) REFERENCES admin_passwords(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_comments_page_status ON comments(page_slug, status, created_at);
CREATE INDEX IF NOT EXISTS idx_replies_comment_status ON replies(comment_id, status);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON admin_sessions(token);
db/seed.sqlThis seeds the initial restricted names. The first admin password must be created via a one-time wrangler command (shown in step 5) because we need to hash it.
INSERT OR IGNORE INTO restricted_names (name) VALUES ('hellahung');
INSERT OR IGNORE INTO restricted_names (name) VALUES ('hella hung');
INSERT OR IGNORE INTO restricted_names (name) VALUES ('fudster');
.superpowers/ and .wrangler/ to .gitignoreAdd these lines to the end of .gitignore:
.superpowers
.wrangler
eleventy.config.js to passthrough-copy new static files and exclude non-page filesThe current config already copies assets, scripts, styles.css, robots.txt, and image files. Add passthrough for guestbook.css and guestbook.js. Also tell Eleventy to ignore functions/, db/, and wrangler.toml so it doesn't try to process them.
In eleventy.config.js, add inside the function body:
eleventyConfig.addPassthroughCopy("guestbook.css");
eleventyConfig.addPassthroughCopy("guestbook.js");
// Ignore Cloudflare Functions and DB files
eleventyConfig.ignores.add("functions/**");
eleventyConfig.ignores.add("db/**");
eleventyConfig.ignores.add("wrangler.toml");
Run these commands. The database ID from the first command must be pasted into wrangler.toml.
npx wrangler d1 create hellahung-comments
# Copy the database_id from the output into wrangler.toml
npx wrangler d1 execute hellahung-comments --local --file=db/schema.sql
npx wrangler d1 execute hellahung-comments --local --file=db/seed.sql
Generate a bcrypt-hashed password and insert it. Replace YOUR_PASSWORD_HERE with the actual password.
# Install bcrypt dependency (needed for password hashing in Workers)
npm install bcryptjs
# Insert the first admin (do this manually or via a script)
# For local dev, use wrangler d1 execute:
node -e "const b=require('bcryptjs');b.hash('YOUR_PASSWORD_HERE',10).then(h=>console.log(\"INSERT INTO admin_passwords (label, password_hash) VALUES ('Fudster', '\"+h+\"');\"))" | npx wrangler d1 execute hellahung-comments --local --file=-
git add wrangler.toml db/schema.sql db/seed.sql .gitignore eleventy.config.js package.json package-lock.json
git commit -m "feat: add wrangler config, D1 schema, and project setup for comment system"
Files:
Create: functions/lib/sanitize.js
Create: functions/lib/turnstile.js
Create: functions/lib/auth.js
Create: functions/lib/db.js
[ ] Step 1: Create functions/lib/sanitize.js
/**
* Escape HTML special characters to prevent XSS.
*/
export function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
functions/lib/turnstile.js/**
* Verify a Turnstile CAPTCHA token with Cloudflare's API.
* Returns true if valid, false otherwise.
*/
export async function verifyTurnstile(token, secretKey, ip) {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: secretKey,
response: token,
remoteip: ip,
}),
});
const data = await response.json();
return data.success === true;
}
functions/lib/auth.js/**
* Admin authentication helpers.
* Uses Web Crypto API (available in Workers) for password hashing
* and simple token generation for sessions.
*/
// We use bcryptjs for password hashing since it works in Workers
import bcrypt from "bcryptjs";
/**
* Hash a plaintext password.
*/
export async function hashPassword(password) {
return bcrypt.hash(password, 10);
}
/**
* Compare a plaintext password against a bcrypt hash.
*/
export async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
/**
* Generate a random session token (hex string).
*/
export function generateToken() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
/**
* Validate an admin session token against the DB.
* Returns the admin record if valid, null otherwise.
*/
export async function validateSession(db, token) {
if (!token) return null;
const session = await db
.prepare(
"SELECT s.admin_id, p.label FROM admin_sessions s JOIN admin_passwords p ON s.admin_id = p.id WHERE s.token = ? AND s.expires_at > datetime('now')"
)
.bind(token)
.first();
return session || null;
}
functions/lib/db.js/**
* Shared D1 query helpers for the comment system.
*/
const COMMENTS_PER_PAGE = 5;
/**
* Get approved comments for a page with pagination.
* Returns { comments, totalPages, currentPage }.
* Each comment includes its approved replies.
*/
export async function getApprovedComments(db, pageSlug, page = 1) {
const offset = (page - 1) * COMMENTS_PER_PAGE;
// Get total count
const countResult = await db
.prepare("SELECT COUNT(*) as total FROM comments WHERE page_slug = ? AND status = 'approved'")
.bind(pageSlug)
.first();
const total = countResult.total;
const totalPages = Math.max(1, Math.ceil(total / COMMENTS_PER_PAGE));
// Get comments for this page
const { results: comments } = await db
.prepare(
"SELECT id, name, message, is_admin, created_at FROM comments WHERE page_slug = ? AND status = 'approved' ORDER BY created_at DESC LIMIT ? OFFSET ?"
)
.bind(pageSlug, COMMENTS_PER_PAGE, offset)
.all();
// Get replies for these comments
if (comments.length > 0) {
const commentIds = comments.map((c) => c.id);
const placeholders = commentIds.map(() => "?").join(",");
const { results: replies } = await db
.prepare(
`SELECT id, comment_id, name, message, is_admin, created_at FROM replies WHERE comment_id IN (${placeholders}) AND status = 'approved' ORDER BY created_at ASC`
)
.bind(...commentIds)
.all();
// Attach replies to their parent comments
const replyMap = {};
for (const reply of replies) {
if (!replyMap[reply.comment_id]) replyMap[reply.comment_id] = [];
replyMap[reply.comment_id].push(reply);
}
for (const comment of comments) {
comment.replies = replyMap[comment.id] || [];
}
} else {
// No comments, nothing to attach
}
return { comments, totalPages, currentPage: page };
}
/**
* Check if a name is restricted (case-insensitive).
*/
export async function isNameRestricted(db, name) {
const result = await db
.prepare("SELECT id FROM restricted_names WHERE LOWER(name) = LOWER(?)")
.bind(name.trim())
.first();
return result !== null;
}
/**
* Get all pending comments and replies for admin review.
*/
export async function getPendingItems(db) {
const { results: comments } = await db
.prepare(
"SELECT id, page_slug, name, message, created_at, 'comment' as type FROM comments WHERE status = 'pending' ORDER BY created_at ASC"
)
.all();
const { results: replies } = await db
.prepare(
"SELECT r.id, c.page_slug, r.name, r.message, r.created_at, 'reply' as type, r.comment_id, c.name as parent_name, c.message as parent_message FROM replies r JOIN comments c ON r.comment_id = c.id WHERE r.status = 'pending' ORDER BY r.created_at ASC"
)
.all();
return { comments, replies };
}
git add functions/lib/
git commit -m "feat: add shared library functions (sanitize, turnstile, auth, db helpers)"
Files:
Create: functions/api/comments.js
[ ] Step 1: Create functions/api/comments.js
Cloudflare Pages Functions use file-based routing. functions/api/comments.js handles both GET and POST on /api/comments.
import { getApprovedComments, isNameRestricted } from "../lib/db.js";
import { escapeHtml } from "../lib/sanitize.js";
import { verifyTurnstile } from "../lib/turnstile.js";
export async function onRequestGet(context) {
const { env, request } = context;
const url = new URL(request.url);
const pageSlug = url.searchParams.get("page") || "index";
const page = parseInt(url.searchParams.get("p") || "1", 10);
if (page < 1 || isNaN(page)) {
return Response.json({ error: "Invalid page number" }, { status: 400 });
}
const data = await getApprovedComments(env.DB, pageSlug, page);
return Response.json(data);
}
export async function onRequestPost(context) {
const { env, request } = context;
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { page_slug, name, message, turnstile_token } = body;
// Validate required fields
if (!page_slug || !name || !message || !turnstile_token) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
// Validate lengths
const trimmedName = name.trim();
const trimmedMessage = message.trim();
if (trimmedName.length < 1 || trimmedName.length > 50) {
return Response.json({ error: "Name must be 1-50 characters" }, { status: 400 });
}
if (trimmedMessage.length < 1 || trimmedMessage.length > 1000) {
return Response.json({ error: "Message must be 1-1000 characters" }, { status: 400 });
}
// Verify Turnstile
const ip = request.headers.get("CF-Connecting-IP") || "";
const turnstileValid = await verifyTurnstile(turnstile_token, env.TURNSTILE_SECRET, ip);
if (!turnstileValid) {
return Response.json({ error: "CAPTCHA verification failed" }, { status: 403 });
}
// Check restricted names
const restricted = await isNameRestricted(env.DB, trimmedName);
if (restricted) {
return Response.json({ error: "This name is reserved" }, { status: 403 });
}
// Insert comment as pending
const sanitizedName = escapeHtml(trimmedName);
const sanitizedMessage = escapeHtml(trimmedMessage);
await env.DB.prepare(
"INSERT INTO comments (page_slug, name, message, status, is_admin) VALUES (?, ?, ?, 'pending', 0)"
)
.bind(page_slug, sanitizedName, sanitizedMessage)
.run();
return Response.json({ success: true, message: "Thanks! Your message is awaiting approval." });
}
npx wrangler pages dev _site --d1=DB=hellahung-comments --binding TURNSTILE_SECRET=1x0000000000000000000000000000000AA
The Turnstile secret 1x0000000000000000000000000000000AA is Cloudflare's test secret that always passes.
Test GET:
curl "http://localhost:8788/api/comments?page=bio&p=1"
# Expected: {"comments":[],"totalPages":1,"currentPage":1}
Test POST with restricted name:
curl -X POST http://localhost:8788/api/comments -H "Content-Type: application/json" -d '{"page_slug":"bio","name":"hellahung","message":"test","turnstile_token":"test-token"}'
# Expected: {"error":"This name is reserved"}
Test POST with valid name:
curl -X POST http://localhost:8788/api/comments -H "Content-Type: application/json" -d '{"page_slug":"bio","name":"TestUser","message":"Hello world!","turnstile_token":"test-token"}'
# Expected: {"success":true,"message":"Thanks! Your message is awaiting approval."}
git add functions/api/comments.js
git commit -m "feat: add public comments API (GET list + POST submit)"
Files:
Create: functions/api/replies.js
[ ] Step 1: Create functions/api/replies.js
import { isNameRestricted } from "../lib/db.js";
import { escapeHtml } from "../lib/sanitize.js";
import { verifyTurnstile } from "../lib/turnstile.js";
export async function onRequestPost(context) {
const { env, request } = context;
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { comment_id, name, message, turnstile_token } = body;
// Validate required fields
if (!comment_id || !name || !message || !turnstile_token) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
// Validate lengths
const trimmedName = name.trim();
const trimmedMessage = message.trim();
if (trimmedName.length < 1 || trimmedName.length > 50) {
return Response.json({ error: "Name must be 1-50 characters" }, { status: 400 });
}
if (trimmedMessage.length < 1 || trimmedMessage.length > 1000) {
return Response.json({ error: "Message must be 1-1000 characters" }, { status: 400 });
}
// Check that the parent comment exists and is approved
const parent = await env.DB.prepare(
"SELECT id FROM comments WHERE id = ? AND status = 'approved'"
)
.bind(comment_id)
.first();
if (!parent) {
return Response.json({ error: "Comment not found" }, { status: 404 });
}
// Verify Turnstile
const ip = request.headers.get("CF-Connecting-IP") || "";
const turnstileValid = await verifyTurnstile(turnstile_token, env.TURNSTILE_SECRET, ip);
if (!turnstileValid) {
return Response.json({ error: "CAPTCHA verification failed" }, { status: 403 });
}
// Check restricted names
const restricted = await isNameRestricted(env.DB, trimmedName);
if (restricted) {
return Response.json({ error: "This name is reserved" }, { status: 403 });
}
// Insert reply as pending
const sanitizedName = escapeHtml(trimmedName);
const sanitizedMessage = escapeHtml(trimmedMessage);
await env.DB.prepare(
"INSERT INTO replies (comment_id, name, message, status, is_admin) VALUES (?, ?, ?, 'pending', 0)"
)
.bind(comment_id, sanitizedName, sanitizedMessage)
.run();
return Response.json({ success: true, message: "Thanks! Your reply is awaiting approval." });
}
# First, manually approve the test comment from Task 3 so we can reply to it:
npx wrangler d1 execute hellahung-comments --local --command "UPDATE comments SET status = 'approved' WHERE id = 1"
# Now test reply submission:
curl -X POST http://localhost:8788/api/replies -H "Content-Type: application/json" -d '{"comment_id":1,"name":"ReplyUser","message":"Great comment!","turnstile_token":"test-token"}'
# Expected: {"success":true,"message":"Thanks! Your reply is awaiting approval."}
git add functions/api/replies.js
git commit -m "feat: add public replies API endpoint"
Files:
Create: functions/api/admin/login.js
[ ] Step 1: Create functions/api/admin/login.js
import { verifyPassword, generateToken } from "../../lib/auth.js";
export async function onRequestPost(context) {
const { env, request } = context;
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { password } = body;
if (!password) {
return Response.json({ error: "Password required" }, { status: 400 });
}
// Check password against all admin passwords
const { results: admins } = await env.DB.prepare(
"SELECT id, label, password_hash FROM admin_passwords"
).all();
let matchedAdmin = null;
for (const admin of admins) {
const match = await verifyPassword(password, admin.password_hash);
if (match) {
matchedAdmin = admin;
break;
}
}
if (!matchedAdmin) {
return Response.json({ error: "Invalid password" }, { status: 401 });
}
// Create session token (expires in 24 hours)
const token = generateToken();
await env.DB.prepare(
"INSERT INTO admin_sessions (token, admin_id, expires_at) VALUES (?, ?, datetime('now', '+24 hours'))"
)
.bind(token, matchedAdmin.id)
.run();
// Clean up expired sessions while we're here
await env.DB.prepare("DELETE FROM admin_sessions WHERE expires_at < datetime('now')").run();
return Response.json({
success: true,
token,
label: matchedAdmin.label,
});
}
curl -X POST http://localhost:8788/api/admin/login -H "Content-Type: application/json" -d '{"password":"YOUR_PASSWORD_HERE"}'
# Expected: {"success":true,"token":"<64-char-hex>","label":"Fudster"}
git add functions/api/admin/login.js
git commit -m "feat: add admin login API with session tokens"
Files:
Create: functions/api/admin/pending.js
Create: functions/api/admin/moderate.js
[ ] Step 1: Create functions/api/admin/pending.js
import { validateSession } from "../../lib/auth.js";
import { getPendingItems } from "../../lib/db.js";
export async function onRequestGet(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const pending = await getPendingItems(env.DB);
return Response.json(pending);
}
functions/api/admin/moderate.jsimport { validateSession } from "../../lib/auth.js";
export async function onRequestPost(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { id, type, action } = body;
if (!id || !type || !action) {
return Response.json({ error: "Missing required fields: id, type, action" }, { status: 400 });
}
if (type !== "comment" && type !== "reply") {
return Response.json({ error: "Type must be 'comment' or 'reply'" }, { status: 400 });
}
if (action !== "approve" && action !== "reject") {
return Response.json({ error: "Action must be 'approve' or 'reject'" }, { status: 400 });
}
const table = type === "comment" ? "comments" : "replies";
const newStatus = action === "approve" ? "approved" : "rejected";
const result = await env.DB.prepare(`UPDATE ${table} SET status = ? WHERE id = ? AND status = 'pending'`)
.bind(newStatus, id)
.run();
if (result.meta.changes === 0) {
return Response.json({ error: "Item not found or already moderated" }, { status: 404 });
}
return Response.json({ success: true, status: newStatus });
}
# Get pending items (use token from Task 5 login):
TOKEN="your-token-here"
curl -H "Authorization: Bearer $TOKEN" http://localhost:8788/api/admin/pending
# Expected: {"comments":[...],"replies":[...]}
# Approve a comment:
curl -X POST http://localhost:8788/api/admin/moderate -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"id":1,"type":"comment","action":"approve"}'
# Expected: {"success":true,"status":"approved"}
git add functions/api/admin/pending.js functions/api/admin/moderate.js
git commit -m "feat: add admin pending queue and moderation endpoints"
Files:
Create: functions/api/admin/post.js
[ ] Step 1: Create functions/api/admin/post.js
import { validateSession } from "../../lib/auth.js";
import { escapeHtml } from "../../lib/sanitize.js";
export async function onRequestPost(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { page_slug, name, message, type, comment_id } = body;
// type is "comment" or "reply"
if (!name || !message || !type) {
return Response.json({ error: "Missing required fields: name, message, type" }, { status: 400 });
}
if (type === "reply" && !comment_id) {
return Response.json({ error: "comment_id required for replies" }, { status: 400 });
}
if (type === "comment" && !page_slug) {
return Response.json({ error: "page_slug required for comments" }, { status: 400 });
}
const sanitizedName = escapeHtml(name.trim());
const sanitizedMessage = escapeHtml(message.trim());
if (type === "comment") {
await env.DB.prepare(
"INSERT INTO comments (page_slug, name, message, status, is_admin) VALUES (?, ?, ?, 'approved', 1)"
)
.bind(page_slug, sanitizedName, sanitizedMessage)
.run();
} else {
// Verify parent comment exists
const parent = await env.DB.prepare("SELECT id FROM comments WHERE id = ?")
.bind(comment_id)
.first();
if (!parent) {
return Response.json({ error: "Parent comment not found" }, { status: 404 });
}
await env.DB.prepare(
"INSERT INTO replies (comment_id, name, message, status, is_admin) VALUES (?, ?, ?, 'approved', 1)"
)
.bind(comment_id, sanitizedName, sanitizedMessage)
.run();
}
return Response.json({ success: true, message: "Posted as verified user" });
}
TOKEN="your-token-here"
curl -X POST http://localhost:8788/api/admin/post -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"page_slug":"bio","name":"hELLA HUNG","message":"Welcome to my guestbook!","type":"comment"}'
# Expected: {"success":true,"message":"Posted as verified user"}
# Verify it shows up with is_admin=1:
curl "http://localhost:8788/api/comments?page=bio&p=1"
# Expected: comment with is_admin: 1
git add functions/api/admin/post.js
git commit -m "feat: add admin verified post endpoint"
Files:
Create: functions/api/admin/names.js
[ ] Step 1: Create functions/api/admin/names.js
Cloudflare Pages Functions use onRequestGet, onRequestPost, onRequestDelete for different methods on the same path.
import { validateSession } from "../../lib/auth.js";
export async function onRequestGet(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { results } = await env.DB.prepare("SELECT id, name FROM restricted_names ORDER BY name ASC").all();
return Response.json({ names: results });
}
export async function onRequestPost(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { name } = body;
if (!name || !name.trim()) {
return Response.json({ error: "Name is required" }, { status: 400 });
}
try {
await env.DB.prepare("INSERT INTO restricted_names (name) VALUES (?)")
.bind(name.trim().toLowerCase())
.run();
} catch (e) {
if (e.message.includes("UNIQUE")) {
return Response.json({ error: "Name already restricted" }, { status: 409 });
}
throw e;
}
return Response.json({ success: true });
}
export async function onRequestDelete(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const id = url.searchParams.get("id");
if (!id) {
return Response.json({ error: "ID is required" }, { status: 400 });
}
const result = await env.DB.prepare("DELETE FROM restricted_names WHERE id = ?").bind(id).run();
if (result.meta.changes === 0) {
return Response.json({ error: "Name not found" }, { status: 404 });
}
return Response.json({ success: true });
}
TOKEN="your-token-here"
# List names:
curl -H "Authorization: Bearer $TOKEN" http://localhost:8788/api/admin/names
# Expected: {"names":[{"id":1,"name":"hellahung"},{"id":2,"name":"hella hung"},{"id":3,"name":"fudster"}]}
# Add a name:
curl -X POST http://localhost:8788/api/admin/names -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name":"impersonator"}'
# Expected: {"success":true}
# Delete a name:
curl -X DELETE "http://localhost:8788/api/admin/names?id=4" -H "Authorization: Bearer $TOKEN"
# Expected: {"success":true}
git add functions/api/admin/names.js
git commit -m "feat: add restricted names management API"
Files:
Create: functions/api/admin/passwords.js
[ ] Step 1: Create functions/api/admin/passwords.js
import { validateSession, hashPassword } from "../../lib/auth.js";
export async function onRequestGet(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Only return labels, never hashes
const { results } = await env.DB.prepare("SELECT id, label FROM admin_passwords ORDER BY label ASC").all();
return Response.json({ passwords: results });
}
export async function onRequestPost(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { label, password } = body;
if (!label || !password) {
return Response.json({ error: "Label and password are required" }, { status: 400 });
}
if (password.length < 8) {
return Response.json({ error: "Password must be at least 8 characters" }, { status: 400 });
}
const hash = await hashPassword(password);
await env.DB.prepare("INSERT INTO admin_passwords (label, password_hash) VALUES (?, ?)")
.bind(label.trim(), hash)
.run();
return Response.json({ success: true });
}
export async function onRequestDelete(context) {
const { env, request } = context;
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
const session = await validateSession(env.DB, token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const id = url.searchParams.get("id");
if (!id) {
return Response.json({ error: "ID is required" }, { status: 400 });
}
// Don't allow deleting the last admin password
const countResult = await env.DB.prepare("SELECT COUNT(*) as total FROM admin_passwords").first();
if (countResult.total <= 1) {
return Response.json({ error: "Cannot delete the last admin password" }, { status: 400 });
}
// Also delete associated sessions
await env.DB.prepare("DELETE FROM admin_sessions WHERE admin_id = ?").bind(id).run();
const result = await env.DB.prepare("DELETE FROM admin_passwords WHERE id = ?").bind(id).run();
if (result.meta.changes === 0) {
return Response.json({ error: "Password not found" }, { status: 404 });
}
return Response.json({ success: true });
}
TOKEN="your-token-here"
# List passwords (labels only):
curl -H "Authorization: Bearer $TOKEN" http://localhost:8788/api/admin/passwords
# Expected: {"passwords":[{"id":1,"label":"Fudster"}]}
# Add a password:
curl -X POST http://localhost:8788/api/admin/passwords -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"label":"Moderator2","password":"securepass123"}'
# Expected: {"success":true}
git add functions/api/admin/passwords.js
git commit -m "feat: add admin password management API"
Files:
Create: guestbook.css
[ ] Step 1: Create guestbook.css
This file styles the guestbook section to match the retro aesthetic. It uses the same gold/aqua/red color scheme as the rest of the site.
/* ======================== */
/* GUESTBOOK SECTION */
/* ======================== */
.guestbook {
max-width: 600px;
margin: 30px auto;
padding: 0 10px;
}
.guestbook-header {
border: 4px solid gold;
padding: 12px;
text-align: center;
margin-bottom: 20px;
background: linear-gradient(135deg, #111, #000, #111);
}
.guestbook-header h2 {
color: red;
font-size: 1.4em;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 0, 0, 0.6);
margin: 0 0 4px 0;
letter-spacing: 3px;
}
.guestbook-header p {
color: aqua;
font-size: 0.85em;
text-shadow: 0 0 6px rgba(0, 255, 255, 0.4);
margin: 0;
}
/* Comment list */
.guestbook-comments {
margin-bottom: 20px;
}
.guestbook-comment {
border: 1px solid #333;
padding: 10px 12px;
margin-bottom: 10px;
background: #0a0a0a;
}
.guestbook-comment-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.guestbook-name {
color: gold;
font-weight: bold;
font-size: 0.85em;
text-shadow: 0 0 6px rgba(255, 215, 0, 0.4);
}
.guestbook-verified {
color: gold;
font-size: 0.8em;
text-shadow: 0 0 8px rgba(255, 215, 0, 0.6);
}
.guestbook-date {
color: #666;
font-size: 0.75em;
}
.guestbook-message {
color: #ccc;
font-size: 0.9em;
line-height: 1.4;
word-wrap: break-word;
}
/* Reply button */
.guestbook-reply-btn {
background: none;
border: 1px solid aqua;
color: aqua;
font-size: 0.7em;
padding: 2px 8px;
cursor: pointer;
margin-top: 6px;
font-family: inherit;
letter-spacing: 1px;
text-shadow: 0 0 4px rgba(0, 255, 255, 0.3);
}
.guestbook-reply-btn:hover {
background: aqua;
color: #000;
text-shadow: none;
}
/* Replies */
.guestbook-replies {
margin-left: 20px;
border-left: 2px solid #333;
padding-left: 10px;
margin-top: 8px;
}
.guestbook-reply {
padding: 6px 0;
border-bottom: 1px solid #1a1a1a;
}
.guestbook-reply:last-child {
border-bottom: none;
}
/* Reply form (hidden by default) */
.guestbook-reply-form {
display: none;
margin-left: 20px;
margin-top: 8px;
padding: 10px;
border: 1px solid #333;
background: #0d0d0d;
}
.guestbook-reply-form.active {
display: block;
}
/* Comment form */
.guestbook-form {
border: 2px solid aqua;
padding: 14px;
background: #0a0a0a;
}
.guestbook-form label {
display: block;
color: aqua;
font-size: 0.75em;
margin-bottom: 4px;
letter-spacing: 2px;
text-shadow: 0 0 4px rgba(0, 255, 255, 0.3);
}
.guestbook-form input[type="text"],
.guestbook-form textarea,
.guestbook-reply-form input[type="text"],
.guestbook-reply-form textarea {
width: 100%;
background: #1a1a1a;
border: 1px solid #444;
color: #fff;
padding: 8px;
font-family: inherit;
font-size: 0.85em;
letter-spacing: 1px;
margin-bottom: 10px;
box-sizing: border-box;
}
.guestbook-form input[type="text"]:focus,
.guestbook-form textarea:focus,
.guestbook-reply-form input[type="text"]:focus,
.guestbook-reply-form textarea:focus {
outline: none;
border-color: aqua;
box-shadow: 0 0 6px rgba(0, 255, 255, 0.3);
}
.guestbook-form textarea,
.guestbook-reply-form textarea {
resize: vertical;
min-height: 60px;
}
.guestbook-submit {
display: inline-block;
border: 2px solid gold;
background: none;
color: gold;
padding: 6px 20px;
font-family: inherit;
font-size: 0.85em;
font-weight: bold;
letter-spacing: 2px;
cursor: pointer;
text-shadow: 0 0 6px rgba(255, 215, 0, 0.4);
}
.guestbook-submit:hover {
background: gold;
color: #000;
text-shadow: none;
}
.guestbook-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Pagination */
.guestbook-pagination {
text-align: center;
margin: 15px 0;
}
.guestbook-pagination button {
background: none;
border: 1px solid gold;
color: gold;
padding: 3px 10px;
margin: 0 3px;
cursor: pointer;
font-family: inherit;
font-size: 0.8em;
text-shadow: 0 0 4px rgba(255, 215, 0, 0.3);
}
.guestbook-pagination button:hover {
background: gold;
color: #000;
text-shadow: none;
}
.guestbook-pagination button.active {
background: gold;
color: #000;
text-shadow: none;
}
.guestbook-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Status messages */
.guestbook-status {
text-align: center;
padding: 8px;
margin-bottom: 10px;
font-size: 0.85em;
}
.guestbook-status.success {
color: #0f0;
border: 1px solid #0f0;
}
.guestbook-status.error {
color: red;
border: 1px solid red;
}
/* Empty state */
.guestbook-empty {
text-align: center;
color: #555;
padding: 20px;
font-size: 0.85em;
}
/* Turnstile widget */
.guestbook-turnstile {
margin-bottom: 10px;
}
git add guestbook.css
git commit -m "feat: add guestbook retro-styled CSS"
Files:
Create: guestbook.js
[ ] Step 1: Create guestbook.js
(function () {
"use strict";
var PAGE_SLUG =
window.location.pathname.replace(/^\//, "").replace(/\.html$/, "") || "index";
var currentPage = 1;
var totalPages = 1;
// DOM references (set after DOM loads)
var commentsContainer;
var paginationContainer;
var commentForm;
var statusEl;
function init() {
commentsContainer = document.getElementById("guestbook-comments");
paginationContainer = document.getElementById("guestbook-pagination");
commentForm = document.getElementById("guestbook-form");
statusEl = document.getElementById("guestbook-status");
if (!commentsContainer) return;
commentForm.addEventListener("submit", handleCommentSubmit);
loadComments(1);
}
function loadComments(page) {
currentPage = page;
fetch("/api/comments?page=" + encodeURIComponent(PAGE_SLUG) + "&p=" + page)
.then(function (res) { return res.json(); })
.then(function (data) {
totalPages = data.totalPages;
renderComments(data.comments);
renderPagination();
})
.catch(function () {
commentsContainer.innerHTML = '<div class="guestbook-empty">Could not load comments.</div>';
});
}
function renderComments(comments) {
if (comments.length === 0) {
commentsContainer.innerHTML =
'<div class="guestbook-empty">No messages yet. Be the first to sign the guestbook!</div>';
return;
}
var html = "";
for (var i = 0; i < comments.length; i++) {
html += renderComment(comments[i]);
}
commentsContainer.innerHTML = html;
// Attach reply button listeners
var replyBtns = commentsContainer.querySelectorAll(".guestbook-reply-btn");
for (var j = 0; j < replyBtns.length; j++) {
replyBtns[j].addEventListener("click", handleReplyToggle);
}
}
function renderComment(comment) {
var badge = comment.is_admin ? ' <span class="guestbook-verified">★ verified</span>' : "";
var dateStr = formatDate(comment.created_at);
var html =
'<div class="guestbook-comment" data-id="' + comment.id + '">' +
' <div class="guestbook-comment-header">' +
' <span class="guestbook-name">' + comment.name + '</span>' +
badge +
' <span class="guestbook-date">' + dateStr + '</span>' +
" </div>" +
' <div class="guestbook-message">' + comment.message + "</div>";
// Replies
if (comment.replies && comment.replies.length > 0) {
html += '<div class="guestbook-replies">';
for (var i = 0; i < comment.replies.length; i++) {
html += renderReply(comment.replies[i]);
}
html += "</div>";
}
// Reply button + hidden form
html +=
' <button class="guestbook-reply-btn" data-comment-id="' + comment.id + '">REPLY</button>' +
' <div class="guestbook-reply-form" id="reply-form-' + comment.id + '">' +
' <label>YOUR NAME:</label>' +
' <input type="text" class="reply-name" maxlength="50" required>' +
' <label>YOUR REPLY:</label>' +
' <textarea class="reply-message" maxlength="1000" required></textarea>' +
' <div class="guestbook-turnstile">' +
' <div class="cf-turnstile" data-sitekey="' + getTurnstileSiteKey() + '" data-size="compact"></div>' +
" </div>" +
' <button class="guestbook-submit" onclick="window.__guestbookSubmitReply(' + comment.id + ', this)">REPLY!</button>' +
" </div>" +
"</div>";
return html;
}
function renderReply(reply) {
var badge = reply.is_admin ? ' <span class="guestbook-verified">★ verified</span>' : "";
var dateStr = formatDate(reply.created_at);
return (
'<div class="guestbook-reply">' +
' <div class="guestbook-comment-header">' +
' <span class="guestbook-name">' + reply.name + "</span>" +
badge +
' <span class="guestbook-date">' + dateStr + "</span>" +
" </div>" +
' <div class="guestbook-message">' + reply.message + "</div>" +
"</div>"
);
}
function renderPagination() {
if (totalPages <= 1) {
paginationContainer.innerHTML = "";
return;
}
var html = "";
for (var i = 1; i <= totalPages; i++) {
var activeClass = i === currentPage ? " active" : "";
html += '<button class="' + activeClass + '" onclick="window.__guestbookLoadPage(' + i + ')">' + i + "</button>";
}
paginationContainer.innerHTML = html;
}
function handleReplyToggle(e) {
var commentId = e.target.getAttribute("data-comment-id");
var form = document.getElementById("reply-form-" + commentId);
form.classList.toggle("active");
// Render Turnstile widget if form just became active and hasn't been rendered yet
if (form.classList.contains("active")) {
var turnstileDiv = form.querySelector(".cf-turnstile");
if (turnstileDiv && !turnstileDiv.hasAttribute("data-rendered")) {
turnstileDiv.setAttribute("data-rendered", "true");
if (window.turnstile) {
window.turnstile.render(turnstileDiv, {
sitekey: getTurnstileSiteKey(),
size: "compact",
});
}
}
}
}
function handleCommentSubmit(e) {
e.preventDefault();
var nameInput = document.getElementById("guestbook-name");
var messageInput = document.getElementById("guestbook-message");
var turnstileInput = commentForm.querySelector('[name="cf-turnstile-response"]');
var submitBtn = commentForm.querySelector(".guestbook-submit");
if (!nameInput.value.trim() || !messageInput.value.trim()) {
showStatus("Please fill in all fields.", "error");
return;
}
var token = turnstileInput ? turnstileInput.value : "";
if (!token) {
showStatus("Please complete the CAPTCHA.", "error");
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "SENDING...";
fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
page_slug: PAGE_SLUG,
name: nameInput.value.trim(),
message: messageInput.value.trim(),
turnstile_token: token,
}),
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.success) {
showStatus(data.message, "success");
nameInput.value = "";
messageInput.value = "";
// Reset Turnstile
if (window.turnstile) {
var widget = commentForm.querySelector(".cf-turnstile");
if (widget) window.turnstile.reset(widget);
}
} else {
showStatus(data.error || "Something went wrong.", "error");
}
})
.catch(function () {
showStatus("Network error. Please try again.", "error");
})
.finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = "SIGN IT!";
});
}
// Exposed globally so inline onclick handlers in rendered HTML can call it
window.__guestbookSubmitReply = function (commentId, btn) {
var form = document.getElementById("reply-form-" + commentId);
var nameInput = form.querySelector(".reply-name");
var messageInput = form.querySelector(".reply-message");
var turnstileInput = form.querySelector('[name="cf-turnstile-response"]');
if (!nameInput.value.trim() || !messageInput.value.trim()) {
showStatus("Please fill in all fields.", "error");
return;
}
var token = turnstileInput ? turnstileInput.value : "";
if (!token) {
showStatus("Please complete the CAPTCHA.", "error");
return;
}
btn.disabled = true;
btn.textContent = "SENDING...";
fetch("/api/replies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comment_id: commentId,
name: nameInput.value.trim(),
message: messageInput.value.trim(),
turnstile_token: token,
}),
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.success) {
showStatus(data.message, "success");
form.classList.remove("active");
nameInput.value = "";
messageInput.value = "";
} else {
showStatus(data.error || "Something went wrong.", "error");
}
})
.catch(function () {
showStatus("Network error. Please try again.", "error");
})
.finally(function () {
btn.disabled = false;
btn.textContent = "REPLY!";
});
};
window.__guestbookLoadPage = function (page) {
loadComments(page);
// Scroll to top of guestbook
var header = document.querySelector(".guestbook-header");
if (header) header.scrollIntoView({ behavior: "smooth" });
};
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = "guestbook-status " + type;
statusEl.style.display = "block";
setTimeout(function () {
statusEl.style.display = "none";
}, 5000);
}
function formatDate(isoString) {
var d = new Date(isoString + "Z");
var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return months[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear();
}
function getTurnstileSiteKey() {
var el = document.querySelector("[data-turnstile-sitekey]");
return el ? el.getAttribute("data-turnstile-sitekey") : "";
}
// Initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
git add guestbook.js
git commit -m "feat: add guestbook client-side JavaScript"
Files:
Create: _includes/guestbook.njk
Modify: _includes/base.njk (line 51, before footer include)
Modify: _includes/themed.njk (line 809, before footer include)
[ ] Step 1: Create _includes/guestbook.njk
This partial is included in both layouts. The data-turnstile-sitekey attribute is read by guestbook.js to get the site key without hardcoding it in JS.
<!-- Guestbook -->
<div class="guestbook" data-turnstile-sitekey="YOUR_TURNSTILE_SITE_KEY">
<div class="guestbook-header">
<h2>GUESTBOOK</h2>
<p>Sign the book & leave your mark!</p>
</div>
<div id="guestbook-status" class="guestbook-status" style="display:none;"></div>
<div id="guestbook-comments">
<div class="guestbook-empty">Loading messages...</div>
</div>
<div id="guestbook-pagination" class="guestbook-pagination"></div>
<form id="guestbook-form" class="guestbook-form">
<label for="guestbook-name">YOUR NAME:</label>
<input type="text" id="guestbook-name" maxlength="50" required>
<label for="guestbook-message">YOUR MESSAGE:</label>
<textarea id="guestbook-message" maxlength="1000" required></textarea>
<div class="guestbook-turnstile">
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY" data-theme="dark"></div>
</div>
<button type="submit" class="guestbook-submit">SIGN IT!</button>
</form>
</div>
<link rel="stylesheet" href="/guestbook.css">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="/guestbook.js" defer></script>
Note: Replace YOUR_TURNSTILE_SITE_KEY with your actual Turnstile site key (from the Cloudflare dashboard) in both places. For local development, use 1x00000000000000000000AA (Turnstile's test site key that always passes).
_includes/base.njkInsert the guestbook include before the footer include. In base.njk, the current content area ends at line 51 with ``, followed by `
|
|
Change line 53 in _includes/base.njk from:
<br>
<center>
<table>
<tr>
<td bgcolor="gold">
<center>
<font color="red" size="4"><b>*Created with LOVE*&*PASSiON!!:);)</b></font><br>
</center>
</td>
</tr>
</table>
</center>
<div class="bottom-text">
Hellahung.com
</div>
to:
<!-- Guestbook -->
<div class="guestbook" data-turnstile-sitekey="0x4AAAAAAC1ehugli3VRdRsP">
<div class="guestbook-header">
<h2>GUESTBOOK</h2>
<p>Sign the book & leave your mark!</p>
</div>
<div id="guestbook-status" class="guestbook-status" style="display:none;"></div>
<div id="guestbook-comments">
<div class="guestbook-empty">Loading messages...</div>
</div>
<div id="guestbook-pagination" class="guestbook-pagination"></div>
<form id="guestbook-form" class="guestbook-form">
<label for="guestbook-name">YOUR NAME:</label>
<input type="text" id="guestbook-name" maxlength="50" required>
<label for="guestbook-message">YOUR MESSAGE:</label>
<textarea id="guestbook-message" maxlength="1000" required></textarea>
<div class="guestbook-turnstile">
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC1ehugli3VRdRsP" data-theme="dark"></div>
</div>
<button type="submit" class="guestbook-submit">SIGN IT!</button>
</form>
</div>
<link rel="stylesheet" href="/guestbook.css">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="/guestbook.js" defer></script>
<br>
<center>
<table>
<tr>
<td bgcolor="gold">
<center>
<font color="red" size="4"><b>*Created with LOVE*&*PASSiON!!:);)</b></font><br>
</center>
</td>
</tr>
</table>
</center>
<div class="bottom-text">
Hellahung.com
</div>
_includes/themed.njkIn themed.njk, the footer is included at line 810. Add the guestbook include before it.
Change line 810 in _includes/themed.njk from:
<br>
<center>
<table>
<tr>
<td bgcolor="gold">
<center>
<font color="red" size="4"><b>*Created with LOVE*&*PASSiON!!:);)</b></font><br>
</center>
</td>
</tr>
</table>
</center>
<div class="bottom-text">
Hellahung.com
</div>
to:
<!-- Guestbook -->
<div class="guestbook" data-turnstile-sitekey="0x4AAAAAAC1ehugli3VRdRsP">
<div class="guestbook-header">
<h2>GUESTBOOK</h2>
<p>Sign the book & leave your mark!</p>
</div>
<div id="guestbook-status" class="guestbook-status" style="display:none;"></div>
<div id="guestbook-comments">
<div class="guestbook-empty">Loading messages...</div>
</div>
<div id="guestbook-pagination" class="guestbook-pagination"></div>
<form id="guestbook-form" class="guestbook-form">
<label for="guestbook-name">YOUR NAME:</label>
<input type="text" id="guestbook-name" maxlength="50" required>
<label for="guestbook-message">YOUR MESSAGE:</label>
<textarea id="guestbook-message" maxlength="1000" required></textarea>
<div class="guestbook-turnstile">
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC1ehugli3VRdRsP" data-theme="dark"></div>
</div>
<button type="submit" class="guestbook-submit">SIGN IT!</button>
</form>
</div>
<link rel="stylesheet" href="/guestbook.css">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="/guestbook.js" defer></script>
<br>
<center>
<table>
<tr>
<td bgcolor="gold">
<center>
<font color="red" size="4"><b>*Created with LOVE*&*PASSiON!!:);)</b></font><br>
</center>
</td>
</tr>
</table>
</center>
<div class="bottom-text">
Hellahung.com
</div>
npm run build
# Check that the guestbook HTML appears in the output:
grep -l "guestbook" _site/*.html | head -5
# Expected: several HTML files containing the guestbook section
git add _includes/guestbook.njk _includes/base.njk _includes/themed.njk
git commit -m "feat: add guestbook partial and integrate into both layouts"
Files:
Create: admin.html
[ ] Step 1: Create admin.html
This is an Eleventy page that uses the base layout. The admin functionality is entirely client-side JS that talks to the admin API endpoints.
---
layout: base.njk
title: "Admin - Guestbook Moderation"
---
<center>
<table border="4" cellpadding="2" cellspacing="1">
<tr><td bgcolor="orange" height="40" width="340">
<center><font color="yellow" size="4"><b>GUESTBOOK ADMIN</b></font></center>
</td></tr>
</table>
</center>
<br>
<!-- Login Section -->
<div id="admin-login" style="max-width:400px;margin:0 auto;text-align:center;">
<div style="border:2px solid aqua;padding:20px;background:#0a0a0a;">
<font color="aqua" size="3"><b>MODERATOR LOGIN</b></font><br><br>
<input type="password" id="admin-password" placeholder="Enter password"
style="width:100%;background:#1a1a1a;border:1px solid #444;color:#fff;padding:8px;font-family:inherit;font-size:14px;letter-spacing:1px;margin-bottom:10px;box-sizing:border-box;">
<br>
<button onclick="adminLogin()"
style="border:2px solid gold;background:none;color:gold;padding:6px 20px;font-family:inherit;font-size:14px;font-weight:bold;letter-spacing:2px;cursor:pointer;">
LOGIN
</button>
<div id="login-error" style="color:red;margin-top:10px;display:none;"></div>
</div>
</div>
<!-- Admin Dashboard (hidden until login) -->
<div id="admin-dashboard" style="display:none;max-width:700px;margin:0 auto;">
<center><font color="aqua" size="3">Logged in as: <font color="gold"><b id="admin-label"></b></font></font></center>
<br>
<!-- Tabs -->
<center>
<button onclick="showTab('pending')" class="admin-tab" id="tab-pending" style="border:2px solid gold;background:gold;color:#000;padding:4px 12px;font-family:inherit;cursor:pointer;margin:2px;">PENDING</button>
<button onclick="showTab('post')" class="admin-tab" id="tab-post" style="border:2px solid gold;background:none;color:gold;padding:4px 12px;font-family:inherit;cursor:pointer;margin:2px;">POST AS VERIFIED</button>
<button onclick="showTab('names')" class="admin-tab" id="tab-names" style="border:2px solid gold;background:none;color:gold;padding:4px 12px;font-family:inherit;cursor:pointer;margin:2px;">NAMES</button>
<button onclick="showTab('passwords')" class="admin-tab" id="tab-passwords" style="border:2px solid gold;background:none;color:gold;padding:4px 12px;font-family:inherit;cursor:pointer;margin:2px;">PASSWORDS</button>
</center>
<br>
<!-- Pending Tab -->
<div id="panel-pending" class="admin-panel">
<div id="pending-list"></div>
</div>
<!-- Post as Verified Tab -->
<div id="panel-post" class="admin-panel" style="display:none;">
<div style="border:2px solid aqua;padding:15px;background:#0a0a0a;">
<font color="aqua"><b>POST AS VERIFIED NAME</b></font><br><br>
<label style="color:aqua;font-size:12px;">TYPE:</label><br>
<select id="post-type" onchange="togglePostFields()"
style="background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-bottom:8px;width:100%;box-sizing:border-box;">
<option value="comment">Comment</option>
<option value="reply">Reply</option>
</select><br>
<div id="post-page-field">
<label style="color:aqua;font-size:12px;">PAGE SLUG:</label><br>
<input type="text" id="post-page" placeholder="e.g. bio"
style="width:100%;background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-bottom:8px;box-sizing:border-box;"><br>
</div>
<div id="post-commentid-field" style="display:none;">
<label style="color:aqua;font-size:12px;">COMMENT ID:</label><br>
<input type="number" id="post-commentid" placeholder="e.g. 5"
style="width:100%;background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-bottom:8px;box-sizing:border-box;"><br>
</div>
<label style="color:aqua;font-size:12px;">NAME:</label><br>
<input type="text" id="post-name" placeholder="e.g. hELLA HUNG"
style="width:100%;background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-bottom:8px;box-sizing:border-box;"><br>
<label style="color:aqua;font-size:12px;">MESSAGE:</label><br>
<textarea id="post-message" rows="4"
style="width:100%;background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-bottom:8px;box-sizing:border-box;resize:vertical;"></textarea><br>
<button onclick="adminPost()"
style="border:2px solid gold;background:none;color:gold;padding:6px 20px;font-family:inherit;font-weight:bold;letter-spacing:2px;cursor:pointer;">
POST
</button>
<div id="post-status" style="margin-top:8px;"></div>
</div>
</div>
<!-- Names Tab -->
<div id="panel-names" class="admin-panel" style="display:none;">
<div style="border:2px solid aqua;padding:15px;background:#0a0a0a;">
<font color="aqua"><b>RESTRICTED NAMES</b></font><br><br>
<div id="names-list"></div>
<br>
<input type="text" id="new-name" placeholder="Add restricted name"
style="background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-right:5px;">
<button onclick="addName()"
style="border:2px solid gold;background:none;color:gold;padding:4px 12px;font-family:inherit;cursor:pointer;">ADD</button>
</div>
</div>
<!-- Passwords Tab -->
<div id="panel-passwords" class="admin-panel" style="display:none;">
<div style="border:2px solid aqua;padding:15px;background:#0a0a0a;">
<font color="aqua"><b>ADMIN PASSWORDS</b></font><br><br>
<div id="passwords-list"></div>
<br>
<input type="text" id="new-pw-label" placeholder="Label (e.g. Moderator2)"
style="background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-right:5px;margin-bottom:5px;"><br>
<input type="password" id="new-pw-password" placeholder="Password (min 8 chars)"
style="background:#1a1a1a;border:1px solid #444;color:#fff;padding:6px;font-family:inherit;margin-right:5px;">
<button onclick="addPassword()"
style="border:2px solid gold;background:none;color:gold;padding:4px 12px;font-family:inherit;cursor:pointer;">ADD</button>
</div>
</div>
</div>
<script>
var adminToken = "";
function adminLogin() {
var pw = document.getElementById("admin-password").value;
fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
adminToken = data.token;
document.getElementById("admin-label").textContent = data.label;
document.getElementById("admin-login").style.display = "none";
document.getElementById("admin-dashboard").style.display = "block";
loadPending();
} else {
var errEl = document.getElementById("login-error");
errEl.textContent = data.error;
errEl.style.display = "block";
}
});
}
function authHeaders() {
return { "Authorization": "Bearer " + adminToken, "Content-Type": "application/json" };
}
function showTab(name) {
var panels = document.querySelectorAll(".admin-panel");
for (var i = 0; i < panels.length; i++) panels[i].style.display = "none";
document.getElementById("panel-" + name).style.display = "block";
var tabs = document.querySelectorAll(".admin-tab");
for (var j = 0; j < tabs.length; j++) {
tabs[j].style.background = "none";
tabs[j].style.color = "gold";
}
var activeTab = document.getElementById("tab-" + name);
activeTab.style.background = "gold";
activeTab.style.color = "#000";
if (name === "pending") loadPending();
if (name === "names") loadNames();
if (name === "passwords") loadPasswords();
}
function loadPending() {
fetch("/api/admin/pending", { headers: authHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
var html = "";
if (data.comments.length === 0 && data.replies.length === 0) {
html = '<center><font color="#555">No pending items</font></center>';
}
for (var i = 0; i < data.comments.length; i++) {
var c = data.comments[i];
html += renderPendingItem(c.id, "comment", c.page_slug, c.name, c.message, c.created_at, "");
}
for (var j = 0; j < data.replies.length; j++) {
var r = data.replies[j];
html += renderPendingItem(r.id, "reply", r.page_slug, r.name, r.message, r.created_at,
"Reply to: " + r.parent_name + " - \"" + r.parent_message.substring(0, 60) + "\"");
}
document.getElementById("pending-list").innerHTML = html;
});
}
function renderPendingItem(id, type, pageSlug, name, message, createdAt, context) {
return '<div style="border:1px solid #333;padding:10px;margin-bottom:8px;background:#0a0a0a;">' +
'<font color="gold" size="2"><b>' + name + '</b></font>' +
' <font color="#666" size="1">[' + type + ' on /' + pageSlug + '] ' + createdAt + '</font><br>' +
(context ? '<font color="#888" size="1">' + context + '</font><br>' : '') +
'<font color="#ccc" size="2">' + message + '</font><br>' +
'<button onclick="moderate(' + id + ',\'' + type + '\',\'approve\')" style="border:1px solid #0f0;background:none;color:#0f0;padding:2px 10px;cursor:pointer;margin-top:6px;font-family:inherit;">APPROVE</button> ' +
'<button onclick="moderate(' + id + ',\'' + type + '\',\'reject\')" style="border:1px solid red;background:none;color:red;padding:2px 10px;cursor:pointer;margin-top:6px;font-family:inherit;">REJECT</button>' +
'</div>';
}
function moderate(id, type, action) {
fetch("/api/admin/moderate", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ id: id, type: type, action: action })
})
.then(function(r) { return r.json(); })
.then(function() { loadPending(); });
}
function togglePostFields() {
var type = document.getElementById("post-type").value;
document.getElementById("post-page-field").style.display = type === "comment" ? "block" : "none";
document.getElementById("post-commentid-field").style.display = type === "reply" ? "block" : "none";
}
function adminPost() {
var type = document.getElementById("post-type").value;
var payload = {
type: type,
name: document.getElementById("post-name").value,
message: document.getElementById("post-message").value
};
if (type === "comment") payload.page_slug = document.getElementById("post-page").value;
if (type === "reply") payload.comment_id = parseInt(document.getElementById("post-commentid").value, 10);
fetch("/api/admin/post", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) })
.then(function(r) { return r.json(); })
.then(function(data) {
var el = document.getElementById("post-status");
if (data.success) {
el.innerHTML = '<font color="#0f0">Posted!</font>';
document.getElementById("post-message").value = "";
} else {
el.innerHTML = '<font color="red">' + data.error + '</font>';
}
});
}
function loadNames() {
fetch("/api/admin/names", { headers: authHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
var html = "";
for (var i = 0; i < data.names.length; i++) {
var n = data.names[i];
html += '<div style="display:inline-block;border:1px solid #444;padding:3px 8px;margin:3px;background:#111;">' +
'<font color="#ccc" size="2">' + n.name + '</font> ' +
'<span onclick="deleteName(' + n.id + ')" style="color:red;cursor:pointer;font-size:12px;">X</span>' +
'</div>';
}
document.getElementById("names-list").innerHTML = html || '<font color="#555">No restricted names</font>';
});
}
function addName() {
var name = document.getElementById("new-name").value;
if (!name) return;
fetch("/api/admin/names", { method: "POST", headers: authHeaders(), body: JSON.stringify({ name: name }) })
.then(function() { document.getElementById("new-name").value = ""; loadNames(); });
}
function deleteName(id) {
fetch("/api/admin/names?id=" + id, { method: "DELETE", headers: authHeaders() })
.then(function() { loadNames(); });
}
function loadPasswords() {
fetch("/api/admin/passwords", { headers: authHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
var html = "";
for (var i = 0; i < data.passwords.length; i++) {
var p = data.passwords[i];
html += '<div style="display:inline-block;border:1px solid #444;padding:3px 8px;margin:3px;background:#111;">' +
'<font color="#ccc" size="2">' + p.label + '</font> ' +
'<span onclick="deletePassword(' + p.id + ')" style="color:red;cursor:pointer;font-size:12px;">X</span>' +
'</div>';
}
document.getElementById("passwords-list").innerHTML = html || '<font color="#555">No passwords</font>';
});
}
function addPassword() {
var label = document.getElementById("new-pw-label").value;
var password = document.getElementById("new-pw-password").value;
if (!label || !password) return;
fetch("/api/admin/passwords", { method: "POST", headers: authHeaders(), body: JSON.stringify({ label: label, password: password }) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
document.getElementById("new-pw-label").value = "";
document.getElementById("new-pw-password").value = "";
loadPasswords();
}
});
}
function deletePassword(id) {
fetch("/api/admin/passwords?id=" + id, { method: "DELETE", headers: authHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) alert(data.error);
loadPasswords();
});
}
// Allow Enter key to submit login
document.getElementById("admin-password").addEventListener("keydown", function(e) {
if (e.key === "Enter") adminLogin();
});
</script>
npm run build
# Verify admin.html exists in output:
ls _site/admin.html
# Expected: _site/admin.html
git add admin.html
git commit -m "feat: add admin dashboard page for guestbook moderation"
Files:
Create: functions/_middleware.js
[ ] Step 1: Create functions/_middleware.js
This middleware intercepts HTML page requests from known bots and injects the first 5 approved comments as static HTML into the page.
import { getApprovedComments } from "./lib/db.js";
const BOT_USER_AGENTS = [
"googlebot",
"bingbot",
"slurp",
"duckduckbot",
"baiduspider",
"yandexbot",
"sogou",
"facebot",
"ia_archiver",
];
function isBot(userAgent) {
if (!userAgent) return false;
var ua = userAgent.toLowerCase();
for (var i = 0; i < BOT_USER_AGENTS.length; i++) {
if (ua.indexOf(BOT_USER_AGENTS[i]) !== -1) return true;
}
return false;
}
export async function onRequest(context) {
const { request, next, env } = context;
const response = await next();
// Only process HTML responses for bot requests
const contentType = response.headers.get("Content-Type") || "";
if (!contentType.includes("text/html")) return response;
const userAgent = request.headers.get("User-Agent") || "";
if (!isBot(userAgent)) return response;
// Determine page slug from URL path
const url = new URL(request.url);
let slug = url.pathname.replace(/^\//, "").replace(/\.html$/, "").replace(/\/$/, "");
if (!slug) slug = "index";
// Fetch first page of approved comments
let data;
try {
data = await getApprovedComments(env.DB, slug, 1);
} catch {
// If DB fails, just return the original page
return response;
}
if (data.comments.length === 0) return response;
// Build static HTML for comments
let commentsHtml = '<div class="guestbook-comments-ssr">';
for (const comment of data.comments) {
const badge = comment.is_admin ? " ★" : "";
commentsHtml +=
'<div class="guestbook-comment">' +
'<strong>' + comment.name + badge + '</strong> ' +
'<small>' + comment.created_at + '</small><br>' +
'<p>' + comment.message + '</p>';
if (comment.replies && comment.replies.length > 0) {
commentsHtml += '<div class="guestbook-replies">';
for (const reply of comment.replies) {
const replyBadge = reply.is_admin ? " ★" : "";
commentsHtml +=
'<div class="guestbook-reply">' +
'<strong>' + reply.name + replyBadge + '</strong> ' +
'<small>' + reply.created_at + '</small><br>' +
'<p>' + reply.message + '</p>' +
'</div>';
}
commentsHtml += '</div>';
}
commentsHtml += '</div>';
}
commentsHtml += '</div>';
// Inject comments HTML before the closing </body> tag
let html = await response.text();
html = html.replace(
'<div id="guestbook-comments">',
'<div id="guestbook-comments">' + commentsHtml
);
return new Response(html, {
status: response.status,
headers: response.headers,
});
}
# Start dev server:
npx wrangler pages dev _site --d1=DB=hellahung-comments --binding TURNSTILE_SECRET=1x0000000000000000000000000000000AA
# Normal request (no SSR injection):
curl -s http://localhost:8788/bio.html | grep "guestbook-comments-ssr"
# Expected: no output (no SSR for regular visitors)
# Bot request (SSR injection):
curl -s -H "User-Agent: Googlebot/2.1" http://localhost:8788/bio.html | grep "guestbook-comments-ssr"
# Expected: <div class="guestbook-comments-ssr">...
git add functions/_middleware.js
git commit -m "feat: add bot SSR middleware for SEO-friendly comment rendering"
Files:
Modify: eleventy.config.js (add functions passthrough if needed)
Modify: package.json (add dev script with wrangler)
[ ] Step 1: Add a local dev script to package.json
Add a dev script that builds the site with Eleventy and then starts wrangler pages dev. This is the main command for local development.
In package.json, add to the "scripts" section:
"dev": "npx @11ty/eleventy && npx wrangler pages dev _site --d1=DB=hellahung-comments --binding TURNSTILE_SECRET=1x0000000000000000000000000000000AA"
npm run build
npm run dev
Then test in a browser:
Visit http://localhost:8788/bio.html — verify guestbook section appears at the bottom
Visit http://localhost:8788/admin.html — verify admin login works
[ ] Step 3: Set production secrets on Cloudflare
These must be set in the Cloudflare dashboard under your Pages project > Settings > Environment Variables:
TURNSTILE_SECRET — your real Turnstile secret key (not the test one)The D1 database binding is configured in wrangler.toml and needs to be connected in the Cloudflare dashboard under Pages > Settings > Functions > D1 database bindings. Bind DB to hellahung-comments.
Also apply the schema to the production D1 database:
npx wrangler d1 execute hellahung-comments --remote --file=db/schema.sql
npx wrangler d1 execute hellahung-comments --remote --file=db/seed.sql
And create the first admin password on production:
node -e "const b=require('bcryptjs');b.hash('YOUR_PROD_PASSWORD',10).then(h=>console.log(\"INSERT INTO admin_passwords (label, password_hash) VALUES ('Fudster', '\"+h+\"');\"))" | npx wrangler d1 execute hellahung-comments --remote --file=-
git add package.json
git commit -m "feat: add dev script for local wrangler development"
Push to your Cloudflare Pages branch. Cloudflare will build the Eleventy site and deploy the Pages Functions automatically.
git push
After deploy, verify:
/admin — login and moderation should work| Task | Description | Key Files |
|---|---|---|
| 1 | Project setup — wrangler, D1 schema, eleventy config | wrangler.toml, db/, eleventy.config.js |
| 2 | Shared library functions | functions/lib/*.js |
| 3 | Public API — GET/POST comments | functions/api/comments.js |
| 4 | Public API — POST replies | functions/api/replies.js |
| 5 | Admin API — login + sessions | functions/api/admin/login.js |
| 6 | Admin API — pending + moderation | functions/api/admin/pending.js, moderate.js |
| 7 | Admin API — post as verified | functions/api/admin/post.js |
| 8 | Admin API — restricted names | functions/api/admin/names.js |
| 9 | Admin API — password management | functions/api/admin/passwords.js |
| 10 | Guestbook CSS | guestbook.css |
| 11 | Guestbook JavaScript | guestbook.js |
| 12 | Guestbook partial + layout integration | _includes/guestbook.njk, base.njk, themed.njk |
| 13 | Admin dashboard page | admin.html |
| 14 | Bot SSR middleware | functions/_middleware.js |
| 15 | Final integration + deployment | package.json, Cloudflare dashboard config |