max / makenotwork
10 files changed,
+178 insertions,
-71 deletions
| @@ -0,0 +1,33 @@ | |||
| 1 | + | -- Cover liveness via a bare S3 key, replacing fragile URL-suffix matching. | |
| 2 | + | -- | |
| 3 | + | -- The deletion worker's liveness check (`db::pending_s3_deletions::is_s3_key_live`) | |
| 4 | + | -- exact-matches a stored bare key for every S3 key class EXCEPT covers, which it | |
| 5 | + | -- had to LIKE-suffix-match against a full `cover_image_url`, with hand-rolled | |
| 6 | + | -- wildcard escaping and `/`-anchoring. `items.cover_s3_key` already exists | |
| 7 | + | -- (migration 053) and is written by the item-image confirm path; `projects` | |
| 8 | + | -- stored only the URL. Give projects a bare key column too, backfill both tables' | |
| 9 | + | -- legacy rows from the canonical `{cdn_base}/{s3_key}` URL form, and the registry | |
| 10 | + | -- switches both cover classes to exact matching (the UrlSuffix matcher and its | |
| 11 | + | -- escaping are deleted from the code in the same change). | |
| 12 | + | ||
| 13 | + | ALTER TABLE projects ADD COLUMN IF NOT EXISTS cover_s3_key TEXT; | |
| 14 | + | ||
| 15 | + | -- Backfill from the canonical URL form: strip scheme + host (and any query | |
| 16 | + | -- string) to recover the object key. Stored cover URLs are always | |
| 17 | + | -- `{cdn_base}/{s3_key}`, so the path after the host IS the key. Only touch rows | |
| 18 | + | -- that have a URL but no key yet; a URL that isn't an http(s) path with a | |
| 19 | + | -- non-empty path is left NULL (nothing for the worker to protect). | |
| 20 | + | UPDATE projects | |
| 21 | + | SET cover_s3_key = substring(cover_image_url from '^https?://[^/]+/([^?]*)') | |
| 22 | + | WHERE cover_image_url IS NOT NULL | |
| 23 | + | AND cover_s3_key IS NULL | |
| 24 | + | AND cover_image_url ~ '^https?://[^/]+/[^?]+'; | |
| 25 | + | ||
| 26 | + | -- Legacy items predating migration 053 may carry a cover_image_url with a NULL | |
| 27 | + | -- cover_s3_key; backfill them the same way so dropping the items URL-suffix | |
| 28 | + | -- registry entry can't strand a still-live cover. | |
| 29 | + | UPDATE items | |
| 30 | + | SET cover_s3_key = substring(cover_image_url from '^https?://[^/]+/([^?]*)') | |
| 31 | + | WHERE cover_image_url IS NOT NULL | |
| 32 | + | AND cover_s3_key IS NULL | |
| 33 | + | AND cover_image_url ~ '^https?://[^/]+/[^?]+'; |
| @@ -86,47 +86,36 @@ pub async fn move_to_dead_letter(pool: &PgPool, ids: &[Uuid]) -> Result<u64> { | |||
| 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 @@ pub async fn is_s3_key_live(pool: &PgPool, bucket: &str, s3_key: &str) -> Result | |||
| 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 @@ pub async fn update_project_image_url<'e>( | |||
| 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 @@ pub async fn update_project_cover_cas<'e>( | |||
| 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 @@ pub async fn update_project_cover_cas<'e>( | |||
| 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 |
| @@ -275,7 +275,20 @@ async fn save_appearance( | |||
| 275 | 275 | { | |
| 276 | 276 | return Err(AppError::validation("Invalid cover image URL")); | |
| 277 | 277 | } | |
| 278 | - | db::projects::update_project_image_url(&state.db, project.id, project.user_id, image_url).await?; | |
| 278 | + | // Recover the bare s3_key from the canonical `{cdn_base}/{key}` URL so the | |
| 279 | + | // deletion worker can match the cover by exact key (migration 152). None | |
| 280 | + | // in dev/no-CDN (presigned URL) — acceptable, there is no live worker there. | |
| 281 | + | // The validated URL lives under the CDN base, so the CDN-prefix branch | |
| 282 | + | // recovers the key; bucket/endpoint (path-style fallback) aren't needed. | |
| 283 | + | let cover_s3_key = crate::storage::extract_s3_key_from_url( | |
| 284 | + | image_url, | |
| 285 | + | state.config.cdn_base_url.as_deref(), | |
| 286 | + | None, | |
| 287 | + | None, | |
| 288 | + | ); | |
| 289 | + | db::projects::update_project_image_url( | |
| 290 | + | &state.db, project.id, project.user_id, image_url, cover_s3_key.as_deref(), | |
| 291 | + | ).await?; | |
| 279 | 292 | } | |
| 280 | 293 | Ok(()) | |
| 281 | 294 | } |
| @@ -237,7 +237,7 @@ pub(super) async fn project_image_confirm( | |||
| 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); |
| @@ -241,18 +241,9 @@ pub(super) async fn media_confirm( | |||
| 241 | 241 | // gate (Run #22 Storage MED). Sniff the object's leading bytes; if it is | |
| 242 | 242 | // detectably a different audio/visual category than declared, reject + orphan. | |
| 243 | 243 | { | |
| 244 | - | let mut stream = s3.download_stream(&req.s3_key).await?; | |
| 245 | - | let mut head: Vec<u8> = Vec::with_capacity(4096); | |
| 246 | - | loop { | |
| 247 | - | if head.len() >= 4096 { | |
| 248 | - | break; | |
| 249 | - | } | |
| 250 | - | match stream.try_next().await { | |
| 251 | - | Ok(Some(chunk)) => head.extend_from_slice(&chunk), | |
| 252 | - | Ok(None) => break, | |
| 253 | - | Err(e) => return Err(AppError::Storage(format!("read media header from S3: {e}"))), | |
| 254 | - | } | |
| 255 | - | } | |
| 244 | + | // Ranged read of just the header — a 4 KB sniff must not transfer the | |
| 245 | + | // whole (up to 20 GB) object. Production issues `Range: bytes=0-4095`. | |
| 246 | + | let head = s3.download_head(&req.s3_key, 4096).await?; | |
| 256 | 247 | let detected = infer::get(&head).map(|kind| kind.matcher_type()); | |
| 257 | 248 | // `media_type` is only ever "image" or "video" (see `classify_media`). | |
| 258 | 249 | let mismatch = match media_type { |
| @@ -321,6 +321,23 @@ pub trait StorageBackend: Send + Sync { | |||
| 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 @@ impl S3Client { | |||
| 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 @@ impl StorageBackend for S3Client { | |||
| 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 | } |
| @@ -838,3 +838,35 @@ async fn is_s3_key_live_covers_main_bucket() { | |||
| 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 @@ async fn update_project_cover_cas_guards_against_stale_and_non_owner() { | |||
| 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 @@ impl S3Client { | |||
| 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 |