Guestbook Comment System — Implementation Plan

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


File Structure

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:


Task 1: Project Setup — Wrangler Config + D1 Schema

Files:

name = "hellahung"
compatibility_date = "2024-09-23"

[[d1_databases]]
binding = "DB"
database_name = "hellahung-comments"
database_id = "" # Will be filled after `wrangler d1 create`
CREATE 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);

This 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');

Add these lines to the end of .gitignore:

.superpowers
.wrangler

The 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"

Task 2: Shared Library Functions

Files:

/**
 * Escape HTML special characters to prevent XSS.
 */
export function escapeHtml(str) {
  return str
    .replace(/&/g, "&")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}
/**
 * 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;
}
/**
 * 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;
}
/**
 * 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)"

Task 3: Public API — GET and POST Comments

Files:

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)"

Task 4: Public API — POST Replies

Files:

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"

Task 5: Admin API — Login + Session Management

Files:

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"

Task 6: Admin API — Pending Queue + Moderation

Files:

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);
}
import { 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"

Task 7: Admin API — Post as Verified Name

Files:

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"

Task 8: Admin API — Restricted Names Management

Files:

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"

Task 9: Admin API — Password Management

Files:

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"

Task 10: Guestbook Frontend — CSS

Files:

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"

Task 11: Guestbook Frontend — JavaScript

Files:

(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">&#9733; 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">&#9733; 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"

Task 12: Guestbook Frontend — Nunjucks Partial + Layout Integration

Files:

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 &amp; 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).

Insert the guestbook include before the footer include. In base.njk, the current content area ends at line 51 with ``, followed by `

*Created with LOVE*&*PASSiON!!:);)
Hellahung.com
` on line 53. Add the guestbook between them.

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*&amp;*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 &amp; 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*&amp;*PASSiON!!:);)</b></font><br>
</center>
</td>
</tr>
</table>
</center>
<div class="bottom-text">
Hellahung.com
</div>

In 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*&amp;*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 &amp; 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*&amp;*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"

Task 13: Admin Dashboard Page

Files:

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"

Task 14: Bot SSR Middleware

Files:

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 ? " &#9733;" : "";
    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 ? " &#9733;" : "";
        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"

Task 15: Final Integration + Deployment Config

Files:

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:

These must be set in the Cloudflare dashboard under your Pages project > Settings > Environment Variables:

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:


Summary of All Tasks

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