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
31 files changed, +4443 insertions, -2395 deletions
@@ -969,4 +969,156 @@ whois_check_interval_secs = 43200
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 }
@@ -3508,7 +3508,7 @@ dependencies = [
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
@@ -1,1501 +0,0 @@
1 - //! Item CRUD: creation, listing, text body updates, and ownership lookups.
2 -
3 - use sqlx::PgPool;
4 -
5 - use super::enums::{AiTier, ItemType};
6 - use super::models::*;
7 - use super::{ItemId, MtThreadId, PriceCents, ProjectId, UserId};
8 - use crate::error::Result;
9 -
10 - /// Insert a new item into a project and return the created row.
11 - ///
12 - /// Auto-generates a URL-safe slug from the title. If the slug collides with
13 - /// an existing item in the same project, appends a counter suffix.
14 - #[allow(clippy::too_many_arguments)]
15 - #[tracing::instrument(skip_all)]
16 - pub async fn create_item(
17 - pool: &PgPool,
18 - project_id: ProjectId,
19 - title: &str,
20 - description: Option<&str>,
21 - price_cents: PriceCents,
22 - item_type: ItemType,
23 - ai_tier: AiTier,
24 - ai_disclosure: Option<&str>,
25 - ) -> Result<DbItem> {
26 - let mut slug = crate::helpers::slugify(title);
27 -
28 - // Check for collision and append counter if needed
29 - if item_slug_exists(pool, project_id, &slug).await? {
30 - let base = slug.clone();
31 - let mut counter = 2u32;
32 - loop {
33 - slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
34 - if !item_slug_exists(pool, project_id, &slug).await? {
35 - break;
36 - }
37 - counter += 1;
38 - }
39 - }
40 -
41 - // Retry loop for TOCTOU race on slug uniqueness
42 - let base_slug = slug.clone();
43 - let mut suffix = 1u32;
44 - let item = loop {
45 - match sqlx::query_as::<_, DbItem>(
46 - r#"
47 - INSERT INTO items (project_id, title, description, price_cents, item_type, slug, ai_tier, ai_disclosure)
48 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
49 - RETURNING *
50 - "#,
51 - )
52 - .bind(project_id)
53 - .bind(title)
54 - .bind(description)
55 - .bind(price_cents)
56 - .bind(item_type)
57 - .bind(&slug)
58 - .bind(ai_tier)
59 - .bind(ai_disclosure)
60 - .fetch_one(pool)
61 - .await
62 - {
63 - Ok(item) => break item,
64 - Err(sqlx::Error::Database(db_err))
65 - if db_err.code().as_deref() == Some("23505") && suffix < 100 =>
66 - {
67 - suffix += 1;
68 - slug = super::validated_types::Slug::from_trusted(
69 - format!("{}-{}", base_slug, suffix),
70 - );
71 - continue;
72 - }
73 - Err(e) => return Err(e.into()),
74 - }
75 - };
76 -
77 - Ok(item)
78 - }
79 -
80 - /// Check whether a slug already exists for a given project.
81 - async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>(
82 - executor: E,
83 - project_id: ProjectId,
84 - slug: &super::validated_types::Slug,
85 - ) -> Result<bool> {
86 - let exists: bool = sqlx::query_scalar(
87 - "SELECT EXISTS(SELECT 1 FROM items WHERE project_id = $1 AND slug = $2)",
88 - )
89 - .bind(project_id)
90 - .bind(slug)
91 - .fetch_one(executor)
92 - .await?;
93 -
94 - Ok(exists)
95 - }
96 -
97 - /// Fetch an item by primary key. Returns `None` if not found.
98 - #[tracing::instrument(skip_all)]
99 - pub async fn get_item_by_id(pool: &PgPool, id: ItemId) -> Result<Option<DbItem>> {
100 - let item = sqlx::query_as::<_, DbItem>("SELECT * FROM items WHERE id = $1")
101 - .bind(id)
102 - .fetch_optional(pool)
103 - .await?;
104 -
105 - Ok(item)
106 - }
107 -
108 - /// Fetch titles for a batch of item IDs. Returns (item_id, title) pairs.
109 - #[tracing::instrument(skip_all)]
110 - pub async fn get_item_titles_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, String)>> {
111 - if ids.is_empty() {
112 - return Ok(vec![]);
113 - }
114 - let rows: Vec<(ItemId, String)> = sqlx::query_as(
115 - "SELECT id, title FROM items WHERE id = ANY($1)",
116 - )
117 - .bind(ids)
118 - .fetch_all(pool)
119 - .await?;
120 -
121 - Ok(rows)
122 - }
123 -
124 - /// Fetch project_id for a batch of item IDs. Returns (item_id, project_id) pairs.
125 - ///
126 - /// Used by bulk operations to verify all items belong to the same project in one query.
127 - #[tracing::instrument(skip_all)]
128 - pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, super::ProjectId)>> {
129 - if ids.is_empty() {
130 - return Ok(vec![]);
131 - }
132 - let rows: Vec<(ItemId, super::ProjectId)> = sqlx::query_as(
133 - "SELECT id, project_id FROM items WHERE id = ANY($1)",
134 - )
135 - .bind(ids)
136 - .fetch_all(pool)
137 - .await?;
138 -
139 - Ok(rows)
140 - }
141 -
142 - /// List all items in a project, ordered by sort_order then newest.
143 - ///
144 - /// Capped at 500 as a safety limit.
145 - #[tracing::instrument(skip_all)]
146 - pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> {
147 - let items = sqlx::query_as::<_, DbItem>(
148 - "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC LIMIT 500",
149 - )
150 - .bind(project_id)
151 - .fetch_all(pool)
152 - .await?;
153 -
154 - Ok(items)
155 - }
156 -
157 - /// List all items across all projects owned by a user, newest first.
158 - ///
159 - /// Capped at 500 as a safety limit.
160 - #[tracing::instrument(skip_all)]
161 - pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbItem>> {
162 - let items = sqlx::query_as::<_, DbItem>(
163 - r#"
164 - SELECT i.* FROM items i
165 - JOIN projects p ON i.project_id = p.id
166 - WHERE p.user_id = $1 AND i.deleted_at IS NULL
167 - ORDER BY i.created_at DESC
168 - LIMIT 500
169 - "#,
170 - )
171 - .bind(user_id)
172 - .fetch_all(pool)
173 - .await?;
174 -
175 - Ok(items)
176 - }
177 -
178 - /// Count items per project for all projects owned by a user.
179 - ///
180 - /// Returns `(project_id, count)` tuples. Used by the CLI to avoid N+1 queries.
181 - #[tracing::instrument(skip_all)]
182 - pub async fn count_items_by_user_projects(
183 - pool: &PgPool,
184 - user_id: UserId,
185 - ) -> Result<Vec<(ProjectId, i64)>> {
186 - let rows: Vec<(ProjectId, i64)> = sqlx::query_as(
187 - r#"
188 - SELECT i.project_id, COUNT(*) AS cnt
189 - FROM items i
190 - JOIN projects p ON i.project_id = p.id
191 - WHERE p.user_id = $1 AND i.deleted_at IS NULL
192 - GROUP BY i.project_id
193 - "#,
194 - )
195 - .bind(user_id)
196 - .fetch_all(pool)
197 - .await?;
198 -
199 - Ok(rows)
200 - }
201 -
202 - /// List only public items in a project, ordered by sort_order then newest.
203 - ///
204 - /// Capped at 500 as a safety limit.
205 - #[tracing::instrument(skip_all)]
206 - pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> {
207 - let items = sqlx::query_as::<_, DbItem>(
208 - "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC LIMIT 500",
209 - )
210 - .bind(project_id)
211 - .fetch_all(pool)
212 - .await?;
213 -
214 - Ok(items)
215 - }
216 -
217 - /// Partially update an item's fields (COALESCE keeps existing values when `None`).
218 - ///
219 - /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
220 - /// `Some(Some(dt))` = set schedule.
221 - #[allow(clippy::too_many_arguments)]
222 - #[tracing::instrument(skip_all)]
223 - pub async fn update_item(
224 - pool: &PgPool,
225 - id: ItemId,
226 - user_id: UserId,
227 - title: Option<&str>,
228 - description: Option<&str>,
229 - price_cents: Option<PriceCents>,
230 - item_type: Option<ItemType>,
231 - is_public: Option<bool>,
232 - pwyw_enabled: Option<bool>,
233 - pwyw_min_cents: Option<PriceCents>,
234 - publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
235 - web_only: Option<bool>,
236 - ai_tier: Option<AiTier>,
237 - ai_disclosure: Option<Option<&str>>,
238 - ) -> Result<DbItem> {
239 - // Flatten the double-Option: if outer is None, pass current DB value (via SQL CASE).
240 - // $10 = whether to update publish_at, $11 = the new value (NULL to clear).
241 - let update_publish_at = publish_at.is_some();
242 - let publish_at_value = publish_at.flatten();
243 -
244 - // ai_disclosure uses the same double-Option pattern as publish_at:
245 - // None = no change, Some(None) = clear, Some(Some(text)) = set.
246 - let update_ai_disclosure = ai_disclosure.is_some();
247 - let ai_disclosure_value = ai_disclosure.flatten();
248 -
249 - let item = sqlx::query_as::<_, DbItem>(
250 - r#"
251 - UPDATE items
252 - SET title = COALESCE($3, title),
253 - description = COALESCE($4, description),
254 - price_cents = COALESCE($5, price_cents),
255 - item_type = COALESCE($6, item_type),
256 - is_public = CASE WHEN removed_by_admin AND $7 = true THEN false ELSE COALESCE($7, is_public) END,
257 - pwyw_enabled = COALESCE($8, pwyw_enabled),
258 - pwyw_min_cents = COALESCE($9, pwyw_min_cents),
259 - publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END,
260 - web_only = COALESCE($12, web_only),
261 - ai_tier = COALESCE($13, ai_tier),
262 - ai_disclosure = CASE WHEN $14 THEN $15 ELSE ai_disclosure END
263 - WHERE id = $1
264 - AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
265 - RETURNING *
266 - "#,
267 - )
268 - .bind(id)
269 - .bind(user_id)
270 - .bind(title)
271 - .bind(description)
272 - .bind(price_cents)
273 - .bind(item_type)
274 - .bind(is_public)
275 - .bind(pwyw_enabled)
276 - .bind(pwyw_min_cents)
277 - .bind(update_publish_at)
278 - .bind(publish_at_value)
279 - .bind(web_only)
280 - .bind(ai_tier)
281 - .bind(update_ai_disclosure)
282 - .bind(ai_disclosure_value)
283 - .fetch_one(pool)
284 - .await?;
285 -
286 - Ok(item)
287 - }
288 -
289 - /// Publish all items whose scheduled publish time has passed.
290 - ///
291 - /// Atomically sets `is_public = true` and clears `publish_at`, returning the
292 - /// newly published items so the caller can send release announcements.
293 - #[tracing::instrument(skip_all)]
294 - pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> {
295 - let items = sqlx::query_as::<_, DbItem>(
296 - r#"
297 - UPDATE items
298 - SET is_public = true, publish_at = NULL, updated_at = NOW()
299 - WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false AND removed_by_admin = false
300 - RETURNING *
301 - "#,
302 - )
303 - .fetch_all(pool)
304 - .await?;
305 -
306 - Ok(items)
307 - }
308 -
309 - /// Soft-delete an item (sets deleted_at, recoverable for 7 days).
310 - #[tracing::instrument(skip_all)]
311 - pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> {
312 - sqlx::query(
313 - r#"
314 - UPDATE items SET deleted_at = NOW(), is_public = false
315 - WHERE id = $1 AND deleted_at IS NULL
316 - AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
317 - "#,
318 - )
319 - .bind(id)
320 - .bind(user_id)
321 - .execute(pool)
322 - .await?;
323 -
324 - Ok(())
325 - }
326 -
327 - /// Restore a soft-deleted item.
328 - #[tracing::instrument(skip_all)]
329 - pub async fn restore_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<bool> {
330 - let result = sqlx::query(
331 - r#"
332 - UPDATE items SET deleted_at = NULL
333 - WHERE id = $1 AND deleted_at IS NOT NULL
334 - AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
335 - "#,
336 - )
337 - .bind(id)
338 - .bind(user_id)
339 - .execute(pool)
340 - .await?;
341 -
342 - Ok(result.rows_affected() > 0)
343 - }
344 -
345 - /// Get soft-deleted items for a project (for the "Recently Deleted" section).
346 - #[tracing::instrument(skip_all)]
347 - pub async fn get_deleted_items_by_project(
348 - pool: &PgPool,
349 - project_id: ProjectId,
350 - ) -> Result<Vec<DbItem>> {
351 - let items = sqlx::query_as::<_, DbItem>(
352 - "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 500",
353 - )
354 - .bind(project_id)
355 - .fetch_all(pool)
356 - .await?;
357 -
358 - Ok(items)
359 - }
360 -
361 - /// Collect S3 keys from items that are about to be purged (soft-deleted >7 days).
362 - /// Returns all non-null S3 keys (audio, cover, video) so they can be deleted
363 - /// from S3 before the DB rows are removed.
364 - #[tracing::instrument(skip_all)]
365 - pub async fn get_expired_deleted_item_s3_keys(pool: &PgPool) -> Result<Vec<String>> {
366 - let keys: Vec<String> = sqlx::query_scalar(
367 - r#"
368 - SELECT k FROM (
369 - SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k
370 - FROM items
371 - WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days'
372 - AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL)
373 - ) sub
374 - "#,
375 - )
376 - .fetch_all(pool)
377 - .await?;
378 -
379 - Ok(keys)
380 - }
381 -
382 - /// Collect S3 keys from versions belonging to items about to be purged.
383 - /// Must be called before purge since CASCADE delete destroys version rows.
384 - #[tracing::instrument(skip_all)]
385 - pub async fn get_expired_deleted_item_version_s3_keys(pool: &PgPool) -> Result<Vec<String>> {
386 - let keys: Vec<String> = sqlx::query_scalar(
387 - r#"
388 - SELECT v.s3_key
389 - FROM versions v
390 - JOIN items i ON v.item_id = i.id
391 - WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days'
392 - AND v.s3_key IS NOT NULL
393 - "#,
394 - )
395 - .fetch_all(pool)
396 - .await?;
397 -
398 - Ok(keys)
399 - }
400 -
401 - /// Sum total file sizes per user for items about to be purged, including version files.
402 - /// Returns (user_id, total_bytes) pairs for storage decrement.
403 - #[tracing::instrument(skip_all)]
404 - pub async fn get_expired_deleted_item_storage_by_user(pool: &PgPool) -> Result<Vec<(super::UserId, i64)>> {
405 - let rows: Vec<(super::UserId, i64)> = sqlx::query_as(
406 - r#"
407 - SELECT p.user_id,
408 - COALESCE(SUM(
409 - COALESCE(i.audio_file_size_bytes, 0) +
410 - COALESCE(i.cover_file_size_bytes, 0) +
411 - COALESCE(i.video_file_size_bytes, 0) +
412 - COALESCE(ver.version_bytes, 0)
413 - ), 0)::BIGINT AS total_bytes
414 - FROM items i
415 - JOIN projects p ON i.project_id = p.id
416 - LEFT JOIN LATERAL (
417 - SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes
418 - FROM versions v
419 - WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL
420 - ) ver ON true
421 - WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days'
422 - GROUP BY p.user_id
423 - "#,
424 - )
425 - .fetch_all(pool)
426 - .await?;
427 -
428 - Ok(rows)
429 - }
430 -
431 - /// Collect all S3 keys from items belonging to a project (audio, cover, video).
432 - #[tracing::instrument(skip_all)]
433 - pub async fn get_project_item_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> {
434 - let keys: Vec<String> = sqlx::query_scalar(
435 - r#"
436 - SELECT k FROM (
437 - SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k
438 - FROM items
439 - WHERE project_id = $1
440 - AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL)
441 - ) sub
442 - "#,
443 - )
444 - .bind(project_id)
445 - .fetch_all(pool)
446 - .await?;
447 -
448 - Ok(keys)
449 - }
450 -
451 - /// Collect S3 keys from versions belonging to items in a project.
452 - #[tracing::instrument(skip_all)]
453 - pub async fn get_project_version_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> {
454 - let keys: Vec<String> = sqlx::query_scalar(
455 - r#"
456 - SELECT v.s3_key
457 - FROM versions v
458 - JOIN items i ON v.item_id = i.id
459 - WHERE i.project_id = $1 AND v.s3_key IS NOT NULL
460 - "#,
461 - )
462 - .bind(project_id)
463 - .fetch_all(pool)
464 - .await?;
465 -
466 - Ok(keys)
467 - }
468 -
469 - /// Sum total file sizes for all items and versions in a project.
470 - #[tracing::instrument(skip_all)]
471 - pub async fn get_project_storage_bytes(pool: &PgPool, project_id: super::ProjectId) -> Result<i64> {
472 - let total: i64 = sqlx::query_scalar(
473 - r#"
474 - SELECT COALESCE(SUM(
475 - COALESCE(i.audio_file_size_bytes, 0) +
476 - COALESCE(i.cover_file_size_bytes, 0) +
477 - COALESCE(i.video_file_size_bytes, 0) +
478 - COALESCE(ver.version_bytes, 0)
479 - ), 0)::BIGINT
480 - FROM items i
481 - LEFT JOIN LATERAL (
482 - SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes
483 - FROM versions v
484 - WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL
485 - ) ver ON true
486 - WHERE i.project_id = $1
487 - "#,
488 - )
489 - .bind(project_id)
490 - .fetch_one(pool)
491 - .await?;
492 -
493 - Ok(total)
494 - }
495 -
496 - /// Permanently delete items that were soft-deleted more than 7 days ago.
497 - #[tracing::instrument(skip_all)]
498 - pub async fn purge_expired_deleted_items(pool: &PgPool) -> Result<u64> {
499 - let result = sqlx::query(
500 - "DELETE FROM items WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days'",
Lines truncated
@@ -0,0 +1,311 @@
1 + //! Bulk and structural item operations: cross-project moves, batch
2 + //! publish/unpublish/delete/repricing/tagging, and item duplication.
3 +
4 + use sqlx::PgPool;
5 +
6 + use crate::db::models::*;
7 + use crate::db::{ItemId, PriceCents, ProjectId, UserId};
8 + use crate::error::Result;
9 +
10 + pub async fn move_item(
11 + pool: &PgPool,
12 + project_id: ProjectId,
13 + user_id: UserId,
14 + item_id: ItemId,
15 + direction: &str,
16 + ) -> Result<()> {
17 + let mut tx = pool.begin().await?;
18 +
19 + // Lock and fetch item IDs in display order (scoped to projects owned by user)
20 + let item_ids: Vec<ItemId> = sqlx::query_scalar(
21 + r#"
22 + SELECT id FROM items
23 + WHERE project_id = $1
24 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
25 + ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE
26 + "#,
27 + )
28 + .bind(project_id)
29 + .bind(user_id)
30 + .fetch_all(&mut *tx)
31 + .await?;
32 +
33 + let Some(pos) = item_ids.iter().position(|id| *id == item_id) else {
34 + return Ok(());
35 + };
36 +
37 + let swap_pos = match direction {
38 + "up" if pos > 0 => pos - 1,
39 + "down" if pos + 1 < item_ids.len() => pos + 1,
40 + _ => return Ok(()),
41 + };
42 +
43 + // Normalize all sort_orders, swapping the target pair (single batch UPDATE)
44 + let mut ids = Vec::with_capacity(item_ids.len());
45 + let mut orders = Vec::with_capacity(item_ids.len());
46 + for (i, id) in item_ids.iter().enumerate() {
47 + ids.push(*id);
48 + orders.push(if i == pos {
49 + swap_pos as i32
50 + } else if i == swap_pos {
51 + pos as i32
52 + } else {
53 + i as i32
54 + });
55 + }
56 + sqlx::query(
57 + "UPDATE items SET sort_order = batch.ord FROM UNNEST($1::UUID[], $2::INT[]) AS batch(id, ord) WHERE items.id = batch.id",
58 + )
59 + .bind(&ids)
60 + .bind(&orders)
61 + .execute(&mut *tx)
62 + .await?;
63 +
64 + tx.commit().await?;
65 + Ok(())
66 + }
67 +
68 + /// Bulk-publish items: set `is_public = true` and clear any scheduled `publish_at`.
69 + ///
70 + /// Only affects items matching both the given IDs and project. Returns rows affected.
71 + #[tracing::instrument(skip_all)]
72 + pub async fn bulk_publish(
73 + pool: &PgPool,
74 + item_ids: &[ItemId],
75 + project_id: ProjectId,
76 + user_id: UserId,
77 + ) -> Result<u64> {
78 + let result = sqlx::query(
79 + r#"
80 + UPDATE items
81 + SET is_public = true, publish_at = NULL, updated_at = NOW()
82 + WHERE id = ANY($1) AND project_id = $2
83 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
84 + AND removed_by_admin = false
85 + "#,
86 + )
87 + .bind(item_ids)
88 + .bind(project_id)
89 + .bind(user_id)
90 + .execute(pool)
91 + .await?;
92 +
93 + Ok(result.rows_affected())
94 + }
95 +
96 + /// Bulk-unpublish items: set `is_public = false`.
97 + ///
98 + /// Only affects items matching both the given IDs and project. Returns rows affected.
99 + #[tracing::instrument(skip_all)]
100 + pub async fn bulk_unpublish(
101 + pool: &PgPool,
102 + item_ids: &[ItemId],
103 + project_id: ProjectId,
104 + user_id: UserId,
105 + ) -> Result<u64> {
106 + let result = sqlx::query(
107 + r#"
108 + UPDATE items
109 + SET is_public = false, updated_at = NOW()
110 + WHERE id = ANY($1) AND project_id = $2
111 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
112 + "#,
113 + )
114 + .bind(item_ids)
115 + .bind(project_id)
116 + .bind(user_id)
117 + .execute(pool)
118 + .await?;
119 +
120 + Ok(result.rows_affected())
121 + }
122 +
123 + /// Soft-delete items from a project (sets deleted_at, recoverable for 7 days).
124 + ///
125 + /// Only affects items matching both the given IDs and project. Returns rows affected.
126 + #[tracing::instrument(skip_all)]
127 + pub async fn bulk_delete(
128 + pool: &PgPool,
129 + item_ids: &[ItemId],
130 + project_id: ProjectId,
131 + user_id: UserId,
132 + ) -> Result<u64> {
133 + let result = sqlx::query(
134 + r#"
135 + UPDATE items SET deleted_at = NOW(), is_public = false
136 + WHERE id = ANY($1) AND project_id = $2 AND deleted_at IS NULL
137 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
138 + "#,
139 + )
140 + .bind(item_ids)
141 + .bind(project_id)
142 + .bind(user_id)
143 + .execute(pool)
144 + .await?;
145 +
146 + Ok(result.rows_affected())
147 + }
148 +
149 + /// Bulk-update price on selected items.
150 + ///
151 + /// Only affects items matching both the given IDs and project. Returns rows affected.
152 + #[tracing::instrument(skip_all)]
153 + pub async fn bulk_update_price(
154 + pool: &PgPool,
155 + item_ids: &[ItemId],
156 + project_id: ProjectId,
157 + user_id: UserId,
158 + price_cents: PriceCents,
159 + ) -> Result<u64> {
160 + let result = sqlx::query(
161 + r#"
162 + UPDATE items SET price_cents = $4
163 + WHERE id = ANY($1) AND project_id = $2
164 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
165 + "#,
166 + )
167 + .bind(item_ids)
168 + .bind(project_id)
169 + .bind(user_id)
170 + .bind(price_cents)
171 + .execute(pool)
172 + .await?;
173 +
174 + Ok(result.rows_affected())
175 + }
176 +
177 + /// Bulk-add a tag to selected items (skips duplicates via ON CONFLICT).
178 + ///
179 + /// Returns number of new tag associations created.
180 + #[tracing::instrument(skip_all)]
181 + pub async fn bulk_add_tag(
182 + pool: &PgPool,
183 + item_ids: &[ItemId],
184 + project_id: ProjectId,
185 + user_id: UserId,
186 + tag_id: crate::db::TagId,
187 + ) -> Result<u64> {
188 + // Verify all items belong to the project owned by this user,
189 + // then insert tag associations for each.
190 + let result = sqlx::query(
191 + r#"
192 + INSERT INTO item_tags (item_id, tag_id)
193 + SELECT i.id, $4
194 + FROM items i
195 + JOIN projects p ON i.project_id = p.id
196 + WHERE i.id = ANY($1) AND i.project_id = $2 AND p.user_id = $3
197 + ON CONFLICT (item_id, tag_id) DO NOTHING
198 + "#,
199 + )
200 + .bind(item_ids)
201 + .bind(project_id)
202 + .bind(user_id)
203 + .bind(tag_id)
204 + .execute(pool)
205 + .await?;
206 +
207 + Ok(result.rows_affected())
208 + }
209 +
210 + /// Duplicate an item and its metadata (tags, chapters, content insertion placements).
211 + ///
212 + /// Creates a draft copy with "Copy of …" title. Does not copy versions (S3 files),
213 + /// license keys, download codes, or discount codes.
214 + #[tracing::instrument(skip_all)]
215 + pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) -> Result<DbItem> {
216 + let mut tx = pool.begin().await?;
217 +
218 + // Generate a unique slug for the copy (verify ownership via project)
219 + let source = sqlx::query_as::<_, DbItem>(
220 + "SELECT * FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
221 + )
222 + .bind(source_id)
223 + .bind(user_id)
224 + .fetch_one(&mut *tx)
225 + .await?;
226 + let copy_title = format!("Copy of {}", &source.title);
227 + let copy_title: String = copy_title.chars().take(200).collect();
228 + let mut slug = crate::helpers::slugify(&copy_title);
229 + if super::item_slug_exists(&mut *tx, source.project_id, &slug).await? {
230 + let base = slug.clone();
231 + let mut counter = 2u32;
232 + loop {
233 + if counter > 100 {
234 + return Err(crate::error::AppError::BadRequest(
235 + "Too many copies with similar names. Rename an existing copy first.".to_string(),
236 + ));
237 + }
238 + slug = crate::db::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
239 + if !super::item_slug_exists(&mut *tx, source.project_id, &slug).await? {
240 + break;
241 + }
242 + counter += 1;
243 + }
244 + }
245 +
246 + // Step 1: Clone item row
247 + let new_item = sqlx::query_as::<_, DbItem>(
248 + r#"
249 + INSERT INTO items (
250 + project_id, title, description, price_cents, item_type, thumbnail_url,
251 + sort_order, body, word_count, reading_time_minutes, duration_seconds,
252 + episode_number, enable_license_keys, default_max_activations,
253 + pwyw_enabled, pwyw_min_cents, is_public, slug
254 + )
255 + SELECT
256 + project_id, LEFT('Copy of ' || title, 200), description, price_cents,
257 + item_type, thumbnail_url, sort_order, body, word_count,
258 + reading_time_minutes, duration_seconds, episode_number,
259 + enable_license_keys, default_max_activations, pwyw_enabled,
260 + pwyw_min_cents, false, $2
261 + FROM items WHERE id = $1
262 + RETURNING *
263 + "#,
264 + )
265 + .bind(source_id)
266 + .bind(&slug)
267 + .fetch_one(&mut *tx)
268 + .await?;
269 +
270 + // Step 2: Copy tags
271 + sqlx::query(
272 + r#"
273 + INSERT INTO item_tags (item_id, tag_id, is_primary)
274 + SELECT $2, tag_id, is_primary FROM item_tags WHERE item_id = $1
275 + "#,
276 + )
277 + .bind(source_id)
278 + .bind(new_item.id)
279 + .execute(&mut *tx)
280 + .await?;
281 +
282 + // Step 3: Copy chapters
283 + sqlx::query(
284 + r#"
285 + INSERT INTO chapters (item_id, title, start_seconds, sort_order)
286 + SELECT $2, title, start_seconds, sort_order FROM chapters WHERE item_id = $1
287 + "#,
288 + )
289 + .bind(source_id)
290 + .bind(new_item.id)
291 + .execute(&mut *tx)
292 + .await?;
293 +
294 + // Step 4: Copy content insertion placements
295 + sqlx::query(
296 + r#"
297 + INSERT INTO content_insertion_placements (item_id, insertion_id, position, offset_ms, sort_order)
298 + SELECT $2, insertion_id, position, offset_ms, sort_order
299 + FROM content_insertion_placements WHERE item_id = $1
300 + "#,
301 + )
302 + .bind(source_id)
303 + .bind(new_item.id)
304 + .execute(&mut *tx)
305 + .await?;
306 +
307 + tx.commit().await?;
308 +
309 + Ok(new_item)
310 + }
311 +
@@ -0,0 +1,198 @@
1 + //! Item media metadata: post-upload file-size writebacks and per-content-type
2 + //! S3 key/URL/metadata updates (audio, cover, video).
3 +
4 + use sqlx::PgPool;
5 +
6 + use crate::db::models::*;
7 + use crate::db::{ItemId, UserId};
8 + use crate::error::Result;
9 +
10 + /// Get the audio, cover, and video file sizes for an item (for storage decrement on delete).
11 + #[tracing::instrument(skip_all)]
12 + pub async fn get_item_file_sizes(
13 + pool: &PgPool,
14 + id: ItemId,
15 + ) -> Result<crate::db::models::ItemFileSizes> {
16 + let row = sqlx::query_as::<_, (Option<i64>, Option<i64>, Option<i64>)>(
17 + "SELECT audio_file_size_bytes, cover_file_size_bytes, video_file_size_bytes FROM items WHERE id = $1",
18 + )
19 + .bind(id)
20 + .fetch_optional(pool)
21 + .await?;
22 +
23 + match row {
24 + Some((audio, cover, video)) => Ok(crate::db::models::ItemFileSizes {
25 + audio_file_size_bytes: audio,
26 + cover_file_size_bytes: cover,
27 + video_file_size_bytes: video,
28 + }),
29 + None => Ok(crate::db::models::ItemFileSizes {
30 + audio_file_size_bytes: None,
31 + cover_file_size_bytes: None,
32 + video_file_size_bytes: None,
33 + }),
34 + }
35 + }
36 +
37 + /// Update the audio file size on an item (defense-in-depth: verifies ownership).
38 + #[tracing::instrument(skip_all)]
39 + pub async fn update_item_audio_file_size(
40 + pool: &PgPool,
41 + item_id: ItemId,
42 + user_id: UserId,
43 + bytes: i64,
44 + ) -> Result<()> {
45 + sqlx::query(
46 + "UPDATE items SET audio_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
47 + )
48 + .bind(item_id)
49 + .bind(bytes)
50 + .bind(user_id)
51 + .execute(pool)
52 + .await?;
53 +
54 + Ok(())
55 + }
56 +
57 + /// Update the cover image URL for an item (defense-in-depth: verifies ownership).
58 + #[tracing::instrument(skip_all)]
59 + pub async fn update_item_cover_image_url(
60 + pool: &PgPool,
61 + item_id: ItemId,
62 + user_id: UserId,
63 + url: &str,
64 + ) -> Result<()> {
65 + sqlx::query(
66 + "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)",
67 + )
68 + .bind(item_id)
69 + .bind(url)
70 + .bind(user_id)
71 + .execute(pool)
72 + .await?;
73 +
74 + Ok(())
75 + }
76 +
77 + /// Atomically update cover image URL, S3 key, and file size in a single UPDATE
78 + /// (defense-in-depth: verifies ownership).
79 + #[tracing::instrument(skip_all)]
80 + pub async fn update_item_cover(
81 + pool: &PgPool,
82 + item_id: ItemId,
83 + user_id: UserId,
84 + url: &str,
85 + s3_key: &str,
86 + file_size_bytes: i64,
87 + ) -> Result<()> {
88 + sqlx::query(
89 + r#"UPDATE items
90 + SET cover_image_url = $2, cover_s3_key = $3, cover_file_size_bytes = $4, updated_at = NOW()
91 + WHERE id = $1
92 + AND project_id IN (SELECT id FROM projects WHERE user_id = $5)"#,
93 + )
94 + .bind(item_id)
95 + .bind(url)
96 + .bind(s3_key)
97 + .bind(file_size_bytes)
98 + .bind(user_id)
99 + .execute(pool)
100 + .await?;
101 +
102 + Ok(())
103 + }
104 +
105 + /// Update the cover file size on an item (defense-in-depth: verifies ownership).
106 + #[tracing::instrument(skip_all)]
107 + pub async fn update_item_cover_file_size(
108 + pool: &PgPool,
109 + item_id: ItemId,
110 + user_id: UserId,
111 + bytes: i64,
112 + ) -> Result<()> {
113 + sqlx::query(
114 + "UPDATE items SET cover_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
115 + )
116 + .bind(item_id)
117 + .bind(bytes)
118 + .bind(user_id)
119 + .execute(pool)
120 + .await?;
121 +
122 + Ok(())
123 + }
124 +
125 + /// Update the video S3 key for an item (defense-in-depth: verifies ownership).
126 + #[tracing::instrument(skip_all)]
127 + pub async fn update_item_video_s3_key(
128 + pool: &PgPool,
129 + item_id: ItemId,
130 + user_id: UserId,
131 + s3_key: &str,
132 + ) -> Result<DbItem> {
133 + let item = sqlx::query_as::<_, DbItem>(
134 + r#"
135 + UPDATE items
136 + SET video_s3_key = $2, updated_at = NOW()
137 + WHERE id = $1
138 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
139 + RETURNING *
140 + "#,
141 + )
142 + .bind(item_id)
143 + .bind(s3_key)
144 + .bind(user_id)
145 + .fetch_one(pool)
146 + .await?;
147 +
148 + Ok(item)
149 + }
150 +
151 + /// Update the video file size on an item (defense-in-depth: verifies ownership).
152 + #[tracing::instrument(skip_all)]
153 + pub async fn update_item_video_file_size(
154 + pool: &PgPool,
155 + item_id: ItemId,
156 + user_id: UserId,
157 + bytes: i64,
158 + ) -> Result<()> {
159 + sqlx::query(
160 + "UPDATE items SET video_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
161 + )
162 + .bind(item_id)
163 + .bind(bytes)
164 + .bind(user_id)
165 + .execute(pool)
166 + .await?;
167 +
168 + Ok(())
169 + }
170 +
171 + /// Update video metadata (duration, resolution) on an item (defense-in-depth: verifies ownership).
172 + #[tracing::instrument(skip_all)]
173 + pub async fn update_item_video_metadata(
174 + pool: &PgPool,
175 + item_id: ItemId,
176 + user_id: UserId,
177 + duration_seconds: Option<i32>,
178 + width: Option<i32>,
179 + height: Option<i32>,
180 + ) -> Result<()> {
181 + sqlx::query(
182 + r#"
183 + UPDATE items
184 + SET video_duration_seconds = $2, video_width = $3, video_height = $4, updated_at = NOW()
185 + WHERE id = $1
186 + AND project_id IN (SELECT id FROM projects WHERE user_id = $5)
187 + "#,
188 + )
189 + .bind(item_id)
190 + .bind(duration_seconds)
191 + .bind(width)
192 + .bind(height)
193 + .bind(user_id)
194 + .execute(pool)
195 + .await?;
196 +
197 + Ok(())
198 + }
@@ -0,0 +1,1014 @@
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::*;
12 +
13 + use sqlx::PgPool;
14 +
15 + use super::enums::{AiTier, ItemType};
16 + use super::models::*;
17 + use super::{ItemId, MtThreadId, PriceCents, ProjectId, UserId};
18 + use crate::error::Result;
19 +
20 + /// Insert a new item into a project and return the created row.
21 + ///
22 + /// Auto-generates a URL-safe slug from the title. If the slug collides with
23 + /// an existing item in the same project, appends a counter suffix.
24 + #[allow(clippy::too_many_arguments)]
25 + #[tracing::instrument(skip_all)]
26 + pub async fn create_item(
27 + pool: &PgPool,
28 + project_id: ProjectId,
29 + title: &str,
30 + description: Option<&str>,
31 + price_cents: PriceCents,
32 + item_type: ItemType,
33 + ai_tier: AiTier,
34 + ai_disclosure: Option<&str>,
35 + ) -> Result<DbItem> {
36 + let mut slug = crate::helpers::slugify(title);
37 +
38 + // Check for collision and append counter if needed
39 + if item_slug_exists(pool, project_id, &slug).await? {
40 + let base = slug.clone();
41 + let mut counter = 2u32;
42 + loop {
43 + slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
44 + if !item_slug_exists(pool, project_id, &slug).await? {
45 + break;
46 + }
47 + counter += 1;
48 + }
49 + }
50 +
51 + // Retry loop for TOCTOU race on slug uniqueness
52 + let base_slug = slug.clone();
53 + let mut suffix = 1u32;
54 + let item = loop {
55 + match sqlx::query_as::<_, DbItem>(
56 + r#"
57 + INSERT INTO items (project_id, title, description, price_cents, item_type, slug, ai_tier, ai_disclosure)
58 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
59 + RETURNING *
60 + "#,
61 + )
62 + .bind(project_id)
63 + .bind(title)
64 + .bind(description)
65 + .bind(price_cents)
66 + .bind(item_type)
67 + .bind(&slug)
68 + .bind(ai_tier)
69 + .bind(ai_disclosure)
70 + .fetch_one(pool)
71 + .await
72 + {
73 + Ok(item) => break item,
74 + Err(sqlx::Error::Database(db_err))
75 + if db_err.code().as_deref() == Some("23505") && suffix < 100 =>
76 + {
77 + suffix += 1;
78 + slug = super::validated_types::Slug::from_trusted(
79 + format!("{}-{}", base_slug, suffix),
80 + );
81 + continue;
82 + }
83 + Err(e) => return Err(e.into()),
84 + }
85 + };
86 +
87 + Ok(item)
88 + }
89 +
90 + /// Check whether a slug already exists for a given project.
91 + pub(super) async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>(
92 + executor: E,
93 + project_id: ProjectId,
94 + slug: &super::validated_types::Slug,
95 + ) -> Result<bool> {
96 + let exists: bool = sqlx::query_scalar(
97 + "SELECT EXISTS(SELECT 1 FROM items WHERE project_id = $1 AND slug = $2)",
98 + )
99 + .bind(project_id)
100 + .bind(slug)
101 + .fetch_one(executor)
102 + .await?;
103 +
104 + Ok(exists)
105 + }
106 +
107 + /// Fetch an item by primary key. Returns `None` if not found.
108 + #[tracing::instrument(skip_all)]
109 + pub async fn get_item_by_id(pool: &PgPool, id: ItemId) -> Result<Option<DbItem>> {
110 + let item = sqlx::query_as::<_, DbItem>("SELECT * FROM items WHERE id = $1")
111 + .bind(id)
112 + .fetch_optional(pool)
113 + .await?;
114 +
115 + Ok(item)
116 + }
117 +
118 + /// Fetch titles for a batch of item IDs. Returns (item_id, title) pairs.
119 + #[tracing::instrument(skip_all)]
120 + pub async fn get_item_titles_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, String)>> {
121 + if ids.is_empty() {
122 + return Ok(vec![]);
123 + }
124 + let rows: Vec<(ItemId, String)> = sqlx::query_as(
125 + "SELECT id, title FROM items WHERE id = ANY($1)",
126 + )
127 + .bind(ids)
128 + .fetch_all(pool)
129 + .await?;
130 +
131 + Ok(rows)
132 + }
133 +
134 + /// Fetch project_id for a batch of item IDs. Returns (item_id, project_id) pairs.
135 + ///
136 + /// Used by bulk operations to verify all items belong to the same project in one query.
137 + #[tracing::instrument(skip_all)]
138 + pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, super::ProjectId)>> {
139 + if ids.is_empty() {
140 + return Ok(vec![]);
141 + }
142 + let rows: Vec<(ItemId, super::ProjectId)> = sqlx::query_as(
143 + "SELECT id, project_id FROM items WHERE id = ANY($1)",
144 + )
145 + .bind(ids)
146 + .fetch_all(pool)
147 + .await?;
148 +
149 + Ok(rows)
150 + }
151 +
152 + /// List all items in a project, ordered by sort_order then newest.
153 + ///
154 + /// Capped at 500 as a safety limit.
155 + #[tracing::instrument(skip_all)]
156 + pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> {
157 + let items = sqlx::query_as::<_, DbItem>(
158 + "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC LIMIT 500",
159 + )
160 + .bind(project_id)
161 + .fetch_all(pool)
162 + .await?;
163 +
164 + Ok(items)
165 + }
166 +
167 + /// List all items across all projects owned by a user, newest first.
168 + ///
169 + /// Capped at 500 as a safety limit.
170 + #[tracing::instrument(skip_all)]
171 + pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbItem>> {
172 + let items = sqlx::query_as::<_, DbItem>(
173 + r#"
174 + SELECT i.* FROM items i
175 + JOIN projects p ON i.project_id = p.id
176 + WHERE p.user_id = $1 AND i.deleted_at IS NULL
177 + ORDER BY i.created_at DESC
178 + LIMIT 500
179 + "#,
180 + )
181 + .bind(user_id)
182 + .fetch_all(pool)
183 + .await?;
184 +
185 + Ok(items)
186 + }
187 +
188 + /// Count items per project for all projects owned by a user.
189 + ///
190 + /// Returns `(project_id, count)` tuples. Used by the CLI to avoid N+1 queries.
191 + #[tracing::instrument(skip_all)]
192 + pub async fn count_items_by_user_projects(
193 + pool: &PgPool,
194 + user_id: UserId,
195 + ) -> Result<Vec<(ProjectId, i64)>> {
196 + let rows: Vec<(ProjectId, i64)> = sqlx::query_as(
197 + r#"
198 + SELECT i.project_id, COUNT(*) AS cnt
199 + FROM items i
200 + JOIN projects p ON i.project_id = p.id
201 + WHERE p.user_id = $1 AND i.deleted_at IS NULL
202 + GROUP BY i.project_id
203 + "#,
204 + )
205 + .bind(user_id)
206 + .fetch_all(pool)
207 + .await?;
208 +
209 + Ok(rows)
210 + }
211 +
212 + /// List only public items in a project, ordered by sort_order then newest.
213 + ///
214 + /// Capped at 500 as a safety limit.
215 + #[tracing::instrument(skip_all)]
216 + pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> {
217 + let items = sqlx::query_as::<_, DbItem>(
218 + "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC LIMIT 500",
219 + )
220 + .bind(project_id)
221 + .fetch_all(pool)
222 + .await?;
223 +
224 + Ok(items)
225 + }
226 +
227 + /// Partially update an item's fields (COALESCE keeps existing values when `None`).
228 + ///
229 + /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
230 + /// `Some(Some(dt))` = set schedule.
231 + #[allow(clippy::too_many_arguments)]
232 + #[tracing::instrument(skip_all)]
233 + pub async fn update_item(
234 + pool: &PgPool,
235 + id: ItemId,
236 + user_id: UserId,
237 + title: Option<&str>,
238 + description: Option<&str>,
239 + price_cents: Option<PriceCents>,
240 + item_type: Option<ItemType>,
241 + is_public: Option<bool>,
242 + pwyw_enabled: Option<bool>,
243 + pwyw_min_cents: Option<PriceCents>,
244 + publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
245 + web_only: Option<bool>,
246 + ai_tier: Option<AiTier>,
247 + ai_disclosure: Option<Option<&str>>,
248 + ) -> Result<DbItem> {
249 + // Flatten the double-Option: if outer is None, pass current DB value (via SQL CASE).
250 + // $10 = whether to update publish_at, $11 = the new value (NULL to clear).
251 + let update_publish_at = publish_at.is_some();
252 + let publish_at_value = publish_at.flatten();
253 +
254 + // ai_disclosure uses the same double-Option pattern as publish_at:
255 + // None = no change, Some(None) = clear, Some(Some(text)) = set.
256 + let update_ai_disclosure = ai_disclosure.is_some();
257 + let ai_disclosure_value = ai_disclosure.flatten();
258 +
259 + let item = sqlx::query_as::<_, DbItem>(
260 + r#"
261 + UPDATE items
262 + SET title = COALESCE($3, title),
263 + description = COALESCE($4, description),
264 + price_cents = COALESCE($5, price_cents),
265 + item_type = COALESCE($6, item_type),
266 + is_public = CASE WHEN removed_by_admin AND $7 = true THEN false ELSE COALESCE($7, is_public) END,
267 + pwyw_enabled = COALESCE($8, pwyw_enabled),
268 + pwyw_min_cents = COALESCE($9, pwyw_min_cents),
269 + publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END,
270 + web_only = COALESCE($12, web_only),
271 + ai_tier = COALESCE($13, ai_tier),
272 + ai_disclosure = CASE WHEN $14 THEN $15 ELSE ai_disclosure END
273 + WHERE id = $1
274 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
275 + RETURNING *
276 + "#,
277 + )
278 + .bind(id)
279 + .bind(user_id)
280 + .bind(title)
281 + .bind(description)
282 + .bind(price_cents)
283 + .bind(item_type)
284 + .bind(is_public)
285 + .bind(pwyw_enabled)
286 + .bind(pwyw_min_cents)
287 + .bind(update_publish_at)
288 + .bind(publish_at_value)
289 + .bind(web_only)
290 + .bind(ai_tier)
291 + .bind(update_ai_disclosure)
292 + .bind(ai_disclosure_value)
293 + .fetch_one(pool)
294 + .await?;
295 +
296 + Ok(item)
297 + }
298 +
299 + /// Publish all items whose scheduled publish time has passed.
300 + ///
301 + /// Atomically sets `is_public = true` and clears `publish_at`, returning the
302 + /// newly published items so the caller can send release announcements.
303 + #[tracing::instrument(skip_all)]
304 + pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> {
305 + let items = sqlx::query_as::<_, DbItem>(
306 + r#"
307 + UPDATE items
308 + SET is_public = true, publish_at = NULL, updated_at = NOW()
309 + WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false AND removed_by_admin = false
310 + RETURNING *
311 + "#,
312 + )
313 + .fetch_all(pool)
314 + .await?;
315 +
316 + Ok(items)
317 + }
318 +
319 + /// Soft-delete an item (sets deleted_at, recoverable for 7 days).
320 + #[tracing::instrument(skip_all)]
321 + pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> {
322 + sqlx::query(
323 + r#"
324 + UPDATE items SET deleted_at = NOW(), is_public = false
325 + WHERE id = $1 AND deleted_at IS NULL
326 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
327 + "#,
328 + )
329 + .bind(id)
330 + .bind(user_id)
331 + .execute(pool)
332 + .await?;
333 +
334 + Ok(())
335 + }
336 +
337 + /// Restore a soft-deleted item.
338 + #[tracing::instrument(skip_all)]
339 + pub async fn restore_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<bool> {
340 + let result = sqlx::query(
341 + r#"
342 + UPDATE items SET deleted_at = NULL
343 + WHERE id = $1 AND deleted_at IS NOT NULL
344 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
345 + "#,
346 + )
347 + .bind(id)
348 + .bind(user_id)
349 + .execute(pool)
350 + .await?;
351 +
352 + Ok(result.rows_affected() > 0)
353 + }
354 +
355 + /// Get soft-deleted items for a project (for the "Recently Deleted" section).
356 + #[tracing::instrument(skip_all)]
357 + pub async fn get_deleted_items_by_project(
358 + pool: &PgPool,
359 + project_id: ProjectId,
360 + ) -> Result<Vec<DbItem>> {
361 + let items = sqlx::query_as::<_, DbItem>(
362 + "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 500",
363 + )
364 + .bind(project_id)
365 + .fetch_all(pool)
366 + .await?;
367 +
368 + Ok(items)
369 + }
370 +
371 + /// Collect S3 keys from items that are about to be purged (soft-deleted >7 days).
372 + /// Returns all non-null S3 keys (audio, cover, video) so they can be deleted
373 + /// from S3 before the DB rows are removed.
374 + #[tracing::instrument(skip_all)]
375 + pub async fn get_expired_deleted_item_s3_keys(pool: &PgPool) -> Result<Vec<String>> {
376 + let keys: Vec<String> = sqlx::query_scalar(
377 + r#"
378 + SELECT k FROM (
379 + SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k
380 + FROM items
381 + WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days'
382 + AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL)
383 + ) sub
384 + "#,
385 + )
386 + .fetch_all(pool)
387 + .await?;
388 +
389 + Ok(keys)
390 + }
391 +
392 + /// Collect S3 keys from versions belonging to items about to be purged.
393 + /// Must be called before purge since CASCADE delete destroys version rows.
394 + #[tracing::instrument(skip_all)]
395 + pub async fn get_expired_deleted_item_version_s3_keys(pool: &PgPool) -> Result<Vec<String>> {
396 + let keys: Vec<String> = sqlx::query_scalar(
397 + r#"
398 + SELECT v.s3_key
399 + FROM versions v
400 + JOIN items i ON v.item_id = i.id
401 + WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days'
402 + AND v.s3_key IS NOT NULL
403 + "#,
404 + )
405 + .fetch_all(pool)
406 + .await?;
407 +
408 + Ok(keys)
409 + }
410 +
411 + /// Sum total file sizes per user for items about to be purged, including version files.
412 + /// Returns (user_id, total_bytes) pairs for storage decrement.
413 + #[tracing::instrument(skip_all)]
414 + pub async fn get_expired_deleted_item_storage_by_user(pool: &PgPool) -> Result<Vec<(super::UserId, i64)>> {
415 + let rows: Vec<(super::UserId, i64)> = sqlx::query_as(
416 + r#"
417 + SELECT p.user_id,
418 + COALESCE(SUM(
419 + COALESCE(i.audio_file_size_bytes, 0) +
420 + COALESCE(i.cover_file_size_bytes, 0) +
421 + COALESCE(i.video_file_size_bytes, 0) +
422 + COALESCE(ver.version_bytes, 0)
423 + ), 0)::BIGINT AS total_bytes
424 + FROM items i
425 + JOIN projects p ON i.project_id = p.id
426 + LEFT JOIN LATERAL (
427 + SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes
428 + FROM versions v
429 + WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL
430 + ) ver ON true
431 + WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days'
432 + GROUP BY p.user_id
433 + "#,
434 + )
435 + .fetch_all(pool)
436 + .await?;
437 +
438 + Ok(rows)
439 + }
440 +
441 + /// Collect all S3 keys from items belonging to a project (audio, cover, video).
442 + #[tracing::instrument(skip_all)]
443 + pub async fn get_project_item_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> {
444 + let keys: Vec<String> = sqlx::query_scalar(
445 + r#"
446 + SELECT k FROM (
447 + SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k
448 + FROM items
449 + WHERE project_id = $1
450 + AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL)
451 + ) sub
452 + "#,
453 + )
454 + .bind(project_id)
455 + .fetch_all(pool)
456 + .await?;
457 +
458 + Ok(keys)
459 + }
460 +
461 + /// Collect S3 keys from versions belonging to items in a project.
462 + #[tracing::instrument(skip_all)]
463 + pub async fn get_project_version_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> {
464 + let keys: Vec<String> = sqlx::query_scalar(
465 + r#"
466 + SELECT v.s3_key
467 + FROM versions v
468 + JOIN items i ON v.item_id = i.id
469 + WHERE i.project_id = $1 AND v.s3_key IS NOT NULL
470 + "#,
471 + )
472 + .bind(project_id)
473 + .fetch_all(pool)
474 + .await?;
475 +
476 + Ok(keys)
477 + }
478 +
479 + /// Sum total file sizes for all items and versions in a project.
480 + #[tracing::instrument(skip_all)]
481 + pub async fn get_project_storage_bytes(pool: &PgPool, project_id: super::ProjectId) -> Result<i64> {
482 + let total: i64 = sqlx::query_scalar(
483 + r#"
484 + SELECT COALESCE(SUM(
485 + COALESCE(i.audio_file_size_bytes, 0) +
486 + COALESCE(i.cover_file_size_bytes, 0) +
487 + COALESCE(i.video_file_size_bytes, 0) +
488 + COALESCE(ver.version_bytes, 0)
489 + ), 0)::BIGINT
490 + FROM items i
491 + LEFT JOIN LATERAL (
492 + SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes
493 + FROM versions v
494 + WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL
495 + ) ver ON true
496 + WHERE i.project_id = $1
497 + "#,
498 + )
499 + .bind(project_id)
500 + .fetch_one(pool)
Lines truncated
@@ -1,1067 +0,0 @@
1 - //! SyncKit: device registration, change log, and encryption key management.
2 -
3 - use serde_json::Value as JsonValue;
4 - use sqlx::PgPool;
5 -
6 - use super::enums::SyncPlatform;
7 - use super::models::*;
8 - use super::{ItemId, ProjectId, SyncAppId, SyncDeviceId, UserId};
9 - use crate::error::Result;
10 -
11 - // ── Sync Apps ──
12 -
13 - /// Compute the SHA-256 hash of an API key (hex-encoded).
14 - pub fn hash_api_key(api_key: &str) -> String {
15 - use sha2::Digest;
16 - let hash = sha2::Sha256::digest(api_key.as_bytes());
17 - hex::encode(hash)
18 - }
19 -
20 - /// Create a new sync app. Stores the hashed API key and prefix.
21 - #[tracing::instrument(skip_all)]
22 - pub async fn create_sync_app(
23 - pool: &PgPool,
24 - creator_id: UserId,
25 - name: &str,
26 - api_key: &str,
27 - project_id: Option<ProjectId>,
28 - item_id: Option<ItemId>,
29 - ) -> Result<DbSyncApp> {
30 - let key_hash = hash_api_key(api_key);
31 - let key_prefix = &api_key[..8.min(api_key.len())];
32 - let app = sqlx::query_as::<_, DbSyncApp>(
33 - r#"
34 - INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id)
35 - VALUES ($1, $2, $3, $4, $5, $6)
36 - RETURNING *
37 - "#,
38 - )
39 - .bind(creator_id)
40 - .bind(name)
41 - .bind(&key_hash)
42 - .bind(key_prefix)
43 - .bind(project_id)
44 - .bind(item_id)
45 - .fetch_one(pool)
46 - .await?;
47 -
48 - Ok(app)
49 - }
50 -
51 - /// Update the project/item link for a sync app.
52 - #[tracing::instrument(skip_all)]
53 - pub async fn update_sync_app_link(
54 - pool: &PgPool,
55 - app_id: SyncAppId,
56 - project_id: Option<ProjectId>,
57 - item_id: Option<ItemId>,
58 - ) -> Result<DbSyncApp> {
59 - let app = sqlx::query_as::<_, DbSyncApp>(
60 - r#"
61 - UPDATE sync_apps SET project_id = $2, item_id = $3
62 - WHERE id = $1
63 - RETURNING *
64 - "#,
65 - )
66 - .bind(app_id)
67 - .bind(project_id)
68 - .bind(item_id)
69 - .fetch_one(pool)
70 - .await?;
71 -
72 - Ok(app)
73 - }
74 -
75 - /// Get a sync app by API key (only if active). Hashes the input before lookup.
76 - #[tracing::instrument(skip_all)]
77 - pub async fn get_sync_app_by_api_key(
78 - pool: &PgPool,
79 - api_key: &str,
80 - ) -> Result<Option<DbSyncApp>> {
81 - let key_hash = hash_api_key(api_key);
82 - let app = sqlx::query_as::<_, DbSyncApp>(
83 - "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true",
84 - )
85 - .bind(&key_hash)
86 - .fetch_optional(pool)
87 - .await?;
88 -
89 - Ok(app)
90 - }
91 -
92 - /// Get a sync app by ID.
93 - #[tracing::instrument(skip_all)]
94 - pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result<Option<DbSyncApp>> {
95 - let app = sqlx::query_as::<_, DbSyncApp>(
96 - "SELECT * FROM sync_apps WHERE id = $1",
97 - )
98 - .bind(id)
99 - .fetch_optional(pool)
100 - .await?;
101 -
102 - Ok(app)
103 - }
104 -
105 - /// List all sync apps for a creator.
106 - #[tracing::instrument(skip_all)]
107 - pub async fn get_sync_apps_by_creator(
108 - pool: &PgPool,
109 - creator_id: UserId,
110 - ) -> Result<Vec<DbSyncApp>> {
111 - let apps = sqlx::query_as::<_, DbSyncApp>(
112 - "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100",
113 - )
114 - .bind(creator_id)
115 - .fetch_all(pool)
116 - .await?;
117 -
118 - Ok(apps)
119 - }
120 -
121 - /// Get all sync apps linked to a specific project.
122 - pub async fn get_sync_apps_by_project(
123 - pool: &PgPool,
124 - project_id: ProjectId,
125 - ) -> Result<Vec<DbSyncApp>> {
126 - let apps = sqlx::query_as::<_, DbSyncApp>(
127 - "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100",
128 - )
129 - .bind(project_id)
130 - .fetch_all(pool)
131 - .await?;
132 -
133 - Ok(apps)
134 - }
135 -
136 - /// Regenerate an API key for a sync app. Stores the hashed key and prefix.
137 - #[tracing::instrument(skip_all)]
138 - pub async fn regenerate_sync_app_key(
139 - pool: &PgPool,
140 - app_id: SyncAppId,
141 - new_api_key: &str,
142 - ) -> Result<DbSyncApp> {
143 - let key_hash = hash_api_key(new_api_key);
144 - let key_prefix = &new_api_key[..8.min(new_api_key.len())];
145 - let app = sqlx::query_as::<_, DbSyncApp>(
146 - r#"
147 - UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3
148 - WHERE id = $1
149 - RETURNING *
150 - "#,
151 - )
152 - .bind(app_id)
153 - .bind(&key_hash)
154 - .bind(key_prefix)
155 - .fetch_one(pool)
156 - .await?;
157 -
158 - Ok(app)
159 - }
160 -
161 - /// Delete a sync app (cascades to devices and log entries).
162 - #[tracing::instrument(skip_all)]
163 - pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> {
164 - sqlx::query("DELETE FROM sync_apps WHERE id = $1")
165 - .bind(app_id)
166 - .execute(pool)
167 - .await?;
168 -
169 - Ok(())
170 - }
171 -
172 - // ── Sync Devices ──
173 -
174 - /// Register or update a sync device.
175 - ///
176 - /// Upserts on the `(app_id, user_id, device_name)` unique constraint.
177 - /// On conflict (same device re-registering), updates the platform string
178 - /// and bumps `last_seen_at` so stale-device detection stays accurate.
179 - #[tracing::instrument(skip_all)]
180 - pub async fn upsert_sync_device(
181 - pool: &PgPool,
182 - app_id: SyncAppId,
183 - user_id: UserId,
184 - device_name: &str,
185 - platform: SyncPlatform,
186 - ) -> Result<DbSyncDevice> {
187 - let device = sqlx::query_as::<_, DbSyncDevice>(
188 - r#"
189 - INSERT INTO sync_devices (app_id, user_id, device_name, platform)
190 - VALUES ($1, $2, $3, $4)
191 - ON CONFLICT (app_id, user_id, device_name)
192 - DO UPDATE SET platform = EXCLUDED.platform, last_seen_at = NOW()
193 - RETURNING *
194 - "#,
195 - )
196 - .bind(app_id)
197 - .bind(user_id)
198 - .bind(device_name)
199 - .bind(platform)
200 - .fetch_one(pool)
201 - .await?;
202 -
203 - Ok(device)
204 - }
205 -
206 - /// List all devices for a user within an app.
207 - #[tracing::instrument(skip_all)]
208 - pub async fn get_sync_devices(
209 - pool: &PgPool,
210 - app_id: SyncAppId,
211 - user_id: UserId,
212 - ) -> Result<Vec<DbSyncDevice>> {
213 - let devices = sqlx::query_as::<_, DbSyncDevice>(
214 - "SELECT * FROM sync_devices WHERE app_id = $1 AND user_id = $2 ORDER BY last_seen_at DESC LIMIT 100",
215 - )
216 - .bind(app_id)
217 - .bind(user_id)
218 - .fetch_all(pool)
219 - .await?;
220 -
221 - Ok(devices)
222 - }
223 -
224 - /// Delete a device by ID (only if owned by user within app).
225 - #[tracing::instrument(skip_all)]
226 - pub async fn delete_sync_device(
227 - pool: &PgPool,
228 - device_id: SyncDeviceId,
229 - app_id: SyncAppId,
230 - user_id: UserId,
231 - ) -> Result<bool> {
232 - let result = sqlx::query(
233 - "DELETE FROM sync_devices WHERE id = $1 AND app_id = $2 AND user_id = $3",
234 - )
235 - .bind(device_id)
236 - .bind(app_id)
237 - .bind(user_id)
238 - .execute(pool)
239 - .await?;
240 -
241 - Ok(result.rows_affected() > 0)
242 - }
243 -
244 - /// Touch last_seen_at for a device.
245 - #[tracing::instrument(skip_all)]
246 - pub async fn touch_sync_device(pool: &PgPool, device_id: SyncDeviceId) -> Result<()> {
247 - sqlx::query("UPDATE sync_devices SET last_seen_at = NOW() WHERE id = $1")
248 - .bind(device_id)
249 - .execute(pool)
250 - .await?;
251 -
252 - Ok(())
253 - }
254 -
255 - // ── Sync Log ──
256 -
257 - /// Push a batch of changes to the sync log. Returns the highest seq assigned.
258 - ///
259 - /// `batch_id` is a client-generated UUID for idempotent push. If a batch with
260 - /// the same ID has already been committed for this app+user, the existing max
261 - /// seq is returned without re-inserting (at-most-once semantics).
262 - ///
263 - /// All changes are inserted within a single transaction for atomicity and
264 - /// performance (one fsync instead of N).
265 - #[allow(clippy::type_complexity)]
266 - #[tracing::instrument(skip_all)]
267 - pub async fn push_sync_changes(
268 - pool: &PgPool,
269 - app_id: SyncAppId,
270 - user_id: UserId,
271 - device_id: SyncDeviceId,
272 - batch_id: uuid::Uuid,
273 - changes: &[(String, String, String, chrono::DateTime<chrono::Utc>, Option<JsonValue>)],
274 - ) -> Result<i64> {
275 - if changes.is_empty() {
276 - return Ok(0);
277 - }
278 -
279 - // Decompose into parallel Vecs for UNNEST-based batch INSERT
280 - let mut table_names: Vec<String> = Vec::with_capacity(changes.len());
281 - let mut operations: Vec<String> = Vec::with_capacity(changes.len());
282 - let mut row_ids: Vec<String> = Vec::with_capacity(changes.len());
283 - let mut client_timestamps: Vec<chrono::DateTime<chrono::Utc>> = Vec::with_capacity(changes.len());
284 - let mut data_values: Vec<JsonValue> = Vec::with_capacity(changes.len());
285 -
286 - for (table_name, operation, row_id, client_timestamp, data) in changes {
287 - table_names.push(table_name.clone());
288 - operations.push(operation.clone());
289 - row_ids.push(row_id.clone());
290 - client_timestamps.push(*client_timestamp);
291 - data_values.push(data.clone().unwrap_or(JsonValue::Null));
292 - }
293 -
294 - let mut tx = pool.begin().await?;
295 -
296 - // Idempotency check inside the transaction to prevent TOCTOU race.
297 - let existing: (Option<i64>,) = sqlx::query_as(
298 - "SELECT MAX(seq) FROM sync_log WHERE app_id = $1 AND user_id = $2 AND batch_id = $3",
299 - )
300 - .bind(app_id)
301 - .bind(user_id)
302 - .bind(batch_id)
303 - .fetch_one(&mut *tx)
304 - .await?;
305 -
306 - if let Some(max_seq) = existing.0 {
307 - tracing::debug!(batch_id = %batch_id, cursor = max_seq, "Push batch already committed, returning existing cursor");
308 - tx.rollback().await.ok();
309 - return Ok(max_seq);
310 - }
311 -
312 - // Read the current key_id from sync_keys so pushed entries are stamped
313 - // with the active encryption key. Falls back to NULL if no key exists yet
314 - // (pre-encryption setup — entries have no encrypted data anyway).
315 - let current_key_id: Option<i32> = sqlx::query_scalar(
316 - "SELECT key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2",
317 - )
318 - .bind(app_id)
319 - .bind(user_id)
320 - .fetch_optional(&mut *tx)
321 - .await?;
322 -
323 - let seqs: Vec<i64> = sqlx::query_scalar(
324 - r#"
325 - INSERT INTO sync_log (app_id, user_id, device_id, batch_id, table_name, operation, row_id, client_timestamp, data, key_id)
326 - SELECT $1, $2, $3, $4, t.*, $10
327 - FROM UNNEST($5::text[], $6::text[], $7::text[], $8::timestamptz[], $9::jsonb[]) AS t
328 - RETURNING seq
329 - "#,
330 - )
331 - .bind(app_id)
332 - .bind(user_id)
333 - .bind(device_id)
334 - .bind(batch_id)
335 - .bind(&table_names)
336 - .bind(&operations)
337 - .bind(&row_ids)
338 - .bind(&client_timestamps)
339 - .bind(&data_values)
340 - .bind(current_key_id)
341 - .fetch_all(&mut *tx)
342 - .await?;
343 -
344 - tx.commit().await?;
345 -
346 - let max_seq = seqs.iter().copied().max().unwrap_or(0);
347 -
348 - Ok(max_seq)
349 - }
350 -
351 - /// Pull changes since a cursor for a user within an app.
352 - ///
353 - /// Prefer `pull_sync_changes_filtered` for new code — it supports optional
354 - /// table and timestamp filters. This function is kept for backward compatibility.
355 - #[allow(dead_code)]
356 - #[tracing::instrument(skip_all)]
357 - pub async fn pull_sync_changes(
358 - pool: &PgPool,
359 - app_id: SyncAppId,
360 - user_id: UserId,
361 - cursor: i64,
362 - limit: i64,
363 - ) -> Result<Vec<DbSyncLogEntry>> {
364 - let entries = sqlx::query_as::<_, DbSyncLogEntry>(
365 - r#"
366 - SELECT * FROM sync_log
367 - WHERE app_id = $1 AND user_id = $2 AND seq > $3
368 - ORDER BY seq ASC
369 - LIMIT $4
370 - "#,
371 - )
372 - .bind(app_id)
373 - .bind(user_id)
374 - .bind(cursor)
375 - .bind(limit)
376 - .fetch_all(pool)
377 - .await?;
378 -
379 - Ok(entries)
380 - }
381 -
382 - /// Pull changes since a cursor with optional table and timestamp filters.
383 - ///
384 - /// When `tables` is `Some`, only entries whose `table_name` is in the list are returned.
385 - /// When `since` is `Some`, only entries with `client_timestamp >= since` are returned.
386 - /// Both filters compose (AND). Passing `None` for both is identical to `pull_sync_changes`.
387 - #[tracing::instrument(skip_all)]
388 - pub async fn pull_sync_changes_filtered(
389 - pool: &PgPool,
390 - app_id: SyncAppId,
391 - user_id: UserId,
392 - cursor: i64,
393 - limit: i64,
394 - tables: Option<&[String]>,
395 - since: Option<chrono::DateTime<chrono::Utc>>,
396 - ) -> Result<Vec<DbSyncLogEntry>> {
397 - let entries = sqlx::query_as::<_, DbSyncLogEntry>(
398 - r#"
399 - SELECT * FROM sync_log
400 - WHERE app_id = $1 AND user_id = $2 AND seq > $3
401 - AND ($5::text[] IS NULL OR table_name = ANY($5))
402 - AND ($6::timestamptz IS NULL OR client_timestamp >= $6)
403 - ORDER BY seq ASC
404 - LIMIT $4
405 - "#,
406 - )
407 - .bind(app_id)
408 - .bind(user_id)
409 - .bind(cursor)
410 - .bind(limit)
411 - .bind(tables)
412 - .bind(since)
413 - .fetch_all(pool)
414 - .await?;
415 -
416 - Ok(entries)
417 - }
418 -
419 - // ── Sync Keys ──
420 -
421 - /// Upsert an encrypted master key for a user within an app with optimistic
422 - /// concurrency control.
423 - ///
424 - /// `expected_version` is checked on UPDATE: if the current `key_version`
425 - /// doesn't match, the update is skipped and `false` is returned (the caller
426 - /// should return 409 Conflict). On INSERT (first key), `expected_version`
427 - /// must be 0.
428 - ///
429 - /// Returns `true` if the key was inserted or updated, `false` on version mismatch.
430 - #[tracing::instrument(skip_all)]
431 - pub async fn upsert_sync_key(
432 - pool: &PgPool,
433 - app_id: SyncAppId,
434 - user_id: UserId,
435 - encrypted_key: &str,
436 - expected_version: i32,
437 - ) -> Result<bool> {
438 - let result = sqlx::query(
439 - r#"
440 - INSERT INTO sync_keys (app_id, user_id, encrypted_key)
441 - VALUES ($1, $2, $3)
442 - ON CONFLICT (app_id, user_id)
443 - DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key,
444 - key_version = sync_keys.key_version + 1,
445 - updated_at = NOW()
446 - WHERE sync_keys.key_version = $4
447 - "#,
448 - )
449 - .bind(app_id)
450 - .bind(user_id)
451 - .bind(encrypted_key)
452 - .bind(expected_version)
453 - .execute(pool)
454 - .await?;
455 -
456 - Ok(result.rows_affected() > 0)
457 - }
458 -
459 - /// Encryption key info returned by `get_sync_key`.
460 - pub struct SyncKeyInfo {
461 - pub encrypted_key: String,
462 - pub key_version: i32,
463 - pub key_id: i32,
464 - /// If a key rotation is in progress, the new key envelope and its key_id.
465 - pub pending_key: Option<(String, i32)>,
466 - }
467 -
468 - /// Get the encrypted master key, version, key_id, and any pending rotation key.
469 - #[tracing::instrument(skip_all)]
470 - pub async fn get_sync_key(
471 - pool: &PgPool,
472 - app_id: SyncAppId,
473 - user_id: UserId,
474 - ) -> Result<Option<SyncKeyInfo>> {
475 - let row: Option<(String, i32, i32)> = sqlx::query_as(
476 - "SELECT encrypted_key, key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2",
477 - )
478 - .bind(app_id)
479 - .bind(user_id)
480 - .fetch_optional(pool)
481 - .await?;
482 -
483 - let Some((encrypted_key, key_version, key_id)) = row else {
484 - return Ok(None);
485 - };
486 -
487 - // Check for an active rotation
488 - let pending: Option<(String, i32)> = sqlx::query_as(
489 - "SELECT new_encrypted_key, new_key_id FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2",
490 - )
491 - .bind(app_id)
492 - .bind(user_id)
493 - .fetch_optional(pool)
494 - .await?;
495 -
496 - Ok(Some(SyncKeyInfo {
497 - encrypted_key,
498 - key_version,
499 - key_id,
500 - pending_key: pending,
Lines truncated
@@ -0,0 +1,167 @@
1 + use sqlx::PgPool;
2 +
3 + use crate::db::models::*;
4 + use crate::db::{ItemId, ProjectId, SyncAppId, UserId};
5 + use crate::error::Result;
6 +
7 + // ── Sync Apps ──
8 +
9 + /// Compute the SHA-256 hash of an API key (hex-encoded).
10 + pub fn hash_api_key(api_key: &str) -> String {
11 + use sha2::Digest;
12 + let hash = sha2::Sha256::digest(api_key.as_bytes());
13 + hex::encode(hash)
14 + }
15 +
16 + /// Create a new sync app. Stores the hashed API key and prefix.
17 + #[tracing::instrument(skip_all)]
18 + pub async fn create_sync_app(
19 + pool: &PgPool,
20 + creator_id: UserId,
21 + name: &str,
22 + api_key: &str,
23 + project_id: Option<ProjectId>,
24 + item_id: Option<ItemId>,
25 + ) -> Result<DbSyncApp> {
26 + let key_hash = hash_api_key(api_key);
27 + let key_prefix = &api_key[..8.min(api_key.len())];
28 + let app = sqlx::query_as::<_, DbSyncApp>(
29 + r#"
30 + INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id)
31 + VALUES ($1, $2, $3, $4, $5, $6)
32 + RETURNING *
33 + "#,
34 + )
35 + .bind(creator_id)
36 + .bind(name)
37 + .bind(&key_hash)
38 + .bind(key_prefix)
39 + .bind(project_id)
40 + .bind(item_id)
41 + .fetch_one(pool)
42 + .await?;
43 +
44 + Ok(app)
45 + }
46 +
47 + /// Update the project/item link for a sync app.
48 + #[tracing::instrument(skip_all)]
49 + pub async fn update_sync_app_link(
50 + pool: &PgPool,
51 + app_id: SyncAppId,
52 + project_id: Option<ProjectId>,
53 + item_id: Option<ItemId>,
54 + ) -> Result<DbSyncApp> {
55 + let app = sqlx::query_as::<_, DbSyncApp>(
56 + r#"
57 + UPDATE sync_apps SET project_id = $2, item_id = $3
58 + WHERE id = $1
59 + RETURNING *
60 + "#,
61 + )
62 + .bind(app_id)
63 + .bind(project_id)
64 + .bind(item_id)
65 + .fetch_one(pool)
66 + .await?;
67 +
68 + Ok(app)
69 + }
70 +
71 + /// Get a sync app by API key (only if active). Hashes the input before lookup.
72 + #[tracing::instrument(skip_all)]
73 + pub async fn get_sync_app_by_api_key(
74 + pool: &PgPool,
75 + api_key: &str,
76 + ) -> Result<Option<DbSyncApp>> {
77 + let key_hash = hash_api_key(api_key);
78 + let app = sqlx::query_as::<_, DbSyncApp>(
79 + "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true",
80 + )
81 + .bind(&key_hash)
82 + .fetch_optional(pool)
83 + .await?;
84 +
85 + Ok(app)
86 + }
87 +
88 + /// Get a sync app by ID.
89 + #[tracing::instrument(skip_all)]
90 + pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result<Option<DbSyncApp>> {
91 + let app = sqlx::query_as::<_, DbSyncApp>(
92 + "SELECT * FROM sync_apps WHERE id = $1",
93 + )
94 + .bind(id)
95 + .fetch_optional(pool)
96 + .await?;
97 +
98 + Ok(app)
99 + }
100 +
101 + /// List all sync apps for a creator.
102 + #[tracing::instrument(skip_all)]
103 + pub async fn get_sync_apps_by_creator(
104 + pool: &PgPool,
105 + creator_id: UserId,
106 + ) -> Result<Vec<DbSyncApp>> {
107 + let apps = sqlx::query_as::<_, DbSyncApp>(
108 + "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100",
109 + )
110 + .bind(creator_id)
111 + .fetch_all(pool)
112 + .await?;
113 +
114 + Ok(apps)
115 + }
116 +
117 + /// Get all sync apps linked to a specific project.
118 + pub async fn get_sync_apps_by_project(
119 + pool: &PgPool,
120 + project_id: ProjectId,
121 + ) -> Result<Vec<DbSyncApp>> {
122 + let apps = sqlx::query_as::<_, DbSyncApp>(
123 + "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100",
124 + )
125 + .bind(project_id)
126 + .fetch_all(pool)
127 + .await?;
128 +
129 + Ok(apps)
130 + }
131 +
132 + /// Regenerate an API key for a sync app. Stores the hashed key and prefix.
133 + #[tracing::instrument(skip_all)]
134 + pub async fn regenerate_sync_app_key(
135 + pool: &PgPool,
136 + app_id: SyncAppId,
137 + new_api_key: &str,
138 + ) -> Result<DbSyncApp> {
139 + let key_hash = hash_api_key(new_api_key);
140 + let key_prefix = &new_api_key[..8.min(new_api_key.len())];
141 + let app = sqlx::query_as::<_, DbSyncApp>(
142 + r#"
143 + UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3
144 + WHERE id = $1
145 + RETURNING *
146 + "#,
147 + )
148 + .bind(app_id)
149 + .bind(&key_hash)
150 + .bind(key_prefix)
151 + .fetch_one(pool)
152 + .await?;
153 +
154 + Ok(app)
155 + }
156 +
157 + /// Delete a sync app (cascades to devices and log entries).
158 + #[tracing::instrument(skip_all)]
159 + pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> {
160 + sqlx::query("DELETE FROM sync_apps WHERE id = $1")
161 + .bind(app_id)
162 + .execute(pool)
163 + .await?;
164 +
165 + Ok(())
166 + }
167 +
@@ -0,0 +1,96 @@
1 + use sqlx::PgPool;
2 +
3 + use crate::db::models::*;
4 + use crate::db::{SyncAppId, UserId};
5 + use crate::error::Result;
6 +
7 + // ── Sync Blobs ──
8 +
9 + /// Get a blob by content hash for a user within an app.
10 + #[tracing::instrument(skip_all)]
11 + pub async fn get_sync_blob_by_hash(
12 + pool: &PgPool,
13 + app_id: SyncAppId,
14 + user_id: UserId,
15 + hash: &str,
16 + ) -> Result<Option<DbSyncBlob>> {
17 + let blob = sqlx::query_as::<_, DbSyncBlob>(
18 + "SELECT * FROM sync_blobs WHERE app_id = $1 AND user_id = $2 AND hash = $3",
19 + )
20 + .bind(app_id)
21 + .bind(user_id)
22 + .bind(hash)
23 + .fetch_optional(pool)
24 + .await?;
25 +
26 + Ok(blob)
27 + }
28 +
29 + /// Insert a sync blob, updating size on conflict (idempotent).
30 + ///
31 + /// Uses `ON CONFLICT DO UPDATE` to keep `size_bytes` consistent with the
32 + /// actual S3 object when concurrent uploads race.
33 + #[tracing::instrument(skip_all)]
34 + pub async fn create_sync_blob_idempotent(
35 + pool: &PgPool,
36 + app_id: SyncAppId,
37 + user_id: UserId,
38 + hash: &str,
39 + size_bytes: i64,
40 + s3_key: &str,
41 + ) -> Result<()> {
42 + sqlx::query(
43 + r#"
44 + INSERT INTO sync_blobs (app_id, user_id, hash, size_bytes, s3_key)
45 + VALUES ($1, $2, $3, $4, $5)
46 + ON CONFLICT (app_id, user_id, hash)
47 + DO UPDATE SET size_bytes = EXCLUDED.size_bytes
48 + "#,
49 + )
50 + .bind(app_id)
51 + .bind(user_id)
52 + .bind(hash)
53 + .bind(size_bytes)
54 + .bind(s3_key)
55 + .execute(pool)
56 + .await?;
57 +
58 + Ok(())
59 + }
60 +
61 + /// Get total blob storage used by a user for an app (in bytes).
62 + #[tracing::instrument(skip_all)]
63 + pub async fn get_blob_storage_used(
64 + pool: &PgPool,
65 + app_id: SyncAppId,
66 + user_id: UserId,
67 + ) -> Result<i64> {
68 + let total: Option<i64> = sqlx::query_scalar(
69 + "SELECT SUM(size_bytes)::BIGINT FROM sync_blobs WHERE app_id = $1 AND user_id = $2",
70 + )
71 + .bind(app_id)
72 + .bind(user_id)
73 + .fetch_one(pool)
74 + .await?;
75 +
76 + Ok(total.unwrap_or(0))
77 + }
78 +
79 + /// Count devices registered for a user/app pair.
80 + #[tracing::instrument(skip_all)]
81 + pub async fn count_sync_devices(
82 + pool: &PgPool,
83 + app_id: SyncAppId,
84 + user_id: UserId,
85 + ) -> Result<i64> {
86 + let count: i64 = sqlx::query_scalar(
87 + "SELECT COUNT(*) FROM sync_devices WHERE app_id = $1 AND user_id = $2",
88 + )
89 + .bind(app_id)
90 + .bind(user_id)
91 + .fetch_one(pool)
92 + .await?;
93 +
94 + Ok(count)
95 + }
96 +
@@ -0,0 +1,90 @@
1 + use sqlx::PgPool;
2 +
3 + use crate::db::enums::SyncPlatform;
4 + use crate::db::models::*;
5 + use crate::db::{SyncAppId, SyncDeviceId, UserId};
6 + use crate::error::Result;
7 +
8 + // ── Sync Devices ──
9 +
10 + /// Register or update a sync device.
11 + ///
12 + /// Upserts on the `(app_id, user_id, device_name)` unique constraint.
13 + /// On conflict (same device re-registering), updates the platform string
14 + /// and bumps `last_seen_at` so stale-device detection stays accurate.
15 + #[tracing::instrument(skip_all)]
16 + pub async fn upsert_sync_device(
17 + pool: &PgPool,
18 + app_id: SyncAppId,
19 + user_id: UserId,
20 + device_name: &str,
21 + platform: SyncPlatform,
22 + ) -> Result<DbSyncDevice> {
23 + let device = sqlx::query_as::<_, DbSyncDevice>(
24 + r#"
25 + INSERT INTO sync_devices (app_id, user_id, device_name, platform)
26 + VALUES ($1, $2, $3, $4)
27 + ON CONFLICT (app_id, user_id, device_name)
28 + DO UPDATE SET platform = EXCLUDED.platform, last_seen_at = NOW()
29 + RETURNING *
30 + "#,
31 + )
32 + .bind(app_id)
33 + .bind(user_id)
34 + .bind(device_name)
35 + .bind(platform)
36 + .fetch_one(pool)
37 + .await?;
38 +
39 + Ok(device)
40 + }
41 +
42 + /// List all devices for a user within an app.
43 + #[tracing::instrument(skip_all)]
44 + pub async fn get_sync_devices(
45 + pool: &PgPool,
46 + app_id: SyncAppId,
47 + user_id: UserId,
48 + ) -> Result<Vec<DbSyncDevice>> {
49 + let devices = sqlx::query_as::<_, DbSyncDevice>(
50 + "SELECT * FROM sync_devices WHERE app_id = $1 AND user_id = $2 ORDER BY last_seen_at DESC LIMIT 100",
51 + )
52 + .bind(app_id)
53 + .bind(user_id)
54 + .fetch_all(pool)
55 + .await?;
56 +
57 + Ok(devices)
58 + }
59 +
60 + /// Delete a device by ID (only if owned by user within app).
61 + #[tracing::instrument(skip_all)]
62 + pub async fn delete_sync_device(
63 + pool: &PgPool,
64 + device_id: SyncDeviceId,
65 + app_id: SyncAppId,
66 + user_id: UserId,
67 + ) -> Result<bool> {
68 + let result = sqlx::query(
69 + "DELETE FROM sync_devices WHERE id = $1 AND app_id = $2 AND user_id = $3",
70 + )
71 + .bind(device_id)
72 + .bind(app_id)
73 + .bind(user_id)
74 + .execute(pool)
75 + .await?;
76 +
77 + Ok(result.rows_affected() > 0)
78 + }
79 +
80 + /// Touch last_seen_at for a device.
81 + #[tracing::instrument(skip_all)]
82 + pub async fn touch_sync_device(pool: &PgPool, device_id: SyncDeviceId) -> Result<()> {
83 + sqlx::query("UPDATE sync_devices SET last_seen_at = NOW() WHERE id = $1")
84 + .bind(device_id)
85 + .execute(pool)
86 + .await?;
87 +
88 + Ok(())
89 + }
90 +
@@ -0,0 +1,250 @@
1 + use sqlx::PgPool;
2 +
3 + use crate::db::{SyncAppId, SyncDeviceId, UserId};
4 + use crate::error::Result;
5 +
6 + // ── Sync Keys ──
7 +
8 + /// Upsert an encrypted master key for a user within an app with optimistic
9 + /// concurrency control.
10 + ///
11 + /// `expected_version` is checked on UPDATE: if the current `key_version`
12 + /// doesn't match, the update is skipped and `false` is returned (the caller
13 + /// should return 409 Conflict). On INSERT (first key), `expected_version`
14 + /// must be 0.
15 + ///
16 + /// Returns `true` if the key was inserted or updated, `false` on version mismatch.
17 + #[tracing::instrument(skip_all)]
18 + pub async fn upsert_sync_key(
19 + pool: &PgPool,
20 + app_id: SyncAppId,
21 + user_id: UserId,
22 + encrypted_key: &str,
23 + expected_version: i32,
24 + ) -> Result<bool> {
25 + let result = sqlx::query(
26 + r#"
27 + INSERT INTO sync_keys (app_id, user_id, encrypted_key)
28 + VALUES ($1, $2, $3)
29 + ON CONFLICT (app_id, user_id)
30 + DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key,
31 + key_version = sync_keys.key_version + 1,
32 + updated_at = NOW()
33 + WHERE sync_keys.key_version = $4
34 + "#,
35 + )
36 + .bind(app_id)
37 + .bind(user_id)
38 + .bind(encrypted_key)
39 + .bind(expected_version)
40 + .execute(pool)
41 + .await?;
42 +
43 + Ok(result.rows_affected() > 0)
44 + }
45 +
46 + /// Encryption key info returned by `get_sync_key`.
47 + pub struct SyncKeyInfo {
48 + pub encrypted_key: String,
49 + pub key_version: i32,
50 + pub key_id: i32,
51 + /// If a key rotation is in progress, the new key envelope and its key_id.
52 + pub pending_key: Option<(String, i32)>,
53 + }
54 +
55 + /// Get the encrypted master key, version, key_id, and any pending rotation key.
56 + #[tracing::instrument(skip_all)]
57 + pub async fn get_sync_key(
58 + pool: &PgPool,
59 + app_id: SyncAppId,
60 + user_id: UserId,
61 + ) -> Result<Option<SyncKeyInfo>> {
62 + let row: Option<(String, i32, i32)> = sqlx::query_as(
63 + "SELECT encrypted_key, key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2",
64 + )
65 + .bind(app_id)
66 + .bind(user_id)
67 + .fetch_optional(pool)
68 + .await?;
69 +
70 + let Some((encrypted_key, key_version, key_id)) = row else {
71 + return Ok(None);
72 + };
73 +
74 + // Check for an active rotation
75 + let pending: Option<(String, i32)> = sqlx::query_as(
76 + "SELECT new_encrypted_key, new_key_id FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2",
77 + )
78 + .bind(app_id)
79 + .bind(user_id)
80 + .fetch_optional(pool)
81 + .await?;
82 +
83 + Ok(Some(SyncKeyInfo {
84 + encrypted_key,
85 + key_version,
86 + key_id,
87 + pending_key: pending,
88 + }))
89 + }
90 +
91 + /// Get device count and sync log entry count for all apps owned by a creator.
92 + /// Returns Vec of (app_id, device_count, log_entry_count). Single query replaces N+1 loop.
93 + #[tracing::instrument(skip_all)]
94 + pub async fn get_sync_app_stats_batch(
95 + pool: &PgPool,
96 + creator_id: UserId,
97 + ) -> Result<Vec<(SyncAppId, i64, i64)>> {
98 + let rows: Vec<(SyncAppId, i64, i64)> = sqlx::query_as(
99 + r#"
100 + SELECT
101 + a.id,
102 + (SELECT COUNT(*) FROM sync_devices d WHERE d.app_id = a.id),
103 + (SELECT COUNT(*) FROM sync_log l WHERE l.app_id = a.id)
104 + FROM sync_apps a
105 + WHERE a.creator_id = $1
106 + "#,
107 + )
108 + .bind(creator_id)
109 + .fetch_all(pool)
110 + .await?;
111 +
112 + Ok(rows)
113 + }
114 +
115 + /// Delete sync log entries older than the given number of days.
116 + ///
117 + /// `retain_days` must be positive. Returns 0 immediately for non-positive values
118 + /// to prevent accidental deletion of all entries.
119 + #[tracing::instrument(skip_all)]
120 + pub async fn prune_sync_log(pool: &PgPool, retain_days: i64) -> Result<u64> {
121 + if retain_days <= 0 {
122 + tracing::warn!("prune_sync_log called with non-positive retain_days={retain_days}, skipping");
123 + return Ok(0);
124 + }
125 + let result = sqlx::query(
126 + "DELETE FROM sync_log WHERE created_at < NOW() - make_interval(days => $1)",
127 + )
128 + .bind(retain_days)
129 + .execute(pool)
130 + .await?;
131 +
132 + Ok(result.rows_affected())
133 + }
134 +
135 + /// Update a device's last-pulled cursor position.
136 + #[tracing::instrument(skip_all)]
137 + pub async fn update_device_cursor(
138 + pool: &PgPool,
139 + device_id: SyncDeviceId,
140 + seq: i64,
141 + ) -> Result<()> {
142 + sqlx::query(
143 + "UPDATE sync_devices SET last_pulled_seq = GREATEST(last_pulled_seq, $1) WHERE id = $2",
144 + )
145 + .bind(seq)
146 + .bind(device_id)
147 + .execute(pool)
148 + .await?;
149 + Ok(())
150 + }
151 +
152 + /// Compact the sync log by removing entries that all devices for a given
153 + /// (app_id, user_id) have already pulled. Keeps a safety margin of entries
154 + /// newer than `min_age_days` regardless of cursor positions.
155 + ///
156 + /// Returns the number of entries deleted.
157 + #[allow(dead_code)] // Public API for targeted per-user compaction
158 + #[tracing::instrument(skip_all)]
159 + pub async fn compact_sync_log(
160 + pool: &PgPool,
161 + app_id: SyncAppId,
162 + user_id: UserId,
163 + min_age_days: i64,
164 + ) -> Result<u64> {
165 + if min_age_days <= 0 {
166 + return Ok(0);
167 + }
168 +
169 + // Find the lowest cursor across all devices for this user+app.
170 + // Entries below this seq have been pulled by every device.
171 + let min_cursor: Option<i64> = sqlx::query_scalar(
172 + "SELECT MIN(last_pulled_seq) FROM sync_devices WHERE app_id = $1 AND user_id = $2",
173 + )
174 + .bind(app_id)
175 + .bind(user_id)
176 + .fetch_one(pool)
177 + .await?;
178 +
179 + let safe_seq = match min_cursor {
180 + Some(seq) if seq > 0 => seq,
181 + _ => return Ok(0), // No devices or no pulls yet
182 + };
183 +
184 + // Delete entries below the safe cursor AND older than the safety margin.
185 + let result = sqlx::query(
186 + r#"
187 + DELETE FROM sync_log
188 + WHERE app_id = $1 AND user_id = $2
189 + AND seq <= $3
190 + AND created_at < NOW() - make_interval(days => $4)
191 + "#,
192 + )
193 + .bind(app_id)
194 + .bind(user_id)
195 + .bind(safe_seq)
196 + .bind(min_age_days)
197 + .execute(pool)
198 + .await?;
199 +
200 + Ok(result.rows_affected())
201 + }
202 +
203 + /// Compact sync logs for all user+app pairs that have compactable entries.
204 + /// Finds pairs where MIN(last_pulled_seq) across devices > 0, then deletes
205 + /// entries below that threshold (with age safety margin).
206 + ///
207 + /// Returns total entries deleted across all users.
208 + #[tracing::instrument(skip_all)]
209 + pub async fn compact_all_sync_logs(pool: &PgPool, min_age_days: i64) -> Result<u64> {
210 + if min_age_days <= 0 {
211 + return Ok(0);
212 + }
213 +
214 + // Find (app_id, user_id) pairs where compaction is possible:
215 + // all devices have pulled past seq 0, and there are old entries to delete.
216 + let pairs: Vec<(SyncAppId, UserId, i64)> = sqlx::query_as(
217 + r#"
218 + SELECT app_id, user_id, MIN(last_pulled_seq) AS min_seq
219 + FROM sync_devices
220 + WHERE last_pulled_seq > 0
221 + GROUP BY app_id, user_id
222 + HAVING MIN(last_pulled_seq) > 0
223 + "#,
224 + )
225 + .fetch_all(pool)
226 + .await?;
227 +
228 + let mut total_deleted: u64 = 0;
229 + for (app_id, user_id, safe_seq) in pairs {
230 + let result = sqlx::query(
231 + r#"
232 + DELETE FROM sync_log
233 + WHERE app_id = $1 AND user_id = $2
234 + AND seq <= $3
235 + AND created_at < NOW() - make_interval(days => $4)
236 + "#,
237 + )
238 + .bind(app_id)
239 + .bind(user_id)
240 + .bind(safe_seq)
241 + .bind(min_age_days)
242 + .execute(pool)
243 + .await?;
244 +
245 + total_deleted += result.rows_affected();
246 + }
247 +
248 + Ok(total_deleted)
249 + }
250 +
@@ -0,0 +1,171 @@
1 + use serde_json::Value as JsonValue;
2 + use sqlx::PgPool;
3 +
4 + use crate::db::models::*;
5 + use crate::db::{SyncAppId, SyncDeviceId, UserId};
6 + use crate::error::Result;
7 +
8 + // ── Sync Log ──
9 +
10 + /// Push a batch of changes to the sync log. Returns the highest seq assigned.
11 + ///
12 + /// `batch_id` is a client-generated UUID for idempotent push. If a batch with
13 + /// the same ID has already been committed for this app+user, the existing max
14 + /// seq is returned without re-inserting (at-most-once semantics).
15 + ///
16 + /// All changes are inserted within a single transaction for atomicity and
17 + /// performance (one fsync instead of N).
18 + #[allow(clippy::type_complexity)]
19 + #[tracing::instrument(skip_all)]
20 + pub async fn push_sync_changes(
21 + pool: &PgPool,
22 + app_id: SyncAppId,
23 + user_id: UserId,
24 + device_id: SyncDeviceId,
25 + batch_id: uuid::Uuid,
26 + changes: &[(String, String, String, chrono::DateTime<chrono::Utc>, Option<JsonValue>)],
27 + ) -> Result<i64> {
28 + if changes.is_empty() {
29 + return Ok(0);
30 + }
31 +
32 + // Decompose into parallel Vecs for UNNEST-based batch INSERT
33 + let mut table_names: Vec<String> = Vec::with_capacity(changes.len());
34 + let mut operations: Vec<String> = Vec::with_capacity(changes.len());
35 + let mut row_ids: Vec<String> = Vec::with_capacity(changes.len());
36 + let mut client_timestamps: Vec<chrono::DateTime<chrono::Utc>> = Vec::with_capacity(changes.len());
37 + let mut data_values: Vec<JsonValue> = Vec::with_capacity(changes.len());
38 +
39 + for (table_name, operation, row_id, client_timestamp, data) in changes {
40 + table_names.push(table_name.clone());
41 + operations.push(operation.clone());
42 + row_ids.push(row_id.clone());
43 + client_timestamps.push(*client_timestamp);
44 + data_values.push(data.clone().unwrap_or(JsonValue::Null));
45 + }
46 +
47 + let mut tx = pool.begin().await?;
48 +
49 + // Idempotency check inside the transaction to prevent TOCTOU race.
50 + let existing: (Option<i64>,) = sqlx::query_as(
51 + "SELECT MAX(seq) FROM sync_log WHERE app_id = $1 AND user_id = $2 AND batch_id = $3",
52 + )
53 + .bind(app_id)
54 + .bind(user_id)
55 + .bind(batch_id)
56 + .fetch_one(&mut *tx)
57 + .await?;
58 +
59 + if let Some(max_seq) = existing.0 {
60 + tracing::debug!(batch_id = %batch_id, cursor = max_seq, "Push batch already committed, returning existing cursor");
61 + tx.rollback().await.ok();
62 + return Ok(max_seq);
63 + }
64 +
65 + // Read the current key_id from sync_keys so pushed entries are stamped
66 + // with the active encryption key. Falls back to NULL if no key exists yet
67 + // (pre-encryption setup — entries have no encrypted data anyway).
68 + let current_key_id: Option<i32> = sqlx::query_scalar(
69 + "SELECT key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2",
70 + )
71 + .bind(app_id)
72 + .bind(user_id)
73 + .fetch_optional(&mut *tx)
74 + .await?;
75 +
76 + let seqs: Vec<i64> = sqlx::query_scalar(
77 + r#"
78 + INSERT INTO sync_log (app_id, user_id, device_id, batch_id, table_name, operation, row_id, client_timestamp, data, key_id)
79 + SELECT $1, $2, $3, $4, t.*, $10
80 + FROM UNNEST($5::text[], $6::text[], $7::text[], $8::timestamptz[], $9::jsonb[]) AS t
81 + RETURNING seq
82 + "#,
83 + )
84 + .bind(app_id)
85 + .bind(user_id)
86 + .bind(device_id)
87 + .bind(batch_id)
88 + .bind(&table_names)
89 + .bind(&operations)
90 + .bind(&row_ids)
91 + .bind(&client_timestamps)
92 + .bind(&data_values)
93 + .bind(current_key_id)
94 + .fetch_all(&mut *tx)
95 + .await?;
96 +
97 + tx.commit().await?;
98 +
99 + let max_seq = seqs.iter().copied().max().unwrap_or(0);
100 +
101 + Ok(max_seq)
102 + }
103 +
104 + /// Pull changes since a cursor for a user within an app.
105 + ///
106 + /// Prefer `pull_sync_changes_filtered` for new code — it supports optional
107 + /// table and timestamp filters. This function is kept for backward compatibility.
108 + #[allow(dead_code)]
109 + #[tracing::instrument(skip_all)]
110 + pub async fn pull_sync_changes(
111 + pool: &PgPool,
112 + app_id: SyncAppId,
113 + user_id: UserId,
114 + cursor: i64,
115 + limit: i64,
116 + ) -> Result<Vec<DbSyncLogEntry>> {
117 + let entries = sqlx::query_as::<_, DbSyncLogEntry>(
118 + r#"
119 + SELECT * FROM sync_log
120 + WHERE app_id = $1 AND user_id = $2 AND seq > $3
121 + ORDER BY seq ASC
122 + LIMIT $4
123 + "#,
124 + )
125 + .bind(app_id)
126 + .bind(user_id)
127 + .bind(cursor)
128 + .bind(limit)
129 + .fetch_all(pool)
130 + .await?;
131 +
132 + Ok(entries)
133 + }
134 +
135 + /// Pull changes since a cursor with optional table and timestamp filters.
136 + ///
137 + /// When `tables` is `Some`, only entries whose `table_name` is in the list are returned.
138 + /// When `since` is `Some`, only entries with `client_timestamp >= since` are returned.
139 + /// Both filters compose (AND). Passing `None` for both is identical to `pull_sync_changes`.
140 + #[tracing::instrument(skip_all)]
141 + pub async fn pull_sync_changes_filtered(
142 + pool: &PgPool,
143 + app_id: SyncAppId,
144 + user_id: UserId,
145 + cursor: i64,
146 + limit: i64,
147 + tables: Option<&[String]>,
148 + since: Option<chrono::DateTime<chrono::Utc>>,
149 + ) -> Result<Vec<DbSyncLogEntry>> {
150 + let entries = sqlx::query_as::<_, DbSyncLogEntry>(
151 + r#"
152 + SELECT * FROM sync_log
153 + WHERE app_id = $1 AND user_id = $2 AND seq > $3
154 + AND ($5::text[] IS NULL OR table_name = ANY($5))
155 + AND ($6::timestamptz IS NULL OR client_timestamp >= $6)
156 + ORDER BY seq ASC
157 + LIMIT $4
158 + "#,
159 + )
160 + .bind(app_id)
161 + .bind(user_id)
162 + .bind(cursor)
163 + .bind(limit)
164 + .bind(tables)
165 + .bind(since)
166 + .fetch_all(pool)
167 + .await?;
168 +
169 + Ok(entries)
170 + }
171 +
@@ -0,0 +1,19 @@
1 + //! SyncKit: device registration, change log, blob storage, and encryption key
2 + //! management.
3 + //!
4 + //! Split by domain into submodules — all functions are re-exported flat so
5 + //! call sites keep using `db::synckit::<fn_name>(...)`.
6 +
7 + mod apps;
8 + mod blobs;
9 + mod devices;
10 + mod keys;
11 + mod log;
12 + mod rotation;
13 +
14 + pub use apps::*;
15 + pub use blobs::*;
16 + pub use devices::*;
17 + pub use keys::*;
18 + pub use log::*;
19 + pub use rotation::*;
@@ -0,0 +1,320 @@
1 + use serde_json::Value as JsonValue;
2 + use sqlx::PgPool;
3 +
4 + use crate::db::{SyncAppId, SyncDeviceId, UserId};
5 + use crate::error::Result;
6 +
7 + // ── Key Rotation ──
8 +
9 + /// Begin a key rotation. Returns the rotation row if created, or the existing
10 + /// row if this device already has an active rotation (resume support).
11 + ///
12 + /// Returns `None` if the key_version doesn't match (caller should 409).
13 + /// Returns `Err` if a different device has an active rotation.
14 + #[tracing::instrument(skip_all)]
15 + pub async fn begin_key_rotation(
16 + pool: &PgPool,
17 + app_id: SyncAppId,
18 + user_id: UserId,
19 + device_id: SyncDeviceId,
20 + new_encrypted_key: &str,
21 + expected_key_version: i32,
22 + ) -> Result<std::result::Result<crate::db::models::DbSyncKeyRotation, &'static str>> {
23 + // Verify key_version matches
24 + let key_row: Option<(i32, i32)> = sqlx::query_as(
25 + "SELECT key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2",
26 + )
27 + .bind(app_id)
28 + .bind(user_id)
29 + .fetch_optional(pool)
30 + .await?;
31 +
32 + let Some((current_version, current_key_id)) = key_row else {
33 + return Ok(Err("no encryption key exists"));
34 + };
35 +
36 + if current_version != expected_key_version {
37 + return Ok(Err("key version mismatch"));
38 + }
39 +
40 + // Check for existing rotation
41 + let existing = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>(
42 + "SELECT * FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2",
43 + )
44 + .bind(app_id)
45 + .bind(user_id)
46 + .fetch_optional(pool)
47 + .await?;
48 +
49 + if let Some(rotation) = existing {
50 + if rotation.device_id == device_id {
51 + // Same device resuming — return existing rotation
52 + return Ok(Ok(rotation));
53 + }
54 + return Ok(Err("rotation already in progress by another device"));
55 + }
56 +
57 + // Get target_seq (max seq for this user)
58 + let target_seq: i64 = sqlx::query_scalar(
59 + "SELECT COALESCE(MAX(seq), 0) FROM sync_log WHERE app_id = $1 AND user_id = $2",
60 + )
61 + .bind(app_id)
62 + .bind(user_id)
63 + .fetch_one(pool)
64 + .await?;
65 +
66 + let new_key_id = current_key_id + 1;
67 +
68 + let rotation = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>(
69 + r#"
70 + INSERT INTO sync_key_rotations (app_id, user_id, device_id, new_encrypted_key, old_key_version, new_key_id, target_seq)
71 + VALUES ($1, $2, $3, $4, $5, $6, $7)
72 + RETURNING *
73 + "#,
74 + )
75 + .bind(app_id)
76 + .bind(user_id)
77 + .bind(device_id)
78 + .bind(new_encrypted_key)
79 + .bind(current_version)
80 + .bind(new_key_id)
81 + .bind(target_seq)
82 + .fetch_one(pool)
83 + .await?;
84 +
85 + Ok(Ok(rotation))
86 + }
87 +
88 + /// Get the active rotation for a user, if any.
89 + #[tracing::instrument(skip_all)]
90 + pub async fn get_key_rotation(
91 + pool: &PgPool,
92 + app_id: SyncAppId,
93 + user_id: UserId,
94 + ) -> Result<Option<crate::db::models::DbSyncKeyRotation>> {
95 + let rotation = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>(
96 + "SELECT * FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2",
97 + )
98 + .bind(app_id)
99 + .bind(user_id)
100 + .fetch_optional(pool)
101 + .await?;
102 +
103 + Ok(rotation)
104 + }
105 +
106 + /// Pull sync log entries that need re-encryption (key_id != new_key_id).
107 + /// Returns entries ordered by seq, paginated by after_seq.
108 + #[tracing::instrument(skip_all)]
109 + pub async fn get_rotation_entries(
110 + pool: &PgPool,
111 + app_id: SyncAppId,
112 + user_id: UserId,
113 + new_key_id: i32,
114 + after_seq: i64,
115 + limit: i64,
116 + ) -> Result<Vec<(i64, Option<JsonValue>)>> {
117 + let entries: Vec<(i64, Option<JsonValue>)> = sqlx::query_as(
118 + r#"
119 + SELECT seq, data FROM sync_log
120 + WHERE app_id = $1 AND user_id = $2
121 + AND seq > $3
122 + AND (key_id IS NULL OR key_id != $5)
123 + ORDER BY seq ASC
124 + LIMIT $4
125 + "#,
126 + )
127 + .bind(app_id)
128 + .bind(user_id)
129 + .bind(after_seq)
130 + .bind(limit)
131 + .bind(new_key_id)
132 + .fetch_all(pool)
133 + .await?;
134 +
135 + Ok(entries)
136 + }
137 +
138 + /// Batch-update re-encrypted sync log entries during key rotation.
139 + /// Sets data and key_id for each (seq) in the batch.
140 + /// Returns the number of rows updated.
141 + #[tracing::instrument(skip_all)]
142 + pub async fn submit_rotation_batch(
143 + pool: &PgPool,
144 + app_id: SyncAppId,
145 + user_id: UserId,
146 + rotation_id: uuid::Uuid,
147 + new_key_id: i32,
148 + entries: &[(i64, Option<JsonValue>)],
149 + ) -> Result<u64> {
150 + if entries.is_empty() {
151 + return Ok(0);
152 + }
153 +
154 + let mut seqs: Vec<i64> = Vec::with_capacity(entries.len());
155 + let mut data_values: Vec<JsonValue> = Vec::with_capacity(entries.len());
156 + for (seq, data) in entries {
157 + seqs.push(*seq);
158 + data_values.push(data.clone().unwrap_or(JsonValue::Null));
159 + }
160 +
161 + let mut tx = pool.begin().await?;
162 +
163 + let updated = sqlx::query(
164 + r#"
165 + UPDATE sync_log AS sl
166 + SET data = CASE WHEN batch.new_data = 'null'::jsonb THEN NULL ELSE batch.new_data END,
167 + key_id = $3
168 + FROM UNNEST($4::bigint[], $5::jsonb[]) AS batch(seq, new_data)
169 + WHERE sl.seq = batch.seq AND sl.app_id = $1 AND sl.user_id = $2
170 + "#,
171 + )
172 + .bind(app_id)
173 + .bind(user_id)
174 + .bind(new_key_id)
175 + .bind(&seqs)
176 + .bind(&data_values)
177 + .execute(&mut *tx)
178 + .await?;
179 +
180 + // Update progress marker
181 + if let Some(&max_seq) = seqs.iter().max() {
182 + sqlx::query(
183 + r#"
184 + UPDATE sync_key_rotations
185 + SET re_encrypted_through_seq = GREATEST(re_encrypted_through_seq, $1),
186 + updated_at = NOW()
187 + WHERE id = $2
188 + "#,
189 + )
190 + .bind(max_seq)
191 + .bind(rotation_id)
192 + .execute(&mut *tx)
193 + .await?;
194 + }
195 +
196 + tx.commit().await?;
197 +
198 + Ok(updated.rows_affected())
199 + }
200 +
201 + /// Complete a key rotation: swap the new key into sync_keys and delete the rotation.
202 + /// Returns `Err("entries remain")` if un-rotated entries still exist, with the count.
203 + #[tracing::instrument(skip_all)]
204 + pub async fn complete_key_rotation(
205 + pool: &PgPool,
206 + app_id: SyncAppId,
207 + user_id: UserId,
208 + rotation_id: uuid::Uuid,
209 + ) -> Result<std::result::Result<i32, i64>> {
210 + let rotation = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>(
211 + "SELECT * FROM sync_key_rotations WHERE id = $1 AND app_id = $2 AND user_id = $3",
212 + )
213 + .bind(rotation_id)
214 + .bind(app_id)
215 + .bind(user_id)
216 + .fetch_optional(pool)
217 + .await?;
218 +
219 + let Some(rotation) = rotation else {
220 + return Ok(Err(0)); // No rotation found
221 + };
222 +
223 + // Check for remaining un-rotated entries up to the target_seq captured at
224 + // rotation start. Entries arriving after rotation began are excluded — they
225 + // will use the new key once rotation completes.
226 + let remaining: i64 = sqlx::query_scalar(
227 + r#"
228 + SELECT COUNT(*) FROM sync_log
229 + WHERE app_id = $1 AND user_id = $2
230 + AND seq <= $4
231 + AND (key_id IS NULL OR key_id != $3)
232 + "#,
233 + )
234 + .bind(app_id)
235 + .bind(user_id)
236 + .bind(rotation.new_key_id)
237 + .bind(rotation.target_seq)
238 + .fetch_one(pool)
239 + .await?;
240 +
241 + if remaining > 0 {
242 + return Ok(Err(remaining));
243 + }
244 +
245 + // Atomically swap key and delete rotation
246 + let mut tx = pool.begin().await?;
247 +
248 + sqlx::query(
249 + r#"
250 + UPDATE sync_keys
251 + SET encrypted_key = $3,
252 + key_version = key_version + 1,
253 + key_id = $4,
254 + updated_at = NOW()
255 + WHERE app_id = $1 AND user_id = $2
256 + "#,
257 + )
258 + .bind(app_id)
259 + .bind(user_id)
260 + .bind(&rotation.new_encrypted_key)
261 + .bind(rotation.new_key_id)
262 + .execute(&mut *tx)
263 + .await?;
264 +
265 + sqlx::query("DELETE FROM sync_key_rotations WHERE id = $1")
266 + .bind(rotation_id)
267 + .execute(&mut *tx)
268 + .await?;
269 +
270 + tx.commit().await?;
271 +
272 + Ok(Ok(rotation.new_key_id))
273 + }
274 +
275 + /// Cancel a stale rotation (only if older than the stale threshold).
276 + /// Returns true if cancelled, false if not found or not stale.
277 + #[tracing::instrument(skip_all)]
278 + pub async fn cancel_stale_rotation(
279 + pool: &PgPool,
280 + app_id: SyncAppId,
281 + user_id: UserId,
282 + stale_hours: i64,
283 + ) -> Result<bool> {
284 + let result = sqlx::query(
285 + r#"
286 + DELETE FROM sync_key_rotations
287 + WHERE app_id = $1 AND user_id = $2
288 + AND updated_at < NOW() - make_interval(hours => $3)
289 + "#,
290 + )
291 + .bind(app_id)
292 + .bind(user_id)
293 + .bind(stale_hours)
294 + .execute(pool)
295 + .await?;
296 +
297 + Ok(result.rows_affected() > 0)
298 + }
299 +
300 + /// Get sync status: total changes and latest seq for a user within an app.
301 + #[tracing::instrument(skip_all)]
302 + pub async fn get_sync_status(
303 + pool: &PgPool,
304 + app_id: SyncAppId,
305 + user_id: UserId,
306 + ) -> Result<(i64, Option<i64>)> {
307 + let row: (i64, Option<i64>) = sqlx::query_as(
308 + r#"
309 + SELECT COUNT(*), MAX(seq)
310 + FROM sync_log
311 + WHERE app_id = $1 AND user_id = $2
312 + "#,
313 + )
314 + .bind(app_id)
315 + .bind(user_id)
316 + .fetch_one(pool)
317 + .await?;
318 +
319 + Ok(row)
320 + }
@@ -72,51 +72,6 @@ pub async fn get_tags_for_items(
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 @@ mod tests {
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 @@ mod tests {
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 }
@@ -1,1088 +0,0 @@
1 - //! Templates for public-facing pages: landing, auth, content, blog, discover.
2 -
3 - use std::sync::Arc;
4 -
5 - use askama::Template;
6 -
7 - use crate::auth::SessionUser;
8 - use crate::git;
9 - use crate::types::*;
10 -
11 - use super::CsrfTokenOption;
12 -
13 - // ============================================================================
14 - // Public Pages
15 - // ============================================================================
16 -
17 - /// Sandbox info page explaining the ephemeral demo mode.
18 - #[derive(Template)]
19 - #[template(path = "pages/sandbox.html")]
20 - pub struct SandboxTemplate {
21 - pub csrf_token: CsrfTokenOption,
22 - }
23 -
24 - /// Content policy page.
25 - #[derive(Template)]
26 - #[template(path = "pages/policy.html")]
27 - pub struct PolicyTemplate {
28 - /// CSRF token injected into forms; `None` on public pages that have no forms.
29 - pub csrf_token: CsrfTokenOption,
30 - /// Logged-in user context for the site header; `None` when not authenticated.
31 - pub session_user: Option<SessionUser>,
32 - }
33 -
34 - /// Landing page.
35 - #[derive(Template)]
36 - #[template(path = "pages/index.html")]
37 - pub struct IndexTemplate {
38 - pub csrf_token: CsrfTokenOption,
39 - pub host_url: Arc<str>,
40 - pub total_creators: u32,
41 - pub total_items: u32,
42 - }
43 -
44 - /// User's library shell with inline purchases tab (other tabs loaded via HTMX).
45 - #[derive(Template)]
46 - #[template(path = "pages/library.html")]
47 - pub struct LibraryTemplate {
48 - pub csrf_token: CsrfTokenOption,
49 - pub session_user: Option<SessionUser>,
50 - pub purchases: Vec<crate::db::DbPurchaseRow>,
51 - pub subscriptions: Vec<UserSubscription>,
52 - pub has_mt_memberships: bool,
53 - }
54 -
55 - /// Shopping cart page with items grouped by seller.
56 - #[derive(Template)]
57 - #[template(path = "pages/cart.html")]
58 - pub struct CartTemplate {
59 - pub csrf_token: CsrfTokenOption,
60 - pub session_user: Option<SessionUser>,
61 - pub seller_groups: Vec<CartSellerGroup>,
62 - pub wishlist_suggestions: Vec<crate::db::wishlists::WishlistItem>,
63 - pub total_items: usize,
64 - /// Set to "partial" when a multi-seller checkout partially succeeded.
65 - pub checkout_status: String,
66 - }
67 -
68 - /// A group of cart items from the same seller.
69 - pub struct CartSellerGroup {
70 - pub seller_username: String,
71 - pub seller_id: String,
72 - pub stripe_ready: bool,
73 - pub items: Vec<crate::db::cart::CartItem>,
74 - pub subtotal_cents: i32,
75 - pub item_count: usize,
76 - /// How much the creator saves vs. individual purchases ($0.30 per extra item).
77 - pub savings_cents: i32,
78 - }
79 -
80 - /// Login page.
81 - #[derive(Template)]
82 - #[template(path = "pages/login.html")]
83 - pub struct LoginTemplate {
84 - pub csrf_token: CsrfTokenOption,
85 - }
86 -
87 - // ============================================================================
88 - // Join Wizard
89 - // ============================================================================
90 -
91 - /// Full page: join/signup wizard.
92 - #[derive(Template)]
93 - #[template(path = "wizards/wizard_join.html")]
94 - pub struct WizardJoinTemplate {
95 - pub csrf_token: CsrfTokenOption,
96 - pub nav: Vec<super::StepNavItem>,
97 - pub invite_code: Option<String>,
98 - }
99 -
100 - /// Step 1 partial: account creation (for back-nav reload).
101 - #[derive(Template)]
102 - #[template(path = "wizards/steps/join/account.html")]
103 - pub struct WizardJoinAccountTemplate {
104 - pub nav: Vec<super::StepNavItem>,
105 - pub csrf_token: CsrfTokenOption,
106 - pub invite_code: Option<String>,
107 - }
108 -
109 - /// Step 2 partial: profile (display name + bio).
110 - #[derive(Template)]
111 - #[template(path = "wizards/steps/join/profile.html")]
112 - pub struct WizardJoinProfileTemplate {
113 - pub nav: Vec<super::StepNavItem>,
114 - }
115 -
116 - /// Step 3 partial: welcome/complete with intent branching.
117 - #[derive(Template)]
118 - #[template(path = "wizards/steps/join/complete.html")]
119 - pub struct WizardJoinCompleteTemplate {
120 - pub nav: Vec<super::StepNavItem>,
121 - pub display_name: String,
122 - /// Whether this user already has creator access.
123 - pub is_creator: bool,
124 - /// Whether this user arrived via invite (already has waitlist entry).
125 - pub has_invite: bool,
126 - }
127 -
128 - /// Two-factor authentication verification page (login flow).
129 - #[derive(Template)]
130 - #[template(path = "pages/two_factor.html")]
131 - pub struct TwoFactorTemplate {
132 - pub csrf_token: CsrfTokenOption,
133 - pub session_user: Option<SessionUser>,
134 - pub error: Option<String>,
135 - }
136 -
137 - /// OAuth2 authorization / consent page.
138 - #[derive(Template)]
139 - #[template(path = "pages/oauth_authorize.html")]
140 - pub struct OAuthAuthorizeTemplate {
141 - pub csrf_token: CsrfTokenOption,
142 - pub session_user: Option<SessionUser>,
143 - pub app_name: String,
144 - pub client_id: String,
145 - pub redirect_uri: String,
146 - pub state: String,
147 - pub code_challenge: String,
148 - pub code_challenge_method: String,
149 - pub error_message: Option<String>,
150 - }
151 -
152 - /// Forgot password form.
153 - #[derive(Template)]
154 - #[template(path = "pages/forgot_password.html")]
155 - pub struct ForgotPasswordTemplate {
156 - pub csrf_token: CsrfTokenOption,
157 - }
158 -
159 - /// Password reset form (reached via email link).
160 - #[derive(Template)]
161 - #[template(path = "pages/reset_password.html")]
162 - pub struct ResetPasswordTemplate {
163 - pub csrf_token: CsrfTokenOption,
164 - pub valid: bool,
165 - pub user_id: String,
166 - pub expires: String,
167 - pub sig: String,
168 - }
169 -
170 - /// Public user profile page.
171 - #[derive(Template)]
172 - #[template(path = "pages/user.html")]
173 - #[allow(dead_code)] // Fields used by Askama template
174 - pub struct UserTemplate {
175 - pub csrf_token: CsrfTokenOption,
176 - pub session_user: Option<SessionUser>,
177 - pub user: User,
178 - pub custom_links: Vec<CustomLink>,
179 - pub projects: Vec<Project>,
180 - pub public_collections: Vec<Collection>,
181 - /// User ID for the follow button target.
182 - pub user_id: String,
183 - /// Whether the current viewer is looking at their own profile.
184 - pub is_own_profile: bool,
185 - /// Whether the current viewer is following this user.
186 - pub is_following: bool,
187 - /// Total follower count for this user.
188 - pub follower_count: i64,
189 - /// Base URL for OG meta tags.
190 - pub host_url: Arc<str>,
191 - /// Whether this creator has voluntarily paused their account.
192 - pub creator_paused: bool,
193 - /// Whether this creator accepts tips.
194 - pub tips_enabled: bool,
195 - /// Creator's user ID for tip checkout (string for template use).
196 - pub creator_id: String,
197 - /// Project ID for tip attribution (None on user profile pages).
198 - pub tip_project_id: Option<String>,
199 - }
200 -
201 - /// Public collection page (shareable URL).
202 - #[derive(Template)]
203 - #[template(path = "pages/collection.html")]
204 - #[allow(dead_code)] // Fields used by Askama template
205 - pub struct CollectionTemplate {
206 - pub csrf_token: CsrfTokenOption,
207 - pub session_user: Option<SessionUser>,
208 - pub collection: Collection,
209 - pub items: Vec<CollectionItem>,
210 - pub owner_username: String,
211 - pub owner_display_name: Option<String>,
212 - pub is_owner: bool,
213 - }
214 -
215 - /// Public project page with item listing.
216 - #[derive(Template)]
217 - #[template(path = "pages/project.html")]
218 - #[allow(dead_code)] // Fields used by Askama template
219 - pub struct ProjectTemplate {
220 - pub csrf_token: CsrfTokenOption,
221 - pub session_user: Option<SessionUser>,
222 - pub project: Project,
223 - pub creator_username: String,
224 - pub items: Vec<Item>,
225 - /// Project ID for the follow button target.
226 - pub project_id: String,
227 - /// Whether the current viewer is following this project.
228 - pub is_following: bool,
229 - /// Total follower count for this project.
230 - pub follower_count: i64,
231 - /// Active subscription tiers available for this project.
232 - pub subscription_tiers: Vec<SubscriptionTier>,
233 - /// Whether the current viewer already has an active subscription.
234 - pub has_subscription: bool,
235 - /// Base URL for OG meta tags.
236 - pub host_url: Arc<str>,
237 - /// Linked git repositories: (name, URL) pairs.
238 - pub git_repos: Vec<(String, String)>,
239 - /// Whether this project has any published blog posts.
240 - pub has_blog_posts: bool,
241 - /// URL to the paired MT community forum (None if no community provisioned).
242 - pub community_url: Option<String>,
243 - /// Whether the project owner accepts tips.
244 - pub tips_enabled: bool,
245 - /// Creator's user ID for tip checkout (string for template use).
246 - pub creator_id: String,
247 - /// Project ID for tip attribution.
248 - pub tip_project_id: Option<String>,
249 - /// Whether the current viewer owns this project.
250 - pub is_owner: bool,
251 - /// Tabbed markdown sections (privacy, terms, FAQ, etc).
252 - pub sections: Vec<crate::types::ProjectSection>,
253 - }
254 -
255 - /// Project paywall landing page (shown when a project requires purchase/subscription).
256 - #[derive(Template)]
257 - #[template(path = "pages/project_paywall.html")]
258 - pub struct ProjectPaywallTemplate {
259 - pub csrf_token: CsrfTokenOption,
260 - pub session_user: Option<SessionUser>,
261 - pub project: Project,
262 - pub creator_username: String,
263 - /// Human-readable pricing (e.g. "$19.99", "Subscription").
264 - pub price_display: String,
265 - /// What kind of checkout flow is needed.
266 - pub checkout_type: crate::pricing::CheckoutType,
267 - /// Available subscription tiers (for subscription-model projects).
268 - pub subscription_tiers: Vec<SubscriptionTier>,
269 - /// Base URL for OG meta tags.
270 - pub host_url: Arc<str>,
271 - }
272 -
273 - /// Public item detail page.
274 - #[derive(Template)]
275 - #[template(path = "pages/item.html")]
276 - #[allow(dead_code)] // Fields used by Askama template
277 - pub struct ItemTemplate {
278 - pub csrf_token: CsrfTokenOption,
279 - pub session_user: Option<SessionUser>,
280 - pub item: Item,
281 - pub creator_username: String,
282 - pub project_title: String,
283 - pub project_slug: String,
284 - pub versions: Vec<Version>,
285 - /// Base URL for OG meta tags.
286 - pub host_url: Arc<str>,
287 - /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
288 - pub discussion_url: Option<String>,
289 - /// Number of posts in the linked discussion thread.
290 - pub discussion_count: Option<i64>,
291 - /// Project cover image URL (fallback for og:image when item has no cover).
292 - pub project_cover_image_url: Option<String>,
293 - /// Child items for bundle-type items (empty for non-bundles).
294 - pub bundle_items: Vec<Item>,
295 - /// Bundles containing this item (for unlisted items, to show "Available in" links).
296 - pub containing_bundles: Vec<Item>,
297 - /// Tabbed content sections (e.g. Features, Installation, Specs).
298 - pub sections: Vec<ItemSection>,
299 - /// Whether the current user is the item's creator (for dashboard links).
300 - pub is_owner: bool,
301 - /// Whether the current user has wishlisted this item.
302 - pub is_wishlisted: bool,
303 - /// Whether the current user has this item in their cart.
304 - pub in_cart: bool,
305 - /// How many of the current user's collections contain this item.
306 - pub collection_count: u32,
307 - }
308 -
309 - /// Blog/article reader view.
310 - #[derive(Template)]
311 - #[template(path = "pages/text_reader.html")]
312 - #[allow(dead_code)] // Fields used by Askama template
313 - pub struct TextReaderTemplate {
314 - pub csrf_token: CsrfTokenOption,
315 - pub session_user: Option<SessionUser>,
316 - pub item: Item,
317 - pub creator_username: String,
318 - pub creator_display_name: Option<String>,
319 - /// First-letter initials for the avatar circle (e.g. "JD" for "Jane Doe").
320 - pub creator_avatar_initials: String,
321 - pub project_title: String,
322 - pub project_slug: String,
323 - /// Whether the item has a zero price (free content, no purchase required).
324 - pub is_free: bool,
325 - /// Whether the current user already has this item in their library.
326 - pub in_library: bool,
327 - /// Whether the current user can view the full content (purchased, free, or is the creator).
328 - pub has_access: bool,
329 - /// Base URL for OG meta tags.
330 - pub host_url: Arc<str>,
331 - /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
332 - pub discussion_url: Option<String>,
333 - /// Number of posts in the linked discussion thread.
334 - pub discussion_count: Option<i64>,
335 - }
336 -
337 - /// Audio streaming player view.
338 - #[derive(Template)]
339 - #[template(path = "pages/audio_player.html")]
340 - pub struct AudioPlayerTemplate {
341 - pub csrf_token: CsrfTokenOption,
342 - pub session_user: Option<SessionUser>,
343 - pub item: Item,
344 - pub creator_username: String,
345 - pub creator_display_name: Option<String>,
346 - /// First-letter initials for the avatar circle.
347 - pub creator_avatar_initials: String,
348 - pub project_title: Option<String>,
349 - pub project_slug: String,
350 - /// Pre-signed S3 URL for the audio file; `None` if no audio uploaded yet.
351 - pub audio_url: Option<String>,
352 - /// Timestamp-based chapter markers for the player seek bar.
353 - pub chapters: Vec<Chapter>,
354 - /// Whether the item has a zero price.
355 - pub is_free: bool,
356 - /// Whether the current user already has this item in their library.
357 - pub in_library: bool,
358 - /// Whether the current user can stream the audio (purchased, free, or is the creator).
359 - pub has_access: bool,
360 - /// JSON-encoded segment list for insertion playback. Empty string means no insertions.
361 - pub segments_json: String,
362 - /// Base URL for OG meta tags.
363 - pub host_url: Arc<str>,
364 - /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
365 - pub discussion_url: Option<String>,
366 - /// Number of posts in the linked discussion thread.
367 - pub discussion_count: Option<i64>,
368 - }
369 -
370 - /// Video player page with custom controls, insertions, chapters.
371 - #[derive(Template)]
372 - #[template(path = "pages/video_player.html")]
373 - pub struct VideoPlayerTemplate {
374 - pub csrf_token: CsrfTokenOption,
375 - pub session_user: Option<SessionUser>,
376 - pub item: Item,
377 - pub creator_username: String,
378 - pub creator_display_name: Option<String>,
379 - pub creator_avatar_initials: String,
380 - pub project_title: Option<String>,
381 - pub project_slug: String,
382 - /// Pre-signed S3 URL for the video file.
383 - pub video_url: Option<String>,
384 - pub chapters: Vec<Chapter>,
385 - pub is_free: bool,
386 - pub in_library: bool,
387 - pub has_access: bool,
388 - pub segments_json: String,
389 - pub host_url: Arc<str>,
390 - pub discussion_url: Option<String>,
391 - pub discussion_count: Option<i64>,
392 - }
393 -
394 - /// Browse/discover page with filtering and pagination.
395 - #[derive(Template)]
396 - #[template(path = "pages/discover.html")]
397 - pub struct DiscoverTemplate {
398 - pub csrf_token: CsrfTokenOption,
399 - pub session_user: Option<SessionUser>,
400 - pub items: Vec<DiscoverItem>,
401 - pub projects: Vec<DiscoverProject>,
402 - /// Active browse mode: `"items"` or `"projects"`.
403 - pub mode: String,
404 - /// Item type facets for sidebar (e.g. text, audio, download).
405 - pub type_filters: Vec<FilterCategory>,
406 - /// Tag facets for sidebar (top tags by count).
407 - pub tag_filters: Vec<FilterCategory>,
408 - /// Project category facets for sidebar (projects mode only).
409 - pub category_filters: Vec<FilterCategory>,
410 - pub price_filters: Vec<PriceFilter>,
411 - pub total_items: u32,
412 - pub current_page: u32,
413 - pub total_pages: u32,
414 - pub search_query: String,
415 - /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`).
416 - pub sort_by: String,
417 - /// Currently selected item_type filter, or empty for "All".
418 - pub current_type: String,
419 - /// Currently selected tag slug filter, or empty for "All".
420 - pub current_tag: String,
421 - /// Currently selected category slug filter, or empty for "All".
422 - pub current_category: String,
423 - /// Page numbers to render in the pagination bar.
424 - pub pagination_range: Vec<u32>,
425 - pub showing_start: u32,
426 - pub showing_end: u32,
427 - /// AI tier facets for sidebar (items mode only).
428 - pub ai_tier_filters: Vec<FilterCategory>,
429 - /// Currently selected AI tier filter slug, or empty for "All".
430 - pub current_ai_tier: String,
431 - /// Whether the "has source code" filter is active (projects mode).
432 - pub has_source: bool,
433 - /// Number of active filters (for mobile filter toggle badge).
434 - pub active_filter_count: u32,
435 - /// Whether the current user is authenticated (for collection save buttons in results).
436 - pub is_authenticated: bool,
437 - }
438 -
439 - /// Tag tree browser with breadcrumb navigation.
440 - #[derive(Template)]
441 - #[template(path = "pages/tag_tree.html")]
442 - pub struct TagTreeTemplate {
443 - pub csrf_token: CsrfTokenOption,
444 - pub session_user: Option<SessionUser>,
445 - pub categories: Vec<TagTreeNode>,
446 - pub breadcrumbs: Vec<TagBreadcrumb>,
447 - pub current_tag: Option<TagBreadcrumb>,
448 - }
449 -
450 - /// Purchase confirmation page showing fee breakdown.
451 - #[derive(Template)]
452 - #[template(path = "pages/purchase.html")]
453 - pub struct PurchaseTemplate {
454 - pub csrf_token: CsrfTokenOption,
455 - pub item: Item,
456 - pub creator_username: String,
457 - pub stripe_fee: String,
458 - pub creator_receives: String,
459 - /// Pre-filled promo code from `?code=` query parameter.
460 - pub promo_code: String,
461 - /// Whether PWYW pricing is enabled for this item.
462 - pub pwyw_enabled: bool,
463 - /// Minimum price in cents when PWYW is enabled.
464 - pub pwyw_min_cents: i32,
465 - /// Formatted suggested price in dollars (e.g. "9.99").
466 - pub suggested_price: String,
467 - /// Formatted minimum price in dollars (e.g. "1.00").
468 - pub pwyw_min_dollars: String,
469 - /// Whether the creator has Stripe Tax enabled.
470 - pub stripe_tax_enabled: bool,
471 - /// Whether the current visitor is logged in (show guest checkout if not).
472 - pub is_logged_in: bool,
473 - }
474 -
475 - /// Minimal direct purchase page — no navigation, for link-in-bio sharing.
476 - #[derive(Template)]
477 - #[template(path = "pages/buy.html")]
478 - pub struct BuyPageTemplate {
479 - pub item: Item,
480 - pub creator_username: String,
481 - pub creator_display_name: Option<String>,
482 - pub pwyw_enabled: bool,
483 - pub pwyw_min_dollars: String,
484 - pub suggested_price: String,
485 - pub host_url: Arc<str>,
486 - }
487 -
488 - /// Feed page showing items from followed users, projects, and tags.
489 - #[derive(Template)]
490 - #[template(path = "pages/feed.html")]
491 - pub struct FeedTemplate {
492 - pub csrf_token: CsrfTokenOption,
493 - pub session_user: Option<SessionUser>,
494 - pub items: Vec<DiscoverItem>,
495 - pub total_items: u32,
496 - pub current_page: u32,
497 - pub total_pages: u32,
498 - pub pagination_range: Vec<u32>,
499 - pub showing_start: u32,
500 - pub showing_end: u32,
Lines truncated
@@ -0,0 +1,250 @@
1 + //! Templates for the public git source browser, issues, and repo settings.
2 +
3 + use std::sync::Arc;
4 +
5 + use askama::Template;
6 +
7 + use crate::auth::SessionUser;
8 + use crate::git;
9 + use crate::types::*;
10 +
11 + use super::super::CsrfTokenOption;
12 +
13 + /// An item paired with its versions, for release display on the git repo page.
14 + pub struct ReleaseItem {
15 + pub item: Item,
16 + pub versions: Vec<Version>,
17 + }
18 +
19 + /// Repository overview: file tree at HEAD + README.
20 + #[derive(Template)]
21 + #[template(path = "pages/git/repo.html")]
22 + pub struct GitRepoTemplate {
23 + pub csrf_token: CsrfTokenOption,
24 + pub session_user: Option<SessionUser>,
25 + pub owner: String,
26 + pub repo_name: String,
27 + pub description: Option<String>,
28 + pub current_ref: String,
29 + pub refs: Vec<git::RefInfo>,
30 + pub tree_items: Vec<git::TreeItem>,
31 + pub readme_html: Option<String>,
32 + pub host_url: Arc<str>,
33 + /// Hostname for SSH clone URLs (e.g., "git.makenot.work"). Hidden when `None`.
34 + pub git_ssh_host: Option<String>,
35 + /// Linked project, if this repo is associated with a public project.
36 + pub linked_project: Option<Project>,
37 + /// Public items with versions from the linked project (releases).
38 + pub release_items: Vec<ReleaseItem>,
39 + /// Number of open issues for the nav bar badge.
40 + pub open_issue_count: i64,
41 + /// Whether the current viewer is the repo owner (for settings link).
42 + pub is_owner: bool,
43 + pub active_tab: &'static str,
44 + }
45 +
46 + /// Subdirectory listing with breadcrumb navigation.
47 + #[derive(Template)]
48 + #[template(path = "pages/git/tree.html")]
49 + pub struct GitTreeTemplate {
50 + pub csrf_token: CsrfTokenOption,
51 + pub session_user: Option<SessionUser>,
52 + pub owner: String,
53 + pub repo_name: String,
54 + pub current_ref: String,
55 + pub refs: Vec<git::RefInfo>,
56 + pub path: String,
57 + pub parent_path: String,
58 + /// Whether we're in a subdirectory (for ".." link and path joining).
59 + pub in_subdir: bool,
60 + pub breadcrumbs: Vec<git::Breadcrumb>,
61 + pub tree_items: Vec<git::TreeItem>,
62 + pub open_issue_count: i64,
63 + pub is_owner: bool,
64 + pub active_tab: &'static str,
65 + }
66 +
67 + /// File viewer with syntax highlighting and line numbers.
68 + #[derive(Template)]
69 + #[template(path = "pages/git/file.html")]
70 + pub struct GitFileTemplate {
71 + pub csrf_token: CsrfTokenOption,
72 + pub session_user: Option<SessionUser>,
73 + pub owner: String,
74 + pub repo_name: String,
75 + pub current_ref: String,
76 + pub refs: Vec<git::RefInfo>,
77 + pub file_path: String,
78 + pub filename: String,
79 + pub breadcrumbs: Vec<git::Breadcrumb>,
80 + pub file_size: String,
81 + pub line_count: usize,
82 + pub is_binary: bool,
83 + pub highlighted_lines: Vec<String>,
84 + pub open_issue_count: i64,
85 + pub is_owner: bool,
86 + pub active_tab: &'static str,
87 + }
88 +
89 + /// Commit log with pagination.
90 + #[derive(Template)]
91 + #[template(path = "pages/git/commits.html")]
92 + pub struct GitCommitsTemplate {
93 + pub csrf_token: CsrfTokenOption,
94 + pub session_user: Option<SessionUser>,
95 + pub owner: String,
96 + pub repo_name: String,
97 + pub current_ref: String,
98 + pub refs: Vec<git::RefInfo>,
99 + pub commits: Vec<git::CommitInfo>,
100 + pub page: usize,
101 + pub has_more: bool,
102 + pub open_issue_count: i64,
103 + /// Whether the current viewer is the repo owner (for settings link).
104 + pub is_owner: bool,
105 + pub active_tab: &'static str,
106 + }
107 +
108 + /// Commit detail page with inline diffs.
109 + #[derive(Template)]
110 + #[template(path = "pages/git/commit.html")]
111 + pub struct GitCommitDetailTemplate {
112 + pub csrf_token: CsrfTokenOption,
113 + pub session_user: Option<SessionUser>,
114 + pub owner: String,
115 + pub repo_name: String,
116 + pub current_ref: String,
117 + pub refs: Vec<git::RefInfo>,
118 + pub detail: git::CommitDetail,
119 + pub diff_files: Vec<git::DiffFile>,
120 + pub total_files: usize,
121 + pub total_additions: usize,
122 + pub total_deletions: usize,
123 + pub open_issue_count: i64,
124 + pub is_owner: bool,
125 + pub active_tab: &'static str,
126 + }
127 +
128 + /// Blame view for a single file.
129 + #[derive(Template)]
130 + #[template(path = "pages/git/blame.html")]
131 + pub struct GitBlameTemplate {
132 + pub csrf_token: CsrfTokenOption,
133 + pub session_user: Option<SessionUser>,
134 + pub owner: String,
135 + pub repo_name: String,
136 + pub current_ref: String,
137 + pub refs: Vec<git::RefInfo>,
138 + pub file_path: String,
139 + pub filename: String,
140 + pub breadcrumbs: Vec<git::Breadcrumb>,
141 + pub blame_lines: Vec<git::BlameLine>,
142 + pub open_issue_count: i64,
143 + pub is_owner: bool,
144 + pub active_tab: &'static str,
145 + }
146 +
147 + /// User's repository listing page.
148 + #[derive(Template)]
149 + #[template(path = "pages/git/repos.html")]
150 + pub struct GitUserReposTemplate {
151 + pub csrf_token: CsrfTokenOption,
152 + pub session_user: Option<SessionUser>,
153 + pub owner: String,
154 + pub repos: Vec<crate::db::DbGitRepo>,
155 + pub is_owner: bool,
156 + }
157 +
158 + /// Public explore page listing all public repos across all users.
159 + #[derive(Template)]
160 + #[template(path = "pages/git/explore.html")]
161 + pub struct GitExploreTemplate {
162 + pub csrf_token: CsrfTokenOption,
163 + pub session_user: Option<SessionUser>,
164 + pub repos: Vec<crate::db::git_repos::PublicRepoWithOwner>,
165 + pub page: usize,
166 + pub has_more: bool,
167 + pub total_count: i64,
168 + }
169 +
170 + /// Per-file commit history with breadcrumb context.
171 + #[derive(Template)]
172 + #[template(path = "pages/git/file_log.html")]
173 + pub struct GitFileLogTemplate {
174 + pub csrf_token: CsrfTokenOption,
175 + pub session_user: Option<SessionUser>,
176 + pub owner: String,
177 + pub repo_name: String,
178 + pub current_ref: String,
179 + pub refs: Vec<git::RefInfo>,
180 + pub file_path: String,
181 + pub filename: String,
182 + pub breadcrumbs: Vec<git::Breadcrumb>,
183 + pub commits: Vec<git::CommitInfo>,
184 + pub page: usize,
185 + pub has_more: bool,
186 + pub open_issue_count: i64,
187 + pub is_owner: bool,
188 + pub active_tab: &'static str,
189 + }
190 +
191 + // ============================================================================
192 + // Git Issues
193 + // ============================================================================
194 +
195 + /// Issue list page with status tabs and search (read-only).
196 + #[derive(Template)]
197 + #[template(path = "pages/git/issues.html")]
198 + pub struct GitIssueListTemplate {
199 + pub csrf_token: CsrfTokenOption,
200 + pub session_user: Option<SessionUser>,
201 + pub owner: String,
202 + pub repo_name: String,
203 + pub current_ref: String,
204 + pub issues: Vec<crate::db::DbIssueWithMeta>,
205 + pub open_count: i64,
206 + pub closed_count: i64,
207 + pub current_status: String,
208 + pub search_query: String,
209 + pub current_page: i64,
210 + pub total_pages: i64,
211 + /// Whether the current viewer is the repo owner (for settings link).
212 + pub is_owner: bool,
213 + /// Email address for submitting new issues.
214 + pub email_address: String,
215 + }
216 +
217 + /// Issue detail page with comments (read-only).
218 + #[derive(Template)]
219 + #[template(path = "pages/git/issue.html")]
220 + pub struct GitIssueDetailTemplate {
221 + pub csrf_token: CsrfTokenOption,
222 + pub session_user: Option<SessionUser>,
223 + pub owner: String,
224 + pub repo_name: String,
225 + pub current_ref: String,
226 + pub issue: crate::db::DbIssue,
227 + pub author_username: String,
228 + pub comments: Vec<crate::db::DbIssueCommentWithAuthor>,
229 + pub is_owner: bool,
230 + pub open_issue_count: i64,
231 + /// Email address for submitting new issues.
232 + pub email_address: String,
233 + }
234 +
235 + /// Repository settings page (owner only).
236 + #[derive(Template)]
237 + #[template(path = "pages/git/settings.html")]
238 + pub struct GitRepoSettingsTemplate {
239 + pub csrf_token: CsrfTokenOption,
240 + pub session_user: Option<SessionUser>,
241 + pub owner: String,
242 + pub repo_name: String,
243 + pub current_ref: String,
244 + pub repo: crate::db::DbGitRepo,
245 + pub open_issue_count: i64,
246 + /// Owner's projects for the link dropdown.
247 + pub projects: Vec<crate::db::DbProject>,
248 + /// ID of the currently linked project as a string (for dropdown comparison).
249 + pub linked_project_id: String,
250 + }