]> git.morado.dev Git - blog.morado.dev/commitdiff
Initial commit
authorRoberto Morado <roramigator@duck.com>
Sun, 9 Nov 2025 23:17:48 +0000 (18:17 -0500)
committerRoberto Morado <roramigator@duck.com>
Sun, 9 Nov 2025 23:17:48 +0000 (18:17 -0500)
.gitignore [new file with mode: 0644]
README.md [new file with mode: 0644]
main.ts [new file with mode: 0644]
sync_posts.ts [new file with mode: 0644]
tests/main_test.ts [new file with mode: 0644]
users.ts [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..a221964
--- /dev/null
@@ -0,0 +1,4 @@
+.DS_Store
+deploy/
+posts/
+storage/
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..43620bf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,154 @@
+# WitnessBlog
+
+Minimalist personal blog written for Deno. The app serves public Markdown posts,
+protects an admin panel with username/password auth, persists data in Deno KV,
+and mirrors everything to on-disk Markdown for easy versioning.
+
+## Features
+
+- Public homepage that lists every post (Markdown rendered with `marked`).
+- Individual post pages with clean typography.
+- Password-protected admin panel for creating new posts in Markdown.
+- Posts are stored in Deno KV _and_ exported as Markdown files (front matter +
+  body).
+- Session-based auth with HttpOnly cookies plus CSRF-protected admin forms.
+- Markdown output is sanitized before serving to minimize XSS risk.
+- CLI tooling for creating/listing/deleting users (`users.ts`).
+- Sync utility (`sync_posts.ts`) to reconcile KV and the Markdown files.
+- Basic unit/integration tests under `tests/`.
+
+## Requirements
+
+- [Deno](https://deno.com/runtime) 1.41+ with the `--unstable-kv` flag
+  available.
+- Local filesystem access to write posts and KV files (default `./posts` and
+  `./storage`).
+
+## Project Layout
+
+```
+main.ts        # HTTP server, routes, rendering, persistence helpers
+sync_posts.ts  # KV ↔️ Markdown sync utility
+users.ts       # CLI user management
+posts/         # Markdown copies of every post (front matter + content)
+storage/       # Deno KV data files
+tests/         # Deno test suite
+deploy/        # Server configs (nginx, systemd, git hooks)
+```
+
+## Running the Blog
+
+```sh
+deno run --allow-net --allow-read --allow-write --unstable-kv main.ts
+```
+
+- Public site: http://localhost:8000/
+- Admin panel: http://localhost:8000/admin
+
+Before logging in you must create a user (see next section). New posts authored
+in the admin UI are saved to both KV and `./posts/<slug-id>.md` automatically.
+
+## Managing Users
+
+`users.ts` provides interactive commands for managing accounts stored in KV.
+
+```
+deno run --allow-read --allow-write --unstable-kv users.ts <command>
+```
+
+Available commands:
+
+| Command             | Description                                                 |
+| ------------------- | ----------------------------------------------------------- |
+| `create` (default)  | Interactive prompt for username + password (bcrypt hashed). |
+| `list`              | Displays every user with their creation timestamp.          |
+| `delete <username>` | Removes a user after confirmation.                          |
+| `help`              | Prints usage.                                               |
+
+Example:
+
+```sh
+deno run --allow-read --allow-write --unstable-kv users.ts list
+deno run --allow-read --allow-write --unstable-kv users.ts delete alice
+```
+
+## Security Notes
+
+- Every login and admin form submission carries a CSRF token that must match the
+  HttpOnly `csrf_token` cookie issued when the page is rendered.
+- Markdown is rendered with `marked` and then sanitized server-side so
+  `<script>`, inline event handlers, and `javascript:` URLs are stripped before
+  hitting the browser.
+- Session cookies use `SameSite=Strict` and the server invalidates expired
+  sessions automatically.
+
+## Syncing Markdown and KV
+
+Use `sync_posts.ts` whenever you edit Markdown files directly or want to ensure
+KV is in sync.
+
+```sh
+deno run --allow-read --allow-write --allow-net --unstable-kv sync_posts.ts [--delete-kv] [--delete-md]
+```
+
+- Default behavior fills in whichever side is missing or outdated.
+- `--delete-kv`: remove KV entries that lack Markdown files.
+- `--delete-md`: delete Markdown files that lack KV entries.
+
+## Deployment
+
+Example setup for a single VPS:
+
+1. **Systemd service** – copy `deploy/deno.service` to
+   `/etc/systemd/system/witnessblog.service`, update the `WorkingDirectory`,
+   `ExecStart`, and `Environment` lines for your server, then run
+   `sudo systemctl daemon-reload && sudo systemctl enable --now witnessblog`.
+2. **Reverse proxy** – copy `deploy/nginx.conf` to
+   `/etc/nginx/sites-available/witnessblog.conf`, replace `blog.example.com`
+   with your domain, symlink into `sites-enabled`, and reload nginx. Add TLS
+   (e.g., with Certbot) as needed.
+3. **Git push deployments** – initialize a bare repo on the server
+   (`/srv/git/witnessblog.git`), copy `deploy/post-receive` into
+   `hooks/post-receive`, `chmod +x` it, and update the paths inside the script
+   to match your git dir, work tree, and systemd service name. From your local
+   machine, add the remote
+   (`git remote add production user@server:/srv/git/witnessblog.git`) and run
+   `git push production main`. Every push triggers the hook, which checks out
+   the new code, warms the Deno cache, runs `sync_posts.ts`, and restarts the
+   systemd service.
+
+> Tip: Make sure `/var/www/witnessblog` (or whichever work tree you use) is
+> writable by the git/systemd user so that the Deno process can update
+> `storage/` and `posts/`.
+
+## Running Tests
+
+```sh
+deno test -A --unstable-kv tests/main_test.ts
+```
+
+Current coverage includes:
+
+- Core helpers (slug generation, HTML escaping, Markdown export).
+- Auth flows (login success, CSRF enforcement on `/post`, happy-path
+  publishing).
+- Sync edge cases (Markdown-only posts backfilling KV, regenerating Markdown
+  from KV data).
+
+## Posts Directory
+
+Each Markdown file begins with YAML front matter (title, timestamp, id, author,
+filename). Content can include Markdown elements supported by `marked`. Editing
+files manually is fine; just run the sync script to push updates back into KV.
+
+## Troubleshooting
+
+- **Session/login problems**: double-check you created a user via `users.ts` and
+  are running the server with `--unstable-kv` so sessions persist.
+- **Sync conflicts**: run `sync_posts.ts` without delete flags first to see what
+  it updates, then rerun with deletion flags if you truly want to prune data.
+
+## Next Steps
+
+- Harden the sync/CLI scripts for multi-user environments.
+- Add end-to-end/browser tests for the admin UI and publishing workflow.
diff --git a/main.ts b/main.ts
new file mode 100644 (file)
index 0000000..4f0fd0d
--- /dev/null
+++ b/main.ts
@@ -0,0 +1,813 @@
+// main.ts - Minimalist Personal Blog with Authentication
+// Run with: deno run --allow-net --allow-read --allow-write --unstable-kv main.ts
+
+import { marked } from "https://esm.sh/marked@9.1.6";
+import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
+
+export interface Post {
+  id: string;
+  title: string;
+  content: string;
+  timestamp: number;
+  filename: string;
+  author: string;
+}
+
+interface User {
+  username: string;
+  passwordHash: string;
+  createdAt: number;
+}
+
+interface Session {
+  username: string;
+  expiresAt: number;
+}
+
+const POSTS_DIR = "./posts";
+const STORAGE_DIR = "./storage";
+const CSRF_COOKIE_NAME = "csrf_token";
+const CSRF_MAX_AGE_SECONDS = 60 * 60;
+
+const config = {
+  title: "God's Witness",
+};
+
+await Deno.mkdir(STORAGE_DIR, { recursive: true });
+const kv = await Deno.openKv(`${STORAGE_DIR}/db`);
+export function __closeKvForTests() {
+  kv.close();
+}
+
+function trimMD(text: string) {
+  return text
+    // Remove Markdown links but keep link text
+    .replace(/\[([^\]]+)\]\(https?:\/\/[^\)]+\)/g, "$1")
+    // Remove bold/italic markers (**text**, __text__, *text*, _text_)
+    .replace(/(\*\*|__|\*|_)(.*?)\1/g, "$2")
+    // Remove inline code (`code`)
+    .replace(/`([^`]+)`/g, "$1")
+    // Remove headings (#, ##, ###, etc.)
+    .replace(/^#+\s*/gm, "")
+    // Remove blockquotes (> )
+    .replace(/^>\s?/gm, "")
+    // Remove horizontal rules (---, ***, etc.)
+    .replace(/^(-{3,}|\*{3,})$/gm, "")
+    // Remove image markdown ![alt](url)
+    .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
+    // Remove remaining markdown list markers (-, *, +)
+    .replace(/^\s*[-*+]\s+/gm, "")
+    // Trim extra whitespace
+    .trim();
+}
+
+// Ensure posts directory exists
+async function ensurePostsDir() {
+  await Deno.mkdir(POSTS_DIR, { recursive: true });
+}
+
+// Load all posts from KV
+async function getAllPosts(): Promise<Post[]> {
+  const posts: Post[] = [];
+  const entries = kv.list<Post>({ prefix: ["posts"] });
+
+  for await (const entry of entries) {
+    posts.push(entry.value);
+  }
+
+  return posts;
+}
+
+// Get single post by ID
+async function getPost(id: string): Promise<Post | null> {
+  const result = await kv.get<Post>(["posts", id]);
+  return result.value;
+}
+
+// Save individual post as markdown file
+export async function savePostMarkdown(post: Post) {
+  const content = `---
+title: ${post.title}
+timestamp: ${post.timestamp}
+id: ${post.id}
+author: ${post.author}
+filename: ${generateFilename(post.title, post.id)}
+---
+
+${post.content}
+`;
+  await Deno.writeTextFile(`${POSTS_DIR}/${post.filename}`, content);
+  console.log(`📄 Created ${post.filename}`);
+}
+
+// Generate simple ID
+function generateId(): string {
+  return Date.now().toString(36) + Math.random().toString(36).slice(2);
+}
+
+// Generate filename from title
+export function generateFilename(title: string, id: string): string {
+  const slug = title
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^-|-$/g, "")
+    .slice(0, 50);
+  return `${slug}-${id}.md`;
+}
+
+// Session management
+function generateSessionId(): string {
+  return crypto.randomUUID();
+}
+
+function generateCsrfToken(): string {
+  return crypto.randomUUID().replace(/-/g, "");
+}
+
+function buildCsrfCookie(token: string): string {
+  return `${CSRF_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${CSRF_MAX_AGE_SECONDS}`;
+}
+
+function getCsrfTokenFromCookie(req: Request): string | null {
+  const cookie = req.headers.get("cookie");
+  if (!cookie) return null;
+  const match = cookie.match(new RegExp(`${CSRF_COOKIE_NAME}=([^;]+)`));
+  return match ? match[1] : null;
+}
+
+function validateCsrf(req: Request, data: Record<string, string>): boolean {
+  const cookieToken = getCsrfTokenFromCookie(req);
+  const formToken = data["csrf_token"];
+  return Boolean(cookieToken && formToken && cookieToken === formToken);
+}
+
+async function createSession(username: string): Promise<string> {
+  const sessionId = generateSessionId();
+  const session: Session = {
+    username,
+    expiresAt: Date.now() + (24 * 60 * 60 * 1000), // 24 hours
+  };
+  await kv.set(["sessions", sessionId], session);
+  return sessionId;
+}
+
+async function getSession(sessionId: string): Promise<Session | null> {
+  const result = await kv.get<Session>(["sessions", sessionId]);
+  if (!result.value) return null;
+
+  if (result.value.expiresAt < Date.now()) {
+    await kv.delete(["sessions", sessionId]);
+    return null;
+  }
+
+  return result.value;
+}
+
+async function deleteSession(sessionId: string) {
+  await kv.delete(["sessions", sessionId]);
+}
+
+// Get session from cookie
+function getSessionIdFromCookie(req: Request): string | null {
+  const cookie = req.headers.get("cookie");
+  if (!cookie) return null;
+
+  const match = cookie.match(/session=([^;]+)/);
+  return match ? match[1] : null;
+}
+
+// End session management
+
+// HTML templates
+function renderHeader(username?: string) {
+  return username
+    ? `<header>
+      <div class="header-content">
+        <div>
+          <h1><a href="/">${config.title}</a></h1>
+        </div>
+        <div class="user-info">
+          <span class="username">👤 ${escapeHTML(username)}</span>
+          <a href="/" class="admin-btn">View Blog</a>
+          <a href="/logout" class="logout-btn">Logout</a>
+        </div>
+      </div>
+    </header>`
+    : `<header>
+      <div class="header-content">
+        <div>
+          <h1><a href="/">${config.title}</a></h1>
+        </div>
+        <div class="user-info">
+          <a href="/admin" class="login-btn">Admin</a>
+        </div>
+      </div>
+    </header>`;
+}
+
+function renderHTML(content: string): string {
+  return `<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>${config.title}</title>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+      line-height: 1.6;
+      color: #333;
+      max-width: 700px;
+      margin: 0 auto;
+      padding: 40px 20px;
+      background: #fafafa;
+    }
+    header {
+      border-bottom: 2px solid #333;
+      padding-bottom: 20px;
+      margin-bottom: 40px;
+    }
+    .header-content {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 10px;
+    }
+    .user-info {
+      display: flex;
+      gap: 15px;
+      align-items: center;
+      font-size: 0.9em;
+    }
+    .username {
+      color: #666;
+    }
+    .logout-btn, .login-btn, .admin-btn {
+      color: white;
+      padding: 8px 16px;
+      border: none;
+      border-radius: 4px;
+      cursor: pointer;
+      font-size: 0.9em;
+      text-decoration: none;
+      display: inline-block;
+    }
+    .logout-btn { background: #dc3545; }
+    .logout-btn:hover { background: #c82333; }
+    .login-btn { background: #28a745; }
+    .login-btn:hover { background: #218838; }
+    .admin-btn { background: #007bff; }
+    .admin-btn:hover { background: #0056b3; }
+    h1 { font-size: 2em; margin-bottom: 10px; }
+    h1 a { color: #333; text-decoration: none; }
+    h1 a:hover { color: #555; }
+    h2 { font-size: 1.5em; margin: 30px 0 15px; }
+    h3 { font-size: 1.2em; margin: 25px 0 10px; }
+    article, .post-item {
+      background: white;
+      padding: 30px;
+      margin-bottom: 30px;
+      border-radius: 4px;
+      box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+    }
+    .post-item {
+      transition: box-shadow 0.2s;
+      cursor: pointer;
+    }
+    .post-item:hover {
+      box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+    }
+    .post-title {
+      font-size: 1.5em;
+      font-weight: 600;
+      margin-bottom: 10px;
+      color: #333;
+      text-decoration: none;
+      display: block;
+    }
+    .post-title:hover {
+      color: #007bff;
+    }
+    .post-meta {
+      color: #999;
+      font-size: 0.85em;
+      margin-bottom: 15px;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 10px;
+    }
+    .post-author {
+      color: #666;
+      font-weight: 500;
+    }
+    .post-excerpt {
+      color: #666;
+      line-height: 1.6;
+    }
+    .post-content {
+      word-wrap: break-word;
+    }
+    .post-content p { margin-bottom: 15px; }
+    .post-content ul, .post-content ol {
+      margin: 15px 0 15px 25px;
+    }
+    .post-content li { margin-bottom: 8px; }
+    .post-content pre {
+      background: #f5f5f5;
+      padding: 15px;
+      border-radius: 4px;
+      overflow-x: auto;
+      margin: 15px 0;
+    }
+    .post-content code {
+      background: #f5f5f5;
+      padding: 2px 6px;
+      border-radius: 3px;
+      font-family: 'Courier New', monospace;
+      font-size: 0.9em;
+    }
+    .post-content pre code {
+      background: none;
+      padding: 0;
+    }
+    .post-content blockquote {
+      border-left: 3px solid #ddd;
+      padding-left: 15px;
+      margin: 15px 0;
+      color: #666;
+    }
+    .post-content a {
+      color: #0066cc;
+      text-decoration: none;
+    }
+    .post-content a:hover {
+      text-decoration: underline;
+    }
+    .back-link {
+      display: inline-block;
+      margin-bottom: 20px;
+      color: #007bff;
+      text-decoration: none;
+    }
+    .back-link:hover {
+      text-decoration: underline;
+    }
+    form {
+      background: white;
+      padding: 30px;
+      border-radius: 4px;
+      box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+      margin-bottom: 40px;
+    }
+    .login-form {
+      max-width: 400px;
+      margin: 60px auto;
+    }
+    input, textarea {
+      width: 100%;
+      padding: 12px;
+      margin-bottom: 15px;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      font-family: inherit;
+      font-size: 1em;
+    }
+    textarea { 
+      min-height: 200px; 
+      resize: vertical;
+      font-family: 'Courier New', monospace;
+    }
+    button {
+      background: #333;
+      color: white;
+      padding: 12px 24px;
+      border: none;
+      border-radius: 4px;
+      cursor: pointer;
+      font-size: 1em;
+      width: 100%;
+    }
+    button:hover { background: #555; }
+    .empty { color: #999; text-align: center; padding: 40px; }
+    .help-text {
+      font-size: 0.85em;
+      color: #666;
+      margin-bottom: 15px;
+    }
+    .error {
+      background: #f8d7da;
+      color: #721c24;
+      padding: 12px;
+      border-radius: 4px;
+      margin-bottom: 20px;
+    }
+  </style>
+</head>
+<body>
+  ${content}
+</body>
+</html>`;
+}
+
+function renderLoginPage(csrfToken: string, error?: string): string {
+  return renderHTML(`
+    <form class="login-form" method="POST" action="/login">
+      <h1>Login</h1>
+      ${error ? `<div class="error">${escapeHTML(error)}</div>` : ""}
+      ${csrfField(csrfToken)}
+      <input type="text" name="username" placeholder="Username" required autofocus>
+      <input type="password" name="password" placeholder="Password" required>
+      <button type="submit">Login</button>
+    </form>
+  `);
+}
+
+async function renderPublicHome(): Promise<string> {
+  const posts = await getAllPosts();
+  const sortedPosts = posts.sort((a, b) => b.timestamp - a.timestamp);
+
+  const postsHTML = sortedPosts.length > 0
+    ? sortedPosts.map((post) => {
+      // Create excerpt (first 200 chars)
+      const plainText = trimMD(post.content);
+      const excerpt = plainText.length > 200
+        ? plainText.slice(0, 200) + "..."
+        : plainText;
+
+      return `
+      <div class="post-item" onclick="window.location='/post/${post.id}'">
+        <a href="/post/${post.id}" class="post-title">${
+        escapeHTML(post.title)
+      }</a>
+        <div class="post-meta">
+          <div>
+            <span>${new Date(post.timestamp).toLocaleDateString()}</span>
+            <span> • </span>
+            <span class="post-author">by ${escapeHTML(post.author)}</span>
+          </div>
+        </div>
+        <div class="post-excerpt">${escapeHTML(excerpt)}</div>
+      </div>
+    `;
+    }).join("")
+    : '<div class="empty">No posts yet.</div>';
+
+  return renderHTML(`
+    ${renderHeader()}
+
+    <section>
+      ${postsHTML}
+    </section>
+  `);
+}
+
+async function renderPostView(postId: string): Promise<string | null> {
+  const post = await getPost(postId);
+  if (!post) return null;
+
+  const htmlContent = sanitizeHtmlContent(marked.parse(post.content) as string);
+
+  return renderHTML(`
+    ${renderHeader()}
+
+    <a href="/" class="back-link">← Back to all posts</a>
+
+    <article>
+      <h2>${escapeHTML(post.title)}</h2>
+      <div class="post-meta">
+        <div>
+          <span>${new Date(post.timestamp).toLocaleString()}</span>
+          <span> • </span>
+          <span class="post-author">by ${escapeHTML(post.author)}</span>
+        </div>
+      </div>
+      <div class="post-content">${htmlContent}</div>
+    </article>
+  `);
+}
+
+async function renderAdminPage(
+  username: string,
+  csrfToken: string,
+): Promise<string> {
+  const posts = await getAllPosts();
+  const sortedPosts = posts.sort((a, b) => b.timestamp - a.timestamp);
+
+  const postsHTML = sortedPosts.length > 0
+    ? `<h2>Recent Posts (${sortedPosts.length})</h2>` +
+      sortedPosts.slice(0, 5).map((post) => {
+        const excerpt = trimMD(post.content).slice(0, 100) +
+          "...";
+        return `
+      <div class="post-item" onclick="window.location='/post/${post.id}'">
+        <a href="/post/${post.id}" class="post-title">${
+          escapeHTML(post.title)
+        }</a>
+        <div class="post-meta">
+          <span>${new Date(post.timestamp).toLocaleDateString()}</span>
+        </div>
+        <div class="post-excerpt">${escapeHTML(excerpt)}</div>
+      </div>
+    `;
+      }).join("")
+    : '<div class="empty">No posts yet. Create your first post below!</div>';
+
+  return renderHTML(`
+    ${renderHeader(username)}
+
+    <form method="POST" action="/post">
+      <h2>New Post</h2>
+      ${csrfField(csrfToken)}
+      <input type="text" name="title" placeholder="Post title" required>
+      <div class="help-text">Write your post in Markdown format. Supports **bold**, *italic*, lists, links, code blocks, and more!</div>
+      <textarea name="content" placeholder="Write your thoughts in markdown...
+
+Example:
+## Subheading
+- List item
+- Another item
+
+**Bold text** and *italic text*
+
+\`inline code\` or:
+
+\`\`\`
+code block
+\`\`\`" required></textarea>
+      <button type="submit">Publish</button>
+    </form>
+
+    <section>
+      ${postsHTML}
+    </section>
+  `);
+}
+
+// End HTML templates
+
+function loginFormResponse(message?: string, status = 200): Response {
+  const csrfToken = generateCsrfToken();
+  return new Response(renderLoginPage(csrfToken, message), {
+    status,
+    headers: {
+      "Content-Type": "text/html",
+      "Set-Cookie": buildCsrfCookie(csrfToken),
+    },
+  });
+}
+
+async function adminPageResponse(username: string): Promise<Response> {
+  const csrfToken = generateCsrfToken();
+  const html = await renderAdminPage(username, csrfToken);
+  return new Response(html, {
+    headers: {
+      "Content-Type": "text/html",
+      "Set-Cookie": buildCsrfCookie(csrfToken),
+    },
+  });
+}
+
+export function escapeHTML(str: string): string {
+  return str
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#039;");
+}
+
+function csrfField(token: string): string {
+  return `<input type="hidden" name="csrf_token" value="${escapeHTML(token)}">`;
+}
+
+function sanitizeHtmlContent(html: string): string {
+  const blockedTags = [
+    "script",
+    "style",
+    "iframe",
+    "object",
+    "embed",
+    "link",
+    "meta",
+    "base",
+  ];
+
+  let sanitized = html;
+
+  for (const tag of blockedTags) {
+    const blockPattern = new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, "gi");
+    sanitized = sanitized.replace(blockPattern, "");
+    const voidPattern = new RegExp(`<${tag}[^>]*\\/?>`, "gi");
+    sanitized = sanitized.replace(voidPattern, "");
+  }
+
+  sanitized = sanitized.replace(
+    /\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi,
+    "",
+  );
+
+  sanitized = sanitized.replace(
+    /\b(href|src)\s*=\s*("|')?\s*javascript:[^"'\s>]*/gi,
+    (_match, attr) => {
+      return `${attr}="#"`;
+    },
+  );
+
+  return sanitized;
+}
+
+export function parseFormData(body: string): Record<string, string> {
+  const params = new URLSearchParams(body);
+  const data: Record<string, string> = {};
+  for (const [key, value] of params) {
+    data[key] = value;
+  }
+  return data;
+}
+
+export async function handler(req: Request): Promise<Response> {
+  const url = new URL(req.url);
+
+  // Check authentication
+  const sessionId = getSessionIdFromCookie(req);
+  const session = sessionId ? await getSession(sessionId) : null;
+
+  // Public routes - no authentication required
+
+  // Homepage - list all posts
+  if (req.method === "GET" && url.pathname === "/") {
+    const html = await renderPublicHome();
+    return new Response(html, {
+      headers: { "Content-Type": "text/html" },
+    });
+  }
+
+  // View individual post
+  if (req.method === "GET" && url.pathname.startsWith("/post/")) {
+    const postId = url.pathname.split("/post/")[1];
+    const html = await renderPostView(postId);
+
+    if (!html) {
+      return new Response("Post not found", { status: 404 });
+    }
+
+    return new Response(html, {
+      headers: { "Content-Type": "text/html" },
+    });
+  }
+
+  // Admin/Protected routes
+
+  // Admin page - requires authentication
+  if (req.method === "GET" && url.pathname === "/admin") {
+    if (!session) {
+      return new Response(null, {
+        status: 303,
+        headers: { Location: "/login" },
+      });
+    }
+
+    return await adminPageResponse(session.username);
+  }
+
+  // Login page
+  if (req.method === "GET" && url.pathname === "/login") {
+    if (session) {
+      return new Response(null, {
+        status: 303,
+        headers: { Location: "/admin" },
+      });
+    }
+    return loginFormResponse();
+  }
+
+  // Login handler
+  if (req.method === "POST" && url.pathname === "/login") {
+    try {
+      const body = await req.text();
+      const data = parseFormData(body);
+
+      if (!validateCsrf(req, data)) {
+        return loginFormResponse(
+          "Security verification failed. Please try again.",
+          403,
+        );
+      }
+
+      const userResult = await kv.get<User>(["users", data.username]);
+
+      if (!userResult.value) {
+        return loginFormResponse("Invalid username or password");
+      }
+
+      const validPassword = await bcrypt.compare(
+        data.password,
+        userResult.value.passwordHash,
+      );
+
+      if (!validPassword) {
+        return loginFormResponse("Invalid username or password");
+      }
+
+      const newSessionId = await createSession(data.username);
+
+      return new Response(null, {
+        status: 303,
+        headers: {
+          Location: "/admin",
+          "Set-Cookie":
+            `session=${newSessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`,
+        },
+      });
+    } catch (error) {
+      return loginFormResponse(
+        `An error occurred ${`Error: ${
+          error instanceof Error ? error.message : String(error)
+        }`}`,
+        500,
+      );
+    }
+  }
+
+  // Logout handler
+  if (req.method === "GET" && url.pathname === "/logout") {
+    if (sessionId) {
+      await deleteSession(sessionId);
+    }
+    return new Response(null, {
+      status: 303,
+      headers: {
+        Location: "/",
+        "Set-Cookie": "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
+      },
+    });
+  }
+
+  // Create post - requires authentication
+  if (req.method === "POST" && url.pathname === "/post") {
+    if (!session) {
+      return new Response("Unauthorized", { status: 401 });
+    }
+
+    try {
+      const body = await req.text();
+      const data = parseFormData(body);
+
+      if (!validateCsrf(req, data)) {
+        return new Response("Invalid CSRF token", { status: 403 });
+      }
+
+      if (!data.title || !data.content) {
+        return new Response("Missing title or content", {
+          status: 400,
+        });
+      }
+
+      const id = generateId();
+      const post: Post = {
+        id,
+        title: data.title,
+        content: data.content,
+        timestamp: Date.now(),
+        filename: generateFilename(data.title, id),
+        author: session.username,
+      };
+
+      await kv.set(["posts", id], post);
+      await savePostMarkdown(post);
+
+      return new Response(null, {
+        status: 303,
+        headers: { Location: "/admin" },
+      });
+    } catch (error) {
+      return loginFormResponse(
+        `An error occurred ${`Error: ${
+          error instanceof Error ? error.message : String(error)
+        }`}`,
+        500,
+      );
+    }
+  }
+
+  return new Response("Not Found", { status: 404 });
+}
+
+// Initialize and start server
+await ensurePostsDir();
+
+if (import.meta.main) {
+  const PORT = 5995;
+  console.log(`🚀 Blog server running at http://localhost:${PORT}`);
+  console.log(`📖 Public blog at http://localhost:${PORT}`);
+  console.log(`🔐 Admin panel at http://localhost:${PORT}/admin`);
+  console.log(`📁 Markdown files saved in ${POSTS_DIR}/`);
+  console.log(`🗄️ Storage ready at ${STORAGE_DIR}/`);
+  console.log(`💾 Using Deno KV for data storage`);
+
+  Deno.serve({ port: PORT }, handler);
+}
diff --git a/sync_posts.ts b/sync_posts.ts
new file mode 100644 (file)
index 0000000..fc3b2fa
--- /dev/null
@@ -0,0 +1,174 @@
+// sync_posts.ts
+// Run with: deno run --allow-read --allow-write --allow-net --allow-env sync_posts.ts
+
+import { extractYaml } from "jsr:@std/front-matter@1.0.9";
+import * as path from "jsr:@std/path@1.1.2";
+
+import { generateFilename, Post, savePostMarkdown } from "./main.ts";
+
+const POSTS_DIR = "./posts";
+const KV_PATH = "./storage/db";
+
+interface MarkdownPost {
+  title: string;
+  timestamp: string;
+  id: string;
+  author: string;
+  content: string;
+  filename: string;
+}
+
+export interface SyncOptions {
+  deleteKv?: boolean;
+  deleteMd?: boolean;
+}
+
+export interface SyncResult {
+  created: number;
+  updated: number;
+  deleted: number;
+  unchanged: number;
+}
+
+// Helper — read all markdown files
+async function readMarkdownFiles(dir: string): Promise<Map<string, MarkdownPost>> {
+  const map = new Map<string, MarkdownPost>();
+
+  for await (const entry of Deno.readDir(dir)) {
+    if (!entry.isFile || !entry.name.endsWith(".md")) continue;
+
+    const filePath = path.join(dir, entry.name);
+    const text = await Deno.readTextFile(filePath);
+
+    const parsed = extractYaml(text);
+    const { title, timestamp, filename, id, author } = parsed.attrs as Record<
+      string,
+      string
+    >;
+    const content = parsed.body.trim();
+
+    if (!id || !title) {
+      console.warn(`⚠️  Skipping ${entry.name}: missing id or title.`);
+      continue;
+    }
+
+    map.set(id, { title, filename, timestamp, id, author, content });
+  }
+
+  return map;
+}
+
+// Load posts from KV
+async function getKvPosts(kv: Deno.Kv): Promise<Map<string, Post>> {
+  const kvPosts = new Map<string, Post>();
+
+  for await (const entry of kv.list<Post>({ prefix: ["posts"] })) {
+    const id = entry.key[1] as string;
+    kvPosts.set(id, entry.value);
+  }
+
+  return kvPosts;
+}
+
+async function safeRemove(file: string) {
+  try {
+    await Deno.remove(file);
+    console.log(`🗑️  Deleted: ${file}`);
+    return true;
+  } catch {
+    console.warn(`⚠️  Could not delete: ${file}`);
+    return false;
+  }
+}
+
+async function safeStat(file: string) {
+  try {
+    await Deno.stat(file);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export async function syncPosts(
+  { deleteKv = false, deleteMd = false }: SyncOptions = {},
+): Promise<SyncResult> {
+  const kv = await Deno.openKv(KV_PATH);
+  let created = 0;
+  let updated = 0;
+  let deleted = 0;
+  let unchanged = 0;
+
+  try {
+    await Deno.mkdir(POSTS_DIR, { recursive: true });
+    const mdPosts = await readMarkdownFiles(POSTS_DIR);
+    const kvPosts = await getKvPosts(kv);
+
+    // --- Pass 1: Ensure all Markdown posts exist in KV ---
+    for (const [id, mdPost] of mdPosts) {
+      const kvPost = kvPosts.get(id);
+      const mdFile = path.join(POSTS_DIR, generateFilename(mdPost.title, id));
+
+      if (!kvPost) {
+        console.warn(`⚠️  Markdown file ${id} has no matching KV post`);
+        if (deleteMd) {
+          if (await safeRemove(mdFile)) deleted++;
+        } else {
+          await kv.set(["posts", id], mdPost);
+          console.log(`✅ Created KV post: ${mdPost.title} (${id})`);
+          created++;
+        }
+        continue;
+      }
+
+      const isSame = kvPost.title === mdPost.title &&
+        kvPost.content.trim() === mdPost.content &&
+        kvPost.author === mdPost.author &&
+        kvPost.timestamp.toString() === mdPost.timestamp.toString();
+
+      if (isSame) {
+        unchanged++;
+        continue;
+      }
+
+      await kv.set(["posts", id], { ...kvPost, ...mdPost });
+      console.log(`✅ Updated KV: ${mdPost.title} (${id})`);
+      updated++;
+    }
+
+    // --- Pass 2: Ensure all KV posts have Markdown files ---
+    for (const [id, kvPost] of kvPosts) {
+      const mdFile = path.join(
+        POSTS_DIR,
+        generateFilename(kvPost.title, kvPost.id),
+      );
+      const exists = await safeStat(mdFile);
+      if (exists) continue;
+
+      if (deleteKv) {
+        await kv.delete(["posts", id]);
+        console.log(`🗑️  Deleted KV post (no Markdown): ${kvPost.title} (${id})`);
+        deleted++;
+      } else {
+        await savePostMarkdown(kvPost);
+        console.log(`✅ Regenerated Markdown: ${kvPost.title} (${id})`);
+        created++;
+      }
+    }
+
+    return { created, updated, deleted, unchanged };
+  } finally {
+    console.log("\n--- Sync Summary ---");
+    console.log(`Created: ${created}`);
+    console.log(`Updated: ${updated}`);
+    console.log(`Deleted: ${deleted}`);
+    console.log(`Unchanged: ${unchanged}`);
+    kv.close();
+  }
+}
+
+if (import.meta.main) {
+  const deleteKv = Deno.args.includes("--delete-kv");
+  const deleteMd = Deno.args.includes("--delete-md");
+  await syncPosts({ deleteKv, deleteMd });
+}
diff --git a/tests/main_test.ts b/tests/main_test.ts
new file mode 100644 (file)
index 0000000..bffe643
--- /dev/null
@@ -0,0 +1,419 @@
+// tests/main_test.ts
+// Run with:
+// deno test -A --unstable-kv tests/main_test.ts
+
+import {
+    escapeHTML,
+    generateFilename,
+    handler,
+    parseFormData,
+    type Post,
+    savePostMarkdown,
+} from "../main.ts";
+import { syncPosts } from "../sync_posts.ts";
+import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
+
+// Helper: create a temp project folder and run a callback inside it.
+// We chdir BEFORE importing modules that touch the fs or KV (you already imported main.ts above),
+// but for our cases here, we only use exported functions that don't re-open KV.
+async function withTempDir(fn: (dir: string) => Promise<void> | void) {
+    const prev = Deno.cwd();
+    const dir = await Deno.makeTempDir();
+    try {
+        Deno.chdir(dir);
+        await Deno.mkdir("./posts", { recursive: true });
+        await Deno.mkdir("./storage", { recursive: true });
+        // Note: KV at ./storage/db will be created lazily by code paths that hit it.
+        await fn(dir);
+    } finally {
+        Deno.chdir(prev);
+        // optional: clean up dir with Deno.remove(dir, { recursive: true })
+        // Leaving artifacts can be useful for debugging; comment out if you prefer cleanup.
+    }
+}
+
+async function seedUser(username: string, password: string) {
+    const kv = await Deno.openKv("./storage/db");
+    await kv.set(["users", username], {
+        username,
+        passwordHash: await bcrypt.hash(password),
+        createdAt: Date.now(),
+    });
+    kv.close();
+}
+
+function getCookieValue(setCookie: string | null, name: string): string | null {
+    if (!setCookie) return null;
+    const match = setCookie.match(new RegExp(`${name}=([^;]+)`));
+    return match ? match[1] : null;
+}
+
+function buildFormBody(data: Record<string, string>): string {
+    return Object.entries(data)
+        .map(([key, value]) =>
+            `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
+        )
+        .join("&");
+}
+
+function samplePost(overrides: Partial<Post> = {}): Post {
+    const title = overrides.title ?? "Sample Post";
+    const id = overrides.id ??
+        crypto.randomUUID().replace(/-/g, "").slice(0, 12);
+    const filename = overrides.filename ?? generateFilename(title, id);
+    return {
+        id,
+        title,
+        content: overrides.content ?? "Content",
+        timestamp: overrides.timestamp ?? Date.now(),
+        filename,
+        author: overrides.author ?? "tester",
+    } satisfies Post;
+}
+
+Deno.test("generateFilename() slugs and truncates properly", () => {
+    const id = "abc123";
+    const title =
+        "Hello, world!  This — title — has #punctuation &    extra    spaces.";
+    const fn = generateFilename(title, id);
+    // Expect: slug is lowercase, hyphenated, no leading/trailing hyphens, <= 50 chars before "-id"
+    if (!fn.endsWith(`-${id}.md`)) {
+        throw new Error(`Expected filename to end with -${id}.md, got ${fn}`);
+    }
+    const base = fn.slice(0, -(`-${id}.md`.length));
+    if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(base)) {
+        throw new Error(`Slug not normalized as expected: ${base}`);
+    }
+    if (base.length > 50) {
+        throw new Error(
+            `Slug should be <= 50 chars, got length ${base.length}`,
+        );
+    }
+});
+
+Deno.test("escapeHTML() escapes critical characters", () => {
+    const raw = `<div class="x">O'Reilly & friends</div>`;
+    const escaped = escapeHTML(raw);
+    if (
+        escaped !==
+            "&lt;div class=&quot;x&quot;&gt;O&#039;Reilly &amp; friends&lt;/div&gt;"
+    ) {
+        throw new Error(`Unexpected escape result: ${escaped}`);
+    }
+});
+
+Deno.test("parseFormData() parses urlencoded bodies", () => {
+    const body = "username=john&password=s3cr3t%21&note=hello+world";
+    const data = parseFormData(body);
+    if (data.username !== "john") throw new Error("username mismatch");
+    if (data.password !== "s3cr3t!") throw new Error("password mismatch");
+    if (data.note !== "hello world") throw new Error("note mismatch");
+});
+
+Deno.test("savePostMarkdown() writes front matter + body", async () => {
+    await withTempDir(async (cwd) => {
+        const post: Post = {
+            id: "p1",
+            title: "My First Post",
+            content: "This is **markdown**.",
+            timestamp: 1731033600000, // 2024-11-08T00:00:00.000Z example
+            filename: "my-first-post-p1.md",
+            author: "alice",
+        };
+
+        await savePostMarkdown(post);
+
+        const text = await Deno.readTextFile(`${cwd}/posts/${post.filename}`);
+        // Check front matter essentials:
+        if (!text.includes(`title: ${post.title}`)) {
+            throw new Error("missing title in front matter");
+        }
+        if (!text.includes(`id: ${post.id}`)) {
+            throw new Error("missing id in front matter");
+        }
+        if (!text.includes(`author: ${post.author}`)) {
+            throw new Error("missing author in front matter");
+        }
+        if (!text.includes(post.content)) {
+            throw new Error("missing body content");
+        }
+    });
+});
+
+Deno.test("handler GET / returns public homepage", async () => {
+    await withTempDir(async () => {
+        // Call the handler directly without Deno.serve
+
+        const mod = await import(
+            new URL("../main.ts", import.meta.url).href +
+                `?t=${crypto.randomUUID()}`
+        );
+        const { handler, __closeKvForTests } = mod;
+
+        const req = new Request("http://localhost/");
+        const res = await handler(req);
+        if (res.status !== 200) {
+            throw new Error(`Unexpected status ${res.status}`);
+        }
+        const html = await res.text();
+        if (!html.includes("God's Witness")) {
+            throw new Error("Page title missing");
+        }
+        if (!html.includes("Admin")) {
+            throw new Error("Admin link missing");
+        }
+        if (!html.includes("No posts yet.")) {
+            // Fresh temp KV has no posts
+            throw new Error("Expected empty state");
+        }
+        __closeKvForTests?.();
+    });
+});
+
+Deno.test("handler GET /post/:id returns 404 when post missing", async () => {
+    await withTempDir(async () => {
+        const req = new Request("http://localhost/post/not-there");
+        const res = await handler(req);
+        if (res.status !== 404) {
+            throw new Error(`Expected 404 for missing post, got ${res.status}`);
+        }
+    });
+});
+
+Deno.test("handler POST /login with unknown user returns error page", async () => {
+    await withTempDir(async () => {
+        const csrfToken = "testtoken";
+        const body = "username=nobody&password=foo&csrf_token=" + csrfToken;
+        const req = new Request("http://localhost/login", {
+            method: "POST",
+            headers: {
+                "content-type": "application/x-www-form-urlencoded",
+                cookie: `csrf_token=${csrfToken}`,
+            },
+            body,
+        });
+        const res = await handler(req);
+        if (res.status !== 200) {
+            throw new Error(`Expected 200 with error HTML, got ${res.status}`);
+        }
+        const html = await res.text();
+        if (!html.includes("Invalid username or password")) {
+            throw new Error("Expected login error message");
+        }
+    });
+});
+
+Deno.test("handler POST /login succeeds and sets session cookie", async () => {
+    await withTempDir(async () => {
+        const mod = await import(
+            new URL("../main.ts", import.meta.url).href +
+                `?t=${crypto.randomUUID()}`
+        );
+        const { handler, __closeKvForTests } = mod;
+
+        await seedUser("alice", "supersecret");
+
+        const csrfToken = "validtoken";
+        const body = buildFormBody({
+            username: "alice",
+            password: "supersecret",
+            csrf_token: csrfToken,
+        });
+        const req = new Request("http://localhost/login", {
+            method: "POST",
+            headers: {
+                "content-type": "application/x-www-form-urlencoded",
+                cookie: `csrf_token=${csrfToken}`,
+            },
+            body,
+        });
+
+        const res = await handler(req);
+        if (res.status !== 303 || res.headers.get("location") !== "/admin") {
+            throw new Error(`Expected redirect to /admin, got ${res.status}`);
+        }
+        const setCookie = res.headers.get("set-cookie");
+        if (!setCookie || !setCookie.includes("session=")) {
+            throw new Error("Session cookie missing");
+        }
+        __closeKvForTests?.();
+    });
+});
+
+Deno.test("handler POST /post enforces CSRF tokens", async () => {
+    await withTempDir(async () => {
+        const mod = await import(
+            new URL("../main.ts", import.meta.url).href +
+                `?t=${crypto.randomUUID()}`
+        );
+        const { handler, __closeKvForTests } = mod;
+
+        await seedUser("bob", "hunter2");
+
+        // Perform login to obtain session
+        const loginCsrf = "csrf-login";
+        const loginReq = new Request("http://localhost/login", {
+            method: "POST",
+            headers: {
+                "content-type": "application/x-www-form-urlencoded",
+                cookie: `csrf_token=${loginCsrf}`,
+            },
+            body: buildFormBody({
+                username: "bob",
+                password: "hunter2",
+                csrf_token: loginCsrf,
+            }),
+        });
+        const loginRes = await handler(loginReq);
+        const sessionCookie = getCookieValue(
+            loginRes.headers.get("set-cookie"),
+            "session",
+        );
+        if (!sessionCookie) {
+            throw new Error("Missing session cookie after login");
+        }
+
+        // Attempt to create a post without CSRF token
+        const postReq = new Request("http://localhost/post", {
+            method: "POST",
+            headers: {
+                "content-type": "application/x-www-form-urlencoded",
+                cookie: `session=${sessionCookie}`,
+            },
+            body: buildFormBody({ title: "Hello", content: "World" }),
+        });
+        const postRes = await handler(postReq);
+        if (postRes.status !== 403) {
+            throw new Error(
+                `Expected 403 for missing CSRF, got ${postRes.status}`,
+            );
+        }
+        __closeKvForTests?.();
+    });
+});
+
+Deno.test("handler POST /post creates posts with valid session and CSRF", async () => {
+    await withTempDir(async (cwd) => {
+        const mod = await import(
+            new URL("../main.ts", import.meta.url).href +
+                `?t=${crypto.randomUUID()}`
+        );
+        const { handler, __closeKvForTests } = mod;
+
+        await seedUser("clare", "opensesame");
+
+        const loginCsrf = "csrf-login";
+        const loginRes = await handler(
+            new Request("http://localhost/login", {
+                method: "POST",
+                headers: {
+                    "content-type": "application/x-www-form-urlencoded",
+                    cookie: `csrf_token=${loginCsrf}`,
+                },
+                body: buildFormBody({
+                    username: "clare",
+                    password: "opensesame",
+                    csrf_token: loginCsrf,
+                }),
+            }),
+        );
+
+        const sessionCookie = getCookieValue(
+            loginRes.headers.get("set-cookie"),
+            "session",
+        );
+        if (!sessionCookie) {
+            throw new Error("Missing session cookie after login");
+        }
+
+        // Load admin page to get CSRF token for post form
+        const adminRes = await handler(
+            new Request("http://localhost/admin", {
+                headers: { cookie: `session=${sessionCookie}` },
+            }),
+        );
+        const csrfHeader = adminRes.headers.get("set-cookie");
+        const csrfToken = getCookieValue(csrfHeader, "csrf_token");
+        if (!csrfToken) {
+            throw new Error("Missing CSRF cookie from admin response");
+        }
+
+        const createRes = await handler(
+            new Request("http://localhost/post", {
+                method: "POST",
+                headers: {
+                    "content-type": "application/x-www-form-urlencoded",
+                    cookie: `session=${sessionCookie}; csrf_token=${csrfToken}`,
+                },
+                body: buildFormBody({
+                    title: "Test Post",
+                    content: "Body",
+                    csrf_token: csrfToken,
+                }),
+            }),
+        );
+
+        if (createRes.status !== 303) {
+            throw new Error(
+                `Expected redirect after creating post, got ${createRes.status}`,
+            );
+        }
+
+        // Verify Markdown file was written in the temp posts directory
+        const files = [...Deno.readDirSync(`${cwd}/posts`)];
+        if (!files.some((f) => f.isFile && f.name.endsWith(".md"))) {
+            throw new Error("Expected markdown file to be generated");
+        }
+
+        __closeKvForTests?.();
+    });
+});
+
+Deno.test("syncPosts backfills KV entries from Markdown files", async () => {
+    await withTempDir(async () => {
+        const post = samplePost({ title: "Backfill Test", content: "md only" });
+        await savePostMarkdown(post);
+
+        const result = await syncPosts();
+        if (result.created === 0) {
+            throw new Error("Expected KV backfill to count as created");
+        }
+
+        const kv = await Deno.openKv("./storage/db");
+        const stored = await kv.get<Post>(["posts", post.id]);
+        kv.close();
+        if (!stored.value) {
+            throw new Error("KV entry missing after sync");
+        }
+    });
+});
+
+Deno.test("syncPosts regenerates Markdown files when KV is missing them", async () => {
+    await withTempDir(async (cwd) => {
+        const post = samplePost({ title: "Regenerate" });
+        const kv = await Deno.openKv("./storage/db");
+        await kv.set(["posts", post.id], post);
+        kv.close();
+
+        const expectedPath = `${cwd}/posts/${post.filename}`;
+        try {
+            await Deno.remove(expectedPath);
+        } catch {
+            // ignore
+        }
+
+        const result = await syncPosts();
+        if (result.created === 0) {
+            throw new Error(
+                "Expected Markdown regeneration to increment created count",
+            );
+        }
+
+        const exists = await Deno.stat(expectedPath).then(() => true).catch(
+            () => false
+        );
+        if (!exists) {
+            throw new Error("Expected Markdown file to be recreated");
+        }
+    });
+});
diff --git a/users.ts b/users.ts
new file mode 100644 (file)
index 0000000..36418a1
--- /dev/null
+++ b/users.ts
@@ -0,0 +1,195 @@
+// users.ts - Manage blog users from the terminal
+// Examples:
+//   deno run --allow-read --allow-write --unstable-kv users.ts create
+//   deno run --allow-read --allow-write --unstable-kv users.ts list
+//   deno run --allow-read --allow-write --unstable-kv users.ts delete alice
+
+import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
+
+interface User {
+  username: string;
+  passwordHash: string;
+  createdAt: number;
+}
+
+const KV_PATH = "./storage/db";
+
+async function prompt(message: string): Promise<string> {
+  console.log(message);
+  const buf = new Uint8Array(1024);
+  const n = await Deno.stdin.read(buf);
+  return new TextDecoder().decode(buf.subarray(0, n || 0)).trim();
+}
+
+async function promptPassword(message: string): Promise<string> {
+  // Note: This is a simple implementation. In production, you'd want to hide input
+  console.log(message);
+  const buf = new Uint8Array(1024);
+  const n = await Deno.stdin.read(buf);
+  return new TextDecoder().decode(buf.subarray(0, n || 0)).trim();
+}
+
+async function createUser() {
+  console.log("=".repeat(50));
+  console.log("Create New User for Minimalist Blog");
+  console.log("=".repeat(50));
+  console.log();
+
+  const username = await prompt("Enter username:");
+
+  if (!username || username.length < 3) {
+    console.error("❌ Username must be at least 3 characters long");
+    Deno.exit(1);
+  }
+
+  const kv = await Deno.openKv(KV_PATH);
+
+  // Check if user already exists
+  const existingUser = await kv.get(["users", username]);
+  if (existingUser.value) {
+    console.error(`❌ User "${username}" already exists`);
+    kv.close();
+    Deno.exit(1);
+  }
+
+  const password = await promptPassword("Enter password:");
+
+  if (!password || password.length < 6) {
+    console.error("❌ Password must be at least 6 characters long");
+    kv.close();
+    Deno.exit(1);
+  }
+
+  const confirmPassword = await promptPassword("Confirm password:");
+
+  if (password !== confirmPassword) {
+    console.error("❌ Passwords do not match");
+    kv.close();
+    Deno.exit(1);
+  }
+
+  console.log();
+  console.log("🔐 Hashing password...");
+
+  const passwordHash = await bcrypt.hash(password);
+
+  const user: User = {
+    username,
+    passwordHash,
+    createdAt: Date.now(),
+  };
+
+  await kv.set(["users", username], user);
+
+  console.log();
+  console.log("✅ User created successfully!");
+  console.log(`   Username: ${username}`);
+  console.log(`   Created: ${new Date(user.createdAt).toLocaleString()}`);
+  console.log();
+  console.log("You can now login at http://localhost:8000/login");
+
+  kv.close();
+}
+
+async function listUsers() {
+  const kv = await Deno.openKv(KV_PATH);
+  const users: User[] = [];
+
+  const entries = kv.list<User>({ prefix: ["users"] });
+  for await (const entry of entries) {
+    users.push(entry.value);
+  }
+
+  kv.close();
+
+  if (users.length === 0) {
+    console.log("No users found in the database.");
+    return;
+  }
+
+  users.sort((a, b) => a.username.localeCompare(b.username));
+  console.log(`Found ${users.length} user(s):\n`);
+  console.log(`${"Username".padEnd(20)}Created`);
+  console.log("-".repeat(38));
+  for (const user of users) {
+    const created = new Date(user.createdAt).toLocaleString();
+    console.log(`${user.username.padEnd(20)}${created}`);
+  }
+}
+
+async function promptYesNo(message: string): Promise<boolean> {
+  const answer = (await prompt(`${message} (y/N)`)).toLowerCase();
+  return answer === "y" || answer === "yes";
+}
+
+async function deleteUser(username: string | undefined) {
+  if (!username) {
+    console.error("❌ Please provide a username to delete.");
+    Deno.exit(1);
+  }
+
+  const kv = await Deno.openKv(KV_PATH);
+  const key = ["users", username];
+  const result = await kv.get<User>(key);
+
+  if (!result.value) {
+    console.error(`❌ User \"${username}\" does not exist.`);
+    kv.close();
+    Deno.exit(1);
+  }
+
+  const confirm = await promptYesNo(
+    `Are you sure you want to delete user \"${username}\"?`,
+  );
+  if (!confirm) {
+    console.log("Aborted.");
+    kv.close();
+    return;
+  }
+
+  await kv.delete(key);
+  kv.close();
+  console.log(`🗑️  Deleted user \"${username}\".`);
+}
+
+function printHelp() {
+  console.log("Manage blog users stored in Deno KV.\n");
+  console.log("Usage:");
+  console.log("  deno run --allow-read --allow-write --unstable-kv users.ts <command>\n");
+  console.log("Commands:");
+  console.log("  create            Create a new user (default)");
+  console.log("  list              List existing users with creation dates");
+  console.log("  delete <user>     Delete a user after confirmation");
+  console.log("  help              Show this message");
+}
+
+async function main() {
+  const [command, maybeArg] = Deno.args;
+
+  switch (command) {
+    case "help":
+    case "--help":
+    case "-h":
+      printHelp();
+      return;
+    case "list":
+    case "--list":
+    case "-l":
+      await listUsers();
+      return;
+    case "delete":
+    case "--delete":
+      await deleteUser(maybeArg);
+      return;
+    case "create":
+    case undefined:
+      await createUser();
+      return;
+    default:
+      console.error(`Unknown command: ${command}`);
+      printHelp();
+      Deno.exit(1);
+  }
+}
+
+await main();