--- /dev/null
+# KV Image Board
+
+KV Image Board is a server-rendered image- and discussion-board written in Deno, Hono, and Preact.
+Everything required to bootstrap a new instance—branding, rate limits, boards, welcome posts—is
+stored in a single JSON configuration file so deployments stay deterministic and environment
+agnostic.
+
+The stack is intentionally lightweight: server-side rendering only, Deno KV for storage, and a thin
+HTTP layer backed by a dependency-injected application core. The same core services are reused by
+the command line tooling to keep operational tasks consistent with runtime behaviour.
+
+---
+
+## Feature Highlights
+
+- **Config-driven bootstrap** – `config/app.config.json` defines app copy, rate limits, default
+ boards, and the private members board, including its seeded welcome post.
+- **Members-only area** – Authenticated users unlock the `/members/` board and can read permanent
+ announcements posted by administrators. Posts may omit subjects or attachments to simplify long
+ form communication.
+- **First-class moderation tooling** – Ships with task runners for boards, threads, announcements,
+ members, and database maintenance. Scripts accept the short IDs shown in the UI for quick actions.
+- **Rate limited interactions** – Posting threads, commenting, and filing board requests are guarded
+ by KV-backed token buckets shared between the HTTP API and CLI tools.
+- **Zero client JavaScript** – Pages render on the server via Preact templates. Forms post back to
+ Hono routes for fast, accessible interactions.
+- **Deterministic retention** – A background archive use case (run via cron or CLI) marks stale
+ threads read-only while permanent announcements never expire.
+
+---
+
+## Architecture Overview
+
+```
+├── config/ # JSON configuration (sole source of initial data)
+├── src/
+│ ├── application/ # Pure use cases, contracts & utilities
+│ ├── bootstrap/ # Dependency container & startup hooks
+│ ├── config/ # Runtime configuration loader & validation
+│ ├── domain/ # Entities and repository interfaces
+│ ├── infrastructure/ # KV adapters, filesystem storage, session store
+│ └── interfaces/http/ # Hono routes and Preact SSR views
+├── scripts/ # Operational task runners (boards, threads, members, …)
+├── public/ # Static assets (CSS)
+├── storage/ # Uploaded files (created on first run)
+└── main.ts # Entrypoint creating the KV-backed application
+```
+
+The runtime centres on a dependency container that wires repositories, services (clock, rate
+limiter, storage), and application use cases. HTTP handlers and CLI scripts both receive their
+dependencies from this container, guaranteeing identical behaviour regardless of the execution
+surface.
+
+Sessions and rate limiting live entirely inside Deno KV: session cookies are signed HMAC tokens, and
+rate limiting uses atomic KV counters keyed by session ID and client IP.
+
+---
+
+## Configuration
+
+All initial data is sourced from `config/app.config.json`. Example:
+
+```json
+{
+ "app": {
+ "name": "KV Image Board",
+ "description": "Browse community boards or request a new one.",
+ "homeHeading": "KV Boards",
+ "publicDir": "../public",
+ "storageRoot": "../storage",
+ "assetsBasePath": "/assets",
+ "uploadsBasePath": "/uploads",
+ "threadTtlDays": 7,
+ "boardRequestWindowDays": 3,
+ "footerText": "Powered by Deno KV · Community moderated boards",
+ "rateLimits": {
+ "createPost": { "limit": 5, "windowSeconds": 60 },
+ "createComment": { "limit": 10, "windowSeconds": 60 },
+ "createBoardRequest": { "limit": 3, "windowSeconds": 3600 }
+ }
+ },
+ "defaultBoard": {
+ "slug": "g",
+ "name": "General",
+ "description": "General purpose image board.",
+ "themeColor": "#0f172a"
+ },
+ "memberBoard": {
+ "slug": "members",
+ "name": "Members",
+ "description": "Private community board for registered members.",
+ "themeColor": "#1e3a8a",
+ "welcomePost": {
+ "title": "Welcome to the members board",
+ "body": "Introduce yourself and let others know what you are working on.",
+ "permanent": true
+ }
+ }
+}
+```
+
+* Tips:
+ * Relative paths are resolved against the configuration file. Keeping environment-specific JSON
+ alongside deployments is easy.
+ * Footer text, welcome post copy, and moderation defaults change instantly without touching code.
+ * Run with a different configuration via `APP_CONFIG_PATH=/path/to/app.config.json deno task dev`.
+
+---
+
+## Running Locally
+
+```bash
+# 1. Install prerequisites (Deno 1.39+)
+# 2. Optionally copy the config file and tweak values
+
+APP_CONFIG_PATH=./config/app.config.json deno task dev
+```
+
+By default the application listens on `http://localhost:8000`. On startup it:
+
+1. Loads the configuration and initialises the dependency container.
+2. Ensures the default public board and the private members board exist.
+3. Seeds the members board with the configured welcome post if there are no threads yet.
+
+Threads and comments are stored in Deno KV; uploaded media is written to `storage/` (created on the
+first write).
+
+---
+
+## CLI Toolbox
+
+All scripts run with the same container as the HTTP server. Short post IDs (first eight characters)
+shown in the UI can be used with every moderation command.
+
+| Task | Command & Description |
+|---------------------------|-----------------------|
+| Boards | `deno task boards list` / `create <slug> <name>` / `requests` / `delete <slug>` |
+| Threads | `deno task threads delete|repost|unpost|archive <postId>` or `archive-run` |
+| Announcements | `deno task announcements list|add "message"|delete <id>` |
+| Members (admin posts) | `deno task members post "body" [--title "..."] [--slug members]` / `archive <postId>` |
+| Database maintenance | `deno task db wipe --yes` |
+| Formatting & linting | `deno fmt`, `deno lint` |
+| Tests | `deno task test` |
+
+Because scripts accept short IDs, operators can copy the identifier shown in the UI (`No.ab12cd34`)
+and execute commands without hunting for the full UUID.
+
+---
+
+## Example Use Cases
+
+1. **Spin up a private community**: Edit the configuration to reflect your brand, set rate limits,
+ and deploy. Members sign up via the built-in auth panel, unlocking `/members/` while anonymous
+ users continue browsing public boards.
+2. **Publish an announcement**: `deno task members post "New guidelines are live." --title "Update"`
+ creates a permanent, non-expiring thread surfaced to every member. The scheduled archive job
+ ignores permanent posts.
+3. **Moderation sweep**: Run `deno task threads archive-run` nightly (or via cron) to move expired
+ threads into archive mode. For individual clean-up, use `deno task threads archive ab12cd34` with
+ the short ID shown in the browser.
+4. **Rapid prototyping**: Swap out the configuration file to test new colour schemes or different
+ board line-ups. Because bootstrap data is JSON, you can version and migrate it like any other
+ asset.
+
+---
+
+## Development Workflow
+
+- `deno lint` and `deno fmt` keep the codebase clean (lint is part of CI by default).
+- `deno test --allow-read --allow-write --allow-env --unstable-kv` covers application use cases,
+ repositories, and configuration parsing.
+- Scripts leverage the same use cases as the HTTP handlers, so adding new moderation capabilities
+ usually means wiring the relevant use case into both layers.
+- The UI relies on `public/styles.css`. When adding new components make sure the stylesheet contains
+ matching selectors (the audit step above ensured everything currently in use is covered).
+
+---
+
+## Troubleshooting
+
+| Symptom | Fix |
+|---------|-----|
+| `Thread XYZ not found` when using CLI | Use the short ID shown in the UI (`No.ab12cd34`). All tasks now accept prefixes and resolve the full ID automatically. |
+| `Configuration is missing ...` | Make sure every section exists in `app.config.json` (app, defaultBoard, memberBoard). |
+| Missing uploads | Confirm `storage/` is writable by the process and the configured `uploadsBasePath` points to a valid route. |
+| Rate limit triggered too quickly | Adjust the relevant entry in `app.rateLimits` and restart the server to reload configuration. |
+
+---
+
+Happy hacking!
--- /dev/null
+{
+ "app": {
+ "name": "Telluride Community Boards",
+ "description": "Browse community boards or request a new one.",
+ "homeHeading": "Telluride Board",
+ "publicDir": "../public",
+ "storageRoot": "../storage",
+ "assetsBasePath": "/assets",
+ "uploadsBasePath": "/uploads",
+ "threadTtlDays": 7,
+ "boardRequestWindowDays": 3,
+ "rateLimits": {
+ "createPost": { "limit": 5, "windowSeconds": 60 },
+ "createComment": { "limit": 10, "windowSeconds": 60 },
+ "createBoardRequest": { "limit": 3, "windowSeconds": 3600 }
+ },
+ "footerText": "Community moderated boards"
+ },
+ "defaultBoard": {
+ "slug": "g",
+ "name": "General",
+ "description": "General purpose image board.",
+ "themeColor": "#0f172a"
+ },
+ "memberBoard": {
+ "slug": "m",
+ "name": "Members",
+ "description": "Private community board for registered members.",
+ "themeColor": "#1e3a8a",
+ "welcomePost": {
+ "title": "Welcome to the members board",
+ "body": "Introduce yourself and let others know what you are working on.",
+ "permanent": true
+ }
+ }
+}
--- /dev/null
+{
+ "imports": {
+ "@hono/hono": "jsr:@hono/hono@4.10.4",
+ "preact": "npm:preact@10.19.4",
+ "preact-render-to-string": "npm:preact-render-to-string@6.2.1"
+ },
+ "lint": {
+ "rules": {
+ "tags": ["recommended"]
+ }
+ },
+ "fmt": {
+ "lineWidth": 100,
+ "semiColons": true,
+ "singleQuote": false
+ },
+ "tasks": {
+ "dev": "deno run --allow-read --allow-write --allow-net --allow-env --unstable-kv --unstable-cron main.ts",
+ "fmt": "deno fmt",
+ "lint": "deno lint",
+ "test": "deno test --allow-read --allow-write --allow-env --unstable-kv",
+ "threads": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/threads.ts",
+ "boards": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/boards.ts",
+ "db": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/db.ts",
+ "announcements": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/announcements.ts",
+ "members": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/members.ts",
+ "compile": "deno run --allow-net --allow-read --allow-write --allow-run scripts/build.ts"
+ }
+}
--- /dev/null
+{
+ "version": "5",
+ "specifiers": {
+ "jsr:@hono/hono@4.10.4": "4.10.4",
+ "jsr:@std/encoding@1": "1.0.10",
+ "jsr:@std/internal@^1.0.10": "1.0.12",
+ "jsr:@std/path@1": "1.1.2",
+ "npm:preact-render-to-string@6.2.1": "6.2.1_preact@10.19.4",
+ "npm:preact@10.19.4": "10.19.4"
+ },
+ "jsr": {
+ "@hono/hono@4.10.4": {
+ "integrity": "e54d00c4cf994e7ae297d7321793cf940656b9c5e934564c03ffc15499041b9e"
+ },
+ "@std/encoding@1.0.10": {
+ "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
+ },
+ "@std/internal@1.0.12": {
+ "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
+ },
+ "@std/path@1.1.2": {
+ "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
+ "dependencies": [
+ "jsr:@std/internal"
+ ]
+ }
+ },
+ "npm": {
+ "preact-render-to-string@6.2.1_preact@10.19.4": {
+ "integrity": "sha512-5t7nFeMUextd53igL3GAakAAMaUD+dVWDHaRYaeh1tbPIjQIBtgJnMw6vf8VS/lviV0ggFtkgebatPxvtJsXyQ==",
+ "dependencies": [
+ "preact",
+ "pretty-format"
+ ]
+ },
+ "preact@10.19.4": {
+ "integrity": "sha512-dwaX5jAh0Ga8uENBX1hSOujmKWgx9RtL80KaKUFLc6jb4vCEAc3EeZ0rnQO/FO4VgjfPMfoLFWnNG8bHuZ9VLw=="
+ },
+ "pretty-format@3.8.0": {
+ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
+ }
+ },
+ "workspace": {
+ "dependencies": [
+ "jsr:@hono/hono@4.10.4",
+ "npm:preact-render-to-string@6.2.1",
+ "npm:preact@10.19.4"
+ ]
+ }
+}
--- /dev/null
+import { createApp } from "./src/interfaces/http/app.ts";
+import { createAppContainer, ensureDefaultBoardsExist } from "./src/bootstrap/app_container.ts";
+
+const container = await createAppContainer({
+ configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+});
+
+await ensureDefaultBoardsExist(container);
+
+const { config, useCases, services } = container;
+
+if ("cron" in Deno) {
+ Deno.cron("archive-stale-posts", "0 3 * * *", async () => {
+ const archived = await useCases.archiveExpiredPostsUseCase.execute();
+ if (archived > 0) {
+ console.log(`Archived ${archived} stale posts.`);
+ }
+ });
+
+ Deno.cron("auto-create-boards", "30 3 * * *", async () => {
+ const result = await useCases.autoCreateBoardsUseCase.execute();
+ if (result.created > 0) {
+ console.log(
+ `Auto-created ${result.created} board${result.created === 1 ? "" : "s"} from requests.`,
+ );
+ }
+ });
+}
+
+const sessionSecret = Deno.env.get("SESSION_COOKIE_SECRET") ?? crypto.randomUUID();
+const sessionCookieSecureEnv = Deno.env.get("SESSION_COOKIE_SECURE");
+const inferredEnv = (Deno.env.get("APP_ENV") ?? Deno.env.get("NODE_ENV") ?? "").toLowerCase();
+const sessionCookieSecure = sessionCookieSecureEnv !== undefined
+ ? !["false", "0", ""].includes(sessionCookieSecureEnv.toLowerCase())
+ : ["production", "staging"].includes(inferredEnv);
+const trustProxy = (Deno.env.get("TRUST_PROXY") ?? "false").toLowerCase() === "true";
+
+const app = createApp({
+ appName: config.app.name,
+ homePageTitle: config.app.name,
+ homePageHeading: config.app.homeHeading,
+ homePageDescription: config.app.description,
+ footerText: config.app.footerText,
+ assetsBasePath: config.app.assetsBasePath,
+ createPostUseCase: useCases.createPostUseCase,
+ listPostsUseCase: useCases.listPostsUseCase,
+ listArchivedPostsUseCase: useCases.listArchivedPostsUseCase,
+ getPostUseCase: useCases.getPostUseCase,
+ createCommentUseCase: useCases.createCommentUseCase,
+ listCommentsUseCase: useCases.listCommentsUseCase,
+ listBoardsUseCase: useCases.listBoardsUseCase,
+ createBoardRequestUseCase: useCases.createBoardRequestUseCase,
+ toggleBoardRequestVoteUseCase: useCases.toggleBoardRequestVoteUseCase,
+ getBoardBySlugUseCase: useCases.getBoardBySlugUseCase,
+ getBoardRequestsOverviewUseCase: useCases.getBoardRequestsOverviewUseCase,
+ listBoardAnnouncementsUseCase: useCases.listBoardAnnouncementsUseCase,
+ listPopularPostsUseCase: useCases.listPopularPostsUseCase,
+ imageStorage: services.imageStorage,
+ publicDir: config.app.publicDir,
+ uploadsBasePath: config.app.uploadsBasePath,
+ rateLimiter: services.rateLimiter,
+ rateLimits: config.app.rateLimits,
+ threadTtlDays: config.app.threadTtlDays,
+ boardRequestWindowDays: config.app.boardRequestWindowDays,
+ sessionSecret,
+ sessionCookieSecure,
+ trustProxy,
+ registerUserUseCase: useCases.registerUserUseCase,
+ authenticateUserUseCase: useCases.authenticateUserUseCase,
+ sessionStore: services.sessionStore,
+ userRepository: container.repositories.userRepository,
+ memberBoard: config.memberBoard,
+});
+
+const port = Number(Deno.env.get("PORT") ?? "8000");
+console.log(`${config.app.name} listening on http://localhost:${port}`);
+Deno.serve({ port }, (request, connInfo) => app.fetch(request, { connInfo }, connInfo));
--- /dev/null
+:root {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ background-color: #f2f2f2;
+ color: #1a1a1a;
+ --brand-color: #1d4d7a;
+ --brand-border: #c4c4c4;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0 auto;
+ max-width: 960px;
+ padding: 24px 16px 64px;
+ background-color: #f2f2f2;
+ line-height: 1.5;
+}
+
+main,
+.home-main {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+.board-header {
+ background: #e8e8e8;
+ border: 1px solid var(--brand-border);
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.board-nav {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ font-size: 0.9rem;
+}
+
+.board-title {
+ margin: 0;
+ font-size: 1.6rem;
+ font-weight: 600;
+ color: var(--brand-color);
+}
+
+.board-desc,
+.board-header .muted {
+ margin: 0;
+ color: #3c3c3c;
+ font-size: 0.95rem;
+}
+
+.post-form,
+.request-form,
+.request-list-container,
+.thread {
+ background: #fff;
+ border: 1px solid var(--brand-border);
+ padding: 14px;
+}
+
+.section-title {
+ margin: 0 0 10px;
+ font-size: 1.1rem;
+ color: var(--brand-color);
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: 130px 1fr;
+ gap: 10px 14px;
+ align-items: center;
+}
+
+.form-grid label {
+ text-align: right;
+ font-size: 0.9rem;
+ color: #2f2f2f;
+}
+
+.form-grid input,
+.form-grid textarea {
+ font: inherit;
+ width: 100%;
+ padding: 6px 8px;
+ border: 1px solid #b0b0b0;
+ background: #fff;
+}
+
+.form-grid textarea {
+ resize: vertical;
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+button {
+ padding: 6px 12px;
+ border: 1px solid #b0b0b0;
+ background: #f5f5f5;
+ cursor: pointer;
+ font: inherit;
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: wait;
+}
+
+button.vote-button,
+button.vote-button.voted {
+ font-size: 0.85rem;
+ padding: 4px 10px;
+}
+
+button.vote-button.voted {
+ background: #fff;
+}
+
+.error,
+.success {
+ margin: 8px 0;
+ padding: 8px 10px;
+ border: 1px solid #b0b0b0;
+ background: #fafafa;
+ font-size: 0.9rem;
+}
+
+.success {
+ color: #165a35;
+}
+
+.flash-container {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ z-index: 2000;
+ max-width: 280px;
+}
+
+.flash-toast {
+ border: none;
+ border-radius: 6px;
+ padding: 10px 14px;
+ font: inherit;
+ text-align: left;
+ color: #fff;
+ background: var(--brand-color);
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.2);
+ cursor: pointer;
+ transition: transform 0.15s ease, opacity 0.2s ease;
+}
+
+.flash-toast:hover {
+ transform: translateY(-1px);
+ opacity: 0.9;
+}
+
+.flash-toast:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+}
+
+.flash-toast.flash-error {
+ background: #b91c1c;
+}
+
+.flash-toast.flash-success {
+ background: #15803d;
+}
+
+.muted {
+ color: #555;
+ font-size: 0.9rem;
+}
+
+.board-grid {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
+.board-card {
+ display: block;
+ background: #fff;
+ border: 1px solid var(--brand-border);
+ padding: 14px;
+}
+
+.board-card h3 {
+ margin: 0 0 6px;
+ font-size: 1.05rem;
+}
+
+.board-card p {
+ margin: 0;
+ color: #3f3f46;
+ font-size: 0.95rem;
+}
+
+.board-requests {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.request-panel {
+ border: 1px solid var(--brand-border);
+ background: #fff;
+ padding: 12px;
+}
+
+.request-panel summary {
+ cursor: pointer;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.request-form {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.request-form label {
+ font-size: 0.9rem;
+ color: #333;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.request-form textarea {
+ resize: vertical;
+}
+
+.request-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.requests-body {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 18px;
+}
+
+.request-list li {
+ background: #fff;
+ border: 1px solid var(--brand-border);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.request-summary h3 {
+ margin: 0 0 4px;
+ font-size: 1rem;
+ color: var(--brand-color);
+}
+
+.request-summary p {
+ margin: 0;
+ color: #3f3f46;
+ font-size: 0.95rem;
+}
+
+.request-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 0.88rem;
+}
+
+.threads {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.thread header {
+ font-size: 0.9rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 4px;
+ color: #303030;
+}
+
+.post-meta {
+ color: var(--brand-color);
+ font-weight: 600;
+}
+
+.post-date {
+ color: #505050;
+}
+
+.thread-body {
+ display: flex;
+ gap: 12px;
+}
+
+.thread-body.no-image {
+ flex-direction: column;
+}
+
+.attachment {
+ display: flex;
+}
+
+.thread-replies {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.thread-reply {
+ margin-top: 16px;
+}
+
+.thread-body img {
+ max-width: 220px;
+ border: 1px solid var(--brand-border);
+}
+
+.thread-text {
+ max-width: 100%;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.thread-text strong {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--brand-color);
+}
+
+.id-link {
+ border: none;
+ background: none;
+ color: var(--brand-color);
+ cursor: pointer;
+ padding: 0;
+ font: inherit;
+ text-decoration: underline;
+}
+
+.id-link:hover {
+ text-decoration: none;
+}
+
+.reply-list {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ font-size: 0.85rem;
+ color: #4b4b4b;
+}
+
+.status-pill {
+ color: #a11e1e;
+ font-weight: 600;
+}
+
+.reply {
+ border-left: 2px solid var(--brand-border);
+ padding-left: 10px;
+ margin-top: 10px;
+}
+
+.reply.highlight {
+ background: rgba(253, 230, 138, 0.35);
+}
+
+.comment-form {
+ margin-top: 10px;
+ border: 1px solid var(--brand-border);
+ background: #fafafa;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.comment-form label {
+ font-size: 0.85rem;
+ color: #303030;
+}
+
+.comment-form input,
+.comment-form textarea {
+ border: 1px solid #b0b0b0;
+ padding: 6px;
+ font: inherit;
+}
+
+.spinner {
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+@media (max-width: 720px) {
+ body {
+ padding: 20px 12px 48px;
+ }
+
+ .form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .form-grid label {
+ text-align: left;
+ }
+
+ .thread-body {
+ flex-direction: column;
+ }
+
+ .thread-body img {
+ max-width: 100%;
+ }
+
+ .board-nav {
+ justify-content: flex-start;
+ }
+}
+
+.thread-body img,
+.board-card img,
+.request-summary img {
+ max-width: 220px;
+ height: auto;
+}
+
+.attachment img,
+.comment-attachment img {
+ max-width: 200px;
+ height: auto;
+}
+
+.comment-attachment img {
+ border: 1px solid var(--brand-border);
+}
+
+.bulletin-board {
+ background: #fff7d6;
+ border: 1px solid #d5c894;
+ padding: 12px;
+ color: #3d3d1f;
+}
+
+.bulletin-board h2 {
+ margin: 0 0 8px;
+ font-size: 1rem;
+}
+
+.bulletin-board ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.bulletin-board li {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.request-controls {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 8px;
+}
+
+.popular-posts {
+ border: 1px solid var(--brand-border);
+ background: #fff;
+ padding: 14px;
+}
+
+.popular-posts h2 {
+ margin: 0 0 8px;
+ font-size: 1.05rem;
+}
+
+.popular-posts ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.popular-posts li {
+ font-size: 0.95rem;
+}
+
+.site-footer {
+ margin-top: 24px;
+ text-align: center;
+ font-size: 0.85rem;
+ color: #4b4b4b;
+}
+
+.member-auth {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.member-auth .request-panel {
+ margin-top: 12px;
+}
+
+.member-card {
+ border: 1px solid var(--brand-border);
+ background: #fff;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.member-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.auth-forms {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 18px;
+}
+
+.auth-form {
+ border: 1px solid var(--brand-border);
+ background: #f9f9f9;
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.auth-form.active {
+ background: #fff;
+ border-color: var(--brand-color);
+ box-shadow: 0 0 0 2px rgba(29, 77, 122, 0.1);
+}
+
+.auth-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 0.9rem;
+ color: #333;
+}
+
+.auth-form input {
+ font: inherit;
+ padding: 6px 8px;
+ border: 1px solid #b0b0b0;
+ background: #fff;
+}
+
+.auth-form button {
+ align-self: flex-start;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 14px;
+ border: 1px solid var(--brand-border);
+ background: #fff;
+ font: inherit;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.button:hover {
+ text-decoration: none;
+ background: #f5f5f5;
+}
+
+.logout-form {
+ margin: 0;
+}
--- /dev/null
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+
+const COMMANDS = ["list", "add", "delete"] as const;
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+ console.log(`Announcements
+
+Usage:
+ deno task announcements list List bulletin messages
+ deno task announcements add \"message\" Add a bulletin
+ deno task announcements delete <id> Remove a bulletin
+`);
+};
+
+const ensureArg = (value: string | undefined, message: string): string => {
+ if (!value) {
+ console.error(message);
+ usage();
+ Deno.exit(1);
+ }
+ return value;
+};
+
+const main = async () => {
+ const [command, ...rest] = Deno.args as [Command | undefined, ...string[]];
+ if (!command || !COMMANDS.includes(command)) {
+ usage();
+ Deno.exit(command ? 1 : 0);
+ }
+
+ const container = await createAppContainer({
+ configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+ });
+
+ try {
+ const {
+ useCases: {
+ listBoardAnnouncementsUseCase,
+ createBoardAnnouncementUseCase,
+ deleteBoardAnnouncementUseCase,
+ },
+ } = container;
+
+ switch (command) {
+ case "list": {
+ const announcements = await listBoardAnnouncementsUseCase.execute();
+ if (announcements.length === 0) {
+ console.log("No announcements.");
+ break;
+ }
+ console.table(
+ announcements.map((announcement) => ({
+ id: announcement.id,
+ message: announcement.message,
+ createdAt: announcement.createdAt,
+ })),
+ );
+ break;
+ }
+ case "add": {
+ const message = rest.join(" ").trim();
+ ensureArg(message, "add requires a message to post");
+ const announcement = await createBoardAnnouncementUseCase.execute(message);
+ console.log(`Announcement ${announcement.id} created.`);
+ break;
+ }
+ case "delete": {
+ const id = ensureArg(rest[0], "delete requires <id>");
+ await deleteBoardAnnouncementUseCase.execute(id);
+ console.log(`Announcement ${id} deleted.`);
+ break;
+ }
+ default:
+ usage();
+ Deno.exit(1);
+ }
+ } finally {
+ container.close();
+ }
+};
+
+if (import.meta.main) {
+ await main();
+}
--- /dev/null
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+
+const COMMANDS = [
+ "list",
+ "requests",
+ "create",
+ "create-from-request",
+ "delete",
+ "close-request",
+] as const;
+
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+ console.log(`Board management
+
+Usage:
+ deno task boards list List all boards
+ deno task boards requests List board requests and vote counts
+ deno task boards create <slug> <name> [options]
+ --description \"...\" Optional description
+ --color #1E40AF Optional hex theme color
+ deno task boards create-from-request <id> Fulfil a request and create the board
+ deno task boards delete <slug> Delete a board and its threads
+ deno task boards close-request <id> Close a request without creating a board
+`);
+};
+
+const parseFlags = (args: string[]) => {
+ const result: Record<string, string | boolean> = {};
+ for (let i = 0; i < args.length; i += 1) {
+ const current = args[i];
+ if (!current.startsWith("--")) {
+ continue;
+ }
+ const key = current.slice(2);
+ const candidate = args[i + 1];
+ if (!candidate || candidate.startsWith("--")) {
+ result[key] = true;
+ } else {
+ result[key] = candidate;
+ i += 1;
+ }
+ }
+ return result;
+};
+
+const ensureArgument = (value: string | undefined, message: string) => {
+ if (!value) {
+ console.error(message);
+ usage();
+ Deno.exit(1);
+ }
+};
+
+const deleteBoardThreads = async (
+ container: Awaited<ReturnType<typeof createAppContainer>>,
+ boardId: string,
+) => {
+ const { postRepository, commentRepository } = container.repositories;
+ const { imageStorage } = container.services;
+
+ const posts = await postRepository.listByBoard(boardId);
+ for (const post of posts) {
+ const comments = await commentRepository.listByPost(post.id);
+ for (const comment of comments) {
+ if (comment.imagePath) {
+ await imageStorage.deleteImage(comment.imagePath);
+ }
+ }
+ await commentRepository.deleteByPost(post.id);
+ if (post.imagePath) {
+ await imageStorage.deleteImage(post.imagePath);
+ }
+ await postRepository.delete(post.id);
+ }
+ return posts.length;
+};
+
+const main = async () => {
+ const [command, ...rest] = Deno.args as [Command | undefined, ...string[]];
+ if (!command || !COMMANDS.includes(command)) {
+ usage();
+ Deno.exit(command ? 1 : 0);
+ }
+
+ const container = await createAppContainer({
+ configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+ });
+
+ try {
+ const {
+ config,
+ repositories: {
+ boardRepository,
+ boardRequestRepository,
+ boardRequestVoteRepository,
+ },
+ useCases: {
+ listBoardsUseCase,
+ listBoardRequestsUseCase,
+ createBoardUseCase,
+ updateBoardRequestStatusUseCase,
+ },
+ } = container;
+
+ switch (command) {
+ case "list": {
+ const boards = await listBoardsUseCase.execute();
+ if (boards.length === 0) {
+ console.log("No boards configured.");
+ break;
+ }
+ console.table(
+ boards.map((board) => ({
+ slug: board.slug,
+ name: board.name,
+ themeColor: board.themeColor,
+ createdAt: board.createdAt,
+ })),
+ );
+ break;
+ }
+ case "requests": {
+ const requests = await listBoardRequestsUseCase.execute();
+ if (requests.length === 0) {
+ console.log("No active requests.");
+ break;
+ }
+ console.table(
+ await Promise.all(
+ requests.map(async (request) => ({
+ id: request.id,
+ slug: request.slug,
+ name: request.name,
+ votes: await boardRequestVoteRepository.countVotes(request.id),
+ status: request.status,
+ deadline: request.deadline,
+ })),
+ ),
+ );
+ break;
+ }
+ case "create": {
+ const [slug, name] = rest;
+ ensureArgument(slug, "create requires <slug> and <name>");
+ ensureArgument(name, "create requires <slug> and <name>");
+ const flags = parseFlags(rest.slice(2));
+ const description = typeof flags.description === "string" ? flags.description : "";
+ const themeColor = typeof flags.color === "string"
+ ? flags.color
+ : config.defaultBoard.themeColor;
+
+ const board = await createBoardUseCase.execute({
+ slug: slug!,
+ name: name!,
+ description,
+ themeColor,
+ });
+ console.log(`Created board /${board.slug}/ (${board.name}).`);
+ break;
+ }
+ case "create-from-request": {
+ const [requestId] = rest;
+ ensureArgument(requestId, "create-from-request requires <id>");
+ const request = await boardRequestRepository.findById(requestId!);
+ if (!request) {
+ console.error(`Request ${requestId} not found.`);
+ Deno.exit(1);
+ }
+ const board = await createBoardUseCase.execute({
+ slug: request.slug,
+ name: request.name,
+ description: request.description,
+ themeColor: request.themeColor,
+ });
+ await updateBoardRequestStatusUseCase.execute(request.id, "fulfilled");
+ console.log(`Created board /${board.slug}/ and marked request ${request.id} as fulfilled.`);
+ break;
+ }
+ case "delete": {
+ const [slug] = rest;
+ ensureArgument(slug, "delete requires <slug>");
+ const board = await boardRepository.findBySlug(slug!);
+ if (!board) {
+ console.error(`Board /${slug}/ not found.`);
+ Deno.exit(1);
+ }
+ const deletedThreads = await deleteBoardThreads(container, board.id);
+ await boardRepository.delete(board.id);
+ console.log(
+ `Deleted board /${slug}/ and ${deletedThreads} thread${deletedThreads === 1 ? "" : "s"}.`,
+ );
+ break;
+ }
+ case "close-request": {
+ const [requestId] = rest;
+ ensureArgument(requestId, "close-request requires <id>");
+ await updateBoardRequestStatusUseCase.execute(requestId!, "closed");
+ console.log(`Closed request ${requestId}.`);
+ break;
+ }
+ default:
+ usage();
+ Deno.exit(1);
+ }
+ } finally {
+ container.close();
+ }
+};
+
+if (import.meta.main) {
+ await main();
+}
--- /dev/null
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-net
+
+import { dirname, fromFileUrl, join } from "jsr:@std/path@^1.0.0";
+
+const resolveProjectRoot = () => dirname(dirname(fromFileUrl(import.meta.url)));
+
+const projectRoot = resolveProjectRoot();
+const entryFile = join(projectRoot, "main.ts");
+const distDir = join(projectRoot, "dist");
+const executableName = Deno.build.os === "windows" ? "imgboard.exe" : "imgboard";
+const executableFile = join(distDir, executableName);
+
+const ensureDistDir = async () => {
+ await Deno.mkdir(distDir, { recursive: true });
+};
+
+const runCommand = async (args: string[]) => {
+ const command = new Deno.Command(Deno.execPath(), {
+ cwd: projectRoot,
+ args,
+ stdout: "inherit",
+ stderr: "inherit",
+ });
+ const { success } = await command.output();
+ if (!success) {
+ throw new Error(`Command failed: deno ${args.join(" ")}`);
+ }
+};
+
+const runCompile = async () => {
+ await ensureDistDir();
+ const compileArgs = [
+ "compile",
+ "--allow-read",
+ "--allow-write",
+ "--allow-net",
+ "--allow-env",
+ "--unstable-kv",
+ "--unstable-cron",
+ "--output",
+ executableFile,
+ entryFile,
+ ];
+ await runCommand(compileArgs);
+ console.log(`Executable written to ${executableFile}`);
+ console.log("Run with: ./dist/" + executableName);
+};
+
+if (import.meta.main) {
+ try {
+ await runCompile();
+ } catch (error) {
+ console.error("Build failed:", error);
+ Deno.exit(1);
+ }
+}
--- /dev/null
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+const usage = () => {
+ console.log(`Database utilities
+
+Usage:
+ deno task db wipe [--yes] Remove every key from the Deno KV store
+`);
+};
+
+const confirm = async (message: string): Promise<boolean> => {
+ await Deno.stdout.write(new TextEncoder().encode(`${message} [y/N] `));
+ const buf = new Uint8Array(1024);
+ const n = await Deno.stdin.read(buf);
+ if (!n) {
+ return false;
+ }
+ const answer = new TextDecoder().decode(buf.subarray(0, n)).trim().toLowerCase();
+ return answer === "y" || answer === "yes";
+};
+
+import { join } from "jsr:@std/path@^1.0.0";
+import { loadAppConfig } from "../src/config/app_config.ts";
+
+const wipe = async (force: boolean) => {
+ if (!force) {
+ const ok = await confirm(
+ "This will permanently delete all boards, posts, comments, and requests. Continue?",
+ );
+ if (!ok) {
+ console.log("Aborted.");
+ return;
+ }
+ }
+
+ const kv = await Deno.openKv();
+ try {
+ const batch: Deno.KvKey[] = [];
+ for await (const entry of kv.list({ prefix: [] })) {
+ batch.push(entry.key);
+ if (batch.length >= 128) {
+ await deleteBatch(kv, batch.splice(0));
+ }
+ }
+ if (batch.length > 0) {
+ await deleteBatch(kv, batch);
+ }
+ console.log("Database wiped.");
+ } finally {
+ kv.close();
+ }
+
+ await removeStoredImages();
+};
+
+const deleteBatch = async (kv: Deno.Kv, keys: Deno.KvKey[]) => {
+ const tx = kv.atomic();
+ for (const key of keys) {
+ tx.delete(key);
+ }
+ await tx.commit();
+};
+
+const removeStoredImages = async () => {
+ try {
+ const config = await loadAppConfig(Deno.env.get("APP_CONFIG_PATH") ?? undefined);
+ const imagesDir = join(config.app.storageRoot, "images");
+ await Deno.remove(imagesDir, { recursive: true });
+ console.log(`Removed image directory: ${imagesDir}`);
+ } catch (error) {
+ if (error instanceof Deno.errors.NotFound) {
+ return;
+ }
+ console.warn(
+ `Failed to remove stored images: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+};
+
+const main = async () => {
+ const [command, ...args] = Deno.args;
+ if (!command || command === "-h" || command === "--help") {
+ usage();
+ Deno.exit(0);
+ }
+
+ switch (command) {
+ case "wipe": {
+ const force = args.includes("--yes") || args.includes("-y");
+ await wipe(force);
+ break;
+ }
+ default:
+ usage();
+ Deno.exit(1);
+ }
+};
+
+if (import.meta.main) {
+ await main();
+}
--- /dev/null
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+
+const COMMANDS = ["post", "archive"] as const;
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+ console.log(`Members board utilities
+
+Usage:
+ deno task members post <body> [--title "..."] [--slug members]
+ deno task members archive <postId>
+`);
+};
+
+const parseFlags = (args: string[]) => {
+ const result: Record<string, string | boolean> = {};
+ for (let i = 0; i < args.length; i += 1) {
+ const current = args[i];
+ if (!current.startsWith("--")) {
+ continue;
+ }
+ const key = current.slice(2);
+ const candidate = args[i + 1];
+ if (!candidate || candidate.startsWith("--")) {
+ result[key] = true;
+ } else {
+ result[key] = candidate;
+ i += 1;
+ }
+ }
+ return result;
+};
+
+const ensureValue = (value: string | undefined, message: string) => {
+ if (!value) {
+ console.error(message);
+ usage();
+ Deno.exit(1);
+ }
+ return value;
+};
+
+const createAdminPost = async (
+ container: Awaited<ReturnType<typeof createAppContainer>>,
+ body: string,
+ options: { title?: string; slug?: string },
+) => {
+ const trimmedBody = body.trim();
+ if (!trimmedBody) {
+ console.error("Post body must not be empty.");
+ Deno.exit(1);
+ }
+ const { memberBoard } = container.config;
+ const targetSlug = options.slug?.trim().length ? options.slug.trim() : memberBoard.slug;
+ const board = await container.repositories.boardRepository.findBySlug(targetSlug);
+ if (!board) {
+ console.error(`Board /${targetSlug}/ not found.`);
+ Deno.exit(1);
+ }
+
+ const post = await container.useCases.createPostUseCase.execute({
+ boardId: board.id,
+ title: options.title?.trim() || undefined,
+ description: trimmedBody,
+ permanent: true,
+ });
+ console.log(`Created permanent post ${post.id} on /${board.slug}/.`);
+};
+
+const archivePost = async (
+ container: Awaited<ReturnType<typeof createAppContainer>>,
+ postId: string,
+) => {
+ const post = await container.repositories.postRepository.findById(postId);
+ if (!post) {
+ console.error(`Post ${postId} not found.`);
+ Deno.exit(1);
+ }
+
+ const board = await container.repositories.boardRepository.findById(post.boardId);
+ if (!board) {
+ console.error(`Board for post ${postId} not found.`);
+ Deno.exit(1);
+ }
+
+ const targetSlug = container.config.memberBoard.slug;
+ if (board.slug !== targetSlug) {
+ console.error(`Post ${postId} does not belong to /${targetSlug}/.`);
+ Deno.exit(1);
+ }
+
+ post.status = "archived";
+ post.readOnly = true;
+ post.archivedAt = new Date().toISOString();
+ await container.repositories.postRepository.update(post);
+ console.log(`Archived post ${postId}.`);
+};
+
+const main = async () => {
+ const [command, ...rest] = Deno.args as [Command | undefined, ...string[]];
+ if (!command || !COMMANDS.includes(command)) {
+ usage();
+ Deno.exit(command ? 1 : 0);
+ }
+
+ const container = await createAppContainer({
+ configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+ });
+
+ try {
+ switch (command) {
+ case "post": {
+ let bodyArg: string | undefined;
+ const flagArgs: string[] = [];
+ for (const arg of rest) {
+ if (bodyArg === undefined && !arg.startsWith("--")) {
+ bodyArg = arg;
+ } else {
+ flagArgs.push(arg);
+ }
+ }
+ const flags = parseFlags(flagArgs);
+ const candidateBody = typeof flags.body === "string" ? `${flags.body}` : bodyArg;
+ const body = ensureValue(
+ candidateBody,
+ 'post requires a body (pass as argument or --body "...").',
+ );
+ const title = typeof flags.title === "string" ? `${flags.title}` : undefined;
+ const slug = typeof flags.slug === "string" ? `${flags.slug}` : undefined;
+ await createAdminPost(container, body, { title, slug });
+ break;
+ }
+ case "archive": {
+ const [postId] = rest;
+ const targetPostId = ensureValue(postId, "archive requires <postId>");
+ await archivePost(container, targetPostId);
+ break;
+ }
+ default:
+ usage();
+ Deno.exit(1);
+ }
+ } finally {
+ container.close();
+ }
+};
+
+if (import.meta.main) {
+ await main();
+}
--- /dev/null
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+import type { PostRepository } from "../src/domain/repositories/post_repository.ts";
+
+const COMMANDS = ["delete", "repost", "unpost", "archive", "archive-run"] as const;
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+ console.log(`Thread management
+
+Usage:
+ deno task threads delete <postId> Delete a thread and all of its assets
+ deno task threads repost <postId> Surface an archived thread as read-only on the board
+ deno task threads unpost <postId> Hide a reposted thread back into the archive
+ deno task threads archive <postId> Archive a thread immediately (keep assets)
+ deno task threads archive-run Run the archive job immediately
+`);
+};
+
+const ensurePostId = (command: string, value?: string): string => {
+ if (!value) {
+ console.error(`${command} requires a <postId>`);
+ usage();
+ Deno.exit(1);
+ }
+ return value;
+};
+
+const deleteThread = async (
+ container: Awaited<ReturnType<typeof createAppContainer>>,
+ postId: string,
+) => {
+ const { postRepository, commentRepository } = container.repositories;
+ const { imageStorage } = container.services;
+
+ const post = await findPostByPartialId(postRepository, postId);
+ if (!post) {
+ console.error(`Thread ${postId} not found.`);
+ Deno.exit(1);
+ }
+
+ const comments = await commentRepository.listByPost(post.id);
+ for (const comment of comments) {
+ if (comment.imagePath) {
+ await imageStorage.deleteImage(comment.imagePath);
+ }
+ }
+ await commentRepository.deleteByPost(post.id);
+ if (post.imagePath) {
+ await imageStorage.deleteImage(post.imagePath);
+ }
+ await postRepository.delete(post.id);
+
+ console.log(
+ `Deleted thread ${postId} and ${comments.length} comment${comments.length === 1 ? "" : "s"}.`,
+ );
+};
+
+const repostThread = async (
+ container: Awaited<ReturnType<typeof createAppContainer>>,
+ postId: string,
+) => {
+ const { postRepository } = container.repositories;
+ const post = await findPostByPartialId(postRepository, postId);
+ if (!post) {
+ console.error(`Thread ${postId} not found.`);
+ Deno.exit(1);
+ }
+ post.status = "active";
+ post.readOnly = true;
+ await postRepository.update(post);
+ console.log(`Thread ${postId} reposted to the board in read-only mode.`);
+};
+
+const unpostThread = async (
+ container: Awaited<ReturnType<typeof createAppContainer>>,
+ postId: string,
+) => {
+ const { postRepository } = container.repositories;
+ const post = await findPostByPartialId(postRepository, postId);
+ if (!post) {
+ console.error(`Thread ${postId} not found.`);
+ Deno.exit(1);
+ }
+ post.status = "archived";
+ post.readOnly = true;
+ post.archivedAt ??= new Date().toISOString();
+ await postRepository.update(post);
+ console.log(`Thread ${postId} hidden from the live board but kept in the archive.`);
+};
+
+const archiveThread = async (
+ container: Awaited<ReturnType<typeof createAppContainer>>,
+ postId: string,
+) => {
+ const { postRepository } = container.repositories;
+ const post = await findPostByPartialId(postRepository, postId);
+ if (!post) {
+ console.error(`Thread ${postId} not found.`);
+ Deno.exit(1);
+ }
+ post.status = "archived";
+ post.readOnly = true;
+ post.archivedAt = new Date().toISOString();
+ await postRepository.update(post);
+ console.log(`Thread ${postId} archived.`);
+};
+
+const findPostByPartialId = async (
+ postRepository: Pick<PostRepository, "findById" | "listAll">,
+ candidate: string,
+) => {
+ const normalised = candidate.trim();
+ if (normalised.length === 0) {
+ return null;
+ }
+ const direct = await postRepository.findById(normalised);
+ if (direct) {
+ return direct;
+ }
+ const posts = await postRepository.listAll();
+ const lower = normalised.toLowerCase();
+ return posts.find((post) => post.id.toLowerCase().startsWith(lower)) ?? null;
+};
+
+const main = async () => {
+ const [command, postId] = Deno.args as [Command | undefined, string | undefined];
+ if (!command || !COMMANDS.includes(command)) {
+ usage();
+ Deno.exit(command ? 1 : 0);
+ }
+
+ const container = await createAppContainer({
+ configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+ });
+
+ try {
+ switch (command) {
+ case "delete": {
+ await deleteThread(container, ensurePostId(command, postId));
+ break;
+ }
+ case "repost": {
+ await repostThread(container, ensurePostId(command, postId));
+ break;
+ }
+ case "unpost": {
+ await unpostThread(container, ensurePostId(command, postId));
+ break;
+ }
+ case "archive": {
+ await archiveThread(container, ensurePostId(command, postId));
+ break;
+ }
+ case "archive-run": {
+ const archived = await container.useCases.archiveExpiredPostsUseCase.execute();
+ console.log(`Archived ${archived} thread${archived === 1 ? "" : "s"}.`);
+ break;
+ }
+ default:
+ usage();
+ Deno.exit(1);
+ }
+ } finally {
+ container.close();
+ }
+};
+
+if (import.meta.main) {
+ await main();
+}
--- /dev/null
+export interface Clock {
+ now(): Date;
+}
--- /dev/null
+export interface IdGenerator {
+ generate(): string;
+}
--- /dev/null
+export class ApplicationError extends Error {
+ constructor(message: string, public readonly status = 400) {
+ super(message);
+ this.name = "ApplicationError";
+ }
+}
--- /dev/null
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { Clock } from "../contracts/clock.ts";
+
+export class ArchiveExpiredPostsUseCase {
+ constructor(
+ private readonly postRepository: PostRepository,
+ private readonly clock: Clock,
+ private readonly ttlMilliseconds: number,
+ ) {}
+
+ async execute(): Promise<number> {
+ const posts = await this.postRepository.listAll();
+ const now = this.clock.now().getTime();
+ const expired = posts.filter((rawPost) => {
+ const status = rawPost.status ?? "active";
+ const permanent = rawPost.permanent ?? false;
+ return status === "active" && !permanent &&
+ now - new Date(rawPost.createdAt).getTime() > this.ttlMilliseconds;
+ });
+
+ for (const post of expired) {
+ post.status = "archived";
+ post.readOnly = true;
+ post.archivedAt = new Date(now).toISOString();
+ await this.postRepository.update(post);
+ }
+
+ return expired.length;
+ }
+}
--- /dev/null
+import { ArchiveExpiredPostsUseCase } from "./archive_expired_posts_use_case.ts";
+import { FixedClock, InMemoryPostRepository } from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-03-01T00:00:00.000Z");
+
+const buildPost = (overrides: Partial<Record<string, unknown>> = {}) => ({
+ id: `post-${crypto.randomUUID()}`,
+ boardId: "board-1",
+ title: "Sample",
+ description: "",
+ createdAt: NOW.toISOString(),
+ commentCount: 0,
+ status: "active" as const,
+ readOnly: false,
+ archivedAt: null,
+ ...overrides,
+});
+
+Deno.test("ArchiveExpiredPostsUseCase archives posts older than TTL", async () => {
+ const postRepository = new InMemoryPostRepository();
+ const clock = new FixedClock(NOW);
+ const ttl = 24 * 60 * 60 * 1000; // one day
+
+ const recentPost = buildPost({
+ id: "recent",
+ createdAt: new Date(NOW.getTime() - ttl / 2).toISOString(),
+ });
+ const stalePost = buildPost({
+ id: "stale",
+ createdAt: new Date(NOW.getTime() - ttl * 2).toISOString(),
+ });
+
+ await postRepository.create(recentPost);
+ await postRepository.create(stalePost);
+
+ const useCase = new ArchiveExpiredPostsUseCase(postRepository, clock, ttl);
+ const archivedCount = await useCase.execute();
+
+ assertEquals(archivedCount, 1);
+
+ const storedRecent = await postRepository.findById("recent");
+ assertEquals(storedRecent?.status, "active");
+ assertEquals(storedRecent?.readOnly, false);
+ assertEquals(storedRecent?.archivedAt, null);
+
+ const storedStale = await postRepository.findById("stale");
+ assertEquals(storedStale?.status, "archived");
+ assertEquals(storedStale?.readOnly, true);
+ assertEquals(storedStale?.archivedAt, NOW.toISOString());
+});
+
+Deno.test("ArchiveExpiredPostsUseCase skips permanent posts", async () => {
+ const postRepository = new InMemoryPostRepository();
+ const clock = new FixedClock(NOW);
+ const ttl = 24 * 60 * 60 * 1000;
+
+ const permanentPost = buildPost({
+ id: "permanent",
+ createdAt: new Date(NOW.getTime() - ttl * 3).toISOString(),
+ permanent: true,
+ });
+
+ await postRepository.create(permanentPost);
+
+ const useCase = new ArchiveExpiredPostsUseCase(postRepository, clock, ttl);
+ const archivedCount = await useCase.execute();
+
+ assertEquals(archivedCount, 0);
+
+ const stored = await postRepository.findById("permanent");
+ assertEquals(stored?.status, "active");
+ assertEquals(stored?.permanent, true);
+});
--- /dev/null
+import { ApplicationError } from "../errors/application_error.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+import { User } from "../../domain/entities/user.ts";
+import { verifyPassword } from "../utils/password_hash.ts";
+
+export interface AuthenticateUserCommand {
+ username: string;
+ password: string;
+}
+
+export class AuthenticateUserUseCase {
+ constructor(private readonly userRepository: UserRepository) {}
+
+ async execute(command: AuthenticateUserCommand): Promise<User> {
+ const username = command.username?.trim();
+ if (!username) {
+ throw new ApplicationError("Username is required.");
+ }
+ const password = command.password ?? "";
+ const user = await this.userRepository.findByUsername(username);
+ if (!user) {
+ throw new ApplicationError("Invalid username or password.", 401);
+ }
+ const valid = await verifyPassword(password, user.passwordHash);
+ if (!valid) {
+ throw new ApplicationError("Invalid username or password.", 401);
+ }
+ return user;
+ }
+}
--- /dev/null
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+import { CreateBoardUseCase } from "./create_board_use_case.ts";
+import { UpdateBoardRequestStatusUseCase } from "./update_board_request_status_use_case.ts";
+
+export class AutoCreateBoardsFromRequestsUseCase {
+ constructor(
+ private readonly boardRequestRepository: BoardRequestRepository,
+ private readonly boardRequestVoteRepository: BoardRequestVoteRepository,
+ private readonly boardRepository: BoardRepository,
+ private readonly createBoardUseCase: CreateBoardUseCase,
+ private readonly updateBoardRequestStatusUseCase: UpdateBoardRequestStatusUseCase,
+ ) {}
+
+ async execute(): Promise<{ created: number }> {
+ const requests = await this.boardRequestRepository.list();
+ const now = Date.now();
+ const expired = requests.filter((request) => {
+ if (request.status !== "open") {
+ return false;
+ }
+ return new Date(request.deadline).getTime() <= now;
+ });
+
+ let created = 0;
+ for (const request of expired) {
+ const voteCount = await this.boardRequestVoteRepository.countVotes(request.id);
+ if (request.voteCount !== voteCount) {
+ request.voteCount = voteCount;
+ await this.boardRequestRepository.update(request);
+ } else {
+ request.voteCount = voteCount;
+ }
+ if (voteCount === 0) {
+ await this.updateBoardRequestStatusUseCase.execute(request.id, "closed");
+ continue;
+ }
+
+ const existing = await this.boardRepository.findBySlug(request.slug);
+ if (existing) {
+ await this.updateBoardRequestStatusUseCase.execute(request.id, "fulfilled");
+ continue;
+ }
+
+ await this.createBoardUseCase.execute({
+ slug: request.slug,
+ name: request.name,
+ description: request.description,
+ themeColor: request.themeColor,
+ });
+ await this.updateBoardRequestStatusUseCase.execute(request.id, "fulfilled");
+ created += 1;
+ }
+
+ return { created };
+ }
+}
--- /dev/null
+import { AutoCreateBoardsFromRequestsUseCase } from "./auto_create_boards_from_requests_use_case.ts";
+import { CreateBoardUseCase } from "./create_board_use_case.ts";
+import { UpdateBoardRequestStatusUseCase } from "./update_board_request_status_use_case.ts";
+import {
+ FixedClock,
+ InMemoryBoardRepository,
+ InMemoryBoardRequestRepository,
+ InMemoryBoardRequestVoteRepository,
+ SequenceIdGenerator,
+} from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-03-10T00:00:00.000Z");
+
+const buildRequest = (id: string, overrides: Partial<Record<string, unknown>> = {}) => ({
+ id,
+ slug: `board-${id}`,
+ name: `Board ${id}`,
+ description: "",
+ themeColor: "#0f172a",
+ voteCount: 0,
+ createdAt: NOW.toISOString(),
+ deadline: new Date(Date.now() - 60 * 1000).toISOString(),
+ status: "open" as const,
+ ...overrides,
+});
+
+Deno.test("AutoCreateBoardsFromRequestsUseCase handles expired requests", async () => {
+ const boardRepository = new InMemoryBoardRepository();
+ const requestRepository = new InMemoryBoardRequestRepository();
+ const voteRepository = new InMemoryBoardRequestVoteRepository();
+ const idGenerator = new SequenceIdGenerator("board");
+ const clock = new FixedClock(NOW);
+
+ const createBoardUseCase = new CreateBoardUseCase(boardRepository, idGenerator, clock);
+ const updateStatusUseCase = new UpdateBoardRequestStatusUseCase(requestRepository);
+ const useCase = new AutoCreateBoardsFromRequestsUseCase(
+ requestRepository,
+ voteRepository,
+ boardRepository,
+ createBoardUseCase,
+ updateStatusUseCase,
+ );
+
+ await requestRepository.create(buildRequest("no-votes"));
+ await requestRepository.create(buildRequest("existing"));
+ await requestRepository.create(buildRequest("new"));
+
+ await boardRepository.create({
+ id: "existing-board",
+ slug: "board-existing",
+ name: "Existing Board",
+ description: "",
+ themeColor: "#0f172a",
+ createdAt: NOW.toISOString(),
+ status: "active",
+ });
+
+ await voteRepository.addVote("existing", "user-1");
+ await voteRepository.addVote("new", "user-1");
+ await voteRepository.addVote("new", "user-2");
+
+ assertEquals(await voteRepository.countVotes("new"), 2);
+ assertEquals(await boardRepository.findBySlug("board-new"), null);
+
+ const result = await useCase.execute();
+
+ const noVotes = await requestRepository.findById("no-votes");
+ assertEquals(noVotes?.status, "closed");
+ assertEquals(noVotes?.voteCount, 0);
+
+ const existing = await requestRepository.findById("existing");
+ assertEquals(existing?.status, "fulfilled");
+ assertEquals(existing?.voteCount, 1);
+
+ const createdRequest = await requestRepository.findById("new");
+ assertEquals(createdRequest?.status, "fulfilled");
+ assertEquals(createdRequest?.voteCount, 2);
+
+ const newBoard = await boardRepository.findBySlug("board-new");
+ assertEquals(newBoard?.name, "Board new");
+
+ assertEquals(result.created, 1);
+});
--- /dev/null
+import { BoardAnnouncement } from "../../domain/entities/board_announcement.ts";
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export class CreateBoardAnnouncementUseCase {
+ constructor(
+ private readonly announcementRepository: BoardAnnouncementRepository,
+ private readonly idGenerator: IdGenerator,
+ private readonly clock: Clock,
+ ) {}
+
+ async execute(message: string): Promise<BoardAnnouncement> {
+ const text = message?.trim();
+ if (!text) {
+ throw new ApplicationError("Announcement message is required.");
+ }
+
+ const announcement: BoardAnnouncement = {
+ id: this.idGenerator.generate(),
+ message: text,
+ createdAt: this.clock.now().toISOString(),
+ };
+ await this.announcementRepository.create(announcement);
+ return announcement;
+ }
+}
--- /dev/null
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export interface CreateBoardRequestCommand {
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+ durationDays?: number;
+}
+
+export class CreateBoardRequestUseCase {
+ constructor(
+ private readonly boardRequestRepository: BoardRequestRepository,
+ private readonly boardRepository: BoardRepository,
+ private readonly idGenerator: IdGenerator,
+ private readonly clock: Clock,
+ private readonly defaultDurationDays: number,
+ ) {}
+
+ async execute(command: CreateBoardRequestCommand): Promise<BoardRequest> {
+ const slug = normalizeSlug(command.slug);
+ if (!slug) {
+ throw new ApplicationError("Slug is required.");
+ }
+
+ const name = command.name?.trim();
+ if (!name) {
+ throw new ApplicationError("Name is required.");
+ }
+
+ const themeColor = sanitizeColor(command.themeColor);
+
+ const existingBoard = await this.boardRepository.findBySlug(slug);
+ if (existingBoard) {
+ throw new ApplicationError("A board with that slug already exists.");
+ }
+
+ const existingRequests = await this.boardRequestRepository.list();
+ if (existingRequests.some((request) => request.slug === slug && request.status === "open")) {
+ throw new ApplicationError("A request for that board already exists.");
+ }
+
+ const createdAt = this.clock.now();
+ const durationDays = command.durationDays ?? this.defaultDurationDays;
+ const deadline = new Date(createdAt.getTime() + durationDays * 24 * 60 * 60 * 1000);
+
+ const request: BoardRequest = {
+ id: this.idGenerator.generate(),
+ slug,
+ name,
+ description: command.description?.trim() ?? "",
+ themeColor,
+ voteCount: 0,
+ createdAt: createdAt.toISOString(),
+ deadline: deadline.toISOString(),
+ status: "open",
+ };
+
+ await this.boardRequestRepository.create(request);
+ return request;
+ }
+}
+
+const normalizeSlug = (value: string | undefined): string => {
+ const slug = value?.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(
+ /^-|-$/g,
+ "",
+ );
+ return slug ?? "";
+};
+
+const sanitizeColor = (value: string | undefined): string => {
+ const color = value?.trim();
+ if (!color) {
+ return "#0f172a";
+ }
+ if (/^#[0-9a-fA-F]{6}$/.test(color)) {
+ return color.toLowerCase();
+ }
+ throw new ApplicationError("Theme color must be a hex value like #1E40AF.");
+};
--- /dev/null
+import { CreateBoardRequestUseCase } from "./create_board_request_use_case.ts";
+import {
+ FixedClock,
+ InMemoryBoardRepository,
+ InMemoryBoardRequestRepository,
+ SequenceIdGenerator,
+} from "../../testing/fakes.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-02-01T00:00:00.000Z");
+
+Deno.test("CreateBoardRequestUseCase validates inputs", async (t) => {
+ const requestRepository = new InMemoryBoardRequestRepository();
+ const boardRepository = new InMemoryBoardRepository();
+ const idGenerator = new SequenceIdGenerator("request");
+ const clock = new FixedClock(NOW);
+ const useCase = new CreateBoardRequestUseCase(
+ requestRepository,
+ boardRepository,
+ idGenerator,
+ clock,
+ 3,
+ );
+
+ await t.step("requires slug", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ slug: " ",
+ name: "Test",
+ description: "",
+ themeColor: "#123456",
+ }),
+ Error,
+ "Slug is required.",
+ );
+ });
+
+ await t.step("requires name", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ slug: "test",
+ name: " ",
+ description: "",
+ themeColor: "#123456",
+ }),
+ Error,
+ "Name is required.",
+ );
+ });
+
+ await t.step("validates theme color", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ slug: "test",
+ name: "Test",
+ description: "",
+ themeColor: "blue",
+ }),
+ Error,
+ "Theme color must be a hex value like #1E40AF.",
+ );
+ });
+});
+
+Deno.test("CreateBoardRequestUseCase detects conflicts and normalizes slug", async (t) => {
+ const requestRepository = new InMemoryBoardRequestRepository();
+ const boardRepository = new InMemoryBoardRepository();
+ const idGenerator = new SequenceIdGenerator("request");
+ const clock = new FixedClock(NOW);
+ const useCase = new CreateBoardRequestUseCase(
+ requestRepository,
+ boardRepository,
+ idGenerator,
+ clock,
+ 5,
+ );
+
+ await boardRepository.create({
+ id: "board-1",
+ slug: "general",
+ name: "General",
+ description: "",
+ themeColor: "#0f172a",
+ createdAt: NOW.toISOString(),
+ status: "active",
+ });
+
+ await t.step("rejects existing board slug", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ slug: "General ",
+ name: "General 2",
+ description: "",
+ themeColor: "#123456",
+ }),
+ Error,
+ "A board with that slug already exists.",
+ );
+ });
+
+ await requestRepository.create({
+ id: "request-existing",
+ slug: "tech",
+ name: "Technology",
+ description: "",
+ themeColor: "#123456",
+ voteCount: 0,
+ createdAt: NOW.toISOString(),
+ deadline: NOW.toISOString(),
+ status: "open",
+ });
+
+ await t.step("rejects existing open request", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ slug: "tech ",
+ name: "Technology 2",
+ description: "",
+ themeColor: "#abcdef",
+ }),
+ Error,
+ "A request for that board already exists.",
+ );
+ });
+});
+
+Deno.test("CreateBoardRequestUseCase creates request with defaults", async () => {
+ const requestRepository = new InMemoryBoardRequestRepository();
+ const boardRepository = new InMemoryBoardRepository();
+ const idGenerator = new SequenceIdGenerator("request");
+ const clock = new FixedClock(NOW);
+ const useCase = new CreateBoardRequestUseCase(
+ requestRepository,
+ boardRepository,
+ idGenerator,
+ clock,
+ 7,
+ );
+
+ const request = await useCase.execute({
+ slug: " New Board! ",
+ name: " New Board ",
+ description: " Something fun ",
+ themeColor: " ",
+ });
+
+ assertEquals(request.id, "request-1");
+ assertEquals(request.slug, "new-board");
+ assertEquals(request.name, "New Board");
+ assertEquals(request.description, "Something fun");
+ assertEquals(request.themeColor, "#0f172a");
+ assertEquals(request.voteCount, 0);
+ assertEquals(request.status, "open");
+ assertEquals(request.createdAt, NOW.toISOString());
+
+ const expectedDeadline = new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
+ assertEquals(request.deadline, expectedDeadline);
+
+ const stored = await requestRepository.findById("request-1");
+ assertEquals(stored, request);
+});
--- /dev/null
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export interface CreateBoardCommand {
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+}
+
+export class CreateBoardUseCase {
+ constructor(
+ private readonly boardRepository: BoardRepository,
+ private readonly idGenerator: IdGenerator,
+ private readonly clock: Clock,
+ ) {}
+
+ async execute(command: CreateBoardCommand): Promise<Board> {
+ const slug = normalizeSlug(command.slug);
+ if (!slug) {
+ throw new ApplicationError("Slug is required.");
+ }
+
+ const name = command.name?.trim();
+ if (!name) {
+ throw new ApplicationError("Name is required.");
+ }
+
+ const existing = await this.boardRepository.findBySlug(slug);
+ if (existing) {
+ throw new ApplicationError("Board slug already exists.");
+ }
+
+ const board: Board = {
+ id: this.idGenerator.generate(),
+ slug,
+ name,
+ description: command.description?.trim() ?? "",
+ themeColor: sanitizeColor(command.themeColor),
+ createdAt: this.clock.now().toISOString(),
+ status: "active",
+ };
+
+ await this.boardRepository.create(board);
+ return board;
+ }
+}
+
+const normalizeSlug = (value: string | undefined): string => {
+ const slug = value?.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(
+ /^-|-$/g,
+ "",
+ );
+ return slug ?? "";
+};
+
+const sanitizeColor = (value: string | undefined): string => {
+ const color = value?.trim();
+ if (!color) {
+ return "#0f172a";
+ }
+ if (/^#[0-9a-fA-F]{6}$/.test(color)) {
+ return color.toLowerCase();
+ }
+ throw new ApplicationError("Theme color must be a hex value like #1E40AF.");
+};
--- /dev/null
+import { Comment } from "../../domain/entities/comment.ts";
+import { CommentRepository } from "../../domain/repositories/comment_repository.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ImageStorage } from "../../domain/services/image_storage.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+
+export interface CreateCommentCommand {
+ postId: string;
+ author: string;
+ body: string;
+ parentCommentId?: string;
+ authorUserId?: string;
+ authorUsername?: string;
+ image?: {
+ data: Uint8Array;
+ mimeType: string;
+ originalName: string;
+ };
+}
+
+export class CreateCommentUseCase {
+ constructor(
+ private readonly postRepository: PostRepository,
+ private readonly commentRepository: CommentRepository,
+ private readonly imageStorage: ImageStorage,
+ private readonly idGenerator: IdGenerator,
+ private readonly clock: Clock,
+ private readonly userRepository: UserRepository,
+ ) {}
+
+ async execute(command: CreateCommentCommand): Promise<Comment> {
+ const post = await this.postRepository.findById(command.postId);
+ if (!post) {
+ throw new ApplicationError("Post not found.", 404);
+ }
+
+ const status = post.status ?? "active";
+ const readOnly = post.readOnly ?? false;
+ if (status !== "active" || readOnly) {
+ throw new ApplicationError("Thread is read-only.", 403);
+ }
+
+ if (!command.body?.trim()) {
+ throw new ApplicationError("Comment body is required.");
+ }
+
+ if (command.parentCommentId) {
+ const parent = await this.commentRepository.findById(
+ command.postId,
+ command.parentCommentId,
+ );
+ if (!parent) {
+ throw new ApplicationError("Parent comment not found.", 404);
+ }
+ }
+
+ const id = this.idGenerator.generate();
+ let imagePath: string | undefined;
+
+ if (command.image) {
+ imagePath = await this.imageStorage.saveImage({
+ id,
+ data: command.image.data,
+ mimeType: command.image.mimeType,
+ originalName: command.image.originalName,
+ });
+ }
+
+ const commentAuthor = await this.resolveAuthor(command);
+
+ const comment: Comment = {
+ id,
+ postId: command.postId,
+ parentCommentId: command.parentCommentId,
+ author: commentAuthor.displayName,
+ authorUserId: commentAuthor.userId ?? undefined,
+ body: command.body.trim(),
+ imagePath,
+ createdAt: this.clock.now().toISOString(),
+ };
+
+ await this.commentRepository.create(comment);
+ const totalComments = await this.commentRepository.countByPost(command.postId);
+ post.commentCount = totalComments;
+ await this.postRepository.update(post);
+ return comment;
+ }
+
+ private async resolveAuthor(command: CreateCommentCommand): Promise<{
+ displayName: string;
+ userId: string | null;
+ }> {
+ if (command.authorUserId) {
+ const user = await this.userRepository.findById(command.authorUserId);
+ if (!user) {
+ throw new ApplicationError("Account not found.", 403);
+ }
+ return {
+ displayName: user.username,
+ userId: user.id,
+ };
+ }
+
+ const provided = command.author?.trim() ?? "";
+ const displayName = provided.length > 0 ? provided : "Anonymous";
+ const reserved = await this.userRepository.isUsernameTaken(displayName);
+ if (reserved) {
+ throw new ApplicationError(
+ "That name is reserved by a registered member. Please choose another.",
+ );
+ }
+ return {
+ displayName,
+ userId: null,
+ };
+ }
+}
--- /dev/null
+import { CreateCommentUseCase } from "./create_comment_use_case.ts";
+import {
+ FixedClock,
+ InMemoryCommentRepository,
+ InMemoryImageStorage,
+ InMemoryPostRepository,
+ InMemoryUserRepository,
+ SequenceIdGenerator,
+} from "../../testing/fakes.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-01-01T00:00:00.000Z");
+
+const buildPost = (overrides: Partial<Record<string, unknown>> = {}) => ({
+ id: "post-1",
+ boardId: "board-1",
+ title: "Hello",
+ description: "World",
+ imagePath: "/uploads/images/post-1",
+ createdAt: new Date("2024-01-01T00:00:00.000Z").toISOString(),
+ commentCount: 0,
+ status: "active" as const,
+ readOnly: false,
+ archivedAt: null,
+ ...overrides,
+});
+
+Deno.test("CreateCommentUseCase validates inputs", async (t) => {
+ const postRepository = new InMemoryPostRepository();
+ const commentRepository = new InMemoryCommentRepository();
+ const imageStorage = new InMemoryImageStorage();
+ const idGenerator = new SequenceIdGenerator("comment");
+ const clock = new FixedClock(NOW);
+ const userRepository = new InMemoryUserRepository();
+ const useCase = new CreateCommentUseCase(
+ postRepository,
+ commentRepository,
+ imageStorage,
+ idGenerator,
+ clock,
+ userRepository,
+ );
+
+ await t.step("post must exist", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ postId: "missing",
+ author: "Anon",
+ body: "Hello",
+ }),
+ Error,
+ "Post not found.",
+ );
+ });
+
+ await postRepository.create(buildPost({ readOnly: true }));
+
+ await t.step("rejects read-only threads", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ postId: "post-1",
+ author: "Anon",
+ body: "Hello",
+ }),
+ Error,
+ "Thread is read-only.",
+ );
+ });
+
+ await postRepository.update(buildPost({ status: "archived" }));
+
+ await t.step("rejects archived threads", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ postId: "post-1",
+ author: "Anon",
+ body: "Hello",
+ }),
+ Error,
+ "Thread is read-only.",
+ );
+ });
+});
+
+Deno.test("CreateCommentUseCase validates body and parent comment", async (t) => {
+ const postRepository = new InMemoryPostRepository();
+ await postRepository.create(buildPost());
+
+ const commentRepository = new InMemoryCommentRepository();
+ const imageStorage = new InMemoryImageStorage();
+ const idGenerator = new SequenceIdGenerator("comment");
+ const clock = new FixedClock(NOW);
+ const userRepository = new InMemoryUserRepository();
+ const useCase = new CreateCommentUseCase(
+ postRepository,
+ commentRepository,
+ imageStorage,
+ idGenerator,
+ clock,
+ userRepository,
+ );
+
+ await t.step("requires non-empty body", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ postId: "post-1",
+ author: "",
+ body: " ",
+ }),
+ Error,
+ "Comment body is required.",
+ );
+ });
+
+ await t.step("parent comment must exist when provided", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ postId: "post-1",
+ author: "",
+ body: "Hello",
+ parentCommentId: "missing",
+ }),
+ Error,
+ "Parent comment not found.",
+ );
+ });
+});
+
+Deno.test("CreateCommentUseCase saves comment, increments counter, and stores image", async () => {
+ const postRepository = new InMemoryPostRepository();
+ await postRepository.create(buildPost());
+
+ const commentRepository = new InMemoryCommentRepository();
+ const imageStorage = new InMemoryImageStorage();
+ const idGenerator = new SequenceIdGenerator("comment");
+ const clock = new FixedClock(NOW);
+ const userRepository = new InMemoryUserRepository();
+ const useCase = new CreateCommentUseCase(
+ postRepository,
+ commentRepository,
+ imageStorage,
+ idGenerator,
+ clock,
+ userRepository,
+ );
+
+ const comment = await useCase.execute({
+ postId: "post-1",
+ author: " Alice ",
+ body: " Nice thread ",
+ image: {
+ data: new Uint8Array([5, 6, 7]),
+ mimeType: "image/png",
+ originalName: "reply.png",
+ },
+ });
+
+ assertEquals(comment.id, "comment-1");
+ assertEquals(comment.author, "Alice");
+ assertEquals(comment.body, "Nice thread");
+ assertEquals(comment.imagePath, "/uploads/images/comment-1");
+ assertEquals(comment.createdAt, NOW.toISOString());
+
+ const post = await postRepository.findById("post-1");
+ assertEquals(post?.commentCount, 1);
+
+ const stored = await commentRepository.findById("post-1", "comment-1");
+ assertEquals(stored, comment);
+
+ const storedImage = await imageStorage.getImage("/uploads/images/comment-1");
+ assertEquals(Array.from(storedImage), [5, 6, 7]);
+});
+
+Deno.test("CreateCommentUseCase prevents using a claimed username", async () => {
+ const postRepository = new InMemoryPostRepository();
+ await postRepository.create(buildPost());
+
+ const commentRepository = new InMemoryCommentRepository();
+ const imageStorage = new InMemoryImageStorage();
+ const idGenerator = new SequenceIdGenerator("comment");
+ const clock = new FixedClock(NOW);
+ const userRepository = new InMemoryUserRepository();
+
+ await userRepository.create({
+ id: "user-1",
+ username: "Alice",
+ passwordHash: "hash",
+ createdAt: NOW.toISOString(),
+ });
+
+ const useCase = new CreateCommentUseCase(
+ postRepository,
+ commentRepository,
+ imageStorage,
+ idGenerator,
+ clock,
+ userRepository,
+ );
+
+ await assertRejects(
+ () =>
+ useCase.execute({
+ postId: "post-1",
+ author: "Alice",
+ body: "Hi",
+ }),
+ Error,
+ "That name is reserved by a registered member. Please choose another.",
+ );
+});
+
+Deno.test("CreateCommentUseCase uses member identity when provided", async () => {
+ const postRepository = new InMemoryPostRepository();
+ await postRepository.create(buildPost());
+
+ const commentRepository = new InMemoryCommentRepository();
+ const imageStorage = new InMemoryImageStorage();
+ const idGenerator = new SequenceIdGenerator("comment");
+ const clock = new FixedClock(NOW);
+ const userRepository = new InMemoryUserRepository();
+
+ await userRepository.create({
+ id: "user-1",
+ username: "Alice",
+ passwordHash: "hash",
+ createdAt: NOW.toISOString(),
+ });
+
+ const useCase = new CreateCommentUseCase(
+ postRepository,
+ commentRepository,
+ imageStorage,
+ idGenerator,
+ clock,
+ userRepository,
+ );
+
+ const comment = await useCase.execute({
+ postId: "post-1",
+ author: "Someone Else",
+ body: "Hi",
+ authorUserId: "user-1",
+ authorUsername: "Alice",
+ });
+
+ assertEquals(comment.author, "Alice");
+ assertEquals(comment.authorUserId, "user-1");
+});
--- /dev/null
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ImageStorage } from "../../domain/services/image_storage.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export interface CreatePostCommand {
+ boardId: string;
+ description: string;
+ title?: string;
+ image?: {
+ data: Uint8Array;
+ mimeType: string;
+ originalName: string;
+ };
+ permanent?: boolean;
+}
+
+export class CreatePostUseCase {
+ constructor(
+ private readonly postRepository: PostRepository,
+ private readonly imageStorage: ImageStorage,
+ private readonly idGenerator: IdGenerator,
+ private readonly clock: Clock,
+ ) {}
+
+ async execute(command: CreatePostCommand): Promise<ImagePost> {
+ if (!command.boardId?.trim()) {
+ throw new ApplicationError("Board is required.");
+ }
+
+ const description = command.description?.trim();
+ if (!description) {
+ throw new ApplicationError("Post body is required.");
+ }
+
+ const id = this.idGenerator.generate();
+ const createdAt = this.clock.now().toISOString();
+
+ let imagePath: string | undefined;
+ if (command.image) {
+ imagePath = await this.imageStorage.saveImage({
+ id,
+ data: command.image.data,
+ mimeType: command.image.mimeType,
+ originalName: command.image.originalName,
+ });
+ }
+
+ const title = command.title?.trim();
+
+ const post: ImagePost = {
+ id,
+ boardId: command.boardId.trim(),
+ description,
+ createdAt,
+ commentCount: 0,
+ status: "active",
+ readOnly: false,
+ archivedAt: null,
+ };
+ if (title && title.length > 0) {
+ post.title = title;
+ }
+ if (imagePath) {
+ post.imagePath = imagePath;
+ }
+ if (command.permanent === true) {
+ post.permanent = true;
+ }
+
+ await this.postRepository.create(post);
+ return post;
+ }
+}
--- /dev/null
+import { CreatePostUseCase } from "./create_post_use_case.ts";
+import type { CreatePostCommand } from "./create_post_use_case.ts";
+import { FixedClock, InMemoryPostRepository, SequenceIdGenerator } from "../../testing/fakes.ts";
+import { ImageStorage, SaveImageInput } from "../../domain/services/image_storage.ts";
+import { assertEquals, assertRejects, assertStrictEquals } from "../../testing/asserts.ts";
+
+class RecordingImageStorage implements ImageStorage {
+ saved: SaveImageInput | null = null;
+
+ saveImage(input: SaveImageInput): Promise<string> {
+ this.saved = { ...input };
+ return Promise.resolve(`/uploads/images/${input.id}`);
+ }
+
+ getImage(_path: string): Promise<Uint8Array> {
+ return Promise.reject(new Error("not implemented"));
+ }
+
+ deleteImage(_path: string): Promise<void> {
+ return Promise.resolve();
+ }
+}
+
+const NOW = new Date("2024-01-01T12:00:00.000Z");
+
+Deno.test("CreatePostUseCase validates required fields", async (t) => {
+ const postRepository = new InMemoryPostRepository();
+ const imageStorage = new RecordingImageStorage();
+ const idGenerator = new SequenceIdGenerator("post");
+ const clock = new FixedClock(NOW);
+ const useCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+
+ await t.step("missing board id", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ boardId: " ",
+ title: "Test",
+ description: "",
+ image: {
+ data: new Uint8Array([1]),
+ mimeType: "image/png",
+ originalName: "test.png",
+ },
+ }),
+ Error,
+ "Board is required.",
+ );
+ });
+
+ await t.step("missing body", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ boardId: "board-1",
+ description: " ",
+ } as CreatePostCommand),
+ Error,
+ "Post body is required.",
+ );
+ });
+});
+
+Deno.test("CreatePostUseCase persists trimmed post data", async () => {
+ const postRepository = new InMemoryPostRepository();
+ const imageStorage = new RecordingImageStorage();
+ const idGenerator = new SequenceIdGenerator("post");
+ const clock = new FixedClock(NOW);
+ const useCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+
+ const result = await useCase.execute({
+ boardId: " board-1 ",
+ description: " First post ",
+ image: {
+ data: new Uint8Array([1, 2, 3]),
+ mimeType: "image/jpeg",
+ originalName: "photo.jpg",
+ },
+ });
+
+ assertEquals(result.id, "post-1");
+ assertEquals(result.boardId, "board-1");
+ assertEquals(result.description, "First post");
+ assertEquals(result.commentCount, 0);
+ assertEquals(result.status, "active");
+ assertStrictEquals(imageStorage.saved?.id, "post-1");
+ assertEquals(imageStorage.saved?.mimeType, "image/jpeg");
+ assertEquals(imageStorage.saved?.originalName, "photo.jpg");
+ assertEquals(result.createdAt, NOW.toISOString());
+
+ const stored = await postRepository.findById("post-1");
+ assertEquals(stored, result);
+});
+
+Deno.test("CreatePostUseCase allows missing image and title", async () => {
+ const postRepository = new InMemoryPostRepository();
+ const imageStorage = new RecordingImageStorage();
+ const idGenerator = new SequenceIdGenerator("post");
+ const clock = new FixedClock(NOW);
+ const useCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+
+ const result = await useCase.execute({
+ boardId: "members",
+ description: "Announcement without image",
+ permanent: true,
+ });
+
+ assertEquals(result.boardId, "members");
+ assertEquals(result.title, undefined);
+ assertEquals(result.imagePath, undefined);
+ assertEquals(result.description, "Announcement without image");
+ assertEquals(result.permanent, true);
+ assertEquals(imageStorage.saved, null);
+});
--- /dev/null
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+
+export class DeleteBoardAnnouncementUseCase {
+ constructor(private readonly announcementRepository: BoardAnnouncementRepository) {}
+
+ async execute(id: string): Promise<void> {
+ await this.announcementRepository.delete(id);
+ }
+}
--- /dev/null
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export class GetBoardBySlugUseCase {
+ constructor(private readonly boardRepository: BoardRepository) {}
+
+ async execute(slug: string): Promise<Board> {
+ const board = await this.boardRepository.findBySlug(slug);
+ if (!board) {
+ throw new ApplicationError("Board not found.", 404);
+ }
+ return board;
+ }
+}
--- /dev/null
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+
+export interface BoardRequestOverview extends BoardRequest {
+ voted: boolean;
+}
+
+export class GetBoardRequestsOverviewUseCase {
+ constructor(
+ private readonly requestRepository: BoardRequestRepository,
+ private readonly voteRepository: BoardRequestVoteRepository,
+ ) {}
+
+ async execute(userId: string): Promise<BoardRequestOverview[]> {
+ const requests = await this.requestRepository.list();
+ const results: BoardRequestOverview[] = [];
+ for (const request of requests) {
+ const voted = await this.voteRepository.hasVote(request.id, userId);
+ const voteCount = await this.voteRepository.countVotes(request.id);
+ if (voteCount !== request.voteCount) {
+ request.voteCount = voteCount;
+ await this.requestRepository.update(request);
+ }
+ results.push({ ...request, voteCount, voted });
+ }
+ return results.sort((a, b) => a.deadline.localeCompare(b.deadline));
+ }
+}
--- /dev/null
+import { GetBoardRequestsOverviewUseCase } from "./get_board_requests_overview_use_case.ts";
+import {
+ InMemoryBoardRequestRepository,
+ InMemoryBoardRequestVoteRepository,
+} from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+Deno.test("GetBoardRequestsOverviewUseCase merges vote counts and flags voted requests", async () => {
+ const requestRepository = new InMemoryBoardRequestRepository();
+ const voteRepository = new InMemoryBoardRequestVoteRepository();
+ const useCase = new GetBoardRequestsOverviewUseCase(requestRepository, voteRepository);
+
+ await requestRepository.create({
+ id: "req-1",
+ slug: "alpha",
+ name: "Alpha",
+ description: "",
+ themeColor: "#111111",
+ voteCount: 5,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ deadline: "2024-01-10T00:00:00.000Z",
+ status: "open",
+ });
+
+ await requestRepository.create({
+ id: "req-2",
+ slug: "beta",
+ name: "Beta",
+ description: "",
+ themeColor: "#222222",
+ voteCount: 0,
+ createdAt: "2024-01-02T00:00:00.000Z",
+ deadline: "2024-01-05T00:00:00.000Z",
+ status: "open",
+ });
+
+ await voteRepository.addVote("req-1", "user-1");
+ await voteRepository.addVote("req-1", "user-2");
+ await voteRepository.addVote("req-2", "user-1");
+
+ const overview = await useCase.execute("user-1");
+ assertEquals(overview.length, 2);
+ // Sorted by deadline ascending, so req-2 first
+ assertEquals(overview[0].id, "req-2");
+ assertEquals(overview[0].voteCount, 1);
+ assertEquals(overview[0].voted, true);
+
+ assertEquals(overview[1].id, "req-1");
+ assertEquals(overview[1].voteCount, 2);
+ assertEquals(overview[1].voted, true);
+
+ const stored = await requestRepository.findById("req-1");
+ assertEquals(stored?.voteCount, 2);
+});
--- /dev/null
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export class GetPostUseCase {
+ constructor(private readonly postRepository: PostRepository) {}
+
+ async execute(id: string): Promise<ImagePost> {
+ const post = await this.postRepository.findById(id);
+ if (!post) {
+ throw new ApplicationError("Post not found.", 404);
+ }
+
+ return post;
+ }
+}
--- /dev/null
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+
+export class ListArchivedPostsUseCase {
+ constructor(private readonly postRepository: PostRepository) {}
+
+ async execute(boardId: string): Promise<ImagePost[]> {
+ const posts = await this.postRepository.listByBoard(boardId);
+ return posts
+ .map((post) => ({
+ ...post,
+ status: post.status ?? "active",
+ readOnly: post.readOnly ?? false,
+ permanent: post.permanent ?? false,
+ }))
+ .filter((post) => post.status === "archived")
+ .sort((a, b) => (b.archivedAt ?? b.createdAt).localeCompare(a.archivedAt ?? a.createdAt));
+ }
+}
--- /dev/null
+import { BoardAnnouncement } from "../../domain/entities/board_announcement.ts";
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+
+export class ListBoardAnnouncementsUseCase {
+ constructor(private readonly announcementRepository: BoardAnnouncementRepository) {}
+
+ async execute(limit = 10): Promise<BoardAnnouncement[]> {
+ const announcements = await this.announcementRepository.list();
+ return announcements.slice(0, limit);
+ }
+}
--- /dev/null
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+
+export class ListBoardRequestsUseCase {
+ constructor(private readonly boardRequestRepository: BoardRequestRepository) {}
+
+ async execute(): Promise<BoardRequest[]> {
+ const requests = await this.boardRequestRepository.list();
+ return requests.sort((a, b) => a.deadline.localeCompare(b.deadline));
+ }
+}
--- /dev/null
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+
+export class ListBoardsUseCase {
+ constructor(private readonly boardRepository: BoardRepository) {}
+
+ async execute(): Promise<Board[]> {
+ const boards = await this.boardRepository.list();
+ return boards.sort((a, b) => a.slug.localeCompare(b.slug));
+ }
+}
--- /dev/null
+import { Comment } from "../../domain/entities/comment.ts";
+import { CommentRepository } from "../../domain/repositories/comment_repository.ts";
+
+export class ListCommentsUseCase {
+ constructor(private readonly commentRepository: CommentRepository) {}
+
+ async execute(postId: string): Promise<Comment[]> {
+ const comments = await this.commentRepository.listByPost(postId);
+ return comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+ }
+}
--- /dev/null
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ImagePost } from "../../domain/entities/image_post.ts";
+
+export interface PopularPost {
+ post: ImagePost;
+ boardSlug: string;
+ boardName: string;
+}
+
+export class ListPopularPostsUseCase {
+ constructor(
+ private readonly postRepository: PostRepository,
+ private readonly boardRepository: BoardRepository,
+ ) {}
+
+ async execute(limit = 10): Promise<PopularPost[]> {
+ const posts = await this.postRepository.listAll();
+ const sorted = posts
+ .map((post) => ({
+ ...post,
+ status: post.status ?? "active",
+ permanent: post.permanent ?? false,
+ }))
+ .filter((post) => post.status === "active")
+ .sort((a, b) => b.commentCount - a.commentCount || b.createdAt.localeCompare(a.createdAt))
+ .slice(0, limit);
+
+ const results: PopularPost[] = [];
+ for (const post of sorted) {
+ const board = await this.boardRepository.findById(post.boardId);
+ if (!board) {
+ continue;
+ }
+ results.push({
+ post,
+ boardSlug: board.slug,
+ boardName: board.name,
+ });
+ }
+
+ return results;
+ }
+}
--- /dev/null
+import { ListPopularPostsUseCase } from "./list_popular_posts_use_case.ts";
+import { InMemoryBoardRepository, InMemoryPostRepository } from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+Deno.test("ListPopularPostsUseCase sorts by comment count and excludes missing boards", async () => {
+ const postRepository = new InMemoryPostRepository();
+ const boardRepository = new InMemoryBoardRepository();
+ const useCase = new ListPopularPostsUseCase(postRepository, boardRepository);
+
+ await postRepository.create({
+ id: "post-a",
+ boardId: "board-a",
+ title: "A",
+ description: "",
+ imagePath: "",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ commentCount: 10,
+ status: "active",
+ readOnly: false,
+ archivedAt: null,
+ });
+
+ await postRepository.create({
+ id: "post-b",
+ boardId: "board-b",
+ title: "B",
+ description: "",
+ imagePath: "",
+ createdAt: "2024-01-02T00:00:00.000Z",
+ commentCount: 5,
+ status: "active",
+ readOnly: false,
+ archivedAt: null,
+ });
+
+ await postRepository.create({
+ id: "post-c",
+ boardId: "board-c",
+ title: "C",
+ description: "",
+ imagePath: "",
+ createdAt: "2024-01-03T00:00:00.000Z",
+ commentCount: 20,
+ status: "archived",
+ readOnly: true,
+ archivedAt: "2024-01-04T00:00:00.000Z",
+ });
+
+ await boardRepository.create({
+ id: "board-a",
+ slug: "a",
+ name: "Board A",
+ description: "",
+ themeColor: "#111111",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ status: "active",
+ });
+
+ await boardRepository.create({
+ id: "board-b",
+ slug: "b",
+ name: "Board B",
+ description: "",
+ themeColor: "#111111",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ status: "active",
+ });
+
+ const popular = await useCase.execute(5);
+ assertEquals(popular.length, 2);
+ assertEquals(popular[0].post.id, "post-a");
+ assertEquals(popular[0].boardSlug, "a");
+ assertEquals(popular[0].boardName, "Board A");
+ assertEquals(popular[1].post.id, "post-b");
+});
--- /dev/null
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+
+export class ListPostsUseCase {
+ constructor(private readonly postRepository: PostRepository) {}
+
+ async execute(boardId: string): Promise<ImagePost[]> {
+ const posts = await this.postRepository.listByBoard(boardId);
+ return posts
+ .map((post) => ({
+ ...post,
+ status: post.status ?? "active",
+ readOnly: post.readOnly ?? false,
+ permanent: post.permanent ?? false,
+ }))
+ .filter((post) => post.status === "active")
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ }
+}
--- /dev/null
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+import { User } from "../../domain/entities/user.ts";
+import { hashPassword } from "../utils/password_hash.ts";
+
+export interface RegisterUserCommand {
+ username: string;
+ password: string;
+}
+
+const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,20}$/;
+
+export class RegisterUserUseCase {
+ constructor(
+ private readonly userRepository: UserRepository,
+ private readonly idGenerator: IdGenerator,
+ private readonly clock: Clock,
+ ) {}
+
+ async execute(command: RegisterUserCommand): Promise<User> {
+ const username = command.username?.trim();
+ const password = command.password ?? "";
+
+ if (!username || !USERNAME_PATTERN.test(username)) {
+ throw new ApplicationError(
+ "Username must be 3-20 characters and use letters, numbers, or underscores.",
+ );
+ }
+
+ if (password.length < 4) {
+ throw new ApplicationError("Password must be at least 4 characters long.");
+ }
+
+ const usernameTaken = await this.userRepository.isUsernameTaken(username);
+ if (usernameTaken) {
+ throw new ApplicationError("That username is already taken.");
+ }
+
+ const passwordHash = await hashPassword(password);
+ const user: User = {
+ id: this.idGenerator.generate(),
+ username,
+ passwordHash,
+ createdAt: this.clock.now().toISOString(),
+ };
+
+ await this.userRepository.create(user);
+ return user;
+ }
+}
--- /dev/null
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export interface ToggleBoardRequestVoteCommand {
+ requestId: string;
+ userId: string;
+}
+
+export class ToggleBoardRequestVoteUseCase {
+ constructor(
+ private readonly requestRepository: BoardRequestRepository,
+ private readonly voteRepository: BoardRequestVoteRepository,
+ ) {}
+
+ async execute(
+ command: ToggleBoardRequestVoteCommand,
+ ): Promise<{ voteCount: number; voted: boolean }> {
+ const request = await this.requestRepository.findById(command.requestId);
+ if (!request) {
+ throw new ApplicationError("Request not found.", 404);
+ }
+
+ if (request.status !== "open") {
+ throw new ApplicationError("Voting closed for this request.");
+ }
+
+ const hasVote = await this.voteRepository.hasVote(command.requestId, command.userId);
+ if (hasVote) {
+ await this.voteRepository.removeVote(command.requestId, command.userId);
+ } else {
+ await this.voteRepository.addVote(command.requestId, command.userId);
+ }
+
+ const voteCount = await this.voteRepository.countVotes(command.requestId);
+ request.voteCount = voteCount;
+ await this.requestRepository.update(request);
+ return { voteCount, voted: !hasVote };
+ }
+}
--- /dev/null
+import { ToggleBoardRequestVoteUseCase } from "./toggle_board_request_vote_use_case.ts";
+import {
+ InMemoryBoardRequestRepository,
+ InMemoryBoardRequestVoteRepository,
+} from "../../testing/fakes.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const buildRequest = (overrides: Partial<Record<string, unknown>> = {}) => ({
+ id: "request-1",
+ slug: "tech",
+ name: "Tech",
+ description: "",
+ themeColor: "#123456",
+ voteCount: 0,
+ createdAt: new Date("2024-01-01T00:00:00.000Z").toISOString(),
+ deadline: new Date("2024-01-08T00:00:00.000Z").toISOString(),
+ status: "open" as const,
+ ...overrides,
+});
+
+Deno.test("ToggleBoardRequestVoteUseCase validates request status", async (t) => {
+ const requestRepository = new InMemoryBoardRequestRepository();
+ const voteRepository = new InMemoryBoardRequestVoteRepository();
+ const useCase = new ToggleBoardRequestVoteUseCase(requestRepository, voteRepository);
+
+ await t.step("request must exist", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ requestId: "missing",
+ userId: "user-1",
+ }),
+ Error,
+ "Request not found.",
+ );
+ });
+
+ await requestRepository.create(buildRequest({ status: "closed" }));
+
+ await t.step("request must be open for voting", async () => {
+ await assertRejects(
+ () =>
+ useCase.execute({
+ requestId: "request-1",
+ userId: "user-1",
+ }),
+ Error,
+ "Voting closed for this request.",
+ );
+ });
+});
+
+Deno.test("ToggleBoardRequestVoteUseCase toggles votes and updates counts", async () => {
+ const requestRepository = new InMemoryBoardRequestRepository();
+ const voteRepository = new InMemoryBoardRequestVoteRepository();
+ const useCase = new ToggleBoardRequestVoteUseCase(requestRepository, voteRepository);
+
+ await requestRepository.create(buildRequest());
+
+ const first = await useCase.execute({
+ requestId: "request-1",
+ userId: "user-1",
+ });
+
+ assertEquals(first, { voteCount: 1, voted: true });
+
+ const storedAfterFirst = await requestRepository.findById("request-1");
+ assertEquals(storedAfterFirst?.voteCount, 1);
+
+ const second = await useCase.execute({
+ requestId: "request-1",
+ userId: "user-1",
+ });
+
+ assertEquals(second, { voteCount: 0, voted: false });
+
+ const storedAfterSecond = await requestRepository.findById("request-1");
+ assertEquals(storedAfterSecond?.voteCount, 0);
+});
--- /dev/null
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export class UpdateBoardRequestStatusUseCase {
+ constructor(private readonly boardRequestRepository: BoardRequestRepository) {}
+
+ async execute(requestId: string, status: "open" | "closed" | "fulfilled"): Promise<void> {
+ const request = await this.boardRequestRepository.findById(requestId);
+ if (!request) {
+ throw new ApplicationError("Request not found.", 404);
+ }
+ request.status = status;
+ await this.boardRequestRepository.update(request);
+ }
+}
--- /dev/null
+const textEncoder = new TextEncoder();
+
+export const hashPassword = async (password: string): Promise<string> => {
+ const data = textEncoder.encode(password);
+ const digest = await crypto.subtle.digest("SHA-256", data);
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join(
+ "",
+ );
+};
+
+export const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
+ const computed = await hashPassword(password);
+ return timingSafeCompare(computed, hash);
+};
+
+const timingSafeCompare = (a: string, b: string): boolean => {
+ if (a.length !== b.length) {
+ return false;
+ }
+ let mismatch = 0;
+ for (let i = 0; i < a.length; i += 1) {
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ }
+ return mismatch === 0;
+};
--- /dev/null
+import { type AppConfig, loadAppConfig } from "../config/app_config.ts";
+import { KvBoardRepository } from "../infrastructure/kv/kv_board_repository.ts";
+import { KvBoardRequestRepository } from "../infrastructure/kv/kv_board_request_repository.ts";
+import { KvBoardRequestVoteRepository } from "../infrastructure/kv/kv_board_request_vote_repository.ts";
+import { KvBoardAnnouncementRepository } from "../infrastructure/kv/kv_board_announcement_repository.ts";
+import { KvPostRepository } from "../infrastructure/kv/kv_post_repository.ts";
+import { KvCommentRepository } from "../infrastructure/kv/kv_comment_repository.ts";
+import { KvUserRepository } from "../infrastructure/kv/kv_user_repository.ts";
+import { KvSessionStore } from "../infrastructure/kv/kv_session_store.ts";
+import { FileSystemImageStorage } from "../infrastructure/storage/file_system_image_storage.ts";
+import { CryptoIdGenerator } from "../infrastructure/system/crypto_id_generator.ts";
+import { SystemClock } from "../infrastructure/system/system_clock.ts";
+import { KvRateLimiter } from "../infrastructure/system/kv_rate_limiter.ts";
+import { CreateBoardUseCase } from "../application/usecases/create_board_use_case.ts";
+import { ListBoardsUseCase } from "../application/usecases/list_boards_use_case.ts";
+import { CreateBoardRequestUseCase } from "../application/usecases/create_board_request_use_case.ts";
+import { ToggleBoardRequestVoteUseCase } from "../application/usecases/toggle_board_request_vote_use_case.ts";
+import { GetBoardBySlugUseCase } from "../application/usecases/get_board_by_slug_use_case.ts";
+import { GetBoardRequestsOverviewUseCase } from "../application/usecases/get_board_requests_overview_use_case.ts";
+import { ListBoardAnnouncementsUseCase } from "../application/usecases/list_board_announcements_use_case.ts";
+import { ListPopularPostsUseCase } from "../application/usecases/list_popular_posts_use_case.ts";
+import { UpdateBoardRequestStatusUseCase } from "../application/usecases/update_board_request_status_use_case.ts";
+import { AutoCreateBoardsFromRequestsUseCase } from "../application/usecases/auto_create_boards_from_requests_use_case.ts";
+import { CreatePostUseCase } from "../application/usecases/create_post_use_case.ts";
+import { ListPostsUseCase } from "../application/usecases/list_posts_use_case.ts";
+import { GetPostUseCase } from "../application/usecases/get_post_use_case.ts";
+import { CreateCommentUseCase } from "../application/usecases/create_comment_use_case.ts";
+import { ListCommentsUseCase } from "../application/usecases/list_comments_use_case.ts";
+import { ArchiveExpiredPostsUseCase } from "../application/usecases/archive_expired_posts_use_case.ts";
+import { ListArchivedPostsUseCase } from "../application/usecases/list_archived_posts_use_case.ts";
+import { ListBoardRequestsUseCase } from "../application/usecases/list_board_requests_use_case.ts";
+import { CreateBoardAnnouncementUseCase } from "../application/usecases/create_board_announcement_use_case.ts";
+import { DeleteBoardAnnouncementUseCase } from "../application/usecases/delete_board_announcement_use_case.ts";
+import { RateLimiter } from "../domain/services/rate_limiter.ts";
+import { SessionStore } from "../domain/services/session_store.ts";
+import { RegisterUserUseCase } from "../application/usecases/register_user_use_case.ts";
+import { AuthenticateUserUseCase } from "../application/usecases/authenticate_user_use_case.ts";
+
+export interface AppContainer {
+ config: AppConfig;
+ kv: Deno.Kv;
+ repositories: {
+ boardRepository: KvBoardRepository;
+ boardRequestRepository: KvBoardRequestRepository;
+ boardRequestVoteRepository: KvBoardRequestVoteRepository;
+ boardAnnouncementRepository: KvBoardAnnouncementRepository;
+ postRepository: KvPostRepository;
+ commentRepository: KvCommentRepository;
+ userRepository: KvUserRepository;
+ };
+ services: {
+ clock: SystemClock;
+ idGenerator: CryptoIdGenerator;
+ imageStorage: FileSystemImageStorage;
+ rateLimiter: RateLimiter;
+ sessionStore: SessionStore;
+ };
+ useCases: {
+ createBoardUseCase: CreateBoardUseCase;
+ listBoardsUseCase: ListBoardsUseCase;
+ createBoardRequestUseCase: CreateBoardRequestUseCase;
+ listBoardRequestsUseCase: ListBoardRequestsUseCase;
+ toggleBoardRequestVoteUseCase: ToggleBoardRequestVoteUseCase;
+ getBoardBySlugUseCase: GetBoardBySlugUseCase;
+ getBoardRequestsOverviewUseCase: GetBoardRequestsOverviewUseCase;
+ listBoardAnnouncementsUseCase: ListBoardAnnouncementsUseCase;
+ createBoardAnnouncementUseCase: CreateBoardAnnouncementUseCase;
+ deleteBoardAnnouncementUseCase: DeleteBoardAnnouncementUseCase;
+ listPopularPostsUseCase: ListPopularPostsUseCase;
+ updateBoardRequestStatusUseCase: UpdateBoardRequestStatusUseCase;
+ autoCreateBoardsUseCase: AutoCreateBoardsFromRequestsUseCase;
+ createPostUseCase: CreatePostUseCase;
+ listPostsUseCase: ListPostsUseCase;
+ getPostUseCase: GetPostUseCase;
+ createCommentUseCase: CreateCommentUseCase;
+ listCommentsUseCase: ListCommentsUseCase;
+ archiveExpiredPostsUseCase: ArchiveExpiredPostsUseCase;
+ listArchivedPostsUseCase: ListArchivedPostsUseCase;
+ registerUserUseCase: RegisterUserUseCase;
+ authenticateUserUseCase: AuthenticateUserUseCase;
+ };
+ close(): void;
+}
+
+export interface CreateAppContainerOptions {
+ configPath?: string;
+ kv?: Deno.Kv;
+}
+
+export const createAppContainer = async (
+ options: CreateAppContainerOptions = {},
+): Promise<AppContainer> => {
+ const config = await loadAppConfig(options.configPath);
+ const kv = options.kv ?? await Deno.openKv();
+ const boardRepository = new KvBoardRepository(kv);
+ const boardRequestRepository = new KvBoardRequestRepository(kv);
+ const boardRequestVoteRepository = new KvBoardRequestVoteRepository(kv);
+ const boardAnnouncementRepository = new KvBoardAnnouncementRepository(kv);
+ const postRepository = new KvPostRepository(kv);
+ const commentRepository = new KvCommentRepository(kv);
+ const userRepository = new KvUserRepository(kv);
+ const sessionStore = new KvSessionStore(kv);
+
+ const clock = new SystemClock();
+ const idGenerator = new CryptoIdGenerator();
+ const imageStorage = new FileSystemImageStorage({
+ root: config.app.storageRoot,
+ publicBasePath: config.app.uploadsBasePath,
+ });
+ const rateLimiter = new KvRateLimiter(kv);
+
+ const createBoardUseCase = new CreateBoardUseCase(boardRepository, idGenerator, clock);
+ const listBoardsUseCase = new ListBoardsUseCase(boardRepository);
+ const updateBoardRequestStatusUseCase = new UpdateBoardRequestStatusUseCase(
+ boardRequestRepository,
+ );
+ const autoCreateBoardsUseCase = new AutoCreateBoardsFromRequestsUseCase(
+ boardRequestRepository,
+ boardRequestVoteRepository,
+ boardRepository,
+ createBoardUseCase,
+ updateBoardRequestStatusUseCase,
+ );
+
+ const createBoardRequestUseCase = new CreateBoardRequestUseCase(
+ boardRequestRepository,
+ boardRepository,
+ idGenerator,
+ clock,
+ config.app.boardRequestWindowDays,
+ );
+
+ const createPostUseCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+ const listPostsUseCase = new ListPostsUseCase(postRepository);
+ const getPostUseCase = new GetPostUseCase(postRepository);
+ const createCommentUseCase = new CreateCommentUseCase(
+ postRepository,
+ commentRepository,
+ imageStorage,
+ idGenerator,
+ clock,
+ userRepository,
+ );
+ const listCommentsUseCase = new ListCommentsUseCase(commentRepository);
+ const archiveExpiredPostsUseCase = new ArchiveExpiredPostsUseCase(
+ postRepository,
+ clock,
+ config.app.threadTtlDays * 24 * 60 * 60 * 1000,
+ );
+ const listArchivedPostsUseCase = new ListArchivedPostsUseCase(postRepository);
+
+ const listBoardRequestsUseCase = new ListBoardRequestsUseCase(boardRequestRepository);
+ const toggleBoardRequestVoteUseCase = new ToggleBoardRequestVoteUseCase(
+ boardRequestRepository,
+ boardRequestVoteRepository,
+ );
+ const getBoardBySlugUseCase = new GetBoardBySlugUseCase(boardRepository);
+ const getBoardRequestsOverviewUseCase = new GetBoardRequestsOverviewUseCase(
+ boardRequestRepository,
+ boardRequestVoteRepository,
+ );
+ const listBoardAnnouncementsUseCase = new ListBoardAnnouncementsUseCase(
+ boardAnnouncementRepository,
+ );
+ const createBoardAnnouncementUseCase = new CreateBoardAnnouncementUseCase(
+ boardAnnouncementRepository,
+ idGenerator,
+ clock,
+ );
+ const deleteBoardAnnouncementUseCase = new DeleteBoardAnnouncementUseCase(
+ boardAnnouncementRepository,
+ );
+ const listPopularPostsUseCase = new ListPopularPostsUseCase(postRepository, boardRepository);
+ const registerUserUseCase = new RegisterUserUseCase(userRepository, idGenerator, clock);
+ const authenticateUserUseCase = new AuthenticateUserUseCase(userRepository);
+
+ const close = () => {
+ if (!options.kv) {
+ kv.close();
+ }
+ };
+
+ return {
+ config,
+ kv,
+ repositories: {
+ boardRepository,
+ boardRequestRepository,
+ boardRequestVoteRepository,
+ boardAnnouncementRepository,
+ postRepository,
+ commentRepository,
+ userRepository,
+ },
+ services: {
+ clock,
+ idGenerator,
+ imageStorage,
+ rateLimiter,
+ sessionStore,
+ },
+ useCases: {
+ createBoardUseCase,
+ listBoardsUseCase,
+ createBoardRequestUseCase,
+ listBoardRequestsUseCase,
+ toggleBoardRequestVoteUseCase,
+ getBoardBySlugUseCase,
+ getBoardRequestsOverviewUseCase,
+ listBoardAnnouncementsUseCase,
+ createBoardAnnouncementUseCase,
+ deleteBoardAnnouncementUseCase,
+ listPopularPostsUseCase,
+ updateBoardRequestStatusUseCase,
+ autoCreateBoardsUseCase,
+ createPostUseCase,
+ listPostsUseCase,
+ getPostUseCase,
+ createCommentUseCase,
+ listCommentsUseCase,
+ archiveExpiredPostsUseCase,
+ listArchivedPostsUseCase,
+ registerUserUseCase,
+ authenticateUserUseCase,
+ },
+ close,
+ };
+};
+
+export const ensureDefaultBoardsExist = async (container: AppContainer) => {
+ const ensureBoard = async (
+ boardConfig: AppConfig["defaultBoard"],
+ label: string,
+ ) => {
+ const existingBoard = await container.repositories.boardRepository.findBySlug(
+ boardConfig.slug,
+ );
+ if (existingBoard) {
+ return existingBoard;
+ }
+ const created = await container.useCases.createBoardUseCase.execute({
+ slug: boardConfig.slug,
+ name: boardConfig.name,
+ description: boardConfig.description,
+ themeColor: boardConfig.themeColor,
+ });
+ console.log(`Created ${label} board /${created.slug}/`);
+ return created;
+ };
+
+ const defaultBoard = await ensureBoard(container.config.defaultBoard, "default");
+ const memberBoard = await ensureBoard(container.config.memberBoard, "member");
+
+ const welcomePost = container.config.memberBoard.welcomePost;
+ if (welcomePost) {
+ const existingPosts = await container.repositories.postRepository.listByBoard(memberBoard.id);
+ if (existingPosts.length === 0) {
+ await container.useCases.createPostUseCase.execute({
+ boardId: memberBoard.id,
+ title: welcomePost.title,
+ description: welcomePost.body,
+ permanent: welcomePost.permanent ?? true,
+ });
+ console.log(`Seeded welcome post for /${memberBoard.slug}/ board.`);
+ }
+ }
+
+ return defaultBoard;
+};
--- /dev/null
+import { dirname, fromFileUrl, isAbsolute, normalize, resolve } from "jsr:@std/path@^1.0.0";
+
+export interface AppConfig {
+ app: {
+ name: string;
+ description: string;
+ homeHeading: string;
+ publicDir: string;
+ storageRoot: string;
+ assetsBasePath: string;
+ uploadsBasePath: string;
+ threadTtlDays: number;
+ boardRequestWindowDays: number;
+ rateLimits: RateLimitConfig;
+ footerText: string;
+ };
+ defaultBoard: BoardConfig;
+ memberBoard: MemberBoardConfig;
+}
+
+export interface RateLimitConfig {
+ createPost: RateLimitRuleConfig;
+ createComment: RateLimitRuleConfig;
+ createBoardRequest: RateLimitRuleConfig;
+}
+
+export interface RateLimitRuleConfig {
+ limit: number;
+ windowMs: number;
+}
+
+export type BoardConfig = {
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+};
+
+export interface MemberBoardConfig extends BoardConfig {
+ welcomePost?: InitialPostConfig;
+}
+
+export interface InitialPostConfig {
+ title?: string;
+ body: string;
+ image?: string;
+ permanent?: boolean;
+}
+
+const DEFAULT_CONFIG_PATH = normalize(
+ fromFileUrl(new URL("../../config/app.config.json", import.meta.url)),
+);
+
+const toNumber = (value: unknown, fallbackMessage: string): number => {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string" && value.trim().length > 0) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ throw new Error(fallbackMessage);
+};
+
+const requireString = (value: unknown, message: string): string => {
+ if (typeof value === "string" && value.trim().length > 0) {
+ return value.trim();
+ }
+ throw new Error(message);
+};
+
+const resolvePath = (baseDir: string, value: string): string => {
+ const target = isAbsolute(value) ? value : resolve(baseDir, value);
+ return normalize(target);
+};
+
+const sanitizeBasePath = (value: string, field: string): string => {
+ const trimmed = value.trim();
+ if (trimmed.length === 0) {
+ throw new Error(`${field} must not be empty.`);
+ }
+ return `/${trimmed.replace(/^\/+/, "").replace(/\/+$/, "")}`;
+};
+
+const parseRateLimitRule = (field: string, value: unknown): RateLimitRuleConfig => {
+ if (!value || typeof value !== "object") {
+ throw new Error(`${field} must be an object with limit and windowSeconds.`);
+ }
+ const rule = value as Record<string, unknown>;
+ const limit = toNumber(rule.limit, `${field}.limit must be a number.`);
+ const windowSeconds = toNumber(rule.windowSeconds, `${field}.windowSeconds must be a number.`);
+ if (limit <= 0) {
+ throw new Error(`${field}.limit must be greater than zero.`);
+ }
+ if (windowSeconds <= 0) {
+ throw new Error(`${field}.windowSeconds must be greater than zero.`);
+ }
+ return {
+ limit,
+ windowMs: windowSeconds * 1000,
+ };
+};
+
+const parseBoardConfig = (
+ section: Record<string, unknown>,
+ field: string,
+): BoardConfig => {
+ const slug = requireString(
+ section.slug,
+ `${field}.slug must be a non-empty string.`,
+ );
+ const name = requireString(
+ section.name,
+ `${field}.name must be a non-empty string.`,
+ );
+ const description = typeof section.description === "string" ? section.description.trim() : "";
+ const themeColor = requireString(
+ section.themeColor,
+ `${field}.themeColor must be a non-empty string.`,
+ );
+ return {
+ slug,
+ name,
+ description,
+ themeColor,
+ };
+};
+
+const parseWelcomePost = (
+ field: string,
+ value: unknown,
+): InitialPostConfig | undefined => {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+ if (typeof value !== "object") {
+ throw new Error(
+ `${field} must be an object with body, optional title, image, and permanent fields.`,
+ );
+ }
+ const record = value as Record<string, unknown>;
+ const body = requireString(
+ record.body,
+ `${field}.body must be a non-empty string.`,
+ );
+ const title = typeof record.title === "string" ? record.title.trim() : "";
+ const image = typeof record.image === "string" && record.image.trim().length > 0
+ ? record.image.trim()
+ : undefined;
+ const permanent = typeof record.permanent === "boolean" ? record.permanent : false;
+ return {
+ body,
+ title: title.length > 0 ? title : undefined,
+ image,
+ permanent,
+ };
+};
+
+export const loadAppConfig = async (path?: string): Promise<AppConfig> => {
+ const resolvedPath = path
+ ? normalize(isAbsolute(path) ? path : resolve(Deno.cwd(), path))
+ : DEFAULT_CONFIG_PATH;
+
+ const baseDir = dirname(resolvedPath);
+ let parsed: unknown;
+ try {
+ const raw = await Deno.readTextFile(resolvedPath);
+ parsed = JSON.parse(raw);
+ } catch (error) {
+ throw new Error(
+ `Unable to read configuration from ${resolvedPath}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ }
+
+ if (typeof parsed !== "object" || parsed === null) {
+ throw new Error("Configuration file must contain a JSON object.");
+ }
+
+ const appSection = (parsed as Record<string, unknown>).app;
+ const defaultBoardSection = (parsed as Record<string, unknown>).defaultBoard;
+ const memberBoardSection = (parsed as Record<string, unknown>).memberBoard;
+
+ if (!appSection || typeof appSection !== "object") {
+ throw new Error("Configuration is missing the app section.");
+ }
+ if (!defaultBoardSection || typeof defaultBoardSection !== "object") {
+ throw new Error("Configuration is missing the defaultBoard section.");
+ }
+ if (!memberBoardSection || typeof memberBoardSection !== "object") {
+ throw new Error("Configuration is missing the memberBoard section.");
+ }
+
+ const app = appSection as Record<string, unknown>;
+ const defaultBoard = defaultBoardSection as Record<string, unknown>;
+ const memberBoard = memberBoardSection as Record<string, unknown>;
+
+ const appName = requireString(app.name, "app.name must be a non-empty string.");
+ const appDescription = requireString(
+ app.description,
+ "app.description must be a non-empty string.",
+ );
+ const homeHeading = requireString(
+ app.homeHeading,
+ "app.homeHeading must be a non-empty string.",
+ );
+ const publicDir = resolvePath(
+ baseDir,
+ requireString(app.publicDir, "app.publicDir must be provided."),
+ );
+ const storageRoot = resolvePath(
+ baseDir,
+ requireString(app.storageRoot, "app.storageRoot must be provided."),
+ );
+ const uploadsBasePath = sanitizeBasePath(
+ requireString(app.uploadsBasePath, "app.uploadsBasePath must be provided."),
+ "app.uploadsBasePath",
+ );
+ const assetsBasePath = sanitizeBasePath(
+ typeof app.assetsBasePath === "string" ? app.assetsBasePath : "/assets",
+ "app.assetsBasePath",
+ );
+ const threadTtlDays = toNumber(app.threadTtlDays, "app.threadTtlDays must be a number.");
+ const boardRequestWindowDays = toNumber(
+ app.boardRequestWindowDays,
+ "app.boardRequestWindowDays must be a number.",
+ );
+ const rateLimitSection = (app.rateLimits ?? {}) as Record<string, unknown>;
+ const rateLimits: RateLimitConfig = {
+ createPost: parseRateLimitRule(
+ "app.rateLimits.createPost",
+ rateLimitSection.createPost ?? {
+ limit: 5,
+ windowSeconds: 60,
+ },
+ ),
+ createComment: parseRateLimitRule(
+ "app.rateLimits.createComment",
+ rateLimitSection.createComment ?? { limit: 10, windowSeconds: 60 },
+ ),
+ createBoardRequest: parseRateLimitRule(
+ "app.rateLimits.createBoardRequest",
+ rateLimitSection.createBoardRequest ?? { limit: 3, windowSeconds: 3600 },
+ ),
+ };
+
+ const footerText = requireString(app.footerText, "app.footerText must be a non-empty string.");
+
+ const defaultBoardConfig = parseBoardConfig(defaultBoard, "defaultBoard");
+ const memberBoardBase = parseBoardConfig(memberBoard, "memberBoard");
+ const welcomePost = parseWelcomePost(
+ "memberBoard.welcomePost",
+ memberBoard.welcomePost,
+ );
+ const memberBoardConfig: MemberBoardConfig = {
+ ...memberBoardBase,
+ welcomePost,
+ };
+
+ return {
+ app: {
+ name: appName,
+ description: appDescription,
+ homeHeading,
+ publicDir,
+ storageRoot,
+ assetsBasePath,
+ uploadsBasePath,
+ threadTtlDays,
+ boardRequestWindowDays,
+ rateLimits,
+ footerText,
+ },
+ defaultBoard: defaultBoardConfig,
+ memberBoard: memberBoardConfig,
+ };
+};
--- /dev/null
+import { join } from "jsr:@std/path@^1.0.0";
+import { loadAppConfig } from "./app_config.ts";
+import { assertEquals } from "../testing/asserts.ts";
+
+Deno.test("loadAppConfig resolves relative paths and normalizes values", async () => {
+ const tempDir = await Deno.makeTempDir();
+ try {
+ const configPath = join(tempDir, "config.json");
+ await Deno.writeTextFile(
+ configPath,
+ JSON.stringify({
+ app: {
+ name: "Test Board",
+ description: "Testing configuration loader.",
+ homeHeading: "Test Heading",
+ publicDir: "./public",
+ storageRoot: "./storage",
+ assetsBasePath: "/static",
+ uploadsBasePath: "uploads/",
+ threadTtlDays: 5,
+ boardRequestWindowDays: 2,
+ footerText: "Test footer text.",
+ },
+ defaultBoard: {
+ slug: "test",
+ name: "Test",
+ description: "A test board.",
+ themeColor: "#123456",
+ },
+ memberBoard: {
+ slug: "members",
+ name: "Members",
+ description: "Private area.",
+ themeColor: "#1E3A8A",
+ welcomePost: {
+ title: "Welcome",
+ body: "First member post.",
+ permanent: true,
+ },
+ },
+ }),
+ );
+
+ const config = await loadAppConfig(configPath);
+
+ assertEquals(config.app.name, "Test Board");
+ assertEquals(config.app.description, "Testing configuration loader.");
+ assertEquals(config.app.homeHeading, "Test Heading");
+ assertEquals(config.app.assetsBasePath, "/static");
+ assertEquals(config.app.threadTtlDays, 5);
+ assertEquals(config.app.boardRequestWindowDays, 2);
+ assertEquals(config.app.uploadsBasePath, "/uploads");
+ assertEquals(config.app.rateLimits.createPost.limit, 5);
+ assertEquals(config.app.rateLimits.createPost.windowMs, 60_000);
+ assertEquals(config.app.rateLimits.createComment.limit, 10);
+ assertEquals(config.app.rateLimits.createComment.windowMs, 60_000);
+ assertEquals(config.app.rateLimits.createBoardRequest.limit, 3);
+ assertEquals(config.app.rateLimits.createBoardRequest.windowMs, 3_600_000);
+ assertEquals(config.defaultBoard.slug, "test");
+ assertEquals(config.defaultBoard.themeColor, "#123456");
+ assertEquals(config.app.footerText, "Test footer text.");
+ assertEquals(config.memberBoard.slug, "members");
+ assertEquals(config.memberBoard.name, "Members");
+ assertEquals(config.memberBoard.description, "Private area.");
+ assertEquals(config.memberBoard.welcomePost?.body, "First member post.");
+ assertEquals(config.memberBoard.welcomePost?.permanent, true);
+
+ // Paths should resolve relative to the config file.
+ assertEquals(config.app.publicDir.startsWith(tempDir), true);
+ assertEquals(config.app.storageRoot.startsWith(tempDir), true);
+ } finally {
+ await Deno.remove(tempDir, { recursive: true });
+ }
+});
--- /dev/null
+export interface Board {
+ id: string;
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+ createdAt: string;
+ status: "active";
+}
--- /dev/null
+export interface BoardAnnouncement {
+ id: string;
+ message: string;
+ createdAt: string;
+}
--- /dev/null
+export interface BoardRequest {
+ id: string;
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+ voteCount: number;
+ createdAt: string;
+ deadline: string;
+ status: "open" | "closed" | "fulfilled";
+}
--- /dev/null
+export interface BoardRequestVote {
+ requestId: string;
+ userId: string;
+ createdAt: string;
+}
--- /dev/null
+export interface Comment {
+ id: string;
+ postId: string;
+ parentCommentId?: string;
+ author: string;
+ authorUserId?: string;
+ body: string;
+ imagePath?: string;
+ createdAt: string;
+}
--- /dev/null
+export interface ImagePost {
+ id: string;
+ boardId: string;
+ description: string;
+ createdAt: string;
+ commentCount: number;
+ status: "active" | "archived";
+ readOnly: boolean;
+ title?: string;
+ imagePath?: string | null;
+ archivedAt?: string | null;
+ permanent?: boolean;
+}
--- /dev/null
+export interface User {
+ id: string;
+ username: string;
+ passwordHash: string;
+ createdAt: string;
+}
--- /dev/null
+import { BoardAnnouncement } from "../entities/board_announcement.ts";
+
+export interface BoardAnnouncementRepository {
+ list(): Promise<BoardAnnouncement[]>;
+ create(announcement: BoardAnnouncement): Promise<void>;
+ delete(id: string): Promise<void>;
+}
--- /dev/null
+import { Board } from "../entities/board.ts";
+
+export interface BoardRepository {
+ create(board: Board): Promise<void>;
+ update(board: Board): Promise<void>;
+ findById(id: string): Promise<Board | null>;
+ findBySlug(slug: string): Promise<Board | null>;
+ list(): Promise<Board[]>;
+ delete(id: string): Promise<void>;
+}
--- /dev/null
+import { BoardRequest } from "../entities/board_request.ts";
+
+export interface BoardRequestRepository {
+ create(request: BoardRequest): Promise<void>;
+ update(request: BoardRequest): Promise<void>;
+ findById(id: string): Promise<BoardRequest | null>;
+ list(): Promise<BoardRequest[]>;
+ delete(id: string): Promise<void>;
+}
--- /dev/null
+export interface BoardRequestVoteRepository {
+ hasVote(requestId: string, userId: string): Promise<boolean>;
+ addVote(requestId: string, userId: string): Promise<void>;
+ removeVote(requestId: string, userId: string): Promise<void>;
+ countVotes(requestId: string): Promise<number>;
+}
--- /dev/null
+import { Comment } from "../entities/comment.ts";
+
+export interface CommentRepository {
+ create(comment: Comment): Promise<void>;
+ findById(postId: string, id: string): Promise<Comment | null>;
+ listByPost(postId: string): Promise<Comment[]>;
+ deleteByPost(postId: string): Promise<void>;
+ countByPost(postId: string): Promise<number>;
+}
--- /dev/null
+import { ImagePost } from "../entities/image_post.ts";
+
+export interface PostRepository {
+ create(post: ImagePost): Promise<void>;
+ findById(id: string): Promise<ImagePost | null>;
+ listAll(): Promise<ImagePost[]>;
+ listByBoard(boardId: string): Promise<ImagePost[]>;
+ update(post: ImagePost): Promise<void>;
+ delete(id: string): Promise<void>;
+}
--- /dev/null
+import { User } from "../entities/user.ts";
+
+export interface UserRepository {
+ create(user: User): Promise<void>;
+ findById(id: string): Promise<User | null>;
+ findByUsername(username: string): Promise<User | null>;
+ isUsernameTaken(username: string): Promise<boolean>;
+}
--- /dev/null
+export interface SaveImageInput {
+ id: string;
+ data: Uint8Array;
+ mimeType: string;
+ originalName: string;
+}
+
+export interface ImageStorage {
+ saveImage(input: SaveImageInput): Promise<string>;
+ getImage(path: string): Promise<Uint8Array>;
+ deleteImage(path: string): Promise<void>;
+}
--- /dev/null
+export interface RateLimitRule {
+ limit: number;
+ windowMs: number;
+}
+
+export interface RateLimiter {
+ consume(key: string, rule: RateLimitRule): Promise<boolean>;
+}
--- /dev/null
+export interface SessionData {
+ userId: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface SessionStore {
+ get(sessionId: string): Promise<SessionData | null>;
+ set(sessionId: string, data: SessionData): Promise<void>;
+ delete(sessionId: string): Promise<void>;
+}
--- /dev/null
+import { BoardAnnouncement } from "../../domain/entities/board_announcement.ts";
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+
+const ANNOUNCEMENT_PREFIX = ["announcement"] as const;
+
+export class KvBoardAnnouncementRepository implements BoardAnnouncementRepository {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async list(): Promise<BoardAnnouncement[]> {
+ const announcements: BoardAnnouncement[] = [];
+ for await (const entry of this.kv.list<BoardAnnouncement>({ prefix: ANNOUNCEMENT_PREFIX })) {
+ if (entry.value) {
+ announcements.push(entry.value);
+ }
+ }
+ return announcements.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ }
+
+ async create(announcement: BoardAnnouncement): Promise<void> {
+ await this.kv.set([...ANNOUNCEMENT_PREFIX, announcement.id], announcement);
+ }
+
+ async delete(id: string): Promise<void> {
+ await this.kv.delete([...ANNOUNCEMENT_PREFIX, id]);
+ }
+}
--- /dev/null
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+
+const BOARD_KEY_PREFIX = ["board"] as const;
+const BOARD_SLUG_INDEX_PREFIX = ["board_slug"] as const;
+
+export class KvBoardRepository implements BoardRepository {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async create(board: Board): Promise<void> {
+ await this.kv.atomic()
+ .set([...BOARD_KEY_PREFIX, board.id], board)
+ .set([...BOARD_SLUG_INDEX_PREFIX, board.slug], { id: board.id })
+ .commit();
+ }
+
+ async update(board: Board): Promise<void> {
+ await this.kv.atomic()
+ .set([...BOARD_KEY_PREFIX, board.id], board)
+ .set([...BOARD_SLUG_INDEX_PREFIX, board.slug], { id: board.id })
+ .commit();
+ }
+
+ async findById(id: string): Promise<Board | null> {
+ const result = await this.kv.get<Board>([...BOARD_KEY_PREFIX, id]);
+ return result.value ?? null;
+ }
+
+ async findBySlug(slug: string): Promise<Board | null> {
+ const index = await this.kv.get<{ id: string }>([...BOARD_SLUG_INDEX_PREFIX, slug]);
+ if (!index.value) {
+ return null;
+ }
+ return await this.findById(index.value.id);
+ }
+
+ async list(): Promise<Board[]> {
+ const boards: Board[] = [];
+ for await (const entry of this.kv.list<Board>({ prefix: BOARD_KEY_PREFIX })) {
+ if (entry.value) {
+ boards.push(entry.value);
+ }
+ }
+ return boards;
+ }
+
+ async delete(id: string): Promise<void> {
+ const board = await this.findById(id);
+ if (!board) {
+ return;
+ }
+ await this.kv.atomic()
+ .delete([...BOARD_KEY_PREFIX, id])
+ .delete([...BOARD_SLUG_INDEX_PREFIX, board.slug])
+ .commit();
+ }
+}
--- /dev/null
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+
+const BOARD_REQUEST_PREFIX = ["board_request"] as const;
+
+export class KvBoardRequestRepository implements BoardRequestRepository {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async create(request: BoardRequest): Promise<void> {
+ await this.kv.set([...BOARD_REQUEST_PREFIX, request.id], request);
+ }
+
+ async update(request: BoardRequest): Promise<void> {
+ await this.kv.set([...BOARD_REQUEST_PREFIX, request.id], request);
+ }
+
+ async findById(id: string): Promise<BoardRequest | null> {
+ const result = await this.kv.get<BoardRequest>([...BOARD_REQUEST_PREFIX, id]);
+ return result.value ?? null;
+ }
+
+ async list(): Promise<BoardRequest[]> {
+ const requests: BoardRequest[] = [];
+ for await (const entry of this.kv.list<BoardRequest>({ prefix: BOARD_REQUEST_PREFIX })) {
+ if (entry.value) {
+ requests.push(entry.value);
+ }
+ }
+ return requests;
+ }
+
+ async delete(id: string): Promise<void> {
+ await this.kv.delete([...BOARD_REQUEST_PREFIX, id]);
+ }
+}
--- /dev/null
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+
+const VOTE_PREFIX = ["board_request_vote"] as const;
+
+export class KvBoardRequestVoteRepository implements BoardRequestVoteRepository {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ private key(requestId: string, userId: string) {
+ return [...VOTE_PREFIX, requestId, userId] as const;
+ }
+
+ async hasVote(requestId: string, userId: string): Promise<boolean> {
+ const result = await this.kv.get(this.key(requestId, userId));
+ return result.value !== null;
+ }
+
+ async addVote(requestId: string, userId: string): Promise<void> {
+ await this.kv.set(this.key(requestId, userId), true);
+ }
+
+ async removeVote(requestId: string, userId: string): Promise<void> {
+ await this.kv.delete(this.key(requestId, userId));
+ }
+
+ async countVotes(requestId: string): Promise<number> {
+ let count = 0;
+ const prefix = [...VOTE_PREFIX, requestId] as const;
+ for await (const _ of this.kv.list({ prefix })) {
+ count += 1;
+ }
+ return count;
+ }
+}
--- /dev/null
+import { Comment } from "../../domain/entities/comment.ts";
+import { CommentRepository } from "../../domain/repositories/comment_repository.ts";
+
+const COMMENT_KEY_PREFIX = ["comment"] as const;
+
+export class KvCommentRepository implements CommentRepository {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async create(comment: Comment): Promise<void> {
+ await this.kv.set([...COMMENT_KEY_PREFIX, comment.postId, comment.id], comment);
+ }
+
+ async findById(postId: string, id: string): Promise<Comment | null> {
+ const record = await this.kv.get<Comment>([...COMMENT_KEY_PREFIX, postId, id]);
+ return record.value ?? null;
+ }
+
+ async listByPost(postId: string): Promise<Comment[]> {
+ const comments: Comment[] = [];
+ const prefix = [...COMMENT_KEY_PREFIX, postId] as const;
+ for await (const entry of this.kv.list<Comment>({ prefix })) {
+ if (entry.value) {
+ comments.push(entry.value);
+ }
+ }
+
+ return comments;
+ }
+
+ async deleteByPost(postId: string): Promise<void> {
+ const prefix = [...COMMENT_KEY_PREFIX, postId] as const;
+ const ops: Array<Promise<void>> = [];
+ for await (const entry of this.kv.list<Comment>({ prefix })) {
+ ops.push(this.kv.delete(entry.key));
+ }
+ await Promise.all(ops);
+ }
+
+ async countByPost(postId: string): Promise<number> {
+ const prefix = [...COMMENT_KEY_PREFIX, postId] as const;
+ let count = 0;
+ for await (const _ of this.kv.list<Comment>({ prefix })) {
+ count += 1;
+ }
+
+ return count;
+ }
+}
--- /dev/null
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+
+const POST_KEY_PREFIX = ["post"] as const;
+const POST_INDEX_PREFIX = ["post_index"] as const;
+
+export class KvPostRepository implements PostRepository {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async create(post: ImagePost): Promise<void> {
+ post.boardId = post.boardId.trim();
+ await this.kv.atomic()
+ .set([...POST_KEY_PREFIX, post.boardId, post.id], post)
+ .set([...POST_INDEX_PREFIX, post.id], { boardId: post.boardId })
+ .commit();
+ }
+
+ async findById(id: string): Promise<ImagePost | null> {
+ const index = await this.kv.get<{ boardId: string }>([...POST_INDEX_PREFIX, id]);
+ if (!index.value) {
+ return null;
+ }
+ const record = await this.kv.get<ImagePost>([...POST_KEY_PREFIX, index.value.boardId, id]);
+ return record.value ?? null;
+ }
+
+ async listAll(): Promise<ImagePost[]> {
+ const posts: ImagePost[] = [];
+ for await (
+ const entry of this.kv.list<ImagePost>({ prefix: POST_KEY_PREFIX })
+ ) {
+ if (entry.value) {
+ posts.push(entry.value);
+ }
+ }
+ return posts;
+ }
+
+ async listByBoard(boardId: string): Promise<ImagePost[]> {
+ const posts: ImagePost[] = [];
+ const prefix = [...POST_KEY_PREFIX, boardId] as const;
+ for await (const entry of this.kv.list<ImagePost>({ prefix })) {
+ if (entry.value) {
+ posts.push(entry.value);
+ }
+ }
+ return posts;
+ }
+
+ async update(post: ImagePost): Promise<void> {
+ const existing = await this.findById(post.id);
+ if (!existing) {
+ await this.create(post);
+ return;
+ }
+ const boardId = post.boardId.trim();
+ await this.kv.atomic()
+ .set([...POST_KEY_PREFIX, boardId, post.id], post)
+ .set([...POST_INDEX_PREFIX, post.id], { boardId })
+ .commit();
+ }
+
+ async delete(id: string): Promise<void> {
+ const indexKey = [...POST_INDEX_PREFIX, id] as const;
+ const index = await this.kv.get<{ boardId: string }>(indexKey);
+ if (index.value) {
+ await this.kv.atomic()
+ .delete([...POST_KEY_PREFIX, index.value.boardId, id])
+ .delete(indexKey)
+ .commit();
+ }
+ }
+}
--- /dev/null
+import { SessionData, SessionStore } from "../../domain/services/session_store.ts";
+
+const SESSION_PREFIX = ["session"] as const;
+
+export class KvSessionStore implements SessionStore {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async get(sessionId: string): Promise<SessionData | null> {
+ const record = await this.kv.get<SessionData>([...SESSION_PREFIX, sessionId]);
+ return record.value ?? null;
+ }
+
+ async set(sessionId: string, data: SessionData): Promise<void> {
+ await this.kv.set([...SESSION_PREFIX, sessionId], data);
+ }
+
+ async delete(sessionId: string): Promise<void> {
+ await this.kv.delete([...SESSION_PREFIX, sessionId]);
+ }
+}
--- /dev/null
+import { User } from "../../domain/entities/user.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+
+const USER_KEY_PREFIX = ["user"] as const;
+const USER_USERNAME_INDEX = ["user_username"] as const;
+
+const normalizeUsername = (value: string): string => value.toLowerCase();
+
+export class KvUserRepository implements UserRepository {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async create(user: User): Promise<void> {
+ const usernameKey = [...USER_USERNAME_INDEX, normalizeUsername(user.username)] as const;
+ const userKey = [...USER_KEY_PREFIX, user.id] as const;
+ const existing = await this.kv.get(usernameKey);
+ if (existing.value) {
+ throw new Error("Username already exists.");
+ }
+ const result = await this.kv.atomic()
+ .check({ key: usernameKey, versionstamp: null })
+ .set(userKey, user)
+ .set(usernameKey, { id: user.id })
+ .commit();
+ if (!result.ok) {
+ throw new Error("Failed to create user.");
+ }
+ }
+
+ async findById(id: string): Promise<User | null> {
+ const entry = await this.kv.get<User>([...USER_KEY_PREFIX, id]);
+ return entry.value ?? null;
+ }
+
+ async findByUsername(username: string): Promise<User | null> {
+ const indexKey = [...USER_USERNAME_INDEX, normalizeUsername(username)] as const;
+ const index = await this.kv.get<{ id: string }>(indexKey);
+ if (!index.value) {
+ return null;
+ }
+ return await this.findById(index.value.id);
+ }
+
+ async isUsernameTaken(username: string): Promise<boolean> {
+ const indexKey = [...USER_USERNAME_INDEX, normalizeUsername(username)] as const;
+ const record = await this.kv.get(indexKey);
+ return record.value !== null;
+ }
+}
--- /dev/null
+import { join, normalize, relative } from "jsr:@std/path@^1.0.0";
+import { ImageStorage, SaveImageInput } from "../../domain/services/image_storage.ts";
+
+type FileSystemImageStorageOptions = {
+ root: string;
+ publicBasePath: string;
+};
+
+export class FileSystemImageStorage implements ImageStorage {
+ private readonly root: string;
+ private readonly rootPrefix: string;
+ private readonly publicBasePath: string;
+
+ constructor(options: FileSystemImageStorageOptions) {
+ this.root = normalize(options.root);
+ this.rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`;
+ this.publicBasePath = options.publicBasePath.replace(/\/+$/, "");
+ }
+
+ async saveImage(input: SaveImageInput): Promise<string> {
+ await this.ensureRoot();
+ const extension = this.resolveExtension(input.mimeType, input.originalName);
+ const filename = `${input.id}${extension}`;
+ const relativePath = join("images", filename);
+ const absolutePath = this.resolveAbsolute(relativePath);
+ await Deno.mkdir(join(this.root, "images"), { recursive: true });
+ await Deno.writeFile(absolutePath, input.data);
+ return `${this.publicBasePath}/${relativePath}`.replace(/\/+/g, "/");
+ }
+
+ async getImage(path: string): Promise<Uint8Array> {
+ const absolute = this.resolvePublicPath(path);
+ return await Deno.readFile(absolute);
+ }
+
+ async deleteImage(path: string): Promise<void> {
+ const absolute = this.resolvePublicPath(path);
+ try {
+ await Deno.remove(absolute);
+ } catch (error) {
+ if (error instanceof Deno.errors.NotFound) {
+ return;
+ }
+ throw error;
+ }
+ }
+
+ private async ensureRoot() {
+ await Deno.mkdir(this.root, { recursive: true });
+ }
+
+ private resolveExtension(mimeType: string, originalName: string): string {
+ const lowered = mimeType.toLowerCase();
+ if (lowered.includes("jpeg") || lowered.includes("jpg")) {
+ return ".jpg";
+ }
+ if (lowered.includes("png")) {
+ return ".png";
+ }
+ if (lowered.includes("gif")) {
+ return ".gif";
+ }
+ if (lowered.includes("webp")) {
+ return ".webp";
+ }
+ const parts = originalName.split(".");
+ if (parts.length > 1) {
+ return `.${parts.pop()}`;
+ }
+ return ".bin";
+ }
+
+ private resolveAbsolute(relativePath: string): string {
+ const candidate = normalize(join(this.root, relativePath));
+ if (!this.isInsideRoot(candidate)) {
+ throw new Error("Attempt to access path outside storage root.");
+ }
+ return candidate;
+ }
+
+ private resolvePublicPath(publicPath: string): string {
+ const sanitized = publicPath.startsWith(this.publicBasePath)
+ ? publicPath.slice(this.publicBasePath.length)
+ : publicPath;
+ const relativePath = sanitized.replace(/^\/+/, "");
+ return this.resolveAbsolute(relativePath);
+ }
+
+ private isInsideRoot(target: string): boolean {
+ if (target === this.root) {
+ return true;
+ }
+ if (!target.startsWith(this.rootPrefix)) {
+ return false;
+ }
+ const related = relative(this.root, target).replaceAll("\\", "/");
+ if (!related || related === "." || related === "./") {
+ return true;
+ }
+ return !related.split("/").some((segment) => segment === "..");
+ }
+}
--- /dev/null
+import { FileSystemImageStorage } from "./file_system_image_storage.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const decode = (bytes: Uint8Array) => Array.from(bytes);
+
+Deno.test("FileSystemImageStorage saves, reads, and deletes images", async () => {
+ const tempDir = await Deno.makeTempDir();
+ const storage = new FileSystemImageStorage({
+ root: tempDir,
+ publicBasePath: "/uploads",
+ });
+
+ try {
+ const path = await storage.saveImage({
+ id: "file-1",
+ data: new Uint8Array([1, 2, 3]),
+ mimeType: "image/png",
+ originalName: "example.png",
+ });
+
+ assertEquals(path, "/uploads/images/file-1.png");
+
+ const data = await storage.getImage(path);
+ assertEquals(decode(data), [1, 2, 3]);
+
+ await storage.deleteImage(path);
+ await assertRejects(
+ () => storage.getImage(path),
+ Error,
+ );
+
+ // Deleting again should be a no-op.
+ await storage.deleteImage(path);
+ } finally {
+ await Deno.remove(tempDir, { recursive: true });
+ }
+});
+
+Deno.test("FileSystemImageStorage derives extensions from mime type and filename", async () => {
+ const tempDir = await Deno.makeTempDir();
+ const storage = new FileSystemImageStorage({
+ root: tempDir,
+ publicBasePath: "/uploads",
+ });
+
+ try {
+ const gifPath = await storage.saveImage({
+ id: "file-2",
+ data: new Uint8Array([4]),
+ mimeType: "image/gif",
+ originalName: "animation.gif",
+ });
+ assertEquals(gifPath, "/uploads/images/file-2.gif");
+
+ const fallbackPath = await storage.saveImage({
+ id: "file-3",
+ data: new Uint8Array([5]),
+ mimeType: "application/octet-stream",
+ originalName: "mystery.bin",
+ });
+ assertEquals(fallbackPath, "/uploads/images/file-3.bin");
+ } finally {
+ await Deno.remove(tempDir, { recursive: true });
+ }
+});
--- /dev/null
+import { IdGenerator } from "../../application/contracts/id_generator.ts";
+
+export class CryptoIdGenerator implements IdGenerator {
+ generate(): string {
+ return crypto.randomUUID();
+ }
+}
--- /dev/null
+import { RateLimiter, RateLimitRule } from "../../domain/services/rate_limiter.ts";
+
+type RateCounterRecord = {
+ count: number;
+ windowExpiresAt: number;
+};
+
+const RATE_LIMIT_PREFIX = ["rate-limit"] as const;
+
+const computeWindow = (now: number, windowMs: number) => {
+ const windowId = Math.floor(now / windowMs);
+ const windowStart = windowId * windowMs;
+ const windowExpiresAt = windowStart + windowMs;
+ return { windowId, windowExpiresAt };
+};
+
+export class KvRateLimiter implements RateLimiter {
+ constructor(private readonly kv: Deno.Kv) {}
+
+ async consume(key: string, rule: RateLimitRule): Promise<boolean> {
+ const now = Date.now();
+ const { windowId, windowExpiresAt } = computeWindow(now, rule.windowMs);
+ const storageKey = [...RATE_LIMIT_PREFIX, key, windowId] as const;
+ const ttl = Math.max(1, windowExpiresAt - now);
+
+ const existing = await this.kv.get<RateCounterRecord>(storageKey);
+ if (!existing.value) {
+ const result = await this.kv.atomic()
+ .check({ key: storageKey, versionstamp: null })
+ .set(storageKey, { count: 1, windowExpiresAt }, { expireIn: ttl })
+ .commit();
+ return result.ok;
+ }
+
+ if (existing.value.count >= rule.limit) {
+ return false;
+ }
+
+ const nextCount = existing.value.count + 1;
+ const result = await this.kv.atomic()
+ .check(existing)
+ .set(storageKey, { count: nextCount, windowExpiresAt }, { expireIn: ttl })
+ .commit();
+ return result.ok;
+ }
+}
--- /dev/null
+import { Clock } from "../../application/contracts/clock.ts";
+
+export class SystemClock implements Clock {
+ now(): Date {
+ return new Date();
+ }
+}
--- /dev/null
+import { join, normalize, relative } from "jsr:@std/path@^1.0.0";
+import { Context, Hono } from "@hono/hono";
+import { decodeBase64Url, encodeBase64Url } from "jsr:@std/encoding@^1.0.0/base64url";
+
+import { CreateCommentUseCase } from "../../application/usecases/create_comment_use_case.ts";
+import { CreatePostUseCase } from "../../application/usecases/create_post_use_case.ts";
+import { GetPostUseCase } from "../../application/usecases/get_post_use_case.ts";
+import { ListCommentsUseCase } from "../../application/usecases/list_comments_use_case.ts";
+import { ListPostsUseCase } from "../../application/usecases/list_posts_use_case.ts";
+import { ListArchivedPostsUseCase } from "../../application/usecases/list_archived_posts_use_case.ts";
+import { ListBoardsUseCase } from "../../application/usecases/list_boards_use_case.ts";
+import { CreateBoardRequestUseCase } from "../../application/usecases/create_board_request_use_case.ts";
+import { ToggleBoardRequestVoteUseCase } from "../../application/usecases/toggle_board_request_vote_use_case.ts";
+import { GetBoardBySlugUseCase } from "../../application/usecases/get_board_by_slug_use_case.ts";
+import { GetBoardRequestsOverviewUseCase } from "../../application/usecases/get_board_requests_overview_use_case.ts";
+import { ListBoardAnnouncementsUseCase } from "../../application/usecases/list_board_announcements_use_case.ts";
+import { ListPopularPostsUseCase } from "../../application/usecases/list_popular_posts_use_case.ts";
+import { ApplicationError } from "../../application/errors/application_error.ts";
+import { ImageStorage } from "../../domain/services/image_storage.ts";
+import type { ImagePost } from "../../domain/entities/image_post.ts";
+import type { Comment } from "../../domain/entities/comment.ts";
+import { renderAppPage } from "./views/app_page.ts";
+import { renderHomePage } from "./views/home_page.ts";
+import { RateLimiter } from "../../domain/services/rate_limiter.ts";
+import { RegisterUserUseCase } from "../../application/usecases/register_user_use_case.ts";
+import { AuthenticateUserUseCase } from "../../application/usecases/authenticate_user_use_case.ts";
+import { type SessionData, SessionStore } from "../../domain/services/session_store.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+import type { User } from "../../domain/entities/user.ts";
+
+type RateLimitRuleConfig = {
+ limit: number;
+ windowMs: number;
+};
+
+type RateLimitRules = {
+ createPost: RateLimitRuleConfig;
+ createComment: RateLimitRuleConfig;
+ createBoardRequest: RateLimitRuleConfig;
+};
+
+export type AppDependencies = {
+ appName: string;
+ homePageTitle: string;
+ homePageHeading: string;
+ homePageDescription: string;
+ footerText: string;
+ assetsBasePath: string;
+ createPostUseCase: CreatePostUseCase;
+ listPostsUseCase: ListPostsUseCase;
+ listArchivedPostsUseCase: ListArchivedPostsUseCase;
+ getPostUseCase: GetPostUseCase;
+ createCommentUseCase: CreateCommentUseCase;
+ listCommentsUseCase: ListCommentsUseCase;
+ listBoardsUseCase: ListBoardsUseCase;
+ createBoardRequestUseCase: CreateBoardRequestUseCase;
+ toggleBoardRequestVoteUseCase: ToggleBoardRequestVoteUseCase;
+ getBoardBySlugUseCase: GetBoardBySlugUseCase;
+ getBoardRequestsOverviewUseCase: GetBoardRequestsOverviewUseCase;
+ listBoardAnnouncementsUseCase: ListBoardAnnouncementsUseCase;
+ listPopularPostsUseCase: ListPopularPostsUseCase;
+ imageStorage: ImageStorage;
+ publicDir: string;
+ uploadsBasePath: string;
+ rateLimiter: RateLimiter;
+ rateLimits: RateLimitRules;
+ threadTtlDays: number;
+ boardRequestWindowDays: number;
+ sessionSecret: string;
+ sessionCookieSecure: boolean;
+ trustProxy: boolean;
+ registerUserUseCase: RegisterUserUseCase;
+ authenticateUserUseCase: AuthenticateUserUseCase;
+ sessionStore: SessionStore;
+ userRepository: UserRepository;
+ memberBoard: {
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+ welcomePost?: {
+ title?: string;
+ body: string;
+ permanent?: boolean;
+ };
+ };
+};
+
+const ASSET_CONTENT_TYPE: Record<string, string> = {
+ ".js": "application/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+};
+
+const resolveContentType = (filename: string): string => {
+ const lastDotIndex = filename.lastIndexOf(".");
+ if (lastDotIndex === -1) {
+ return "application/octet-stream";
+ }
+ const extension = filename.slice(lastDotIndex);
+ return ASSET_CONTENT_TYPE[extension] ?? "application/octet-stream";
+};
+
+const extractSubPath = (url: string, prefix: string): string | null => {
+ const pathname = new URL(url).pathname;
+ if (!pathname.startsWith(prefix)) {
+ return null;
+ }
+ const relative = pathname.slice(prefix.length).replace(/^\/+/g, "");
+ return relative || null;
+};
+
+const normalizeBasePath = (value: string): { base: string; prefix: string } => {
+ const trimmed = value.trim();
+ const stripped = trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
+ const base = `/${stripped}`;
+ const prefix = `${base}/`;
+ return { base, prefix };
+};
+
+const USER_COOKIE = "uid";
+const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
+const textEncoder = new TextEncoder();
+
+const getCookieValue = (cookies: string | null | undefined, name: string): string | null => {
+ if (!cookies) {
+ return null;
+ }
+ const match = cookies.match(new RegExp(`(?:^|;)\\s*${name}=([^;]+)`));
+ return match?.[1] ?? null;
+};
+
+const isPathInside = (base: string, target: string): boolean => {
+ const normalizedBase = normalize(base);
+ const basePrefix = normalizedBase.endsWith("/") ? normalizedBase : `${normalizedBase}/`;
+ const candidate = normalize(target);
+ if (candidate !== normalizedBase && !candidate.startsWith(basePrefix)) {
+ return false;
+ }
+ const related = relative(normalizedBase, candidate).replaceAll("\\", "/");
+ if (!related || related === "." || related === "./") {
+ return true;
+ }
+ return !related.split("/").some((segment) => segment === "..");
+};
+
+type CookieSigner = {
+ sign(value: string): Promise<string>;
+ verify(value: string, signature: string): Promise<boolean>;
+};
+
+const createCookieSigner = (secret: string): CookieSigner => {
+ const keyPromise = crypto.subtle.importKey(
+ "raw",
+ textEncoder.encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign", "verify"],
+ );
+
+ return {
+ sign: async (value: string): Promise<string> => {
+ const key = await keyPromise;
+ const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(value));
+ return encodeBase64Url(new Uint8Array(signature));
+ },
+ verify: async (value: string, signature: string): Promise<boolean> => {
+ try {
+ const key = await keyPromise;
+ const signatureBytes = decodeBase64Url(signature);
+ return await crypto.subtle.verify("HMAC", key, signatureBytes, textEncoder.encode(value));
+ } catch (_error) {
+ return false;
+ }
+ },
+ };
+};
+
+const generateSessionId = (): string => {
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+ return encodeBase64Url(bytes);
+};
+
+type IdentityOptions = {
+ cookieName: string;
+ secret: string;
+ secure: boolean;
+ trustProxy: boolean;
+ sessionStore: SessionStore;
+ userRepository: UserRepository;
+};
+
+type SessionState = {
+ id: string;
+ data: SessionData;
+ fresh: boolean;
+};
+
+const createIdentityService = (options: IdentityOptions) => {
+ const signer = createCookieSigner(options.secret);
+ const sessionCache = new WeakMap<Context, SessionState>();
+ const accountCache = new WeakMap<Context, User | null>();
+
+ const nowIso = () => new Date().toISOString();
+
+ const setSessionCookie = async (c: Context, sessionId: string) => {
+ const signature = await signer.sign(sessionId);
+ const attributes = [
+ `${options.cookieName}=${sessionId}.${signature}`,
+ "Path=/",
+ "HttpOnly",
+ "SameSite=Lax",
+ `Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
+ ];
+ if (options.secure) {
+ attributes.push("Secure");
+ }
+ c.header("Set-Cookie", attributes.join("; "), { append: true });
+ };
+
+ const ensureSessionState = async (c: Context): Promise<SessionState> => {
+ const cached = sessionCache.get(c);
+ if (cached) {
+ return cached;
+ }
+
+ const cookies = c.req.header("cookie");
+ const existing = getCookieValue(cookies, options.cookieName);
+ let sessionId: string | null = null;
+ if (existing) {
+ const [id, signature] = existing.split(".");
+ if (id && signature && await signer.verify(id, signature)) {
+ sessionId = id;
+ }
+ }
+
+ let fresh = false;
+ if (!sessionId) {
+ sessionId = generateSessionId();
+ fresh = true;
+ }
+
+ await setSessionCookie(c, sessionId);
+
+ let data = await options.sessionStore.get(sessionId);
+ if (!data) {
+ const timestamp = nowIso();
+ data = {
+ userId: null,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ } satisfies SessionData;
+ await options.sessionStore.set(sessionId, data);
+ }
+
+ const state: SessionState = {
+ id: sessionId,
+ data: { ...data },
+ fresh,
+ };
+ sessionCache.set(c, state);
+ return state;
+ };
+
+ const persistSession = async (state: SessionState) => {
+ state.data.updatedAt = nowIso();
+ await options.sessionStore.set(state.id, { ...state.data });
+ };
+
+ const extractClientIp = (c: Context): string => {
+ if (options.trustProxy) {
+ const forwarded = c.req.header("x-forwarded-for");
+ if (forwarded) {
+ const forwardedIp = forwarded.split(",")[0].trim();
+ if (forwardedIp.length > 0) {
+ return forwardedIp;
+ }
+ }
+ const realIp = c.req.header("x-real-ip");
+ if (realIp?.trim()) {
+ return realIp.trim();
+ }
+ }
+
+ const remoteAddr = (c.env as { connInfo?: { remoteAddr?: Deno.Addr } } | undefined)
+ ?.connInfo?.remoteAddr;
+ if (remoteAddr && typeof remoteAddr === "object" && "hostname" in remoteAddr) {
+ return remoteAddr.hostname;
+ }
+
+ return "unknown";
+ };
+
+ const getCurrentUser = async (c: Context): Promise<User | null> => {
+ if (accountCache.has(c)) {
+ return accountCache.get(c) ?? null;
+ }
+ const session = await ensureSessionState(c);
+ if (!session.data.userId) {
+ accountCache.set(c, null);
+ return null;
+ }
+ const user = await options.userRepository.findById(session.data.userId);
+ if (!user) {
+ session.data.userId = null;
+ await persistSession(session);
+ accountCache.set(c, null);
+ return null;
+ }
+ accountCache.set(c, user);
+ return user;
+ };
+
+ const rotateSession = async (c: Context, account: User | null): Promise<SessionState> => {
+ const previous = await ensureSessionState(c);
+ await options.sessionStore.delete(previous.id);
+ sessionCache.delete(c);
+ accountCache.delete(c);
+
+ const timestamp = nowIso();
+ const data: SessionData = {
+ userId: account ? account.id : null,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ };
+ const newId = generateSessionId();
+ await setSessionCookie(c, newId);
+ await options.sessionStore.set(newId, data);
+ const state: SessionState = {
+ id: newId,
+ data: { ...data },
+ fresh: true,
+ };
+ sessionCache.set(c, state);
+ accountCache.set(c, account);
+ return state;
+ };
+
+ const signIn = async (c: Context, user: User) => {
+ await rotateSession(c, user);
+ };
+
+ const signOut = async (c: Context) => {
+ await rotateSession(c, null);
+ };
+
+ const identify = async (c: Context): Promise<RateLimitIdentity> => {
+ const session = await ensureSessionState(c);
+ const account = await getCurrentUser(c);
+ const clientIp = extractClientIp(c);
+ const bucket = account
+ ? `user:${account.id}`
+ : session.fresh
+ ? `ip:${clientIp}`
+ : `${session.id}:${clientIp}`;
+ return {
+ sessionId: session.id,
+ accountId: account?.id ?? null,
+ clientIp,
+ bucket,
+ };
+ };
+
+ const getVisitorId = async (c: Context): Promise<string> => {
+ const session = await ensureSessionState(c);
+ return session.id;
+ };
+
+ return {
+ ensureSession: ensureSessionState,
+ identify,
+ getCurrentUser,
+ signIn,
+ signOut,
+ getVisitorId,
+ };
+};
+
+const pullText = (value: unknown): string => {
+ if (typeof value === "string") {
+ return value;
+ }
+ if (Array.isArray(value) && value.length > 0) {
+ const first = value[0];
+ return typeof first === "string" ? first : "";
+ }
+ return "";
+};
+
+const pickFile = (value: unknown): File | null => {
+ if (value instanceof File) {
+ return value;
+ }
+ if (Array.isArray(value)) {
+ for (const entry of value) {
+ if (entry instanceof File) {
+ return entry;
+ }
+ }
+ }
+ return null;
+};
+
+const buildThreadModels = async (
+ posts: ImagePost[],
+ listCommentsUseCase: ListCommentsUseCase,
+): Promise<
+ Array<
+ Omit<ImagePost, "status" | "readOnly" | "archivedAt"> & {
+ status: "active" | "archived";
+ readOnly: boolean;
+ archivedAt: string | null;
+ comments: Comment[];
+ }
+ >
+> => {
+ const threads: Array<
+ Omit<ImagePost, "status" | "readOnly" | "archivedAt"> & {
+ status: "active" | "archived";
+ readOnly: boolean;
+ archivedAt: string | null;
+ comments: Comment[];
+ }
+ > = [];
+
+ for (const post of posts) {
+ const comments = await listCommentsUseCase.execute(post.id);
+ threads.push({
+ ...post,
+ status: (post.status ?? "active") as "active" | "archived",
+ readOnly: post.readOnly ?? false,
+ archivedAt: post.archivedAt ?? null,
+ comments,
+ });
+ }
+
+ return threads;
+};
+
+const parseJsonBody = async <T>(c: Context, errorMessage = "Invalid JSON payload."): Promise<T> => {
+ try {
+ return await c.req.json<T>();
+ } catch (_error) {
+ throw new ApplicationError(errorMessage);
+ }
+};
+
+export const createApp = (deps: AppDependencies) => {
+ const app = new Hono();
+ const identityService = createIdentityService({
+ cookieName: USER_COOKIE,
+ secret: deps.sessionSecret,
+ secure: deps.sessionCookieSecure,
+ trustProxy: deps.trustProxy,
+ sessionStore: deps.sessionStore,
+ userRepository: deps.userRepository,
+ });
+ const { base: assetBasePath, prefix: assetRoutePrefix } = normalizeBasePath(deps.assetsBasePath);
+ const { base: uploadsBasePath, prefix: uploadsRoutePrefix } = normalizeBasePath(
+ deps.uploadsBasePath,
+ );
+ const publicDir = normalize(deps.publicDir);
+
+ const enforceRateLimit = async (
+ action: keyof RateLimitRules,
+ identity: RateLimitIdentity,
+ ): Promise<void> => {
+ const rule = deps.rateLimits[action];
+ const allowed = await deps.rateLimiter.consume(`${action}:${identity.bucket}`, rule);
+ if (!allowed) {
+ throw new ApplicationError("Too many requests. Please slow down.", 429);
+ }
+ };
+
+ const ensureBoardAccess = async (c: Context, boardSlug: string): Promise<User | null> => {
+ const account = await identityService.getCurrentUser(c);
+ if (boardSlug === deps.memberBoard.slug && !account) {
+ throw new ApplicationError("Members only.", 403);
+ }
+ return account;
+ };
+
+ const ensurePostBoardAccess = async (
+ c: Context,
+ postId: string,
+ ): Promise<{ board: { id: string; slug: string }; account: User | null; post: ImagePost }> => {
+ const post = await deps.getPostUseCase.execute(postId);
+ const boards = await deps.listBoardsUseCase.execute();
+ const board = boards.find((entry) => entry.id === post.boardId);
+ if (!board) {
+ throw new ApplicationError("Board not found.", 404);
+ }
+ const account = await ensureBoardAccess(c, board.slug);
+ return { board: { id: board.id, slug: board.slug }, account, post };
+ };
+
+ app.use("*", async (_c, next) => {
+ try {
+ await next();
+ } catch (error) {
+ if (error instanceof ApplicationError) {
+ throw error;
+ }
+ console.error("Unhandled error", error);
+ throw new ApplicationError("Unexpected server error.", 500);
+ }
+ });
+
+ app.onError((error, c) => {
+ if (error instanceof ApplicationError) {
+ return Response.json({ message: error.message }, { status: error.status });
+ }
+ console.error(error);
+ return c.json({ message: "Internal Server Error" }, 500);
+ });
+
+ app.notFound((c) => c.json({ message: "Resource not found" }, 404));
+
+ app.get("/", async (c) => {
+ const account = await identityService.getCurrentUser(c);
+ const visitorId = await identityService.getVisitorId(c);
+ const voteUserId = account?.id ?? visitorId;
+ const url = new URL(c.req.url);
+ const successMessage = url.searchParams.get("success") ?? undefined;
+ const errorMessage = url.searchParams.get("error") ?? undefined;
+ const showRequestForm = url.searchParams.get("showRequestForm") === "1" ||
+ Boolean(errorMessage);
+ const activeAuthFormRaw = url.searchParams.get("auth");
+ const activeAuthForm = activeAuthFormRaw === "register"
+ ? "register"
+ : activeAuthFormRaw === "login"
+ ? "login"
+ : null;
+ const registerUsername = url.searchParams.get("registerUsername") ?? "";
+ const loginUsername = url.searchParams.get("loginUsername") ?? "";
+
+ const requestDefaults: {
+ slug?: string;
+ name?: string;
+ description?: string;
+ themeColor?: string;
+ } = {};
+
+ const slugParam = url.searchParams.get("slug");
+ if (slugParam) {
+ requestDefaults.slug = slugParam;
+ }
+ const nameParam = url.searchParams.get("name");
+ if (nameParam) {
+ requestDefaults.name = nameParam;
+ }
+ const descriptionParam = url.searchParams.get("description");
+ if (descriptionParam) {
+ requestDefaults.description = descriptionParam;
+ }
+ const themeParam = url.searchParams.get("themeColor");
+ if (themeParam) {
+ requestDefaults.themeColor = themeParam;
+ }
+
+ const boards = await deps.listBoardsUseCase.execute();
+ const visibleBoards = account
+ ? boards
+ : boards.filter((board) => board.slug !== deps.memberBoard.slug);
+ const requests = await deps.getBoardRequestsOverviewUseCase.execute(voteUserId);
+ const announcements = await deps.listBoardAnnouncementsUseCase.execute();
+ const popular = await deps.listPopularPostsUseCase.execute(6);
+ const visiblePopular = account
+ ? popular
+ : popular.filter((entry) => entry.boardSlug !== deps.memberBoard.slug);
+
+ return c.html(
+ renderHomePage({
+ title: deps.homePageTitle,
+ heading: deps.homePageHeading,
+ description: deps.homePageDescription,
+ assetBasePath,
+ threadTtlDays: deps.threadTtlDays,
+ requestDurationDays: deps.boardRequestWindowDays,
+ boards: visibleBoards.map((board) => ({
+ id: board.id,
+ slug: board.slug,
+ name: board.name,
+ description: board.description,
+ themeColor: board.themeColor,
+ })),
+ requests: requests
+ .filter((request) => request.status === "open")
+ .map((request) => ({
+ id: request.id,
+ slug: request.slug,
+ name: request.name,
+ description: request.description,
+ themeColor: request.themeColor,
+ voteCount: request.voteCount,
+ deadline: request.deadline,
+ status: request.status,
+ voted: request.voted,
+ })),
+ announcements,
+ popularPosts: visiblePopular.map((entry) => ({
+ id: entry.post.id,
+ boardSlug: entry.boardSlug,
+ boardName: entry.boardName,
+ title: entry.post.title,
+ commentCount: entry.post.commentCount,
+ })),
+ flash: {
+ success: successMessage,
+ error: errorMessage,
+ },
+ showRequestForm,
+ requestFormDefaults: requestDefaults,
+ currentUser: account
+ ? {
+ username: account.username,
+ }
+ : null,
+ memberBoard: deps.memberBoard,
+ activeAuthForm,
+ loginPrefill: loginUsername,
+ registerPrefill: registerUsername,
+ footerText: deps.footerText,
+ }),
+ );
+ });
+
+ app.get(`${assetBasePath}/*`, async (c) => {
+ const resource = extractSubPath(c.req.url, assetRoutePrefix);
+ if (!resource) {
+ return c.json({ message: "Asset path missing" }, 400);
+ }
+ const filePath = normalize(join(publicDir, resource));
+ if (!isPathInside(publicDir, filePath)) {
+ return c.json({ message: "Invalid asset path" }, 400);
+ }
+
+ try {
+ const file = await Deno.readFile(filePath);
+ const source = file.buffer as ArrayBuffer;
+ const body = source.slice(file.byteOffset, file.byteOffset + file.byteLength);
+ return new Response(body, {
+ headers: { "Content-Type": resolveContentType(resource) },
+ });
+ } catch (error) {
+ if (error instanceof Deno.errors.NotFound) {
+ return c.json({ message: "Asset not found" }, 404);
+ }
+ throw error;
+ }
+ });
+
+ app.get(`${uploadsBasePath}/*`, async (c) => {
+ const resource = extractSubPath(c.req.url, uploadsRoutePrefix);
+ if (!resource) {
+ return c.json({ message: "Image path missing" }, 400);
+ }
+ const publicPath = `${uploadsBasePath}/${resource}`.replace(/\/+/g, "/");
+
+ try {
+ const data = await deps.imageStorage.getImage(publicPath);
+ const source = data.buffer as ArrayBuffer;
+ const body = source.slice(data.byteOffset, data.byteOffset + data.byteLength);
+ return new Response(body, {
+ headers: { "Content-Type": resolveContentType(resource) },
+ });
+ } catch (error) {
+ if (error instanceof Deno.errors.NotFound) {
+ return c.json({ message: "Image not found" }, 404);
+ }
+ throw error;
+ }
+ });
+
+ app.get("/api/boards", async (_c) => {
+ const boards = await deps.listBoardsUseCase.execute();
+ return Response.json(boards);
+ });
+
+ app.get("/api/boards/:slug/posts", async (c) => {
+ const board = await deps.getBoardBySlugUseCase.execute(c.req.param("slug"));
+ await ensureBoardAccess(c, board.slug);
+ const posts = await deps.listPostsUseCase.execute(board.id);
+ return c.json(posts);
+ });
+
+ app.get("/api/boards/:slug/posts/archive", async (c) => {
+ const board = await deps.getBoardBySlugUseCase.execute(c.req.param("slug"));
+ await ensureBoardAccess(c, board.slug);
+ const posts = await deps.listArchivedPostsUseCase.execute(board.id);
+ return c.json(posts);
+ });
+
+ app.get("/api/posts/:id", async (c) => {
+ const { post } = await ensurePostBoardAccess(c, c.req.param("id"));
+ return c.json(post);
+ });
+
+ app.post("/api/boards/:slug/posts", async (c) => {
+ const identity = await identityService.identify(c);
+ await enforceRateLimit("createPost", identity);
+ const board = await deps.getBoardBySlugUseCase.execute(c.req.param("slug"));
+ await ensureBoardAccess(c, board.slug);
+ const body = await c.req.parseBody();
+ const title = pullText(body.title ?? "").trim();
+ const description = pullText(body.description ?? "").trim();
+ const imageFile = pickFile(body.image);
+ let imagePayload:
+ | {
+ data: Uint8Array;
+ mimeType: string;
+ originalName: string;
+ }
+ | undefined;
+ if (imageFile && imageFile.size > 0) {
+ const arrayBuffer = await imageFile.arrayBuffer();
+ imagePayload = {
+ data: new Uint8Array(arrayBuffer),
+ mimeType: imageFile.type,
+ originalName: imageFile.name,
+ };
+ }
+
+ const post = await deps.createPostUseCase.execute({
+ boardId: board.id,
+ title: title.length > 0 ? title : undefined,
+ description,
+ image: imagePayload,
+ });
+
+ return c.json(post, 201);
+ });
+
+ app.get("/api/posts/:id/comments", async (c) => {
+ await ensurePostBoardAccess(c, c.req.param("id"));
+ const comments = await deps.listCommentsUseCase.execute(c.req.param("id"));
+ return c.json(comments);
+ });
+
+ app.post("/api/posts/:id/comments", async (c) => {
+ const postId = c.req.param("id");
+ const contentType = c.req.header("content-type") ?? "";
+ const identity = await identityService.identify(c);
+ await enforceRateLimit("createComment", identity);
+ const { account } = await ensurePostBoardAccess(c, postId);
+
+ if (contentType.includes("application/json")) {
+ const payload = await parseJsonBody<{ author?: string; body?: string; parentId?: string }>(
+ c,
+ "Invalid comment payload.",
+ );
+ const comment = await deps.createCommentUseCase.execute({
+ postId,
+ author: payload.author ?? "",
+ body: payload.body ?? "",
+ parentCommentId: payload.parentId ?? undefined,
+ authorUserId: account?.id,
+ authorUsername: account?.username,
+ });
+ return c.json(comment, 201);
+ }
+
+ const body = await c.req.parseBody();
+ const author = pullText(body.author ?? body.name ?? "");
+ const commentBody = pullText(body.body ?? body.comment ?? "");
+ const parentIdRaw = pullText(body.parentId ?? body.parentCommentId ?? "").trim();
+ const imageFile = pickFile(body.image);
+
+ let imagePayload: {
+ data: Uint8Array;
+ mimeType: string;
+ originalName: string;
+ } | undefined;
+
+ if (imageFile && imageFile.size > 0) {
+ const arrayBuffer = await imageFile.arrayBuffer();
+ imagePayload = {
+ data: new Uint8Array(arrayBuffer),
+ mimeType: imageFile.type,
+ originalName: imageFile.name,
+ };
+ }
+
+ const comment = await deps.createCommentUseCase.execute({
+ postId,
+ author,
+ body: commentBody,
+ parentCommentId: parentIdRaw.length > 0 ? parentIdRaw : undefined,
+ image: imagePayload,
+ authorUserId: account?.id,
+ authorUsername: account?.username,
+ });
+ return c.json(comment, 201);
+ });
+
+ app.get("/api/board-requests", async (c) => {
+ const identity = await identityService.identify(c);
+ const userId = identity.accountId ?? identity.sessionId;
+ const requests = await deps.getBoardRequestsOverviewUseCase.execute(userId);
+ return c.json(requests);
+ });
+
+ app.post("/api/board-requests", async (c) => {
+ const identity = await identityService.identify(c);
+ await enforceRateLimit("createBoardRequest", identity);
+ const payload = await parseJsonBody<
+ { slug: string; name: string; description: string; themeColor: string }
+ >(c, "Invalid board request payload.");
+ const request = await deps.createBoardRequestUseCase.execute({
+ slug: payload.slug,
+ name: payload.name,
+ description: payload.description,
+ themeColor: payload.themeColor,
+ });
+ return c.json(request, 201);
+ });
+
+ app.post("/api/board-requests/:id/toggle-vote", async (c) => {
+ const identity = await identityService.identify(c);
+ const userId = identity.accountId ?? identity.sessionId;
+ const result = await deps.toggleBoardRequestVoteUseCase.execute({
+ requestId: c.req.param("id"),
+ userId,
+ });
+ return c.json(result);
+ });
+
+ app.post("/board-requests", async (c) => {
+ const identity = await identityService.identify(c);
+ await enforceRateLimit("createBoardRequest", identity);
+ const body = await c.req.parseBody();
+ const slug = pullText(body.slug).trim();
+ const name = pullText(body.name).trim();
+ const description = pullText(body.description).trim();
+ const themeColor = pullText(body.themeColor).trim();
+
+ try {
+ await deps.createBoardRequestUseCase.execute({
+ slug,
+ name,
+ description,
+ themeColor,
+ });
+ const params = new URLSearchParams({
+ success: "Request submitted for review.",
+ showRequestForm: "1",
+ });
+ return c.redirect(`/?${params.toString()}`, 303);
+ } catch (error) {
+ if (error instanceof ApplicationError) {
+ const params = new URLSearchParams({
+ error: error.message,
+ showRequestForm: "1",
+ });
+ if (slug) params.set("slug", slug);
+ if (name) params.set("name", name);
+ if (description) params.set("description", description);
+ if (themeColor) params.set("themeColor", themeColor);
+ return c.redirect(`/?${params.toString()}`, 303);
+ }
+ throw error;
+ }
+ });
+
+ app.post("/board-requests/:id/toggle-vote", async (c) => {
+ const identity = await identityService.identify(c);
+ const userId = identity.accountId ?? identity.sessionId;
+ const requestId = c.req.param("id");
+ try {
+ const result = await deps.toggleBoardRequestVoteUseCase.execute({
+ requestId,
+ userId,
+ });
+ const params = new URLSearchParams({
+ success: result.voted ? "Vote recorded." : "Vote removed.",
+ showRequestForm: "1",
+ });
+ return c.redirect(`/?${params.toString()}`, 303);
+ } catch (error) {
+ if (error instanceof ApplicationError) {
+ const params = new URLSearchParams({
+ error: error.message,
+ showRequestForm: "1",
+ });
+ return c.redirect(`/?${params.toString()}`, 303);
+ }
+ throw error;
+ }
+ });
+
+ app.post("/auth/register", async (c) => {
+ const body = await c.req.parseBody();
+ const username = pullText(body.username).trim();
+ const password = pullText(body.password);
+ try {
+ const user = await deps.registerUserUseCase.execute({ username, password });
+ await identityService.signIn(c, user);
+ const params = new URLSearchParams({ success: `Welcome, ${user.username}!` });
+ return c.redirect(`/?${params.toString()}`, 303);
+ } catch (error) {
+ if (error instanceof ApplicationError) {
+ const params = new URLSearchParams({ error: error.message, auth: "register" });
+ if (username) {
+ params.set("registerUsername", username);
+ }
+ return c.redirect(`/?${params.toString()}`, 303);
+ }
+ throw error;
+ }
+ });
+
+ app.post("/auth/login", async (c) => {
+ const body = await c.req.parseBody();
+ const username = pullText(body.username).trim();
+ const password = pullText(body.password);
+ try {
+ const user = await deps.authenticateUserUseCase.execute({ username, password });
+ await identityService.signIn(c, user);
+ const params = new URLSearchParams({ success: `Welcome back, ${user.username}!` });
+ return c.redirect(`/?${params.toString()}`, 303);
+ } catch (error) {
+ if (error instanceof ApplicationError) {
+ const params = new URLSearchParams({ error: error.message, auth: "login" });
+ if (username) {
+ params.set("loginUsername", username);
+ }
+ return c.redirect(`/?${params.toString()}`, 303);
+ }
+ throw error;
+ }
+ });
+
+ app.post("/auth/logout", async (c) => {
+ await identityService.signOut(c);
+ const params = new URLSearchParams({ success: "Signed out." });
+ return c.redirect(`/?${params.toString()}`, 303);
+ });
+
+ app.post("/:slug/posts", async (c) => {
+ const slug = c.req.param("slug");
+ if (["assets", "uploads", "api"].includes(slug) || slug === "") {
+ return c.notFound();
+ }
+
+ const identity = await identityService.identify(c);
+ await enforceRateLimit("createPost", identity);
+ const board = await deps.getBoardBySlugUseCase.execute(slug);
+ await ensureBoardAccess(c, board.slug);
+ const body = await c.req.parseBody();
+ const title = pullText(body.title).trim();
+ const description = pullText(body.description).trim();
+ const imageFile = pickFile(body.image);
+
+ try {
+ let imagePayload:
+ | {
+ data: Uint8Array;
+ mimeType: string;
+ originalName: string;
+ }
+ | undefined;
+
+ if (imageFile && imageFile.size > 0) {
+ const arrayBuffer = await imageFile.arrayBuffer();
+ imagePayload = {
+ data: new Uint8Array(arrayBuffer),
+ mimeType: imageFile.type,
+ originalName: imageFile.name,
+ };
+ }
+
+ const post = await deps.createPostUseCase.execute({
+ boardId: board.id,
+ title: title.length > 0 ? title : undefined,
+ description,
+ image: imagePayload,
+ });
+ const params = new URLSearchParams({ success: "Thread posted." });
+ return c.redirect(`/${board.slug}?${params.toString()}#post-${post.id}`, 303);
+ } catch (error) {
+ if (error instanceof ApplicationError) {
+ const params = new URLSearchParams({ error: error.message });
+ return c.redirect(`/${board.slug}?${params.toString()}#post-form`, 303);
+ }
+ throw error;
+ }
+ });
+
+ app.post("/posts/:id/comments", async (c) => {
+ const postId = c.req.param("id");
+ const identity = await identityService.identify(c);
+ await enforceRateLimit("createComment", identity);
+ const body = await c.req.parseBody();
+ const boardSlugRaw = pullText(body.boardSlug).trim();
+ const author = pullText(body.author ?? body.name ?? "");
+ const commentBody = pullText(body.body ?? body.comment ?? "").trim();
+ const parentIdRaw = pullText(body.parentCommentId ?? body.parentId ?? "").trim();
+ const imageFile = pickFile(body.image);
+
+ const { board, account } = await ensurePostBoardAccess(c, postId);
+
+ const redirectSlug = boardSlugRaw || board.slug;
+
+ const targetSlug = redirectSlug || "";
+
+ const buildRedirectUrl = (params: URLSearchParams, anchor?: string) => {
+ const base = targetSlug ? `/${targetSlug}` : "/";
+ const query = params.toString();
+ return `${base}${query ? `?${query}` : ""}${anchor ?? ""}`;
+ };
+
+ try {
+ const imagePayload = imageFile && imageFile.size > 0
+ ? {
+ data: new Uint8Array(await imageFile.arrayBuffer()),
+ mimeType: imageFile.type,
+ originalName: imageFile.name,
+ }
+ : undefined;
+
+ const comment = await deps.createCommentUseCase.execute({
+ postId,
+ author,
+ body: commentBody,
+ parentCommentId: parentIdRaw.length > 0 ? parentIdRaw : undefined,
+ image: imagePayload,
+ authorUserId: account?.id,
+ authorUsername: account?.username,
+ });
+
+ const params = new URLSearchParams({ success: "Reply posted." });
+ return c.redirect(buildRedirectUrl(params, `#comment-${comment.id}`), 303);
+ } catch (error) {
+ if (error instanceof ApplicationError) {
+ const params = new URLSearchParams({ error: error.message });
+ const replyTarget = parentIdRaw.length > 0 ? parentIdRaw : postId;
+ params.set("reply", replyTarget);
+ return c.redirect(buildRedirectUrl(params, `#reply-form-${postId}`), 303);
+ }
+ throw error;
+ }
+ });
+
+ app.get("/:slug/archive", async (c) => {
+ const slug = c.req.param("slug");
+ if (["assets", "uploads", "api"].includes(slug)) {
+ return c.notFound();
+ }
+ try {
+ const board = await deps.getBoardBySlugUseCase.execute(slug);
+ const account = await ensureBoardAccess(c, board.slug);
+ const posts = await deps.listArchivedPostsUseCase.execute(board.id);
+ const threads = await buildThreadModels(posts, deps.listCommentsUseCase);
+ const url = new URL(c.req.url);
+ const flash = {
+ success: url.searchParams.get("success") ?? undefined,
+ error: url.searchParams.get("error") ?? undefined,
+ };
+
+ return c.html(
+ renderAppPage({
+ mode: "boardArchive",
+ title: `${board.name} Archive - ${deps.appName}`,
+ heading: `/${board.slug}/ - Archive`,
+ description: board.description,
+ assetBasePath,
+ board: {
+ id: board.id,
+ slug: board.slug,
+ name: board.name,
+ description: board.description,
+ themeColor: board.themeColor,
+ readOnly: true,
+ },
+ threadTtlDays: deps.threadTtlDays,
+ posts: threads,
+ flash,
+ currentUser: account ? { username: account.username } : null,
+ isMemberBoard: board.slug === deps.memberBoard.slug,
+ }),
+ );
+ } catch (error) {
+ if (error instanceof ApplicationError && error.status === 403) {
+ const params = new URLSearchParams({
+ error: "Members only. Sign in to access this board.",
+ });
+ return c.redirect(`/?${params.toString()}`, 303);
+ }
+ throw error;
+ }
+ });
+
+ app.get("/:slug", async (c) => {
+ const slug = c.req.param("slug");
+ if (["assets", "uploads", "api"].includes(slug) || slug === "") {
+ return c.notFound();
+ }
+ try {
+ const board = await deps.getBoardBySlugUseCase.execute(slug);
+ const account = await ensureBoardAccess(c, board.slug);
+ const posts = await deps.listPostsUseCase.execute(board.id);
+ const threads = await buildThreadModels(posts, deps.listCommentsUseCase);
+ const url = new URL(c.req.url);
+ const flash = {
+ success: url.searchParams.get("success") ?? undefined,
+ error: url.searchParams.get("error") ?? undefined,
+ };
+
+ const replyId = url.searchParams.get("reply") ?? undefined;
+ let replyTarget: { type: "post" | "comment"; id: string } | null = null;
+ if (replyId) {
+ if (threads.some((thread) => thread.id === replyId)) {
+ replyTarget = { type: "post", id: replyId };
+ } else {
+ for (const thread of threads) {
+ if (thread.comments.some((comment) => comment.id === replyId)) {
+ replyTarget = { type: "comment", id: replyId };
+ break;
+ }
+ }
+ }
+ }
+
+ return c.html(
+ renderAppPage({
+ mode: "board",
+ title: `${board.name} - ${deps.appName}`,
+ heading: `/${board.slug}/ - ${board.name}`,
+ description: board.description,
+ assetBasePath,
+ board: {
+ id: board.id,
+ slug: board.slug,
+ name: board.name,
+ description: board.description,
+ themeColor: board.themeColor,
+ readOnly: false,
+ },
+ threadTtlDays: deps.threadTtlDays,
+ posts: threads,
+ flash,
+ replyTarget,
+ currentUser: account ? { username: account.username } : null,
+ isMemberBoard: board.slug === deps.memberBoard.slug,
+ }),
+ );
+ } catch (error) {
+ if (error instanceof ApplicationError && error.status === 403) {
+ const params = new URLSearchParams({
+ error: "Members only. Sign in to access this board.",
+ });
+ return c.redirect(`/?${params.toString()}`, 303);
+ }
+ throw error;
+ }
+ });
+
+ return app;
+};
--- /dev/null
+import { h } from "preact";
+import type { JSX } from "preact";
+import { type FlashState, renderLayout } from "./shared.ts";
+
+type BoardContext = {
+ id: string;
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+ readOnly: boolean;
+};
+
+type ThreadComment = {
+ id: string;
+ postId: string;
+ parentCommentId?: string;
+ author: string;
+ body: string;
+ imagePath?: string;
+ createdAt: string;
+};
+
+type Thread = {
+ id: string;
+ title: string;
+ description: string;
+ imagePath: string;
+ createdAt: string;
+ commentCount: number;
+ status: "active" | "archived";
+ readOnly: boolean;
+ archivedAt?: string | null;
+ permanent?: boolean;
+ comments: ThreadComment[];
+};
+
+type ReplyTarget = { type: "post"; id: string } | { type: "comment"; id: string };
+
+export interface RenderBoardPageOptions {
+ mode: "board" | "boardArchive";
+ title: string;
+ heading: string;
+ description: string;
+ assetBasePath: string;
+ board: BoardContext;
+ threadTtlDays: number;
+ posts: Thread[];
+ flash?: FlashState;
+ replyTarget?: ReplyTarget | null;
+ currentUser: { username: string } | null;
+ isMemberBoard: boolean;
+}
+
+type CommentNode = ThreadComment & { children: CommentNode[] };
+
+const shortId = (id: string): string => id.slice(0, 8);
+
+const formatTimestamp = (value: string): string => {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return date.toLocaleString();
+};
+
+const buildCommentTree = (comments: ThreadComment[]): CommentNode[] => {
+ const nodes = new Map<string, CommentNode>();
+ for (const comment of comments) {
+ nodes.set(comment.id, { ...comment, children: [] });
+ }
+
+ const roots: CommentNode[] = [];
+ for (const comment of comments) {
+ const node = nodes.get(comment.id);
+ if (!node) {
+ continue;
+ }
+ if (comment.parentCommentId && nodes.has(comment.parentCommentId)) {
+ nodes.get(comment.parentCommentId)?.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ const sort = (items: CommentNode[]) => {
+ items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+ for (const item of items) {
+ sort(item.children);
+ }
+ };
+
+ sort(roots);
+ return roots;
+};
+
+const createCommentForm = (options: {
+ boardSlug: string;
+ postId: string;
+ parentCommentId?: string;
+ formId: string;
+ currentUser: { username: string } | null;
+}) => {
+ const authorId = `${options.formId}-author`;
+ const bodyId = `${options.formId}-body`;
+ const imageId = `${options.formId}-image`;
+
+ return h(
+ "form",
+ {
+ class: "comment-form",
+ method: "post",
+ action: `/posts/${options.postId}/comments`,
+ encType: "multipart/form-data",
+ id: options.formId,
+ },
+ [
+ h("input", { type: "hidden", name: "boardSlug", value: options.boardSlug }),
+ options.parentCommentId &&
+ h("input", { type: "hidden", name: "parentCommentId", value: options.parentCommentId }),
+ options.currentUser
+ ? h("p", { class: "muted" }, `Posting as ${options.currentUser.username}`)
+ : [
+ h("label", { htmlFor: authorId }, "Name"),
+ h("input", {
+ id: authorId,
+ name: "author",
+ placeholder: "Anonymous",
+ }),
+ ],
+ options.currentUser &&
+ h("input", { type: "hidden", name: "author", value: "" }),
+ h("label", { htmlFor: bodyId }, "Reply"),
+ h("textarea", {
+ id: bodyId,
+ name: "body",
+ rows: 3,
+ required: true,
+ }),
+ h("label", { htmlFor: imageId }, "Image"),
+ h("input", {
+ id: imageId,
+ name: "image",
+ type: "file",
+ accept: "image/*",
+ }),
+ h("button", { type: "submit" }, "Submit"),
+ ],
+ );
+};
+
+const renderCommentNode = (
+ node: CommentNode,
+ context: {
+ boardSlug: string;
+ postId: string;
+ commentLookup: Set<string>;
+ replyTargetCommentId?: string;
+ },
+): JSX.Element => {
+ const parentAnchor = node.parentCommentId
+ ? context.commentLookup.has(node.parentCommentId)
+ ? `comment-${node.parentCommentId}`
+ : `post-${node.parentCommentId}`
+ : null;
+ const isHighlighted = context.replyTargetCommentId === node.id;
+ const replies = node.children;
+
+ return h(
+ "div",
+ {
+ class: `reply${isHighlighted ? " highlight" : ""}`,
+ id: `comment-${node.id}`,
+ key: node.id,
+ },
+ [
+ h("header", null, [
+ h("span", { class: "post-meta" }, node.author || "Anonymous"),
+ h("span", { class: "post-date" }, formatTimestamp(node.createdAt)),
+ h("a", { class: "id-link", href: `#comment-${node.id}` }, `No.${shortId(node.id)}`),
+ parentAnchor &&
+ h("span", { class: "parent-ref" }, [
+ ">>",
+ h(
+ "a",
+ { class: "id-link parent-link", href: `#${parentAnchor}` },
+ shortId(node.parentCommentId!),
+ ),
+ ]),
+ replies.length > 0 &&
+ h(
+ "span",
+ { class: "reply-list" },
+ [
+ "Replies:",
+ ...replies.map((child) =>
+ h(
+ "a",
+ { class: "id-link reply-ref", href: `#comment-${child.id}`, key: child.id },
+ shortId(child.id),
+ )
+ ),
+ ],
+ ),
+ h(
+ "a",
+ {
+ class: "id-link",
+ href: `/${context.boardSlug}?reply=${node.id}#reply-form-${context.postId}`,
+ },
+ "Reply",
+ ),
+ ]),
+ node.imagePath &&
+ h("div", { class: "comment-attachment" }, [
+ h(
+ "a",
+ { href: node.imagePath, target: "_blank", rel: "noopener" },
+ h("img", {
+ src: node.imagePath,
+ alt: `reply ${shortId(node.id)} attachment`,
+ }),
+ ),
+ ]),
+ h("div", { class: "thread-text" }, node.body),
+ ...replies.map((child) => renderCommentNode(child, context)),
+ ],
+ );
+};
+
+const renderThread = (
+ post: Thread,
+ context: {
+ board: BoardContext;
+ mode: "board" | "boardArchive";
+ replyTarget?: ReplyTarget | null;
+ currentUser: { username: string } | null;
+ },
+) => {
+ const canReplyToPost = context.mode === "board" && !context.board.readOnly && !post.readOnly &&
+ post.status === "active";
+ const commentLookup = new Set(post.comments.map((comment) => comment.id));
+ const replyCommentId =
+ context.replyTarget?.type === "comment" && commentLookup.has(context.replyTarget.id)
+ ? context.replyTarget.id
+ : undefined;
+ const isReplyingToPost = context.replyTarget?.type === "post" &&
+ context.replyTarget.id === post.id;
+ const commentTree = buildCommentTree(post.comments);
+ const title = post.title?.trim() ?? "";
+ const description = post.description.trim();
+ const hasImage = Boolean(post.imagePath);
+ const replies = post.comments.length === 0
+ ? [h("p", { class: "muted" }, "No replies yet.")]
+ : commentTree.map((node) =>
+ renderCommentNode(node, {
+ boardSlug: context.board.slug,
+ postId: post.id,
+ commentLookup,
+ replyTargetCommentId: replyCommentId,
+ })
+ );
+
+ return h(
+ "article",
+ { class: "thread", id: `post-${post.id}`, key: post.id },
+ [
+ h("header", null, [
+ h("span", { class: "post-meta" }, `/${context.board.slug}/`),
+ title && h("strong", null, title),
+ h("span", { class: "post-date" }, formatTimestamp(post.createdAt)),
+ h(
+ "span",
+ { class: "muted" },
+ `${post.commentCount} repl${post.commentCount === 1 ? "y" : "ies"}`,
+ ),
+ h("span", { class: "muted" }, `No.${shortId(post.id)}`),
+ post.status === "archived" && h("span", { class: "status-pill" }, "Archived"),
+ post.readOnly && h("span", { class: "status-pill" }, "Read-only"),
+ post.permanent && h("span", { class: "status-pill" }, "Pinned"),
+ h(
+ "a",
+ {
+ class: "id-link",
+ href: `/${context.board.slug}?reply=${post.id}#reply-form-${post.id}`,
+ },
+ "Reply to thread",
+ ),
+ ]),
+ (() => {
+ const bodyChildren: JSX.Element[] = [];
+ if (hasImage && post.imagePath) {
+ bodyChildren.push(
+ h("div", { class: "attachment" }, [
+ h(
+ "a",
+ { href: post.imagePath, target: "_blank", rel: "noopener" },
+ h("img", {
+ src: post.imagePath,
+ alt: title || `thread ${shortId(post.id)} image`,
+ }),
+ ),
+ ]),
+ );
+ }
+ bodyChildren.push(
+ h("div", { class: "thread-text" }, [
+ description ? description : h("span", { class: "muted" }, "No description."),
+ ]),
+ );
+ return h("div", { class: `thread-body${hasImage ? "" : " no-image"}` }, bodyChildren);
+ })(),
+ h("div", { class: "thread-replies" }, replies),
+ context.mode === "board" && (
+ canReplyToPost
+ ? h("div", { class: "thread-reply", id: `reply-form-${post.id}` }, [
+ h("h3", { class: "section-title" }, "Reply to this thread"),
+ replyCommentId &&
+ h("p", { class: "muted" }, `Replying to No.${shortId(replyCommentId)}`),
+ !replyCommentId && isReplyingToPost &&
+ h("p", { class: "muted" }, "Replying to this thread."),
+ createCommentForm({
+ boardSlug: context.board.slug,
+ postId: post.id,
+ parentCommentId: replyCommentId,
+ formId: `comment-form-${post.id}`,
+ currentUser: context.currentUser,
+ }),
+ ])
+ : h(
+ "p",
+ { class: "muted" },
+ "Thread is read-only.",
+ )
+ ),
+ ],
+ );
+};
+
+const renderPostForm = (board: BoardContext) =>
+ h(
+ "form",
+ {
+ class: "post-form",
+ method: "post",
+ action: `/${board.slug}/posts`,
+ encType: "multipart/form-data",
+ id: "post-form",
+ },
+ [
+ h("h2", { class: "section-title" }, "Start a new thread"),
+ h("div", { class: "form-grid" }, [
+ h("label", { htmlFor: "thread-title" }, "Subject (optional)"),
+ h("input", { id: "thread-title", name: "title" }),
+ h("label", { htmlFor: "thread-description" }, "Comment"),
+ h("textarea", { id: "thread-description", name: "description", rows: 4, required: true }),
+ h("label", { htmlFor: "thread-image" }, "File (optional)"),
+ h("input", {
+ id: "thread-image",
+ type: "file",
+ name: "image",
+ accept: "image/*",
+ }),
+ ]),
+ h("div", { class: "form-actions" }, [
+ h("button", { type: "submit" }, "Post Thread"),
+ ]),
+ ],
+ );
+
+export const renderAppPage = (options: RenderBoardPageOptions): string => {
+ const navLinks = [
+ h("a", { href: "/" }, "Home"),
+ options.mode === "board"
+ ? h("a", { href: `/${options.board.slug}/archive` }, "View archive")
+ : h("a", { href: `/${options.board.slug}` }, "View board"),
+ ];
+
+ const content = h("main", null, [
+ h("section", { class: "board-header" }, [
+ h("div", { class: "board-nav" }, navLinks),
+ h("h1", { class: "board-title" }, options.heading),
+ h("p", { class: "board-desc" }, options.description),
+ h(
+ "p",
+ { class: "muted" },
+ options.mode === "board"
+ ? `Threads archive after ${options.threadTtlDays} day${
+ options.threadTtlDays === 1 ? "" : "s"
+ }.`
+ : "Archive view · threads are read-only.",
+ ),
+ options.isMemberBoard && h("span", { class: "status-pill" }, "Members only"),
+ options.currentUser &&
+ h("p", { class: "muted" }, `Signed in as ${options.currentUser.username}.`),
+ ]),
+ options.mode === "board" && !options.board.readOnly
+ ? renderPostForm(options.board)
+ : options.mode === "board" &&
+ h("p", { class: "muted" }, "Posting new threads is disabled for this board."),
+ options.posts.length === 0
+ ? h(
+ "p",
+ { class: "muted" },
+ options.mode === "board"
+ ? "No threads yet. Start the conversation above."
+ : "No archived threads.",
+ )
+ : h("section", { class: "threads" }, [
+ ...options.posts.map((post) =>
+ renderThread(post, {
+ board: options.board,
+ mode: options.mode,
+ replyTarget: options.replyTarget,
+ currentUser: options.currentUser,
+ })
+ ),
+ ]),
+ ]);
+
+ return renderLayout({
+ title: options.title,
+ bodyClass: "board",
+ brandColor: options.board.themeColor,
+ assetBasePath: options.assetBasePath,
+ flash: options.flash,
+ children: content,
+ });
+};
--- /dev/null
+import { h } from "preact";
+import { createAccentStyle, type FlashState, renderLayout, sanitizeHex } from "./shared.ts";
+
+interface BoardSummary {
+ id: string;
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+}
+
+interface BoardRequestSummary {
+ id: string;
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+ voteCount: number;
+ deadline: string;
+ status: string;
+ voted: boolean;
+}
+
+interface Announcement {
+ id: string;
+ message: string;
+ createdAt: string;
+}
+
+interface PopularPostSummary {
+ id: string;
+ boardSlug: string;
+ boardName: string;
+ title?: string;
+ commentCount: number;
+}
+
+type RequestFormDefaults = {
+ slug?: string;
+ name?: string;
+ description?: string;
+ themeColor?: string;
+};
+
+export interface RenderHomePageOptions {
+ title: string;
+ heading: string;
+ description: string;
+ assetBasePath: string;
+ threadTtlDays: number;
+ requestDurationDays: number;
+ boards: BoardSummary[];
+ requests: BoardRequestSummary[];
+ announcements: Announcement[];
+ popularPosts: PopularPostSummary[];
+ flash?: FlashState;
+ showRequestForm?: boolean;
+ requestFormDefaults?: RequestFormDefaults;
+ currentUser: { username: string } | null;
+ memberBoard: {
+ slug: string;
+ name: string;
+ description: string;
+ themeColor: string;
+ };
+ activeAuthForm: "login" | "register" | null;
+ loginPrefill?: string;
+ registerPrefill?: string;
+ footerText: string;
+}
+
+const formatTimeRemaining = (deadlineIso: string): string => {
+ const deadline = new Date(deadlineIso).getTime();
+ if (Number.isNaN(deadline)) {
+ return "Unknown deadline";
+ }
+ const diff = deadline - Date.now();
+ if (diff <= 0) {
+ return "Voting closed";
+ }
+ const minutes = Math.floor(diff / (1000 * 60));
+ if (minutes < 60) {
+ return `${minutes} min left`;
+ }
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) {
+ return `${hours} hr left`;
+ }
+ const days = Math.floor(hours / 24);
+ return `${days} day${days === 1 ? "" : "s"} left`;
+};
+
+const formatDateTime = (value: string): string => {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return date.toLocaleString();
+};
+
+const BulletinBoard = ({ announcements }: { announcements: Announcement[] }) => {
+ if (announcements.length === 0) {
+ return null;
+ }
+ return h("section", { class: "bulletin-board" }, [
+ h("h2", null, "Announcements"),
+ h(
+ "ul",
+ null,
+ announcements.map((entry) =>
+ h("li", { key: entry.id }, [
+ h("p", null, entry.message),
+ h("span", { class: "muted" }, formatDateTime(entry.createdAt)),
+ ])
+ ),
+ ),
+ ]);
+};
+
+const BoardGrid = ({ boards }: { boards: BoardSummary[] }) => {
+ if (boards.length === 0) {
+ return h("section", { class: "board-grid" }, [
+ h("p", { class: "muted" }, "No boards yet. Start a new discussion with a request below."),
+ ]);
+ }
+
+ return h(
+ "section",
+ { class: "board-grid" },
+ boards.map((board) =>
+ h(
+ "a",
+ {
+ key: board.id,
+ class: "board-card",
+ href: `/${board.slug}`,
+ style: createAccentStyle(board.themeColor),
+ },
+ [
+ h(
+ "h3",
+ { style: { color: sanitizeHex(board.themeColor) } },
+ `/${board.slug}/ - ${board.name}`,
+ ),
+ h("p", null, board.description || ""),
+ ],
+ )
+ ),
+ );
+};
+
+const AuthPanel = (
+ { currentUser, memberBoard, activeForm, loginPrefill, registerPrefill }: {
+ currentUser: { username: string } | null;
+ memberBoard: { slug: string; name: string; description: string };
+ activeForm: "login" | "register" | null;
+ loginPrefill?: string;
+ registerPrefill?: string;
+ },
+) => {
+ const registerClass = activeForm === "login" ? "auth-form" : "auth-form active";
+ const loginClass = activeForm === "login" ? "auth-form active" : "auth-form";
+ const summaryLabel = currentUser ? "Member account" : "Create or access your account";
+
+ return h("section", { class: "member-auth" }, [
+ h(
+ "details",
+ {
+ class: "request-panel",
+ open: currentUser ? false : true,
+ },
+ [
+ h("summary", null, summaryLabel),
+ h("div", { class: "requests-body" }, [
+ currentUser
+ ? h("div", { class: "member-card" }, [
+ h("h2", null, `Welcome, ${currentUser.username}!`),
+ h(
+ "p",
+ { class: "muted" },
+ `The private /${memberBoard.slug}/ board is available only to registered members.`,
+ ),
+ h("div", { class: "member-actions" }, [
+ h(
+ "a",
+ {
+ class: "button",
+ href: `/${memberBoard.slug}`,
+ },
+ `Visit /${memberBoard.slug}/`,
+ ),
+ h(
+ "form",
+ { method: "post", action: "/auth/logout", class: "logout-form" },
+ [h("button", { type: "submit" }, "Sign out")],
+ ),
+ ]),
+ ])
+ : h("div", { class: "auth-forms" }, [
+ h("div", { class: "request-list-container" }, [
+ h("h2", null, "Register"),
+ h(
+ "form",
+ {
+ class: registerClass,
+ method: "post",
+ action: "/auth/register",
+ },
+ [
+ h("label", null, [
+ "Username",
+ h("input", {
+ name: "username",
+ required: true,
+ minLength: 3,
+ maxLength: 20,
+ value: registerPrefill ?? "",
+ }),
+ ]),
+ h("label", null, [
+ "Password",
+ h("input", {
+ type: "password",
+ name: "password",
+ required: true,
+ minLength: 4,
+ }),
+ ]),
+ h("button", { type: "submit" }, "Create account"),
+ ],
+ ),
+ ]),
+ h("div", { class: "request-list-container" }, [
+ h("h2", null, "Sign in"),
+ h(
+ "form",
+ {
+ class: loginClass,
+ method: "post",
+ action: "/auth/login",
+ },
+ [
+ h("label", null, [
+ "Username",
+ h("input", {
+ name: "username",
+ required: true,
+ value: loginPrefill ?? "",
+ }),
+ ]),
+ h("label", null, [
+ "Password",
+ h("input", {
+ type: "password",
+ name: "password",
+ required: true,
+ }),
+ ]),
+ h("button", { type: "submit" }, "Sign in"),
+ ],
+ ),
+ ]),
+ ]),
+ ]),
+ ],
+ ),
+ ]);
+};
+
+const PopularPosts = ({ posts }: { posts: PopularPostSummary[] }) => {
+ if (posts.length === 0) {
+ return null;
+ }
+
+ return h("section", { class: "popular-posts" }, [
+ h("h2", null, "Popular Threads"),
+ h(
+ "ul",
+ null,
+ posts.map((post) =>
+ h(
+ "li",
+ { key: post.id },
+ [
+ h("a", { href: `/${post.boardSlug}` }, `/${post.boardSlug}/ - ${post.boardName}`),
+ " · ",
+ h(
+ "strong",
+ null,
+ post.title?.trim() && post.title.trim().length > 0
+ ? post.title.trim()
+ : "(No subject)",
+ ),
+ " ",
+ h(
+ "span",
+ { class: "muted" },
+ `(${post.commentCount} repl${post.commentCount === 1 ? "y" : "ies"})`,
+ ),
+ ],
+ )
+ ),
+ ),
+ ]);
+};
+
+const BoardRequestForm = ({ defaults }: { defaults?: RequestFormDefaults }) => {
+ const color = sanitizeHex(defaults?.themeColor ?? "#1e3a8a");
+ return h(
+ "form",
+ {
+ class: "request-form",
+ method: "post",
+ action: "/board-requests",
+ },
+ [
+ h("h2", null, "Request a new board"),
+ h("label", null, [
+ "Slug",
+ h("input", {
+ name: "slug",
+ required: true,
+ placeholder: "e.g. tech",
+ value: defaults?.slug ?? "",
+ }),
+ ]),
+ h("label", null, [
+ "Name",
+ h("input", {
+ name: "name",
+ required: true,
+ value: defaults?.name ?? "",
+ }),
+ ]),
+ h("label", null, [
+ "Description",
+ h("textarea", {
+ name: "description",
+ rows: 3,
+ value: defaults?.description ?? "",
+ }),
+ ]),
+ h("label", null, [
+ "Theme color",
+ h("input", {
+ type: "color",
+ name: "themeColor",
+ value: color,
+ }),
+ ]),
+ h(
+ "button",
+ { type: "submit" },
+ "Submit request",
+ ),
+ ],
+ );
+};
+
+const BoardRequestList = ({ requests }: { requests: BoardRequestSummary[] }) => {
+ if (requests.length === 0) {
+ return h("p", { class: "muted" }, "No outstanding requests. Be the first to submit one!");
+ }
+
+ return h(
+ "ul",
+ { class: "request-list" },
+ requests.map((request) => {
+ const accent = createAccentStyle(request.themeColor);
+ const buttonLabel = request.status !== "open" ? "Closed" : request.voted ? "Unvote" : "Vote";
+ return h(
+ "li",
+ { key: request.id, style: accent },
+ [
+ h("div", { class: "request-summary" }, [
+ h(
+ "h3",
+ { style: { color: sanitizeHex(request.themeColor) } },
+ `/${request.slug}/ - ${request.name}`,
+ ),
+ h("p", null, request.description || ""),
+ ]),
+ h("div", { class: "request-meta" }, [
+ h("span", null, `${request.voteCount} vote${request.voteCount === 1 ? "" : "s"}`),
+ h("span", { class: "muted" }, formatTimeRemaining(request.deadline)),
+ request.status === "open"
+ ? h(
+ "form",
+ {
+ method: "post",
+ action: `/board-requests/${request.id}/toggle-vote`,
+ },
+ [
+ h(
+ "button",
+ {
+ type: "submit",
+ class: request.voted ? "vote-button voted" : "vote-button",
+ },
+ buttonLabel,
+ ),
+ ],
+ )
+ : h(
+ "span",
+ { class: "muted" },
+ request.status === "fulfilled" ? "Fulfilled" : "Closed",
+ ),
+ ]),
+ ],
+ );
+ }),
+ );
+};
+
+export const renderHomePage = (options: RenderHomePageOptions): string => {
+ const requestDetailsOpen = options.showRequestForm ?? false;
+ const content = h("main", { class: "home-main" }, [
+ h("section", { class: "board-header" }, [
+ h("div", { class: "board-nav" }, [h("a", { href: "/" }, "Home")]),
+ h("h1", { class: "board-title" }, options.heading),
+ h("p", { class: "board-desc" }, options.description),
+ h(
+ "p",
+ { class: "muted" },
+ `Threads archive after ${options.threadTtlDays} day${
+ options.threadTtlDays === 1 ? "" : "s"
+ }. Request window: ${options.requestDurationDays} day${
+ options.requestDurationDays === 1 ? "" : "s"
+ }.`,
+ ),
+ ]),
+ h(BulletinBoard, { announcements: options.announcements }),
+ h(BoardGrid, { boards: options.boards }),
+ !options.currentUser &&
+ h(
+ "p",
+ { class: "muted" },
+ `Create an account below to unlock the private /${options.memberBoard.slug}/ board.`,
+ ),
+ h("section", { class: "board-requests" }, [
+ h(
+ "details",
+ { class: "request-panel", open: requestDetailsOpen || options.requests.length > 0 },
+ [
+ h("summary", null, "Community board requests"),
+ h("div", { class: "requests-body" }, [
+ h(BoardRequestForm, { defaults: options.requestFormDefaults }),
+ h("div", { class: "request-list-container" }, [
+ h("h2", null, "Active requests"),
+ h(BoardRequestList, { requests: options.requests }),
+ ]),
+ ]),
+ ],
+ ),
+ ]),
+ h(AuthPanel, {
+ currentUser: options.currentUser,
+ memberBoard: options.memberBoard,
+ activeForm: options.activeAuthForm,
+ loginPrefill: options.loginPrefill,
+ registerPrefill: options.registerPrefill,
+ }),
+ h(PopularPosts, { posts: options.popularPosts }),
+ h(
+ "footer",
+ { class: "site-footer" },
+ options.footerText,
+ ),
+ ]);
+
+ return renderLayout({
+ title: options.title,
+ bodyClass: "home",
+ brandColor: "#0f172a",
+ assetBasePath: options.assetBasePath,
+ flash: options.flash,
+ children: content,
+ });
+};
--- /dev/null
+import { h } from "preact";
+import { renderToString } from "preact-render-to-string";
+import type { ComponentChildren, JSX, VNode } from "preact";
+
+export interface FlashState {
+ success?: string;
+ error?: string;
+}
+
+type LayoutOptions = {
+ title: string;
+ bodyClass?: string;
+ brandColor?: string;
+ flash?: FlashState;
+ assetBasePath: string;
+ children: ComponentChildren;
+};
+
+const DEFAULT_BRAND = "#0f172a";
+
+export const sanitizeHex = (value: string | undefined): string => {
+ const color = value?.trim();
+ if (!color) {
+ return DEFAULT_BRAND;
+ }
+ if (/^#[0-9a-fA-F]{6}$/.test(color)) {
+ return color.toLowerCase();
+ }
+ return DEFAULT_BRAND;
+};
+
+export const hexToRgb = (hex: string) => {
+ const value = hex.replace("#", "");
+ const int = Number.parseInt(value, 16);
+ return {
+ r: (int >> 16) & 255,
+ g: (int >> 8) & 255,
+ b: int & 255,
+ };
+};
+
+const buildBrandStyle = (hex: string): string => {
+ const color = sanitizeHex(hex);
+ const { r, g, b } = hexToRgb(color);
+ const border = `rgba(${r}, ${g}, ${b}, 0.35)`;
+ return `--brand-color: ${color}; --brand-border: ${border};`;
+};
+
+export const createAccentStyle = (hex: string): Record<string, string> => {
+ const color = sanitizeHex(hex);
+ const { r, g, b } = hexToRgb(color);
+ return {
+ borderColor: `rgba(${r}, ${g}, ${b}, 0.35)`,
+ background: `rgba(${r}, ${g}, ${b}, 0.08)`,
+ color,
+ };
+};
+
+const FLASH_SCRIPT = `(function(){
+ const container = document.querySelector(".flash-container");
+ if (!container) return;
+ container.addEventListener("click", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ const toast = target.closest("[data-flash-toast]");
+ if (!toast) {
+ return;
+ }
+ toast.remove();
+ if (!container.querySelector("[data-flash-toast]")) {
+ container.remove();
+ }
+ }, { passive: true });
+})();`;
+
+const FlashBanner = ({ flash }: { flash?: FlashState }) => {
+ const messages = [
+ flash?.error ? { type: "error", text: flash.error } : null,
+ flash?.success ? { type: "success", text: flash.success } : null,
+ ].filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
+
+ if (messages.length === 0) {
+ return null;
+ }
+
+ return h(
+ "div",
+ { class: "flash-container", role: "region", "aria-live": "polite" },
+ messages.map((message, index) =>
+ h(
+ "button",
+ {
+ type: "button",
+ class: `flash-toast flash-${message.type}`,
+ "data-flash-toast": "true",
+ "aria-label": `${message.type === "error" ? "Dismiss error" : "Dismiss message"}`,
+ key: `${message.type}-${index}`,
+ },
+ message.text,
+ )
+ ),
+ );
+};
+
+export const renderLayout = (options: LayoutOptions): string => {
+ const brandColor = sanitizeHex(options.brandColor ?? DEFAULT_BRAND);
+ const assetBasePath = options.assetBasePath.replace(/\/+$/, "");
+ const stylesheetHref = `${assetBasePath}/styles.css`;
+ const hasFlash = Boolean(options.flash?.error || options.flash?.success);
+ const bodyChildren: ComponentChildren[] = [
+ h(FlashBanner, { flash: options.flash }),
+ options.children,
+ ];
+
+ if (hasFlash) {
+ bodyChildren.push(
+ h("script", {
+ dangerouslySetInnerHTML: { __html: FLASH_SCRIPT },
+ }),
+ );
+ }
+
+ const htmlVNode = h("html", { lang: "en" } as JSX.HTMLAttributes<HTMLHtmlElement>, [
+ h("head", null, [
+ h("meta", { charset: "utf-8" }),
+ h("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0" }),
+ h("title", null, options.title),
+ h("link", { rel: "stylesheet", href: stylesheetHref }),
+ ]),
+ h(
+ "body",
+ {
+ class: options.bodyClass ?? "",
+ style: buildBrandStyle(brandColor),
+ },
+ bodyChildren,
+ ),
+ ]) as VNode;
+
+ const html = renderToString(htmlVNode);
+
+ return `<!DOCTYPE html>${html}`;
+};
--- /dev/null
+const objectToString = Object.prototype.toString;
+
+const isDate = (value: unknown): value is Date =>
+ value instanceof Date || objectToString.call(value) === "[object Date]";
+
+const isTypedArray = (value: unknown): value is ArrayBufferView =>
+ ArrayBuffer.isView(value) && !(value instanceof DataView);
+
+const isPlainObject = (value: unknown): value is Record<string, unknown> => {
+ if (value === null || typeof value !== "object") {
+ return false;
+ }
+ if (Array.isArray(value) || isTypedArray(value) || isDate(value)) {
+ return false;
+ }
+ return Object.getPrototypeOf(value) === Object.prototype;
+};
+
+const deepEqual = (a: unknown, b: unknown, seen = new WeakMap<object, object>()): boolean => {
+ if (Object.is(a, b)) {
+ return true;
+ }
+
+ if (typeof a !== typeof b) {
+ return false;
+ }
+
+ if (a === null || b === null) {
+ return false;
+ }
+
+ if (typeof a !== "object" || typeof b !== "object") {
+ return false;
+ }
+
+ if (seen.get(a as object) === b) {
+ return true;
+ }
+ seen.set(a as object, b as object);
+
+ if (isDate(a) && isDate(b)) {
+ return a.getTime() === b.getTime();
+ }
+
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0; i < a.length; i += 1) {
+ if (!deepEqual(a[i], b[i], seen)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if (isTypedArray(a) && isTypedArray(b)) {
+ if (a.byteLength !== b.byteLength || a.constructor !== b.constructor) {
+ return false;
+ }
+ for (let i = 0; i < (a as Uint8Array).length; i += 1) {
+ if ((a as Uint8Array)[i] !== (b as Uint8Array)[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if (isPlainObject(a) && isPlainObject(b)) {
+ const keysA = Object.keys(a);
+ const keysB = Object.keys(b);
+ if (keysA.length !== keysB.length) {
+ return false;
+ }
+
+ for (const key of keysA) {
+ if (!Object.prototype.hasOwnProperty.call(b, key)) {
+ return false;
+ }
+ if (
+ !deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key], seen)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ return false;
+};
+
+const formatValue = (value: unknown): string => {
+ if (typeof value === "string") {
+ return `"${value}"`;
+ }
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+};
+
+export const assertEquals = (actual: unknown, expected: unknown, message?: string) => {
+ if (!deepEqual(actual, expected)) {
+ throw new Error(
+ message ??
+ `Assertion failed: expected ${formatValue(expected)}, received ${formatValue(actual)}`,
+ );
+ }
+};
+
+export const assertStrictEquals = (actual: unknown, expected: unknown, message?: string) => {
+ if (!Object.is(actual, expected)) {
+ throw new Error(
+ message ??
+ `Assertion failed: expected ${formatValue(expected)}, received ${formatValue(actual)}`,
+ );
+ }
+};
+
+type ErrorClass<T extends Error = Error> = new (message?: string) => T;
+
+export const assertRejects = async (
+ fn: () => Promise<unknown>,
+ ExpectedError: ErrorClass = Error,
+ messageIncludes?: string,
+): Promise<void> => {
+ try {
+ await fn();
+ } catch (error) {
+ if (!(error instanceof ExpectedError)) {
+ throw new Error(`Assertion failed: expected rejection with ${ExpectedError.name}`);
+ }
+ if (messageIncludes && !(error as Error).message.includes(messageIncludes)) {
+ throw new Error(
+ `Assertion failed: expected error message to include "${messageIncludes}", got "${
+ (error as Error).message
+ }"`,
+ );
+ }
+ return;
+ }
+ throw new Error("Assertion failed: expected promise to reject");
+};
--- /dev/null
+import { Clock } from "../application/contracts/clock.ts";
+import { IdGenerator } from "../application/contracts/id_generator.ts";
+import { ApplicationError } from "../application/errors/application_error.ts";
+import { Board } from "../domain/entities/board.ts";
+import { BoardAnnouncement } from "../domain/entities/board_announcement.ts";
+import { BoardRequest } from "../domain/entities/board_request.ts";
+import { Comment } from "../domain/entities/comment.ts";
+import { ImagePost } from "../domain/entities/image_post.ts";
+import { User } from "../domain/entities/user.ts";
+import { BoardRepository } from "../domain/repositories/board_repository.ts";
+import { BoardAnnouncementRepository } from "../domain/repositories/board_announcement_repository.ts";
+import { BoardRequestRepository } from "../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../domain/repositories/board_request_vote_repository.ts";
+import { CommentRepository } from "../domain/repositories/comment_repository.ts";
+import { PostRepository } from "../domain/repositories/post_repository.ts";
+import { ImageStorage, SaveImageInput } from "../domain/services/image_storage.ts";
+import { UserRepository } from "../domain/repositories/user_repository.ts";
+
+export class FixedClock implements Clock {
+ constructor(private current: Date) {}
+
+ now(): Date {
+ return new Date(this.current);
+ }
+
+ tickBy(milliseconds: number) {
+ this.current = new Date(this.current.getTime() + milliseconds);
+ }
+
+ set(date: Date) {
+ this.current = new Date(date);
+ }
+}
+
+export class SequenceIdGenerator implements IdGenerator {
+ private index = 0;
+
+ constructor(private readonly prefix = "id") {}
+
+ generate(): string {
+ this.index += 1;
+ return `${this.prefix}-${this.index}`;
+ }
+}
+
+export class InMemoryImageStorage implements ImageStorage {
+ private readonly files = new Map<string, Uint8Array>();
+
+ constructor(private readonly basePath = "/uploads") {}
+
+ saveImage(input: SaveImageInput): Promise<string> {
+ const path = `${this.basePath}/images/${input.id}`;
+ this.files.set(path, input.data);
+ return Promise.resolve(path);
+ }
+
+ getImage(path: string): Promise<Uint8Array> {
+ const data = this.files.get(path);
+ if (!data) {
+ return Promise.reject(new ApplicationError("Image not found.", 404));
+ }
+ return Promise.resolve(data);
+ }
+
+ deleteImage(path: string): Promise<void> {
+ this.files.delete(path);
+ return Promise.resolve();
+ }
+}
+
+export class InMemoryPostRepository implements PostRepository {
+ private readonly posts = new Map<string, ImagePost>();
+
+ create(post: ImagePost): Promise<void> {
+ this.posts.set(post.id, structuredClone(post));
+ return Promise.resolve();
+ }
+
+ findById(id: string): Promise<ImagePost | null> {
+ const post = this.posts.get(id);
+ return Promise.resolve(post ? structuredClone(post) : null);
+ }
+
+ listAll(): Promise<ImagePost[]> {
+ return Promise.resolve(Array.from(this.posts.values()).map((post) => structuredClone(post)));
+ }
+
+ listByBoard(boardId: string): Promise<ImagePost[]> {
+ return Promise.resolve(
+ Array.from(this.posts.values())
+ .filter((post) => post.boardId === boardId)
+ .map((post) => structuredClone(post)),
+ );
+ }
+
+ update(post: ImagePost): Promise<void> {
+ this.posts.set(post.id, structuredClone(post));
+ return Promise.resolve();
+ }
+
+ delete(id: string): Promise<void> {
+ this.posts.delete(id);
+ return Promise.resolve();
+ }
+}
+
+export class InMemoryCommentRepository implements CommentRepository {
+ private readonly comments = new Map<string, Comment>();
+
+ create(comment: Comment): Promise<void> {
+ this.comments.set(this.key(comment.postId, comment.id), structuredClone(comment));
+ return Promise.resolve();
+ }
+
+ findById(postId: string, id: string): Promise<Comment | null> {
+ const comment = this.comments.get(this.key(postId, id));
+ return Promise.resolve(comment ? structuredClone(comment) : null);
+ }
+
+ listByPost(postId: string): Promise<Comment[]> {
+ return Promise.resolve(
+ Array.from(this.comments.values())
+ .filter((comment) => comment.postId === postId)
+ .map((comment) => structuredClone(comment)),
+ );
+ }
+
+ deleteByPost(postId: string): Promise<void> {
+ for (const key of this.comments.keys()) {
+ if (key.startsWith(`${postId}::`)) {
+ this.comments.delete(key);
+ }
+ }
+ return Promise.resolve();
+ }
+
+ countByPost(postId: string): Promise<number> {
+ let count = 0;
+ for (const comment of this.comments.values()) {
+ if (comment.postId === postId) {
+ count += 1;
+ }
+ }
+ return Promise.resolve(count);
+ }
+
+ private key(postId: string, id: string) {
+ return `${postId}::${id}`;
+ }
+}
+
+export class InMemoryBoardRepository implements BoardRepository {
+ private readonly boards = new Map<string, Board>();
+
+ create(board: Board): Promise<void> {
+ this.boards.set(board.id, structuredClone(board));
+ return Promise.resolve();
+ }
+
+ update(board: Board): Promise<void> {
+ this.boards.set(board.id, structuredClone(board));
+ return Promise.resolve();
+ }
+
+ findById(id: string): Promise<Board | null> {
+ const board = this.boards.get(id);
+ return Promise.resolve(board ? structuredClone(board) : null);
+ }
+
+ findBySlug(slug: string): Promise<Board | null> {
+ for (const board of this.boards.values()) {
+ if (board.slug === slug) {
+ return Promise.resolve(structuredClone(board));
+ }
+ }
+ return Promise.resolve(null);
+ }
+
+ list(): Promise<Board[]> {
+ return Promise.resolve(Array.from(this.boards.values()).map((board) => structuredClone(board)));
+ }
+
+ delete(id: string): Promise<void> {
+ this.boards.delete(id);
+ return Promise.resolve();
+ }
+}
+
+export class InMemoryBoardRequestRepository implements BoardRequestRepository {
+ private readonly requests = new Map<string, BoardRequest>();
+
+ create(request: BoardRequest): Promise<void> {
+ this.requests.set(request.id, structuredClone(request));
+ return Promise.resolve();
+ }
+
+ update(request: BoardRequest): Promise<void> {
+ this.requests.set(request.id, structuredClone(request));
+ return Promise.resolve();
+ }
+
+ findById(id: string): Promise<BoardRequest | null> {
+ const request = this.requests.get(id);
+ return Promise.resolve(request ? structuredClone(request) : null);
+ }
+
+ list(): Promise<BoardRequest[]> {
+ return Promise.resolve(
+ Array.from(this.requests.values()).map((request) => structuredClone(request)),
+ );
+ }
+
+ delete(id: string): Promise<void> {
+ this.requests.delete(id);
+ return Promise.resolve();
+ }
+}
+
+const normalizeUsername = (value: string): string => value.toLowerCase();
+
+export class InMemoryUserRepository implements UserRepository {
+ private readonly users = new Map<string, User>();
+ private readonly byUsername = new Map<string, string>();
+
+ create(user: User): Promise<void> {
+ const normalized = normalizeUsername(user.username);
+ if (this.byUsername.has(normalized)) {
+ throw new Error("Username already exists.");
+ }
+ this.users.set(user.id, structuredClone(user));
+ this.byUsername.set(normalized, user.id);
+ return Promise.resolve();
+ }
+
+ findById(id: string): Promise<User | null> {
+ const user = this.users.get(id);
+ return Promise.resolve(user ? structuredClone(user) : null);
+ }
+
+ findByUsername(username: string): Promise<User | null> {
+ const id = this.byUsername.get(normalizeUsername(username));
+ if (!id) {
+ return Promise.resolve(null);
+ }
+ return this.findById(id);
+ }
+
+ isUsernameTaken(username: string): Promise<boolean> {
+ return Promise.resolve(this.byUsername.has(normalizeUsername(username)));
+ }
+}
+
+export class InMemoryBoardRequestVoteRepository implements BoardRequestVoteRepository {
+ private readonly votes = new Set<string>();
+
+ hasVote(requestId: string, userId: string): Promise<boolean> {
+ return Promise.resolve(this.votes.has(this.key(requestId, userId)));
+ }
+
+ addVote(requestId: string, userId: string): Promise<void> {
+ this.votes.add(this.key(requestId, userId));
+ return Promise.resolve();
+ }
+
+ removeVote(requestId: string, userId: string): Promise<void> {
+ this.votes.delete(this.key(requestId, userId));
+ return Promise.resolve();
+ }
+
+ countVotes(requestId: string): Promise<number> {
+ let count = 0;
+ for (const vote of this.votes.values()) {
+ if (vote.startsWith(`${requestId}::`)) {
+ count += 1;
+ }
+ }
+ return Promise.resolve(count);
+ }
+
+ private key(requestId: string, userId: string) {
+ return `${requestId}::${userId}`;
+ }
+}
+
+export class InMemoryBoardAnnouncementRepository implements BoardAnnouncementRepository {
+ private readonly announcements = new Map<string, BoardAnnouncement>();
+
+ list(): Promise<BoardAnnouncement[]> {
+ return Promise.resolve(
+ Array.from(this.announcements.values())
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+ .map((announcement) => structuredClone(announcement)),
+ );
+ }
+
+ create(announcement: BoardAnnouncement): Promise<void> {
+ this.announcements.set(announcement.id, structuredClone(announcement));
+ return Promise.resolve();
+ }
+
+ delete(id: string): Promise<void> {
+ this.announcements.delete(id);
+ return Promise.resolve();
+ }
+}
--- /dev/null
+*
+!.gitignore