max / makenotwork
128 files changed,
+16647 insertions,
-11767 deletions
| @@ -71,14 +71,14 @@ src/ | |||
| 71 | 71 | ├── error.rs Error handling | |
| 72 | 72 | ├── auth.rs Authentication | |
| 73 | 73 | ├── csrf.rs CSRF protection | |
| 74 | - | ├── db/ Database queries (module) | |
| 74 | + | ├── db/ Database queries (directory module) | |
| 75 | 75 | ├── docs.rs Documentation rendering | |
| 76 | 76 | ├── email/ Email handling (directory module) | |
| 77 | - | ├── git.rs Git source browser logic | |
| 77 | + | ├── git/ Git source browser logic (directory module) | |
| 78 | 78 | ├── helpers.rs Shared helper functions | |
| 79 | 79 | ├── markdown.rs Markdown rendering | |
| 80 | 80 | ├── monitor.rs Health monitoring | |
| 81 | - | ├── payments.rs Stripe integration | |
| 81 | + | ├── payments/ Stripe integration (directory module) | |
| 82 | 82 | ├── rss.rs RSS feed generation | |
| 83 | 83 | ├── scanning/ File scanning (ClamAV, YARA, hash lookup) | |
| 84 | 84 | ├── scheduler.rs Background task scheduler | |
| @@ -87,27 +87,27 @@ src/ | |||
| 87 | 87 | ├── synckit_auth.rs SyncKit JWT auth | |
| 88 | 88 | ├── templates/ Askama templates (directory module) | |
| 89 | 89 | ├── types/ Shared types (directory module) | |
| 90 | - | ├── validation.rs Input validation | |
| 90 | + | ├── validation/ Input validation (directory module) | |
| 91 | 91 | ├── wordlist.rs Wordlist for invite codes | |
| 92 | 92 | └── routes/ | |
| 93 | 93 | ├── mod.rs | |
| 94 | 94 | ├── admin.rs Admin panel | |
| 95 | 95 | ├── auth.rs Login, signup, logout | |
| 96 | 96 | ├── api/ JSON API endpoints (directory module) | |
| 97 | - | ├── git.rs Git source browser routes | |
| 98 | - | ├── git_issues.rs Git issue tracker routes | |
| 97 | + | ├── git/ Git source browser routes (directory module) | |
| 98 | + | ├── git_issues/ Git issue tracker routes (directory module) | |
| 99 | 99 | ├── pages/ HTML page routes (directory module) | |
| 100 | - | │ ├── mod.rs Route composer | |
| 101 | - | │ ├── public/ Public-facing pages (directory module) | |
| 102 | - | │ ├── dashboard/ Creator dashboard + HTMX tabs (directory module) | |
| 103 | - | │ ├── email_actions.rs Email link handlers | |
| 104 | - | │ ├── feeds.rs RSS feeds | |
| 105 | - | │ └── blog.rs Blog pages | |
| 100 | + | │ ├── mod.rs Route composer | |
| 101 | + | │ ├── public/ Public-facing pages (directory module) | |
| 102 | + | │ ├── dashboard/ Creator dashboard + HTMX tabs (directory module) | |
| 103 | + | │ ├── email_actions/ Email link handlers (directory module) | |
| 104 | + | │ ├── feeds.rs RSS feeds | |
| 105 | + | │ └── blog.rs Blog pages | |
| 106 | 106 | ├── oauth.rs OAuth provider routes | |
| 107 | - | ├── postmark.rs Postmark webhook handler | |
| 108 | - | ├── storage.rs File upload/download | |
| 107 | + | ├── postmark/ Postmark webhook handler (directory module) | |
| 108 | + | ├── storage/ File upload/download (directory module) | |
| 109 | 109 | ├── stripe/ Stripe webhooks + connect (directory module) | |
| 110 | - | └── synckit.rs SyncKit API endpoints | |
| 110 | + | └── synckit/ SyncKit API endpoints (directory module) | |
| 111 | 111 | ``` | |
| 112 | 112 | ||
| 113 | 113 | Route files should stay under 500 lines. When a route module grows beyond that, split it into a directory module grouped by domain. |
| @@ -1785,6 +1785,7 @@ dependencies = [ | |||
| 1785 | 1785 | "regex", | |
| 1786 | 1786 | "serde", | |
| 1787 | 1787 | "toml", | |
| 1788 | + | "tracing", | |
| 1788 | 1789 | ] | |
| 1789 | 1790 | ||
| 1790 | 1791 | [[package]] | |
| @@ -3350,7 +3351,7 @@ dependencies = [ | |||
| 3350 | 3351 | ||
| 3351 | 3352 | [[package]] | |
| 3352 | 3353 | name = "makenotwork" | |
| 3353 | - | version = "0.3.17" | |
| 3354 | + | version = "0.3.18" | |
| 3354 | 3355 | dependencies = [ | |
| 3355 | 3356 | "anyhow", | |
| 3356 | 3357 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.3.18" | |
| 3 | + | version = "0.3.19" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1,156 @@ | |||
| 1 | + | # MNW Deployment | |
| 2 | + | ||
| 3 | + | Scripts and configuration for deploying MNW to production. | |
| 4 | + | ||
| 5 | + | ## Quick Reference | |
| 6 | + | ||
| 7 | + | ```sh | |
| 8 | + | ./deploy/deploy.sh # Full: build + config + binary + restart | |
| 9 | + | ./deploy/deploy.sh --quick # Build + binary + restart (skip config) | |
| 10 | + | ./deploy/deploy.sh --config # Config files only (Caddyfile, systemd, static, docs) | |
| 11 | + | ``` | |
| 12 | + | ||
| 13 | + | Run all commands from the `MNW/` directory. | |
| 14 | + | ||
| 15 | + | ## Prerequisites (one-time) | |
| 16 | + | ||
| 17 | + | ```sh | |
| 18 | + | brew install zig | |
| 19 | + | cargo install cargo-zigbuild | |
| 20 | + | rustup target add x86_64-unknown-linux-gnu | |
| 21 | + | ``` | |
| 22 | + | ||
| 23 | + | ## What Each Mode Does | |
| 24 | + | ||
| 25 | + | ### Full deploy (default) | |
| 26 | + | ||
| 27 | + | 1. Cross-compiles the binary with `cargo zigbuild --release --target x86_64-unknown-linux-gnu` | |
| 28 | + | 2. Uploads config files (Caddyfile, systemd unit, error pages, security configs) | |
| 29 | + | 3. Minifies CSS via `clean-css-cli`, uploads static assets via rsync | |
| 30 | + | 4. Uploads public site-docs and generated rustdoc | |
| 31 | + | 5. Sends a 30-second restart warning to connected users via internal API | |
| 32 | + | 6. Stops the service, uploads the binary (+ `mnw-admin` if present), restarts | |
| 33 | + | 7. Verifies the app responds on `http://127.0.0.1:3000` | |
| 34 | + | ||
| 35 | + | ### Quick deploy (`--quick`) | |
| 36 | + | ||
| 37 | + | Skips config/static/docs upload. Builds, warns users, uploads binary, restarts. | |
| 38 | + | ||
| 39 | + | ### Config deploy (`--config`) | |
| 40 | + | ||
| 41 | + | Uploads Caddyfile, systemd unit, error pages, security configs, minified CSS, static assets, site-docs, and rustdoc. Reloads systemd and restarts Caddy. Does not touch the application binary. | |
| 42 | + | ||
| 43 | + | ## Production Server | |
| 44 | + | ||
| 45 | + | - **Host:** Hetzner VPS (CCX13 x86, US-West) | |
| 46 | + | - **Public IP:** `5.78.144.244` | |
| 47 | + | - **Tailscale IP:** `100.120.174.96` (hostname: `alpha-west-1`) | |
| 48 | + | - **SSH:** `root@100.120.174.96` (via Tailscale only) | |
| 49 | + | - **OS:** Ubuntu, x86_64 | |
| 50 | + | ||
| 51 | + | ### Filesystem layout | |
| 52 | + | ||
| 53 | + | ``` | |
| 54 | + | /opt/makenotwork/ | |
| 55 | + | makenotwork Application binary | |
| 56 | + | mnw-admin Admin CLI binary | |
| 57 | + | .env Environment variables (secrets) | |
| 58 | + | static/ CSS, JS, fonts, images | |
| 59 | + | error-pages/ Custom 404/500/502 pages | |
| 60 | + | backup-db.sh Database backup script | |
| 61 | + | docs/public/ Site documentation (rendered by DocEngine) | |
| 62 | + | rustdoc/ Generated API reference | |
| 63 | + | deploy/ Security config copies | |
| 64 | + | ||
| 65 | + | /opt/git/ Bare git repos (source browser) | |
| 66 | + | makenotwork.git/ | |
| 67 | + | synckit-client.git/ | |
| 68 | + | ... | |
| 69 | + | ||
| 70 | + | /etc/caddy/Caddyfile Reverse proxy config | |
| 71 | + | /etc/caddy/cloudflare-origin.pem Cloudflare Origin CA cert | |
| 72 | + | /etc/caddy/cloudflare-origin-key.pem Origin CA private key | |
| 73 | + | /etc/caddy/cloudflare-authenticated-origin-pull-ca.pem Cloudflare AOP CA | |
| 74 | + | /etc/caddy/maxj-phd-origin.pem maxj.phd Origin CA cert | |
| 75 | + | /etc/caddy/maxj-phd-origin-key.pem maxj.phd Origin CA key | |
| 76 | + | /etc/systemd/system/makenotwork.service systemd unit | |
| 77 | + | ``` | |
| 78 | + | ||
| 79 | + | ### Services | |
| 80 | + | ||
| 81 | + | | Service | Role | Port | | |
| 82 | + | |---------|------|------| | |
| 83 | + | | `makenotwork` | Application (systemd, runs as `makenotwork` user) | 3000 | | |
| 84 | + | | `caddy` | Reverse proxy, TLS termination | 443 | | |
| 85 | + | | `postgresql` | Database (`makenotwork` db + user) | 5432 | | |
| 86 | + | ||
| 87 | + | ### Networking | |
| 88 | + | ||
| 89 | + | - **Cloudflare** proxies all HTTP/HTTPS traffic (origin IP hidden) | |
| 90 | + | - **SSL:** Full (Strict) mode with Cloudflare Origin CA (15yr wildcard for `*.makenot.work` and `*.maxj.phd`) | |
| 91 | + | - **Authenticated Origin Pulls** enabled (mTLS between Cloudflare and origin) | |
| 92 | + | - **SSH:** `ssh.makenot.work` DNS A record points directly to public IP (proxy OFF) for git push/pull | |
| 93 | + | - **Firewall:** ufw + fail2ban, sshd hardened | |
| 94 | + | ||
| 95 | + | ## Scripts Reference | |
| 96 | + | ||
| 97 | + | | Script | Purpose | | |
| 98 | + | |--------|---------| | |
| 99 | + | | `deploy.sh` | Main deployment script (build, upload, restart) | | |
| 100 | + | | `backup-db.sh` | PostgreSQL backup (pg_dump, uploaded to server) | | |
| 101 | + | | `generate-rustdoc.sh` | Generate rustdoc for library crates | | |
| 102 | + | | `ota-publish.sh` | Publish OTA release (auth, create release, presigned upload, verify) | | |
| 103 | + | | `run-ci.sh` | CI runner (check, test, clippy, audit) -- runs on astra | | |
| 104 | + | | `setup-firewall.sh` | Configure ufw rules | | |
| 105 | + | | `setup-git-ssh.sh` | Configure git SSH access | | |
| 106 | + | | `setup-ssh-keys.sh` | Deploy SSH authorized keys | | |
| 107 | + | ||
| 108 | + | ## Configuration Files | |
| 109 | + | ||
| 110 | + | | File | Deployed to | Purpose | | |
| 111 | + | |------|-------------|---------| | |
| 112 | + | | `Caddyfile` | `/etc/caddy/Caddyfile` | Reverse proxy rules for all domains | | |
| 113 | + | | `makenotwork.service` | `/etc/systemd/system/` | systemd unit (EnvironmentFile, restart policy) | | |
| 114 | + | | `env.production` | Reference for `.env` format | **Not deployed** -- `.env` is edited on server | | |
| 115 | + | | `fail2ban-sshd.conf` | `/opt/makenotwork/deploy/` | fail2ban jail config | | |
| 116 | + | | `sshd-git.conf` | `/opt/makenotwork/deploy/` | SSH config for git user | | |
| 117 | + | | `error-pages/*.html` | `/opt/makenotwork/error-pages/` | Custom Caddy error pages | | |
| 118 | + | ||
| 119 | + | ## Environment Variables | |
| 120 | + | ||
| 121 | + | All secrets live in `/opt/makenotwork/.env` (loaded by systemd `EnvironmentFile`). See `env.production` for the template. Key variables: | |
| 122 | + | ||
| 123 | + | - `DATABASE_URL` -- PostgreSQL connection string | |
| 124 | + | - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_CLIENT_ID` -- Stripe Connect | |
| 125 | + | - `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` -- Hetzner Object Storage | |
| 126 | + | - `POSTMARK_SERVER_TOKEN` -- transactional email | |
| 127 | + | - `JWT_SECRET`, `SESSION_SECRET` -- authentication | |
| 128 | + | - `SYNCKIT_JWT_SECRET` -- SyncKit token signing | |
| 129 | + | - `HOST_URL` -- public base URL (`https://makenot.work`) | |
| 130 | + | ||
| 131 | + | ## Astra (dev/test server) | |
| 132 | + | ||
| 133 | + | - **Tailscale IP:** `100.106.221.39` | |
| 134 | + | - **OS:** Pop!_OS 24.04 LTS, aarch64 (96 cores, 125GB RAM) | |
| 135 | + | - **PostgreSQL 16** with tuned settings | |
| 136 | + | - **Used for:** CI, integration tests, staging | |
| 137 | + | ||
| 138 | + | ### Running tests on astra | |
| 139 | + | ||
| 140 | + | ```sh | |
| 141 | + | ssh 100.106.221.39 | |
| 142 | + | /home/max/staging/run-tests.sh # All tests | |
| 143 | + | /home/max/staging/run-tests.sh auth:: # Filtered | |
| 144 | + | ``` | |
| 145 | + | ||
| 146 | + | Use `--test-threads=8` (or the `RUST_TEST_THREADS=8` env var set in `.bashrc`) to avoid overwhelming PostgreSQL with 96 concurrent `CREATE DATABASE` calls. | |
| 147 | + | ||
| 148 | + | ## Versioning | |
| 149 | + | ||
| 150 | + | - Version is set in `Cargo.toml` and compiled into the binary via `env!("CARGO_PKG_VERSION")` | |
| 151 | + | - **Always ask before bumping** -- never auto-increment | |
| 152 | + | - Edit `Cargo.toml` version field before building | |
| 153 | + | ||
| 154 | + | ## Full Setup | |
| 155 | + | ||
| 156 | + | See `SERVER_SETUP.md` for the complete provisioning checklist (PostgreSQL, Caddy, systemd, Stripe, S3, DNS, Cloudflare, security hardening) and `RECOVERY.md` for disaster recovery procedures. |
| @@ -0,0 +1,291 @@ | |||
| 1 | + | # MNW -- API Reference | |
| 2 | + | ||
| 3 | + | Public JSON API endpoints. All write routes require authentication via session cookie or JWT. | |
| 4 | + | ||
| 5 | + | Rate limits: write routes (burst 10, 2/sec per IP), export routes (burst 3, 1/sec per IP). | |
| 6 | + | ||
| 7 | + | HTMX responses return HTML fragments; non-HTMX requests get JSON. | |
| 8 | + | ||
| 9 | + | --- | |
| 10 | + | ||
| 11 | + | ## Projects | |
| 12 | + | ||
| 13 | + | | Method | Path | Description | | |
| 14 | + | |--------|------|-------------| | |
| 15 | + | | POST | /api/projects | Create a new project | | |
| 16 | + | | GET | /api/projects | List all projects for the authenticated user | | |
| 17 | + | | PUT | /api/projects/{id} | Update a project | | |
| 18 | + | | DELETE | /api/projects/{id} | Delete a project | | |
| 19 | + | ||
| 20 | + | ## Git Repos | |
| 21 | + | ||
| 22 | + | | Method | Path | Description | | |
| 23 | + | |--------|------|-------------| | |
| 24 | + | | POST | /api/repos | Create a bare git repo on disk | | |
| 25 | + | | POST | /api/projects/{id}/repos | Link a repo to a project | | |
| 26 | + | | DELETE | /api/projects/{id}/repos/{name} | Unlink a repo from a project | | |
| 27 | + | | PUT | /api/repos/{id}/visibility | Update repo visibility | | |
| 28 | + | ||
| 29 | + | ## Items | |
| 30 | + | ||
| 31 | + | | Method | Path | Description | | |
| 32 | + | |--------|------|-------------| | |
| 33 | + | | POST | /api/projects/{id}/items | Create a new item | | |
| 34 | + | | PUT | /api/items/{id} | Update an item | | |
| 35 | + | | DELETE | /api/items/{id} | Delete an item | | |
| 36 | + | | POST | /api/items/{id}/duplicate | Duplicate an item as a new draft | | |
| 37 | + | | PUT | /api/items/{id}/move | Reorder an item within its project | | |
| 38 | + | ||
| 39 | + | ### Bulk Operations | |
| 40 | + | ||
| 41 | + | | Method | Path | Description | | |
| 42 | + | |--------|------|-------------| | |
| 43 | + | | POST | /api/items/bulk/publish | Publish multiple items | | |
| 44 | + | | POST | /api/items/bulk/unpublish | Unpublish multiple items | | |
| 45 | + | | POST | /api/items/bulk/delete | Delete multiple items | | |
| 46 | + | ||
| 47 | + | ### Tags | |
| 48 | + | ||
| 49 | + | | Method | Path | Description | | |
| 50 | + | |--------|------|-------------| | |
| 51 | + | | POST | /api/items/{id}/tags | Add a tag to an item | | |
| 52 | + | | DELETE | /api/items/{id}/tags/{tag_id} | Remove a tag from an item | | |
| 53 | + | | PUT | /api/items/{id}/primary-tag | Set the primary tag | | |
| 54 | + | | GET | /api/tags/search | Typeahead tag search | | |
| 55 | + | | GET | /api/items/{id}/tag-suggestions | Suggest tags for an item | | |
| 56 | + | ||
| 57 | + | ### Bundles | |
| 58 | + | ||
| 59 | + | | Method | Path | Description | | |
| 60 | + | |--------|------|-------------| | |
| 61 | + | | POST | /api/items/{id}/bundle/add | Add a child item to a bundle | | |
| 62 | + | | DELETE | /api/items/{id}/bundle/{child_id} | Remove a child from a bundle | | |
| 63 | + | | PUT | /api/items/{id}/bundle/{child_id}/listed | Toggle child visibility | | |
| 64 | + | ||
| 65 | + | ### Text Content | |
| 66 | + | ||
| 67 | + | | Method | Path | Description | | |
| 68 | + | |--------|------|-------------| | |
| 69 | + | | PUT | /api/items/{id}/text | Save or update text body content | | |
| 70 | + | ||
| 71 | + | ### Chapters | |
| 72 | + | ||
| 73 | + | | Method | Path | Description | | |
| 74 | + | |--------|------|-------------| | |
| 75 | + | | GET | /api/items/{id}/chapters | List chapters for an item | | |
| 76 | + | | POST | /api/items/{id}/chapters | Create a chapter marker | | |
| 77 | + | | PUT | /api/chapters/{id} | Update a chapter | | |
| 78 | + | | DELETE | /api/chapters/{id} | Delete a chapter | | |
| 79 | + | ||
| 80 | + | ### Versions | |
| 81 | + | ||
| 82 | + | | Method | Path | Description | | |
| 83 | + | |--------|------|-------------| | |
| 84 | + | | GET | /api/items/{id}/versions | List versions for an item | | |
| 85 | + | | POST | /api/items/{id}/versions | Create a new version | | |
| 86 | + | ||
| 87 | + | ## Blog | |
| 88 | + | ||
| 89 | + | | Method | Path | Description | | |
| 90 | + | |--------|------|-------------| | |
| 91 | + | | POST | /api/projects/{id}/blog | Create a blog post | | |
| 92 | + | | GET | /api/projects/{id}/blog | List blog posts for a project | | |
| 93 | + | | GET | /api/blog/{id} | Get a blog post (includes markdown body) | | |
| 94 | + | | PUT | /api/blog/{id} | Update a blog post | | |
| 95 | + | | DELETE | /api/blog/{id} | Delete a blog post | | |
| 96 | + | ||
| 97 | + | ## Collections | |
| 98 | + | ||
| 99 | + | | Method | Path | Description | | |
| 100 | + | |--------|------|-------------| | |
| 101 | + | | POST | /api/collections | Create a collection | | |
| 102 | + | | PUT | /api/collections/{id} | Update a collection | | |
| 103 | + | | DELETE | /api/collections/{id} | Delete a collection | | |
| 104 | + | | POST | /api/collections/{id}/items/{item_id} | Add an item to a collection | | |
| 105 | + | | DELETE | /api/collections/{id}/items/{item_id} | Remove an item from a collection | | |
| 106 | + | | PUT | /api/collections/{id}/items/reorder | Reorder items | | |
| 107 | + | | GET | /api/collections/for-item/{item_id} | Collections containing an item | | |
| 108 | + | ||
| 109 | + | ## License Keys | |
| 110 | + | ||
| 111 | + | ### Creator Endpoints (auth required) | |
| 112 | + | ||
| 113 | + | | Method | Path | Description | | |
| 114 | + | |--------|------|-------------| | |
| 115 | + | | POST | /api/items/{id}/license-settings | Configure license key settings | | |
| 116 | + | | GET | /api/items/{id}/keys | List keys for an item | | |
| 117 | + | | POST | /api/items/{id}/keys | Generate a new key | | |
| 118 | + | | POST | /api/keys/{id}/revoke | Revoke a key | | |
| 119 | + | ||
| 120 | + | ### Public Endpoints (rate-limited, stable API contract) | |
| 121 | + | ||
| 122 | + | | Method | Path | Description | | |
| 123 | + | |--------|------|-------------| | |
| 124 | + | | POST | /api/keys/validate | Validate and optionally activate a key | | |
| 125 | + | | POST | /api/keys/deactivate | Release an activation slot | | |
| 126 | + | | GET | /api/keys/{code}/status | Check key status | | |
| 127 | + | ||
| 128 | + | ## Promo Codes | |
| 129 | + | ||
| 130 | + | | Method | Path | Description | | |
| 131 | + | |--------|------|-------------| | |
| 132 | + | | POST | /api/promo-codes | Create a promo code | | |
| 133 | + | | GET | /api/promo-codes | List promo codes | | |
| 134 | + | | DELETE | /api/promo-codes/{id} | Delete a promo code | | |
| 135 | + | | POST | /api/promo-codes/claim | Claim a free_access promo code (buyer endpoint) | | |
| 136 | + | ||
| 137 | + | ## Subscription Tiers | |
| 138 | + | ||
| 139 | + | | Method | Path | Description | | |
| 140 | + | |--------|------|-------------| | |
| 141 | + | | POST | /api/projects/{id}/tiers | Create a subscription tier | | |
| 142 | + | | GET | /api/projects/{id}/tiers | List tiers for a project | | |
| 143 | + | | PUT | /api/tiers/{id} | Update a tier | | |
| 144 | + | | DELETE | /api/tiers/{id} | Delete a tier | | |
| 145 | + | ||
| 146 | + | ## Follows | |
| 147 | + | ||
| 148 | + | | Method | Path | Description | | |
| 149 | + | |--------|------|-------------| | |
| 150 | + | | POST | /api/follow/{type}/{id} | Follow a user or project | | |
| 151 | + | | DELETE | /api/follow/{type}/{id} | Unfollow a user or project | | |
| 152 | + | ||
| 153 | + | ## Custom Links | |
| 154 | + | ||
| 155 | + | | Method | Path | Description | | |
| 156 | + | |--------|------|-------------| | |
| 157 | + | | POST | /api/links | Create a profile link | | |
| 158 | + | | PUT | /api/links/{id} | Update a link | | |
| 159 | + | | DELETE | /api/links/{id} | Delete a link | | |
| 160 | + | | PUT | /api/links/reorder | Reorder all links | | |
| 161 | + | ||
| 162 | + | ## Labels | |
| 163 | + | ||
| 164 | + | | Method | Path | Description | | |
| 165 | + | |--------|------|-------------| | |
| 166 | + | | GET | /api/labels | List all available labels | | |
| 167 | + | | GET | /api/projects/{id}/labels | Get labels for a project | | |
| 168 | + | | POST | /api/projects/{id}/labels | Add a label to a project | | |
| 169 | + | | DELETE | /api/projects/{id}/labels/{label_id} | Remove a label from a project | | |
| 170 | + | ||
| 171 | + | ## Categories | |
| 172 | + | ||
| 173 | + | | Method | Path | Description | | |
| 174 | + | |--------|------|-------------| | |
| 175 | + | | POST | /api/categories | Create a category | | |
| 176 | + | | GET | /api/categories/search | Search categories (typeahead) | | |
| 177 | + | ||
| 178 | + | ## Custom Domains | |
| 179 | + | ||
| 180 | + | | Method | Path | Description | | |
| 181 | + | |--------|------|-------------| | |
| 182 | + | | POST | /api/domains | Add a custom domain | | |
| 183 | + | | POST | /api/domains/verify | Trigger DNS TXT verification | | |
| 184 | + | | DELETE | /api/domains/{id} | Remove a custom domain | | |
| 185 | + | ||
| 186 | + | ## Content Insertions | |
| 187 | + | ||
| 188 | + | | Method | Path | Description | | |
| 189 | + | |--------|------|-------------| | |
| 190 | + | | POST | /api/users/me/insertions/presign | Get presigned upload URL | | |
| 191 | + | | POST | /api/users/me/insertions/confirm | Confirm upload | | |
| 192 | + | | GET | /api/users/me/insertions | List insertions | | |
| 193 | + | | PUT | /api/insertions/{id} | Rename an insertion | | |
| 194 | + | | DELETE | /api/insertions/{id} | Delete an insertion | | |
| 195 | + | | POST | /api/items/{id}/insertions | Place an insertion in an item | | |
| 196 | + | | DELETE | /api/item-insertions/{id} | Remove a placement | | |
| 197 | + | | GET | /api/items/{id}/insertions | List placements for an item | | |
| 198 | + | ||
| 199 | + | ## Exports | |
| 200 | + | ||
| 201 | + | | Method | Path | Description | | |
| 202 | + | |--------|------|-------------| | |
| 203 | + | | POST | /api/export/projects | Export projects + items as JSON | | |
| 204 | + | | POST | /api/export/sales | Export sales as CSV | | |
| 205 | + | | POST | /api/export/purchases | Export purchases as CSV | | |
| 206 | + | | POST | /api/export/followers | Export followers as CSV | | |
| 207 | + | | POST | /api/export/content | Export content files as ZIP | | |
| 208 | + | ||
| 209 | + | ## User Account | |
| 210 | + | ||
| 211 | + | | Method | Path | Description | | |
| 212 | + | |--------|------|-------------| | |
| 213 | + | | PUT | /api/users/me | Update display name and bio | | |
| 214 | + | | PUT | /api/users/me/password | Change password | | |
| 215 | + | | PUT | /api/users/me/preferences | Update notification preferences | | |
| 216 | + | | PUT | /api/users/me/stripe-tax | Toggle Stripe tax collection | | |
| 217 | + | | DELETE | /api/users/me | Delete account | | |
| 218 | + | | DELETE | /api/users/me/stripe | Disconnect Stripe Connect | | |
| 219 | + | | POST | /api/users/me/appeal | Submit suspension appeal | | |
| 220 | + | | POST | /api/resend-verification | Resend verification email | | |
| 221 | + | | POST | /api/account/request-deletion | Request account deletion with data export | | |
| 222 | + | ||
| 223 | + | ### Sessions | |
| 224 | + | ||
| 225 | + | | Method | Path | Description | | |
| 226 | + | |--------|------|-------------| | |
| 227 | + | | DELETE | /api/users/me/sessions/{id} | Revoke a session | | |
| 228 | + | | DELETE | /api/users/me/sessions | Revoke all other sessions | | |
| 229 | + | ||
| 230 | + | ### SSH Keys | |
| 231 | + | ||
| 232 | + | | Method | Path | Description | | |
| 233 | + | |--------|------|-------------| | |
| 234 | + | | GET | /api/users/me/ssh-keys | List SSH keys | | |
| 235 | + | | POST | /api/users/me/ssh-keys | Add an SSH key | | |
| 236 | + | | DELETE | /api/users/me/ssh-keys/{id} | Delete an SSH key | | |
| 237 | + | ||
| 238 | + | ### TOTP (2FA) | |
| 239 | + | ||
| 240 | + | | Method | Path | Description | | |
| 241 | + | |--------|------|-------------| | |
| 242 | + | | POST | /api/users/me/totp/setup | Generate TOTP secret and QR code | | |
| 243 | + | | POST | /api/users/me/totp/confirm | Verify first code and enable 2FA | | |
| 244 | + | | POST | /api/users/me/totp/disable | Disable 2FA | | |
| 245 | + | | POST | /api/users/me/totp/backup-codes | Regenerate backup codes | | |
| 246 | + | | GET | /api/users/me/totp/status | Get 2FA status | | |
| 247 | + | ||
| 248 | + | ### Passkeys (WebAuthn) | |
| 249 | + | ||
| 250 | + | | Method | Path | Description | | |
| 251 | + | |--------|------|-------------| | |
| 252 | + | | POST | /api/users/me/passkeys/register/start | Start passkey registration | | |
| 253 | + | | POST | /api/users/me/passkeys/register/finish | Finish passkey registration | | |
| 254 | + | | GET | /api/users/me/passkeys | List passkeys | | |
| 255 | + | | PUT | /api/users/me/passkeys/{id} | Rename a passkey | | |
| 256 | + | | DELETE | /api/users/me/passkeys/{id} | Delete a passkey | | |
| 257 | + | ||
| 258 | + | ## Library | |
| 259 | + | ||
| 260 | + | | Method | Path | Description | | |
| 261 | + | |--------|------|-------------| | |
| 262 | + | | POST | /api/library/add/{item_id} | Add to library | | |
| 263 | + | | DELETE | /api/library/remove/{item_id} | Remove from library | | |
| 264 | + | ||
| 265 | + | ## Miscellaneous | |
| 266 | + | ||
| 267 | + | | Method | Path | Description | | |
| 268 | + | |--------|------|-------------| | |
| 269 | + | | POST | /api/reports | Submit a content report | | |
| 270 | + | | POST | /api/broadcast | Send broadcast to followers | | |
| 271 | + | | POST | /api/waitlist/apply | Join the platform waitlist | | |
| 272 | + | | POST | /api/invites/create | Create an invite code | | |
| 273 | + | | POST | /api/validate/project-slug | Check project slug availability | | |
| 274 | + | | POST | /api/validate/collection-slug | Check collection slug availability | | |
| 275 | + | | POST | /api/validate/blog-slug | Check blog slug availability | | |
| 276 | + | | POST | /api/email-signup | Subscribe to newsletter (public, no auth) | | |
| 277 | + | | GET | /api/restart-status | Check pending service restart (public, no auth) | | |
| 278 | + | ||
| 279 | + | --- | |
| 280 | + | ||
| 281 | + | ## Internal API | |
| 282 | + | ||
| 283 | + | Service-to-service endpoints under `/api/internal/` are authenticated via `ServiceAuth` bearer token. Used by mnw-cli for creator operations, git authorization, and file uploads. Not listed here — see `src/routes/api/internal.rs` for the full surface. | |
| 284 | + | ||
| 285 | + | ## SyncKit API | |
| 286 | + | ||
| 287 | + | SyncKit endpoints under `/api/synckit/` handle cloud sync, device management, and blob operations. Authenticated via SyncKit JWT. See `src/routes/synckit.rs`. | |
| 288 | + | ||
| 289 | + | ## OTA API | |
| 290 | + | ||
| 291 | + | OTA update endpoints under `/api/ota/` serve release metadata and presigned download URLs. See `src/routes/ota.rs`. |
| @@ -44,6 +44,7 @@ MNW/src/ | |||
| 44 | 44 | mt_client.rs HTTP client for Multithreaded internal API | |
| 45 | 45 | rss.rs RSS/Atom feed generation | |
| 46 | 46 | wordlist.rs Wordlist for human-readable invite codes | |
| 47 | + | pricing.rs Access control and pricing model trait | |
| 47 | 48 | db/ Database queries (module per domain) | |
| 48 | 49 | routes/ HTTP handlers (module per domain) | |
| 49 | 50 | templates/ Askama template structs (public, dashboard, partials) | |
| @@ -93,7 +94,7 @@ A `json_error_layer` middleware converts HTML error responses to `{"error": "... | |||
| 93 | 94 | ||
| 94 | 95 | ## Database Layer | |
| 95 | 96 | ||
| 96 | - | PostgreSQL via sqlx with compile-time checked queries. 43 migrations (auto-applied on boot). Connection pool: 25 max connections, 3-second acquire timeout. | |
| 97 | + | PostgreSQL via sqlx with compile-time checked queries. 50 migrations (auto-applied on boot). Connection pool: 25 max connections, 3-second acquire timeout. | |
| 97 | 98 | ||
| 98 | 99 | ### DB Modules | |
| 99 | 100 | ||
| @@ -143,6 +144,8 @@ Each `db/` submodule handles a specific domain: | |||
| 143 | 144 | | `reports` | User content reports | | |
| 144 | 145 | | `fan_plus` | Fan+ subscription tracking | | |
| 145 | 146 | | `creator_tiers` | Creator tier enforcement (storage limits, feature gates) | | |
| 147 | + | | `bundles` | Item bundling/packs | | |
| 148 | + | | `email_signups` | Early signup tracking | | |
| 146 | 149 | ||
| 147 | 150 | ### Type System | |
| 148 | 151 | ||
| @@ -338,7 +341,7 @@ Two spawned Tokio tasks, coordinated via `watch::channel` for graceful shutdown: | |||
| 338 | 341 | | Error types | `src/error.rs` | | |
| 339 | 342 | | Authentication | `src/auth.rs` | | |
| 340 | 343 | | CSRF middleware | `src/csrf.rs` | | |
| 341 | - | | Database modules | `src/db/` (53 submodules) | | |
| 344 | + | | Database modules | `src/db/` (49 submodules) | | |
| 342 | 345 | | Route modules | `src/routes/` (17 submodules) | | |
| 343 | 346 | | Template structs | `src/templates/` | | |
| 344 | 347 | | Email service | `src/email/` | | |
| @@ -353,7 +356,7 @@ Two spawned Tokio tasks, coordinated via `watch::channel` for graceful shutdown: | |||
| 353 | 356 | | Scheduler | `src/scheduler.rs` | | |
| 354 | 357 | | Shared types | `src/types/` | | |
| 355 | 358 | | Askama templates | `templates/` | | |
| 356 | - | | Migrations | `migrations/` (001-043) | | |
| 359 | + | | Migrations | `migrations/` (001-050) | | |
| 357 | 360 | | Static assets | `static/` | | |
| 358 | 361 | | Integration tests | `tests/` | | |
| 359 | 362 | | Deploy scripts | `deploy/` | |
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 8 | - | Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + 28 health). 0 clippy warnings. v0.3.13. Grade stable at A. Major additions since Run 11: email-first issue tracker (G6), I5 git patch inbound, bundles + batch upload, ProjectFeature trait. 2 integration test failures found (delete_item_returns_toast, item_wizard_license_keys). | |
| 8 | + | Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + 28 health). 0 clippy warnings. v0.3.18. Grade stable at A. Major additions since Run 11: email-first issue tracker (G6), I5 git patch inbound, bundles + batch upload, ProjectFeature trait. 2 integration test failures found (delete_item_returns_toast, item_wizard_license_keys). | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 |
| @@ -0,0 +1,109 @@ | |||
| 1 | + | # MNW Server AI Anti-Pattern Cleanup | |
| 2 | + | ||
| 3 | + | Audit of MNW server (Rust/Axum backend with HTMX frontend, PostgreSQL, Stripe Connect, S3) for silent error handling and AI-induced anti-patterns. | |
| 4 | + | ||
| 5 | + | **Summary:** 8 MEDIUM, 1 LOW. Zero HIGH. No dead code, no stubs, no string-typing, no `todo!()`/`unimplemented!()`. All 51 `#[allow(dead_code)]` justified (Askama templates, sqlx FromRow, deserialization structs). All 37 `.expect()` justified (initialization, HMAC, static HeaderValues). Zero production `.unwrap()` panics (all 305 in test code or safe `unwrap_or` fallbacks). | |
| 6 | + | ||
| 7 | + | ## Fixes (MEDIUM) | |
| 8 | + | ||
| 9 | + | ### M1. Silent license key creation after free promo claim | |
| 10 | + | ||
| 11 | + | `src/routes/api/promo_codes.rs:427` — `let _ = db::license_keys::create_license_key(...)`. After a free promo code claim with `enable_license_keys` on the item, if the DB insert fails, the customer's claim succeeds but they don't receive a license key. They have no way to know or retry. The creator sees a claim but the buyer has no key. | |
| 12 | + | ||
| 13 | + | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::error!` including item_id, user_id. | |
| 14 | + | ||
| 15 | + | ### M2. Silent session update in AuthUser extractor | |
| 16 | + | ||
| 17 | + | `src/auth.rs:141` — `let _ = session.insert(USER_SESSION_KEY, user.clone()).await`. When the periodic DB refresh detects the user's state has changed (suspended, creator_tier, fan_plus, can_create_projects), the session update is silently dropped. The user continues with stale session data until the next successful refresh cycle. A suspended user may retain access. | |
| 18 | + | ||
| 19 | + | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::warn!` including user_id. | |
| 20 | + | ||
| 21 | + | ### M3. Silent build status updates in build runner | |
| 22 | + | ||
| 23 | + | `src/build_runner.rs:100,171,194,231` — Four `let _ = db::builds::update_build_status(...)`. When the final status update fails (Failed or Succeeded), the build record stays in Running/Pending state indefinitely. Note: the mark-as-Running update (line 125) is already correctly handled with `if let Err(e)` + return. | |
| 24 | + | ||
| 25 | + | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::error!` including build_id and target status. Keep the same control flow (all are at function return points). | |
| 26 | + | ||
| 27 | + | ### M4. Silent build-release association | |
| 28 | + | ||
| 29 | + | `src/build_runner.rs:223` — `let _ = db::builds::set_build_release(...)`. After a successful build with artifacts uploaded and OTA release created, the link between the build record and the release is lost. The dashboard can't show which release came from this build. | |
| 30 | + | ||
| 31 | + | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::error!` including build_id and release_id. | |
| 32 | + | ||
| 33 | + | ### M5. Silent issue tracker updates on git push | |
| 34 | + | ||
| 35 | + | `src/routes/git_issues/push_refs.rs:187,194,200,207,217` — Five `let _ = db::issues::{update_issue_status,create_comment}(...)`. When a developer pushes commits referencing issues (e.g., "Fixes #3", "Reopens #5", "Refs #7"), the status change and reference comment are silently dropped. The developer believes the push updated the issue. | |
| 36 | + | ||
| 37 | + | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including issue_id and the action (close/reopen/reference). | |
| 38 | + | ||
| 39 | + | ### M6. Silent Stripe audit log failures | |
| 40 | + | ||
| 41 | + | 16 instances across `src/routes/stripe/webhook/{checkout.rs,billing.rs,subscriptions.rs}` — `let _ = db::subscriptions::log_subscription_event(...)`. Subscription lifecycle events (purchases, renewals, cancellations, trial starts/ends, payment failures, refunds) are lost when the audit log insert fails. When investigating billing disputes or debugging subscription issues, there's no record of what happened. | |
| 42 | + | ||
| 43 | + | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including the event type. Keep fire-and-forget behavior (the webhook handler must return 200 to Stripe regardless). | |
| 44 | + | ||
| 45 | + | ### M7. Silent onboarding step advancement | |
| 46 | + | ||
| 47 | + | `src/scheduler.rs:193,204,217,228` and `src/routes/pages/public/join_wizard.rs:215` — Five `let _ = db::users::{advance_onboarding_step,batch_advance_onboarding_step}(...)`. If the step advancement fails, users remain at the old step and receive the same onboarding email repeatedly on each scheduler tick. | |
| 48 | + | ||
| 49 | + | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including user_id(s) and step number. | |
| 50 | + | ||
| 51 | + | ### M8. Silent cache invalidation after upload | |
| 52 | + | ||
| 53 | + | `src/routes/storage.rs:360,902` and `src/routes/api/internal.rs:478,711,747` — Five `let _ = db::projects::bump_cache_generation(...)`. After a file upload confirmation or update, the project's cache generation isn't bumped. Browsers and CDN may serve stale content. Users upload new files but see old versions. | |
| 54 | + | ||
| 55 | + | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including project_id. | |
| 56 | + | ||
| 57 | + | ## Fixes (LOW) | |
| 58 | + | ||
| 59 | + | ### L1. Silent session tracking deletion on logout | |
| 60 | + | ||
| 61 | + | `src/routes/auth.rs:255` — `let _ = db::sessions::delete_session_by_id(...)`. If the tracking row deletion fails during logout, the row persists in the sessions table. No security impact (the session itself is separately flushed), just stale data. | |
| 62 | + | ||
| 63 | + | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::warn!` including tracking_id. | |
| 64 | + | ||
| 65 | + | ## Skipping (intentional design) | |
| 66 | + | ||
| 67 | + | **Session flush on invalid session (auth.rs:123, oauth.rs:242, email_actions.rs:610):** `let _ = session.flush().await` fires before returning Unauthorized or after account deletion. If the flush fails, the session store cleans up expired sessions independently. Benign fire-and-forget. | |
| 68 | + | ||
| 69 | + | **Build log append (build_runner.rs:139,148,160,306,328,364):** `let _ = append_log_bounded(...)` writes build log lines. Log lines are diagnostic, not state. Missing a line doesn't affect build correctness. The function itself already has proper error handling internally. | |
| 70 | + | ||
| 71 | + | **Build cleanup (build_runner.rs:319,323,343,352):** `let _ = run_ssh_command(host, "rm -rf ...")` and `let _ = tokio::fs::remove_file(...)`. Best-effort cleanup of temporary files on remote hosts and local filesystem. Failure means leftover temp files, not data corruption. | |
| 72 | + | ||
| 73 | + | **Invite redeemed notification (join_wizard.rs:156):** `let _ = email_client.send_invite_redeemed(...)` in a spawned task. Fire-and-forget notification to the inviter. Notification failure doesn't affect the new user's registration. | |
| 74 | + | ||
| 75 | + | **S3 object deletion after failed upload (routes/storage.rs:307,318,537,548,733,744,862,873 and routes/api/internal.rs:411,429):** `s3.delete_object(...).await.ok()` cleans up orphaned S3 objects when upload confirmation fails validation. Best-effort cleanup — orphaned objects waste storage but don't affect correctness. | |
| 76 | + | ||
| 77 | + | **Stripe webhook header parsing (webhook/mod.rs:34, webhook_v2.rs:33):** `.and_then(|v| v.to_str().ok())` on `Stripe-Signature` header. Returns 400 Bad Request on next line if None. Correct. | |
| 78 | + | ||
| 79 | + | **CSRF token on Stripe Connect page (stripe/connect.rs:26):** `csrf::get_or_create_token(&session).await.ok()` — best-effort CSRF for the onboarding redirect page. The actual Stripe Connect flow has its own security. | |
| 80 | + | ||
| 81 | + | **Session remove for cleanup (routes/auth.rs:373, two_factor.rs:108-135, dashboard/main.rs:149,381, api/passkeys.rs:94, api/users/sessions.rs:42,61):** All `session.remove::<T>(key).await.ok()` patterns are cleaning up temporary session state after it's been consumed. If removal fails, the stale key expires naturally with the session. | |
| 82 | + | ||
| 83 | + | **Session insert for advisory warnings (api/users/profile.rs:144, email_actions.rs:250):** `session.insert("password_warning", ...).await.ok()` — advisory breach notification that displays once. Non-blocking, non-critical. | |
| 84 | + | ||
| 85 | + | **Session get for tracking ID (api/users/profile.rs:155):** `session.get("tracking_id").ok().flatten()` — getting current session tracking ID to revoke other sessions after password change. If this fails, other sessions aren't revoked, but the password IS changed (security improvement still applied). | |
| 86 | + | ||
| 87 | + | **Header value parsing (~15 instances across routes):** `.and_then(|v| v.to_str().ok())` on HTTP headers (HX-Target, Authorization, etc.). Standard Rust pattern for non-ASCII-safe header values. Fallback is correct (returns None, handled by caller). | |
| 88 | + | ||
| 89 | + | **Query/form parameter parsing (~20 instances across routes):** `.and_then(|s| s.parse().ok())`, `.filter_map(|v| v.parse().ok())` on user input from query strings and form fields. Correct — invalid input defaults to None, handled by caller. | |
| 90 | + | ||
| 91 | + | **User lookup for notification display (~6 instances):** `db::users::get_user_by_id().await.ok().flatten()` in checkout, follows, and library routes. Used to get display names for notification emails or receipt details. If lookup fails, the notification still sends with a generic name or is skipped. Non-critical. | |
| 92 | + | ||
| 93 | + | **Date parsing for display (landing.rs:198, dashboard/tabs.rs:427):** `chrono::DateTime::parse_from_rfc3339(s).ok()` — display-only date parsing. Invalid dates are skipped in UI rendering. | |
| 94 | + | ||
| 95 | + | **Health check HTTP calls (health.rs:249,255,477,478):** `.ok()` on reqwest calls and JSON parsing for the public health dashboard. Display-only data — failure shows "unavailable" status, which is correct. | |
| 96 | + | ||
| 97 | + | **Discover page pagination parsing (discover.rs:90,122,292):** `.and_then(|s| s.parse().ok())` on page/offset parameters. Falls back to default pagination. Correct. | |
| 98 | + | ||
| 99 | + | **`#[allow(dead_code)]` (51 instances):** 14 in `types/mod.rs` (Askama template struct fields), 16 in `db/models.rs` (sqlx FromRow), 10 in `templates/{dashboard,public,partials}.rs` (Askama), 6 in `routes/pages/public/health.rs` (deserialization + display), 1 in `db/patches.rs` (forward-use query), 1 in `db/monitor.rs` (DB snapshot), 1 in `routes/pages/dashboard/mod.rs` (query string deserialization). All justified. | |
| 100 | + | ||
| 101 | + | **`.expect()` (37 instances):** 10 in `main.rs` (initialization), 9 in `email/tokens.rs` (HMAC-SHA256, mathematically infallible), 4 in `helpers.rs` (HMAC + rate limiter), 3 in `mt_client.rs` (HTTP client + HMAC + serde), 6 in route files (static HeaderValue construction), 1 in `routes/api/totp.rs` (HMAC), 1 in `bin/mnw-admin.rs` (CLI env var). All infallible or process-fatal startup conditions. | |
| 102 | + | ||
| 103 | + | **Process exit code fallback (build_runner.rs:397,433):** `.unwrap_or(-1)` on `output.status.code()`. Returns -1 when process was killed by signal (no exit code). Correct. | |
| 104 | + | ||
| 105 | + | ## Verification | |
| 106 | + | ||
| 107 | + | ```sh | |
| 108 | + | cd ~/Code/MNW && cargo check && cargo test | |
| 109 | + | ``` |
| @@ -0,0 +1,86 @@ | |||
| 1 | + | # Codesize Efficiency Audit | |
| 2 | + | ||
| 3 | + | **Date:** 2026-04-02 | |
| 4 | + | **Scope:** `src/` (production code only, tests excluded) | |
| 5 | + | **Grade:** B+ | |
| 6 | + | ||
| 7 | + | ## Summary | |
| 8 | + | ||
| 9 | + | - 61,340 lines across 146 `.rs` files in `src/` | |
| 10 | + | - 12 files exceed 500 lines; 6 are exempt (flat lists), 6 are violations, 3 are borderline | |
| 11 | + | - 3 duplication patterns identified | |
| 12 | + | - No dead code found | |
| 13 | + | - Test suite (23,768 lines, 544 tests) is A+ — well-factored, no issues | |
| 14 | + | ||
| 15 | + | ## Exempt Files (flat lists, correctly large) | |
| 16 | + | ||
| 17 | + | | File | Lines | Why exempt | | |
| 18 | + | |------|-------|-----------| | |
| 19 | + | | `src/wordlist.rs` | 2,056 | Static 2048-word array | | |
| 20 | + | | `src/db/models.rs` | 2,045 | FromRow structs + simple accessors | | |
| 21 | + | | `src/db/enums.rs` | 1,217 | 35 `impl_str_enum!` macro enums | | |
| 22 | + | | `src/validation.rs` | 1,177 | 60+ linear validation functions | | |
| 23 | + | | `src/templates/public.rs` | 952 | Askama HTML markup | | |
| 24 | + | | `src/types/mod.rs` | 871 | Type definitions / newtypes | | |
| 25 | + | ||
| 26 | + | ## Violations (branching logic >500 lines) | |
| 27 | + | ||
| 28 | + | | File | Lines | Domains | Split recommendation | | |
| 29 | + | |------|-------|---------|---------------------| | |
| 30 | + | | `src/routes/api/internal.rs` | 1,634 | 10 (SSH, items, blog, promo, licenses, analytics, git auth...) | `internal/` dir with 5-6 submodules | | |
| 31 | + | | `src/git.rs` | 1,176 | 4 (refs, commit graph, blame, annotation) | `git/{refs,graph,blame}.rs` | | |
| 32 | + | | `src/payments.rs` | 1,173 | 5 (PWYW, discounts, licenses, subs, transactions) | `payments/{checkout,subscriptions,discounts}.rs` | | |
| 33 | + | | `src/routes/storage.rs` | 920 | 4 (presign, confirm+scan, download, health) | `routes/storage/` dir | | |
| 34 | + | | `src/routes/postmark.rs` | 887 | 4 (bounces, complaints, tracking, delivery) | `routes/email_webhooks/` dir | | |
| 35 | + | | `src/routes/pages/dashboard/wizards/item.rs` | 791 | 6 wizard steps | `wizards/item/` dir | | |
| 36 | + | | `src/helpers.rs` | 775 | 8 (slugs, CSV, URLs, dates, crypto, email, cache, forms) | `helpers/` dir with 4 submodules | | |
| 37 | + | | `src/routes/stripe/checkout.rs` | 768 | 4 (forms, promo validation, session, payment intent) | Extract `checkout/promo.rs` | | |
| 38 | + | ||
| 39 | + | ## Borderline (monitor, no action needed) | |
| 40 | + | ||
| 41 | + | | File | Lines | Notes | | |
| 42 | + | |------|-------|-------| | |
| 43 | + | | `src/routes/api/items.rs` | 885 | 6 domains (CRUD, publish, chapters) but each handler is self-contained | | |
| 44 | + | | `src/routes/pages/public/health.rs` | 767 | Health page handlers, low branching complexity | | |
| 45 | + | | `src/storage.rs` | 855 | S3 client operations, cohesive module | | |
| 46 | + | | `src/db/items.rs` | 835 | Item queries, each <100 lines, flat structure | | |
| 47 | + | ||
| 48 | + | ## Duplication Patterns | |
| 49 | + | ||
| 50 | + | ### Ownership verification (8+ sites) | |
| 51 | + | ||
| 52 | + | ``` | |
| 53 | + | get_project → check user_id → NotFound/Forbidden | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | Repeated across route files for project-scoped endpoints. Could extract a shared `verify_project_ownership(pool, project_id, user_id)` helper. | |
| 57 | + | ||
| 58 | + | ### Slug collision resolution (5+ sites) | |
| 59 | + | ||
| 60 | + | ``` | |
| 61 | + | slugify → exists check → append suffix loop | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | Appears in project creation, item creation, blog posts, collections, and page creation. Could consolidate into a generic `resolve_unique_slug()` function. | |
| 65 | + | ||
| 66 | + | ### Price formatting (3+ sites) | |
| 67 | + | ||
| 68 | + | Cents-to-display logic (`amount / 100`, decimal formatting) repeated in payment routes, dashboard templates, and email templates. | |
| 69 | + | ||
| 70 | + | ## Dead Code | |
| 71 | + | ||
| 72 | + | None found. All public functions have callers. No unused feature flags. No orphaned modules. | |
| 73 | + | ||
| 74 | + | ## Test Suite | |
| 75 | + | ||
| 76 | + | - 23,768 lines, 544 tests | |
| 77 | + | - Test harness: 1,129 lines (`tests/common/`) | |
| 78 | + | - Well-factored — shared fixtures, clear module boundaries | |
| 79 | + | - Grade: A+ — no splitting needed | |
| 80 | + | ||
| 81 | + | ## Key Paths | |
| 82 | + | ||
| 83 | + | - `src/` — all production code (146 files) | |
| 84 | + | - `tests/` — integration tests | |
| 85 | + | - `src/routes/` — HTTP handlers (largest concentration of branching logic) | |
| 86 | + | - `src/db/` — database queries (mostly flat, exempt) |
| @@ -0,0 +1,62 @@ | |||
| 1 | + | # Concurrency Audit | |
| 2 | + | ||
| 3 | + | Audit date: 2026-04-02. Grade: **A**. | |
| 4 | + | ||
| 5 | + | No code fixes needed. Zero concurrency issues found. | |
| 6 | + | ||
| 7 | + | ## Primitives In Use | |
| 8 | + | ||
| 9 | + | | Primitive | Count | Usage | | |
| 10 | + | |-----------|-------|-------| | |
| 11 | + | | `Arc<T>` | 16 fields | All heavy AppState fields (db pool, S3 client, email client, config, etc.) | | |
| 12 | + | | `DashMap` | 2 caches | session_cache (UserSessionId→Instant), domain_cache (String→UserId) | | |
| 13 | + | | `tokio::spawn` | ~20 sites | Fire-and-forget tasks (emails, webhooks, builds) | | |
| 14 | + | | `tokio::sync::watch` | 1 channel | Graceful shutdown broadcast to monitor + scheduler | | |
| 15 | + | | `tokio::select!` | 2 loops | Monitor + scheduler main loops (shutdown + interval) | | |
| 16 | + | | `LazyLock<Regex>` | 3 regexes | Thread-safe compiled regexes | | |
| 17 | + | | `Mutex` / `RwLock` / `unsafe` / `Condvar` | 0 | Not used in production code | | |
| 18 | + | ||
| 19 | + | ## Verified Areas | |
| 20 | + | ||
| 21 | + | ### AppState (lib.rs) | |
| 22 | + | - `#[derive(Clone)]` with all heavy types in `Arc` | |
| 23 | + | - Cheap per-request clone via Axum's `State` extractor | |
| 24 | + | ||
| 25 | + | ### DashMap (16 call sites) | |
| 26 | + | - All operations are single-level get/insert/remove/retain — no nested access (which could deadlock sharded maps) | |
| 27 | + | - Session cache pruned every monitor cycle via `retain()` with TTL check | |
| 28 | + | - Domain cache populated on startup, updated on domain add/remove | |
| 29 | + | ||
| 30 | + | ### Fire-and-Forget Spawns (~20) | |
| 31 | + | - All spawned tasks have internal error handling (`if let Err(e)` + tracing) | |
| 32 | + | - No `.unwrap()` or `.expect()` in any spawned task | |
| 33 | + | - Panic risk negligible | |
| 34 | + | ||
| 35 | + | ### Email Fan-Out (3 paths) | |
| 36 | + | - `send_release_announcements` (scheduler.rs:63-89) | |
| 37 | + | - `send_blog_post_announcements` (scheduler.rs:147-173) | |
| 38 | + | - `broadcast_send` (broadcast.rs:93-109) | |
| 39 | + | - Pattern: spawn one task, iterate sequentially over subscribers | |
| 40 | + | - Sequential sending naturally rate-limits Postmark API calls | |
| 41 | + | ||
| 42 | + | ### Graceful Shutdown (main.rs) | |
| 43 | + | - `tokio::sync::watch` channel created in main, `subscribe()` shared to monitor + scheduler | |
| 44 | + | - Both use `tokio::select!` to check shutdown signal on every loop iteration | |
| 45 | + | ||
| 46 | + | ### HTTP Client Timeouts (mt_client.rs) | |
| 47 | + | - 5s request timeout, 3s connect timeout | |
| 48 | + | - Prevents hung connections from blocking the async runtime | |
| 49 | + | ||
| 50 | + | ### Connection Pool (sqlx PgPool) | |
| 51 | + | - Shared via `Arc` in AppState | |
| 52 | + | - Spawned tasks share the pool; sqlx handles connection limiting internally | |
| 53 | + | ||
| 54 | + | ### Onboarding Scheduler (scheduler.rs:181-238) | |
| 55 | + | - Sequential email sending within the scheduler tick | |
| 56 | + | - Batch operations for skip cases | |
| 57 | + | ||
| 58 | + | ## Intentional Design Decisions | |
| 59 | + | ||
| 60 | + | - **No Sentry** — panics in spawned tasks go to stderr only. Acceptable for private alpha. | |
| 61 | + | - **JoinHandle discarded** — intentional fire-and-forget for emails, webhooks, cache invalidation. All have internal error handling; parent tasks don't need the result. | |
| 62 | + | - **No Semaphore on email fan-out** — sequential sending in a single task is sufficient for current scale. A concurrency limiter would only matter if switching to concurrent per-subscriber sending. |
| @@ -0,0 +1,533 @@ | |||
| 1 | + | # MNW Database Schema | |
| 2 | + | ||
| 3 | + | PostgreSQL schema reference. 50 migrations, 60+ tables. Migrations live in `migrations/` and auto-run on boot via sqlx. | |
| 4 | + | ||
| 5 | + | ## Domain Map | |
| 6 | + | ||
| 7 | + | | Domain | Tables | Purpose | | |
| 8 | + | |--------|--------|---------| | |
| 9 | + | | Auth & Users | 10 | Identity, sessions, passkeys, waitlist, creator tiers | | |
| 10 | + | | Content | 9 | Projects, items, versions, chapters, tags, blog posts | | |
| 11 | + | | Commerce | 9 | Transactions, subscriptions, license keys, promo codes | | |
| 12 | + | | Collections | 3 | User-curated collections and bundles | | |
| 13 | + | | Git & Issues | 8 | Repositories, SSH keys, issues, email threading | | |
| 14 | + | | SyncKit | 9 | Cloud sync, E2E encryption, OTA updates, build pipeline | | |
| 15 | + | | Email | 4 | Mailing lists, subscribers, file scanning, signups | | |
| 16 | + | | Moderation | 3 | Reports, platform labels, project label assignments | | |
| 17 | + | | Content Insertions | 2 | Reusable media clips with placement positions | | |
| 18 | + | | Social | 1 | Follows (users, projects, tags) | | |
| 19 | + | | Creator Growth | 1 | Invite codes | | |
| 20 | + | | Sessions | 1 | HTTP session store (tower-sessions) | | |
| 21 | + | ||
| 22 | + | --- | |
| 23 | + | ||
| 24 | + | ## Auth & Users | |
| 25 | + | ||
| 26 | + | ### users | |
| 27 | + | Primary user table. Handles identity, Stripe Connect, email verification, security, and creator access. | |
| 28 | + | ||
| 29 | + | | Column | Type | Notes | | |
| 30 | + | |--------|------|-------| | |
| 31 | + | | `id` | UUID PK | | | |
| 32 | + | | `username` | VARCHAR | Unique | | |
| 33 | + | | `email` | VARCHAR | Unique | | |
| 34 | + | | `password_hash` | VARCHAR | argon2 | | |
| 35 | + | | `display_name` | VARCHAR | | | |
| 36 | + | | `bio` | TEXT | | | |
| 37 | + | | `avatar_url` | VARCHAR | | | |
| 38 | + | | `stripe_account_id` | VARCHAR | Stripe Connect | | |
| 39 | + | | `stripe_onboarding_complete` | BOOL | | | |
| 40 | + | | `stripe_payouts_enabled` | BOOL | | | |
| 41 | + | | `stripe_charges_enabled` | BOOL | | | |
| 42 | + | | `email_verified` | BOOL | | | |
| 43 | + | | `email_verification_token` | VARCHAR | | | |
| 44 | + | | `totp_secret` | VARCHAR | TOTP 2FA | | |
| 45 | + | | `totp_enabled` | BOOL | | | |
| 46 | + | | `failed_login_attempts` | INT | Brute-force protection | | |
| 47 | + | | `locked_until` | TIMESTAMPTZ | | | |
| 48 | + | | `can_create_projects` | BOOL | Waitlist gate | | |
| 49 | + | | `creator_tier` | VARCHAR | Migration 036 | | |
| 50 | + | | `login_notification_enabled` | BOOL | | | |
| 51 | + | | `notify_release` | BOOL | Migration 015 | | |
| 52 | + | | `created_at`, `updated_at` | TIMESTAMPTZ | | | |
| 53 | + | ||
| 54 | + | ### user_passkeys | |
| 55 | + | WebAuthn/passkey credentials. Migration 006. | |
| 56 | + | ||
| 57 | + | - FK `user_id` -> users | |
| 58 | + | - `credential_json` (JSONB), `credential_id` (BYTEA, unique per user), `name`, `last_used_at` | |
| 59 | + | ||
| 60 | + | ### user_sessions | |
| 61 | + | Active login sessions. | |
| 62 | + | ||
| 63 | + | - FK `user_id` -> users | |
| 64 | + | - `user_agent`, `ip_address`, `created_at`, `last_active_at` | |
| 65 | + | ||
| 66 | + | ### login_tokens | |
| 67 | + | One-time tokens for passwordless login. | |
| 68 | + | ||
| 69 | + | - FK `user_id` -> users | |
| 70 | + | - `token_hash` (unique), `expires_at`, `used_at` | |
| 71 | + | ||
| 72 | + | ### backup_codes | |
| 73 | + | 2FA recovery codes. | |
| 74 | + | ||
| 75 | + | - FK `user_id` -> users | |
| 76 | + | - `code_hash`, `used_at` | |
| 77 | + | ||
| 78 | + | ### oauth_authorization_codes | |
| 79 | + | PKCE OAuth provider (used by Multithreaded). | |
| 80 | + | ||
| 81 | + | - FK `app_id` -> sync_apps, `user_id` -> users | |
| 82 | + | - `code` (unique), `code_challenge`, `code_challenge_method`, `redirect_uri`, `expires_at`, `used_at` | |
| 83 | + | ||
| 84 | + | ### email_suppressions | |
| 85 | + | Bounce/complaint tracking. Migration 015. | |
| 86 | + | ||
| 87 | + | - `email` (unique, case-insensitive), `reason` ('HardBounce', 'SpamComplaint') | |
| 88 | + | ||
| 89 | + | ### creator_waitlist | |
| 90 | + | Alpha access waitlist. | |
| 91 | + | ||
| 92 | + | - FK `user_id` (unique) -> users, `wave_id` -> creator_waves, `invited_by_user_id` -> users | |
| 93 | + | - `pitch`, `status` ('pending', 'approved', 'rejected'), `selection_method`, `admin_note` | |
| 94 | + | ||
| 95 | + | ### creator_waves | |
| 96 | + | Batch invitation waves. | |
| 97 | + | ||
| 98 | + | - `wave_number` (unique), `hand_picked_count`, `lottery_count`, `total_eligible`, `note` | |
| 99 | + | ||
| 100 | + | ### creator_subscriptions | |
| 101 | + | Creator tier billing. Migration 036. | |
| 102 | + | ||
| 103 | + | - FK `user_id` (unique) -> users | |
| 104 | + | - `stripe_subscription_id` (unique), `tier`, `status`, `current_period_start`, `current_period_end` | |
| 105 | + | ||
| 106 | + | --- | |
| 107 | + | ||
| 108 | + | ## Content | |
| 109 | + | ||
| 110 | + | ### projects | |
| 111 | + | Creator projects (albums, podcasts, books, software, etc.). | |
| 112 | + | ||
| 113 | + | | Column | Type | Notes | | |
| 114 | + | |--------|------|-------| | |
| 115 | + | | `id` | UUID PK | | | |
| 116 | + | | `user_id` | UUID FK | -> users | | |
| 117 | + | | `slug` | VARCHAR | Unique per user | | |
| 118 | + | | `title` | VARCHAR | | | |
| 119 | + | | `description` | TEXT | | | |
| 120 | + | | `project_type` | VARCHAR | music, podcast, blog, book, course, software, art, writing | | |
| 121 | + | | `category_id` | UUID FK | -> project_categories | | |
| 122 | + | | `cover_image_url` | VARCHAR | | | |
| 123 | + | | `is_public` | BOOL | | | |
| 124 | + | | `mt_community_id` | UUID | Multithreaded link (migration 037) | | |
| 125 | + | | `features` | TEXT[] | Platform capabilities (migration 046) | | |
| 126 | + | | `created_at`, `updated_at` | TIMESTAMPTZ | | | |
| 127 | + | ||
| 128 | + | Unique constraint: `(user_id, slug)`. | |
| 129 | + | ||
| 130 | + | ### items | |
| 131 | + | Individual content pieces within a project. | |
| 132 | + | ||
| 133 | + | | Column | Type | Notes | | |
| 134 | + | |--------|------|-------| | |
| 135 | + | | `id` | UUID PK | | | |
| 136 | + | | `project_id` | UUID FK | -> projects (CASCADE) | | |
| 137 | + | | `title` | VARCHAR | | | |
| 138 | + | | `description` | TEXT | | | |
| 139 | + | | `slug` | VARCHAR | Unique per project (migration 043) | | |
| 140 | + | | `price_cents` | INT | >= 0 | | |
| 141 | + | | `item_type` | VARCHAR | | | |
| 142 | + | | `is_public` | BOOL | | | |
| 143 | + | | `listed` | BOOL | Default true (migration 048) | | |
| 144 | + | | `body` | TEXT | Text content | | |
| 145 | + | | `word_count`, `reading_time_minutes` | INT | | | |
| 146 | + | | `audio_url`, `duration_seconds` | | Audio content | | |
| 147 | + | | `audio_s3_key`, `cover_s3_key` | VARCHAR | S3 storage keys | | |
| 148 | + | | `enable_license_keys` | BOOL | | | |
| 149 | + | | `default_max_activations` | INT | | | |
| 150 | + | | `sales_count`, `play_count`, `download_count` | INT | Denormalized counters | | |
| 151 | + | | `pwyw_enabled` | BOOL | Pay-what-you-want | | |
| 152 | + | | `pwyw_min_cents` | INT | | | |
| 153 | + | | `publish_at` | TIMESTAMPTZ | Scheduled publishing (migration 011) | | |
| 154 | + | | `scan_status` | VARCHAR | File scanning (migration 004) | | |
| 155 | + | | `mt_thread_id` | UUID | Multithreaded link (migration 037) | | |
| 156 | + | | `sort_order` | INT | >= 0 | | |
| 157 | + | | `created_at`, `updated_at` | TIMESTAMPTZ | | | |
| 158 | + | ||
| 159 | + | ### versions | |
| 160 | + | File versions for downloadable items. | |
| 161 | + | ||
| 162 | + | - FK `item_id` -> items (CASCADE) | |
| 163 | + | - `version_number`, `changelog`, `file_url`, `file_size_bytes`, `file_name`, `s3_key` | |
| 164 | + | - `download_count` (>= 0), `is_current` (unique constraint: one current per item) | |
| 165 | + | - `scan_status` (migration 004) | |
| 166 | + | ||
| 167 | + | ### chapters | |
| 168 | + | Audio chapter markers. | |
| 169 | + | ||
| 170 | + | - FK `item_id` -> items (CASCADE) | |
| 171 | + | - `title`, `start_seconds`, `sort_order` | |
| 172 | + | ||
| 173 | + | ### tags | |
| 174 | + | Hierarchical tag system. | |
| 175 | + | ||
| 176 | + | - Self-referential: `parent_id` -> tags | |
| 177 | + | - `name`, `slug` (unique), `sort_order`, `path` (denormalized hierarchy, migration 038) | |
| 178 | + | ||
| 179 | + | ### item_tags | |
| 180 | + | Many-to-many item-tag association. | |
| 181 | + | ||
| 182 | + | - PK `(item_id, tag_id)` | |
| 183 | + | - `is_primary` (one primary tag per item) | |
| 184 | + | ||
| 185 | + | ### blog_posts | |
| 186 | + | Project blog posts with markdown. | |
| 187 | + | ||
| 188 | + | - FK `project_id` -> projects (CASCADE), `author_id` -> users | |
| 189 | + | - `title`, `slug`, `body_markdown`, `body_html` | |
| 190 | + | - `published_at`, `publish_at` (scheduled, migration 011) | |
| 191 | + | - `mt_thread_id` (migration 037) | |
| 192 | + | - Unique: `(project_id, slug)` | |
| 193 | + | ||
| 194 | + | ### project_categories | |
| 195 | + | Browsable project categories. | |
| 196 | + | ||
| 197 | + | - `name`, `slug` (both unique) | |
| 198 | + | ||
| 199 | + | ### custom_links | |
| 200 | + | User profile links. | |
| 201 | + | ||
| 202 | + | - FK `user_id` -> users | |
| 203 | + | - `url`, `title`, `description`, `sort_order` | |
| 204 | + | ||
| 205 | + | --- | |
| 206 | + | ||
| 207 | + | ## Commerce | |
| 208 | + | ||
| 209 | + | ### transactions | |
| 210 | + | Purchase records. | |
| 211 | + | ||
| 212 | + | - FK `buyer_id` -> users, `seller_id` -> users (nullable), `item_id` -> items (nullable), `project_id` (migration 047, nullable) | |
| 213 | + | - `amount_cents` (>= 0), `platform_fee_cents` (>= 0), `currency` | |
| 214 | + | - `status`: 'pending', 'completed', 'failed', 'refunded' | |
| 215 | + | - `stripe_payment_intent_id`, `stripe_checkout_session_id` | |
| 216 | + | - `item_title`, `seller_username` -- denormalized (preserved after deletion) | |
| 217 | + | - `share_contact` | |
| 218 | + | - View: **purchases** (distinct on buyer_id, item_id, completed) | |
| 219 | + | ||
| 220 | + | ### subscription_tiers | |
| 221 | + | Subscription plans within projects (or items, migration 047). | |
| 222 | + | ||
| 223 | + | - FK `project_id` -> projects, `item_id` (nullable) -> items | |
| 224 | + | - `name`, `description`, `price_cents` (> 0), `sort_order`, `is_active` | |
| 225 | + | - `stripe_product_id`, `stripe_price_id` | |
| 226 | + | ||
| 227 | + | ### subscriptions | |
| 228 | + | Active subscriber records. | |
| 229 | + | ||
| 230 | + | - FK `subscriber_id` -> users, `tier_id` -> subscription_tiers, `project_id` -> projects, `item_id` (nullable) | |
| 231 | + | - `stripe_subscription_id`, `stripe_customer_id`, `status` | |
| 232 | + | - `current_period_start`, `current_period_end`, `canceled_at` | |
| 233 | + | ||
| 234 | + | ### subscription_events | |
| 235 | + | Stripe webhook event log. | |
| 236 | + | ||
| 237 | + | - `stripe_event_id` (unique), `event_type`, `payload` (JSONB) | |
| 238 | + | ||
| 239 | + | ### license_keys | |
| 240 | + | Software license keys. | |
| 241 | + | ||
| 242 | + | - FK `item_id` -> items, `owner_id` -> users, `transaction_id` -> transactions (nullable) | |
| 243 | + | - `key_code` (unique), `max_activations`, `activation_count`, `revoked_at` | |
| 244 | + | ||
| 245 | + | ### license_activations | |
| 246 | + | Machine activations for license keys. | |
| 247 | + | ||
| 248 | + | - FK `license_key_id` -> license_keys | |
| 249 | + | - `machine_id`, `label`, `is_active` | |
| 250 | + | - Unique: `(license_key_id, machine_id)` | |
| 251 | + | ||
| 252 | + | ### promo_codes | |
| 253 | + | Unified promotion system (replaces old discount_codes + download_codes). Migration 019. | |
| 254 | + | ||
| 255 | + | - FK `creator_id` -> users, `item_id` (nullable), `project_id` (nullable), `tier_id` (nullable) | |
| 256 | + | - `code` (unique per creator, case-insensitive) | |
| 257 | + | - `code_purpose`: 'discount', 'free_access', 'free_trial' | |
| 258 | + | - Discount fields: `discount_type` ('percentage', 'fixed'), `discount_value`, `min_price_cents` | |
| 259 | + | - Trial fields: `trial_days` | |
| 260 | + | - `max_uses`, `use_count`, `expires_at` | |
| 261 | + | - `is_platform_wide` (migration 031) | |
| 262 | + | - CHECK constraints enforce field consistency by purpose | |
| 263 | + | ||
| 264 | + | ### fan_plus_subscriptions | |
| 265 | + | Consumer-side platform subscription. Migration 031. | |
| 266 | + | ||
| 267 | + | - FK `user_id` (unique) -> users | |
| 268 | + | - `stripe_subscription_id` (unique), `status`, `current_period_start`, `current_period_end` | |
| 269 | + | ||
| 270 | + | ### contact_revocations | |
| 271 | + | Buyer-seller contact sharing opt-out. Migration 016. | |
| 272 | + | ||
| 273 | + | - PK `(buyer_id, seller_id)` | |
| 274 | + | - `revoked_at` | |
| 275 | + | ||
| 276 | + | --- | |
| 277 | + | ||
| 278 | + | ## Collections & Bundles | |
| 279 | + | ||
| 280 | + | ### collections | |
| 281 | + | User-curated collections (playlists, reading lists). Migration 032. | |
| 282 | + | ||
| 283 | + | - FK `user_id` -> users | |
| 284 | + | - `slug`, `title`, `description`, `is_public` | |
| 285 | + | - Unique: `(user_id, slug)` | |
| 286 | + | ||
| 287 | + | ### collection_items | |
| 288 | + | Items within a collection. Migration 032. | |
| 289 | + | ||
| 290 | + | - PK `(collection_id, item_id)` | |
| 291 | + | - `position`, `added_at` | |
| 292 | + | ||
| 293 | + | ### bundle_items | |
| 294 | + | Items grouped into a bundle (parent item). Migration 048. | |
| 295 | + | ||
| 296 | + | - PK `(bundle_id, item_id)` -- both FK to items | |
| 297 | + | - `sort_order`, `added_at` | |
| 298 | + | - CHECK: `bundle_id != item_id` | |
| 299 | + | ||
| 300 | + | --- | |
| 301 | + | ||
| 302 | + | ## Git & Issues | |
| 303 | + | ||
| 304 | + | ### git_repos | |
| 305 | + | Git repositories (bare repos on disk, browsed via `git2`). Migration 020. | |
| 306 | + | ||
| 307 | + | - FK `user_id` -> users, `project_id` -> projects (nullable) | |
| 308 | + | - `name` | |
| 309 | + | - Unique: `(user_id, name)` | |
| 310 | + | ||
| 311 | + | ### ssh_keys | |
| 312 | + | User SSH public keys for git push. Migration 024. | |
| 313 | + | ||
| 314 | + | - FK `user_id` -> users | |
| 315 | + | - `public_key`, `fingerprint`, `label` | |
| 316 | + | - Unique: `(user_id, fingerprint)` | |
| 317 | + | ||
| 318 | + | ### issues | |
| 319 | + | Email-first issue tracker. Migration 027. | |
| 320 | + | ||
| 321 | + | - FK `repo_id` -> git_repos, `author_user_id` -> users | |
| 322 | + | - `number` (unique per repo), `title`, `body_markdown`, `body_html` | |
| 323 | + | - `status`: 'open', 'closed' | |
| 324 | + | ||
| 325 | + | ### issue_comments | |
| 326 | + | Comments on issues. Migration 027. | |
| 327 | + | ||
| 328 | + | - FK `issue_id` -> issues, `author_user_id` -> users | |
| 329 | + | - `body_markdown`, `body_html` | |
| 330 | + | ||
| 331 | + | ### issue_labels, issue_label_assignments | |
| 332 | + | Label definitions and assignments. Migration 027. | |
| 333 | + | ||
| 334 | + | - Labels: `name`, `color` (hex), unique per repo | |
| 335 | + | - Assignments: PK `(issue_id, label_id)` | |
| 336 | + | ||
| 337 | + | ### issue_message_ids | |
| 338 | + | Email Message-ID bridging for issue threading. Migration 045. | |
| 339 | + | ||
| 340 | + | - FK `issue_id` -> issues | |
| 341 | + | - `message_id` (unique) | |
| 342 | + | ||
| 343 | + | ### patch_message_ids | |
| 344 | + | Email Message-ID bridging for patch discussions. Migration 044. | |
| 345 | + | ||
| 346 | + | - FK `project_id` -> projects | |
| 347 | + | - `message_id` (unique), `thread_id` (Multithreaded thread ID) | |
| 348 | + | ||
| 349 | + | --- | |
| 350 | + | ||
| 351 | + | ## SyncKit | |
| 352 | + | ||
| 353 | + | ### sync_apps | |
| 354 | + | Registered applications. | |
| 355 | + | ||
| 356 | + | - FK `creator_id` -> users | |
| 357 | + | - `name`, `api_key` (unique, 64-char), `slug` (unique, nullable, migration 033), `is_active` | |
| 358 | + | ||
| 359 | + | ### sync_devices | |
| 360 | + | Per-user device registrations. | |
| 361 | + | ||
| 362 | + | - FK `app_id` -> sync_apps, `user_id` -> users | |
| 363 | + | - `device_name`, `platform`, `last_seen_at` | |
| 364 | + | - Unique: `(app_id, user_id, device_name)` | |
| 365 | + | ||
| 366 | + | ### sync_keys | |
| 367 | + | E2E encryption keys (server never has plaintext). | |
| 368 | + | ||
| 369 | + | - PK `(app_id, user_id)` | |
| 370 | + | - `key_version`, `encrypted_key` | |
| 371 | + | ||
| 372 | + | ### sync_log | |
| 373 | + | Changelog entries for push/pull sync. | |
| 374 | + | ||
| 375 | + | - PK `seq` (BIGSERIAL) | |
| 376 | + | - FK `app_id`, `user_id`, `device_id` | |
| 377 | + | - `table_name`, `operation` ('INSERT', 'UPDATE', 'DELETE'), `row_id`, `client_timestamp`, `data` (JSONB) | |
| 378 | + | ||
| 379 | + | ### sync_blobs | |
| 380 | + | Content-addressed file storage for sync. Migration 012. | |
| 381 | + | ||
| 382 | + | - FK `app_id`, `user_id` | |
| 383 | + | - `hash` (content hash), `s3_key`, `size_bytes` | |
| 384 | + | - Unique: `(app_id, user_id, hash)` | |
| 385 | + | ||
| 386 | + | ### ota_releases | |
| 387 | + | OTA update releases. Migration 033. | |
| 388 | + | ||
| 389 | + | - FK `app_id` -> sync_apps | |
| 390 | + | - `version`, `notes`, `signature`, `pub_date` | |
| 391 | + | - Unique: `(app_id, version)` | |
| 392 | + | ||
| 393 | + | ### ota_artifacts | |
| 394 | + | Per-platform binaries for OTA releases. Migration 033. | |
| 395 | + | ||
| 396 | + | - FK `release_id` -> ota_releases | |
| 397 | + | - `target`, `arch`, `s3_key`, `file_size` | |
| 398 | + | - Unique: `(release_id, target, arch)` | |
| 399 | + | ||
| 400 | + | ### ota_build_configs | |
| 401 | + | Automated build configuration (one per app). Migration 034. | |
| 402 | + | ||
| 403 | + | - FK `app_id` -> sync_apps, `repo_id` -> git_repos | |
| 404 | + | - `build_command`, `artifact_path`, `signing_key_path`, `targets` (TEXT[]), `enabled` | |
| 405 | + | ||
| 406 | + | ### ota_builds | |
| 407 | + | Build execution records. Migration 034. | |
| 408 | + | ||
| 409 | + | - FK `config_id` -> ota_build_configs, `app_id`, `release_id` (nullable) | |
| 410 | + | - `version`, `tag`, `status`, `log`, `error_message`, `triggered_by` (default 'tag') | |
| 411 | + | ||
| 412 | + | --- | |
| 413 | + | ||
| 414 | + | ## Email & Scanning | |
| 415 | + | ||
| 416 | + | ### mailing_lists | |
| 417 | + | Per-project mailing lists. Migration 039. | |
| 418 | + | ||
| 419 | + | - FK `project_id` -> projects | |
| 420 | + | - `list_type`: 'content', 'devlog', 'patches' | |
| 421 | + | - Unique: `(project_id, list_type)` | |
| 422 | + | ||
| 423 | + | ### mailing_list_subscribers | |
| 424 | + | List memberships. Migration 039. | |
| 425 | + | ||
| 426 | + | - FK `list_id` -> mailing_lists, `user_id` -> users | |
| 427 | + | - Unique: `(list_id, user_id)` | |
| 428 | + | ||
| 429 | + | ### file_scan_results | |
| 430 | + | ClamAV/YARA scan results for uploaded files. Migration 004. | |
| 431 | + | ||
| 432 | + | - `s3_key`, `scan_status`, `scan_layers` (JSONB), `sha256`, `file_size_bytes` | |
| 433 | + | ||
| 434 | + | ### email_signups | |
| 435 | + | Landing page email collection. Migration 050. | |
| 436 | + | ||
| 437 | + | - `email` (unique), `source` (default 'landing') | |
| 438 | + | ||
| 439 | + | --- | |
| 440 | + | ||
| 441 | + | ## Moderation | |
| 442 | + | ||
| 443 | + | ### reports | |
| 444 | + | User-submitted reports. Migration 030. | |
| 445 | + | ||
| 446 | + | - FK `reporter_user_id`, `resolved_by` (nullable) -> users | |
| 447 | + | - `target_type`, `target_id` (polymorphic), `report_type`, `reason`, `status`, `admin_notes` | |
| 448 | + | ||
| 449 | + | ### labels | |
| 450 | + | Platform-wide creator commitment labels. Migration 029. Examples: drm-free, no-tracking, solo-dev, accessible. | |
| 451 | + | ||
| 452 | + | - `slug` (unique), `display_name`, `definition`, `examples`, `nonexamples`, `sort_order` | |
| 453 | + | ||
| 454 | + | ### project_labels | |
| 455 | + | Label assignments to projects. Migration 029. | |
| 456 | + | ||
| 457 | + | - PK `(project_id, label_id)` | |
| 458 | + | - `confirmed_at` | |
| 459 | + | ||
| 460 | + | --- | |
| 461 | + | ||
| 462 | + | ## Other | |
| 463 | + | ||
| 464 | + | ### content_insertions, content_insertion_placements | |
| 465 | + | Reusable media clips (e.g., intros, ads) with per-item placement. Migration 007. | |
| 466 | + | ||
| 467 | + | - Insertions: FK `user_id`, `title`, `media_type`, `storage_key`, `duration_ms`, `file_size`, `mime_type` | |
| 468 | + | - Placements: FK `item_id`, `insertion_id`, `position` ('pre_roll', 'mid_roll', 'post_roll'), `offset_ms` | |
| 469 | + | ||
| 470 | + | ### follows | |
| 471 | + | Polymorphic follow system. | |
| 472 | + | ||
| 473 | + | - FK `follower_id` -> users | |
| 474 | + | - `target_type` ('user', 'project', 'tag'), `target_id` (no FK, polymorphic) | |
| 475 | + | - Unique: `(follower_id, target_type, target_id)` | |
| 476 | + | ||
| 477 | + | ### invite_codes | |
| 478 | + | Alpha access invite codes. Migration 009. | |
| 479 | + | ||
| 480 | + | - FK `creator_id` -> users, `redeemed_by_id` (nullable) -> users | |
| 481 | + | - `code` (unique), `redeemed_at` | |
| 482 | + | ||
| 483 | + | ### tower_sessions.session | |
| 484 | + | HTTP session store (managed by tower-sessions-sqlx-store). | |
| 485 | + | ||
| 486 | + | - PK `id` (TEXT), `data` (BYTEA), `expiry_date` | |
| 487 | + | ||
| 488 | + | --- | |
| 489 | + | ||
| 490 | + | ## Design Patterns | |
| 491 | + | ||
| 492 | + | - **Polymorphic targeting:** `follows` and `reports` use `(target_type, target_id)` instead of per-type FK columns | |
| 493 | + | - **Denormalized counters:** `items.sales_count`/`play_count`/`download_count` for fast reads | |
| 494 | + | - **Denormalized display fields:** `transactions.item_title`/`seller_username` survive deletion | |
| 495 | + | - **E2E encryption:** `sync_keys.encrypted_key` is opaque to the server | |
| 496 | + | - **Unified promo codes:** Single table with CHECK constraints instead of separate discount/download tables | |
| 497 | + | - **Message-ID bridging:** `issue_message_ids` and `patch_message_ids` link email threads to platform objects | |
| 498 | + | - **Scheduled publishing:** `publish_at` on items and blog_posts for future release | |
| 499 | + | - **Content-addressed blobs:** `sync_blobs` deduplicates by `(app_id, user_id, hash)` | |
| 500 | + |
Lines truncated
| @@ -0,0 +1,313 @@ | |||
| 1 | + | # MNW Code Patterns | |
| 2 | + | ||
| 3 | + | Recurring patterns, macros, and conventions used throughout the MNW codebase. | |
| 4 | + | ||
| 5 | + | ## Macros | |
| 6 | + | ||
| 7 | + | ### `impl_str_enum!` | |
| 8 | + | ||
| 9 | + | **Location:** `src/db/enums.rs` | |
| 10 | + | ||
| 11 | + | Generates `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` for enums that map to text columns. The sqlx impls delegate to `String`, so the enum works with both TEXT and VARCHAR columns. | |
| 12 | + | ||
| 13 | + | ```rust | |
| 14 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 15 | + | #[serde(rename_all = "snake_case")] | |
| 16 | + | pub enum CodePurpose { | |
| 17 | + | Discount, | |
| 18 | + | FreeAccess, | |
| 19 | + | FreeTrial, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | impl_str_enum!(CodePurpose { | |
| 23 | + | Discount => "discount", | |
| 24 | + | FreeAccess => "free_access", | |
| 25 | + | FreeTrial => "free_trial", | |
| 26 | + | }); | |
| 27 | + | ``` | |
| 28 | + | ||
| 29 | + | **What it generates:** | |
| 30 | + | - `Display::fmt()` writes the mapped string literal | |
| 31 | + | - `FromStr::from_str()` parses back, with error `"invalid CodePurpose: xyz"` | |
| 32 | + | - `sqlx::Type`/`Encode`/`Decode` so sqlx can read/write the enum directly in queries | |
| 33 | + | ||
| 34 | + | **Used for:** `DiscountType`, `CodePurpose`, `WaitlistStatus`, `TransactionStatus`, `ItemType`, `FileScanStatus`, `SelectionMethod`, `LoginTokenPurpose`, and more. | |
| 35 | + | ||
| 36 | + | ### `define_pg_uuid_id!` | |
| 37 | + | ||
| 38 | + | **Location:** `src/db/id_types.rs` | |
| 39 | + | ||
| 40 | + | Creates newtype wrappers around `uuid::Uuid` for type-safe IDs. Prevents accidental mixing of ID types at compile time (e.g., `UserId` vs `ProjectId`), while remaining transparent in the database layer and JSON APIs. | |
| 41 | + | ||
| 42 | + | ```rust | |
| 43 | + | define_pg_uuid_id!( | |
| 44 | + | UserId, | |
| 45 | + | ProjectId, | |
| 46 | + | ItemId, | |
| 47 | + | VersionId, | |
| 48 | + | TransactionId, | |
| 49 | + | // ... 30+ more | |
| 50 | + | ); | |
| 51 | + | ``` | |
| 52 | + | ||
| 53 | + | **What each type gets:** | |
| 54 | + | - `Copy`, `Clone`, `Hash`, `Eq`, `Ord` | |
| 55 | + | - `::new()` generates a UUID v4 | |
| 56 | + | - `::from_uuid(uuid)` and `From<Uuid>` | |
| 57 | + | - `.as_uuid()` and `Deref<Target=Uuid>` | |
| 58 | + | - `::nil()` for test fixtures | |
| 59 | + | - `serde(transparent)` -- serializes as a UUID string | |
| 60 | + | - `sqlx::Type`/`Encode`/`Decode` for PostgreSQL UUID columns | |
| 61 | + | - `Display` and `FromStr` | |
| 62 | + | ||
| 63 | + | ### `impl_into_response!` | |
| 64 | + | ||
| 65 | + | **Location:** `src/templates/mod.rs` | |
| 66 | + | ||
| 67 | + | Bulk-implements `IntoResponse` for Askama template structs, so they can be returned directly from route handlers. | |
| 68 | + | ||
| 69 | + | ```rust | |
| 70 | + | impl_into_response!( | |
| 71 | + | IndexTemplate, | |
| 72 | + | LoginTemplate, | |
| 73 | + | DashboardUserTemplate, | |
| 74 | + | // ... 90+ template types | |
| 75 | + | ); | |
| 76 | + | ``` | |
| 77 | + | ||
| 78 | + | Each implementation calls `render_template(self)`, which renders to HTML and returns a 200 response (or a 500 error page if rendering fails). | |
| 79 | + | ||
| 80 | + | ## HTMX Dual-Response Pattern | |
| 81 | + | ||
| 82 | + | Routes detect HTMX requests and return different formats. Page routes return HTML fragments; API clients get JSON. | |
| 83 | + | ||
| 84 | + | ```rust | |
| 85 | + | if is_htmx_request(&headers) { | |
| 86 | + | // Return HX-Redirect header | |
| 87 | + | let mut response = Response::new(Body::empty()); | |
| 88 | + | response.headers_mut().insert( | |
| 89 | + | "HX-Redirect", | |
| 90 | + | format!("/dashboard/item/{}", item.id).parse().expect("valid path"), | |
| 91 | + | ); | |
| 92 | + | return Ok(response); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | Ok(Json(ItemResponse { /* ... */ }).into_response()) | |
| 96 | + | ``` | |
| 97 | + | ||
| 98 | + | ### Response patterns by action | |
| 99 | + | ||
| 100 | + | | Action | HTMX response | Non-HTMX response | | |
| 101 | + | |--------|---------------|-------------------| | |
| 102 | + | | Create + redirect | `HX-Redirect` header | `Json(ItemResponse)` | | |
| 103 | + | | Delete with feedback | `htmx_toast_response("Deleted", "success")` | `StatusCode::NO_CONTENT` | | |
| 104 | + | | Save with status | `Html(SaveStatusTemplate { success, message })` | `Json(UpdateTextResponse)` | | |
| 105 | + | | Inline edit | HTML fragment (re-rendered partial) | `Json(...)` | | |
| 106 | + | ||
| 107 | + | ### Helper functions | |
| 108 | + | ||
| 109 | + | ```rust | |
| 110 | + | // src/helpers.rs | |
| 111 | + | pub fn is_htmx_request(headers: &HeaderMap) -> bool { | |
| 112 | + | headers.get("HX-Request").is_some() | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | pub fn htmx_toast_response(message: &str, toast_type: &str) -> impl IntoResponse { | |
| 116 | + | // Returns HX-Trigger header with showToast JSON event | |
| 117 | + | // Frontend JS listens for this and renders a toast notification | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue { | |
| 121 | + | // Serializes: { "showToast": { "message": "...", "type": "..." } } | |
| 122 | + | } | |
| 123 | + | ``` | |
| 124 | + | ||
| 125 | + | ## `ListResponse<T>` Envelope | |
| 126 | + | ||
| 127 | + | **Location:** `src/types/mod.rs` | |
| 128 | + | ||
| 129 | + | All JSON list endpoints wrap results in a `{ "data": [...] }` envelope, making the response forward-compatible for pagination metadata. | |
| 130 | + | ||
| 131 | + | ```rust | |
| 132 | + | #[derive(Serialize)] | |
| 133 | + | pub struct ListResponse<T: Serialize> { | |
| 134 | + | pub data: Vec<T>, | |
| 135 | + | } | |
| 136 | + | ``` | |
| 137 | + | ||
| 138 | + | Usage: | |
| 139 | + | ```rust | |
| 140 | + | let chapters = db::chapters::get_chapters_by_item(&state.db, item_id).await?; | |
| 141 | + | Ok(Json(ListResponse { data: chapters.into_iter().map(ChapterResponse::from).collect() })) | |
| 142 | + | ``` | |
| 143 | + | ||
| 144 | + | ## Error Handling | |
| 145 | + | ||
| 146 | + | **Location:** `src/error.rs` | |
| 147 | + | ||
| 148 | + | ### AppError enum | |
| 149 | + | ||
| 150 | + | ```rust | |
| 151 | + | pub enum AppError { | |
| 152 | + | NotFound, | |
| 153 | + | Unauthorized, | |
| 154 | + | Forbidden, | |
| 155 | + | BadRequest(String), | |
| 156 | + | Validation(String), | |
| 157 | + | Database(sqlx::Error), | |
| 158 | + | Internal(anyhow::Error), | |
| 159 | + | Storage(String), | |
| 160 | + | InvalidFileType(String), | |
| 161 | + | FileTooLarge(String), | |
| 162 | + | MalwareDetected(String), | |
| 163 | + | ServiceUnavailable(String), | |
| 164 | + | } | |
| 165 | + | ``` | |
| 166 | + | ||
| 167 | + | ### HTTP status mapping | |
| 168 | + | ||
| 169 | + | | Variant | Status code | | |
| 170 | + | |---------|------------| | |
| 171 | + | | `NotFound` | 404 | | |
| 172 | + | | `Unauthorized` | 401 | | |
| 173 | + | | `Forbidden` | 403 | | |
| 174 | + | | `BadRequest` | 400 | | |
| 175 | + | | `Validation` | 422 | | |
| 176 | + | | `Database`, `Internal`, `Storage` | 500 | | |
| 177 | + | | `InvalidFileType` | 400 | | |
| 178 | + | | `FileTooLarge` | 413 | | |
| 179 | + | | `MalwareDetected` | 422 | | |
| 180 | + | | `ServiceUnavailable` | 503 | | |
| 181 | + | ||
| 182 | + | ### User-safe messages | |
| 183 | + | ||
| 184 | + | Internal errors (`Database`, `Internal`, `Storage`) return generic "Something went wrong" to the client. `Validation` and `BadRequest` messages pass through since they describe user input problems. | |
| 185 | + | ||
| 186 | + | ### JSON error middleware | |
| 187 | + | ||
| 188 | + | API routes use a middleware layer (`json_error_layer` in `routes/api/mod.rs`) that converts `AppError` HTML responses to JSON `{"error": "..."}` responses. Page routes render an error template. | |
| 189 | + | ||
| 190 | + | ### Result alias | |
| 191 | + | ||
| 192 | + | ```rust | |
| 193 | + | pub type Result<T> = std::result::Result<T, AppError>; | |
| 194 | + | ``` | |
| 195 | + | ||
| 196 | + | ## Rate Limiting | |
| 197 | + | ||
| 198 | + | **Location:** `src/helpers.rs` (config builders), `src/routes/api/mod.rs` (applied to routes) | |
| 199 | + | ||
| 200 | + | Uses `tower-governor` with `SmartIpKeyExtractor` (reads X-Forwarded-For or direct IP). | |
| 201 | + | ||
| 202 | + | ```rust | |
| 203 | + | let write_rate_limit = rate_limiter_ms( | |
| 204 | + | constants::API_WRITE_RATE_LIMIT_MS, | |
| 205 | + | constants::API_WRITE_RATE_LIMIT_BURST, | |
| 206 | + | ); | |
| 207 | + | ||
| 208 | + | let write_routes = Router::new() | |
| 209 | + | .route("/api/projects", post(projects::create_project)) | |
| 210 | + | // ... | |
| 211 | + | .route_layer(GovernorLayer { config: write_rate_limit }); | |
| 212 | + | ``` | |
| 213 | + | ||
| 214 | + | Rate limits are grouped by tier: | |
| 215 | + | - **Write routes** -- mutations (create, update, delete) | |
| 216 | + | - **Export routes** -- data export endpoints (lower burst) | |
| 217 | + | - **License key routes** -- activation/validation (tight limits) | |
| 218 | + | - **Dashboard GET routes** -- read-heavy dashboard endpoints | |
| 219 | + | ||
| 220 | + | ## Template View Models | |
| 221 | + | ||
| 222 | + | **Location:** `src/templates/` (public.rs, dashboard.rs, partials.rs) | |
| 223 | + | ||
| 224 | + | Each template is an Askama struct with all the data it needs pre-loaded. No database calls happen in templates. | |
| 225 | + | ||
| 226 | + | ```rust | |
| 227 | + | #[derive(Template)] | |
| 228 | + | #[template(path = "pages/project.html")] | |
| 229 | + | pub struct ProjectTemplate { | |
| 230 | + | pub csrf_token: CsrfTokenOption, | |
| 231 | + | pub session_user: Option<SessionUser>, | |
| 232 | + | pub project: Project, | |
| 233 | + | pub creator_username: String, | |
| 234 | + | pub items: Vec<Item>, | |
| 235 | + | pub subscription_tiers: Vec<SubscriptionTier>, | |
| 236 | + | pub is_following: bool, | |
| 237 | + | pub follower_count: i64, | |
| 238 | + | // ... | |
| 239 | + | } | |
| 240 | + | ``` | |
| 241 | + | ||
| 242 | + | ### Organization | |
| 243 | + | ||
| 244 | + | | Module | Contents | | |
| 245 | + | |--------|----------| | |
| 246 | + | | `templates/public.rs` | Public pages (landing, auth, profiles, content) | | |
| 247 | + | | `templates/dashboard.rs` | Creator dashboards, admin views | | |
| 248 | + | | `templates/partials.rs` | HTMX fragments, tab content, alerts, form status | | |
| 249 | + | ||
| 250 | + | ### Physical template files | |
| 251 | + | ||
| 252 | + | ``` | |
| 253 | + | templates/ | |
| 254 | + | base.html Base layout with head, nav, footer | |
| 255 | + | pages/ Full public pages (30+) | |
| 256 | + | dashboards/ Creator + admin dashboards | |
| 257 | + | partials/ HTMX fragments | |
| 258 | + | tabs/ Tab content for dashboards | |
| 259 | + | wizards/ Multi-step flows (join wizard) | |
| 260 | + | steps/ | |
| 261 | + | ``` | |
| 262 | + | ||
| 263 | + | ### Common partial types | |
| 264 | + | ||
| 265 | + | - `AlertTemplate` -- feedback messages with optional link (builder pattern) | |
| 266 | + | - `FormStatusTemplate` -- inline success/error for HTMX form submissions | |
| 267 | + | - Tab templates -- one struct per dashboard tab, loaded via HTMX on tab click | |
| 268 | + | ||
| 269 | + | ## Route Organization | |
| 270 | + | ||
| 271 | + | Routes are split by audience: | |
| 272 | + | ||
| 273 | + | ``` | |
| 274 | + | routes/ | |
| 275 | + | api/ JSON endpoints (grouped by domain: items, projects, subscriptions, etc.) | |
| 276 | + | pages/ | |
| 277 | + | public/ Public-facing HTML pages | |
| 278 | + | dashboard/ Authenticated creator dashboard pages | |
| 279 | + | feeds.rs RSS/Atom feeds | |
| 280 | + | blog.rs Blog pages | |
| 281 | + | auth.rs Login, signup, logout, password reset | |
| 282 | + | git.rs Source browser | |
| 283 | + | git_issues.rs Issue tracker | |
| 284 | + | oauth.rs OAuth provider (for Multithreaded) | |
| 285 | + | postmark.rs Inbound email webhook | |
| 286 | + | storage.rs File upload/download (presigned URLs) | |
| 287 | + | stripe/ Stripe webhooks and Connect callbacks | |
| 288 | + | synckit.rs SyncKit cloud sync + OTA API | |
| 289 | + | ``` | |
| 290 | + | ||
| 291 | + | The `api/mod.rs` file composes all API sub-routers, applies rate limiting layers, and adds the JSON error middleware. | |
| 292 | + | ||
| 293 | + | ## Database Conventions | |
| 294 | + | ||
| 295 | + | - All tables use UUID primary keys (except `sync_log` which uses BIGSERIAL) | |
| 296 | + | - Text enums stored as VARCHAR/TEXT, mapped via `impl_str_enum!` | |
| 297 | + | - Timestamps are `TIMESTAMPTZ NOT NULL DEFAULT NOW()` unless nullable | |
| 298 | + | - Foreign keys use `ON DELETE CASCADE` for owned relationships | |
| 299 | + | - Compile-time checked queries via sqlx -- migrations auto-run on boot | |
| 300 | + | - Each integration test creates and drops its own PostgreSQL database | |
| 301 | + | ||
| 302 | + | ## Key Paths | |
| 303 | + | ||
| 304 | + | | Pattern | Location | | |
| 305 | + | |---------|----------| | |
| 306 | + | | Enum macro | `src/db/enums.rs` | | |
| 307 | + | | ID macro | `src/db/id_types.rs` | | |
| 308 | + | | Template macro | `src/templates/mod.rs` | | |
| 309 | + | | Error type | `src/error.rs` | | |
| 310 | + | | HTMX helpers | `src/helpers.rs` | | |
| 311 | + | | Rate limiters | `src/helpers.rs`, `src/routes/api/mod.rs` | | |
| 312 | + | | ListResponse | `src/types/mod.rs` | | |
| 313 | + | | Constants | `src/constants.rs` | |
| @@ -0,0 +1,89 @@ | |||
| 1 | + | # Payload Audit | |
| 2 | + | ||
| 3 | + | **Date:** 2026-04-02 | |
| 4 | + | **Scope:** Browser-facing assets served by MNW | |
| 5 | + | **Grade:** A — Lean payload, no bloat | |
| 6 | + | ||
| 7 | + | ## Summary | |
| 8 | + | ||
| 9 | + | - ~170 KB gzipped first load, ~55 KB subsequent pages | |
| 10 | + | - Hand-written CSS + vanilla JS + HTMX only — no framework bloat | |
| 11 | + | - woff2 fonts with preload and `font-display: swap` | |
| 12 | + | - Conditional JS loading per page type | |
| 13 | + | - No large dead asset sections found | |
| 14 | + | ||
| 15 | + | ## Asset Inventory | |
| 16 | + | ||
| 17 | + | ### Production Page Load (gzipped) | |
| 18 | + | ||
| 19 | + | | Category | Uncompressed | Gzipped | Notes | | |
| 20 | + | |----------|-------------|---------|-------| | |
| 21 | + | | CSS (style.css + wizard.css) | 90 KB | ~16 KB | Hand-written, no framework | | |
| 22 | + | | JS custom (mnw, passkey, upload, insertions, wizard) | 27 KB | ~8 KB | Vanilla JS, unminified | | |
| 23 | + | | JS HTMX 2.0.4 | 51 KB | ~16 KB | Local copy, minified | | |
| 24 | + | | Fonts (woff2, 5 faces) | 122 KB | ~115 KB | Already compressed | | |
| 25 | + | | Images (logo + favicon) | 18 KB | ~15 KB | Optimized | | |
| 26 | + | | **Typical first load** | **~308 KB** | **~170 KB** | Fonts cached after first visit | | |
| 27 | + | | **Subsequent pages** | **~186 KB** | **~55 KB** | Fonts + HTMX + CSS cached | | |
| 28 | + | ||
| 29 | + | ### CSS Files | |
| 30 | + | ||
| 31 | + | - `style.css` — main stylesheet, 431 classes | |
| 32 | + | - `wizard.css` — wizard-specific styles, loaded only on wizard pages | |
| 33 | + | ||
| 34 | + | ### JS Files | |
| 35 | + | ||
| 36 | + | | File | Size | Loaded on | | |
| 37 | + | |------|------|-----------| | |
| 38 | + | | `mnw.js` | Main script | All pages | | |
| 39 | + | | `passkey.js` | WebAuthn | `/login` only | | |
| 40 | + | | `upload.js` | File upload | Dashboard pages | | |
| 41 | + | | `insertions.js` | Content editor | Dashboard pages | | |
| 42 | + | | `wizard.js` | Step wizard | Wizard pages only | | |
| 43 | + | ||
| 44 | + | ### Fonts (woff2) | |
| 45 | + | ||
| 46 | + | 5 font faces served via `@font-face` with `font-display: swap`. Critical faces use `<link rel="preload">`. | |
| 47 | + | ||
| 48 | + | TTF fallbacks exist on disk (1.5 MB) but are never served — woff2 takes priority in all modern browsers. | |
| 49 | + | ||
| 50 | + | ## Compression | |
| 51 | + | ||
| 52 | + | Caddy serves all responses with gzip + zstd compression enabled. No additional build-time minification pipeline. | |
| 53 | + | ||
| 54 | + | ## CSS Unused Classes Analysis | |
| 55 | + | ||
| 56 | + | ~362 of 431 classes in style.css appear unused by simple `grep` against templates, but most are accounted for: | |
| 57 | + | ||
| 58 | + | - **JS-created classes** — toast variants, dragover, fade-out, active states (created dynamically) | |
| 59 | + | - **Askama template conditionals** — `class="toast-{{ type }}"` and similar dynamic class construction | |
| 60 | + | - **Git browser classes** — 30+ classes for the repository viewer feature (confirmed active) | |
| 61 | + | - **Use-case + analytics pages** — confirmed used in their respective templates | |
| 62 | + | ||
| 63 | + | True dead CSS is minimal. A browser-based Coverage tool would give exact numbers, but static analysis shows no large dead sections. | |
| 64 | + | ||
| 65 | + | ## Assessed Non-Issues | |
| 66 | + | ||
| 67 | + | | Item | Assessment | | |
| 68 | + | |------|-----------| | |
| 69 | + | | Custom JS unminified | Saves 1-2 KB gzipped if minified. Not worth a build step at this scale. | | |
| 70 | + | | TTF font files on disk | Not served to browsers. Only occupies disk space, not bandwidth. | | |
| 71 | + | | upload.js on non-upload pages | 1.1 KB gzipped. Marginal overhead, not worth conditional loading complexity. | | |
| 72 | + | | HTMX served locally | Correct — avoids CDN dependency, enables offline-first development. | | |
| 73 | + | ||
| 74 | + | ## What's Good | |
| 75 | + | ||
| 76 | + | - **No framework bloat** — no Tailwind, Bootstrap, React, or build toolchain | |
| 77 | + | - **woff2 fonts** with preload + swap — fast text rendering | |
| 78 | + | - **Conditional JS loading** — scripts loaded only where needed | |
| 79 | + | - **HTMX 2.0.4** — current version, minimal footprint | |
| 80 | + | - **Zero unused JS functions** — all exports have documented call sites | |
| 81 | + | - **Caddy compression** — gzip + zstd on all routes, no configuration gaps | |
| 82 | + | ||
| 83 | + | ## Key Paths | |
| 84 | + | ||
| 85 | + | - `static/css/` — stylesheets | |
| 86 | + | - `static/js/` — JavaScript files | |
| 87 | + | - `static/fonts/` — font files (woff2 + ttf) | |
| 88 | + | - `static/images/` — logo + favicon | |
| 89 | + | - `src/templates/` — Askama templates (CSS class consumers) |
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: All pre-beta phases + frontend audit. Active: None. Next: Post-beta features below. | |
| 4 | + | Done: All pre-beta phases + frontend audit + content fingerprinting. Active: None. Next: Post-beta features below. | |
| 5 | 5 | ||
| 6 | - | Live at makenot.work. v0.3.17. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed. | |
| 6 | + | Live at makenot.work. v0.3.18. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed. | |
| 7 | 7 | ||
| 8 | 8 | **Scope:** Sections tagged `(pre-beta)` ship before initial beta. Untagged sections are post-beta. | |
| 9 | 9 | ||
| @@ -186,6 +186,54 @@ Findings from investor/business and marketing review of all customer-facing temp | |||
| 186 | 186 | ||
| 187 | 187 | --- | |
| 188 | 188 | ||
| 189 | + | ## Content Fingerprinting (Anti-Piracy) | |
| 190 | + | ||
| 191 | + | Migration 051. 27 unit tests + 10 integration tests. | |
| 192 | + | ||
| 193 | + | ### Done | |
| 194 | + | - [x] Transactional fingerprint registry (`db/fingerprints.rs`, `download_fingerprints` table) | |
| 195 | + | - [x] Visible "Licensed to" stamps — language-aware comment headers for 30+ extensions (`fingerprint/visible.rs`) | |
| 196 | + | - [x] Invisible text watermarks — zero-width character encoding with round-trip extract (`fingerprint/watermark_text.rs`) | |
| 197 | + | - [x] Token-gated streaming — IP-bound sessions, concurrency cap (2), stale expiry (`fingerprint/streaming.rs`) | |
| 198 | + | - [x] License key binding — phone-home verify + deactivate endpoints, JWT offline grace (`routes/api/license_keys.rs`) | |
| 199 | + | - [x] `streaming_sessions` table with IP binding and expiry | |
| 200 | + | - [x] `license_activations` table with machine fingerprint + activation cap | |
| 201 | + | - [x] `license_verification_enabled` flag on projects | |
| 202 | + | - [x] Scheduler cleanup of stale streaming sessions | |
| 203 | + | - [x] Download routes record fingerprints for paid content | |
| 204 | + | ||
| 205 | + | ### Remaining | |
| 206 | + | - [ ] Invisible image watermarks — LSB encoding (stub exists at `fingerprint/watermark_image.rs`) | |
| 207 | + | - [ ] Invisible audio watermarks — spread-spectrum (stub exists at `fingerprint/watermark_audio.rs`) | |
| 208 | + | - [ ] Wire visible stamps into download routes (stamp text files before serving) | |
| 209 | + | - [ ] Wire ZWC watermarks into download routes (watermark text files before serving) | |
| 210 | + | - [ ] ZIP/tar archive stamping (inject LICENSE.txt at archive root) | |
| 211 | + | - [ ] Dashboard UI for fingerprint tracing (lookup by fingerprint_id, view download history) | |
| 212 | + | - [ ] Anti-hotlink header checks in streaming routes (logic exists in `fingerprint/streaming.rs`) | |
| 213 | + | ||
| 214 | + | --- | |
| 215 | + | ||
| 216 | + | ## Bundled License Text | |
| 217 | + | ||
| 218 | + | Migration 052. 8 unit tests + 5 integration tests. | |
| 219 | + | ||
| 220 | + | Per-item license configuration: creators pick from 7 presets or write custom terms. License displayed on item page, downloadable as LICENSE.txt, URL included in download API responses. | |
| 221 | + | ||
| 222 | + | ### Done | |
| 223 | + | - [x] `license_preset` and `custom_license_text` columns on items (migration 052) | |
| 224 | + | - [x] License templates module (`fingerprint/license_templates.rs`) — 7 presets + custom, `{year}`/`{owner}` placeholder substitution | |
| 225 | + | - [x] `update_item_license_text` DB function (`db/items.rs`) | |
| 226 | + | - [x] License preset in `PUT /api/items/{id}/license-settings` (validation: custom requires text, preset key validated) | |
| 227 | + | - [x] `GET /api/items/{id}/license.txt` — public endpoint, renders license as text/plain | |
| 228 | + | - [x] `license_url` field in `VersionDownloadResponse` (set when item has a license) | |
| 229 | + | - [x] Wizard distribution step: license preset dropdown + custom textarea | |
| 230 | + | - [x] Dashboard pricing tab: license preset dropdown + custom textarea in license settings form | |
| 231 | + | - [x] Item public page: license section with name, collapsible full text (lazy-loaded), download link | |
| 232 | + | - [x] 8 unit tests (preset round-trip, render substitution, custom text, options count) | |
| 233 | + | - [x] 5 integration tests (preset set+serve, custom license, 404 when none, clear license, validation) | |
| 234 | + | ||
| 235 | + | --- | |
| 236 | + | ||
| 189 | 237 | ## Post-Beta | |
| 190 | 238 | ||
| 191 | 239 | ### Phase 11B: Promotions | |
| @@ -366,7 +414,7 @@ Archive policy: items on platform 12+ months stay hosted if creator cancels. | |||
| 366 | 414 | - [ ] Revisit admin system (currently config-based ADMIN_USER_ID) | |
| 367 | 415 | - [ ] Test restore from backup | |
| 368 | 416 | - [ ] S3 bucket versioning | |
| 369 | - | - [ ] PDF stamping (watermark with buyer email/name — Gumroad has this as piracy disincentive) | |
| 417 | + | - [ ] PDF stamping (watermark with buyer email/name — superseded by fingerprinting system, needs PDF library integration) | |
| 370 | 418 | ||
| 371 | 419 | ## Key Paths | |
| 372 | 420 | ``` | |
| @@ -374,9 +422,10 @@ MNW/src/ | |||
| 374 | 422 | lib.rs, main.rs, config.rs, error.rs, auth.rs, db/ | |
| 375 | 423 | storage.rs, payments.rs, templates/, routes/ | |
| 376 | 424 | git.rs, git_issues.rs, synckit_auth.rs, build_runner.rs, validation.rs | |
| 425 | + | fingerprint/ (registry, visible stamps, watermarks, streaming) | |
| 377 | 426 | MNW/tests/ | |
| 378 | 427 | integration.rs, harness/, workflows/*.rs | |
| 379 | - | MNW/migrations/ (001-045) | |
| 428 | + | MNW/migrations/ (001-051) | |
| 380 | 429 | MNW/templates/ | |
| 381 | 430 | MNW/deploy/ | |
| 382 | 431 | MNW/site-docs/public/, MNW/site-docs/unpublished/ |
| @@ -0,0 +1,34 @@ | |||
| 1 | + | -- Content fingerprinting for anti-piracy: download tracking, streaming sessions, license activation binding. | |
| 2 | + | ||
| 3 | + | -- Track every download/stream with a unique fingerprint for tracing leaked content. | |
| 4 | + | CREATE TABLE download_fingerprints ( | |
| 5 | + | id BIGSERIAL PRIMARY KEY, | |
| 6 | + | user_id UUID NOT NULL REFERENCES users(id), | |
| 7 | + | content_type TEXT NOT NULL, | |
| 8 | + | content_id TEXT NOT NULL, | |
| 9 | + | fingerprint_id UUID NOT NULL DEFAULT gen_random_uuid(), | |
| 10 | + | watermark_method TEXT, | |
| 11 | + | ip_address INET, | |
| 12 | + | user_agent TEXT, | |
| 13 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 14 | + | ); | |
| 15 | + | CREATE INDEX idx_download_fp_user ON download_fingerprints(user_id); | |
| 16 | + | CREATE INDEX idx_download_fp_fingerprint ON download_fingerprints(fingerprint_id); | |
| 17 | + | CREATE INDEX idx_download_fp_content ON download_fingerprints(content_type, content_id); | |
| 18 | + | ||
| 19 | + | -- Server-side streaming session tracking for IP binding and concurrency enforcement. | |
| 20 | + | CREATE TABLE streaming_sessions ( | |
| 21 | + | id BIGSERIAL PRIMARY KEY, | |
| 22 | + | user_id UUID NOT NULL REFERENCES users(id), | |
| 23 | + | content_id TEXT NOT NULL, | |
| 24 | + | session_token UUID NOT NULL DEFAULT gen_random_uuid(), | |
| 25 | + | ip_address INET NOT NULL, | |
| 26 | + | started_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 27 | + | last_active_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 28 | + | expired BOOLEAN NOT NULL DEFAULT false | |
| 29 | + | ); | |
| 30 | + | CREATE INDEX idx_stream_session_user ON streaming_sessions(user_id, expired); | |
| 31 | + | CREATE INDEX idx_stream_session_token ON streaming_sessions(session_token); | |
| 32 | + | ||
| 33 | + | -- Per-project opt-in for license verification (phone-home on first use). | |
| 34 | + | ALTER TABLE projects ADD COLUMN license_verification_enabled BOOLEAN NOT NULL DEFAULT false; |
| @@ -0,0 +1,3 @@ | |||
| 1 | + | -- Per-item license text: preset selection and optional custom text. | |
| 2 | + | ALTER TABLE items ADD COLUMN license_preset TEXT; | |
| 3 | + | ALTER TABLE items ADD COLUMN custom_license_text TEXT; |
| @@ -138,7 +138,9 @@ impl FromRequestParts<crate::AppState> for AuthUser { | |||
| 138 | 138 | user.is_fan_plus = is_fan_plus; | |
| 139 | 139 | user.can_create_projects = result.can_create_projects; | |
| 140 | 140 | user.creator_tier = creator_tier; | |
| 141 | - | let _ = session.insert(USER_SESSION_KEY, user.clone()).await; | |
| 141 | + | if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await { | |
| 142 | + | tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state"); | |
| 143 | + | } | |
| 142 | 144 | } | |
| 143 | 145 | state.session_cache.insert(tracking_id, Instant::now()); | |
| 144 | 146 | } |
| @@ -31,7 +31,7 @@ | |||
| 31 | 31 | use clap::{Parser, Subcommand}; | |
| 32 | 32 | use sqlx::PgPool; | |
| 33 | 33 | ||
| 34 | - | use makenotwork::db::{self, AppealDecision, SelectionMethod, Username, WaitlistStatus}; | |
| 34 | + | use makenotwork::db::{self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus}; | |
| 35 | 35 | ||
| 36 | 36 | #[derive(Parser)] | |
| 37 | 37 | #[command(name = "mnw-admin", about = "MNW admin CLI")] | |
| @@ -489,7 +489,7 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<( | |||
| 489 | 489 | "{:<12} {:<30} {:>10} {:<10}", | |
| 490 | 490 | date, title_short, amount, tx.status | |
| 491 | 491 | ); | |
| 492 | - | if tx.status.to_string() == "completed" { | |
| 492 | + | if tx.status == TransactionStatus::Completed { | |
| 493 | 493 | total_cents += tx.amount_cents as i64; | |
| 494 | 494 | } | |
| 495 | 495 | } |
| @@ -97,13 +97,15 @@ pub async fn dispatch_pending_build(state: &AppState) { | |||
| 97 | 97 | Ok(Some(c)) => c, | |
| 98 | 98 | Ok(None) => { | |
| 99 | 99 | tracing::error!(build_id = %build.id, "build config not found for pending build"); | |
| 100 | - | let _ = db::builds::update_build_status( | |
| 100 | + | if let Err(e) = db::builds::update_build_status( | |
| 101 | 101 | &state.db, | |
| 102 | 102 | build.id, | |
| 103 | 103 | BuildStatus::Failed, | |
| 104 | 104 | Some("Build config not found"), | |
| 105 | 105 | ) | |
| 106 | - | .await; | |
| 106 | + | .await { | |
| 107 | + | tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (config not found)"); | |
| 108 | + | } | |
| 107 | 109 | return; | |
| 108 | 110 | } | |
| 109 | 111 | Err(e) => { | |
| @@ -168,13 +170,15 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 168 | 170 | ||
| 169 | 171 | if artifact_keys.is_empty() { | |
| 170 | 172 | let err_msg = first_error.as_deref().unwrap_or("no targets produced artifacts"); | |
| 171 | - | let _ = db::builds::update_build_status( | |
| 173 | + | if let Err(e) = db::builds::update_build_status( | |
| 172 | 174 | &state.db, | |
| 173 | 175 | build.id, | |
| 174 | 176 | BuildStatus::Failed, | |
| 175 | 177 | Some(err_msg), | |
| 176 | 178 | ) | |
| 177 | - | .await; | |
| 179 | + | .await { | |
| 180 | + | tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (no artifacts)"); | |
| 181 | + | } | |
| 178 | 182 | return; | |
| 179 | 183 | } | |
| 180 | 184 | ||
| @@ -191,13 +195,15 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 191 | 195 | Ok(r) => r, | |
| 192 | 196 | Err(e) => { | |
| 193 | 197 | let msg = format!("failed to create OTA release: {e}"); | |
| 194 | - | let _ = db::builds::update_build_status( | |
| 198 | + | if let Err(e) = db::builds::update_build_status( | |
| 195 | 199 | &state.db, | |
| 196 | 200 | build.id, | |
| 197 | 201 | BuildStatus::Failed, | |
| 198 | 202 | Some(&msg), | |
| 199 | 203 | ) | |
| 200 | - | .await; | |
| 204 | + | .await { | |
| 205 | + | tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (release creation)"); | |
| 206 | + | } | |
| 201 | 207 | return; | |
| 202 | 208 | } | |
| 203 | 209 | }; | |
| @@ -220,7 +226,9 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 220 | 226 | } | |
| 221 | 227 | ||
| 222 | 228 | // Link build to release | |
| 223 | - | let _ = db::builds::set_build_release(&state.db, build.id, release.id).await; | |
| 229 | + | if let Err(e) = db::builds::set_build_release(&state.db, build.id, release.id).await { | |
| 230 | + | tracing::error!(build_id = %build.id, release_id = %release.id, error = ?e, "failed to link build to release"); | |
| 231 | + | } | |
| 224 | 232 | ||
| 225 | 233 | let status = if all_succeeded { | |
| 226 | 234 | BuildStatus::Succeeded | |
| @@ -228,13 +236,15 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { | |||
| 228 | 236 | BuildStatus::Failed | |
| 229 | 237 | }; | |
| 230 | 238 | ||
| 231 | - | let _ = db::builds::update_build_status( | |
| 239 | + | if let Err(e) = db::builds::update_build_status( | |
| 232 | 240 | &state.db, | |
| 233 | 241 | build.id, | |
| 234 | 242 | status, | |
| 235 | 243 | first_error.as_deref(), | |
| 236 | 244 | ) | |
| 237 | - | .await; | |
| 245 | + | .await { | |
| 246 | + | tracing::error!(build_id = %build.id, status = %status, error = ?e, "failed to update final build status"); | |
| 247 | + | } | |
| 238 | 248 | ||
| 239 | 249 | tracing::info!( | |
| 240 | 250 | build_id = %build.id, |
| @@ -105,7 +105,7 @@ impl Config { | |||
| 105 | 105 | Ok(secret) => secret, | |
| 106 | 106 | Err(_) => { | |
| 107 | 107 | // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start | |
| 108 | - | let is_production = host.to_string() == "0.0.0.0" | |
| 108 | + | let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED) | |
| 109 | 109 | || std::env::var("HOST_URL") | |
| 110 | 110 | .map(|u| u.starts_with("https://")) | |
| 111 | 111 | .unwrap_or(false); |