max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
10 files changed,
+178 insertions,
-71 deletions
| @@ -321,6 +321,23 @@ | |||
| 321 | 321 | /// drive the stream to disk (scanner spool) or to a layer that consumes | |
| 322 | 322 | /// chunks directly. | |
| 323 | 323 | async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>; | |
| 324 | + | /// Read up to the first `len` bytes of an object. Production overrides this | |
| 325 | + | /// with a ranged `GetObject` so a content sniff transfers only the header, | |
| 326 | + | /// not the whole object. The default streams and stops early — correct, but | |
| 327 | + | /// it still initiates a full GET — which is fine for in-memory test backends. | |
| 328 | + | async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> { | |
| 329 | + | let mut stream = self.download_stream(s3_key).await?; | |
| 330 | + | let mut head = Vec::with_capacity(len.min(64 * 1024)); | |
| 331 | + | while head.len() < len { | |
| 332 | + | match stream.try_next().await { | |
| 333 | + | Ok(Some(chunk)) => head.extend_from_slice(&chunk), | |
| 334 | + | Ok(None) => break, | |
| 335 | + | Err(e) => return Err(AppError::Storage(format!("read object head from S3: {e}"))), | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | head.truncate(len); | |
| 339 | + | Ok(head) | |
| 340 | + | } | |
| 324 | 341 | async fn upload_object(&self, s3_key: &S3Key, content_type: &str, data: Vec<u8>, cache_control: Option<&str>) -> Result<()>; | |
| 325 | 342 | /// Delete an object. Requires an [`S3DeleteAuthority`] — route handlers | |
| 326 | 343 | /// cannot mint one, so they must enqueue through `pending_s3_deletions` | |
| @@ -642,6 +659,14 @@ | |||
| 642 | 659 | .map_err(AppError::Storage) | |
| 643 | 660 | } | |
| 644 | 661 | ||
| 662 | + | /// Read the first `len` bytes via a ranged S3 GET (for content sniffing). | |
| 663 | + | pub async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> { | |
| 664 | + | self.inner | |
| 665 | + | .download_head(s3_key, len) | |
| 666 | + | .await | |
| 667 | + | .map_err(AppError::Storage) | |
| 668 | + | } | |
| 669 | + | ||
| 645 | 670 | /// Upload an object to S3 from bytes | |
| 646 | 671 | pub async fn upload_object( | |
| 647 | 672 | &self, | |
| @@ -857,6 +882,10 @@ | |||
| 857 | 882 | self.download_stream(s3_key).await | |
| 858 | 883 | } | |
| 859 | 884 | ||
| 885 | + | async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> { | |
| 886 | + | self.download_head(s3_key, len).await | |
| 887 | + | } | |
| 888 | + | ||
| 860 | 889 | async fn upload_object(&self, s3_key: &S3Key, content_type: &str, data: Vec<u8>, cache_control: Option<&str>) -> Result<()> { | |
| 861 | 890 | self.upload_object(s3_key, content_type, data, cache_control).await | |
| 862 | 891 | } |
| @@ -86,47 +86,36 @@ | |||
| 86 | 86 | Ok(result.rows_affected()) | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | /// How a table column references an S3 object key. | |
| 90 | - | #[derive(Clone, Copy)] | |
| 91 | - | enum S3KeyMatch { | |
| 92 | - | /// `column = $1` — the column stores the bare s3_key. | |
| 93 | - | Exact, | |
| 94 | - | /// `column LIKE $2 ESCAPE '\'` — the column stores a full URL ending in the | |
| 95 | - | /// s3_key (e.g. a CDN cover-image URL). The bound value is `%{escaped_key}`. | |
| 96 | - | UrlSuffix, | |
| 97 | - | } | |
| 98 | - | ||
| 99 | 89 | /// One place a live S3 object key can still be referenced. [`is_s3_key_live`] is | |
| 100 | 90 | /// generated from this registry, so a new s3_key-bearing table is added in | |
| 101 | 91 | /// exactly one place AND declares its own bucket — it can't be checked against | |
| 102 | 92 | /// the wrong bucket (the OTA-key-checked-in-the-main-branch bug this replaced) | |
| 103 | - | /// or silently forgotten by one branch of a hand-written query. | |
| 93 | + | /// or silently forgotten by one branch of a hand-written query. Every registered | |
| 94 | + | /// column stores a bare s3_key, so the check is always `column = $1` — covers | |
| 95 | + | /// gained `cover_s3_key` in migration 152, retiring the old URL-suffix match. | |
| 104 | 96 | struct S3KeyRef { | |
| 105 | 97 | bucket: &'static str, | |
| 106 | 98 | table: &'static str, | |
| 107 | 99 | column: &'static str, | |
| 108 | - | matching: S3KeyMatch, | |
| 109 | 100 | } | |
| 110 | 101 | ||
| 111 | 102 | /// The registry. To add a table that stores an S3 key, add a row here. | |
| 112 | 103 | const S3_KEY_REFS: &[S3KeyRef] = &[ | |
| 113 | 104 | // ── main bucket: creator content ── | |
| 114 | - | S3KeyRef { bucket: "main", table: "media_files", column: "s3_key", matching: S3KeyMatch::Exact }, | |
| 115 | - | S3KeyRef { bucket: "main", table: "versions", column: "s3_key", matching: S3KeyMatch::Exact }, | |
| 116 | - | S3KeyRef { bucket: "main", table: "items", column: "audio_s3_key", matching: S3KeyMatch::Exact }, | |
| 117 | - | S3KeyRef { bucket: "main", table: "items", column: "cover_s3_key", matching: S3KeyMatch::Exact }, | |
| 118 | - | S3KeyRef { bucket: "main", table: "items", column: "video_s3_key", matching: S3KeyMatch::Exact }, | |
| 119 | - | // cover_image_url stores a full CDN URL, not a bare key; image keys live | |
| 120 | - | // under their own `.../image/` prefix so a suffix match is unambiguous. | |
| 121 | - | S3KeyRef { bucket: "main", table: "items", column: "cover_image_url", matching: S3KeyMatch::UrlSuffix }, | |
| 122 | - | S3KeyRef { bucket: "main", table: "projects", column: "cover_image_url", matching: S3KeyMatch::UrlSuffix }, | |
| 123 | - | // gallery images store the bare s3_key directly (not a URL) → exact match. | |
| 124 | - | S3KeyRef { bucket: "main", table: "item_images", column: "s3_key", matching: S3KeyMatch::Exact }, | |
| 125 | - | S3KeyRef { bucket: "main", table: "project_images", column: "s3_key", matching: S3KeyMatch::Exact }, | |
| 126 | - | S3KeyRef { bucket: "main", table: "content_insertions", column: "storage_key", matching: S3KeyMatch::Exact }, | |
| 105 | + | S3KeyRef { bucket: "main", table: "media_files", column: "s3_key" }, | |
| 106 | + | S3KeyRef { bucket: "main", table: "versions", column: "s3_key" }, | |
| 107 | + | S3KeyRef { bucket: "main", table: "items", column: "audio_s3_key" }, | |
| 108 | + | S3KeyRef { bucket: "main", table: "items", column: "cover_s3_key" }, | |
| 109 | + | S3KeyRef { bucket: "main", table: "items", column: "video_s3_key" }, | |
| 110 | + | // Covers store the bare key directly (migration 152 added projects.cover_s3_key; | |
| 111 | + | // items.cover_s3_key predates it). | |
| 112 | + | S3KeyRef { bucket: "main", table: "projects", column: "cover_s3_key" }, | |
| 113 | + | S3KeyRef { bucket: "main", table: "item_images", column: "s3_key" }, | |
| 114 | + | S3KeyRef { bucket: "main", table: "project_images", column: "s3_key" }, | |
| 115 | + | S3KeyRef { bucket: "main", table: "content_insertions", column: "storage_key" }, | |
| 127 | 116 | // ── synckit bucket: SyncKit blobs + OTA artifacts (both deterministic keys) ── | |
| 128 | - | S3KeyRef { bucket: "synckit", table: "sync_blobs", column: "s3_key", matching: S3KeyMatch::Exact }, | |
| 129 | - | S3KeyRef { bucket: "synckit", table: "ota_artifacts", column: "s3_key", matching: S3KeyMatch::Exact }, | |
| 117 | + | S3KeyRef { bucket: "synckit", table: "sync_blobs", column: "s3_key" }, | |
| 118 | + | S3KeyRef { bucket: "synckit", table: "ota_artifacts", column: "s3_key" }, | |
| 130 | 119 | ]; | |
| 131 | 120 | ||
| 132 | 121 | /// Returns true if any live row in `bucket` still references `s3_key`. Used by | |
| @@ -156,33 +145,18 @@ | |||
| 156 | 145 | } | |
| 157 | 146 | ||
| 158 | 147 | // Table/column names are compile-time constants from S3_KEY_REFS (never user | |
| 159 | - | // input); the key value is always a bound parameter ($1 exact, $2 url-suffix). | |
| 160 | - | let needs_url_suffix = refs.iter().any(|r| matches!(r.matching, S3KeyMatch::UrlSuffix)); | |
| 148 | + | // input); the key value is always the bound parameter $1. Every class stores | |
| 149 | + | // a bare key, so liveness is a flat OR of equality checks. | |
| 161 | 150 | let clauses: Vec<String> = refs | |
| 162 | 151 | .iter() | |
| 163 | - | .map(|r| match r.matching { | |
| 164 | - | S3KeyMatch::Exact => format!("EXISTS(SELECT 1 FROM {} WHERE {} = $1)", r.table, r.column), | |
| 165 | - | S3KeyMatch::UrlSuffix => { | |
| 166 | - | format!("EXISTS(SELECT 1 FROM {} WHERE {} LIKE $2 ESCAPE '\\')", r.table, r.column) | |
| 167 | - | } | |
| 168 | - | }) | |
| 152 | + | .map(|r| format!("EXISTS(SELECT 1 FROM {} WHERE {} = $1)", r.table, r.column)) | |
| 169 | 153 | .collect(); | |
| 170 | 154 | let sql = format!("SELECT {}", clauses.join(" OR ")); | |
| 171 | 155 | ||
| 172 | - | let mut query = sqlx::query_scalar::<_, bool>(&sql).bind(s3_key); | |
| 173 | - | if needs_url_suffix { | |
| 174 | - | // Escape LIKE wildcards so a key with a literal `_`/`%` (from a user | |
| 175 | - | // folder name) can't false-positive against neighbouring rows. | |
| 176 | - | let escaped = s3_key.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); | |
| 177 | - | // Anchor on the leading path separator. Stored values are always | |
| 178 | - | // `{cdn_base}/{s3_key}`, so the key is preceded by `/` in every live | |
| 179 | - | // URL. Without the anchor, `LIKE '%u/1/cover.png'` also matches an | |
| 180 | - | // unrelated row ending `.../OTHERu/1/cover.png`, falsely marking a dead | |
| 181 | - | // object live and suppressing its deletion (orphan leak). Requiring the | |
| 182 | - | // `/` boundary makes the substring match a true path-suffix match. | |
| 183 | - | query = query.bind(format!("%/{escaped}")); | |
| 184 | - | } | |
| 185 | - | Ok(query.fetch_one(pool).await?) | |
| 156 | + | Ok(sqlx::query_scalar::<_, bool>(&sql) | |
| 157 | + | .bind(s3_key) | |
| 158 | + | .fetch_one(pool) | |
| 159 | + | .await?) | |
| 186 | 160 | } | |
| 187 | 161 | ||
| 188 | 162 | /// Fetch stale pending deletions (older than min_age, up to limit). |
| @@ -362,11 +362,16 @@ | |||
| 362 | 362 | id: ProjectId, | |
| 363 | 363 | user_id: UserId, | |
| 364 | 364 | url: &str, | |
| 365 | + | cover_s3_key: Option<&str>, | |
| 365 | 366 | ) -> Result<bool> { | |
| 366 | - | let result = sqlx::query("UPDATE projects SET cover_image_url = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3") | |
| 367 | + | // Store the bare key alongside the URL so the deletion worker's liveness | |
| 368 | + | // check matches it exactly (no URL-suffix parsing). The wizard derives the | |
| 369 | + | // key from the CDN URL it just validated; `None` only when clearing. | |
| 370 | + | let result = sqlx::query("UPDATE projects SET cover_image_url = $1, cover_s3_key = $4, updated_at = NOW() WHERE id = $2 AND user_id = $3") | |
| 367 | 371 | .bind(url) | |
| 368 | 372 | .bind(id) | |
| 369 | 373 | .bind(user_id) | |
| 374 | + | .bind(cover_s3_key) | |
| 370 | 375 | .execute(executor) | |
| 371 | 376 | .await?; | |
| 372 | 377 | ||
| @@ -394,11 +399,14 @@ | |||
| 394 | 399 | user_id: UserId, | |
| 395 | 400 | expected_old_url: Option<&str>, | |
| 396 | 401 | url: &str, | |
| 402 | + | cover_s3_key: &str, | |
| 397 | 403 | file_size_bytes: i64, | |
| 398 | 404 | ) -> Result<bool> { | |
| 405 | + | // Record the bare key (the confirm handler's `req.s3_key`) so deletion-worker | |
| 406 | + | // liveness is an exact key match, not a URL-suffix match. | |
| 399 | 407 | let result = sqlx::query( | |
| 400 | 408 | r#"UPDATE projects | |
| 401 | - | SET cover_image_url = $1, cover_image_size_bytes = $4, updated_at = NOW() | |
| 409 | + | SET cover_image_url = $1, cover_s3_key = $6, cover_image_size_bytes = $4, updated_at = NOW() | |
| 402 | 410 | WHERE id = $2 AND user_id = $3 | |
| 403 | 411 | AND cover_image_url IS NOT DISTINCT FROM $5"#, | |
| 404 | 412 | ) | |
| @@ -407,6 +415,7 @@ | |||
| 407 | 415 | .bind(user_id) | |
| 408 | 416 | .bind(file_size_bytes) | |
| 409 | 417 | .bind(expected_old_url) | |
| 418 | + | .bind(cover_s3_key) | |
| 410 | 419 | .execute(executor) | |
| 411 | 420 | .await?; | |
| 412 | 421 |
| @@ -838,3 +838,35 @@ | |||
| 838 | 838 | "an unreferenced main-bucket key must not be reported live" | |
| 839 | 839 | ); | |
| 840 | 840 | } | |
| 841 | + | ||
| 842 | + | #[tokio::test] | |
| 843 | + | async fn is_s3_key_live_matches_project_cover_by_bare_key() { | |
| 844 | + | // Migration 152: project covers store a bare `cover_s3_key` and liveness is | |
| 845 | + | // an exact key match (no URL-suffix matching). A project whose cover key is | |
| 846 | + | // set must report that key live, regardless of the cover_image_url value. | |
| 847 | + | let mut h = TestHarness::new().await; | |
| 848 | + | let user_id = h.signup("coverliveuser", "coverlive@example.com", "Password1!").await; | |
| 849 | + | let pid: uuid::Uuid = sqlx::query_scalar( | |
| 850 | + | "INSERT INTO projects (user_id, slug, title, cover_image_url, cover_s3_key) \ | |
| 851 | + | VALUES ($1, 'cov', 'Cov', 'https://cdn.example/abc/projects/p/image/c.png', $2) RETURNING id", | |
| 852 | + | ) | |
| 853 | + | .bind(user_id) | |
| 854 | + | .bind("abc/projects/p/image/c.png") | |
| 855 | + | .fetch_one(&h.db) | |
| 856 | + | .await | |
| 857 | + | .unwrap(); | |
| 858 | + | let _ = pid; | |
| 859 | + | ||
| 860 | + | assert!( | |
| 861 | + | makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "main", "abc/projects/p/image/c.png") | |
| 862 | + | .await | |
| 863 | + | .unwrap(), | |
| 864 | + | "a project cover's bare s3_key must be reported live" | |
| 865 | + | ); | |
| 866 | + | assert!( | |
| 867 | + | !makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "main", "abc/projects/p/image/OTHER.png") | |
| 868 | + | .await | |
| 869 | + | .unwrap(), | |
| 870 | + | "a non-matching cover key must not be reported live" | |
| 871 | + | ); | |
| 872 | + | } |
| @@ -998,34 +998,35 @@ | |||
| 998 | 998 | let owner = UserId::from(uuid::Uuid::parse_str(&user_id).unwrap()); | |
| 999 | 999 | ||
| 1000 | 1000 | // cover_image_url is NULL. A stale expected url matches zero rows. | |
| 1001 | - | let ok = update_project_cover_cas(&h.db, pid, owner, Some("stale_url"), "url_v1", 1000) | |
| 1001 | + | let ok = update_project_cover_cas(&h.db, pid, owner, Some("stale_url"), "url_v1", "key_v1", 1000) | |
| 1002 | 1002 | .await | |
| 1003 | 1003 | .unwrap(); | |
| 1004 | 1004 | assert!(!ok, "a stale expected url must match zero rows"); | |
| 1005 | 1005 | ||
| 1006 | 1006 | // Correct CAS (expected None) commits. | |
| 1007 | - | let ok = update_project_cover_cas(&h.db, pid, owner, None, "url_v1", 1000).await.unwrap(); | |
| 1007 | + | let ok = update_project_cover_cas(&h.db, pid, owner, None, "url_v1", "key_v1", 1000).await.unwrap(); | |
| 1008 | 1008 | assert!(ok); | |
| 1009 | 1009 | ||
| 1010 | 1010 | // Stale-None loses now that the cover is set. | |
| 1011 | - | let ok = update_project_cover_cas(&h.db, pid, owner, None, "url_v2", 2000).await.unwrap(); | |
| 1011 | + | let ok = update_project_cover_cas(&h.db, pid, owner, None, "url_v2", "key_v2", 2000).await.unwrap(); | |
| 1012 | 1012 | assert!(!ok, "stale-None confirm must lose once the cover is set"); | |
| 1013 | 1013 | ||
| 1014 | 1014 | // A non-owner cannot write, even with the correct expected url. | |
| 1015 | 1015 | let stranger = UserId::new(); | |
| 1016 | - | let ok = update_project_cover_cas(&h.db, pid, stranger, Some("url_v1"), "url_v3", 3000) | |
| 1016 | + | let ok = update_project_cover_cas(&h.db, pid, stranger, Some("url_v1"), "url_v3", "key_v3", 3000) | |
| 1017 | 1017 | .await | |
| 1018 | 1018 | .unwrap(); | |
| 1019 | 1019 | assert!(!ok, "a non-owner must not write the project cover"); | |
| 1020 | 1020 | ||
| 1021 | - | let (url, sz): (Option<String>, Option<i64>) = sqlx::query_as( | |
| 1022 | - | "SELECT cover_image_url, cover_image_size_bytes FROM projects WHERE id = $1::uuid", | |
| 1021 | + | let (url, key, sz): (Option<String>, Option<String>, Option<i64>) = sqlx::query_as( | |
| 1022 | + | "SELECT cover_image_url, cover_s3_key, cover_image_size_bytes FROM projects WHERE id = $1::uuid", | |
| 1023 | 1023 | ) | |
| 1024 | 1024 | .bind(&project_id) | |
| 1025 | 1025 | .fetch_one(&h.db) | |
| 1026 | 1026 | .await | |
| 1027 | 1027 | .unwrap(); | |
| 1028 | 1028 | assert_eq!(url.as_deref(), Some("url_v1")); | |
| 1029 | + | assert_eq!(key.as_deref(), Some("key_v1"), "the bare cover key is persisted alongside the url"); | |
| 1029 | 1030 | assert_eq!(sz, Some(1000)); | |
| 1030 | 1031 | } | |
| 1031 | 1032 |
| @@ -160,6 +160,31 @@ | |||
| 160 | 160 | Ok(resp.body) | |
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | + | /// Download only the first `len` bytes of an object via a ranged | |
| 164 | + | /// `GetObject` (`Range: bytes=0-{len-1}`). Used for content sniffing so a | |
| 165 | + | /// 4 KB header read doesn't initiate a transfer of the whole object. Returns | |
| 166 | + | /// fewer bytes if the object is smaller than `len`. | |
| 167 | + | pub async fn download_head(&self, key: &str, len: usize) -> Result<Vec<u8>, String> { | |
| 168 | + | if len == 0 { | |
| 169 | + | return Ok(Vec::new()); | |
| 170 | + | } | |
| 171 | + | let resp = self | |
| 172 | + | .client | |
| 173 | + | .get_object() | |
| 174 | + | .bucket(&self.bucket) | |
| 175 | + | .key(key) | |
| 176 | + | .range(format!("bytes=0-{}", len - 1)) | |
| 177 | + | .send() | |
| 178 | + | .await | |
| 179 | + | .map_err(|e| format!("S3 ranged download failed: {e}"))?; | |
| 180 | + | let data = resp | |
| 181 | + | .body | |
| 182 | + | .collect() | |
| 183 | + | .await | |
| 184 | + | .map_err(|e| format!("S3 ranged body read failed: {e}"))?; | |
| 185 | + | Ok(data.to_vec()) | |
| 186 | + | } | |
| 187 | + | ||
| 163 | 188 | /// Delete an object from S3. | |
| 164 | 189 | pub async fn delete(&self, key: &str) -> Result<(), String> { | |
| 165 | 190 | self.client |
| @@ -237,7 +237,7 @@ | |||
| 237 | 237 | ).await?; | |
| 238 | 238 | let ok = db::projects::update_project_cover_cas( | |
| 239 | 239 | &mut *tx, req.project_id, user.id, | |
| 240 | - | project.cover_image_url.as_deref(), &image_url, file_size_bytes, | |
| 240 | + | project.cover_image_url.as_deref(), &image_url, &req.s3_key, file_size_bytes, | |
| 241 | 241 | ).await?; | |
| 242 | 242 | if !ok { | |
| 243 | 243 | return Ok(false); |