From 8a74aa4651964727c7315d0591d914eb9a6a4167 Mon Sep 17 00:00:00 2001 From: Roberto Morado Date: Tue, 14 Apr 2026 13:17:19 -0400 Subject: [PATCH] Initial commit --- README.md | 190 +++ config/app.config.json | 36 + deno.json | 29 + deno.lock | 50 + dist/.DS_Store | Bin 0 -> 6148 bytes main.ts | 77 ++ public/styles.css | 619 +++++++++ scripts/announcements.ts | 87 ++ scripts/boards.ts | 216 +++ scripts/build.ts | 56 + scripts/db.ts | 101 ++ scripts/members.ts | 152 +++ scripts/threads.ts | 172 +++ src/.DS_Store | Bin 0 -> 6148 bytes src/application/.DS_Store | Bin 0 -> 6148 bytes src/application/contracts/clock.ts | 3 + src/application/contracts/id_generator.ts | 3 + src/application/errors/application_error.ts | 6 + .../archive_expired_posts_use_case.ts | 30 + .../archive_expired_posts_use_case_test.ts | 74 ++ .../usecases/authenticate_user_use_case.ts | 30 + ...to_create_boards_from_requests_use_case.ts | 58 + ...eate_boards_from_requests_use_case_test.ts | 84 ++ .../create_board_announcement_use_case.ts | 28 + .../usecases/create_board_request_use_case.ts | 86 ++ .../create_board_request_use_case_test.ts | 166 +++ .../usecases/create_board_use_case.ts | 69 + .../usecases/create_comment_use_case.ts | 120 ++ .../usecases/create_comment_use_case_test.ts | 253 ++++ .../usecases/create_post_use_case.ts | 76 ++ .../usecases/create_post_use_case_test.ts | 114 ++ .../delete_board_announcement_use_case.ts | 9 + .../usecases/get_board_by_slug_use_case.ts | 15 + .../get_board_requests_overview_use_case.ts | 29 + ...t_board_requests_overview_use_case_test.ts | 54 + src/application/usecases/get_post_use_case.ts | 16 + .../usecases/list_archived_posts_use_case.ts | 19 + .../list_board_announcements_use_case.ts | 11 + .../usecases/list_board_requests_use_case.ts | 11 + .../usecases/list_boards_use_case.ts | 11 + .../usecases/list_comments_use_case.ts | 11 + .../usecases/list_popular_posts_use_case.ts | 44 + .../list_popular_posts_use_case_test.ts | 75 ++ .../usecases/list_posts_use_case.ts | 19 + .../usecases/register_user_use_case.ts | 52 + .../toggle_board_request_vote_use_case.ts | 40 + ...toggle_board_request_vote_use_case_test.ts | 79 ++ .../update_board_request_status_use_case.ts | 15 + src/application/utils/password_hash.ts | 25 + src/bootstrap/app_container.ts | 269 ++++ src/config/app_config.ts | 280 ++++ src/config/app_config_test.ts | 74 ++ src/domain/.DS_Store | Bin 0 -> 6148 bytes src/domain/entities/board.ts | 9 + src/domain/entities/board_announcement.ts | 5 + src/domain/entities/board_request.ts | 11 + src/domain/entities/board_request_vote.ts | 5 + src/domain/entities/comment.ts | 10 + src/domain/entities/image_post.ts | 13 + src/domain/entities/user.ts | 6 + .../board_announcement_repository.ts | 7 + src/domain/repositories/board_repository.ts | 10 + .../repositories/board_request_repository.ts | 9 + .../board_request_vote_repository.ts | 6 + src/domain/repositories/comment_repository.ts | 9 + src/domain/repositories/post_repository.ts | 10 + src/domain/repositories/user_repository.ts | 8 + src/domain/services/image_storage.ts | 12 + src/domain/services/rate_limiter.ts | 8 + src/domain/services/session_store.ts | 11 + src/infrastructure/.DS_Store | Bin 0 -> 6148 bytes .../kv/kv_board_announcement_repository.ts | 26 + src/infrastructure/kv/kv_board_repository.ts | 57 + .../kv/kv_board_request_repository.ts | 35 + .../kv/kv_board_request_vote_repository.ts | 33 + .../kv/kv_comment_repository.ts | 48 + src/infrastructure/kv/kv_post_repository.ts | 73 ++ src/infrastructure/kv/kv_session_store.ts | 20 + src/infrastructure/kv/kv_user_repository.ts | 48 + .../storage/file_system_image_storage.ts | 102 ++ .../storage/file_system_image_storage_test.ts | 65 + .../system/crypto_id_generator.ts | 7 + src/infrastructure/system/kv_rate_limiter.ts | 46 + src/infrastructure/system/system_clock.ts | 7 + src/interfaces/http/app.ts | 1164 +++++++++++++++++ src/interfaces/http/views/app_page.ts | 429 ++++++ src/interfaces/http/views/home_page.ts | 480 +++++++ src/interfaces/http/views/shared.ts | 145 ++ src/testing/asserts.ts | 144 ++ src/testing/fakes.ts | 305 +++++ storage/.gitignore | 2 + 91 files changed, 7528 insertions(+) create mode 100644 README.md create mode 100644 config/app.config.json create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 dist/.DS_Store create mode 100644 main.ts create mode 100644 public/styles.css create mode 100644 scripts/announcements.ts create mode 100644 scripts/boards.ts create mode 100644 scripts/build.ts create mode 100644 scripts/db.ts create mode 100644 scripts/members.ts create mode 100644 scripts/threads.ts create mode 100644 src/.DS_Store create mode 100644 src/application/.DS_Store create mode 100644 src/application/contracts/clock.ts create mode 100644 src/application/contracts/id_generator.ts create mode 100644 src/application/errors/application_error.ts create mode 100644 src/application/usecases/archive_expired_posts_use_case.ts create mode 100644 src/application/usecases/archive_expired_posts_use_case_test.ts create mode 100644 src/application/usecases/authenticate_user_use_case.ts create mode 100644 src/application/usecases/auto_create_boards_from_requests_use_case.ts create mode 100644 src/application/usecases/auto_create_boards_from_requests_use_case_test.ts create mode 100644 src/application/usecases/create_board_announcement_use_case.ts create mode 100644 src/application/usecases/create_board_request_use_case.ts create mode 100644 src/application/usecases/create_board_request_use_case_test.ts create mode 100644 src/application/usecases/create_board_use_case.ts create mode 100644 src/application/usecases/create_comment_use_case.ts create mode 100644 src/application/usecases/create_comment_use_case_test.ts create mode 100644 src/application/usecases/create_post_use_case.ts create mode 100644 src/application/usecases/create_post_use_case_test.ts create mode 100644 src/application/usecases/delete_board_announcement_use_case.ts create mode 100644 src/application/usecases/get_board_by_slug_use_case.ts create mode 100644 src/application/usecases/get_board_requests_overview_use_case.ts create mode 100644 src/application/usecases/get_board_requests_overview_use_case_test.ts create mode 100644 src/application/usecases/get_post_use_case.ts create mode 100644 src/application/usecases/list_archived_posts_use_case.ts create mode 100644 src/application/usecases/list_board_announcements_use_case.ts create mode 100644 src/application/usecases/list_board_requests_use_case.ts create mode 100644 src/application/usecases/list_boards_use_case.ts create mode 100644 src/application/usecases/list_comments_use_case.ts create mode 100644 src/application/usecases/list_popular_posts_use_case.ts create mode 100644 src/application/usecases/list_popular_posts_use_case_test.ts create mode 100644 src/application/usecases/list_posts_use_case.ts create mode 100644 src/application/usecases/register_user_use_case.ts create mode 100644 src/application/usecases/toggle_board_request_vote_use_case.ts create mode 100644 src/application/usecases/toggle_board_request_vote_use_case_test.ts create mode 100644 src/application/usecases/update_board_request_status_use_case.ts create mode 100644 src/application/utils/password_hash.ts create mode 100644 src/bootstrap/app_container.ts create mode 100644 src/config/app_config.ts create mode 100644 src/config/app_config_test.ts create mode 100644 src/domain/.DS_Store create mode 100644 src/domain/entities/board.ts create mode 100644 src/domain/entities/board_announcement.ts create mode 100644 src/domain/entities/board_request.ts create mode 100644 src/domain/entities/board_request_vote.ts create mode 100644 src/domain/entities/comment.ts create mode 100644 src/domain/entities/image_post.ts create mode 100644 src/domain/entities/user.ts create mode 100644 src/domain/repositories/board_announcement_repository.ts create mode 100644 src/domain/repositories/board_repository.ts create mode 100644 src/domain/repositories/board_request_repository.ts create mode 100644 src/domain/repositories/board_request_vote_repository.ts create mode 100644 src/domain/repositories/comment_repository.ts create mode 100644 src/domain/repositories/post_repository.ts create mode 100644 src/domain/repositories/user_repository.ts create mode 100644 src/domain/services/image_storage.ts create mode 100644 src/domain/services/rate_limiter.ts create mode 100644 src/domain/services/session_store.ts create mode 100644 src/infrastructure/.DS_Store create mode 100644 src/infrastructure/kv/kv_board_announcement_repository.ts create mode 100644 src/infrastructure/kv/kv_board_repository.ts create mode 100644 src/infrastructure/kv/kv_board_request_repository.ts create mode 100644 src/infrastructure/kv/kv_board_request_vote_repository.ts create mode 100644 src/infrastructure/kv/kv_comment_repository.ts create mode 100644 src/infrastructure/kv/kv_post_repository.ts create mode 100644 src/infrastructure/kv/kv_session_store.ts create mode 100644 src/infrastructure/kv/kv_user_repository.ts create mode 100644 src/infrastructure/storage/file_system_image_storage.ts create mode 100644 src/infrastructure/storage/file_system_image_storage_test.ts create mode 100644 src/infrastructure/system/crypto_id_generator.ts create mode 100644 src/infrastructure/system/kv_rate_limiter.ts create mode 100644 src/infrastructure/system/system_clock.ts create mode 100644 src/interfaces/http/app.ts create mode 100644 src/interfaces/http/views/app_page.ts create mode 100644 src/interfaces/http/views/home_page.ts create mode 100644 src/interfaces/http/views/shared.ts create mode 100644 src/testing/asserts.ts create mode 100644 src/testing/fakes.ts create mode 100644 storage/.gitignore diff --git a/README.md b/README.md new file mode 100644 index 0000000..f867d51 --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# 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 ` / `requests` / `delete ` | +| Threads | `deno task threads delete|repost|unpost|archive ` or `archive-run` | +| Announcements | `deno task announcements list|add "message"|delete ` | +| Members (admin posts) | `deno task members post "body" [--title "..."] [--slug members]` / `archive ` | +| 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! diff --git a/config/app.config.json b/config/app.config.json new file mode 100644 index 0000000..5c9a536 --- /dev/null +++ b/config/app.config.json @@ -0,0 +1,36 @@ +{ + "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 + } + } +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..7789515 --- /dev/null +++ b/deno.json @@ -0,0 +1,29 @@ +{ + "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" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..22cb294 --- /dev/null +++ b/deno.lock @@ -0,0 +1,50 @@ +{ + "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" + ] + } +} diff --git a/dist/.DS_Store b/dist/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 { + 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)); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..316ef85 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,619 @@ +: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; +} diff --git a/scripts/announcements.ts b/scripts/announcements.ts new file mode 100644 index 0000000..4827d99 --- /dev/null +++ b/scripts/announcements.ts @@ -0,0 +1,87 @@ +#!/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 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 "); + await deleteBoardAnnouncementUseCase.execute(id); + console.log(`Announcement ${id} deleted.`); + break; + } + default: + usage(); + Deno.exit(1); + } + } finally { + container.close(); + } +}; + +if (import.meta.main) { + await main(); +} diff --git a/scripts/boards.ts b/scripts/boards.ts new file mode 100644 index 0000000..f197211 --- /dev/null +++ b/scripts/boards.ts @@ -0,0 +1,216 @@ +#!/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 [options] + --description \"...\" Optional description + --color #1E40AF Optional hex theme color + deno task boards create-from-request Fulfil a request and create the board + deno task boards delete Delete a board and its threads + deno task boards close-request Close a request without creating a board +`); +}; + +const parseFlags = (args: string[]) => { + const result: Record = {}; + 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>, + 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 and "); + ensureArgument(name, "create requires and "); + 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 "); + 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 "); + 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 "); + 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(); +} diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..13dc682 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,56 @@ +#!/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); + } +} diff --git a/scripts/db.ts b/scripts/db.ts new file mode 100644 index 0000000..a771ee3 --- /dev/null +++ b/scripts/db.ts @@ -0,0 +1,101 @@ +#!/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 => { + 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(); +} diff --git a/scripts/members.ts b/scripts/members.ts new file mode 100644 index 0000000..bb3efe2 --- /dev/null +++ b/scripts/members.ts @@ -0,0 +1,152 @@ +#!/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 [--title "..."] [--slug members] + deno task members archive +`); +}; + +const parseFlags = (args: string[]) => { + const result: Record = {}; + 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>, + 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>, + 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 "); + await archivePost(container, targetPostId); + break; + } + default: + usage(); + Deno.exit(1); + } + } finally { + container.close(); + } +}; + +if (import.meta.main) { + await main(); +} diff --git a/scripts/threads.ts b/scripts/threads.ts new file mode 100644 index 0000000..4eeefc0 --- /dev/null +++ b/scripts/threads.ts @@ -0,0 +1,172 @@ +#!/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 Delete a thread and all of its assets + deno task threads repost Surface an archived thread as read-only on the board + deno task threads unpost Hide a reposted thread back into the archive + deno task threads archive 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 `); + usage(); + Deno.exit(1); + } + return value; +}; + +const deleteThread = async ( + container: Awaited>, + 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>, + 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>, + 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>, + 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, + 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(); +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..656cf1959dedcd7ff403a156880233d386137d16 GIT binary patch literal 6148 zcmeHKOHRWu5S@XF7Am1GS^5gxAgam^p{5*bA z?N~%~J5=`~8xdKd6P3bDqk7hKk(`%6jX7#5dHK_Z${!`VJ z&Ax3LmVGUIY^lZ8I`nr2OwCt_Yvm+IWO&I96%4QNuO&-(O9lLy%2E!<(aQkMVVdD4 zI`(pa;RxpvQa5#2A_^*u%$Tiyj)Z$n&VV!E3^)US&H(Ogw$<9uM`yqpa0b2@;Q0{H ziABQEFdiKk;u8S4fI2qFd`mDUBrFn+hOnTig#s;9(-T829Qu&_iiD$~g^Q-gho+T3 zA1@kLNB$7qMGHe8odIVcXW-JN8{Yp9_{+>b^79a%IRnnXKVv`_yT{!Yn@V@{}C?eXv4^JX{5!t{E<-@|#?78{ER+&*C9Pc>GtMqb}%Wzp#KTa5T3(h2O;N9^1 z@ia~2V-mAefC^9nDnJFOz^@gs-V5980vV|Q6`%s&3fT9dzzu8S9O$181Rnu_4bpB{ z`z!%0mH^hoIS?6`1{D}o%@IR`j(o|wnm7jrT{MRe&671J6!p{b{^I4LHIR`CP=R{| z*0J4N|9^)6F#q3^xT6A8;9n`AP508ZxKj4k#pSHm7WffvHJ@-Ztet}3?HK6o7#nNH d2QP}cVrx9FiF2USk#{ { + 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; + } +} diff --git a/src/application/usecases/archive_expired_posts_use_case_test.ts b/src/application/usecases/archive_expired_posts_use_case_test.ts new file mode 100644 index 0000000..6d8b9d6 --- /dev/null +++ b/src/application/usecases/archive_expired_posts_use_case_test.ts @@ -0,0 +1,74 @@ +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> = {}) => ({ + 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); +}); diff --git a/src/application/usecases/authenticate_user_use_case.ts b/src/application/usecases/authenticate_user_use_case.ts new file mode 100644 index 0000000..2315b3a --- /dev/null +++ b/src/application/usecases/authenticate_user_use_case.ts @@ -0,0 +1,30 @@ +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 { + 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; + } +} diff --git a/src/application/usecases/auto_create_boards_from_requests_use_case.ts b/src/application/usecases/auto_create_boards_from_requests_use_case.ts new file mode 100644 index 0000000..d77fa96 --- /dev/null +++ b/src/application/usecases/auto_create_boards_from_requests_use_case.ts @@ -0,0 +1,58 @@ +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 }; + } +} diff --git a/src/application/usecases/auto_create_boards_from_requests_use_case_test.ts b/src/application/usecases/auto_create_boards_from_requests_use_case_test.ts new file mode 100644 index 0000000..f4a4052 --- /dev/null +++ b/src/application/usecases/auto_create_boards_from_requests_use_case_test.ts @@ -0,0 +1,84 @@ +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> = {}) => ({ + 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); +}); diff --git a/src/application/usecases/create_board_announcement_use_case.ts b/src/application/usecases/create_board_announcement_use_case.ts new file mode 100644 index 0000000..ca0310e --- /dev/null +++ b/src/application/usecases/create_board_announcement_use_case.ts @@ -0,0 +1,28 @@ +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 { + 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; + } +} diff --git a/src/application/usecases/create_board_request_use_case.ts b/src/application/usecases/create_board_request_use_case.ts new file mode 100644 index 0000000..5af887c --- /dev/null +++ b/src/application/usecases/create_board_request_use_case.ts @@ -0,0 +1,86 @@ +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 { + 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."); +}; diff --git a/src/application/usecases/create_board_request_use_case_test.ts b/src/application/usecases/create_board_request_use_case_test.ts new file mode 100644 index 0000000..561226b --- /dev/null +++ b/src/application/usecases/create_board_request_use_case_test.ts @@ -0,0 +1,166 @@ +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); +}); diff --git a/src/application/usecases/create_board_use_case.ts b/src/application/usecases/create_board_use_case.ts new file mode 100644 index 0000000..45bf0f0 --- /dev/null +++ b/src/application/usecases/create_board_use_case.ts @@ -0,0 +1,69 @@ +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 { + 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."); +}; diff --git a/src/application/usecases/create_comment_use_case.ts b/src/application/usecases/create_comment_use_case.ts new file mode 100644 index 0000000..57e7caf --- /dev/null +++ b/src/application/usecases/create_comment_use_case.ts @@ -0,0 +1,120 @@ +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 { + 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, + }; + } +} diff --git a/src/application/usecases/create_comment_use_case_test.ts b/src/application/usecases/create_comment_use_case_test.ts new file mode 100644 index 0000000..d214e54 --- /dev/null +++ b/src/application/usecases/create_comment_use_case_test.ts @@ -0,0 +1,253 @@ +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> = {}) => ({ + 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"); +}); diff --git a/src/application/usecases/create_post_use_case.ts b/src/application/usecases/create_post_use_case.ts new file mode 100644 index 0000000..2b27e1e --- /dev/null +++ b/src/application/usecases/create_post_use_case.ts @@ -0,0 +1,76 @@ +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 { + 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; + } +} diff --git a/src/application/usecases/create_post_use_case_test.ts b/src/application/usecases/create_post_use_case_test.ts new file mode 100644 index 0000000..953b875 --- /dev/null +++ b/src/application/usecases/create_post_use_case_test.ts @@ -0,0 +1,114 @@ +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 { + this.saved = { ...input }; + return Promise.resolve(`/uploads/images/${input.id}`); + } + + getImage(_path: string): Promise { + return Promise.reject(new Error("not implemented")); + } + + deleteImage(_path: string): Promise { + 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); +}); diff --git a/src/application/usecases/delete_board_announcement_use_case.ts b/src/application/usecases/delete_board_announcement_use_case.ts new file mode 100644 index 0000000..c1a20a9 --- /dev/null +++ b/src/application/usecases/delete_board_announcement_use_case.ts @@ -0,0 +1,9 @@ +import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts"; + +export class DeleteBoardAnnouncementUseCase { + constructor(private readonly announcementRepository: BoardAnnouncementRepository) {} + + async execute(id: string): Promise { + await this.announcementRepository.delete(id); + } +} diff --git a/src/application/usecases/get_board_by_slug_use_case.ts b/src/application/usecases/get_board_by_slug_use_case.ts new file mode 100644 index 0000000..db3e711 --- /dev/null +++ b/src/application/usecases/get_board_by_slug_use_case.ts @@ -0,0 +1,15 @@ +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 { + const board = await this.boardRepository.findBySlug(slug); + if (!board) { + throw new ApplicationError("Board not found.", 404); + } + return board; + } +} diff --git a/src/application/usecases/get_board_requests_overview_use_case.ts b/src/application/usecases/get_board_requests_overview_use_case.ts new file mode 100644 index 0000000..b26d06e --- /dev/null +++ b/src/application/usecases/get_board_requests_overview_use_case.ts @@ -0,0 +1,29 @@ +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 { + 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)); + } +} diff --git a/src/application/usecases/get_board_requests_overview_use_case_test.ts b/src/application/usecases/get_board_requests_overview_use_case_test.ts new file mode 100644 index 0000000..998cce5 --- /dev/null +++ b/src/application/usecases/get_board_requests_overview_use_case_test.ts @@ -0,0 +1,54 @@ +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); +}); diff --git a/src/application/usecases/get_post_use_case.ts b/src/application/usecases/get_post_use_case.ts new file mode 100644 index 0000000..ce3dcf9 --- /dev/null +++ b/src/application/usecases/get_post_use_case.ts @@ -0,0 +1,16 @@ +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 { + const post = await this.postRepository.findById(id); + if (!post) { + throw new ApplicationError("Post not found.", 404); + } + + return post; + } +} diff --git a/src/application/usecases/list_archived_posts_use_case.ts b/src/application/usecases/list_archived_posts_use_case.ts new file mode 100644 index 0000000..52acb03 --- /dev/null +++ b/src/application/usecases/list_archived_posts_use_case.ts @@ -0,0 +1,19 @@ +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 { + 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)); + } +} diff --git a/src/application/usecases/list_board_announcements_use_case.ts b/src/application/usecases/list_board_announcements_use_case.ts new file mode 100644 index 0000000..c5288c2 --- /dev/null +++ b/src/application/usecases/list_board_announcements_use_case.ts @@ -0,0 +1,11 @@ +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 { + const announcements = await this.announcementRepository.list(); + return announcements.slice(0, limit); + } +} diff --git a/src/application/usecases/list_board_requests_use_case.ts b/src/application/usecases/list_board_requests_use_case.ts new file mode 100644 index 0000000..7dd459b --- /dev/null +++ b/src/application/usecases/list_board_requests_use_case.ts @@ -0,0 +1,11 @@ +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 { + const requests = await this.boardRequestRepository.list(); + return requests.sort((a, b) => a.deadline.localeCompare(b.deadline)); + } +} diff --git a/src/application/usecases/list_boards_use_case.ts b/src/application/usecases/list_boards_use_case.ts new file mode 100644 index 0000000..d03dd49 --- /dev/null +++ b/src/application/usecases/list_boards_use_case.ts @@ -0,0 +1,11 @@ +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 { + const boards = await this.boardRepository.list(); + return boards.sort((a, b) => a.slug.localeCompare(b.slug)); + } +} diff --git a/src/application/usecases/list_comments_use_case.ts b/src/application/usecases/list_comments_use_case.ts new file mode 100644 index 0000000..460fb1f --- /dev/null +++ b/src/application/usecases/list_comments_use_case.ts @@ -0,0 +1,11 @@ +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 { + const comments = await this.commentRepository.listByPost(postId); + return comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } +} diff --git a/src/application/usecases/list_popular_posts_use_case.ts b/src/application/usecases/list_popular_posts_use_case.ts new file mode 100644 index 0000000..16e68d0 --- /dev/null +++ b/src/application/usecases/list_popular_posts_use_case.ts @@ -0,0 +1,44 @@ +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 { + 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; + } +} diff --git a/src/application/usecases/list_popular_posts_use_case_test.ts b/src/application/usecases/list_popular_posts_use_case_test.ts new file mode 100644 index 0000000..417bca3 --- /dev/null +++ b/src/application/usecases/list_popular_posts_use_case_test.ts @@ -0,0 +1,75 @@ +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"); +}); diff --git a/src/application/usecases/list_posts_use_case.ts b/src/application/usecases/list_posts_use_case.ts new file mode 100644 index 0000000..b9e4d9b --- /dev/null +++ b/src/application/usecases/list_posts_use_case.ts @@ -0,0 +1,19 @@ +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 { + 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)); + } +} diff --git a/src/application/usecases/register_user_use_case.ts b/src/application/usecases/register_user_use_case.ts new file mode 100644 index 0000000..50527c1 --- /dev/null +++ b/src/application/usecases/register_user_use_case.ts @@ -0,0 +1,52 @@ +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 { + 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; + } +} diff --git a/src/application/usecases/toggle_board_request_vote_use_case.ts b/src/application/usecases/toggle_board_request_vote_use_case.ts new file mode 100644 index 0000000..d1782ad --- /dev/null +++ b/src/application/usecases/toggle_board_request_vote_use_case.ts @@ -0,0 +1,40 @@ +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 }; + } +} diff --git a/src/application/usecases/toggle_board_request_vote_use_case_test.ts b/src/application/usecases/toggle_board_request_vote_use_case_test.ts new file mode 100644 index 0000000..798d794 --- /dev/null +++ b/src/application/usecases/toggle_board_request_vote_use_case_test.ts @@ -0,0 +1,79 @@ +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> = {}) => ({ + 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); +}); diff --git a/src/application/usecases/update_board_request_status_use_case.ts b/src/application/usecases/update_board_request_status_use_case.ts new file mode 100644 index 0000000..f4ea7ef --- /dev/null +++ b/src/application/usecases/update_board_request_status_use_case.ts @@ -0,0 +1,15 @@ +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 { + const request = await this.boardRequestRepository.findById(requestId); + if (!request) { + throw new ApplicationError("Request not found.", 404); + } + request.status = status; + await this.boardRequestRepository.update(request); + } +} diff --git a/src/application/utils/password_hash.ts b/src/application/utils/password_hash.ts new file mode 100644 index 0000000..ba6cca7 --- /dev/null +++ b/src/application/utils/password_hash.ts @@ -0,0 +1,25 @@ +const textEncoder = new TextEncoder(); + +export const hashPassword = async (password: string): Promise => { + 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 => { + 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; +}; diff --git a/src/bootstrap/app_container.ts b/src/bootstrap/app_container.ts new file mode 100644 index 0000000..cd19808 --- /dev/null +++ b/src/bootstrap/app_container.ts @@ -0,0 +1,269 @@ +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 => { + 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; +}; diff --git a/src/config/app_config.ts b/src/config/app_config.ts new file mode 100644 index 0000000..56f4b34 --- /dev/null +++ b/src/config/app_config.ts @@ -0,0 +1,280 @@ +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; + 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, + 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; + 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 => { + 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).app; + const defaultBoardSection = (parsed as Record).defaultBoard; + const memberBoardSection = (parsed as Record).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; + const defaultBoard = defaultBoardSection as Record; + const memberBoard = memberBoardSection as Record; + + 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; + 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, + }; +}; diff --git a/src/config/app_config_test.ts b/src/config/app_config_test.ts new file mode 100644 index 0000000..50350ad --- /dev/null +++ b/src/config/app_config_test.ts @@ -0,0 +1,74 @@ +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 }); + } +}); diff --git a/src/domain/.DS_Store b/src/domain/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..71e906748401b38116cc394a70759328c43a3aea GIT binary patch literal 6148 zcmeH~J#GRq5QS#~#Y&-|r1TZIfrZEkxBvpSh=jB#o21^3Yv;`m63Z$QRf_11G=BDY z#+JXvwutEF>$DbGibw-Dm8&&Vvw8E0y=25xeT`n;#+b}e*gh2u%Kmrd0@TQjnH?65d zsD&)Hg$P_`sM0%HQR7%uJpe}n(>{~s4|L;@u6uL$UTyW4JXq`X@vhqJr3!1wT*A=lH< jj2;0P*j2o`%PZDLUQHcBy&Q2bhw>p{TxcZl7X*F)CYBtp literal 0 HcmV?d00001 diff --git a/src/domain/entities/board.ts b/src/domain/entities/board.ts new file mode 100644 index 0000000..0c4a1e8 --- /dev/null +++ b/src/domain/entities/board.ts @@ -0,0 +1,9 @@ +export interface Board { + id: string; + slug: string; + name: string; + description: string; + themeColor: string; + createdAt: string; + status: "active"; +} diff --git a/src/domain/entities/board_announcement.ts b/src/domain/entities/board_announcement.ts new file mode 100644 index 0000000..45130f1 --- /dev/null +++ b/src/domain/entities/board_announcement.ts @@ -0,0 +1,5 @@ +export interface BoardAnnouncement { + id: string; + message: string; + createdAt: string; +} diff --git a/src/domain/entities/board_request.ts b/src/domain/entities/board_request.ts new file mode 100644 index 0000000..31742eb --- /dev/null +++ b/src/domain/entities/board_request.ts @@ -0,0 +1,11 @@ +export interface BoardRequest { + id: string; + slug: string; + name: string; + description: string; + themeColor: string; + voteCount: number; + createdAt: string; + deadline: string; + status: "open" | "closed" | "fulfilled"; +} diff --git a/src/domain/entities/board_request_vote.ts b/src/domain/entities/board_request_vote.ts new file mode 100644 index 0000000..ce92f46 --- /dev/null +++ b/src/domain/entities/board_request_vote.ts @@ -0,0 +1,5 @@ +export interface BoardRequestVote { + requestId: string; + userId: string; + createdAt: string; +} diff --git a/src/domain/entities/comment.ts b/src/domain/entities/comment.ts new file mode 100644 index 0000000..203c269 --- /dev/null +++ b/src/domain/entities/comment.ts @@ -0,0 +1,10 @@ +export interface Comment { + id: string; + postId: string; + parentCommentId?: string; + author: string; + authorUserId?: string; + body: string; + imagePath?: string; + createdAt: string; +} diff --git a/src/domain/entities/image_post.ts b/src/domain/entities/image_post.ts new file mode 100644 index 0000000..1e53c1c --- /dev/null +++ b/src/domain/entities/image_post.ts @@ -0,0 +1,13 @@ +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; +} diff --git a/src/domain/entities/user.ts b/src/domain/entities/user.ts new file mode 100644 index 0000000..9625fe6 --- /dev/null +++ b/src/domain/entities/user.ts @@ -0,0 +1,6 @@ +export interface User { + id: string; + username: string; + passwordHash: string; + createdAt: string; +} diff --git a/src/domain/repositories/board_announcement_repository.ts b/src/domain/repositories/board_announcement_repository.ts new file mode 100644 index 0000000..f9097d2 --- /dev/null +++ b/src/domain/repositories/board_announcement_repository.ts @@ -0,0 +1,7 @@ +import { BoardAnnouncement } from "../entities/board_announcement.ts"; + +export interface BoardAnnouncementRepository { + list(): Promise; + create(announcement: BoardAnnouncement): Promise; + delete(id: string): Promise; +} diff --git a/src/domain/repositories/board_repository.ts b/src/domain/repositories/board_repository.ts new file mode 100644 index 0000000..0aca8d9 --- /dev/null +++ b/src/domain/repositories/board_repository.ts @@ -0,0 +1,10 @@ +import { Board } from "../entities/board.ts"; + +export interface BoardRepository { + create(board: Board): Promise; + update(board: Board): Promise; + findById(id: string): Promise; + findBySlug(slug: string): Promise; + list(): Promise; + delete(id: string): Promise; +} diff --git a/src/domain/repositories/board_request_repository.ts b/src/domain/repositories/board_request_repository.ts new file mode 100644 index 0000000..653ce3f --- /dev/null +++ b/src/domain/repositories/board_request_repository.ts @@ -0,0 +1,9 @@ +import { BoardRequest } from "../entities/board_request.ts"; + +export interface BoardRequestRepository { + create(request: BoardRequest): Promise; + update(request: BoardRequest): Promise; + findById(id: string): Promise; + list(): Promise; + delete(id: string): Promise; +} diff --git a/src/domain/repositories/board_request_vote_repository.ts b/src/domain/repositories/board_request_vote_repository.ts new file mode 100644 index 0000000..d28c6b2 --- /dev/null +++ b/src/domain/repositories/board_request_vote_repository.ts @@ -0,0 +1,6 @@ +export interface BoardRequestVoteRepository { + hasVote(requestId: string, userId: string): Promise; + addVote(requestId: string, userId: string): Promise; + removeVote(requestId: string, userId: string): Promise; + countVotes(requestId: string): Promise; +} diff --git a/src/domain/repositories/comment_repository.ts b/src/domain/repositories/comment_repository.ts new file mode 100644 index 0000000..30fd5fc --- /dev/null +++ b/src/domain/repositories/comment_repository.ts @@ -0,0 +1,9 @@ +import { Comment } from "../entities/comment.ts"; + +export interface CommentRepository { + create(comment: Comment): Promise; + findById(postId: string, id: string): Promise; + listByPost(postId: string): Promise; + deleteByPost(postId: string): Promise; + countByPost(postId: string): Promise; +} diff --git a/src/domain/repositories/post_repository.ts b/src/domain/repositories/post_repository.ts new file mode 100644 index 0000000..a5971b4 --- /dev/null +++ b/src/domain/repositories/post_repository.ts @@ -0,0 +1,10 @@ +import { ImagePost } from "../entities/image_post.ts"; + +export interface PostRepository { + create(post: ImagePost): Promise; + findById(id: string): Promise; + listAll(): Promise; + listByBoard(boardId: string): Promise; + update(post: ImagePost): Promise; + delete(id: string): Promise; +} diff --git a/src/domain/repositories/user_repository.ts b/src/domain/repositories/user_repository.ts new file mode 100644 index 0000000..2450de7 --- /dev/null +++ b/src/domain/repositories/user_repository.ts @@ -0,0 +1,8 @@ +import { User } from "../entities/user.ts"; + +export interface UserRepository { + create(user: User): Promise; + findById(id: string): Promise; + findByUsername(username: string): Promise; + isUsernameTaken(username: string): Promise; +} diff --git a/src/domain/services/image_storage.ts b/src/domain/services/image_storage.ts new file mode 100644 index 0000000..b1f52ce --- /dev/null +++ b/src/domain/services/image_storage.ts @@ -0,0 +1,12 @@ +export interface SaveImageInput { + id: string; + data: Uint8Array; + mimeType: string; + originalName: string; +} + +export interface ImageStorage { + saveImage(input: SaveImageInput): Promise; + getImage(path: string): Promise; + deleteImage(path: string): Promise; +} diff --git a/src/domain/services/rate_limiter.ts b/src/domain/services/rate_limiter.ts new file mode 100644 index 0000000..fbd1298 --- /dev/null +++ b/src/domain/services/rate_limiter.ts @@ -0,0 +1,8 @@ +export interface RateLimitRule { + limit: number; + windowMs: number; +} + +export interface RateLimiter { + consume(key: string, rule: RateLimitRule): Promise; +} diff --git a/src/domain/services/session_store.ts b/src/domain/services/session_store.ts new file mode 100644 index 0000000..b787fa0 --- /dev/null +++ b/src/domain/services/session_store.ts @@ -0,0 +1,11 @@ +export interface SessionData { + userId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SessionStore { + get(sessionId: string): Promise; + set(sessionId: string, data: SessionData): Promise; + delete(sessionId: string): Promise; +} diff --git a/src/infrastructure/.DS_Store b/src/infrastructure/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6c5a00883e9eacbadbfff40db05795a69db2453c GIT binary patch literal 6148 zcmeH~O-chn5QSgGpaC~oy6h`>gCWEdcmaPBTo@TJiQsN_UfWkc%E%zuxsX>-{U+5_ z-ORU`N&&FdadQnU0W9dQ`0!o(CDsR z>5v+q4lXeQQ0EMX@i}G*YV!cKD;-i9p;?wvv(#$DuqaCO4^j_QOcl0k~ pZlrUGR!ogn%pcy0uSR*zpSj { + const announcements: BoardAnnouncement[] = []; + for await (const entry of this.kv.list({ 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 { + await this.kv.set([...ANNOUNCEMENT_PREFIX, announcement.id], announcement); + } + + async delete(id: string): Promise { + await this.kv.delete([...ANNOUNCEMENT_PREFIX, id]); + } +} diff --git a/src/infrastructure/kv/kv_board_repository.ts b/src/infrastructure/kv/kv_board_repository.ts new file mode 100644 index 0000000..1090723 --- /dev/null +++ b/src/infrastructure/kv/kv_board_repository.ts @@ -0,0 +1,57 @@ +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 { + 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 { + 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 { + const result = await this.kv.get([...BOARD_KEY_PREFIX, id]); + return result.value ?? null; + } + + async findBySlug(slug: string): Promise { + 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 { + const boards: Board[] = []; + for await (const entry of this.kv.list({ prefix: BOARD_KEY_PREFIX })) { + if (entry.value) { + boards.push(entry.value); + } + } + return boards; + } + + async delete(id: string): Promise { + 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(); + } +} diff --git a/src/infrastructure/kv/kv_board_request_repository.ts b/src/infrastructure/kv/kv_board_request_repository.ts new file mode 100644 index 0000000..95f80d6 --- /dev/null +++ b/src/infrastructure/kv/kv_board_request_repository.ts @@ -0,0 +1,35 @@ +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 { + await this.kv.set([...BOARD_REQUEST_PREFIX, request.id], request); + } + + async update(request: BoardRequest): Promise { + await this.kv.set([...BOARD_REQUEST_PREFIX, request.id], request); + } + + async findById(id: string): Promise { + const result = await this.kv.get([...BOARD_REQUEST_PREFIX, id]); + return result.value ?? null; + } + + async list(): Promise { + const requests: BoardRequest[] = []; + for await (const entry of this.kv.list({ prefix: BOARD_REQUEST_PREFIX })) { + if (entry.value) { + requests.push(entry.value); + } + } + return requests; + } + + async delete(id: string): Promise { + await this.kv.delete([...BOARD_REQUEST_PREFIX, id]); + } +} diff --git a/src/infrastructure/kv/kv_board_request_vote_repository.ts b/src/infrastructure/kv/kv_board_request_vote_repository.ts new file mode 100644 index 0000000..e53cdd8 --- /dev/null +++ b/src/infrastructure/kv/kv_board_request_vote_repository.ts @@ -0,0 +1,33 @@ +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 { + const result = await this.kv.get(this.key(requestId, userId)); + return result.value !== null; + } + + async addVote(requestId: string, userId: string): Promise { + await this.kv.set(this.key(requestId, userId), true); + } + + async removeVote(requestId: string, userId: string): Promise { + await this.kv.delete(this.key(requestId, userId)); + } + + async countVotes(requestId: string): Promise { + let count = 0; + const prefix = [...VOTE_PREFIX, requestId] as const; + for await (const _ of this.kv.list({ prefix })) { + count += 1; + } + return count; + } +} diff --git a/src/infrastructure/kv/kv_comment_repository.ts b/src/infrastructure/kv/kv_comment_repository.ts new file mode 100644 index 0000000..b95c5c6 --- /dev/null +++ b/src/infrastructure/kv/kv_comment_repository.ts @@ -0,0 +1,48 @@ +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 { + await this.kv.set([...COMMENT_KEY_PREFIX, comment.postId, comment.id], comment); + } + + async findById(postId: string, id: string): Promise { + const record = await this.kv.get([...COMMENT_KEY_PREFIX, postId, id]); + return record.value ?? null; + } + + async listByPost(postId: string): Promise { + const comments: Comment[] = []; + const prefix = [...COMMENT_KEY_PREFIX, postId] as const; + for await (const entry of this.kv.list({ prefix })) { + if (entry.value) { + comments.push(entry.value); + } + } + + return comments; + } + + async deleteByPost(postId: string): Promise { + const prefix = [...COMMENT_KEY_PREFIX, postId] as const; + const ops: Array> = []; + for await (const entry of this.kv.list({ prefix })) { + ops.push(this.kv.delete(entry.key)); + } + await Promise.all(ops); + } + + async countByPost(postId: string): Promise { + const prefix = [...COMMENT_KEY_PREFIX, postId] as const; + let count = 0; + for await (const _ of this.kv.list({ prefix })) { + count += 1; + } + + return count; + } +} diff --git a/src/infrastructure/kv/kv_post_repository.ts b/src/infrastructure/kv/kv_post_repository.ts new file mode 100644 index 0000000..90d31af --- /dev/null +++ b/src/infrastructure/kv/kv_post_repository.ts @@ -0,0 +1,73 @@ +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 { + 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 { + const index = await this.kv.get<{ boardId: string }>([...POST_INDEX_PREFIX, id]); + if (!index.value) { + return null; + } + const record = await this.kv.get([...POST_KEY_PREFIX, index.value.boardId, id]); + return record.value ?? null; + } + + async listAll(): Promise { + const posts: ImagePost[] = []; + for await ( + const entry of this.kv.list({ prefix: POST_KEY_PREFIX }) + ) { + if (entry.value) { + posts.push(entry.value); + } + } + return posts; + } + + async listByBoard(boardId: string): Promise { + const posts: ImagePost[] = []; + const prefix = [...POST_KEY_PREFIX, boardId] as const; + for await (const entry of this.kv.list({ prefix })) { + if (entry.value) { + posts.push(entry.value); + } + } + return posts; + } + + async update(post: ImagePost): Promise { + 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 { + 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(); + } + } +} diff --git a/src/infrastructure/kv/kv_session_store.ts b/src/infrastructure/kv/kv_session_store.ts new file mode 100644 index 0000000..9bcdc98 --- /dev/null +++ b/src/infrastructure/kv/kv_session_store.ts @@ -0,0 +1,20 @@ +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 { + const record = await this.kv.get([...SESSION_PREFIX, sessionId]); + return record.value ?? null; + } + + async set(sessionId: string, data: SessionData): Promise { + await this.kv.set([...SESSION_PREFIX, sessionId], data); + } + + async delete(sessionId: string): Promise { + await this.kv.delete([...SESSION_PREFIX, sessionId]); + } +} diff --git a/src/infrastructure/kv/kv_user_repository.ts b/src/infrastructure/kv/kv_user_repository.ts new file mode 100644 index 0000000..0d7c06d --- /dev/null +++ b/src/infrastructure/kv/kv_user_repository.ts @@ -0,0 +1,48 @@ +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 { + 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 { + const entry = await this.kv.get([...USER_KEY_PREFIX, id]); + return entry.value ?? null; + } + + async findByUsername(username: string): Promise { + 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 { + const indexKey = [...USER_USERNAME_INDEX, normalizeUsername(username)] as const; + const record = await this.kv.get(indexKey); + return record.value !== null; + } +} diff --git a/src/infrastructure/storage/file_system_image_storage.ts b/src/infrastructure/storage/file_system_image_storage.ts new file mode 100644 index 0000000..f91c375 --- /dev/null +++ b/src/infrastructure/storage/file_system_image_storage.ts @@ -0,0 +1,102 @@ +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 { + 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 { + const absolute = this.resolvePublicPath(path); + return await Deno.readFile(absolute); + } + + async deleteImage(path: string): Promise { + 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 === ".."); + } +} diff --git a/src/infrastructure/storage/file_system_image_storage_test.ts b/src/infrastructure/storage/file_system_image_storage_test.ts new file mode 100644 index 0000000..e260eb7 --- /dev/null +++ b/src/infrastructure/storage/file_system_image_storage_test.ts @@ -0,0 +1,65 @@ +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 }); + } +}); diff --git a/src/infrastructure/system/crypto_id_generator.ts b/src/infrastructure/system/crypto_id_generator.ts new file mode 100644 index 0000000..8054936 --- /dev/null +++ b/src/infrastructure/system/crypto_id_generator.ts @@ -0,0 +1,7 @@ +import { IdGenerator } from "../../application/contracts/id_generator.ts"; + +export class CryptoIdGenerator implements IdGenerator { + generate(): string { + return crypto.randomUUID(); + } +} diff --git a/src/infrastructure/system/kv_rate_limiter.ts b/src/infrastructure/system/kv_rate_limiter.ts new file mode 100644 index 0000000..f457d23 --- /dev/null +++ b/src/infrastructure/system/kv_rate_limiter.ts @@ -0,0 +1,46 @@ +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 { + 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(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; + } +} diff --git a/src/infrastructure/system/system_clock.ts b/src/infrastructure/system/system_clock.ts new file mode 100644 index 0000000..d7dfbc8 --- /dev/null +++ b/src/infrastructure/system/system_clock.ts @@ -0,0 +1,7 @@ +import { Clock } from "../../application/contracts/clock.ts"; + +export class SystemClock implements Clock { + now(): Date { + return new Date(); + } +} diff --git a/src/interfaces/http/app.ts b/src/interfaces/http/app.ts new file mode 100644 index 0000000..c42fa73 --- /dev/null +++ b/src/interfaces/http/app.ts @@ -0,0 +1,1164 @@ +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 = { + ".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; + verify(value: string, signature: string): Promise; +}; + +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 => { + 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 => { + 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(); + const accountCache = new WeakMap(); + + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 & { + status: "active" | "archived"; + readOnly: boolean; + archivedAt: string | null; + comments: Comment[]; + } + > +> => { + const threads: Array< + Omit & { + 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 (c: Context, errorMessage = "Invalid JSON payload."): Promise => { + try { + return await c.req.json(); + } 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 => { + 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 => { + 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; +}; diff --git a/src/interfaces/http/views/app_page.ts b/src/interfaces/http/views/app_page.ts new file mode 100644 index 0000000..c92da77 --- /dev/null +++ b/src/interfaces/http/views/app_page.ts @@ -0,0 +1,429 @@ +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(); + 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; + 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, + }); +}; diff --git a/src/interfaces/http/views/home_page.ts b/src/interfaces/http/views/home_page.ts new file mode 100644 index 0000000..fc1ae20 --- /dev/null +++ b/src/interfaces/http/views/home_page.ts @@ -0,0 +1,480 @@ +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, + }); +}; diff --git a/src/interfaces/http/views/shared.ts b/src/interfaces/http/views/shared.ts new file mode 100644 index 0000000..4d9fa32 --- /dev/null +++ b/src/interfaces/http/views/shared.ts @@ -0,0 +1,145 @@ +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 => { + 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 => 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, [ + 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 `${html}`; +}; diff --git a/src/testing/asserts.ts b/src/testing/asserts.ts new file mode 100644 index 0000000..16c6590 --- /dev/null +++ b/src/testing/asserts.ts @@ -0,0 +1,144 @@ +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 => { + 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()): 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)[key], (b as Record)[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 = new (message?: string) => T; + +export const assertRejects = async ( + fn: () => Promise, + ExpectedError: ErrorClass = Error, + messageIncludes?: string, +): Promise => { + 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"); +}; diff --git a/src/testing/fakes.ts b/src/testing/fakes.ts new file mode 100644 index 0000000..f4389ff --- /dev/null +++ b/src/testing/fakes.ts @@ -0,0 +1,305 @@ +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(); + + constructor(private readonly basePath = "/uploads") {} + + saveImage(input: SaveImageInput): Promise { + const path = `${this.basePath}/images/${input.id}`; + this.files.set(path, input.data); + return Promise.resolve(path); + } + + getImage(path: string): Promise { + 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 { + this.files.delete(path); + return Promise.resolve(); + } +} + +export class InMemoryPostRepository implements PostRepository { + private readonly posts = new Map(); + + create(post: ImagePost): Promise { + this.posts.set(post.id, structuredClone(post)); + return Promise.resolve(); + } + + findById(id: string): Promise { + const post = this.posts.get(id); + return Promise.resolve(post ? structuredClone(post) : null); + } + + listAll(): Promise { + return Promise.resolve(Array.from(this.posts.values()).map((post) => structuredClone(post))); + } + + listByBoard(boardId: string): Promise { + return Promise.resolve( + Array.from(this.posts.values()) + .filter((post) => post.boardId === boardId) + .map((post) => structuredClone(post)), + ); + } + + update(post: ImagePost): Promise { + this.posts.set(post.id, structuredClone(post)); + return Promise.resolve(); + } + + delete(id: string): Promise { + this.posts.delete(id); + return Promise.resolve(); + } +} + +export class InMemoryCommentRepository implements CommentRepository { + private readonly comments = new Map(); + + create(comment: Comment): Promise { + this.comments.set(this.key(comment.postId, comment.id), structuredClone(comment)); + return Promise.resolve(); + } + + findById(postId: string, id: string): Promise { + const comment = this.comments.get(this.key(postId, id)); + return Promise.resolve(comment ? structuredClone(comment) : null); + } + + listByPost(postId: string): Promise { + return Promise.resolve( + Array.from(this.comments.values()) + .filter((comment) => comment.postId === postId) + .map((comment) => structuredClone(comment)), + ); + } + + deleteByPost(postId: string): Promise { + for (const key of this.comments.keys()) { + if (key.startsWith(`${postId}::`)) { + this.comments.delete(key); + } + } + return Promise.resolve(); + } + + countByPost(postId: string): Promise { + 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(); + + create(board: Board): Promise { + this.boards.set(board.id, structuredClone(board)); + return Promise.resolve(); + } + + update(board: Board): Promise { + this.boards.set(board.id, structuredClone(board)); + return Promise.resolve(); + } + + findById(id: string): Promise { + const board = this.boards.get(id); + return Promise.resolve(board ? structuredClone(board) : null); + } + + findBySlug(slug: string): Promise { + for (const board of this.boards.values()) { + if (board.slug === slug) { + return Promise.resolve(structuredClone(board)); + } + } + return Promise.resolve(null); + } + + list(): Promise { + return Promise.resolve(Array.from(this.boards.values()).map((board) => structuredClone(board))); + } + + delete(id: string): Promise { + this.boards.delete(id); + return Promise.resolve(); + } +} + +export class InMemoryBoardRequestRepository implements BoardRequestRepository { + private readonly requests = new Map(); + + create(request: BoardRequest): Promise { + this.requests.set(request.id, structuredClone(request)); + return Promise.resolve(); + } + + update(request: BoardRequest): Promise { + this.requests.set(request.id, structuredClone(request)); + return Promise.resolve(); + } + + findById(id: string): Promise { + const request = this.requests.get(id); + return Promise.resolve(request ? structuredClone(request) : null); + } + + list(): Promise { + return Promise.resolve( + Array.from(this.requests.values()).map((request) => structuredClone(request)), + ); + } + + delete(id: string): Promise { + this.requests.delete(id); + return Promise.resolve(); + } +} + +const normalizeUsername = (value: string): string => value.toLowerCase(); + +export class InMemoryUserRepository implements UserRepository { + private readonly users = new Map(); + private readonly byUsername = new Map(); + + create(user: User): Promise { + 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 { + const user = this.users.get(id); + return Promise.resolve(user ? structuredClone(user) : null); + } + + findByUsername(username: string): Promise { + const id = this.byUsername.get(normalizeUsername(username)); + if (!id) { + return Promise.resolve(null); + } + return this.findById(id); + } + + isUsernameTaken(username: string): Promise { + return Promise.resolve(this.byUsername.has(normalizeUsername(username))); + } +} + +export class InMemoryBoardRequestVoteRepository implements BoardRequestVoteRepository { + private readonly votes = new Set(); + + hasVote(requestId: string, userId: string): Promise { + return Promise.resolve(this.votes.has(this.key(requestId, userId))); + } + + addVote(requestId: string, userId: string): Promise { + this.votes.add(this.key(requestId, userId)); + return Promise.resolve(); + } + + removeVote(requestId: string, userId: string): Promise { + this.votes.delete(this.key(requestId, userId)); + return Promise.resolve(); + } + + countVotes(requestId: string): Promise { + 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(); + + list(): Promise { + return Promise.resolve( + Array.from(this.announcements.values()) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map((announcement) => structuredClone(announcement)), + ); + } + + create(announcement: BoardAnnouncement): Promise { + this.announcements.set(announcement.id, structuredClone(announcement)); + return Promise.resolve(); + } + + delete(id: string): Promise { + this.announcements.delete(id); + return Promise.resolve(); + } +} diff --git a/storage/.gitignore b/storage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore -- 2.39.5