Skip to main content

max / makenotwork

v0.5.19: split large modules; remove dead find_or_create_tag Refactor four oversized files into focused submodules: - db/items.rs (1501 lines) -> items/{mod,bulk,media}.rs - db/synckit.rs (1067 lines) -> synckit/{mod,apps,blobs,devices,keys,log,rotation}.rs - templates/public.rs (1088) -> public/{mod,git,health}.rs - types/mod.rs (864) -> types/{mod,admin,blog,content,dashboard,discover,payments,user}.rs Drop the unused db::tags::find_or_create_tag (its only callers were in the old items.rs paths; current item flows attach tags by tag_id). Also: small touch-ups in pom/config.rs, email/tokens.rs, scanning/yara.rs, tests/health.rs.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-16 16:17 UTC
Commit: 065429e4f4f3396dc2576699015c7aae2ddb5e11
Parent: e6ce744
29 files changed, +3247 insertions, -2437 deletions
@@ -3508,7 +3508,7 @@
3508 3508
3509 3509 [[package]]
3510 3510 name = "makenotwork"
3511 - version = "0.5.18"
3511 + version = "0.5.19"
3512 3512 dependencies = [
3513 3513 "anyhow",
3514 3514 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.5.18"
3 + version = "0.5.19"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -969,4 +969,156 @@
969 969 let config: Config = toml::from_str(toml).unwrap();
970 970 assert_eq!(config.serve.whois_check_interval_secs, 43200);
971 971 }
972 +
973 + // ─────────────────────────────────────────────────────────────────────
974 + // Defaults-pin tests — every `default_*` constant function is pinned to
975 + // its expected value. Catches `replace fn -> u64 with 0/1` mutations and
976 + // accidental drift when defaults are tweaked. These constants encode
977 + // operational policy (check cadence, retention, etc.) so changes should
978 + // be deliberate.
979 + // ─────────────────────────────────────────────────────────────────────
980 +
981 + #[test]
982 + fn defaults_numeric_intervals() {
983 + assert_eq!(default_peer_heartbeat(), 60, "peer heartbeat = 1 min");
984 + assert_eq!(default_tls_check_interval(), 3600, "tls = 1 hour");
985 + assert_eq!(default_route_check_interval(), 300, "routes = 5 min");
986 + assert_eq!(default_dns_check_interval(), 3600, "dns = 1 hour");
987 + assert_eq!(default_cors_check_interval(), 3600, "cors = 1 hour");
988 + assert_eq!(default_whois_check_interval(), 86400, "whois = 24 hours");
989 + assert_eq!(default_serve_interval(), 300, "serve = 5 min");
990 + assert_eq!(default_prune_days(), 30, "prune = 30 days");
991 + }
992 +
993 + #[test]
994 + fn defaults_listen_address() {
995 + assert_eq!(default_listen(), "127.0.0.1:9100");
996 + }
997 +
998 + #[test]
999 + fn defaults_warn_thresholds() {
1000 + assert_eq!(default_whois_warn_days(), 30, "whois 30 days lead time");
1001 + assert_eq!(default_tls_warn_days(), 14, "tls 14 days lead time");
1002 + assert_eq!(default_tls_port(), 443);
1003 + }
1004 +
1005 + #[test]
1006 + fn defaults_cors() {
1007 + assert_eq!(default_cors_method(), "PUT");
1008 + assert_eq!(default_max_age_hours(), 25, "25h allows cron drift");
1009 + }
1010 +
1011 + #[test]
1012 + fn defaults_backup() {
1013 + assert_eq!(default_backup_interval(), 3600, "hourly backup check");
1014 + }
1015 +
1016 + #[test]
1017 + fn defaults_ssh_banner() {
1018 + assert_eq!(default_ssh_banner_port(), 22);
1019 + assert_eq!(default_ssh_banner_timeout(), 5);
1020 + }
1021 +
1022 + #[test]
1023 + fn defaults_latency_baseline() {
1024 + assert_eq!(default_baseline_window_hours(), 168, "7 days");
1025 + // Spike threshold compares as f64; pin with bit-exact match.
1026 + assert_eq!(default_spike_threshold().to_bits(), 2.0_f64.to_bits());
1027 + }
1028 +
1029 + #[test]
1030 + fn defaults_health_and_test_timeouts() {
1031 + assert_eq!(default_health_timeout(), 10);
1032 + assert_eq!(default_test_timeout(), 600, "10-minute CI suite budget");
1033 + assert_eq!(default_staleness_days(), 7);
1034 + }
1035 +
1036 + #[test]
1037 + fn defaults_alerts() {
1038 + assert_eq!(default_alert_from(), "PoM Alerts <pom-alerts@makenot.work>");
1039 + assert_eq!(default_cooldown_secs(), 300, "5-minute alert cooldown");
1040 + }
1041 +
1042 + // ── Config method tests ──
1043 +
1044 + #[test]
1045 + fn instance_name_returns_configured_value() {
1046 + let toml = r#"
1047 + [serve]
1048 + [instance]
1049 + name = "test-host"
1050 + [targets.x]
1051 + label = "X"
1052 + [targets.x.health]
1053 + url = "https://example.com"
1054 + "#;
1055 + let config: Config = toml::from_str(toml).unwrap();
1056 + assert_eq!(config.instance_name(), "test-host");
1057 + }
1058 +
1059 + #[test]
1060 + fn instance_name_falls_back_to_non_empty() {
1061 + // When `name` is None, fall back to hostname or "unknown" — must not be
1062 + // empty regardless. Catches the `instance_name -> String with "xyzzy"`
1063 + // mutant and the empty-string variant.
1064 + let toml = r#"
1065 + [serve]
1066 + [instance]
1067 + [targets.x]
1068 + label = "X"
1069 + [targets.x.health]
1070 + url = "https://example.com"
1071 + "#;
1072 + let config: Config = toml::from_str(toml).unwrap();
1073 + let name = config.instance_name();
1074 + assert!(!name.is_empty(), "fallback must produce a non-empty name");
1075 + // It also must not be the cargo-mutants sentinel.
1076 + assert_ne!(name, "xyzzy");
1077 + }
1078 +
1079 + #[test]
1080 + fn default_config_path_ends_in_pom_toml() {
1081 + // The exact dir varies per OS, but the suffix is stable.
1082 + // Catches `default_config_path -> Ok(Default::default())` (which would
1083 + // return an empty PathBuf and fail the ends_with check).
1084 + let path = default_config_path().unwrap();
1085 + assert!(
1086 + path.ends_with("pom/pom.toml") || path.ends_with("pom\\pom.toml"),
1087 + "expected …/pom/pom.toml, got {path:?}"
1088 + );
1089 + }
1090 +
1091 + #[test]
1092 + fn db_path_ends_in_pom_db() {
1093 + // Same rationale as default_config_path. db_path also has a side
1094 + // effect (creates the parent dir) so we can't easily mock it; the
1095 + // suffix check is the cleanest pin.
1096 + let path = db_path().unwrap();
1097 + assert!(
1098 + path.ends_with("pom/pom.db") || path.ends_with("pom\\pom.db"),
1099 + "expected …/pom/pom.db, got {path:?}"
1100 + );
1101 + }
1102 +
1103 + #[test]
1104 + fn config_load_rejects_route_without_leading_slash() {
1105 + // Catches `delete ! in Config::load` (L431): without the `!`, the
1106 + // validator would only reject routes that DO start with '/' — wrong.
1107 + let toml = r#"
1108 + [serve]
1109 + [targets.bad]
1110 + label = "Bad"
1111 + expected_routes = ["no-leading-slash"]
1112 + [targets.bad.health]
1113 + url = "https://example.com"
1114 + "#;
1115 + let tmp = std::env::temp_dir().join(format!("pom_test_{}.toml", std::process::id()));
1116 + std::fs::write(&tmp, toml).unwrap();
1117 + let result = Config::load(Some(tmp.as_path()));
1118 + let _ = std::fs::remove_file(&tmp);
1119 + assert!(
1120 + matches!(result, Err(PomError::Config(_))),
1121 + "expected Config error rejecting bad route; got {result:?}"
1122 + );
1123 + }
972 1124 }
@@ -302,10 +302,13 @@
302 302
303 303 let pool = PgPool::connect(&database_url).await.expect("Failed to connect");
304 304
305 - // Check that all expected tables exist (must match migrations 001-025)
305 + // Check that all expected tables exist (must match migrations 001-025).
306 + // Note: the session table was renamed from `sessions` to `user_sessions`
307 + // when tower-sessions-sqlx-store config was updated; the old name was left
308 + // in this list and broke the assertion in fresh DBs.
306 309 let tables = vec![
307 310 "users", "projects", "items", "versions", "transactions",
308 - "custom_links", "sessions", "blog_posts", "chapters",
311 + "custom_links", "user_sessions", "blog_posts", "chapters",
309 312 "creator_waitlist", "creator_waves", "login_tokens",
310 313 "license_keys", "license_activations",
311 314 "sync_apps", "sync_devices", "sync_log", "sync_keys",
@@ -72,51 +72,6 @@
72 72 Ok(map)
73 73 }
74 74
75 - /// Find an existing tag by slug or create a new one.
76 - ///
77 - /// Slugs use dot-notation (e.g. `audio.genre.electronic`). The `path` column
78 - /// is set to the slug, and `parent_id` is resolved from the parent slug
79 - /// (everything before the last dot).
80 - #[tracing::instrument(skip_all)]
81 - pub async fn find_or_create_tag(pool: &PgPool, name: &str, slug: &str) -> Result<DbTag> {
82 - // Try to find existing tag by slug first
83 - if let Some(tag) = sqlx::query_as::<_, DbTag>(
84 - "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE slug = $1",
85 - )
86 - .bind(slug)
87 - .fetch_optional(pool)
88 - .await?
89 - {
90 - return Ok(tag);
91 - }
92 -
93 - // Resolve parent from slug hierarchy (e.g. "audio.genre.electronic" → parent "audio.genre")
94 - let parent_id: Option<TagId> = if let Some(parent_slug) = tagtree::parent(slug) {
95 - sqlx::query_scalar("SELECT id FROM tags WHERE slug = $1")
96 - .bind(parent_slug)
97 - .fetch_optional(pool)
98 - .await?
99 - } else {
100 - None
101 - };
102 -
103 - let tag = sqlx::query_as::<_, DbTag>(
104 - r#"
105 - INSERT INTO tags (name, slug, parent_id, path)
106 - VALUES ($1, $2, $3, $2)
107 - ON CONFLICT (slug) DO UPDATE SET name = tags.name
108 - RETURNING id, name, slug, parent_id, sort_order, created_at, path
109 - "#,
110 - )
111 - .bind(name)
112 - .bind(slug)
113 - .bind(parent_id)
114 - .fetch_one(pool)
115 - .await?;
116 -
117 - Ok(tag)
118 - }
119 -
120 75 /// Attach a tag to an item. If `is_primary` is true and the item already has
121 76 /// a primary tag, the old primary is cleared first (within a transaction).
122 77 #[tracing::instrument(skip_all)]
@@ -659,4 +659,142 @@
659 659 assert!(parse_issue_reply_token("issue+a.b", secret).is_none());
660 660 assert!(parse_issue_reply_token("issue+not-uuid.not-uuid.abcd1234abcd1234", secret).is_none());
661 661 }
662 +
663 + // ─────────────────────────────────────────────────────────────────────
664 + // Expiry arithmetic tests — pin `now + EXPIRY` so cargo-mutants can't
665 + // replace `+` with `*`/`-` without the test catching it. Each generator
666 + // emits an `expires=` URL parameter; we assert the value is within a
667 + // tight window of `now + EXPIRY`.
668 + // ─────────────────────────────────────────────────────────────────────
669 +
670 + /// Extract the `expires` query param from a URL emitted by a token generator.
671 + fn extract_expires(url: &str) -> i64 {
672 + let parsed: url::Url = url.parse().expect("valid URL");
673 + parsed
674 + .query_pairs()
675 + .find(|(k, _)| k == "expires")
676 + .expect("expires param")
677 + .1
678 + .parse()
679 + .expect("expires is i64")
680 + }
681 +
682 + /// Assert `actual ∈ [now+expiry, now+expiry + slack]`. The slack covers the
683 + /// few ms between calling `Utc::now()` inside the function and `Utc::now()`
684 + /// here. Any mutation that flips the arithmetic (e.g. `+` → `*`) will
685 + /// produce a value wildly outside this window.
686 + fn assert_within_expiry_window(actual: i64, expiry_secs: i64) {
687 + let now = chrono::Utc::now().timestamp();
688 + let expected_min = now + expiry_secs - 1;
689 + let expected_max = now + expiry_secs + 5;
690 + assert!(
691 + actual >= expected_min && actual <= expected_max,
692 + "expires={actual} outside [{expected_min}, {expected_max}] (now={now}, expiry_secs={expiry_secs})"
693 + );
694 + }
695 +
696 + #[test]
697 + fn password_reset_url_expires_matches_constant() {
698 + let url = generate_password_reset_url(
699 + "https://example.com",
700 + UserId::new(),
701 + "argon2$dummy",
702 + "secret",
703 + );
704 + assert_within_expiry_window(extract_expires(&url), constants::PASSWORD_RESET_EXPIRY_SECS);
705 + }
706 +
707 + #[test]
708 + fn verification_url_expires_matches_constant() {
709 + let url = generate_verification_url(
710 + "https://example.com",
711 + UserId::new(),
712 + "user@example.com",
713 + "secret",
714 + );
715 + assert_within_expiry_window(extract_expires(&url), constants::EMAIL_VERIFICATION_EXPIRY_SECS);
716 + }
717 +
718 + #[test]
719 + fn deletion_url_expires_matches_constant() {
720 + let url = generate_deletion_url(
721 + "https://example.com",
722 + UserId::new(),
723 + "user@example.com",
724 + "secret",
725 + );
726 + assert_within_expiry_window(extract_expires(&url), constants::ACCOUNT_DELETION_EXPIRY_SECS);
727 + }
728 +
729 + // ─────────────────────────────────────────────────────────────────────
730 + // Expiry-comparison boundary tests for `verify_email_signature` and
731 + // `verify_password_reset_signature`. Catches `<` → `==`/`<=` mutations on
732 + // the `if expires < now { return false; }` guard.
733 + // ─────────────────────────────────────────────────────────────────────
734 +
735 + #[test]
736 + fn verify_email_signature_rejects_already_expired() {
737 + // Build a signed URL, then verify with an `expires` value 60s in the past.
738 + // The signature won't match (since the message contains expires) — but
739 + // the early `expires < now` check should fire first and short-circuit.
740 + let user_id = UserId::new();
741 + let email = "test@example.com";
742 + let secret = "secret";
743 + let now = chrono::Utc::now().timestamp();
744 +
745 + // Generate a sig for an EXPIRED timestamp.
746 + use hmac::{Hmac, Mac};
747 + use sha2::Sha256;
748 + let expires_past = now - 60;
749 + let message = format!("verify:{}:{}:{}", user_id, expires_past, email);
750 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
751 + mac.update(message.as_bytes());
752 + let sig_past = hex::encode(mac.finalize().into_bytes());
753 +
754 + // Sig is valid for the message, but expires < now → must reject.
755 + assert!(
756 + !verify_email_signature(user_id, expires_past, email, &sig_past, secret),
757 + "must reject expired signature"
758 + );
759 + }
760 +
761 + #[test]
762 + fn verify_email_signature_accepts_just_in_future() {
763 + // Inverse: a sig that's still valid (expires just in the future) must
764 + // pass — catches `<` → `<=` (which would reject expires == now-1+1).
765 + let user_id = UserId::new();
766 + let email = "test@example.com";
767 + let secret = "secret";
768 + let expires_future = chrono::Utc::now().timestamp() + 3600;
769 +
770 + use hmac::{Hmac, Mac};
771 + use sha2::Sha256;
772 + let message = format!("verify:{}:{}:{}", user_id, expires_future, email);
773 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
774 + mac.update(message.as_bytes());
775 + let sig = hex::encode(mac.finalize().into_bytes());
776 +
777 + assert!(verify_email_signature(user_id, expires_future, email, &sig, secret));
778 + }
779 +
780 + #[test]
781 + fn verify_password_reset_signature_rejects_expired() {
782 + // Same boundary check for password reset path (L96 in this file).
783 + let user_id = UserId::new();
784 + let password_hash = "argon2$dummy";
785 + let secret = "secret";
786 + let expires_past = chrono::Utc::now().timestamp() - 60;
787 +
788 + use hmac::{Hmac, Mac};
789 + use sha2::Sha256;
790 + let message = format!("reset:{}:{}:{}", user_id, expires_past, password_hash);
791 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
792 + mac.update(message.as_bytes());
793 + let sig_past = hex::encode(mac.finalize().into_bytes());
794 +
795 + assert!(
796 + !verify_password_reset_signature(user_id, expires_past, password_hash, &sig_past, secret),
797 + "must reject expired password reset signature"
798 + );
799 + }
662 800 }
@@ -333,4 +333,35 @@
333 333 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
334 334 assert!(result.is_err());
335 335 }
336 +
337 + #[test]
338 + fn default_namespace_rule_is_unprefixed() {
339 + // Catches the L79 `==` → `!=` mutation. The function emits "rule" for
340 + // default-namespace rules but "ns:rule" otherwise. Under the mutant the
341 + // prefix logic inverts. Existing tests check `detail.contains("rule_x")`
342 + // which is also true for "default:rule_x", so they don't catch the flip.
343 + // Pin the exact format.
344 + let mut compiler = yara_x::Compiler::new();
345 + compiler
346 + .add_source(
347 + r#"
348 + rule plain {
349 + strings:
350 + $a = "TARGET"
351 + condition:
352 + $a
353 + }
354 + "#,
355 + )
356 + .unwrap();
357 + let rules = compiler.build();
358 + let result = scan_with_yara(&rules, b"TARGET in data");
359 + let detail = result.detail.unwrap();
360 + // Default-namespace rule must appear unprefixed.
361 + assert!(detail.contains("plain"), "missing rule name: {detail}");
362 + assert!(
363 + !detail.contains("default:"),
364 + "default-namespace rules must not be prefixed; got {detail}"
365 + );
366 + }
336 367 }
@@ -2,10 +2,27 @@
2 2 //!
3 3 //! These structs are the "view models" that sit between database rows and
4 4 //! Askama templates. Conversions from `db::Db*` types live in `conversions.rs`.
5 + //!
6 + //! Types are grouped by feature surface in submodules and re-exported flat
7 + //! for ergonomic imports (`use crate::types::*` still works).
5 8
9 + mod admin;
10 + mod blog;
11 + mod content;
6 12 mod conversions;
13 + mod dashboard;
14 + mod discover;
15 + mod payments;
16 + mod user;
17 +
18 + pub use admin::*;
19 + pub use blog::*;
20 + pub use content::*;
21 + pub use dashboard::*;
22 + pub use discover::*;
23 + pub use payments::*;
24 + pub use user::*;
7 25
8 - use crate::constants::{DATE_FMT_DATETIME, DATE_FMT_FULL};
9 26 use serde::Serialize;
10 27
11 28 /// Envelope for JSON list endpoints: `{"data": [...]}`.
@@ -17,197 +34,6 @@
17 34 pub data: Vec<T>,
18 35 }
19 36
20 - /// User profile data
21 - #[derive(Clone)]
22 - pub struct User {
23 - pub username: String,
24 - pub email: String,
25 - pub display_name: Option<String>,
26 - pub bio: Option<String>,
27 - pub avatar_initials: String,
28 - pub avatar_url: Option<String>,
29 - // Stripe Connect status
30 - pub stripe_connected: bool,
31 - pub stripe_account_id: Option<String>,
32 - pub stripe_onboarding_complete: bool,
33 - pub stripe_payouts_enabled: bool,
34 - pub stripe_charges_enabled: bool,
35 - pub stripe_tax_enabled: bool,
36 - // Notification preferences
37 - pub notify_sale: bool,
38 - pub notify_follower: bool,
39 - pub notify_release: bool,
40 - pub login_notification_enabled: bool,
41 - pub notify_issues: bool,
42 - pub notify_status: bool,
43 - // Tips
44 - pub tips_enabled: bool,
45 - pub notify_tip: bool,
46 - }
47 -
48 - impl User {
49 - /// Returns the display name if set, otherwise falls back to the username.
50 - pub fn display_name_or_username(&self) -> &str {
51 - self.display_name.as_deref().filter(|s| !s.is_empty()).unwrap_or(&self.username)
52 - }
53 -
54 - /// Derive the Stripe connection status from the view fields.
55 - fn stripe_connection_status(&self) -> crate::db::StripeConnectionStatus {
56 - use crate::db::StripeConnectionStatus;
57 - if !self.stripe_connected {
58 - StripeConnectionStatus::NotConnected
59 - } else if !self.stripe_onboarding_complete {
60 - StripeConnectionStatus::Onboarding
61 - } else if !self.stripe_payouts_enabled {
62 - StripeConnectionStatus::PayoutsPending
63 - } else {
64 - StripeConnectionStatus::Active
65 - }
66 - }
67 -
68 - /// Human-readable Stripe Connect status for display in the dashboard.
69 - pub fn stripe_status_text(&self) -> &str {
70 - self.stripe_connection_status().text()
71 - }
72 -
73 - /// CSS class for the Stripe status badge (`"active"`, `"pending"`, or `"inactive"`).
74 - pub fn stripe_status_class(&self) -> &str {
75 - self.stripe_connection_status().css_class()
76 - }
77 -
78 - /// Display name (or username fallback) escaped for JSON string embedding (JSON-LD).
79 - pub fn display_name_json(&self) -> String {
80 - json_escape(self.display_name_or_username())
81 - }
82 -
83 - /// Bio escaped for JSON string embedding (JSON-LD), or empty string if none.
84 - pub fn bio_json(&self) -> String {
85 - self.bio.as_deref().map(json_escape).unwrap_or_default()
86 - }
87 - }
88 -
89 - /// A project owned by a user
90 - #[derive(Clone)]
91 - pub struct Project {
92 - pub id: String,
93 - pub slug: String,
94 - pub title: String,
95 - pub description: String,
96 - pub item_count: u32,
97 - pub project_type: String,
98 - pub cover_image_url: Option<String>,
99 - }
100 -
101 - impl Project {
102 - /// Title escaped for JSON string embedding (JSON-LD).
103 - pub fn title_json(&self) -> String {
104 - json_escape(&self.title)
105 - }
106 -
107 - /// Description escaped for JSON string embedding (JSON-LD).
108 - pub fn description_json(&self) -> String {
109 - json_escape(&self.description)
110 - }
111 - }
112 -
113 - /// A tag attached to an item, for template display.
114 - #[derive(Clone)]
115 - pub struct TagView {
116 - pub id: String,
117 - pub name: String,
118 - pub slug: String,
119 - pub is_primary: bool,
120 - }
121 -
122 - /// Content-type-specific view data for an item.
123 - ///
124 - /// Mirrors `db::ContentData` but with display-ready computed fields
125 - /// (formatted duration strings, rendered HTML, etc.).
126 - #[derive(Clone)]
127 - pub enum ItemContent {
128 - Text {
129 - body: Option<String>,
130 - body_html: Option<String>,
131 - reading_time: Option<String>,
132 - word_count: Option<i32>,
133 - reading_time_minutes: Option<i32>,
134 - },
135 - Audio {
136 - duration: Option<String>,
137 - duration_seconds: Option<i32>,
138 - cover_url: Option<String>,
139 - episode_number: Option<u32>,
140 - audio_s3_key: Option<String>,
141 - },
142 - Video {
143 - duration: Option<String>,
144 - duration_seconds: Option<i32>,
145 - cover_url: Option<String>,
146 - video_s3_key: Option<String>,
147 - width: Option<i32>,
148 - height: Option<i32>,
149 - },
150 - Other,
151 - }
152 -
153 - /// An individual item for sale
154 - #[derive(Clone)]
155 - pub struct Item {
156 - pub id: String,
157 - pub title: String,
158 - pub price: String,
159 - /// Raw price in cents for structured data (JSON-LD).
160 - pub price_cents: i32,
161 - pub item_type: String,
162 - pub description: String,
163 - pub thumbnail: String,
164 - pub release_date: String,
165 - pub sales_count: u32,
166 - pub tags: Vec<TagView>,
167 - pub content: ItemContent,
168 - pub cover_image_url: Option<String>,
169 - // Access control
170 - pub is_free: bool,
171 - pub can_access: bool,
172 - // License key settings
173 - pub enable_license_keys: bool,
174 - pub default_max_activations: Option<i32>,
175 - // PWYW
176 - pub pwyw_enabled: bool,
177 - pub pwyw_min_cents: Option<i32>,
178 - // Scheduled publish
179 - pub publish_at: Option<String>,
180 - pub is_public: bool,
181 - // Bundle
182 - pub listed: bool,
183 - pub bundle_item_count: i64,
184 - // License text
185 - pub license_preset: Option<String>,
186 - pub custom_license_text: Option<String>,
187 - // AI tier
188 - pub ai_tier: crate::db::AiTier,
189 - pub ai_disclosure: Option<String>,
190 - }
191 -
192 - impl Item {
193 - /// Price formatted as a decimal string for JSON-LD (e.g. "9.99").
194 - pub fn price_decimal(&self) -> String {
195 - let abs = self.price_cents.unsigned_abs();
196 - let sign = if self.price_cents < 0 { "-" } else { "" };
197 - format!("{sign}{}.{:02}", abs / 100, abs % 100)
198 - }
199 -
200 - /// Title escaped for JSON string embedding (JSON-LD).
201 - pub fn title_json(&self) -> String {
202 - json_escape(&self.title)
203 - }
204 -
205 - /// Description escaped for JSON string embedding (JSON-LD).
206 - pub fn description_json(&self) -> String {
207 - json_escape(&self.description)
208 - }
209 - }
210 -
211 37 /// Escape a string for safe embedding inside a JSON string value within
212 38 /// a `<script>` tag. Escapes JSON special chars plus `</` to prevent
213 39 /// script injection.
@@ -232,660 +58,6 @@
232 58 out
233 59 }
234 60
235 - /// Payout summary derived from the Stripe Balance API.
236 - #[derive(Clone)]
237 - pub struct PayoutSummary {
238 - pub available: String,
239 - pub pending: String,
240 - }
241 -
242 - /// A single bar in a revenue bar chart.
243 - #[derive(Clone)]
244 - pub struct ChartBar {
245 - pub label: String,
246 - pub height_pct: f64,
247 - pub value: String,
248 - pub count: i64,
249 - }
250 -
251 - /// A tabbed content section within an item, with pre-rendered HTML.
252 - #[derive(Clone)]
253 - pub struct ItemSection {
254 - pub id: String,
255 - pub title: String,
256 - pub slug: String,
257 - pub body: String,
258 - pub body_html: String,
259 - pub sort_order: i32,
260 - }
261 -
262 - impl ItemSection {
263 - /// Create from a DB row with media URL resolution for public-facing pages.
264 - pub fn from_db(s: &crate::db::DbItemSection, user_id: crate::db::UserId, cdn_base: &str) -> Self {
265 - ItemSection {
266 - id: s.id.to_string(),
267 - title: s.title.clone(),
268 - slug: s.slug.clone(),
269 - body: s.body.clone(),
270 - body_html: crate::markdown::render_creator_markdown(&s.body, user_id, cdn_base),
271 - sort_order: s.sort_order,
272 - }
273 - }
274 - }
275 -
276 - /// A tabbed content section within a project, with pre-rendered HTML.
277 - #[derive(Clone)]
278 - pub struct ProjectSection {
279 - pub id: String,
280 - pub title: String,
281 - pub slug: String,
282 - pub body: String,
283 - pub body_html: String,
284 - pub sort_order: i32,
285 - }
286 -
287 - impl ProjectSection {
288 - /// Create from a DB row with media URL resolution for public-facing pages.
289 - pub fn from_db(s: &crate::db::DbProjectSection, user_id: crate::db::UserId, cdn_base: &str) -> Self {
290 - ProjectSection {
291 - id: s.id.to_string(),
292 - title: s.title.clone(),
293 - slug: s.slug.clone(),
294 - body: s.body.clone(),
295 - body_html: crate::markdown::render_creator_markdown(&s.body, user_id, cdn_base),
296 - sort_order: s.sort_order,
297 - }
298 - }
299 - }
300 -
301 - /// Chapter/timestamp for audio content
302 - #[derive(Clone)]
303 - pub struct Chapter {
304 - pub title: String,
305 - pub timestamp: String,
306 - pub start_seconds: f64,
307 - }
308 -
309 - /// Custom link on a user profile
310 - #[derive(Clone)]
311 - pub struct CustomLink {
312 - pub url: String,
313 - pub title: String,
314 - pub description: String,
315 - }
316 -
317 - /// Item in the discover list
318 - #[derive(Clone)]
319 - #[allow(dead_code)] // Fields used by Askama templates
320 - pub struct DiscoverItem {
321 - pub id: String,
322 - pub name: String,
323 - pub creator: String,
324 - pub project: String,
325 - pub item_type: String,
326 - pub primary_tag: String,
327 - pub price: String,
328 - pub is_free: bool,
329 - pub sales: u32,
330 - pub date: String,
331 - }
332 -
333 - /// Project in the discover list (projects mode)
334 - #[derive(Clone)]
335 - #[allow(dead_code)] // Fields used by Askama templates
336 - pub struct DiscoverProject {
337 - pub slug: String,
338 - pub title: String,
339 - pub creator: String,
340 - pub project_type: String,
341 - pub description: String,
342 - pub item_count: u32,
343 - pub date: String,
344 - pub category_name: Option<String>,
345 - pub category_slug: Option<String>,
346 - }
347 -
348 - /// Filter category for discover page
349 - #[derive(Clone)]
350 - pub struct FilterCategory {
351 - pub name: String,
352 - /// Query-parameter value (slug for tags, raw item_type for types).
353 - pub value: String,
354 - pub count: u32,
355 - pub active: bool,
356 - /// Tag UUID (empty for "All" and type filters).
357 - pub id: String,
358 - /// Whether the viewer follows this tag.
359 - pub following: bool,
360 - }
361 -
362 - /// A tag node for the tree browser page.
363 - #[derive(Clone)]
364 - pub struct TagTreeNode {
365 - pub name: String,
366 - pub slug: String,
367 - pub item_count: u32,
368 - pub child_count: usize,
369 - }
370 -
371 - /// A breadcrumb in the tag tree hierarchy.
372 - #[derive(Clone)]
373 - pub struct TagBreadcrumb {
374 - pub name: String,
375 - pub slug: String,
376 - }
377 -
378 - /// Price filter option
379 - #[derive(Clone)]
380 - pub struct PriceFilter {
381 - pub label: String,
382 - pub count: u32,
383 - }
384 -
385 - /// Transaction for payments tab
386 - #[derive(Clone)]
387 - pub struct Transaction {
388 - pub date: String,
389 - pub tx_type: String,
390 - pub description: String,
391 - pub amount: String,
392 - pub is_incoming: bool,
393 - pub status: String,
394 - pub details: String,
395 - }
396 -
397 - /// Tip received for payments tab
398 - #[derive(Clone)]
399 - pub struct TipReceived {
400 - pub date: String,
401 - pub tipper_name: String,
402 - pub amount: String,
403 - pub message: Option<String>,
404 - }
405 -
406 - /// Project member row for dashboard members tab
407 - #[derive(Clone)]
408 - pub struct ProjectMemberRow {
409 - pub id: String,
410 - pub user_id: String,
411 - pub username: String,
412 - pub display_name: Option<String>,
413 - pub role: String,
414 - pub split_percent: i16,
415 - pub stripe_connected: bool,
416 - pub added_at: String,
417 - }
418 -
419 - /// Project card for dashboard
420 - #[derive(Clone)]
421 - pub struct ProjectCard {
422 - pub id: crate::db::ProjectId,
423 - pub title: String,
424 - pub project_type: String,
425 - pub created_date: String,
426 - pub updated_date: Option<String>,
427 - pub stats: String,
428 - pub status: String,
429 - pub slug: String,
430 - }
431 -
432 - /// Stat card for dashboards
433 - #[derive(Clone)]
434 - pub struct StatCard {
435 - pub label: String,
436 - pub value: String,
437 - pub change: Option<String>,
438 - pub is_positive: bool,
439 - }
440 -
441 - /// A buyer who opted to share their email with the creator.
442 - #[derive(Clone)]
443 - #[allow(dead_code)] // Fields used by Askama template
444 - pub struct BuyerContact {
445 - pub username: String,
446 - pub email: String,
447 - pub total_purchases: i64,
448 - pub total_spent: String,
449 - pub last_purchase: String,
450 - }
451 -
452 - /// Per-project comparison row for cross-project analytics.
453 - #[derive(Clone)]
454 - #[allow(dead_code)] // Fields used by Askama template
455 - pub struct ProjectComparison {
456 - pub title: String,
457 - pub revenue: String,
458 - pub revenue_pct: f64,
459 - pub sales: i64,
460 - pub views: i64,
461 - pub conversion: String,
462 - }
463 -
464 - /// Content item for project dashboard
465 - #[derive(Clone)]
466 - pub struct ContentItem {
467 - pub position: u32,
468 - pub title: String,
469 - pub item_type: String,
470 - pub price: String,
471 - pub sales: u32,
472 - pub revenue: String,
473 - pub status: String,
474 - pub id: String,
475 - /// True if this item is unlisted (only accessible via bundle).
476 - pub is_unlisted: bool,
477 - /// Child items if this is a bundle (shown nested in the dashboard).
478 - pub children: Vec<ContentItem>,
479 - }
480 -
481 - /// A single step in the creator onboarding checklist.
482 - #[derive(Clone)]
483 - pub struct OnboardingStep {
484 - pub label: &'static str,
485 - pub done: bool,
486 - pub link_tab: &'static str,
487 - pub link_label: &'static str,
488 - }
489 -
490 - /// Progress checklist shown to new creators until all steps are complete.
491 - #[derive(Clone)]
492 - pub struct OnboardingChecklist {
493 - pub steps: Vec<OnboardingStep>,
494 - pub completed: usize,
495 - pub total: usize,
496 - }
497 -
498 - /// A sync app row for the SyncKit dashboard tab.
499 - #[derive(Clone)]
500 - #[allow(dead_code)] // Fields used by Askama template
501 - pub struct SyncAppRow {
502 - pub id: String,
503 - pub name: String,
504 - pub api_key_masked: String,
505 - pub api_key_full: String,
506 - pub is_active: bool,
Lines truncated
@@ -1,4 +1,14 @@
1 1 //! Item CRUD: creation, listing, text body updates, and ownership lookups.
2 + //!
3 + //! Bulk and structural operations (move, bulk_*, duplicate) live in the
4 + //! `bulk` submodule and are re-exported flat so call sites still see
5 + //! `db::items::bulk_publish` etc.
6 +
7 + mod bulk;
8 + mod media;
9 +
10 + pub use bulk::*;
11 + pub use media::*;
2 12
3 13 use sqlx::PgPool;
4 14
@@ -78,7 +88,7 @@
78 88 }
79 89
80 90 /// Check whether a slug already exists for a given project.
81 - async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>(
91 + pub(super) async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>(
82 92 executor: E,
83 93 project_id: ProjectId,
84 94 slug: &super::validated_types::Slug,
@@ -884,503 +894,6 @@
884 894 Ok(())
885 895 }
886 896
887 - /// Reorder an item within its project by swapping with the adjacent item.
888 - ///
889 - /// Normalizes all sort_orders first (0, 1, 2, ...) to handle the cold-start
890 - /// case where all items have sort_order=0, then swaps with the neighbor.
891 - /// Uses `FOR UPDATE` to serialize concurrent reorder requests on the same project.
892 - #[tracing::instrument(skip_all)]
893 - pub async fn move_item(
894 - pool: &PgPool,
895 - project_id: ProjectId,
896 - user_id: UserId,
897 - item_id: ItemId,
898 - direction: &str,
899 - ) -> Result<()> {
900 - let mut tx = pool.begin().await?;
901 -
902 - // Lock and fetch item IDs in display order (scoped to projects owned by user)
903 - let item_ids: Vec<ItemId> = sqlx::query_scalar(
904 - r#"
905 - SELECT id FROM items
906 - WHERE project_id = $1
907 - AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
908 - ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE
909 - "#,
910 - )
911 - .bind(project_id)
912 - .bind(user_id)
913 - .fetch_all(&mut *tx)
914 - .await?;
915 -
916 - let Some(pos) = item_ids.iter().position(|id| *id == item_id) else {
917 - return Ok(());
918 - };
919 -
920 - let swap_pos = match direction {
921 - "up" if pos > 0 => pos - 1,
922 - "down" if pos + 1 < item_ids.len() => pos + 1,
923 - _ => return Ok(()),
924 - };
925 -
926 - // Normalize all sort_orders, swapping the target pair (single batch UPDATE)
927 - let mut ids = Vec::with_capacity(item_ids.len());
928 - let mut orders = Vec::with_capacity(item_ids.len());
929 - for (i, id) in item_ids.iter().enumerate() {
930 - ids.push(*id);
931 - orders.push(if i == pos {
932 - swap_pos as i32
933 - } else if i == swap_pos {
934 - pos as i32
935 - } else {
936 - i as i32
937 - });
938 - }
939 - sqlx::query(
940 - "UPDATE items SET sort_order = batch.ord FROM UNNEST($1::UUID[], $2::INT[]) AS batch(id, ord) WHERE items.id = batch.id",
941 - )
942 - .bind(&ids)
943 - .bind(&orders)
944 - .execute(&mut *tx)
945 - .await?;
946 -
947 - tx.commit().await?;
948 - Ok(())
949 - }
950 -
951 - /// Bulk-publish items: set `is_public = true` and clear any scheduled `publish_at`.
952 - ///
953 - /// Only affects items matching both the given IDs and project. Returns rows affected.
954 - #[tracing::instrument(skip_all)]
955 - pub async fn bulk_publish(
956 - pool: &PgPool,
957 - item_ids: &[ItemId],
958 - project_id: ProjectId,
959 - user_id: UserId,
960 - ) -> Result<u64> {
961 - let result = sqlx::query(
962 - r#"
963 - UPDATE items
964 - SET is_public = true, publish_at = NULL, updated_at = NOW()
965 - WHERE id = ANY($1) AND project_id = $2
966 - AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
967 - AND removed_by_admin = false
968 - "#,
969 - )
970 - .bind(item_ids)
971 - .bind(project_id)
972 - .bind(user_id)
973 - .execute(pool)
974 - .await?;
975 -
976 - Ok(result.rows_affected())
977 - }
978 -
979 - /// Bulk-unpublish items: set `is_public = false`.
980 - ///
981 - /// Only affects items matching both the given IDs and project. Returns rows affected.
982 - #[tracing::instrument(skip_all)]
983 - pub async fn bulk_unpublish(
984 - pool: &PgPool,
985 - item_ids: &[ItemId],
986 - project_id: ProjectId,
987 - user_id: UserId,
988 - ) -> Result<u64> {
989 - let result = sqlx::query(
990 - r#"
991 - UPDATE items
992 - SET is_public = false, updated_at = NOW()
993 - WHERE id = ANY($1) AND project_id = $2
994 - AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
995 - "#,
996 - )
997 - .bind(item_ids)
998 - .bind(project_id)
999 - .bind(user_id)
1000 - .execute(pool)
1001 - .await?;
1002 -
1003 - Ok(result.rows_affected())
1004 - }
1005 -
1006 - /// Soft-delete items from a project (sets deleted_at, recoverable for 7 days).
1007 - ///
1008 - /// Only affects items matching both the given IDs and project. Returns rows affected.
1009 - #[tracing::instrument(skip_all)]
1010 - pub async fn bulk_delete(
1011 - pool: &PgPool,
1012 - item_ids: &[ItemId],
1013 - project_id: ProjectId,
1014 - user_id: UserId,
1015 - ) -> Result<u64> {
1016 - let result = sqlx::query(
1017 - r#"
1018 - UPDATE items SET deleted_at = NOW(), is_public = false
1019 - WHERE id = ANY($1) AND project_id = $2 AND deleted_at IS NULL
1020 - AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
1021 - "#,
1022 - )
1023 - .bind(item_ids)
1024 - .bind(project_id)
1025 - .bind(user_id)
1026 - .execute(pool)
1027 - .await?;
1028 -
1029 - Ok(result.rows_affected())
1030 - }
1031 -
1032 - /// Bulk-update price on selected items.
1033 - ///
1034 - /// Only affects items matching both the given IDs and project. Returns rows affected.
1035 - #[tracing::instrument(skip_all)]
1036 - pub async fn bulk_update_price(
1037 - pool: &PgPool,
1038 - item_ids: &[ItemId],
1039 - project_id: ProjectId,
1040 - user_id: UserId,
1041 - price_cents: PriceCents,
1042 - ) -> Result<u64> {
1043 - let result = sqlx::query(
1044 - r#"
1045 - UPDATE items SET price_cents = $4
1046 - WHERE id = ANY($1) AND project_id = $2
1047 - AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
1048 - "#,
1049 - )
1050 - .bind(item_ids)
1051 - .bind(project_id)
1052 - .bind(user_id)
1053 - .bind(price_cents)
1054 - .execute(pool)
1055 - .await?;
1056 -
1057 - Ok(result.rows_affected())
1058 - }
1059 -
1060 - /// Bulk-add a tag to selected items (skips duplicates via ON CONFLICT).
1061 - ///
1062 - /// Returns number of new tag associations created.
1063 - #[tracing::instrument(skip_all)]
1064 - pub async fn bulk_add_tag(
1065 - pool: &PgPool,
1066 - item_ids: &[ItemId],
1067 - project_id: ProjectId,
1068 - user_id: UserId,
1069 - tag_id: super::TagId,
1070 - ) -> Result<u64> {
1071 - // Verify all items belong to the project owned by this user,
1072 - // then insert tag associations for each.
1073 - let result = sqlx::query(
1074 - r#"
1075 - INSERT INTO item_tags (item_id, tag_id)
1076 - SELECT i.id, $4
1077 - FROM items i
1078 - JOIN projects p ON i.project_id = p.id
1079 - WHERE i.id = ANY($1) AND i.project_id = $2 AND p.user_id = $3
1080 - ON CONFLICT (item_id, tag_id) DO NOTHING
1081 - "#,
1082 - )
1083 - .bind(item_ids)
1084 - .bind(project_id)
1085 - .bind(user_id)
1086 - .bind(tag_id)
1087 - .execute(pool)
1088 - .await?;
1089 -
1090 - Ok(result.rows_affected())
1091 - }
1092 -
1093 - /// Duplicate an item and its metadata (tags, chapters, content insertion placements).
1094 - ///
1095 - /// Creates a draft copy with "Copy of …" title. Does not copy versions (S3 files),
1096 - /// license keys, download codes, or discount codes.
1097 - #[tracing::instrument(skip_all)]
1098 - pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) -> Result<DbItem> {
1099 - let mut tx = pool.begin().await?;
1100 -
1101 - // Generate a unique slug for the copy (verify ownership via project)
1102 - let source = sqlx::query_as::<_, DbItem>(
1103 - "SELECT * FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
1104 - )
1105 - .bind(source_id)
1106 - .bind(user_id)
1107 - .fetch_one(&mut *tx)
1108 - .await?;
1109 - let copy_title = format!("Copy of {}", &source.title);
1110 - let copy_title: String = copy_title.chars().take(200).collect();
1111 - let mut slug = crate::helpers::slugify(&copy_title);
1112 - if item_slug_exists(&mut *tx, source.project_id, &slug).await? {
1113 - let base = slug.clone();
1114 - let mut counter = 2u32;
1115 - loop {
1116 - if counter > 100 {
1117 - return Err(crate::error::AppError::BadRequest(
1118 - "Too many copies with similar names. Rename an existing copy first.".to_string(),
1119 - ));
1120 - }
1121 - slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
1122 - if !item_slug_exists(&mut *tx, source.project_id, &slug).await? {
1123 - break;
1124 - }
1125 - counter += 1;
1126 - }
1127 - }
1128 -
1129 - // Step 1: Clone item row
1130 - let new_item = sqlx::query_as::<_, DbItem>(
1131 - r#"
1132 - INSERT INTO items (
1133 - project_id, title, description, price_cents, item_type, thumbnail_url,
1134 - sort_order, body, word_count, reading_time_minutes, duration_seconds,
1135 - episode_number, enable_license_keys, default_max_activations,
1136 - pwyw_enabled, pwyw_min_cents, is_public, slug
1137 - )
1138 - SELECT
1139 - project_id, LEFT('Copy of ' || title, 200), description, price_cents,
1140 - item_type, thumbnail_url, sort_order, body, word_count,
1141 - reading_time_minutes, duration_seconds, episode_number,
1142 - enable_license_keys, default_max_activations, pwyw_enabled,
1143 - pwyw_min_cents, false, $2
1144 - FROM items WHERE id = $1
1145 - RETURNING *
1146 - "#,
1147 - )
1148 - .bind(source_id)
1149 - .bind(&slug)
1150 - .fetch_one(&mut *tx)
1151 - .await?;
1152 -
1153 - // Step 2: Copy tags
1154 - sqlx::query(
1155 - r#"
1156 - INSERT INTO item_tags (item_id, tag_id, is_primary)
1157 - SELECT $2, tag_id, is_primary FROM item_tags WHERE item_id = $1
1158 - "#,
1159 - )
1160 - .bind(source_id)
1161 - .bind(new_item.id)
1162 - .execute(&mut *tx)
1163 - .await?;
1164 -
1165 - // Step 3: Copy chapters
1166 - sqlx::query(
1167 - r#"
1168 - INSERT INTO chapters (item_id, title, start_seconds, sort_order)
1169 - SELECT $2, title, start_seconds, sort_order FROM chapters WHERE item_id = $1
1170 - "#,
1171 - )
1172 - .bind(source_id)
1173 - .bind(new_item.id)
1174 - .execute(&mut *tx)
1175 - .await?;
1176 -
1177 - // Step 4: Copy content insertion placements
1178 - sqlx::query(
1179 - r#"
1180 - INSERT INTO content_insertion_placements (item_id, insertion_id, position, offset_ms, sort_order)
1181 - SELECT $2, insertion_id, position, offset_ms, sort_order
1182 - FROM content_insertion_placements WHERE item_id = $1
1183 - "#,
1184 - )
1185 - .bind(source_id)
1186 - .bind(new_item.id)
1187 - .execute(&mut *tx)
1188 - .await?;
1189 -
1190 - tx.commit().await?;
1191 -
1192 - Ok(new_item)
1193 - }
1194 -
1195 - /// Get the audio, cover, and video file sizes for an item (for storage decrement on delete).
1196 - #[tracing::instrument(skip_all)]
1197 - pub async fn get_item_file_sizes(
1198 - pool: &PgPool,
1199 - id: ItemId,
1200 - ) -> Result<super::models::ItemFileSizes> {
1201 - let row = sqlx::query_as::<_, (Option<i64>, Option<i64>, Option<i64>)>(
1202 - "SELECT audio_file_size_bytes, cover_file_size_bytes, video_file_size_bytes FROM items WHERE id = $1",
1203 - )
1204 - .bind(id)
1205 - .fetch_optional(pool)
1206 - .await?;
1207 -
1208 - match row {
1209 - Some((audio, cover, video)) => Ok(super::models::ItemFileSizes {
1210 - audio_file_size_bytes: audio,
1211 - cover_file_size_bytes: cover,
1212 - video_file_size_bytes: video,
1213 - }),
1214 - None => Ok(super::models::ItemFileSizes {
1215 - audio_file_size_bytes: None,
1216 - cover_file_size_bytes: None,
1217 - video_file_size_bytes: None,
1218 - }),
1219 - }
1220 - }
1221 -
1222 - /// Update the audio file size on an item (defense-in-depth: verifies ownership).
1223 - #[tracing::instrument(skip_all)]
1224 - pub async fn update_item_audio_file_size(
1225 - pool: &PgPool,
1226 - item_id: ItemId,
1227 - user_id: UserId,
1228 - bytes: i64,
1229 - ) -> Result<()> {
1230 - sqlx::query(
1231 - "UPDATE items SET audio_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
1232 - )
1233 - .bind(item_id)
1234 - .bind(bytes)
1235 - .bind(user_id)
1236 - .execute(pool)
1237 - .await?;
1238 -
1239 - Ok(())
1240 - }
1241 -
1242 - /// Update the cover image URL for an item (defense-in-depth: verifies ownership).
1243 - #[tracing::instrument(skip_all)]
1244 - pub async fn update_item_cover_image_url(
1245 - pool: &PgPool,
1246 - item_id: ItemId,
1247 - user_id: UserId,
1248 - url: &str,
1249 - ) -> Result<()> {
1250 - sqlx::query(
1251 - "UPDATE items SET cover_image_url = $2, updated_at = NOW() WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
1252 - )
1253 - .bind(item_id)
1254 - .bind(url)
1255 - .bind(user_id)
1256 - .execute(pool)
1257 - .await?;
1258 -
1259 - Ok(())
1260 - }
1261 -
1262 - /// Atomically update cover image URL, S3 key, and file size in a single UPDATE
1263 - /// (defense-in-depth: verifies ownership).
1264 - #[tracing::instrument(skip_all)]
1265 - pub async fn update_item_cover(
1266 - pool: &PgPool,
1267 - item_id: ItemId,
1268 - user_id: UserId,
1269 - url: &str,
1270 - s3_key: &str,
1271 - file_size_bytes: i64,
1272 - ) -> Result<()> {
1273 - sqlx::query(
1274 - r#"UPDATE items
1275 - SET cover_image_url = $2, cover_s3_key = $3, cover_file_size_bytes = $4, updated_at = NOW()
1276 - WHERE id = $1
1277 - AND project_id IN (SELECT id FROM projects WHERE user_id = $5)"#,
1278 - )
1279 - .bind(item_id)
1280 - .bind(url)
1281 - .bind(s3_key)
1282 - .bind(file_size_bytes)
1283 - .bind(user_id)
1284 - .execute(pool)
1285 - .await?;
1286 -
1287 - Ok(())
1288 - }
1289 -
1290 - /// Update the cover file size on an item (defense-in-depth: verifies ownership).
1291 - #[tracing::instrument(skip_all)]
1292 - pub async fn update_item_cover_file_size(
1293 - pool: &PgPool,
1294 - item_id: ItemId,
1295 - user_id: UserId,
1296 - bytes: i64,
1297 - ) -> Result<()> {
1298 - sqlx::query(
1299 - "UPDATE items SET cover_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
1300 - )
1301 - .bind(item_id)
1302 - .bind(bytes)
1303 - .bind(user_id)
1304 - .execute(pool)
1305 - .await?;
1306 -
1307 - Ok(())
1308 - }
1309 -
1310 - /// Update the video S3 key for an item (defense-in-depth: verifies ownership).
1311 - #[tracing::instrument(skip_all)]
1312 - pub async fn update_item_video_s3_key(
1313 - pool: &PgPool,
1314 - item_id: ItemId,
1315 - user_id: UserId,
1316 - s3_key: &str,
1317 - ) -> Result<DbItem> {
1318 - let item = sqlx::query_as::<_, DbItem>(
1319 - r#"
1320 - UPDATE items
1321 - SET video_s3_key = $2, updated_at = NOW()
1322 - WHERE id = $1
1323 - AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
1324 - RETURNING *
1325 - "#,
1326 - )
1327 - .bind(item_id)
1328 - .bind(s3_key)
1329 - .bind(user_id)
1330 - .fetch_one(pool)
1331 - .await?;
1332 -
1333 - Ok(item)
1334 - }
1335 -
1336 - /// Update the video file size on an item (defense-in-depth: verifies ownership).
1337 - #[tracing::instrument(skip_all)]
1338 - pub async fn update_item_video_file_size(
1339 - pool: &PgPool,
1340 - item_id: ItemId,
1341 - user_id: UserId,
1342 - bytes: i64,
1343 - ) -> Result<()> {
1344 - sqlx::query(
1345 - "UPDATE items SET video_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
1346 - )
1347 - .bind(item_id)
1348 - .bind(bytes)
1349 - .bind(user_id)
1350 - .execute(pool)
1351 - .await?;
1352 -
1353 - Ok(())
1354 - }
1355 -
1356 - /// Update video metadata (duration, resolution) on an item (defense-in-depth: verifies ownership).
1357 - #[tracing::instrument(skip_all)]
1358 - pub async fn update_item_video_metadata(
1359 - pool: &PgPool,
1360 - item_id: ItemId,
1361 - user_id: UserId,
Lines truncated
@@ -1,11 +1,19 @@
1 1 //! Templates for public-facing pages: landing, auth, content, blog, discover.
2 + //!
3 + //! Git source-browser templates live in the `git` submodule and are
4 + //! re-exported flat — call sites still see `templates::GitRepoTemplate` etc.
5 +
6 + mod git;
7 + mod health;
8 +
9 + pub use git::*;
10 + pub use health::*;
2 11
3 12 use std::sync::Arc;
4 13
5 14 use askama::Template;
6 15
7 16 use crate::auth::SessionUser;
8 - use crate::git;
9 17 use crate::types::*;
10 18
11 19 use super::CsrfTokenOption;
@@ -705,384 +713,4 @@
705 713 pub csrf_token: CsrfTokenOption,
706 714 }
707 715
708 - // ============================================================================
709 - // Health Page
710 - // ============================================================================
711 716
712 - /// Test result for health page display.
713 - #[derive(Clone)]
714 - pub struct HealthTest {
715 - pub name: String,
716 - pub passed: bool,
717 - pub latency_ms: u64,
718 - }
719 -
720 - /// Pre-formatted health snapshot for template rendering.
721 - #[derive(Clone)]
722 - pub struct PrivacyJobDisplay {
723 - pub name: String,
724 - pub description: String,
725 - pub last_ran: String,
726 - pub rows_affected: String,
727 - pub status_class: String,
728 - }
729 -
730 - /// Pre-formatted health check snapshot for template rendering.
731 - pub struct HealthSnapshotDisplay {
732 - pub checked_at: String,
733 - pub status: String,
734 - pub status_class: String,
735 - pub duration_ms: i32,
736 - }
737 -
738 - /// Pre-formatted PoM snapshot for template rendering.
739 - #[derive(Clone)]
740 - pub struct PomSnapshotDisplay {
741 - pub checked_at: String,
742 - pub status: String,
743 - pub status_class: String,
744 - pub response_time_ms: i64,
745 - }
746 -
747 - /// Pre-formatted PoM incident for template rendering.
748 - #[derive(Clone)]
749 - pub struct PomIncidentDisplay {
750 - pub to_status: String,
751 - pub started_at: String,
752 - pub duration: String,
753 - }
754 -
755 - /// Public page: platform health status and monitoring dashboard.
756 - #[derive(Template)]
757 - #[template(path = "pages/health.html")]
758 - pub struct HealthTemplate {
759 - pub csrf_token: CsrfTokenOption,
760 - pub session_user: Option<SessionUser>,
761 - // Overall status
762 - pub overall_status: String,
763 - pub overall_status_class: String,
764 - pub uptime: String,
765 - pub version: String,
766 - pub check_duration_ms: u64,
767 - // Database
768 - pub db_status: String,
769 - pub db_status_class: String,
770 - pub db_pool_size: String,
771 - pub db_pool_max: String,
772 - pub db_pool_utilization: String,
773 - pub db_active_connections: String,
774 - pub user_count: String,
775 - pub project_count: String,
776 - pub item_count: String,
777 - pub transaction_count: String,
778 - pub blog_post_count: String,
779 - // Sessions
780 - pub session_status: String,
781 - pub session_status_class: String,
782 - pub active_sessions: String,
783 - // Storage
784 - pub storage_status: String,
785 - pub storage_status_class: String,
786 - pub storage_configured: bool,
787 - pub storage_bucket: String,
788 - pub storage_region: String,
789 - // Stripe
790 - pub stripe_status: String,
791 - pub stripe_status_class: String,
792 - pub stripe_configured: bool,
793 - pub stripe_mode: String,
794 - pub connected_creators: String,
795 - // Email
796 - pub email_status: String,
797 - pub email_status_class: String,
798 - pub email_provider: String,
799 - // SyncKit
800 - pub synckit_status: String,
801 - pub synckit_status_class: String,
802 - pub synckit_configured: bool,
803 - pub synckit_app_count: String,
804 - pub synckit_device_count: String,
805 - pub synckit_log_entries: String,
806 - // Security & Monitoring
807 - pub admin_status: String,
808 - // Background monitor
809 - pub monitor_enabled: bool,
810 - pub monitor_interval_secs: u64,
811 - pub alerts_configured: bool,
812 - pub uptime_24h: Option<String>,
813 - pub uptime_7d: Option<String>,
814 - pub last_incident: Option<String>,
815 - pub recent_snapshots: Vec<HealthSnapshotDisplay>,
816 - // Server
817 - pub environment: String,
818 - pub host: Arc<str>,
819 - pub started_at: String,
820 - // Privacy & Compliance
821 - pub privacy_jobs: Vec<PrivacyJobDisplay>,
822 - // Tests
823 - pub public_tests: Vec<HealthTest>,
824 - pub db_tests: Vec<HealthTest>,
825 - pub generated_at: String,
826 - // External monitoring (PoM)
827 - pub pom_available: bool,
828 - pub pom_status: Option<String>,
829 - pub pom_status_class: Option<String>,
830 - pub pom_response_time_ms: Option<i64>,
831 - pub pom_checked_at: Option<String>,
832 - pub pom_uptime_24h: Option<String>,
833 - pub pom_uptime_7d: Option<String>,
834 - pub pom_recent: Vec<PomSnapshotDisplay>,
835 - pub pom_avg_latency: Option<String>,
836 - pub pom_p95_latency: Option<String>,
837 - pub pom_incident_active: bool,
838 - pub pom_incident_status: Option<String>,
839 - pub pom_incident_since: Option<String>,
840 - pub pom_recent_incidents: Vec<PomIncidentDisplay>,
841 - // External monitoring (PoM) — route checks
842 - pub pom_routes_total: usize,
843 - pub pom_routes_ok: usize,
844 - pub pom_routes_failed: Vec<String>,
845 - }
846 -
847 - // ============================================================================
848 - // Git Source Browser
849 - // ============================================================================
850 -
851 - /// An item paired with its versions, for release display on the git repo page.
852 - pub struct ReleaseItem {
853 - pub item: Item,
854 - pub versions: Vec<Version>,
855 - }
856 -
857 - /// Repository overview: file tree at HEAD + README.
858 - #[derive(Template)]
859 - #[template(path = "pages/git/repo.html")]
860 - pub struct GitRepoTemplate {
861 - pub csrf_token: CsrfTokenOption,
862 - pub session_user: Option<SessionUser>,
863 - pub owner: String,
864 - pub repo_name: String,
865 - pub description: Option<String>,
866 - pub current_ref: String,
867 - pub refs: Vec<git::RefInfo>,
868 - pub tree_items: Vec<git::TreeItem>,
869 - pub readme_html: Option<String>,
870 - pub host_url: Arc<str>,
871 - /// Hostname for SSH clone URLs (e.g., "git.makenot.work"). Hidden when `None`.
872 - pub git_ssh_host: Option<String>,
873 - /// Linked project, if this repo is associated with a public project.
874 - pub linked_project: Option<Project>,
875 - /// Public items with versions from the linked project (releases).
876 - pub release_items: Vec<ReleaseItem>,
877 - /// Number of open issues for the nav bar badge.
878 - pub open_issue_count: i64,
879 - /// Whether the current viewer is the repo owner (for settings link).
880 - pub is_owner: bool,
881 - pub active_tab: &'static str,
882 - }
883 -
884 - /// Subdirectory listing with breadcrumb navigation.
885 - #[derive(Template)]
886 - #[template(path = "pages/git/tree.html")]
887 - pub struct GitTreeTemplate {
888 - pub csrf_token: CsrfTokenOption,
889 - pub session_user: Option<SessionUser>,
890 - pub owner: String,
891 - pub repo_name: String,
892 - pub current_ref: String,
893 - pub refs: Vec<git::RefInfo>,
894 - pub path: String,
895 - pub parent_path: String,
896 - /// Whether we're in a subdirectory (for ".." link and path joining).
897 - pub in_subdir: bool,
898 - pub breadcrumbs: Vec<git::Breadcrumb>,
899 - pub tree_items: Vec<git::TreeItem>,
900 - pub open_issue_count: i64,
901 - pub is_owner: bool,
902 - pub active_tab: &'static str,
903 - }
904 -
905 - /// File viewer with syntax highlighting and line numbers.
906 - #[derive(Template)]
907 - #[template(path = "pages/git/file.html")]
908 - pub struct GitFileTemplate {
909 - pub csrf_token: CsrfTokenOption,
910 - pub session_user: Option<SessionUser>,
911 - pub owner: String,
912 - pub repo_name: String,
913 - pub current_ref: String,
914 - pub refs: Vec<git::RefInfo>,
915 - pub file_path: String,
916 - pub filename: String,
917 - pub breadcrumbs: Vec<git::Breadcrumb>,
918 - pub file_size: String,
919 - pub line_count: usize,
920 - pub is_binary: bool,
921 - pub highlighted_lines: Vec<String>,
922 - pub open_issue_count: i64,
923 - pub is_owner: bool,
924 - pub active_tab: &'static str,
925 - }
926 -
927 - /// Commit log with pagination.
928 - #[derive(Template)]
929 - #[template(path = "pages/git/commits.html")]
930 - pub struct GitCommitsTemplate {
931 - pub csrf_token: CsrfTokenOption,
932 - pub session_user: Option<SessionUser>,
933 - pub owner: String,
934 - pub repo_name: String,
935 - pub current_ref: String,
936 - pub refs: Vec<git::RefInfo>,
937 - pub commits: Vec<git::CommitInfo>,
938 - pub page: usize,
939 - pub has_more: bool,
940 - pub open_issue_count: i64,
941 - /// Whether the current viewer is the repo owner (for settings link).
942 - pub is_owner: bool,
943 - pub active_tab: &'static str,
944 - }
945 -
946 - /// Commit detail page with inline diffs.
947 - #[derive(Template)]
948 - #[template(path = "pages/git/commit.html")]
949 - pub struct GitCommitDetailTemplate {
950 - pub csrf_token: CsrfTokenOption,
951 - pub session_user: Option<SessionUser>,
952 - pub owner: String,
953 - pub repo_name: String,
954 - pub current_ref: String,
955 - pub refs: Vec<git::RefInfo>,
956 - pub detail: git::CommitDetail,
957 - pub diff_files: Vec<git::DiffFile>,
958 - pub total_files: usize,
959 - pub total_additions: usize,
960 - pub total_deletions: usize,
961 - pub open_issue_count: i64,
962 - pub is_owner: bool,
963 - pub active_tab: &'static str,
964 - }
965 -
966 - /// Blame view for a single file.
967 - #[derive(Template)]
968 - #[template(path = "pages/git/blame.html")]
969 - pub struct GitBlameTemplate {
970 - pub csrf_token: CsrfTokenOption,
971 - pub session_user: Option<SessionUser>,
972 - pub owner: String,
973 - pub repo_name: String,
974 - pub current_ref: String,
975 - pub refs: Vec<git::RefInfo>,
976 - pub file_path: String,
977 - pub filename: String,
978 - pub breadcrumbs: Vec<git::Breadcrumb>,
979 - pub blame_lines: Vec<git::BlameLine>,
980 - pub open_issue_count: i64,
981 - pub is_owner: bool,
982 - pub active_tab: &'static str,
983 - }
984 -
985 - /// User's repository listing page.
986 - #[derive(Template)]
987 - #[template(path = "pages/git/repos.html")]
988 - pub struct GitUserReposTemplate {
989 - pub csrf_token: CsrfTokenOption,
990 - pub session_user: Option<SessionUser>,
991 - pub owner: String,
992 - pub repos: Vec<crate::db::DbGitRepo>,
993 - pub is_owner: bool,
994 - }
995 -
996 - /// Public explore page listing all public repos across all users.
997 - #[derive(Template)]
998 - #[template(path = "pages/git/explore.html")]
999 - pub struct GitExploreTemplate {
1000 - pub csrf_token: CsrfTokenOption,
1001 - pub session_user: Option<SessionUser>,
1002 - pub repos: Vec<crate::db::git_repos::PublicRepoWithOwner>,
1003 - pub page: usize,
1004 - pub has_more: bool,
1005 - pub total_count: i64,
1006 - }
1007 -
1008 - /// Per-file commit history with breadcrumb context.
1009 - #[derive(Template)]
1010 - #[template(path = "pages/git/file_log.html")]
1011 - pub struct GitFileLogTemplate {
1012 - pub csrf_token: CsrfTokenOption,
1013 - pub session_user: Option<SessionUser>,
1014 - pub owner: String,
1015 - pub repo_name: String,
1016 - pub current_ref: String,
1017 - pub refs: Vec<git::RefInfo>,
1018 - pub file_path: String,
1019 - pub filename: String,
1020 - pub breadcrumbs: Vec<git::Breadcrumb>,
1021 - pub commits: Vec<git::CommitInfo>,
1022 - pub page: usize,
1023 - pub has_more: bool,
1024 - pub open_issue_count: i64,
1025 - pub is_owner: bool,
1026 - pub active_tab: &'static str,
1027 - }
1028 -
1029 - // ============================================================================
1030 - // Git Issues
1031 - // ============================================================================
1032 -
1033 - /// Issue list page with status tabs and search (read-only).
1034 - #[derive(Template)]
1035 - #[template(path = "pages/git/issues.html")]
1036 - pub struct GitIssueListTemplate {
1037 - pub csrf_token: CsrfTokenOption,
1038 - pub session_user: Option<SessionUser>,
1039 - pub owner: String,
1040 - pub repo_name: String,
1041 - pub current_ref: String,
1042 - pub issues: Vec<crate::db::DbIssueWithMeta>,
1043 - pub open_count: i64,
1044 - pub closed_count: i64,
1045 - pub current_status: String,
1046 - pub search_query: String,
1047 - pub current_page: i64,
1048 - pub total_pages: i64,
1049 - /// Whether the current viewer is the repo owner (for settings link).
1050 - pub is_owner: bool,
1051 - /// Email address for submitting new issues.
1052 - pub email_address: String,
1053 - }
1054 -
1055 - /// Issue detail page with comments (read-only).
1056 - #[derive(Template)]
1057 - #[template(path = "pages/git/issue.html")]
1058 - pub struct GitIssueDetailTemplate {
1059 - pub csrf_token: CsrfTokenOption,
1060 - pub session_user: Option<SessionUser>,
1061 - pub owner: String,
1062 - pub repo_name: String,
1063 - pub current_ref: String,
1064 - pub issue: crate::db::DbIssue,
1065 - pub author_username: String,
1066 - pub comments: Vec<crate::db::DbIssueCommentWithAuthor>,
1067 - pub is_owner: bool,
1068 - pub open_issue_count: i64,
1069 - /// Email address for submitting new issues.
1070 - pub email_address: String,
1071 - }
1072 -
1073 - /// Repository settings page (owner only).
1074 - #[derive(Template)]
1075 - #[template(path = "pages/git/settings.html")]
1076 - pub struct GitRepoSettingsTemplate {
1077 - pub csrf_token: CsrfTokenOption,
1078 - pub session_user: Option<SessionUser>,
1079 - pub owner: String,
1080 - pub repo_name: String,
1081 - pub current_ref: String,
1082 - pub repo: crate::db::DbGitRepo,
1083 - pub open_issue_count: i64,
1084 - /// Owner's projects for the link dropdown.
1085 - pub projects: Vec<crate::db::DbProject>,
1086 - /// ID of the currently linked project as a string (for dropdown comparison).
1087 - pub linked_project_id: String,
1088 - }