--- /dev/null
+.DS_Store
+deploy/
+posts/
+storage/
\ No newline at end of file
--- /dev/null
+# 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.
--- /dev/null
+// 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 
+ .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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+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);
+}
--- /dev/null
+// 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 });
+}
--- /dev/null
+// 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 !==
+ "<div class="x">O'Reilly & friends</div>"
+ ) {
+ throw new Error(`Unexpected escape result: ${escaped}`);
+ }
+});
+
+Deno.test("parseFormData() parses urlencoded bodies", () => {
+ const body = "username=john&password=s3cr3t%21¬e=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");
+ }
+ });
+});
--- /dev/null
+// 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();