max / makenotwork
41 files changed,
+906 insertions,
-360 deletions
| @@ -3351,15 +3351,13 @@ dependencies = [ | |||
| 3351 | 3351 | ||
| 3352 | 3352 | [[package]] | |
| 3353 | 3353 | name = "makenotwork" | |
| 3354 | - | version = "0.3.18" | |
| 3354 | + | version = "0.3.19" | |
| 3355 | 3355 | dependencies = [ | |
| 3356 | 3356 | "anyhow", | |
| 3357 | 3357 | "argon2", | |
| 3358 | 3358 | "askama", | |
| 3359 | 3359 | "async-stripe", | |
| 3360 | 3360 | "async-trait", | |
| 3361 | - | "aws-config", | |
| 3362 | - | "aws-sdk-s3", | |
| 3363 | 3361 | "axum", | |
| 3364 | 3362 | "axum-extra", | |
| 3365 | 3363 | "base64 0.22.1", | |
| @@ -3380,6 +3378,7 @@ dependencies = [ | |||
| 3380 | 3378 | "rand 0.8.5", | |
| 3381 | 3379 | "regex", | |
| 3382 | 3380 | "reqwest", | |
| 3381 | + | "s3-storage", | |
| 3383 | 3382 | "semver", | |
| 3384 | 3383 | "serde", | |
| 3385 | 3384 | "serde_json", | |
| @@ -4770,6 +4769,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4770 | 4769 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 4771 | 4770 | ||
| 4772 | 4771 | [[package]] | |
| 4772 | + | name = "s3-storage" | |
| 4773 | + | version = "0.1.0" | |
| 4774 | + | dependencies = [ | |
| 4775 | + | "aws-config", | |
| 4776 | + | "aws-sdk-s3", | |
| 4777 | + | "tracing", | |
| 4778 | + | ] | |
| 4779 | + | ||
| 4780 | + | [[package]] | |
| 4773 | 4781 | name = "same-file" | |
| 4774 | 4782 | version = "1.0.6" | |
| 4775 | 4783 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.3.19" | |
| 3 | + | version = "0.3.20" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 | ||
| @@ -89,8 +89,7 @@ regex = "1" | |||
| 89 | 89 | semver = "1" | |
| 90 | 90 | ||
| 91 | 91 | # S3 Storage | |
| 92 | - | aws-sdk-s3 = "1.119.0" | |
| 93 | - | aws-config = { version = "1.8.14", features = ["behavior-version-latest"] } | |
| 92 | + | s3-storage = { path = "../Shared/s3-storage" } | |
| 94 | 93 | ||
| 95 | 94 | # Stripe Payments | |
| 96 | 95 | async-stripe = { version = "0.37.3", features = ["runtime-tokio-hyper", "checkout", "connect", "billing"] } |
| @@ -32,13 +32,13 @@ MNW/src/ | |||
| 32 | 32 | error.rs AppError enum → HTTP status + HTML/JSON response | |
| 33 | 33 | auth.rs Argon2 hashing, session management, extractors (AuthUser, MaybeUser, AdminUser) | |
| 34 | 34 | csrf.rs CSRF middleware (token generation + validation) | |
| 35 | - | validation.rs Input validation helpers | |
| 35 | + | validation/ Input validation helpers (directory module) | |
| 36 | 36 | helpers.rs Shared utility functions, rate limiter constructors | |
| 37 | - | payments.rs Stripe Connect client wrapper | |
| 37 | + | payments/ Stripe Connect client wrapper (directory module) | |
| 38 | 38 | storage.rs S3 storage backend (trait + implementation, presigned URLs) | |
| 39 | 39 | synckit_auth.rs SyncKit JWT token creation + extraction | |
| 40 | 40 | build_runner.rs SSH-based remote build execution | |
| 41 | - | git.rs Git source browser logic (git2, syntax highlighting) | |
| 41 | + | git/ Git source browser logic (git2, syntax highlighting, directory module) | |
| 42 | 42 | monitor.rs Background health monitor (DB, S3, sessions) | |
| 43 | 43 | scheduler.rs Background scheduler (scheduled publish, mailing list delivery) | |
| 44 | 44 | mt_client.rs HTTP client for Multithreaded internal API | |
| @@ -346,11 +346,11 @@ Two spawned Tokio tasks, coordinated via `watch::channel` for graceful shutdown: | |||
| 346 | 346 | | Template structs | `src/templates/` | | |
| 347 | 347 | | Email service | `src/email/` | | |
| 348 | 348 | | File scanning | `src/scanning/` | | |
| 349 | - | | Stripe integration | `src/payments.rs` | | |
| 349 | + | | Stripe integration | `src/payments/` | | |
| 350 | 350 | | S3 storage | `src/storage.rs` | | |
| 351 | 351 | | SyncKit auth | `src/synckit_auth.rs` | | |
| 352 | 352 | | Build runner | `src/build_runner.rs` | | |
| 353 | - | | Git browser | `src/git.rs` | | |
| 353 | + | | Git browser | `src/git/` | | |
| 354 | 354 | | MT client | `src/mt_client.rs` | | |
| 355 | 355 | | Health monitor | `src/monitor.rs` | | |
| 356 | 356 | | Scheduler | `src/scheduler.rs` | |
| @@ -1,11 +1,11 @@ | |||
| 1 | 1 | # MakeNotWork -- Audit Review | |
| 2 | 2 | ||
| 3 | - | **Last audited:** 2026-03-28 (thirty-third audit, Run 12 cross-project) | |
| 4 | - | **Previous audit:** 2026-03-22 (thirty-second audit, Run 11 MNW-focused) | |
| 3 | + | **Last audited:** 2026-04-06 (thirty-fourth audit, Run 13 cross-project) | |
| 4 | + | **Previous audit:** 2026-03-28 (thirty-third audit, Run 12 cross-project) | |
| 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.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). | |
| 8 | + | Run 13 cross-project audit. ~1,186 tests (632 unit + ~551 integration + 17 admin + 28 health). 0 clippy warnings. v0.3.19. Grade stable at A. Major additions since Run 12: video upload/playback (migration 053), content fingerprinting (051), bundled license text (052), maintainability splits (validation/, git/, payments/ directory modules), S3 storage extraction to shared crate (`Shared/s3-storage/`). Dead code/duplication audit: ~40 lines removed. Run 12 test failures (delete_item_returns_toast, item_wizard_license_keys) resolved. | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 | ||
| @@ -13,7 +13,7 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + | |||
| 13 | 13 | |-----------|:-----:|-------| | |
| 14 | 14 | | Code Quality | A | thiserror errors; clean naming; `//!` on 153/153 files; proper `?` propagation; 0 clippy warnings | | |
| 15 | 15 | | Architecture | A | Single crate with clean internal modules; `lib.rs` exports `build_app` for test reuse; `pub(crate)` hides internal DB modules; view model layer separates DB from templates; mailing list delivery cleanly layered (db → scheduler → email) | | |
| 16 | - | | Testing | A | 1,174 tests (584 unit + 545 integration + 17 admin + 28 health), 2 failures (see action items); per-test DB isolation; load test scaffolding; 61 adversarial tests; 17 wizard tests; 10 mailing list tests | | |
| 16 | + | | Testing | A | ~1,186 tests (632 unit + ~551 integration + 17 admin + 28 health); per-test DB isolation; load test scaffolding; 61 adversarial tests; 17 wizard tests; 10 mailing list tests; 6 video workflow tests | | |
| 17 | 17 | | Security | A+ | Zero SQL injection (parameterized queries only); Argon2 hashing; CSRF synchronizer tokens with constant-time comparison; session fixation prevention; account lockout; HIBP breach check; multi-layer file scanning (ClamAV + YARA + hash + archive); ammonia HTML sanitization; HMAC-signed email links; rate limiting on all write/auth endpoints; SameSite=Strict cookies; body size limit; admin routes hidden; Debug impls redact secrets; passkeys/WebAuthn; TOTP 2FA on all auth paths; self-purchase prevention; trust tiers for uploads. Security deep dive (2026-03-13): session cache fail-closed, scanner errors fail-closed (HeldForReview), scan status allowlist, JWT issuer validation. | | |
| 18 | 18 | | Performance | A | Parameterized queries with safety LIMITs; presigned S3 URLs (no proxy); `FOR UPDATE` on reorder; admin user list paginated (LIMIT/OFFSET + count query + HTMX prev/next); no N+1 queries; max_connections=10 default | | |
| 19 | 19 | | Documentation | A | Module-level `//!` on 100% of source files; public functions have `///` doc comments; API response conventions documented in `routes/api/mod.rs`; edge cases and state invariants documented | | |
| @@ -24,8 +24,8 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + | |||
| 24 | 24 | | Concurrency | A+ | Atomic DB operations for race-prone paths (Stripe connect, broadcast rate limit, release announcements); transaction + `FOR UPDATE` for reorder; graceful shutdown via watch channel; all purchase flows transactional | | |
| 25 | 25 | | Resilience | A | Graceful shutdown (SIGINT/SIGTERM); optional services degrade gracefully (S3, Stripe, scanning, git browser); timeouts on external HTTP calls (email: 10s, HIBP: 3s, ClamAV: 30s, MalwareBazaar: 5s); 3s DB pool acquire timeout | | |
| 26 | 26 | | API Consistency | A | Documented response conventions; JSON error layer on API routes; `ListResponse<T>` envelope on all list endpoints; HTMX-aware dual responses documented as intentional; rate limit tiers clearly separated | | |
| 27 | - | | Migration Safety | A | 48 additive migrations (001-048), auto-applied on boot; all schema changes additive (new tables, columns with defaults, indexes); no destructive migrations | | |
| 28 | - | | Codebase Size | A | ~29K source LOC for a full creator platform (~30+ features) is efficient; no files exceed the 500-line branching guideline. Largest files: tokens.rs (500), notifications.rs (311). DocEngine extracted to standalone crate (reduced from ~50K). | | |
| 27 | + | | Migration Safety | A | 53 additive migrations (001-053), auto-applied on boot; all schema changes additive (new tables, columns with defaults, indexes); no destructive migrations | | |
| 28 | + | | Codebase Size | A | ~29K source LOC for a full creator platform (~30+ features) is efficient; no files exceed the 500-line branching guideline. Largest files: tokens.rs (500), notifications.rs (311). DocEngine extracted to standalone crate (reduced from ~50K). Maintainability splits: validation/, git/, payments/ converted to directory modules. | | |
| 29 | 29 | | Infrastructure | -- | Not yet audited. Checklist below. | | |
| 30 | 30 | ||
| 31 | 31 | ## Module Heatmap | |
| @@ -42,15 +42,15 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + | |||
| 42 | 42 | | monitor.rs | A | A | n/a | A | A | A | A | n/a | | |
| 43 | 43 | | scheduler.rs | A | A | n/a | A | A | A | A | n/a | | |
| 44 | 44 | | constants.rs | A+ | A | n/a | A | n/a | A | n/a | n/a | | |
| 45 | - | | validation.rs | A | A | A | A | n/a | A | n/a | n/a | | |
| 45 | + | | validation/ | A | A | A | A | n/a | A | n/a | n/a | | |
| 46 | 46 | | email/ | A | A | A | A | A | A | n/a | n/a | | |
| 47 | 47 | | storage.rs | A | A | A | A | A | A | n/a | n/a | | |
| 48 | - | | payments.rs | A | A | A | A | n/a | A | n/a | n/a | | |
| 48 | + | | payments/ | A | A | A | A | n/a | A | n/a | n/a | | |
| 49 | 49 | | helpers.rs | A | A | A | A | n/a | A | n/a | n/a | | |
| 50 | 50 | | rss.rs | A | A | A | A | n/a | A | n/a | n/a | | |
| 51 | 51 | | markdown.rs | A | A | A | A+ | n/a | A | n/a | n/a | | |
| 52 | 52 | | docs.rs | A | A | A | A | n/a | A | n/a | n/a | | |
| 53 | - | | git.rs | A- | A | A | A | A | A | A | n/a | | |
| 53 | + | | git/ | A- | A | A | A | A | A | A | n/a | | |
| 54 | 54 | | wordlist.rs | A | n/a | n/a | n/a | n/a | A | n/a | n/a | | |
| 55 | 55 | | types/ | A | A | A- | A | n/a | A | n/a | n/a | | |
| 56 | 56 | | templates/ | A | A | n/a | A | n/a | A | A | n/a | | |
| @@ -78,11 +78,11 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + | |||
| 78 | 78 | | db/other (9 files) | A | A | n/a | A | A | A | n/a | n/a | | |
| 79 | 79 | | routes/auth.rs | A | A | n/a | A+ | A | A | A | n/a | | |
| 80 | 80 | | routes/admin.rs | A | A | n/a | A | A | A | A | n/a | | |
| 81 | - | | routes/storage.rs | A | A | n/a | A | A | A | A | n/a | | |
| 81 | + | | routes/storage/ | A | A | n/a | A | A | A | A | n/a | | |
| 82 | 82 | | routes/oauth.rs | A | A | n/a | A | A | A | A | n/a | | |
| 83 | - | | routes/synckit.rs | A | A | n/a | A | A | A | A | n/a | | |
| 84 | - | | routes/git.rs | A | A | n/a | A | A | A | A | n/a | | |
| 85 | - | | routes/postmark.rs | A | A | n/a | A | A | A | A | n/a | | |
| 83 | + | | routes/synckit/ | A | A | n/a | A | A | A | A | n/a | | |
| 84 | + | | routes/git/ | A | A | n/a | A | A | A | A | n/a | | |
| 85 | + | | routes/postmark/ | A | A | n/a | A | A | A | A | n/a | | |
| 86 | 86 | | routes/stripe/checkout.rs | A | A | n/a | A | A | A | A | A | | |
| 87 | 87 | | routes/stripe/webhook.rs | A | A | n/a | A | A | A | A | A | | |
| 88 | 88 | | routes/stripe/connect.rs | A | A | n/a | A | A | A | A | A | | |
| @@ -106,7 +106,7 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + | |||
| 106 | 106 | ||
| 107 | 107 | ### Cold Spots | |
| 108 | 108 | ||
| 109 | - | None -- all module-level cold spots resolved. email/ split into 6 files (mod.rs 216, tokens.rs 500, templates/auth.rs 180, templates/monetization.rs 187, templates/notifications.rs 311, templates/onboarding.rs 94). No file exceeds 500 lines. | |
| 109 | + | None -- all module-level cold spots resolved. email/ split into 6 files. validation/, git/, payments/ split into directory modules (Run 13 maintainability splits). No file exceeds 500 lines. | |
| 110 | 110 | ||
| 111 | 111 | Dependencies A- (upstream-blocked transitive vulnerabilities) is the only project-level dimension below A. | |
| 112 | 112 | ||
| @@ -193,7 +193,7 @@ cargo --version | |||
| 193 | 193 | Every attack surface covered. Argon2 with 128-char max, CSRF synchronizer tokens with constant-time comparison, session fixation prevention, account lockout, rate limiting on all sensitive endpoints, HMAC-signed URLs, Stripe webhook verification, login tokens hashed with SHA-256, OAuth PKCE with S256, passkeys/WebAuthn, TOTP 2FA enforced on all auth paths (login link, OAuth), account deletion via POST with confirmation, self-purchase prevention, 6-layer malware scanning pipeline, trust tiers for new uploads. No SQL injection vectors -- zero `format!()` in any `sqlx::query` call confirmed by grep. | |
| 194 | 194 | ||
| 195 | 195 | ### 2. Comprehensive test suite | |
| 196 | - | 821 tests across ~60 test files. Per-test database isolation. In-process load test harness. 53 adversarial exploit-attempt tests. 14.4 tests/KLOC. Test:source ratio 0.40. | |
| 196 | + | ~1,186 tests across ~67 test files. Per-test database isolation. In-process load test harness. 61 adversarial exploit-attempt tests. ~40 tests/KLOC. Test:source ratio ~0.40. | |
| 197 | 197 | ||
| 198 | 198 | ### 3. Zero N+1 queries | |
| 199 | 199 | Systematic prevention: batch queries with ANY($1), LEFT JOINs with aggregation, pre-computed denormalized fields, single round-trip health checks. No N+1 patterns found. | |
| @@ -293,11 +293,14 @@ Filed in `docs/mnw/todo.md`. | |||
| 293 | 293 | 24. Monitor async-stripe for instant fix (RUSTSEC-2024-0384) | |
| 294 | 294 | 25. Monitor aws-sdk-s3 for rustls-webpki 0.101.7 fix (RUSTSEC-2026-0049) | |
| 295 | 295 | ||
| 296 | - | ### New (Run 12) | |
| 297 | - | 31. **[MEDIUM]** Fix `delete_item_returns_toast` test failure — HX-Trigger header missing on item delete response | |
| 298 | - | 32. **[MEDIUM]** Fix `item_wizard_license_keys` test failure — RowNotFound after license key wizard step | |
| 296 | + | ### New (Run 12) — All resolved | |
| 297 | + | 31. ~~**[MEDIUM]** Fix `delete_item_returns_toast` test failure~~ -- done | |
| 298 | + | 32. ~~**[MEDIUM]** Fix `item_wizard_license_keys` test failure~~ -- done | |
| 299 | 299 | 33. **[LOW]** bincode unmaintained (RUSTSEC-2025-0141) — upstream via syntect/yara-x, warning only | |
| 300 | 300 | ||
| 301 | + | ### Run 13 (2026-04-06) — Maintenance | |
| 302 | + | No new action items. Dead code/duplication audit: ~40 lines removed across MNW+GO. Maintainability splits completed (validation/, git/, payments/). S3 storage extraction to `Shared/s3-storage/` shared crate (MNW + MT). | |
| 303 | + | ||
| 301 | 304 | ### New (twenty-fifth audit) | |
| 302 | 305 | 25. ~~Split email.rs into submodules~~ -- Done (email/mod.rs + email/tokens.rs, ~800 + ~500 LOC) | |
| 303 | 306 | 26. ~~Upgrade DMARC policy from p=none to p=quarantine~~ -- Done | |
| @@ -335,10 +338,10 @@ Key changes: | |||
| 335 | 338 | - **HIGH fix:** Suspended users could manage promo codes and license keys — added `check_not_suspended()` to all handlers | |
| 336 | 339 | - **HIGH fix:** Session `suspended` flag stale after admin suspension — `touch_session` now returns `TouchResult` with live `suspended` value from DB, session updated on mismatch | |
| 337 | 340 | - **HIGH fix:** Webhook signature had no timestamp freshness check — added 300s tolerance, rejects stale and future timestamps | |
| 338 | - | - **6 new webhook timestamp unit tests** in payments.rs | |
| 341 | + | - **6 new webhook timestamp unit tests** in payments/ | |
| 339 | 342 | - **10 new integration tests** in suspension.rs (checkout, promo codes, license keys, session staleness, webhook timestamps) | |
| 340 | 343 | ||
| 341 | - | Total: 3 open items (3 upstream-blocked deps) | |
| 344 | + | Total: 4 open items (3 upstream-blocked deps + 1 low-severity warning) | |
| 342 | 345 | ||
| 343 | 346 | ## Previous Action Item Verification | |
| 344 | 347 |
| @@ -19,7 +19,7 @@ | |||
| 19 | 19 | | `src/wordlist.rs` | 2,056 | Static 2048-word array | | |
| 20 | 20 | | `src/db/models.rs` | 2,045 | FromRow structs + simple accessors | | |
| 21 | 21 | | `src/db/enums.rs` | 1,217 | 35 `impl_str_enum!` macro enums | | |
| 22 | - | | `src/validation.rs` | 1,177 | 60+ linear validation functions | | |
| 22 | + | | `src/validation/` | 1,177 | 60+ linear validation functions (split into directory module) | | |
| 23 | 23 | | `src/templates/public.rs` | 952 | Askama HTML markup | | |
| 24 | 24 | | `src/types/mod.rs` | 871 | Type definitions / newtypes | | |
| 25 | 25 | ||
| @@ -28,10 +28,10 @@ | |||
| 28 | 28 | | File | Lines | Domains | Split recommendation | | |
| 29 | 29 | |------|-------|---------|---------------------| | |
| 30 | 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 | | |
| 31 | + | | ~~`src/git.rs`~~ | 1,176 | Split into `src/git/` (refs.rs, objects.rs, history.rs) | | |
| 32 | + | | ~~`src/payments.rs`~~ | 1,173 | Split into `src/payments/` (checkout.rs, webhooks.rs, connect.rs) | | |
| 33 | + | | ~~`src/routes/storage.rs`~~ | 920 | Split into `src/routes/storage/` dir | | |
| 34 | + | | ~~`src/routes/postmark.rs`~~ | 887 | Split into `src/routes/postmark/` dir | | |
| 35 | 35 | | `src/routes/pages/dashboard/wizards/item.rs` | 791 | 6 wizard steps | `wizards/item/` dir | | |
| 36 | 36 | | `src/helpers.rs` | 775 | 8 (slugs, CSV, URLs, dates, crypto, email, cache, forms) | `helpers/` dir with 4 submodules | | |
| 37 | 37 | | `src/routes/stripe/checkout.rs` | 768 | 4 (forms, promo validation, session, payment intent) | Extract `checkout/promo.rs` | |
| @@ -279,11 +279,11 @@ routes/ | |||
| 279 | 279 | feeds.rs RSS/Atom feeds | |
| 280 | 280 | blog.rs Blog pages | |
| 281 | 281 | auth.rs Login, signup, logout, password reset | |
| 282 | - | git.rs Source browser | |
| 283 | - | git_issues.rs Issue tracker | |
| 282 | + | git/ Source browser (directory module) | |
| 283 | + | git_issues/ Issue tracker (directory module) | |
| 284 | 284 | oauth.rs OAuth provider (for Multithreaded) | |
| 285 | - | postmark.rs Inbound email webhook | |
| 286 | - | storage.rs File upload/download (presigned URLs) | |
| 285 | + | postmark/ Inbound email webhook (directory module) | |
| 286 | + | storage/ File upload/download (presigned URLs, directory module) | |
| 287 | 287 | stripe/ Stripe webhooks and Connect callbacks | |
| 288 | 288 | synckit.rs SyncKit cloud sync + OTA API | |
| 289 | 289 | ``` |
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: All pre-beta phases + frontend audit + content fingerprinting. Active: None. Next: Post-beta features below. | |
| 4 | + | Done: All pre-beta phases + frontend audit + content fingerprinting + bundled license text + video upload/playback + maintainability splits + S3 storage extraction (shared crate). Active: None. Next: Deploy v0.3.20, then post-beta features below. | |
| 5 | 5 | ||
| 6 | - | Live at makenot.work. v0.3.18. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed. | |
| 6 | + | Live at makenot.work. v0.3.19. 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 | ||
| @@ -247,8 +247,27 @@ Competitive context: Gumroad has per-product affiliates with configurable rates. | |||
| 247 | 247 | ### Phase 13D: Importers | |
| 248 | 248 | Shared infra first: `POST /api/users/me/import` multipart upload, `ImportJob` table, background `tokio::spawn`, progress polling. Subscriber pipeline: dedup by email, re-confirmation, rate-limit 100/hr. Content helpers: HTML-to-markdown, image re-upload to S3, tag normalization, Stripe customer ID mapping. Build order: (1) Generic CSV with column mapping UI + presets for Ko-fi/Itch.io/Sellfy, (2) Substack ZIP, (3) Ghost JSON, (4) Gumroad API/CSV, (5) Bandcamp sales CSV, (6) Lemon Squeezy REST API, (7) Patreon OAuth API (hardest). | |
| 249 | 249 | ||
| 250 | - | ### Phase 14: Video | |
| 251 | - | - [ ] Video upload to S3, HTML5 player, streaming via presigned URLs, thumbnails | |
| 250 | + | ### Phase 14: Video — Done | |
| 251 | + | Migration 053. 6 integration tests. | |
| 252 | + | - [x] `video_s3_key`, `video_file_size_bytes`, `video_duration_seconds`, `video_width`, `video_height` columns on items | |
| 253 | + | - [x] `FileType::Video` (MP4, WebM, MOV, 20 GB max) | |
| 254 | + | - [x] `ContentData::Video` + `ItemContent::Video` view type | |
| 255 | + | - [x] Upload via presign/confirm flow | |
| 256 | + | - [x] HTML5 `<video>` player on item page (lazy stream URL fetch) | |
| 257 | + | - [x] Stream URL endpoint supports video (reuses audio access control + fingerprinting) | |
| 258 | + | - [x] Wizard "video" group with dedicated upload step + JS handler | |
| 259 | + | - [x] Storage tracking includes `video_file_size_bytes` (dashboard breakdown, delete cleanup) | |
| 260 | + | - [x] Scanning: content-type verification for video | |
| 261 | + | - [x] Data export includes video fields | |
| 262 | + | ||
| 263 | + | ### Phase 14E: Video Streaming Tier Features (post-beta) | |
| 264 | + | Streaming tier ($40/mo) premium features. Trigger: >100 creators with video content. | |
| 265 | + | - [ ] Server-side transcoding (ffmpeg): upload any format, serve optimized MP4/WebM | |
| 266 | + | - [ ] Adaptive bitrate streaming (HLS/DASH) — multiple quality levels per video | |
| 267 | + | - [ ] Background lossless re-encoding: re-mux uploaded videos to more efficient containers, optimize keyframes, strip unused metadata. No quality loss, just smaller files | |
| 268 | + | - [ ] Bandwidth metering + per-tier bandwidth caps | |
| 269 | + | - [ ] Auto-generated thumbnails (ffmpeg frame extraction at configurable timestamp) | |
| 270 | + | - [ ] Video duration auto-detection on upload (ffprobe) | |
| 252 | 271 | ||
| 253 | 272 | ### Phase 14B: Embeddable Widgets | |
| 254 | 273 | - [ ] Embed endpoint /embed/i/{uuid} — embeddable buy button, audio player, 30-sec preview | |
| @@ -366,7 +385,7 @@ Share search infrastructure between MNW and MT instead of building independently | |||
| 366 | 385 | ||
| 367 | 386 | ### Image Upload Pipeline | |
| 368 | 387 | Consolidate S3 upload infrastructure shared between MNW and MT. | |
| 369 | - | - [ ] Extract shared presigned-upload flow into reusable module (MNW `storage.rs` + MT `storage.rs`) | |
| 388 | + | - [x] Extract shared S3 client into `Shared/s3-storage/` crate (2026-04-06). MNW + MT both delegate to shared client. Removed direct `aws-sdk-s3`/`aws-config` deps from both. | |
| 370 | 389 | - [ ] Shared image processing: thumbnail generation, format validation, size limits | |
| 371 | 390 | - [ ] Consistent upload UX patterns across MNW dashboard and MT post composer | |
| 372 | 391 | ||
| @@ -420,12 +439,12 @@ Archive policy: items on platform 12+ months stay hosted if creator cancels. | |||
| 420 | 439 | ``` | |
| 421 | 440 | MNW/src/ | |
| 422 | 441 | lib.rs, main.rs, config.rs, error.rs, auth.rs, db/ | |
| 423 | - | storage.rs, payments.rs, templates/, routes/ | |
| 424 | - | git.rs, git_issues.rs, synckit_auth.rs, build_runner.rs, validation.rs | |
| 442 | + | storage.rs, payments/, templates/, routes/ | |
| 443 | + | git/, git_issues/, synckit_auth.rs, build_runner.rs, validation/ | |
| 425 | 444 | fingerprint/ (registry, visible stamps, watermarks, streaming) | |
| 426 | 445 | MNW/tests/ | |
| 427 | 446 | integration.rs, harness/, workflows/*.rs | |
| 428 | - | MNW/migrations/ (001-051) | |
| 447 | + | MNW/migrations/ (001-053) | |
| 429 | 448 | MNW/templates/ | |
| 430 | 449 | MNW/deploy/ | |
| 431 | 450 | MNW/site-docs/public/, MNW/site-docs/unpublished/ |
| @@ -0,0 +1,9 @@ | |||
| 1 | + | -- Video item support: direct-file video upload and playback. | |
| 2 | + | -- Video items store their file in S3 (video_s3_key) and reuse the existing | |
| 3 | + | -- cover_s3_key/cover_image_url fields for the poster image. | |
| 4 | + | ||
| 5 | + | ALTER TABLE items ADD COLUMN video_s3_key TEXT; | |
| 6 | + | ALTER TABLE items ADD COLUMN video_file_size_bytes BIGINT; | |
| 7 | + | ALTER TABLE items ADD COLUMN video_duration_seconds INTEGER; | |
| 8 | + | ALTER TABLE items ADD COLUMN video_width INTEGER; | |
| 9 | + | ALTER TABLE items ADD COLUMN video_height INTEGER; |
| @@ -303,11 +303,18 @@ pub async fn recalculate_storage_used(pool: &PgPool, user_id: UserId) -> Result< | |||
| 303 | 303 | FROM items i | |
| 304 | 304 | JOIN projects p ON i.project_id = p.id | |
| 305 | 305 | WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL | |
| 306 | + | ), | |
| 307 | + | video_bytes AS ( | |
| 308 | + | SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0) AS total | |
| 309 | + | FROM items i | |
| 310 | + | JOIN projects p ON i.project_id = p.id | |
| 311 | + | WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL | |
| 306 | 312 | ) | |
| 307 | 313 | SELECT ((SELECT total FROM version_bytes) | |
| 308 | 314 | + (SELECT total FROM insertion_bytes) | |
| 309 | 315 | + (SELECT total FROM audio_bytes) | |
| 310 | - | + (SELECT total FROM cover_bytes))::BIGINT AS total | |
| 316 | + | + (SELECT total FROM cover_bytes) | |
| 317 | + | + (SELECT total FROM video_bytes))::BIGINT AS total | |
| 311 | 318 | "#, | |
| 312 | 319 | ) | |
| 313 | 320 | .bind(user_id) | |
| @@ -369,12 +376,24 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto | |||
| 369 | 376 | .fetch_one(pool) | |
| 370 | 377 | .await?; | |
| 371 | 378 | ||
| 379 | + | let video: i64 = sqlx::query_scalar( | |
| 380 | + | r#" | |
| 381 | + | SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0) | |
| 382 | + | FROM items i JOIN projects p ON i.project_id = p.id | |
| 383 | + | WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL | |
| 384 | + | "#, | |
| 385 | + | ) | |
| 386 | + | .bind(user_id) | |
| 387 | + | .fetch_one(pool) | |
| 388 | + | .await?; | |
| 389 | + | ||
| 372 | 390 | Ok(StorageBreakdown { | |
| 373 | 391 | audio_bytes: audio, | |
| 374 | 392 | cover_bytes: cover, | |
| 375 | 393 | download_bytes: download, | |
| 376 | 394 | insertion_bytes: insertion, | |
| 377 | - | total_bytes: audio + cover + download + insertion, | |
| 395 | + | video_bytes: video, | |
| 396 | + | total_bytes: audio + cover + download + insertion + video, | |
| 378 | 397 | }) | |
| 379 | 398 | } | |
| 380 | 399 |
| @@ -23,7 +23,6 @@ pub async fn insert_email_signup(pool: &PgPool, email: &str, source: &str) -> Re | |||
| 23 | 23 | ||
| 24 | 24 | /// Row returned by the admin email signups query. | |
| 25 | 25 | pub struct DbEmailSignup { | |
| 26 | - | pub id: Uuid, | |
| 27 | 26 | pub email: String, | |
| 28 | 27 | pub source: String, | |
| 29 | 28 | pub created_at: chrono::DateTime<chrono::Utc>, | |
| @@ -34,7 +33,7 @@ pub async fn get_all_email_signups(pool: &PgPool) -> Result<Vec<DbEmailSignup>> | |||
| 34 | 33 | let rows = sqlx::query_as!( | |
| 35 | 34 | DbEmailSignup, | |
| 36 | 35 | r#" | |
| 37 | - | SELECT id, email, source, | |
| 36 | + | SELECT email, source, | |
| 38 | 37 | created_at as "created_at: chrono::DateTime<chrono::Utc>" | |
| 39 | 38 | FROM email_signups | |
| 40 | 39 | ORDER BY created_at DESC |
| @@ -340,12 +340,14 @@ impl ItemType { | |||
| 340 | 340 | /// Determines what the content step looks like: | |
| 341 | 341 | /// - `"text"` → Markdown editor | |
| 342 | 342 | /// - `"audio"` → Audio file upload | |
| 343 | + | /// - `"video"` → Video file upload | |
| 343 | 344 | /// - `"bundle"` → Item picker for bundle contents | |
| 344 | 345 | /// - `"file"` → Generic file upload | |
| 345 | 346 | pub fn wizard_group(&self) -> &'static str { | |
| 346 | 347 | match self { | |
| 347 | 348 | Self::Text => "text", | |
| 348 | 349 | Self::Audio => "audio", | |
| 350 | + | Self::Video => "video", | |
| 349 | 351 | Self::Bundle => "bundle", | |
| 350 | 352 | _ => "file", | |
| 351 | 353 | } | |
| @@ -642,6 +644,7 @@ impl ProjectFeature { | |||
| 642 | 644 | let (label, desc) = match group { | |
| 643 | 645 | "text" => ("Text", "Write in the editor"), | |
| 644 | 646 | "audio" => ("Audio", "Upload audio files"), | |
| 647 | + | "video" => ("Video", "Upload video files"), | |
| 645 | 648 | "bundle" => ("Bundle", "Collection of other items"), | |
| 646 | 649 | _ => ("File", "Upload any file"), | |
| 647 | 650 | }; | |
| @@ -1148,10 +1151,14 @@ mod tests { | |||
| 1148 | 1151 | } | |
| 1149 | 1152 | ||
| 1150 | 1153 | #[test] | |
| 1154 | + | fn wizard_group_video() { | |
| 1155 | + | assert_eq!(ItemType::Video.wizard_group(), "video"); | |
| 1156 | + | } | |
| 1157 | + | ||
| 1158 | + | #[test] | |
| 1151 | 1159 | fn wizard_group_file_types() { | |
| 1152 | 1160 | for t in [ | |
| 1153 | 1161 | ItemType::Digital, | |
| 1154 | - | ItemType::Video, | |
| 1155 | 1162 | ItemType::Course, | |
| 1156 | 1163 | ItemType::Plugin, | |
| 1157 | 1164 | ItemType::Sample, | |
| @@ -1181,12 +1188,13 @@ mod tests { | |||
| 1181 | 1188 | } | |
| 1182 | 1189 | ||
| 1183 | 1190 | #[test] | |
| 1184 | - | fn wizard_cards_downloads_two_groups() { | |
| 1185 | - | // All download types are in the "file" group + bundle → 2 cards | |
| 1191 | + | fn wizard_cards_downloads_three_groups() { | |
| 1192 | + | // Download types split into "file" + "video" groups + bundle → 3 cards | |
| 1186 | 1193 | let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]); | |
| 1187 | - | assert_eq!(cards.len(), 2); | |
| 1194 | + | assert_eq!(cards.len(), 3); | |
| 1188 | 1195 | let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); | |
| 1189 | 1196 | assert!(groups.contains(&"digital")); // first type in file group | |
| 1197 | + | assert!(groups.contains(&"video")); // video group | |
| 1190 | 1198 | assert!(groups.contains(&"bundle")); | |
| 1191 | 1199 | } | |
| 1192 | 1200 | ||
| @@ -1209,9 +1217,9 @@ mod tests { | |||
| 1209 | 1217 | } | |
| 1210 | 1218 | ||
| 1211 | 1219 | #[test] | |
| 1212 | - | fn wizard_cards_empty_features_all_four_groups() { | |
| 1213 | - | // No content features → all types → 4 wizard groups (text, audio, file, bundle) | |
| 1220 | + | fn wizard_cards_empty_features_all_five_groups() { | |
| 1221 | + | // No content features → all types → 5 wizard groups (text, audio, video, file, bundle) | |
| 1214 | 1222 | let cards = ProjectFeature::wizard_type_cards(&[]); | |
| 1215 | - | assert_eq!(cards.len(), 4); | |
| 1223 | + | assert_eq!(cards.len(), 5); | |
| 1216 | 1224 | } | |
| 1217 | 1225 | } |
| @@ -76,29 +76,6 @@ pub async fn get_by_fingerprint_id( | |||
| 76 | 76 | .await | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | - | /// Look up fingerprints for a given user and content. | |
| 80 | - | #[tracing::instrument(skip_all)] | |
| 81 | - | pub async fn get_by_user_and_content( | |
| 82 | - | pool: &PgPool, | |
| 83 | - | user_id: UserId, | |
| 84 | - | content_type: &str, | |
| 85 | - | content_id: &str, | |
| 86 | - | ) -> Result<Vec<DbDownloadFingerprint>, sqlx::Error> { | |
| 87 | - | sqlx::query_as( | |
| 88 | - | "SELECT id, user_id, content_type, content_id, fingerprint_id, | |
| 89 | - | watermark_method, ip_address::TEXT, user_agent, created_at | |
| 90 | - | FROM download_fingerprints | |
| 91 | - | WHERE user_id = $1 AND content_type = $2 AND content_id = $3 | |
| 92 | - | ORDER BY created_at DESC | |
| 93 | - | LIMIT 100", | |
| 94 | - | ) | |
| 95 | - | .bind(user_id) | |
| 96 | - | .bind(content_type) | |
| 97 | - | .bind(content_id) | |
| 98 | - | .fetch_all(pool) | |
| 99 | - | .await | |
| 100 | - | } | |
| 101 | - | ||
| 102 | 79 | // ── Streaming sessions ── | |
| 103 | 80 | ||
| 104 | 81 | /// Create a new streaming session. Returns the session token. |
| @@ -480,16 +480,16 @@ pub async fn set_mt_thread_id( | |||
| 480 | 480 | Ok(()) | |
| 481 | 481 | } | |
| 482 | 482 | ||
| 483 | - | /// Collect all S3 keys for items owned by a user (audio + cover). | |
| 483 | + | /// Collect all S3 keys for items owned by a user (audio + cover + video). | |
| 484 | 484 | /// | |
| 485 | - | /// Returns item title, project slug, and optional S3 keys for audio/cover. | |
| 485 | + | /// Returns item title, project slug, and optional S3 keys for audio/cover/video. | |
| 486 | 486 | /// Only includes items that have at least one S3 key. | |
| 487 | 487 | pub async fn get_user_s3_keys(pool: &PgPool, user_id: UserId) -> Result<Vec<ItemS3KeyRow>> { | |
| 488 | 488 | let rows = sqlx::query_as::<_, ItemS3KeyRow>( | |
| 489 | 489 | r#" | |
| 490 | - | SELECT i.title, p.slug AS project_slug, i.audio_s3_key, i.cover_s3_key | |
| 490 | + | SELECT i.title, p.slug AS project_slug, i.audio_s3_key, i.cover_s3_key, i.video_s3_key | |
| 491 | 491 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 492 | - | WHERE p.user_id = $1 AND (i.audio_s3_key IS NOT NULL OR i.cover_s3_key IS NOT NULL) | |
| 492 | + | WHERE p.user_id = $1 AND (i.audio_s3_key IS NOT NULL OR i.cover_s3_key IS NOT NULL OR i.video_s3_key IS NOT NULL) | |
| 493 | 493 | ORDER BY p.slug, i.sort_order | |
| 494 | 494 | LIMIT 500 | |
| 495 | 495 | "#, | |
| @@ -748,26 +748,28 @@ pub async fn duplicate_item(pool: &PgPool, source_id: ItemId) -> Result<DbItem> | |||
| 748 | 748 | Ok(new_item) | |
| 749 | 749 | } | |
| 750 | 750 | ||
| 751 | - | /// Get the audio and cover file sizes for an item (for storage decrement on delete). | |
| 751 | + | /// Get the audio, cover, and video file sizes for an item (for storage decrement on delete). | |
| 752 | 752 | pub async fn get_item_file_sizes( | |
| 753 | 753 | pool: &PgPool, | |
| 754 | 754 | id: ItemId, | |
| 755 | 755 | ) -> Result<super::models::ItemFileSizes> { | |
| 756 | - | let row = sqlx::query_as::<_, (Option<i64>, Option<i64>)>( | |
| 757 | - | "SELECT audio_file_size_bytes, cover_file_size_bytes FROM items WHERE id = $1", | |
| 756 | + | let row = sqlx::query_as::<_, (Option<i64>, Option<i64>, Option<i64>)>( | |
| 757 | + | "SELECT audio_file_size_bytes, cover_file_size_bytes, video_file_size_bytes FROM items WHERE id = $1", | |
| 758 | 758 | ) | |
| 759 | 759 | .bind(id) | |
| 760 | 760 | .fetch_optional(pool) | |
| 761 | 761 | .await?; | |
| 762 | 762 | ||
| 763 | 763 | match row { | |
| 764 | - | Some((audio, cover)) => Ok(super::models::ItemFileSizes { | |
| 764 | + | Some((audio, cover, video)) => Ok(super::models::ItemFileSizes { | |
| 765 | 765 | audio_file_size_bytes: audio, | |
| 766 | 766 | cover_file_size_bytes: cover, | |
| 767 | + | video_file_size_bytes: video, | |
| 767 | 768 | }), | |
| 768 | 769 | None => Ok(super::models::ItemFileSizes { | |
| 769 | 770 | audio_file_size_bytes: None, | |
| 770 | 771 | cover_file_size_bytes: None, | |
| 772 | + | video_file_size_bytes: None, | |
| 771 | 773 | }), | |
| 772 | 774 | } | |
| 773 | 775 | } | |
| @@ -823,6 +825,70 @@ pub async fn update_item_cover_file_size( | |||
| 823 | 825 | Ok(()) | |
| 824 | 826 | } | |
| 825 | 827 | ||
| 828 | + | /// Update the video S3 key for an item. | |
| 829 | + | pub async fn update_item_video_s3_key( | |
| 830 | + | pool: &PgPool, | |
| 831 | + | item_id: ItemId, | |
| 832 | + | s3_key: &str, | |
| 833 | + | ) -> Result<DbItem> { | |
| 834 | + | let item = sqlx::query_as::<_, DbItem>( | |
| 835 | + | r#" | |
| 836 | + | UPDATE items | |
| 837 | + | SET video_s3_key = $2, updated_at = NOW() | |
| 838 | + | WHERE id = $1 | |
| 839 | + | RETURNING * | |
| 840 | + | "#, | |
| 841 | + | ) | |
| 842 | + | .bind(item_id) | |
| 843 | + | .bind(s3_key) | |
| 844 | + | .fetch_one(pool) | |
| 845 | + | .await?; | |
| 846 | + | ||
| 847 | + | Ok(item) | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | /// Update the video file size on an item. | |
| 851 | + | pub async fn update_item_video_file_size( | |
| 852 | + | pool: &PgPool, | |
| 853 | + | item_id: ItemId, | |
| 854 | + | bytes: i64, | |
| 855 | + | ) -> Result<()> { | |
| 856 | + | sqlx::query( | |
| 857 | + | "UPDATE items SET video_file_size_bytes = $2 WHERE id = $1", | |
| 858 | + | ) | |
| 859 | + | .bind(item_id) | |
| 860 | + | .bind(bytes) | |
| 861 | + | .execute(pool) | |
| 862 | + | .await?; | |
| 863 | + | ||
| 864 | + | Ok(()) | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | /// Update video metadata (duration, resolution) on an item. | |
| 868 | + | pub async fn update_item_video_metadata( | |
| 869 | + | pool: &PgPool, | |
| 870 | + | item_id: ItemId, | |
| 871 | + | duration_seconds: Option<i32>, | |
| 872 | + | width: Option<i32>, | |
| 873 | + | height: Option<i32>, | |
| 874 | + | ) -> Result<()> { | |
| 875 | + | sqlx::query( | |
| 876 | + | r#" | |
| 877 | + | UPDATE items | |
| 878 | + | SET video_duration_seconds = $2, video_width = $3, video_height = $4, updated_at = NOW() | |
| 879 | + | WHERE id = $1 | |
| 880 | + | "#, | |
| 881 | + | ) | |
| 882 | + | .bind(item_id) | |
| 883 | + | .bind(duration_seconds) | |
| 884 | + | .bind(width) | |
| 885 | + | .bind(height) | |
| 886 | + | .execute(pool) | |
| 887 | + | .await?; | |
| 888 | + | ||
| 889 | + | Ok(()) | |
| 890 | + | } | |
| 891 | + | ||
| 826 | 892 | /// Fetch a public item by project ID and slug (for custom domain routing). | |
| 827 | 893 | pub async fn get_item_by_project_and_slug( | |
| 828 | 894 | pool: &PgPool, |
| @@ -319,6 +319,17 @@ pub struct DbItem { | |||
| 319 | 319 | pub license_preset: Option<String>, | |
| 320 | 320 | /// Custom license text, used when license_preset = "custom". | |
| 321 | 321 | pub custom_license_text: Option<String>, | |
| 322 | + | // Video content fields | |
| 323 | + | /// S3 object key for the video file. | |
| 324 | + | pub video_s3_key: Option<String>, | |
| 325 | + | /// Size of the video file in bytes. | |
| 326 | + | pub video_file_size_bytes: Option<i64>, | |
| 327 | + | /// Video duration in seconds. | |
| 328 | + | pub video_duration_seconds: Option<i32>, | |
| 329 | + | /// Video width in pixels. | |
| 330 | + | pub video_width: Option<i32>, | |
| 331 | + | /// Video height in pixels. | |
| 332 | + | pub video_height: Option<i32>, | |
| 322 | 333 | } | |
| 323 | 334 | ||
| 324 | 335 | /// Content-type-specific data extracted from a `DbItem`. | |
| @@ -342,6 +353,14 @@ pub enum ContentData { | |||
| 342 | 353 | duration_seconds: Option<i32>, | |
| 343 | 354 | episode_number: Option<i32>, | |
| 344 | 355 | }, | |
| 356 | + | Video { | |
| 357 | + | video_s3_key: Option<String>, | |
| 358 | + | cover_s3_key: Option<String>, | |
| 359 | + | cover_image_url: Option<String>, | |
| 360 | + | duration_seconds: Option<i32>, | |
| 361 | + | width: Option<i32>, | |
| 362 | + | height: Option<i32>, | |
| 363 | + | }, | |
| 345 | 364 | Other, | |
| 346 | 365 | } | |
| 347 | 366 | ||
| @@ -362,13 +381,21 @@ impl DbItem { | |||
| 362 | 381 | duration_seconds: self.duration_seconds, | |
| 363 | 382 | episode_number: self.episode_number, | |
| 364 | 383 | }, | |
| 384 | + | super::ItemType::Video => ContentData::Video { | |
| 385 | + | video_s3_key: self.video_s3_key.clone(), | |
| 386 | + | cover_s3_key: self.cover_s3_key.clone(), | |
| 387 | + | cover_image_url: self.cover_image_url.clone(), | |
| 388 | + | duration_seconds: self.video_duration_seconds, | |
| 389 | + | width: self.video_width, | |
| 390 | + | height: self.video_height, | |
| 391 | + | }, | |
| 365 | 392 | _ => ContentData::Other, | |
| 366 | 393 | } | |
| 367 | 394 | } | |
| 368 | 395 | ||
| 369 | - | /// Whether this item has any S3-hosted content (audio or cover image). | |
| 396 | + | /// Whether this item has any S3-hosted content (audio, video, or cover image). | |
| 370 | 397 | pub fn has_s3_content(&self) -> bool { | |
| 371 | - | self.audio_s3_key.is_some() || self.cover_s3_key.is_some() | |
| 398 | + | self.audio_s3_key.is_some() || self.cover_s3_key.is_some() || self.video_s3_key.is_some() | |
| 372 | 399 | } | |
| 373 | 400 | } | |
| 374 | 401 | ||
| @@ -1293,6 +1320,7 @@ pub struct ItemS3KeyRow { | |||
| 1293 | 1320 | pub project_slug: Slug, | |
| 1294 | 1321 | pub audio_s3_key: Option<String>, | |
| 1295 | 1322 | pub cover_s3_key: Option<String>, | |
| 1323 | + | pub video_s3_key: Option<String>, | |
| 1296 | 1324 | } | |
| 1297 | 1325 | ||
| 1298 | 1326 | /// A version's S3 key for content export. | |
| @@ -1579,11 +1607,12 @@ pub struct DbCreatorSubscription { | |||
| 1579 | 1607 | pub grace_enforced_at: Option<DateTime<Utc>>, | |
| 1580 | 1608 | } | |
| 1581 | 1609 | ||
| 1582 | - | /// File sizes for an item's audio and cover uploads (for storage decrement on delete). | |
| 1610 | + | /// File sizes for an item's audio, video, and cover uploads (for storage decrement on delete). | |
| 1583 | 1611 | #[derive(Debug, Clone)] | |
| 1584 | 1612 | pub struct ItemFileSizes { | |
| 1585 | 1613 | pub audio_file_size_bytes: Option<i64>, | |
| 1586 | 1614 | pub cover_file_size_bytes: Option<i64>, | |
| 1615 | + | pub video_file_size_bytes: Option<i64>, | |
| 1587 | 1616 | } | |
| 1588 | 1617 | ||
| 1589 | 1618 | /// Per-category storage breakdown for the creator dashboard. | |
| @@ -1593,6 +1622,7 @@ pub struct StorageBreakdown { | |||
| 1593 | 1622 | pub cover_bytes: i64, | |
| 1594 | 1623 | pub download_bytes: i64, | |
| 1595 | 1624 | pub insertion_bytes: i64, | |
| 1625 | + | pub video_bytes: i64, | |
| 1596 | 1626 | pub total_bytes: i64, | |
| 1597 | 1627 | } | |
| 1598 | 1628 | ||
| @@ -2002,6 +2032,11 @@ mod tests { | |||
| 2002 | 2032 | listed: true, | |
| 2003 | 2033 | license_preset: None, | |
| 2004 | 2034 | custom_license_text: None, | |
| 2035 | + | video_s3_key: None, | |
| 2036 | + | video_file_size_bytes: None, | |
| 2037 | + | video_duration_seconds: None, | |
| 2038 | + | video_width: None, | |
| 2039 | + | video_height: None, | |
| 2005 | 2040 | } | |
| 2006 | 2041 | } | |
| 2007 | 2042 | ||
| @@ -2032,6 +2067,24 @@ mod tests { | |||
| 2032 | 2067 | } | |
| 2033 | 2068 | ||
| 2034 | 2069 | #[test] | |
| 2070 | + | fn content_video_variant() { | |
| 2071 | + | let mut item = make_item(super::super::ItemType::Video); | |
| 2072 | + | item.video_s3_key = Some("video/test.mp4".to_string()); | |
| 2073 | + | item.video_duration_seconds = Some(300); | |
| 2074 | + | item.video_width = Some(1920); | |
| 2075 | + | item.video_height = Some(1080); | |
| 2076 | + | match item.content() { | |
| 2077 | + | ContentData::Video { video_s3_key, duration_seconds, width, height, .. } => { | |
| 2078 | + | assert_eq!(video_s3_key.as_deref(), Some("video/test.mp4")); | |
| 2079 | + | assert_eq!(duration_seconds, Some(300)); | |
| 2080 | + | assert_eq!(width, Some(1920)); | |
| 2081 | + | assert_eq!(height, Some(1080)); | |
| 2082 | + | } | |
| 2083 | + | _ => panic!("expected Video variant"), | |
| 2084 | + | } | |
| 2085 | + | } | |
| 2086 | + | ||
| 2087 | + | #[test] | |
| 2035 | 2088 | fn content_other_variant() { | |
| 2036 | 2089 | let item = make_item(super::super::ItemType::Digital); | |
| 2037 | 2090 | assert!(matches!(item.content(), ContentData::Other)); | |
| @@ -2050,4 +2103,13 @@ mod tests { | |||
| 2050 | 2103 | item.cover_s3_key = None; | |
| 2051 | 2104 | assert!(!item.has_s3_content()); | |
| 2052 | 2105 | } | |
| 2106 | + | ||
| 2107 | + | #[test] | |
| 2108 | + | fn has_s3_content_video() { | |
| 2109 | + | let mut item = make_item(super::super::ItemType::Video); | |
| 2110 | + | item.audio_s3_key = None; | |
| 2111 | + | item.cover_s3_key = None; | |
| 2112 | + | item.video_s3_key = Some("video/test.mp4".to_string()); | |
| 2113 | + | assert!(item.has_s3_content()); | |
| 2114 | + | } | |
| 2053 | 2115 | } |
| @@ -85,12 +85,6 @@ impl TextWatermarker { | |||
| 85 | 85 | pos.max(1).min(token_count.saturating_sub(1)) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | /// Strip all ZWC characters from text (for clean extraction scanning). | |
| 89 | - | fn strip_zwc(text: &str) -> String { | |
| 90 | - | text.chars() | |
| 91 | - | .filter(|c| !matches!(*c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}')) | |
| 92 | - | .collect() | |
| 93 | - | } | |
| 94 | 88 | } | |
| 95 | 89 | ||
| 96 | 90 | impl Watermarker for TextWatermarker { | |
| @@ -174,7 +168,11 @@ mod tests { | |||
| 174 | 168 | ||
| 175 | 169 | let embedded = wm.embed(text.as_bytes(), fp).unwrap(); | |
| 176 | 170 | let embedded_str = std::str::from_utf8(&embedded).unwrap(); | |
| 177 | - | let stripped = TextWatermarker::strip_zwc(embedded_str); | |
| 171 | + | // Strip zero-width characters to verify visible text is preserved | |
| 172 | + | let stripped: String = embedded_str | |
| 173 | + | .chars() | |
| 174 | + | .filter(|c| !matches!(*c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}')) | |
| 175 | + | .collect(); | |
| 178 | 176 | assert_eq!(stripped, text); | |
| 179 | 177 | } | |
| 180 | 178 |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | - | use git2::{Diff, DiffOptions, ObjectType, Oid, Repository, Sort}; | |
| 5 | + | use git2::{Diff, DiffOptions, Oid, Repository, Sort}; | |
| 6 | 6 | ||
| 7 | 7 | use super::{ | |
| 8 | 8 | BlameLine, CommitDetail, CommitInfo, DiffFile, DiffHunk, DiffLine, DiffStatus, GitError, |
| @@ -119,24 +119,16 @@ pub fn format_revenue(cents: i64) -> String { | |||
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | /// Format a byte count as a human-readable file size string. | |
| 122 | + | /// Returns "N/A" for zero bytes (useful for optional file sizes). | |
| 122 | 123 | pub fn format_file_size(bytes: i64) -> String { | |
| 123 | 124 | if bytes == 0 { | |
| 124 | - | "N/A".to_string() | |
| 125 | - | } else if bytes < 1024 { | |
| 126 | - | format!("{} B", bytes) | |
| 127 | - | } else if bytes < 1024 * 1024 { | |
| 128 | - | format!("{:.1} KB", bytes as f64 / 1024.0) | |
| 129 | - | } else if bytes < 1024 * 1024 * 1024 { | |
| 130 | - | format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) | |
| 131 | - | } else { | |
| 132 | - | format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 125 | + | return "N/A".to_string(); | |
| 133 | 126 | } | |
| 127 | + | format_bytes(bytes) | |
| 134 | 128 | } | |
| 135 | 129 | ||
| 136 | 130 | /// Format a byte count as a compact human-readable string (e.g. "1.5 GB"). | |
| 137 | - | /// | |
| 138 | - | /// Unlike [`format_file_size`], this is designed for storage quota display where | |
| 139 | - | /// zero means "0 B" rather than "N/A". | |
| 131 | + | /// Returns "0 B" for zero bytes (useful for storage quota display). | |
| 140 | 132 | pub fn format_bytes(bytes: i64) -> String { | |
| 141 | 133 | let bytes = bytes.max(0) as u64; | |
| 142 | 134 | if bytes < 1024 { | |
| @@ -146,7 +138,7 @@ pub fn format_bytes(bytes: i64) -> String { | |||
| 146 | 138 | } else if bytes < 1024 * 1024 * 1024 { | |
| 147 | 139 | format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) | |
| 148 | 140 | } else { | |
| 149 | - | format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 141 | + | format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 150 | 142 | } | |
| 151 | 143 | } | |
| 152 | 144 | ||
| @@ -563,7 +555,7 @@ mod tests { | |||
| 563 | 555 | ||
| 564 | 556 | #[test] | |
| 565 | 557 | fn format_bytes_gigabytes() { | |
| 566 | - | assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.00 GB"); | |
| 558 | + | assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB"); | |
| 567 | 559 | } | |
| 568 | 560 | ||
| 569 | 561 | #[test] |
| @@ -19,7 +19,6 @@ mod connect; | |||
| 19 | 19 | mod webhooks; | |
| 20 | 20 | ||
| 21 | 21 | pub use checkout::*; | |
| 22 | - | pub use connect::*; | |
| 23 | 22 | pub use webhooks::*; | |
| 24 | 23 | ||
| 25 | 24 | use stripe::Client; |
| @@ -663,6 +663,11 @@ mod tests { | |||
| 663 | 663 | web_only: false, | |
| 664 | 664 | audio_file_size_bytes: None, | |
| 665 | 665 | cover_file_size_bytes: None, | |
| 666 | + | video_s3_key: None, | |
| 667 | + | video_file_size_bytes: None, | |
| 668 | + | video_duration_seconds: None, | |
| 669 | + | video_width: None, | |
| 670 | + | video_height: None, | |
| 666 | 671 | slug: "test".to_string(), | |
| 667 | 672 | listed: true, | |
| 668 | 673 | license_preset: None, |
| @@ -91,6 +91,13 @@ pub(super) async fn export_projects( | |||
| 91 | 91 | "episode_number": episode_number, | |
| 92 | 92 | }) | |
| 93 | 93 | } | |
| 94 | + | db::ContentData::Video { duration_seconds, width, height, .. } => { | |
| 95 | + | serde_json::json!({ | |
| 96 | + | "duration_seconds": duration_seconds, | |
| 97 | + | "width": width, | |
| 98 | + | "height": height, | |
| 99 | + | }) | |
| 100 | + | } | |
| 94 | 101 | db::ContentData::Other => serde_json::json!({}), | |
| 95 | 102 | }; | |
| 96 | 103 | ||
| @@ -416,6 +423,10 @@ pub(super) async fn export_content( | |||
| 416 | 423 | let ext = extension_from_key(key); | |
| 417 | 424 | files.push((key.clone(), format!("projects/{}/{}-cover.{}", slug, title, ext))); | |
| 418 | 425 | } | |
| 426 | + | if let Some(ref key) = item.video_s3_key { | |
| 427 | + | let ext = extension_from_key(key); | |
| 428 | + | files.push((key.clone(), format!("projects/{}/{}-video.{}", slug, title, ext))); | |
| 429 | + | } | |
| 419 | 430 | } | |
| 420 | 431 | ||
| 421 | 432 | for ver in &version_keys { |