Skip to main content

max / makenotwork

Split storage.rs, and let only key.rs mint an S3Key 2318 lines holding five concerns: the key newtype, the upload allow-lists, the bucket and its delete authority, the storage trait, and the S3 client. The interesting constraint is that `S3Key`'s tuple field is private and the 13 key generators mint one by direct construction, so they cannot live in a sibling of the file that declares it. They do not need to: an inherent impl may live in any module of the defining crate, so key.rs owns the newtype and its generators together as a second `impl S3Client`. The field stays private and only key.rs can mint a key, which is tighter than before, where all 2318 lines could. The "exactly two ways to build a key" doc at the top is now true of a 400-line file rather than a promise made across 2318. The two grep-based seals move to their own file rather than into tests.rs: they are lints over src/routes/, not unit tests, and they resolve their target from CARGO_MANIFEST_DIR, so moving the file does not move what they scan. Their two byte-identical `walk` copies become one. Same 61 tests, all 77 under storage:: passing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 02:43 UTC
Signed with PGP, not checked
Commit: 115ca79dca7b8f753aa3b95cae926d9accc52d52
Parent: 315b8a0
10 files changed, +2265 insertions, -500 deletions
@@ -1,2318 +1,0 @@
1 - //! S3-compatible storage client for Hetzner Object Storage
2 - //!
3 - //! Provides presigned URL generation for client-direct uploads/downloads.
4 - //! Delegates S3 operations to the shared `s3_storage` crate.
5 - //!
6 - //! See also: `/docs/guide/audio`, `/docs/guide/video`, `/docs/guide/software`
7 -
8 - use std::str::FromStr;
9 -
10 - use crate::config::StorageConfig;
11 - use crate::constants;
12 - use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId};
13 - use crate::error::{AppError, Result};
14 -
15 - /// A storage object key. There are exactly two ways to obtain one, and an
16 - /// ad-hoc `format!("...")` is neither:
17 - ///
18 - /// 1. A `S3Client::generate_*` constructor, the single, reviewed home for key
19 - /// *layout*. Multi-instance kinds (versions, gallery, media) take their
20 - /// uniqueness segment (a table PK or a fresh uuid) as a required argument, so
21 - /// a collidable key cannot be built; singleton kinds (audio/cover/video, OTA
22 - /// artifacts) are one-per-parent and correctly overwrite-on-replace.
23 - /// 2. [`S3Key::from_stored`], the named trust boundary for a key that already
24 - /// exists in our storage (read back from a DB row). The caller asserts it was
25 - /// minted by a generator at write time; this is how delete/download/re-presign
26 - /// paths address objects without re-deriving their layout.
27 - ///
28 - /// Because every write/presign/delete on [`StorageBackend`] takes `&S3Key`, a
29 - /// hand-built string can never reach S3, the OTA-style inline `format!` key
30 - /// (which bypassed the generators) is now uncompilable.
31 - #[derive(Debug, Clone, PartialEq, Eq, Hash, sqlx::Type)]
32 - #[sqlx(transparent)]
33 - pub struct S3Key(String);
34 -
35 - impl S3Key {
36 - /// Wrap a key read back from durable storage (a DB row). Names the trust
37 - /// boundary: the caller asserts this key was minted by a `generate_*`
38 - /// constructor when the object was written, not freshly invented here.
39 - pub fn from_stored(key: impl AsRef<str>) -> Self {
40 - S3Key(key.as_ref().to_string())
41 - }
42 -
43 - pub fn as_str(&self) -> &str {
44 - &self.0
45 - }
46 -
47 - pub fn into_string(self) -> String {
48 - self.0
49 - }
50 - }
51 -
52 - impl std::ops::Deref for S3Key {
53 - type Target = str;
54 - fn deref(&self) -> &str {
55 - &self.0
56 - }
57 - }
58 -
59 - impl AsRef<str> for S3Key {
60 - fn as_ref(&self) -> &str {
61 - &self.0
62 - }
63 - }
64 -
65 - impl std::fmt::Display for S3Key {
66 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 - f.write_str(&self.0)
68 - }
69 - }
70 -
71 - impl PartialEq<&str> for S3Key {
72 - fn eq(&self, other: &&str) -> bool {
73 - self.0 == *other
74 - }
75 - }
76 -
77 - /// Allowed audio file extensions and their MIME types
78 - const ALLOWED_AUDIO_TYPES: &[(&str, &str)] = &[
79 - ("mp3", "audio/mpeg"),
80 - ("wav", "audio/wav"),
81 - ("m4a", "audio/mp4"),
82 - ("ogg", "audio/ogg"),
83 - ("flac", "audio/flac"),
84 - ("aac", "audio/aac"),
85 - ];
86 -
87 - /// Allowed image file extensions and their MIME types
88 - const ALLOWED_IMAGE_TYPES: &[(&str, &str)] = &[
89 - ("jpg", "image/jpeg"),
90 - ("jpeg", "image/jpeg"),
91 - ("png", "image/png"),
92 - ("webp", "image/webp"),
93 - ("gif", "image/gif"),
94 - ];
95 -
96 - /// Allowed video file extensions and their MIME types
97 - const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[
98 - ("mp4", "video/mp4"),
99 - ("webm", "video/webm"),
100 - ("mov", "video/quicktime"),
101 - ];
102 -
103 - /// Allowed insertion-clip extensions and MIME types. A clip may be audio (the
104 - /// original use: intros, sponsor reads) or video (pre/mid/post-roll on a video
105 - /// item), so this is the union of the audio and video allow-lists. Kept as one
106 - /// literal because `allowed_types()` returns a `&'static` slice.
107 - const ALLOWED_INSERTION_TYPES: &[(&str, &str)] = &[
108 - ("mp3", "audio/mpeg"),
109 - ("wav", "audio/wav"),
110 - ("m4a", "audio/mp4"),
111 - ("ogg", "audio/ogg"),
112 - ("flac", "audio/flac"),
113 - ("aac", "audio/aac"),
114 - ("mp4", "video/mp4"),
115 - ("webm", "video/webm"),
116 - ("mov", "video/quicktime"),
117 - ];
118 -
119 - /// MIME types accepted for video uploads
120 - const ALLOWED_VIDEO_MIMES: &[&str] = &["video/mp4", "video/webm", "video/quicktime"];
121 -
122 - /// Allowed download file extensions and their MIME types
123 - /// Browsers are inconsistent about MIME types for binary downloads,
124 - /// so we accept several common types for each extension.
125 - const ALLOWED_DOWNLOAD_TYPES: &[(&str, &str)] = &[
126 - ("zip", "application/zip"),
127 - ("dmg", "application/x-apple-diskimage"),
128 - ("exe", "application/octet-stream"),
129 - ("appimage", "application/octet-stream"),
130 - ("deb", "application/octet-stream"),
131 - ("clap", "application/octet-stream"),
132 - ("vst3", "application/octet-stream"),
133 - ];
134 -
135 - /// MIME types accepted for download uploads (browsers vary widely)
136 - const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[
137 - "application/octet-stream",
138 - "application/zip",
139 - "application/x-zip-compressed",
140 - "application/x-apple-diskimage",
141 - "application/x-diskcopy",
142 - "application/x-msi",
143 - "application/x-ole-storage",
144 - "application/gzip",
145 - "application/x-tar",
146 - "application/x-gtar",
147 - "application/x-compressed",
148 - "application/x-executable",
149 - "application/x-deb",
150 - "application/vnd.debian.binary-package",
151 - ];
152 -
153 - /// Allowed download file extensions (checked separately from MIME)
154 - const ALLOWED_DOWNLOAD_EXTENSIONS: &[&str] = &[
155 - "zip", "dmg", "exe", "msi", "appimage", "deb", "tar.gz", "clap", "vst3",
156 - ];
157 -
158 - /// Maximum file sizes in bytes
159 - const MAX_AUDIO_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
160 - const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
161 - const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
162 - const MAX_INSERTION_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
163 - const MAX_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
164 - const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
165 - const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
166 -
167 - /// Extensions an Alloy hotfix RPM repository serves. The package itself, the
168 - /// `createrepo_c` metadata under `repodata/` (XML, in whatever compression the
169 - /// generator chose, or the sqlite variants), and the detached signature and
170 - /// public key that go beside `repomd.xml`. Anything else is a publish mistake:
171 - /// nothing in `dnf`'s fetch path asks for it, so serving it is pure surface.
172 - const RPM_REPO_EXTENSIONS: &[&str] = &[
173 - "rpm", "xml", "zst", "gz", "xz", "bz2", "sqlite", "asc", "key", "sig", "yaml",
174 - ];
175 -
176 - /// Default presigned URL expiration.
177 - /// 1 hour balances usability (large uploads over slow connections) against
178 - /// security (limiting the window for URL leakage). Overridable per-call.
179 - const PRESIGN_EXPIRY_SECS: u64 = 3600;
180 -
181 - /// File type categories for upload
182 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
183 - pub enum FileType {
184 - Audio,
185 - Cover,
186 - Download,
187 - Insertion,
188 - Video,
189 - /// Media library image (user-scoped, for inline markdown content).
190 - MediaImage,
191 - /// Media library video (user-scoped, for inline markdown content).
192 - MediaVideo,
193 - }
194 -
195 - /// How the generic `/api/upload/confirm` handler confirms a file type onto an
196 - /// `items` row. Returned by [`FileType::generic_item_confirm`], whose `match`
197 - /// is exhaustive, adding a `FileType` variant fails the build until its
198 - /// posture is declared here, so a new type can't silently fall into the wrong
199 - /// column set (a `Cover` branch that writes `cover_s3_key` but never
200 - /// `cover_image_url` leaves an invisible cover).
201 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
202 - pub enum GenericItemConfirm {
203 - /// Confirmable by the generic handler: write these two columns on the item.
204 - /// Only types fully described by `(s3_key, size)` belong here, anything
205 - /// that needs an extra column (e.g. a CDN render URL) must use a dedicated
206 - /// route instead.
207 - Columns {
208 - s3_key: &'static str,
209 - size: &'static str,
210 - },
211 - /// Not confirmable by the generic handler, use this dedicated route. The
212 - /// handler rejects the request (after cleaning up the staged object) so a
213 - /// misrouted confirm never half-writes the row.
214 - UseRoute(&'static str),
215 - }
216 -
217 - impl FileType {
218 - /// Declare, exhaustively, how `/api/upload/confirm` treats this type.
219 - /// See [`GenericItemConfirm`] for why this is the single source of truth.
220 - pub fn generic_item_confirm(self) -> GenericItemConfirm {
221 - match self {
222 - FileType::Audio => GenericItemConfirm::Columns {
223 - s3_key: "audio_s3_key",
224 - size: "audio_file_size_bytes",
225 - },
226 - FileType::Video => GenericItemConfirm::Columns {
227 - s3_key: "video_s3_key",
228 - size: "video_file_size_bytes",
229 - },
230 - // Cover IS confirmable, but it must also set `cover_image_url` (the
231 - // CDN render source). The generic two-column writer can't, so covers
232 - // go through the dedicated route that writes all three atomically.
233 - FileType::Cover => GenericItemConfirm::UseRoute("/api/items/image/confirm"),
234 - FileType::Download => {
235 - GenericItemConfirm::UseRoute("/api/versions/{version_id}/upload/*")
236 - }
237 - FileType::Insertion => GenericItemConfirm::UseRoute("/api/users/me/insertions/*"),
238 - FileType::MediaImage | FileType::MediaVideo => {
239 - GenericItemConfirm::UseRoute("/api/media/*")
240 - }
241 - }
242 - }
243 -
244 - pub fn as_str(&self) -> &'static str {
245 - match self {
246 - FileType::Audio => "audio",
247 - FileType::Cover => "cover",
248 - FileType::Download => "download",
249 - FileType::Insertion => "insertion",
250 - FileType::Video => "video",
251 - FileType::MediaImage => "media_image",
252 - FileType::MediaVideo => "media_video",
253 - }
254 - }
255 -
256 - pub fn max_size(&self) -> u64 {
257 - match self {
258 - FileType::Audio => MAX_AUDIO_SIZE,
259 - FileType::Cover => MAX_IMAGE_SIZE,
260 - FileType::Download => MAX_DOWNLOAD_SIZE,
261 - FileType::Insertion => MAX_INSERTION_SIZE,
262 - FileType::Video => MAX_VIDEO_SIZE,
263 - FileType::MediaImage => MAX_MEDIA_IMAGE_SIZE,
264 - FileType::MediaVideo => MAX_MEDIA_VIDEO_SIZE,
265 - }
266 - }
267 -
268 - pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] {
269 - match self {
270 - FileType::Audio => ALLOWED_AUDIO_TYPES,
271 - FileType::Insertion => ALLOWED_INSERTION_TYPES,
272 - FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES,
273 - FileType::Download => ALLOWED_DOWNLOAD_TYPES,
274 - FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES,
275 - }
276 - }
277 - }
278 -
279 - impl FromStr for FileType {
280 - type Err = String;
281 -
282 - fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
283 - match s.to_lowercase().as_str() {
284 - "audio" => Ok(FileType::Audio),
285 - "cover" | "image" => Ok(FileType::Cover),
286 - "download" => Ok(FileType::Download),
287 - "insertion" => Ok(FileType::Insertion),
288 - "video" => Ok(FileType::Video),
289 - "media_image" => Ok(FileType::MediaImage),
290 - "media_video" => Ok(FileType::MediaVideo),
291 - _ => Err(format!("Invalid file type: {s}")),
292 - }
293 - }
294 - }
295 -
296 - /// Cache-Control value for immutable content (builds, audio, covers).
297 - /// One year with immutable directive, Cloudflare and browsers cache indefinitely.
298 - pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
299 -
300 - /// Capability proof required by every `StorageBackend` delete method.
301 - ///
302 - /// Direct S3 deletion is sealed off from route handlers: the delete methods
303 - /// take `&S3DeleteAuthority`, so an accidental `s3.delete_object(key)` from a
304 - /// handler does not compile. Route code must instead enqueue through
305 - /// `pending_s3_deletions` (e.g. `routes::storage::enqueue_s3_orphan`), whose
306 - /// worker applies the `is_s3_key_live` guard before deleting, so a handler
307 - /// cannot blind-delete a key a live row still references.
308 - ///
309 - /// Minting is `pub(crate)` and confined by convention to the durable-deletion
310 - /// paths, the scheduler deletion worker + cleanup (`scheduler/cleanup.rs`) and
311 - /// the malware-quarantine scan worker (`scanning/worker.rs`). The build-time
312 - /// guard test `routes_never_delete_s3_directly` fails if any file under
313 - /// `src/routes/` names a delete method or mints an authority, so the seal can't
314 - /// silently erode.
315 - pub struct S3DeleteAuthority(());
316 -
317 - impl S3DeleteAuthority {
318 - /// Mint a deletion authority. Restricted to the sanctioned durable-deletion
319 - /// paths; see the type docs. Route handlers cannot reach a sanctioned path,
320 - /// and the guard test enforces that they don't mint one anyway.
321 - pub(crate) fn new() -> Self {
322 - S3DeleteAuthority(())
323 - }
324 - }
325 -
326 - /// Which configured S3 backend an object lives in.
327 - ///
328 - /// The delete *verb* is type-sealed by [`S3DeleteAuthority`]; this seals the
329 - /// bucket *noun*. The `pending_s3_deletions` queue stores the bucket as text, and
330 - /// the deletion worker dispatches between the main and SyncKit S3 clients on that
331 - /// text. This enum is the single source of truth for the `"main"`/`"synckit"`
332 - /// spellings so an orphan-enqueue can't silently mis-tag a SyncKit object as
333 - /// `main` (where the worker would delete it against the wrong client and leak it
334 - /// forever), `enqueue_s3_orphan` requires an `S3Bucket`, not a bare string.
335 - #[derive(Clone, Copy, Debug, PartialEq, Eq)]
336 - pub enum S3Bucket {
337 - Main,
338 - Synckit,
339 - /// Public, CDN-served bucket (`cdn.makenot.work`). Holds ONLY the
340 - /// immutably-public image kinds after promote (covers, gallery, item/project
341 - /// images, content insertions); a paid object can never enter it, so its
342 - /// blanket public-read policy is safe by construction. Staging is never here
343 - ///, unscanned bytes stay in `Main`; the content object lands here only via
344 - /// the cross-bucket promote (see `scanning::promote_staging_to_content`).
345 - Public,
346 - }
347 -
348 - impl S3Bucket {
349 - /// The stored/text spelling for the deletion queue.
350 - pub fn as_str(self) -> &'static str {
351 - match self {
352 - S3Bucket::Main => "main",
353 - S3Bucket::Synckit => "synckit",
354 - S3Bucket::Public => "public",
355 - }
356 - }
357 -
358 - /// Parse a bucket tag read back from the queue. Unknown/legacy values map to
359 - /// `Main` (the historical default), so a garbled row is still reaped against
360 - /// a backend rather than wedging the queue.
361 - pub fn from_db_str(s: &str) -> Self {
362 - match s {
363 - "synckit" => S3Bucket::Synckit,
364 - "public" => S3Bucket::Public,
365 - _ => S3Bucket::Main,
366 - }
367 - }
368 - }
369 -
370 - /// Deletion enqueue pair for a content-image key whose promote state is unknown.
371 - ///
372 - /// A CDN-image key is a private **staging** key (`staging/...`, in `Main`) until
373 - /// [`crate::scanning::promote_staging_to_content`] repoints it to the public
374 - /// **content** key (`{user}/c/{sha}.ext`, in `Public`). A given object is in
375 - /// EXACTLY one bucket, but a delete/replace path can run in either state, so it
376 - /// can't know which. Enqueue the key under BOTH buckets: the reaper deletes from
377 - /// the bucket the object is in and no-ops the other (content keys are unique to
378 - /// one bucket), and `is_s3_key_live` still guards each bucket against a live
379 - /// reference. Only for the four CDN-served image surfaces; gated media
380 - /// (audio/video/version/media) is always `Main` and insertions always `Main`.
381 - pub fn both_bucket_delete(key: &str) -> [(String, String); 2] {
382 - [
383 - (key.to_string(), S3Bucket::Main.as_str().to_string()),
384 - (key.to_string(), S3Bucket::Public.as_str().to_string()),
385 - ]
386 - }
387 -
388 - /// Aggregate a `ByteStream` into memory, aborting once more than `max_bytes`
389 - /// have been read. Backs [`StorageBackend::download_object_buf_capped`]; factored
390 - /// out as a free function so the cap logic is unit-testable without a full
391 - /// backend. `label` is only used in the error message (the object key).
392 - pub(crate) async fn read_bytestream_capped(
393 - mut stream: s3_storage::ByteStream,
394 - label: &str,
395 - max_bytes: u64,
396 - ) -> Result<bytes::Bytes> {
397 - let mut buf = bytes::BytesMut::new();
398 - let mut read: u64 = 0;
399 - loop {
400 - match stream.try_next().await {
401 - Ok(Some(chunk)) => {
402 - read += chunk.len() as u64;
403 - if read > max_bytes {
404 - return Err(AppError::Storage(format!(
405 - "object {label} exceeds scan in-memory cap ({read} > {max_bytes} bytes); \
406 - recorded size under-reported the real object"
407 - )));
408 - }
409 - buf.extend_from_slice(&chunk);
410 - }
411 - Ok(None) => break,
412 - Err(e) => return Err(AppError::Storage(format!("read object from S3: {e}"))),
413 - }
414 - }
415 - Ok(buf.freeze())
416 - }
417 -
418 - /// Abstract storage backend, implemented by `S3Client` (production) and
419 - /// `InMemoryStorage` (tests). Routes access storage through this trait.
420 - #[async_trait::async_trait]
421 - pub trait StorageBackend: Send + Sync {
422 - /// Generate a presigned upload URL. `max_bytes`, when set, is signed into
423 - /// the URL as `Content-Length` so S3 itself enforces the size cap at the
424 - /// protocol level (prevents oversized PUTs from burning bandwidth before
425 - /// hitting the post-PUT delete-and-charge fallback).
426 - async fn presign_upload(
427 - &self,
428 - s3_key: &S3Key,
429 - content_type: &str,
430 - expiry_secs: Option<u64>,
431 - cache_control: Option<&str>,
432 - max_bytes: Option<i64>,
433 - ) -> Result<String>;
434 - async fn presign_download(&self, s3_key: &S3Key, expiry_secs: Option<u64>) -> Result<String>;
435 - async fn object_exists(&self, s3_key: &str) -> Result<bool>;
436 - async fn object_size(&self, s3_key: &str) -> Result<Option<i64>>;
437 - async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>>;
438 - /// Download as `bytes::Bytes`, no `to_vec` copy of the aggregated body.
439 - /// Memory-sensitive callers (the scanner's buffered branch) use this so the
440 - /// payload isn't transiently doubled.
441 - async fn download_object_buf(&self, s3_key: &str) -> Result<bytes::Bytes>;
442 - /// Stream the object body without buffering the whole payload. Callers
443 - /// drive the stream to disk (scanner spool) or to a layer that consumes
444 - /// chunks directly.
445 - async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>;
446 - /// Download into memory like [`download_object_buf`], but abort if the body
447 - /// exceeds `max_bytes`. The scanner routes files it *recorded* as small to an
448 - /// in-memory branch, but `file_size_bytes` is asserted at upload time and can
449 - /// under-report the real object; this bounds the aggregation so a mis-recorded
450 - /// or abusive object can't pull an unbounded body into RAM, the independent
451 - /// ceiling the spool path already enforces. Streams via
452 - /// `download_stream`, so no backend can hand back the whole body up front.
453 - async fn download_object_buf_capped(
454 - &self,
455 - s3_key: &str,
456 - max_bytes: u64,
457 - ) -> Result<bytes::Bytes> {
458 - let stream = self.download_stream(s3_key).await?;
459 - read_bytestream_capped(stream, s3_key, max_bytes).await
460 - }
461 - /// Read up to the first `len` bytes of an object. Production overrides this
462 - /// with a ranged `GetObject` so a content sniff transfers only the header,
463 - /// not the whole object. The default streams and stops early, correct, but
464 - /// it still initiates a full GET, which is fine for in-memory test backends.
465 - async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> {
466 - let mut stream = self.download_stream(s3_key).await?;
467 - let mut head = Vec::with_capacity(len.min(64 * 1024));
468 - while head.len() < len {
469 - match stream.try_next().await {
470 - Ok(Some(chunk)) => head.extend_from_slice(&chunk),
471 - Ok(None) => break,
472 - Err(e) => return Err(AppError::Storage(format!("read object head from S3: {e}"))),
473 - }
474 - }
475 - head.truncate(len);
476 - Ok(head)
477 - }
478 - async fn upload_object(
479 - &self,
480 - s3_key: &S3Key,
481 - content_type: &str,
482 - data: Vec<u8>,
483 - cache_control: Option<&str>,
484 - ) -> Result<()>;
485 - /// Delete an object. Requires an [`S3DeleteAuthority`], route handlers
486 - /// cannot mint one, so they must enqueue through `pending_s3_deletions`
487 - /// instead of deleting directly.
488 - async fn delete_object(&self, auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()>;
489 - /// Delete a batch of objects in a single S3 `DeleteObjects` request
490 - /// (up to 1000 keys/call). Default loops `delete_object` so test backends
491 - /// don't have to implement it, but production should override.
492 - async fn delete_objects(&self, auth: &S3DeleteAuthority, keys: &[S3Key]) -> Result<()> {
493 - let mut failed = 0usize;
494 - for k in keys {
495 - if let Err(e) = self.delete_object(auth, k).await {
496 - failed += 1;
497 - tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed");
498 - }
499 - }
500 - // Don't report success when every key failed, a total failure must
Lines truncated
@@ -1,0 +1,232 @@
1 + //! The storage trait every backend implements, and the capped read every
2 + //! caller of it goes through.
3 +
4 + use super::bucket::S3DeleteAuthority;
5 + use super::key::S3Key;
6 + use crate::error::{AppError, Result};
7 +
8 + /// Aggregate a `ByteStream` into memory, aborting once more than `max_bytes`
9 + /// have been read. Backs [`StorageBackend::download_object_buf_capped`]; factored
10 + /// out as a free function so the cap logic is unit-testable without a full
11 + /// backend. `label` is only used in the error message (the object key).
12 + pub(crate) async fn read_bytestream_capped(
13 + mut stream: s3_storage::ByteStream,
14 + label: &str,
15 + max_bytes: u64,
16 + ) -> Result<bytes::Bytes> {
17 + let mut buf = bytes::BytesMut::new();
18 + let mut read: u64 = 0;
19 + loop {
20 + match stream.try_next().await {
21 + Ok(Some(chunk)) => {
22 + read += chunk.len() as u64;
23 + if read > max_bytes {
24 + return Err(AppError::Storage(format!(
25 + "object {label} exceeds scan in-memory cap ({read} > {max_bytes} bytes); \
26 + recorded size under-reported the real object"
27 + )));
28 + }
29 + buf.extend_from_slice(&chunk);
30 + }
31 + Ok(None) => break,
32 + Err(e) => return Err(AppError::Storage(format!("read object from S3: {e}"))),
33 + }
34 + }
35 + Ok(buf.freeze())
36 + }
37 +
38 + /// Abstract storage backend, implemented by `S3Client` (production) and
39 + /// `InMemoryStorage` (tests). Routes access storage through this trait.
40 + #[async_trait::async_trait]
41 + pub trait StorageBackend: Send + Sync {
42 + /// Generate a presigned upload URL. `max_bytes`, when set, is signed into
43 + /// the URL as `Content-Length` so S3 itself enforces the size cap at the
44 + /// protocol level (prevents oversized PUTs from burning bandwidth before
45 + /// hitting the post-PUT delete-and-charge fallback).
46 + async fn presign_upload(
47 + &self,
48 + s3_key: &S3Key,
49 + content_type: &str,
50 + expiry_secs: Option<u64>,
51 + cache_control: Option<&str>,
52 + max_bytes: Option<i64>,
53 + ) -> Result<String>;
54 + async fn presign_download(&self, s3_key: &S3Key, expiry_secs: Option<u64>) -> Result<String>;
55 + async fn object_exists(&self, s3_key: &str) -> Result<bool>;
56 + async fn object_size(&self, s3_key: &str) -> Result<Option<i64>>;
57 + async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>>;
58 + /// Download as `bytes::Bytes`, no `to_vec` copy of the aggregated body.
59 + /// Memory-sensitive callers (the scanner's buffered branch) use this so the
60 + /// payload isn't transiently doubled.
61 + async fn download_object_buf(&self, s3_key: &str) -> Result<bytes::Bytes>;
62 + /// Stream the object body without buffering the whole payload. Callers
63 + /// drive the stream to disk (scanner spool) or to a layer that consumes
64 + /// chunks directly.
65 + async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>;
66 + /// Download into memory like [`download_object_buf`], but abort if the body
67 + /// exceeds `max_bytes`. The scanner routes files it *recorded* as small to an
68 + /// in-memory branch, but `file_size_bytes` is asserted at upload time and can
69 + /// under-report the real object; this bounds the aggregation so a mis-recorded
70 + /// or abusive object can't pull an unbounded body into RAM, the independent
71 + /// ceiling the spool path already enforces. Streams via
72 + /// `download_stream`, so no backend can hand back the whole body up front.
73 + async fn download_object_buf_capped(
74 + &self,
75 + s3_key: &str,
76 + max_bytes: u64,
77 + ) -> Result<bytes::Bytes> {
78 + let stream = self.download_stream(s3_key).await?;
79 + read_bytestream_capped(stream, s3_key, max_bytes).await
80 + }
81 + /// Read up to the first `len` bytes of an object. Production overrides this
82 + /// with a ranged `GetObject` so a content sniff transfers only the header,
83 + /// not the whole object. The default streams and stops early, correct, but
84 + /// it still initiates a full GET, which is fine for in-memory test backends.
85 + async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> {
86 + let mut stream = self.download_stream(s3_key).await?;
87 + let mut head = Vec::with_capacity(len.min(64 * 1024));
88 + while head.len() < len {
89 + match stream.try_next().await {
90 + Ok(Some(chunk)) => head.extend_from_slice(&chunk),
91 + Ok(None) => break,
92 + Err(e) => return Err(AppError::Storage(format!("read object head from S3: {e}"))),
93 + }
94 + }
95 + head.truncate(len);
96 + Ok(head)
97 + }
98 + async fn upload_object(
99 + &self,
100 + s3_key: &S3Key,
101 + content_type: &str,
102 + data: Vec<u8>,
103 + cache_control: Option<&str>,
104 + ) -> Result<()>;
105 + /// Delete an object. Requires an [`S3DeleteAuthority`], route handlers
106 + /// cannot mint one, so they must enqueue through `pending_s3_deletions`
107 + /// instead of deleting directly.
108 + async fn delete_object(&self, auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()>;
109 + /// Delete a batch of objects in a single S3 `DeleteObjects` request
110 + /// (up to 1000 keys/call). Default loops `delete_object` so test backends
111 + /// don't have to implement it, but production should override.
112 + async fn delete_objects(&self, auth: &S3DeleteAuthority, keys: &[S3Key]) -> Result<()> {
113 + let mut failed = 0usize;
114 + for k in keys {
115 + if let Err(e) = self.delete_object(auth, k).await {
116 + failed += 1;
117 + tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed");
118 + }
119 + }
120 + // Don't report success when every key failed, a total failure must
121 + // surface so the caller can fall back (Run #2 Storage MINOR). Partial
122 + // failures stay logged; callers pre-enqueue to pending_s3_deletions.
123 + if !keys.is_empty() && failed == keys.len() {
124 + return Err(AppError::Storage(format!(
125 + "delete_objects: all {failed} keys failed"
126 + )));
127 + }
128 + Ok(())
129 + }
130 + /// Delete all objects under a key prefix. Default logs a warning (no-op).
131 + async fn delete_prefix(&self, _auth: &S3DeleteAuthority, _prefix: &str) -> Result<()> {
132 + tracing::warn!("delete_prefix called on a storage backend that does not implement it");
133 + Ok(())
134 + }
135 + /// Upload a file via S3 multipart upload. Required (not defaulted): a
136 + /// default that `tokio::fs::read`s the whole file into RAM + single PUT
137 + /// silently defeats streaming, so a future backend that forgot to override
138 + /// it would quietly lose multipart. Every backend must declare its strategy.
139 + async fn upload_multipart(
140 + &self,
141 + s3_key: &S3Key,
142 + content_type: &str,
143 + file_path: &std::path::Path,
144 + ) -> Result<()>;
145 + /// Server-side copy `src_key` to `dst_key` within this backend's bucket
146 + /// (no bytes transit the process). The scan-then-promote primitive: a Clean
147 + /// staging object is copied to the served key the client holds no presign
148 + /// for, so served bytes are provably the scanned bytes. Required (not
149 + /// defaulted): a silent no-op default would make a
150 + /// promote "succeed" while the served key stays empty.
151 + async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()>;
152 + /// Server-side copy from `src_bucket` into THIS backend's bucket. The
153 + /// cross-bucket half of scan-then-promote: a Clean staging object in the
154 + /// private bucket is lifted into the public (CDN-served) bucket. Call on the
155 + /// public backend with the private bucket name as `src_bucket`. Required
156 + /// (not defaulted) for the same reason as `copy_object`.
157 + async fn copy_object_from(
158 + &self,
159 + src_bucket: &str,
160 + src_key: &S3Key,
161 + dst_key: &S3Key,
162 + ) -> Result<()>;
163 + /// Server-side multipart copy (`UploadPartCopy`) for sources over the 5 GiB
164 + /// single-part `CopyObject` limit, the >5 GiB half of scan-then-promote.
165 + /// Always takes `src_bucket` explicitly, collapsing the
166 + /// `copy_object`/`copy_object_from` pair into one method (pass this
167 + /// backend's own bucket for a same-bucket promote). `content_type` sets the
168 + /// destination's type, since a fresh multipart upload does not inherit the
169 + /// source's metadata the way `CopyObject` does. Required (not defaulted) for
170 + /// the same reason as `copy_object`.
171 + async fn copy_object_multipart(
172 + &self,
173 + src_bucket: &str,
174 + src_key: &S3Key,
175 + dst_key: &S3Key,
176 + content_type: &str,
177 + src_size: u64,
178 + part_size: Option<usize>,
179 + ) -> Result<()>;
180 +
181 + // Client-direct multipart sessions
182 + //
183 + // The counterpart to `upload_multipart`, which drives a whole transfer
184 + // server-side from a local file. Here the server only mints the session and
185 + // the per-part presigned URLs; the client streams parts straight to S3, so
186 + // no object bytes transit the server. This is the path large CLI/desktop
187 + // uploads take (a browser stays on the single-PUT `presign_upload`).
188 + //
189 + // All four are required (not defaulted): a no-op default would mint a
190 + // session no client could complete, or silently drop the cleanup that
191 + // stops orphaned parts billing forever.
192 +
193 + /// Begin a client-direct multipart upload, returning the `upload_id` the
194 + /// part/complete/abort calls key on.
195 + async fn create_multipart_upload(&self, s3_key: &S3Key, content_type: &str) -> Result<String>;
196 + /// Presign an `UploadPart` request for one part (1-based `part_number`).
197 + /// `max_bytes`, when set, is signed as `Content-Length`, the same
198 + /// defense-in-depth as [`Self::presign_upload`], the authoritative size
199 + /// check still happens at confirm time.
200 + ///
201 + /// `checksum_sha256` (base64 of the raw digest), when set, is signed as
202 + /// `x-amz-checksum-sha256` and IS enforced: S3 rehashes the part and
203 + /// rejects a mismatch before the bytes are durable.
204 + async fn presign_upload_part(
205 + &self,
206 + s3_key: &S3Key,
207 + upload_id: &str,
208 + part_number: i32,
209 + expiry_secs: Option<u64>,
210 + max_bytes: Option<i64>,
211 + checksum_sha256: Option<&str>,
212 + ) -> Result<String>;
213 + /// Complete a multipart upload from the collected `(part_number, etag)`
214 + /// pairs. Parts may be passed in any order; the backend sorts them.
215 + async fn complete_multipart_upload(
216 + &self,
217 + s3_key: &S3Key,
218 + upload_id: &str,
219 + parts: &[(i32, String)],
220 + ) -> Result<()>;
221 + /// Abort a multipart upload, releasing its uploaded parts. The
222 + /// pending-upload reaper calls this on sessions that were never confirmed,
223 + /// incomplete multipart uploads bill for their parts indefinitely.
224 + async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()>;
225 + /// Upload ids of the in-progress multipart sessions for exactly `s3_key`.
226 + /// The reaper recovers them from S3 rather than the database, so a session
227 + /// whose tracking row was lost is still cleaned up. Required (not defaulted):
228 + /// an empty default would silently strand billed parts.
229 + async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>>;
230 + async fn check_connectivity(&self) -> std::result::Result<(), String>;
231 + fn bucket(&self) -> &str;
232 + }
@@ -1,0 +1,98 @@
1 + //! Which bucket an object lives in, and the authority a delete needs.
2 +
3 + /// Default presigned URL expiration.
4 + /// 1 hour balances usability (large uploads over slow connections) against
5 + /// security (limiting the window for URL leakage). Overridable per-call.
6 + pub(super) const PRESIGN_EXPIRY_SECS: u64 = 3600;
7 +
8 + /// Cache-Control value for immutable content (builds, audio, covers).
9 + /// One year with immutable directive, Cloudflare and browsers cache indefinitely.
10 + pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
11 +
12 + /// Capability proof required by every `StorageBackend` delete method.
13 + ///
14 + /// Direct S3 deletion is sealed off from route handlers: the delete methods
15 + /// take `&S3DeleteAuthority`, so an accidental `s3.delete_object(key)` from a
16 + /// handler does not compile. Route code must instead enqueue through
17 + /// `pending_s3_deletions` (e.g. `routes::storage::enqueue_s3_orphan`), whose
18 + /// worker applies the `is_s3_key_live` guard before deleting, so a handler
19 + /// cannot blind-delete a key a live row still references.
20 + ///
21 + /// Minting is `pub(crate)` and confined by convention to the durable-deletion
22 + /// paths, the scheduler deletion worker + cleanup (`scheduler/cleanup.rs`) and
23 + /// the malware-quarantine scan worker (`scanning/worker.rs`). The build-time
24 + /// guard test `routes_never_delete_s3_directly` fails if any file under
25 + /// `src/routes/` names a delete method or mints an authority, so the seal can't
26 + /// silently erode.
27 + pub struct S3DeleteAuthority(());
28 +
29 + impl S3DeleteAuthority {
30 + /// Mint a deletion authority. Restricted to the sanctioned durable-deletion
31 + /// paths; see the type docs. Route handlers cannot reach a sanctioned path,
32 + /// and the guard test enforces that they don't mint one anyway.
33 + pub(crate) fn new() -> Self {
34 + S3DeleteAuthority(())
35 + }
36 + }
37 +
38 + /// Which configured S3 backend an object lives in.
39 + ///
40 + /// The delete *verb* is type-sealed by [`S3DeleteAuthority`]; this seals the
41 + /// bucket *noun*. The `pending_s3_deletions` queue stores the bucket as text, and
42 + /// the deletion worker dispatches between the main and SyncKit S3 clients on that
43 + /// text. This enum is the single source of truth for the `"main"`/`"synckit"`
44 + /// spellings so an orphan-enqueue can't silently mis-tag a SyncKit object as
45 + /// `main` (where the worker would delete it against the wrong client and leak it
46 + /// forever), `enqueue_s3_orphan` requires an `S3Bucket`, not a bare string.
47 + #[derive(Clone, Copy, Debug, PartialEq, Eq)]
48 + pub enum S3Bucket {
49 + Main,
50 + Synckit,
51 + /// Public, CDN-served bucket (`cdn.makenot.work`). Holds ONLY the
52 + /// immutably-public image kinds after promote (covers, gallery, item/project
53 + /// images, content insertions); a paid object can never enter it, so its
54 + /// blanket public-read policy is safe by construction. Staging is never here
55 + ///, unscanned bytes stay in `Main`; the content object lands here only via
56 + /// the cross-bucket promote (see `scanning::promote_staging_to_content`).
57 + Public,
58 + }
59 +
60 + impl S3Bucket {
61 + /// The stored/text spelling for the deletion queue.
62 + pub fn as_str(self) -> &'static str {
63 + match self {
64 + S3Bucket::Main => "main",
65 + S3Bucket::Synckit => "synckit",
66 + S3Bucket::Public => "public",
67 + }
68 + }
69 +
70 + /// Parse a bucket tag read back from the queue. Unknown/legacy values map to
71 + /// `Main` (the historical default), so a garbled row is still reaped against
72 + /// a backend rather than wedging the queue.
73 + pub fn from_db_str(s: &str) -> Self {
74 + match s {
75 + "synckit" => S3Bucket::Synckit,
76 + "public" => S3Bucket::Public,
77 + _ => S3Bucket::Main,
78 + }
79 + }
80 + }
81 +
82 + /// Deletion enqueue pair for a content-image key whose promote state is unknown.
83 + ///
84 + /// A CDN-image key is a private **staging** key (`staging/...`, in `Main`) until
85 + /// [`crate::scanning::promote_staging_to_content`] repoints it to the public
86 + /// **content** key (`{user}/c/{sha}.ext`, in `Public`). A given object is in
87 + /// EXACTLY one bucket, but a delete/replace path can run in either state, so it
88 + /// can't know which. Enqueue the key under BOTH buckets: the reaper deletes from
89 + /// the bucket the object is in and no-ops the other (content keys are unique to
90 + /// one bucket), and `is_s3_key_live` still guards each bucket against a live
91 + /// reference. Only for the four CDN-served image surfaces; gated media
92 + /// (audio/video/version/media) is always `Main` and insertions always `Main`.
93 + pub fn both_bucket_delete(key: &str) -> [(String, String); 2] {
94 + [
95 + (key.to_string(), S3Bucket::Main.as_str().to_string()),
96 + (key.to_string(), S3Bucket::Public.as_str().to_string()),
97 + ]
98 + }
@@ -1,0 +1,482 @@
1 + //! The S3 client itself: its handle, the operations it delegates, and its
2 + //! implementation of [`super::StorageBackend`].
3 + //!
4 + //! The trait impl and the inherent methods it delegates to are two halves of
5 + //! one contract, which is why they stay in one file.
6 +
7 + use super::backend::StorageBackend;
8 + use super::bucket::PRESIGN_EXPIRY_SECS;
9 + use super::bucket::S3DeleteAuthority;
10 + use super::key::S3Key;
11 + use crate::config::StorageConfig;
12 + use crate::error::{AppError, Result};
13 +
14 + /// S3 client wrapper for presigned URL operations.
15 + /// Delegates S3 operations to `s3_storage::S3Client`.
16 + #[derive(Clone)]
17 + pub struct S3Client {
18 + inner: s3_storage::S3Client,
19 + }
20 +
21 + impl S3Client {
22 + /// Create a new S3 client from storage configuration.
23 + ///
24 + /// Configures CORS on the bucket at startup so browser PUT uploads to
25 + /// presigned URLs work without manual bucket configuration.
26 + pub async fn new(config: &StorageConfig, host_url: &str) -> Result<Self> {
27 + let s3_config = s3_storage::S3Config {
28 + endpoint: config.endpoint.clone(),
29 + bucket: config.bucket.clone(),
30 + access_key: config.access_key.clone(),
31 + secret_key: config.secret_key.clone(),
32 + region: config.region.clone(),
33 + };
34 +
35 + let inner = s3_storage::S3Client::new(&s3_config)
36 + .await
37 + .map_err(AppError::Storage)?;
38 +
39 + inner.configure_cors(host_url).await;
40 +
41 + Ok(S3Client { inner })
42 + }
43 +
44 + /// Generate a presigned URL for uploading a file. `max_bytes`, when set,
45 + /// binds `Content-Length` into the signature, S3 will reject any PUT
46 + /// whose actual body length differs from `max_bytes`.
47 + pub async fn presign_upload(
48 + &self,
49 + s3_key: &S3Key,
50 + content_type: &str,
51 + expiry_secs: Option<u64>,
52 + cache_control: Option<&str>,
53 + max_bytes: Option<i64>,
54 + ) -> Result<String> {
55 + self.inner
56 + .presign_upload(
57 + s3_key.as_str(),
58 + content_type,
59 + expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS),
60 + cache_control,
61 + max_bytes,
62 + )
63 + .await
64 + .map_err(AppError::Storage)
65 + }
66 +
67 + /// Generate a presigned URL for downloading/streaming a file
68 + pub async fn presign_download(
69 + &self,
70 + s3_key: &S3Key,
71 + expiry_secs: Option<u64>,
72 + ) -> Result<String> {
73 + self.inner
74 + .presign_download(s3_key.as_str(), expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS))
75 + .await
76 + .map_err(AppError::Storage)
77 + }
78 +
79 + /// Check if an object exists in S3
80 + pub async fn object_exists(&self, s3_key: &str) -> Result<bool> {
81 + self.inner
82 + .object_exists(s3_key)
83 + .await
84 + .map_err(AppError::Storage)
85 + }
86 +
87 + /// Get the size of an object in S3 (bytes), or None if not found.
88 + pub async fn object_size(&self, s3_key: &str) -> Result<Option<i64>> {
89 + self.inner
90 + .object_size(s3_key)
91 + .await
92 + .map_err(AppError::Storage)
93 + }
94 +
95 + /// Download an object's bytes from S3
96 + pub async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>> {
97 + self.inner
98 + .download(s3_key)
99 + .await
100 + .map(|(bytes, _content_type)| bytes)
101 + .map_err(AppError::Storage)
102 + }
103 +
104 + /// Download an object as `bytes::Bytes` without the `to_vec` copy. See trait docs.
105 + pub async fn download_object_buf(&self, s3_key: &str) -> Result<bytes::Bytes> {
106 + self.inner
107 + .download_buf(s3_key)
108 + .await
109 + .map(|(bytes, _content_type)| bytes)
110 + .map_err(AppError::Storage)
111 + }
112 +
113 + /// Stream an object's body from S3 without buffering. See trait docs.
114 + pub async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream> {
115 + self.inner
116 + .download_stream(s3_key)
117 + .await
118 + .map_err(AppError::Storage)
119 + }
120 +
121 + /// Read the first `len` bytes via a ranged S3 GET (for content sniffing).
122 + pub async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> {
123 + self.inner
124 + .download_head(s3_key, len)
125 + .await
126 + .map_err(AppError::Storage)
127 + }
128 +
129 + /// Upload an object to S3 from bytes
130 + pub async fn upload_object(
131 + &self,
132 + s3_key: &S3Key,
133 + content_type: &str,
134 + data: Vec<u8>,
135 + cache_control: Option<&str>,
136 + ) -> Result<()> {
137 + self.inner
138 + .upload(s3_key.as_str(), content_type, data, cache_control)
139 + .await
140 + .map_err(AppError::Storage)
141 + }
142 +
143 + /// Delete an object from S3
144 + pub async fn delete_object(&self, s3_key: &S3Key) -> Result<()> {
145 + self.inner
146 + .delete(s3_key.as_str())
147 + .await
148 + .map_err(AppError::Storage)
149 + }
150 +
151 + /// Server-side copy within the bucket. See the [`StorageBackend::copy_object`]
152 + /// trait method for the scan-then-promote rationale.
153 + pub async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> {
154 + self.inner
155 + .copy_object(src_key.as_str(), dst_key.as_str())
156 + .await
157 + .map_err(AppError::Storage)
158 + }
159 +
160 + /// Server-side copy from `src_bucket` into this client's bucket. See the
161 + /// [`StorageBackend::copy_object_from`] trait method for the cross-bucket
162 + /// promote rationale.
163 + pub async fn copy_object_from(
164 + &self,
165 + src_bucket: &str,
166 + src_key: &S3Key,
167 + dst_key: &S3Key,
168 + ) -> Result<()> {
169 + self.inner
170 + .copy_object_from(src_bucket, src_key.as_str(), dst_key.as_str())
171 + .await
172 + .map_err(AppError::Storage)
173 + }
174 +
175 + /// Batched S3 delete (`DeleteObjects`, up to 1000 keys per call).
176 + /// Chunks larger slices into 1000-key batches and logs per-key failures
177 + /// without bubbling, the pending_s3_deletions queue is the safety net.
178 + pub async fn delete_objects(&self, keys: &[S3Key]) -> Result<()> {
179 + if keys.is_empty() {
180 + return Ok(());
181 + }
182 + for chunk in keys.chunks(1000) {
183 + let chunk: Vec<String> = chunk.iter().map(|k| k.as_str().to_string()).collect();
184 + match self.inner.delete_objects(&chunk).await {
185 + Ok(failures) => {
186 + for (k, msg) in &failures {
187 + tracing::warn!(key = %k, error = %msg, "S3 delete_objects: key-level failure");
188 + }
189 + // A whole-batch failure must not read as success (Run #2
190 + // Storage MINOR); partial failures stay logged and the
191 + // pending_s3_deletions queue is the retry net.
192 + if !chunk.is_empty() && failures.len() == chunk.len() {
193 + return Err(AppError::Storage(format!(
194 + "S3 delete_objects: all {} keys in batch failed",
195 + chunk.len()
196 + )));
197 + }
198 + }
199 + Err(e) => return Err(AppError::Storage(e)),
200 + }
201 + }
202 + Ok(())
203 + }
204 +
205 + /// Upload a file to S3 using multipart upload (10 MB parts).
206 + pub async fn upload_multipart(
207 + &self,
208 + s3_key: &S3Key,
209 + content_type: &str,
210 + file_path: &std::path::Path,
211 + ) -> Result<()> {
212 + self.inner
213 + .upload_multipart(s3_key.as_str(), content_type, file_path, None)
214 + .await
215 + .map_err(AppError::Storage)
216 + }
217 +
218 + /// Server-side multipart copy for sources over the 5 GiB single-part
219 + /// `CopyObject` limit. See the [`StorageBackend::copy_object_multipart`]
220 + /// trait method for the promote rationale. `part_size` of `None` lets the
221 + /// storage layer auto-size parts for `src_size`.
222 + pub async fn copy_object_multipart(
223 + &self,
224 + src_bucket: &str,
225 + src_key: &S3Key,
226 + dst_key: &S3Key,
227 + content_type: &str,
228 + src_size: u64,
229 + part_size: Option<usize>,
230 + ) -> Result<()> {
231 + self.inner
232 + .copy_object_multipart(
233 + src_bucket,
234 + src_key.as_str(),
235 + dst_key.as_str(),
236 + content_type,
237 + src_size,
238 + part_size,
239 + )
240 + .await
241 + .map_err(AppError::Storage)
242 + }
243 +
244 + /// Begin a client-direct multipart upload. See the
245 + /// [`StorageBackend::create_multipart_upload`] trait method.
246 + pub async fn create_multipart_upload(
247 + &self,
248 + s3_key: &S3Key,
249 + content_type: &str,
250 + ) -> Result<String> {
251 + self.inner
252 + .create_multipart_upload(s3_key.as_str(), content_type)
253 + .await
254 + .map_err(AppError::Storage)
255 + }
256 +
257 + /// Presign one `UploadPart` request. See the
258 + /// [`StorageBackend::presign_upload_part`] trait method.
259 + pub async fn presign_upload_part(
260 + &self,
261 + s3_key: &S3Key,
262 + upload_id: &str,
263 + part_number: i32,
264 + expiry_secs: Option<u64>,
265 + max_bytes: Option<i64>,
266 + checksum_sha256: Option<&str>,
267 + ) -> Result<String> {
268 + self.inner
269 + .presign_upload_part(
270 + s3_key.as_str(),
271 + upload_id,
272 + part_number,
273 + expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS),
274 + max_bytes,
275 + checksum_sha256,
276 + )
277 + .await
278 + .map_err(AppError::Storage)
279 + }
280 +
281 + /// Complete a multipart upload. See the
282 + /// [`StorageBackend::complete_multipart_upload`] trait method.
283 + pub async fn complete_multipart_upload(
284 + &self,
285 + s3_key: &S3Key,
286 + upload_id: &str,
287 + parts: &[(i32, String)],
288 + ) -> Result<()> {
289 + self.inner
290 + .complete_multipart_upload(s3_key.as_str(), upload_id, parts)
291 + .await
292 + .map_err(AppError::Storage)
293 + }
294 +
295 + /// Abort a multipart upload. See the
296 + /// [`StorageBackend::abort_multipart_upload`] trait method.
297 + pub async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()> {
298 + self.inner
299 + .abort_multipart_upload(s3_key.as_str(), upload_id)
300 + .await
301 + .map_err(AppError::Storage)
302 + }
303 +
304 + /// In-progress multipart sessions for a key. See the
305 + /// [`StorageBackend::list_multipart_uploads_for_key`] trait method.
306 + pub async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> {
307 + self.inner
308 + .list_multipart_uploads_for_key(s3_key)
309 + .await
310 + .map_err(AppError::Storage)
311 + }
312 +
313 + /// Lightweight connectivity check, issues a list with max_keys(0).
314 + pub async fn check_connectivity(&self) -> std::result::Result<(), String> {
315 + self.inner.check_connectivity().await
316 + }
317 + }
318 +
319 + #[async_trait::async_trait]
320 + impl StorageBackend for S3Client {
321 + async fn presign_upload(
322 + &self,
323 + s3_key: &S3Key,
324 + content_type: &str,
325 + expiry_secs: Option<u64>,
326 + cache_control: Option<&str>,
327 + max_bytes: Option<i64>,
328 + ) -> Result<String> {
329 + self.presign_upload(s3_key, content_type, expiry_secs, cache_control, max_bytes)
330 + .await
331 + }
332 +
333 + async fn presign_download(&self, s3_key: &S3Key, expiry_secs: Option<u64>) -> Result<String> {
334 + self.presign_download(s3_key, expiry_secs).await
335 + }
336 +
337 + async fn object_exists(&self, s3_key: &str) -> Result<bool> {
338 + self.object_exists(s3_key).await
339 + }
340 +
341 + async fn object_size(&self, s3_key: &str) -> Result<Option<i64>> {
342 + self.object_size(s3_key).await
343 + }
344 +
345 + async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>> {
346 + self.download_object(s3_key).await
347 + }
348 +
349 + async fn download_object_buf(&self, s3_key: &str) -> Result<bytes::Bytes> {
350 + self.download_object_buf(s3_key).await
351 + }
352 +
353 + async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream> {
354 + self.download_stream(s3_key).await
355 + }
356 +
357 + async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> {
358 + self.download_head(s3_key, len).await
359 + }
360 +
361 + async fn upload_object(
362 + &self,
363 + s3_key: &S3Key,
364 + content_type: &str,
365 + data: Vec<u8>,
366 + cache_control: Option<&str>,
367 + ) -> Result<()> {
368 + self.upload_object(s3_key, content_type, data, cache_control)
369 + .await
370 + }
371 +
372 + async fn delete_object(&self, _auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()> {
373 + // Authority proven by the caller; delegate to the inherent impl.
374 + self.delete_object(s3_key).await
375 + }
376 +
377 + async fn delete_objects(&self, _auth: &S3DeleteAuthority, keys: &[S3Key]) -> Result<()> {
378 + self.delete_objects(keys).await
379 + }
380 +
381 + async fn copy_object_from(
382 + &self,
383 + src_bucket: &str,
384 + src_key: &S3Key,
385 + dst_key: &S3Key,
386 + ) -> Result<()> {
387 + // Inherent method; delegate.
388 + self.copy_object_from(src_bucket, src_key, dst_key).await
389 + }
390 +
391 + async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> {
392 + // Inherent method; delegate.
393 + self.copy_object(src_key, dst_key).await
394 + }
395 +
396 + async fn delete_prefix(&self, _auth: &S3DeleteAuthority, prefix: &str) -> Result<()> {
397 + self.inner
398 + .delete_prefix(prefix)
399 + .await
400 + .map_err(AppError::Storage)
401 + }
402 +
403 + async fn upload_multipart(
404 + &self,
405 + s3_key: &S3Key,
406 + content_type: &str,
407 + file_path: &std::path::Path,
408 + ) -> Result<()> {
409 + self.upload_multipart(s3_key, content_type, file_path).await
410 + }
411 +
412 + async fn copy_object_multipart(
413 + &self,
414 + src_bucket: &str,
415 + src_key: &S3Key,
416 + dst_key: &S3Key,
417 + content_type: &str,
418 + src_size: u64,
419 + part_size: Option<usize>,
420 + ) -> Result<()> {
421 + // Inherent method; delegate.
422 + self.copy_object_multipart(
423 + src_bucket,
424 + src_key,
425 + dst_key,
426 + content_type,
427 + src_size,
428 + part_size,
429 + )
430 + .await
431 + }
432 +
433 + async fn create_multipart_upload(&self, s3_key: &S3Key, content_type: &str) -> Result<String> {
434 + self.create_multipart_upload(s3_key, content_type).await
435 + }
436 +
437 + async fn presign_upload_part(
438 + &self,
439 + s3_key: &S3Key,
440 + upload_id: &str,
441 + part_number: i32,
442 + expiry_secs: Option<u64>,
443 + max_bytes: Option<i64>,
444 + checksum_sha256: Option<&str>,
445 + ) -> Result<String> {
446 + self.presign_upload_part(
447 + s3_key,
448 + upload_id,
449 + part_number,
450 + expiry_secs,
451 + max_bytes,
452 + checksum_sha256,
453 + )
454 + .await
455 + }
456 +
457 + async fn complete_multipart_upload(
458 + &self,
459 + s3_key: &S3Key,
460 + upload_id: &str,
461 + parts: &[(i32, String)],
462 + ) -> Result<()> {
463 + self.complete_multipart_upload(s3_key, upload_id, parts)
464 + .await
465 + }
466 +
467 + async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()> {
468 + self.abort_multipart_upload(s3_key, upload_id).await
469 + }
470 +
471 + async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> {
472 + self.list_multipart_uploads_for_key(s3_key).await
473 + }
474 +
475 + async fn check_connectivity(&self) -> std::result::Result<(), String> {
476 + self.check_connectivity().await
477 + }
478 +
479 + fn bucket(&self) -> &str {
480 + self.inner.bucket()
481 + }
482 + }
@@ -1,0 +1,312 @@
1 + //! What may be uploaded: the allow-list tables, the size ceilings, and the
2 + //! kind of thing a given upload is.
3 +
4 + use super::client::S3Client;
5 + use std::str::FromStr;
6 +
7 + use crate::error::{AppError, Result};
8 +
9 + /// Allowed audio file extensions and their MIME types
10 + const ALLOWED_AUDIO_TYPES: &[(&str, &str)] = &[
11 + ("mp3", "audio/mpeg"),
12 + ("wav", "audio/wav"),
13 + ("m4a", "audio/mp4"),
14 + ("ogg", "audio/ogg"),
15 + ("flac", "audio/flac"),
16 + ("aac", "audio/aac"),
17 + ];
18 +
19 + /// Allowed image file extensions and their MIME types
20 + const ALLOWED_IMAGE_TYPES: &[(&str, &str)] = &[
21 + ("jpg", "image/jpeg"),
22 + ("jpeg", "image/jpeg"),
23 + ("png", "image/png"),
24 + ("webp", "image/webp"),
25 + ("gif", "image/gif"),
26 + ];
27 +
28 + /// Allowed video file extensions and their MIME types
29 + const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[
30 + ("mp4", "video/mp4"),
31 + ("webm", "video/webm"),
32 + ("mov", "video/quicktime"),
33 + ];
34 +
35 + /// Allowed insertion-clip extensions and MIME types. A clip may be audio (the
36 + /// original use: intros, sponsor reads) or video (pre/mid/post-roll on a video
37 + /// item), so this is the union of the audio and video allow-lists. Kept as one
38 + /// literal because `allowed_types()` returns a `&'static` slice.
39 + const ALLOWED_INSERTION_TYPES: &[(&str, &str)] = &[
40 + ("mp3", "audio/mpeg"),
41 + ("wav", "audio/wav"),
42 + ("m4a", "audio/mp4"),
43 + ("ogg", "audio/ogg"),
44 + ("flac", "audio/flac"),
45 + ("aac", "audio/aac"),
46 + ("mp4", "video/mp4"),
47 + ("webm", "video/webm"),
48 + ("mov", "video/quicktime"),
49 + ];
50 +
51 + /// MIME types accepted for video uploads
52 + const ALLOWED_VIDEO_MIMES: &[&str] = &["video/mp4", "video/webm", "video/quicktime"];
53 +
54 + /// Allowed download file extensions and their MIME types
55 + /// Browsers are inconsistent about MIME types for binary downloads,
56 + /// so we accept several common types for each extension.
57 + const ALLOWED_DOWNLOAD_TYPES: &[(&str, &str)] = &[
58 + ("zip", "application/zip"),
59 + ("dmg", "application/x-apple-diskimage"),
60 + ("exe", "application/octet-stream"),
61 + ("appimage", "application/octet-stream"),
62 + ("deb", "application/octet-stream"),
63 + ("clap", "application/octet-stream"),
64 + ("vst3", "application/octet-stream"),
65 + ];
66 +
67 + /// MIME types accepted for download uploads (browsers vary widely)
68 + const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[
69 + "application/octet-stream",
70 + "application/zip",
71 + "application/x-zip-compressed",
72 + "application/x-apple-diskimage",
73 + "application/x-diskcopy",
74 + "application/x-msi",
75 + "application/x-ole-storage",
76 + "application/gzip",
77 + "application/x-tar",
78 + "application/x-gtar",
79 + "application/x-compressed",
80 + "application/x-executable",
81 + "application/x-deb",
82 + "application/vnd.debian.binary-package",
83 + ];
84 +
85 + /// Allowed download file extensions (checked separately from MIME)
86 + const ALLOWED_DOWNLOAD_EXTENSIONS: &[&str] = &[
87 + "zip", "dmg", "exe", "msi", "appimage", "deb", "tar.gz", "clap", "vst3",
88 + ];
89 +
90 + /// Maximum file sizes in bytes
91 + const MAX_AUDIO_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
92 + const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
93 + const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
94 + const MAX_INSERTION_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
95 + const MAX_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
96 + const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
97 + const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
98 +
99 + /// File type categories for upload
100 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
101 + pub enum FileType {
102 + Audio,
103 + Cover,
104 + Download,
105 + Insertion,
106 + Video,
107 + /// Media library image (user-scoped, for inline markdown content).
108 + MediaImage,
109 + /// Media library video (user-scoped, for inline markdown content).
110 + MediaVideo,
111 + }
112 +
113 + /// How the generic `/api/upload/confirm` handler confirms a file type onto an
114 + /// `items` row. Returned by [`FileType::generic_item_confirm`], whose `match`
115 + /// is exhaustive, adding a `FileType` variant fails the build until its
116 + /// posture is declared here, so a new type can't silently fall into the wrong
117 + /// column set (a `Cover` branch that writes `cover_s3_key` but never
118 + /// `cover_image_url` leaves an invisible cover).
119 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
120 + pub enum GenericItemConfirm {
121 + /// Confirmable by the generic handler: write these two columns on the item.
122 + /// Only types fully described by `(s3_key, size)` belong here, anything
123 + /// that needs an extra column (e.g. a CDN render URL) must use a dedicated
124 + /// route instead.
125 + Columns {
126 + s3_key: &'static str,
127 + size: &'static str,
128 + },
129 + /// Not confirmable by the generic handler, use this dedicated route. The
130 + /// handler rejects the request (after cleaning up the staged object) so a
131 + /// misrouted confirm never half-writes the row.
132 + UseRoute(&'static str),
133 + }
134 +
135 + impl FileType {
136 + /// Declare, exhaustively, how `/api/upload/confirm` treats this type.
137 + /// See [`GenericItemConfirm`] for why this is the single source of truth.
138 + pub fn generic_item_confirm(self) -> GenericItemConfirm {
139 + match self {
140 + FileType::Audio => GenericItemConfirm::Columns {
141 + s3_key: "audio_s3_key",
142 + size: "audio_file_size_bytes",
143 + },
144 + FileType::Video => GenericItemConfirm::Columns {
145 + s3_key: "video_s3_key",
146 + size: "video_file_size_bytes",
147 + },
148 + // Cover IS confirmable, but it must also set `cover_image_url` (the
149 + // CDN render source). The generic two-column writer can't, so covers
150 + // go through the dedicated route that writes all three atomically.
151 + FileType::Cover => GenericItemConfirm::UseRoute("/api/items/image/confirm"),
152 + FileType::Download => {
153 + GenericItemConfirm::UseRoute("/api/versions/{version_id}/upload/*")
154 + }
155 + FileType::Insertion => GenericItemConfirm::UseRoute("/api/users/me/insertions/*"),
156 + FileType::MediaImage | FileType::MediaVideo => {
157 + GenericItemConfirm::UseRoute("/api/media/*")
158 + }
159 + }
160 + }
161 +
162 + pub fn as_str(&self) -> &'static str {
163 + match self {
164 + FileType::Audio => "audio",
165 + FileType::Cover => "cover",
166 + FileType::Download => "download",
167 + FileType::Insertion => "insertion",
168 + FileType::Video => "video",
169 + FileType::MediaImage => "media_image",
170 + FileType::MediaVideo => "media_video",
171 + }
172 + }
173 +
174 + pub fn max_size(&self) -> u64 {
175 + match self {
176 + FileType::Audio => MAX_AUDIO_SIZE,
177 + FileType::Cover => MAX_IMAGE_SIZE,
178 + FileType::Download => MAX_DOWNLOAD_SIZE,
179 + FileType::Insertion => MAX_INSERTION_SIZE,
180 + FileType::Video => MAX_VIDEO_SIZE,
181 + FileType::MediaImage => MAX_MEDIA_IMAGE_SIZE,
182 + FileType::MediaVideo => MAX_MEDIA_VIDEO_SIZE,
183 + }
184 + }
185 +
186 + pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] {
187 + match self {
188 + FileType::Audio => ALLOWED_AUDIO_TYPES,
189 + FileType::Insertion => ALLOWED_INSERTION_TYPES,
190 + FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES,
191 + FileType::Download => ALLOWED_DOWNLOAD_TYPES,
192 + FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES,
193 + }
194 + }
195 + }
196 +
197 + impl FromStr for FileType {
198 + type Err = String;
199 +
200 + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
201 + match s.to_lowercase().as_str() {
202 + "audio" => Ok(FileType::Audio),
203 + "cover" | "image" => Ok(FileType::Cover),
204 + "download" => Ok(FileType::Download),
205 + "insertion" => Ok(FileType::Insertion),
206 + "video" => Ok(FileType::Video),
207 + "media_image" => Ok(FileType::MediaImage),
208 + "media_video" => Ok(FileType::MediaVideo),
209 + _ => Err(format!("Invalid file type: {s}")),
210 + }
211 + }
212 + }
213 +
214 + impl S3Client {
215 + /// Validate content type for the given file type
216 + pub fn validate_content_type(file_type: FileType, content_type: &str) -> Result<()> {
217 + let is_valid = if file_type == FileType::Download {
218 + ALLOWED_DOWNLOAD_MIMES.contains(&content_type)
219 + } else if file_type == FileType::Video {
220 + ALLOWED_VIDEO_MIMES.contains(&content_type)
221 + } else {
222 + let allowed = file_type.allowed_types();
223 + allowed.iter().any(|(_, mime)| *mime == content_type)
224 + };
225 +
226 + if !is_valid {
227 + let allowed_list = if file_type == FileType::Download {
228 + ALLOWED_DOWNLOAD_MIMES.join(", ")
229 + } else if file_type == FileType::Video {
230 + ALLOWED_VIDEO_MIMES.join(", ")
231 + } else {
232 + let allowed = file_type.allowed_types();
233 + allowed
234 + .iter()
235 + .map(|(_, m)| *m)
236 + .collect::<Vec<_>>()
237 + .join(", ")
238 + };
239 + return Err(AppError::InvalidFileType(format!(
240 + "Content type '{content_type}' not allowed. Allowed types: {allowed_list}"
241 + )));
242 + }
243 +
244 + Ok(())
245 + }
246 +
247 + /// Classify a validated MIME type as the `media_type` we persist for an
248 + /// insertion clip: `"video"` for `video/*`, otherwise `"audio"`. The MIME is
249 + /// expected to have already passed `validate_content_type`, so the audio
250 + /// fallback is safe (the only non-audio family the insertion allow-list
251 + /// admits is `video/*`).
252 + pub fn insertion_media_type(mime_type: &str) -> &'static str {
253 + if mime_type.starts_with("video/") {
254 + "video"
255 + } else {
256 + "audio"
257 + }
258 + }
259 +
260 + /// Validate file extension for the given file type
261 + pub fn validate_extension(file_type: FileType, filename: &str) -> Result<()> {
262 + if file_type == FileType::Download {
263 + let lower = filename.to_lowercase();
264 + let is_valid = ALLOWED_DOWNLOAD_EXTENSIONS
265 + .iter()
266 + .any(|ext| lower.ends_with(&format!(".{ext}")));
267 + if !is_valid {
268 + return Err(AppError::InvalidFileType(format!(
269 + "File extension not allowed. Allowed extensions: {}",
270 + ALLOWED_DOWNLOAD_EXTENSIONS.join(", ")
271 + )));
272 + }
273 + return Ok(());
274 + }
275 +
276 + let extension = filename
277 + .rsplit('.')
278 + .next()
279 + .map(str::to_lowercase)
280 + .unwrap_or_default();
281 +
282 + let allowed = file_type.allowed_types();
283 + let is_valid = allowed.iter().any(|(ext, _)| *ext == extension);
284 +
285 + if !is_valid {
286 + let allowed_exts: Vec<&str> = allowed.iter().map(|(e, _)| *e).collect();
287 + return Err(AppError::InvalidFileType(format!(
288 + "File extension '.{}' not allowed. Allowed extensions: {}",
289 + extension,
290 + allowed_exts.join(", ")
291 + )));
292 + }
293 +
294 + Ok(())
295 + }
296 + }
297 +
298 + /// The MIME type registered for `ext` under `file_type`, falling back to
299 + /// `application/octet-stream` for an extension outside the allow-list.
300 + ///
301 + /// Needed by the multipart promote path: a single `CopyObject` carries the
302 + /// source object's metadata across, but a multipart copy writes a *fresh*
303 + /// destination object whose content type comes from `CreateMultipartUpload`, so
304 + /// the promote has to name it explicitly or the served object would default to
305 + /// the wrong type.
306 + pub(crate) fn content_type_for(file_type: FileType, ext: &str) -> &'static str {
307 + file_type
308 + .allowed_types()
309 + .iter()
310 + .find(|(e, _)| e.eq_ignore_ascii_case(ext))
311 + .map_or("application/octet-stream", |(_, ct)| *ct)
312 + }
@@ -1,0 +1,414 @@
1 + //! Object keys, and the only code allowed to mint one.
2 +
3 + use super::client::S3Client;
4 + use super::file_type::FileType;
5 + use crate::constants;
6 +
7 + use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId};
8 + use crate::error::{AppError, Result};
9 +
10 + /// A storage object key. There are exactly two ways to obtain one, and an
11 + /// ad-hoc `format!("...")` is neither:
12 + ///
13 + /// 1. A `S3Client::generate_*` constructor, the single, reviewed home for key
14 + /// *layout*. Multi-instance kinds (versions, gallery, media) take their
15 + /// uniqueness segment (a table PK or a fresh uuid) as a required argument, so
16 + /// a collidable key cannot be built; singleton kinds (audio/cover/video, OTA
17 + /// artifacts) are one-per-parent and correctly overwrite-on-replace.
18 + /// 2. [`S3Key::from_stored`], the named trust boundary for a key that already
19 + /// exists in our storage (read back from a DB row). The caller asserts it was
20 + /// minted by a generator at write time; this is how delete/download/re-presign
21 + /// paths address objects without re-deriving their layout.
22 + ///
23 + /// Because every write/presign/delete on [`StorageBackend`] takes `&S3Key`, a
24 + /// hand-built string can never reach S3, the OTA-style inline `format!` key
25 + /// (which bypassed the generators) is now uncompilable.
26 + #[derive(Debug, Clone, PartialEq, Eq, Hash, sqlx::Type)]
27 + #[sqlx(transparent)]
28 + pub struct S3Key(String);
29 +
30 + impl S3Key {
31 + /// Wrap a key read back from durable storage (a DB row). Names the trust
32 + /// boundary: the caller asserts this key was minted by a `generate_*`
33 + /// constructor when the object was written, not freshly invented here.
34 + pub fn from_stored(key: impl AsRef<str>) -> Self {
35 + S3Key(key.as_ref().to_string())
36 + }
37 +
38 + pub fn as_str(&self) -> &str {
39 + &self.0
40 + }
41 +
42 + pub fn into_string(self) -> String {
43 + self.0
44 + }
45 + }
46 +
47 + impl std::ops::Deref for S3Key {
48 + type Target = str;
49 + fn deref(&self) -> &str {
50 + &self.0
51 + }
52 + }
53 +
54 + impl AsRef<str> for S3Key {
55 + fn as_ref(&self) -> &str {
56 + &self.0
57 + }
58 + }
59 +
60 + impl std::fmt::Display for S3Key {
61 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 + f.write_str(&self.0)
63 + }
64 + }
65 +
66 + impl PartialEq<&str> for S3Key {
67 + fn eq(&self, other: &&str) -> bool {
68 + self.0 == *other
69 + }
70 + }
71 +
72 + /// Extensions an Alloy hotfix RPM repository serves. The package itself, the
73 + /// `createrepo_c` metadata under `repodata/` (XML, in whatever compression the
74 + /// generator chose, or the sqlite variants), and the detached signature and
75 + /// public key that go beside `repomd.xml`. Anything else is a publish mistake:
76 + /// nothing in `dnf`'s fetch path asks for it, so serving it is pure surface.
77 + const RPM_REPO_EXTENSIONS: &[&str] = &[
78 + "rpm", "xml", "zst", "gz", "xz", "bz2", "sqlite", "asc", "key", "sig", "yaml",
79 + ];
80 +
81 + impl S3Client {
82 + /// Generate a consistent S3 key for an object
83 + /// Format: {user_id}/{item_id}/{file_type}/{filename}
84 + pub fn generate_key(
85 + user_id: UserId,
86 + item_id: ItemId,
87 + file_type: FileType,
88 + filename: &str,
89 + ) -> S3Key {
90 + let safe_filename = sanitize_filename(filename);
91 + S3Key(format!(
92 + "{}/{}/{}/{}",
93 + user_id,
94 + item_id,
95 + file_type.as_str(),
96 + safe_filename
97 + ))
98 + }
99 +
100 + /// Staging key for scan-then-promote: browser uploads presign to this key,
101 + /// which is NEVER served. After a Clean scan the worker copies the object to
102 + /// its content-addressed [`content_key`](Self::content_key) and deletes the
103 + /// staging object. The upload's extension is carried in the staging key so
104 + /// the worker can build the content key without re-reading the entity row.
105 + /// Format: `staging/{uuid}/{sanitized_filename}`. The random uuid segment
106 + /// means a replayed presigned PUT can only re-write the (unserved,
107 + /// post-scan-deleted) staging object, never the served content key, and two
108 + /// uploads of the same filename never collide.
109 + /// The original filename is preserved after the uuid so a confirm can recover
110 + /// it (e.g. a version download's suggested name), `sanitize_filename` strips
111 + /// any `/`, so the name can't add path segments or escape the `staging/`
112 + /// prefix. The extension still rides along for the content key.
113 + pub fn generate_staging_key(filename: &str) -> S3Key {
114 + S3Key(format!(
115 + "staging/{}/{}",
116 + uuid::Uuid::new_v4(),
117 + sanitize_filename(filename)
118 + ))
119 + }
120 +
121 + /// Key for an object in the Alloy hotfix RPM repository, from the relative
122 + /// path the publisher names (e.g. `alloy/f43/x86_64/repodata/repomd.xml`).
123 + ///
124 + /// The odd one out among the generators, and deliberately so: every other
125 + /// key layout here is derived from ids we hold, but a yum repository *is* a
126 + /// path layout that `createrepo_c` writes and `dnf` re-derives from
127 + /// `repomd.xml`. The server cannot invent it without reimplementing
128 + /// createrepo, so the caller supplies it. That makes this the one generator
129 + /// whose whole job is refusing bad input, and it returns `Result` for that
130 + /// reason. Which layout the repo actually uses is
131 + /// [`86cb87b9`](https://makenot.work)'s business, not this function's, hence
132 + /// no structure is imposed beyond a segment count.
133 + ///
134 + /// Refused: absolute paths, empty segments (so `//` and a trailing `/`),
135 + /// `.` and `..` in any position, a segment starting `.` or `-`, anything
136 + /// outside `[A-Za-z0-9._+~-]`, and a final segment whose extension is not
137 + /// one a yum repository serves. Together those make traversal
138 + /// unrepresentable rather than merely unlikely, and keep a presigned PUT
139 + /// from writing an object the Caddy block would then serve as something it
140 + /// is not.
141 + pub fn generate_rpm_key(path: &str) -> Result<S3Key> {
142 + let bad = |msg: &str| AppError::BadRequest(format!("invalid RPM object path: {msg}"));
143 +
144 + if path.is_empty() {
145 + return Err(bad("empty"));
146 + }
147 + if path.len() > constants::RPM_MAX_KEY_BYTES {
148 + return Err(bad(&format!(
149 + "longer than {} bytes",
150 + constants::RPM_MAX_KEY_BYTES
151 + )));
152 + }
153 + if path.starts_with('/') {
154 + return Err(bad("must be relative, not absolute"));
155 + }
156 +
157 + let segments: Vec<&str> = path.split('/').collect();
158 + if segments.len() > constants::RPM_MAX_KEY_SEGMENTS {
159 + return Err(bad(&format!(
160 + "more than {} path segments",
161 + constants::RPM_MAX_KEY_SEGMENTS
162 + )));
163 + }
164 +
165 + for segment in &segments {
166 + if segment.is_empty() {
167 + return Err(bad("empty path segment"));
168 + }
169 + if *segment == "." || *segment == ".." {
170 + return Err(bad("`.` and `..` are not path segments"));
171 + }
172 + if segment.starts_with('.') || segment.starts_with('-') {
173 + return Err(bad("a path segment may not start with `.` or `-`"));
174 + }
175 + if !segment
176 + .chars()
177 + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '-'))
178 + {
179 + return Err(bad(
180 + "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`",
181 + ));
182 + }
183 + }
184 +
185 + // Unwrap: `split` on a non-empty string always yields at least one
186 + // segment, and every segment was proven non-empty above.
187 + let filename = segments.last().copied().unwrap_or_default();
188 + let ext = filename
189 + .rsplit_once('.')
190 + .map(|(_, ext)| ext.to_ascii_lowercase())
191 + .ok_or_else(|| bad("the final path segment needs a file extension"))?;
192 + if !RPM_REPO_EXTENSIONS.contains(&ext.as_str()) {
193 + return Err(bad(&format!(
194 + "`.{ext}` is not served from an RPM repository. Allowed: {}",
195 + RPM_REPO_EXTENSIONS.join(", ")
196 + )));
197 + }
198 +
199 + Ok(S3Key(path.to_string()))
200 + }
201 +
202 + /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's
203 + /// name *is* its content hash, so the served bytes are provably the bytes
204 + /// that were scanned, a swapped object would hash to a different key. The
205 + /// key is per-owner (`user_id`) namespaced, so identical bytes uploaded by
206 + /// different creators do NOT collapse to one shared object (no cross-tenant
207 + /// existence oracle). The `c` marker segment cannot collide with the legacy
208 + /// `{user_id}/{item_id}/...` layout because `c` is not a UUID.
209 + pub fn content_key(user_id: UserId, sha256: &str, ext: &str) -> S3Key {
210 + S3Key(format!("{user_id}/c/{sha256}.{ext}"))
211 + }
212 +
213 + /// Generate an S3 key for a version download file. The version's own id is
214 + /// woven into the path so two versions of the same item that share a
215 + /// filename (e.g. a creator who ships every release as `plugin.zip`) never
216 + /// resolve to the same object, mirrors the per-entity-uuid segment the
217 + /// gallery keys use, except the version id is the table's primary key, so
218 + /// uniqueness is guaranteed by construction rather than by a fresh uuid.
219 + /// Format: {user_id}/{item_id}/download/{version_id}/{filename}
220 + pub fn generate_version_key(
221 + user_id: UserId,
222 + item_id: ItemId,
223 + version_id: VersionId,
224 + filename: &str,
225 + ) -> S3Key {
226 + let safe_filename = sanitize_filename(filename);
227 + S3Key(format!(
228 + "{}/{}/{}/{}/{}",
229 + user_id,
230 + item_id,
231 + FileType::Download.as_str(),
232 + version_id,
233 + safe_filename
234 + ))
235 + }
236 +
237 + /// Generate an S3 key for a reusable insertion clip (not tied to any item).
238 + /// Format: {user_id}/insertions/{filename}
239 + pub fn generate_insertion_key(user_id: UserId, filename: &str) -> S3Key {
240 + let safe_filename = sanitize_filename(filename);
241 + S3Key(format!("{user_id}/insertions/{safe_filename}"))
242 + }
243 +
244 + /// Generate an S3 key for a media library file.
245 + /// Format: `{user_id}/media/{folder}/{filename}` (or `{user_id}/media/{filename}` for root folder).
246 + pub fn generate_media_key(user_id: UserId, folder: &str, filename: &str) -> S3Key {
247 + let safe_filename = sanitize_filename(filename);
248 + let safe_folder = sanitize_folder(folder);
249 + if safe_folder.is_empty() {
250 + S3Key(format!("{user_id}/media/{safe_filename}"))
251 + } else {
252 + S3Key(format!("{user_id}/media/{safe_folder}/{safe_filename}"))
253 + }
254 + }
255 +
256 + /// Generate an S3 key for a project image (logo/avatar).
257 + /// Format: projects/{project_id}/image/{sanitized_filename}
258 + pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> S3Key {
259 + let safe_filename = sanitize_filename(filename);
260 + S3Key(format!("projects/{project_id}/image/{safe_filename}"))
261 + }
262 +
263 + /// Generate an S3 key for an OTA release artifact. Singleton per
264 + /// (app, version, target, arch), the release row already enforces
265 + /// `UNIQUE(app_id, version)` and the artifact row `UNIQUE(release_id, target,
266 + /// arch)`, so re-uploading the same artifact correctly overwrites in place.
267 + /// Centralized here so OTA keys are no longer hand-built at the call site.
268 + /// Format: ota/{app_id}/{version}/{target}/{arch}/artifact
269 + pub fn generate_ota_artifact_key(
270 + app_id: SyncAppId,
271 + version: &str,
272 + target: &str,
273 + arch: &str,
274 + ) -> S3Key {
275 + S3Key(format!("ota/{app_id}/{version}/{target}/{arch}/artifact"))
276 + }
277 +
278 + /// Generate an S3 key for a SyncKit content-addressed blob. The hash is the
279 + /// uniqueness segment (and `UNIQUE(app_id, user_id, hash)` backs it), so two
280 + /// uploads of identical bytes resolve to one object by design.
281 + /// Format: {app_id}/{user_id}/{hash}
282 + pub fn generate_synckit_blob_key(app_id: SyncAppId, user_id: UserId, hash: &str) -> S3Key {
283 + S3Key(format!("{app_id}/{user_id}/{hash}"))
284 + }
285 +
286 + /// Generate an S3 key for a generated content-export archive. Ephemeral
287 + /// (presigned, then reaped); the timestamp keeps repeat exports distinct.
288 + /// Format: {user_id}/exports/content-{timestamp}.zip
289 + pub fn generate_content_export_key(user_id: UserId, timestamp: &str) -> S3Key {
290 + S3Key(format!("{user_id}/exports/content-{timestamp}.zip"))
291 + }
292 +
293 + /// Generate an S3 key for an item gallery image. A per-image uuid segment
294 + /// keeps multiple gallery uploads from colliding (unlike the single cover,
295 + /// which has a fixed `cover/` path).
296 + /// Format: {user_id}/{item_id}/gallery/{image_uuid}/{sanitized_filename}
297 + pub fn generate_item_gallery_key(
298 + user_id: UserId,
299 + item_id: ItemId,
300 + image_uuid: uuid::Uuid,
301 + filename: &str,
302 + ) -> S3Key {
303 + let safe_filename = sanitize_filename(filename);
304 + S3Key(format!(
305 + "{user_id}/{item_id}/gallery/{image_uuid}/{safe_filename}"
306 + ))
307 + }
308 +
309 + /// Generate an S3 key for a project gallery image.
310 + /// Format: projects/{project_id}/gallery/{image_uuid}/{sanitized_filename}
311 + pub fn generate_project_gallery_key(
312 + project_id: ProjectId,
313 + image_uuid: uuid::Uuid,
314 + filename: &str,
315 + ) -> S3Key {
316 + let safe_filename = sanitize_filename(filename);
317 + S3Key(format!(
318 + "projects/{project_id}/gallery/{image_uuid}/{safe_filename}"
319 + ))
320 + }
321 + }
322 +
323 + /// Sanitize a filename: keep only alphanumeric, dots, dashes, and underscores.
324 + /// Prevents path traversal, shell injection, and S3 key encoding issues.
325 + /// Falls back to "file" if the sanitized result has no basename (only extension or empty).
326 + ///
327 + /// **By design**: the sanitizer keeps `.`/`-`/`_` and strips everything else,
328 + /// so e.g. `"../etc/passwd"` collapses to `"..etcpasswd"`, preserved as a
329 + /// literal filename, not as a directory traversal. The unit test pins this
330 + /// behavior: we don't reject names containing `..`, we just guarantee the
331 + /// output has no path separators. S3 keys are namespaced by user/item ID
332 + /// upstream, so a flat literal here can't escape the user's prefix.
333 + ///
334 + /// `pub(crate)` so confirm handlers store a filename that matches the tail of
335 + /// the key `generate_media_key` produced, rather than re-deriving a weaker
336 + /// filter that drops the empty-basename fallback.
337 + /// The lowercased ASCII-alphanumeric file extension for a staging/content key,
338 + /// or `"bin"` when the filename has none. Bounded to 16 chars so a crafted
339 + /// filename can't bloat the key. Content keys carry an extension purely so
340 + /// CDN-served objects keep a sensible suffix (content-type sniffing, browser
341 + /// "save as"); the hash is the identity, the extension is cosmetic.
342 + // Retained as a tested key-extension utility; `generate_staging_key` now embeds
343 + // the full sanitized filename (which carries the extension) instead of calling
344 + // this, so it has no production caller today.
345 + #[allow(dead_code)]
346 + pub(crate) fn extension_for(filename: &str) -> String {
347 + let ext: String = std::path::Path::new(filename)
348 + .extension()
349 + .and_then(|s| s.to_str())
350 + .unwrap_or("")
351 + .chars()
352 + .filter(char::is_ascii_alphanumeric)
353 + .map(|c| c.to_ascii_lowercase())
354 + .take(16)
355 + .collect();
356 + if ext.is_empty() {
357 + "bin".to_string()
358 + } else {
359 + ext
360 + }
361 + }
362 +
363 + /// The extension segment of a key's basename (the text after the last `.`), or
364 + /// `"bin"`. Lets the scan worker's promote step carry a staging object's
365 + /// extension onto its content key without re-reading the entity row.
366 + pub(crate) fn key_extension(key: &str) -> &str {
367 + key.rsplit('/')
368 + .next()
369 + .and_then(|base| base.rsplit_once('.').map(|(_, ext)| ext))
370 + .filter(|ext| !ext.is_empty())
371 + .unwrap_or("bin")
372 + }
373 +
374 + pub(crate) fn sanitize_filename(filename: &str) -> String {
375 + let sanitized: String = filename
376 + .chars()
377 + .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
378 + .collect();
379 + // Ensure the result has a non-empty basename (not just ".ext" or empty)
380 + let stem = std::path::Path::new(&sanitized)
381 + .file_stem()
382 + .and_then(|s| s.to_str())
383 + .unwrap_or("");
384 + if stem.is_empty() {
385 + let ext = std::path::Path::new(&sanitized)
386 + .extension()
387 + .and_then(|s| s.to_str())
388 + .unwrap_or("");
389 + if ext.is_empty() {
390 + "file".to_string()
391 + } else {
392 + format!("file.{ext}")
393 + }
394 + } else {
395 + sanitized
396 + }
397 + }
398 +
399 + /// Sanitize a folder name: keep only alphanumeric, dashes, and underscores.
400 + /// Rejects path traversal (`..`) and slashes. Returns empty string for root folder.
401 + pub fn sanitize_folder(folder: &str) -> String {
402 + let trimmed = folder.trim();
403 + if trimmed.is_empty() {
404 + return String::new();
405 + }
406 + // Reject any path traversal
407 + if trimmed.contains("..") {
408 + return String::new();
409 + }
410 + trimmed
411 + .chars()
412 + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
413 + .collect()
414 + }
@@ -1,0 +1,25 @@
1 + //! S3-compatible storage client for Hetzner Object Storage
2 + //!
3 + //! Provides presigned URL generation for client-direct uploads/downloads.
4 + //! Delegates S3 operations to the shared `s3_storage` crate.
5 + //!
6 + //! See also: `/docs/guide/audio`, `/docs/guide/video`, `/docs/guide/software`
7 +
8 + mod backend;
9 + mod bucket;
10 + mod client;
11 + mod file_type;
12 + mod key;
13 + mod urls;
14 +
15 + pub use backend::*;
16 + pub use bucket::*;
17 + pub use client::*;
18 + pub use file_type::*;
19 + pub use key::*;
20 + pub use urls::*;
21 +
22 + #[cfg(test)]
23 + mod seals;
24 + #[cfg(test)]
25 + mod tests;
@@ -1,0 +1,133 @@
1 + //! Two grep-based lints over `src/routes/`, not unit tests: one proves every
2 + //! bucket delete goes through the authority, the other that no served key is
3 + //! built by hand. Both resolve their target from `CARGO_MANIFEST_DIR`, so
4 + //! moving this file does not move what they scan.
5 +
6 + use std::path::Path;
7 +
8 + /// Hand every `.rs` file under `dir` to `f`, with its path and its contents.
9 + /// Both seals below are a grep over the same tree, so they share the walk.
10 + fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
11 + let Ok(entries) = std::fs::read_dir(dir) else {
12 + return;
13 + };
14 + for entry in entries.flatten() {
15 + let path = entry.path();
16 + if path.is_dir() {
17 + walk(&path, f);
18 + } else if path.extension().is_some_and(|e| e == "rs")
19 + && let Ok(contents) = std::fs::read_to_string(&path)
20 + {
21 + f(&path, &contents);
22 + }
23 + }
24 + }
25 +
26 + /// Build-time enforcement: route handlers must never
27 + /// delete S3 objects directly, nor mint an [`S3DeleteAuthority`]. Direct
28 + /// deletion is for the sanctioned durable-deletion paths (`scheduler/cleanup.rs`,
29 + /// `scanning/worker.rs`) only; handlers enqueue through `pending_s3_deletions`.
30 + ///
31 + /// The type system already makes the accidental `s3.delete_object(key)`
32 + /// uncompilable (the delete methods require an authority handlers can't reach).
33 + /// This test closes the deliberate-circumvention gap: it fails the build if any
34 + /// file under `src/routes/` names a delete method or the authority type, so the
35 + /// seal cannot silently erode in a future handler.
36 + #[cfg(test)]
37 + mod delete_seal_guard {
38 + use super::walk;
39 + use std::path::Path;
40 +
41 + #[test]
42 + fn routes_never_delete_s3_directly() {
43 + let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes");
44 + let mut offenders = Vec::new();
45 + walk(&routes_dir, &mut |path, contents| {
46 + for (i, line) in contents.lines().enumerate() {
47 + // Skip comment/doc lines (they legitimately mention the API).
48 + if line.trim_start().starts_with("//") {
49 + continue;
50 + }
51 + if line.contains(".delete_object(")
52 + || line.contains(".delete_objects(")
53 + || line.contains(".delete_prefix(")
54 + || line.contains("S3DeleteAuthority")
55 + {
56 + offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
57 + }
58 + }
59 + });
60 + assert!(
61 + offenders.is_empty(),
62 + "CHRONIC B' seal violated, route code must enqueue via \
63 + routes::storage::enqueue_s3_orphan, never delete S3 directly or mint an \
64 + S3DeleteAuthority. Offending lines:\n{}",
65 + offenders.join("\n")
66 + );
67 + }
68 + }
69 +
70 + /// C1 scan-then-promote seal: route handlers must never mint a *served* S3 key.
71 + ///
72 + /// A presigned client upload can only ever land at a `staging/{uuid}` key
73 + /// ([`S3Client::generate_staging_key`]); the served, content-addressed key
74 + /// ([`S3Client::content_key`]) is created in exactly one place, the scan
75 + /// worker's promote step, after a Clean verdict, so the bytes a buyer is served
76 + /// are provably the bytes that were scanned. The mutable-served-key class (a
77 + /// presign minting `{user}/{item}/type/filename`, then the owner re-PUTting to it
78 + /// after it goes Clean) is what this closes.
79 + ///
80 + /// This guard fails the build if any file under `src/routes/` names a served-key
81 + /// generator or `content_key`. It is stronger than `pub(crate)` visibility,
82 + /// route code lives in the same crate, so `pub(crate)` would not stop it from
83 + /// calling these, and it is the same grep-proof discipline as the delete seal
84 + /// above. (The build runner uploads OTA artifacts server-side to a deterministic
85 + /// key via `generate_ota_artifact_key`; it lives outside `src/routes/`, so it is
86 + /// legitimately unaffected.)
87 + #[cfg(test)]
88 + mod served_key_seal_guard {
89 + use super::walk;
90 + use std::path::Path;
91 +
92 + #[test]
93 + fn routes_never_mint_served_keys() {
94 + let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes");
95 + // Every served-key generator, plus the content-key minter. `staging`
96 + // keys are the ONLY key a route may mint, so `generate_staging_key` is
97 + // deliberately absent from this list.
98 + // Anchored to the `S3Client::` call prefix so an unrelated `generate_key`
99 + // (e.g. `license_keys::generate_key`, `helpers::generate_key_code`) is not
100 + // a false positive, only the storage generators are S3Client methods.
101 + const FORBIDDEN: &[&str] = &[
102 + "S3Client::generate_key(",
103 + "S3Client::generate_version_key(",
104 + "S3Client::generate_insertion_key(",
105 + "S3Client::generate_media_key(",
106 + "S3Client::generate_project_image_key(",
107 + "S3Client::generate_ota_artifact_key(",
108 + "S3Client::generate_item_gallery_key(",
109 + "S3Client::generate_project_gallery_key(",
110 + "S3Client::content_key(",
111 + ];
112 + let mut offenders = Vec::new();
113 + walk(&routes_dir, &mut |path, contents| {
114 + for (i, line) in contents.lines().enumerate() {
115 + if line.trim_start().starts_with("//") {
116 + continue;
117 + }
118 + for needle in FORBIDDEN {
119 + if line.contains(needle) {
120 + offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
121 + }
122 + }
123 + }
124 + });
125 + assert!(
126 + offenders.is_empty(),
127 + "C1 seal violated, route handlers must presign only `generate_staging_key`; \
128 + the served/content key is minted solely by the scan worker's promote step. \
129 + Offending lines:\n{}",
130 + offenders.join("\n")
131 + );
132 + }
133 + }
@@ -1,0 +1,600 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use crate::db::{ItemId, ProjectId, UserId, VersionId};
5 + use crate::error::AppError;
6 + use std::str::FromStr;
7 +
8 + #[tokio::test]
9 + async fn capped_read_aborts_when_body_exceeds_cap() {
10 + // A recorded-small object whose real body is larger must not aggregate
11 + // past the cap (Run 22 Perf, the buffered scan-download ceiling).
12 + let stream = s3_storage::ByteStream::from(vec![0u8; 100]);
13 + let err = read_bytestream_capped(stream, "k", 50).await.unwrap_err();
14 + assert!(
15 + matches!(err, AppError::Storage(_)),
16 + "expected Storage error, got {err:?}"
17 + );
18 + }
19 +
20 + #[tokio::test]
21 + async fn capped_read_allows_body_within_cap() {
22 + let stream = s3_storage::ByteStream::from(vec![7u8; 40]);
23 + let out = read_bytestream_capped(stream, "k", 50).await.unwrap();
24 + assert_eq!(out.len(), 40);
25 + assert!(out.iter().all(|&b| b == 7));
26 + }
27 +
28 + #[tokio::test]
29 + async fn capped_read_allows_body_exactly_at_cap() {
30 + // The abort condition is strictly `>`, so a body equal to the cap passes.
31 + let stream = s3_storage::ByteStream::from(vec![1u8; 50]);
32 + let out = read_bytestream_capped(stream, "k", 50).await.unwrap();
33 + assert_eq!(out.len(), 50);
34 + }
35 +
36 + #[test]
37 + fn extract_key_cdn_form() {
38 + let key = extract_s3_key_from_url(
39 + "https://cdn.makenot.work/projects/abc/image/cover.png",
40 + "https://cdn.makenot.work",
41 + None,
42 + None,
43 + );
44 + assert_eq!(key.as_deref(), Some("projects/abc/image/cover.png"));
45 + }
46 +
47 + #[test]
48 + fn extract_key_cdn_with_trailing_slash_in_base() {
49 + let key = extract_s3_key_from_url(
50 + "https://cdn.makenot.work/foo/bar",
51 + "https://cdn.makenot.work/",
52 + None,
53 + None,
54 + );
55 + assert_eq!(key.as_deref(), Some("foo/bar"));
56 + }
57 +
58 + #[test]
59 + fn extract_key_strips_query_string() {
60 + let key = extract_s3_key_from_url(
61 + "https://cdn.makenot.work/foo/bar?X-Amz-Signature=zzz",
62 + "https://cdn.makenot.work",
63 + None,
64 + None,
65 + );
66 + assert_eq!(key.as_deref(), Some("foo/bar"));
67 + }
68 +
69 + #[test]
70 + fn extract_key_path_style_s3() {
71 + let key = extract_s3_key_from_url(
72 + "https://fsn1.your-objectstorage.com/my-bucket/u/123/image/cover.png?X-Amz=...",
73 + "",
74 + Some("my-bucket"),
75 + Some("https://fsn1.your-objectstorage.com"),
76 + );
77 + assert_eq!(key.as_deref(), Some("u/123/image/cover.png"));
78 + }
79 +
80 + #[test]
81 + fn extract_key_path_style_rejects_attacker_host() {
82 + // Attacker-controlled host with the legitimate bucket name in the
83 + // path must NOT be accepted. The endpoint pin closes the gap.
84 + let key = extract_s3_key_from_url(
85 + "https://attacker.example/my-bucket/poisoned",
86 + "",
87 + Some("my-bucket"),
88 + Some("https://fsn1.your-objectstorage.com"),
89 + );
90 + assert_eq!(key, None);
91 + }
92 +
93 + #[test]
94 + fn extract_key_path_style_requires_endpoint() {
95 + // Without the endpoint, the path-style branch must not fire, bucket
96 + // name alone is not enough to identify a trustworthy host.
97 + let key = extract_s3_key_from_url(
98 + "https://fsn1.your-objectstorage.com/my-bucket/u/123/key",
99 + "",
100 + Some("my-bucket"),
101 + None,
102 + );
103 + assert_eq!(key, None);
104 + }
105 +
106 + #[test]
107 + fn extract_key_returns_none_when_no_prefix_matches() {
108 + // Neither the CDN base nor the bucket name is present in the URL.
109 + let key = extract_s3_key_from_url(
110 + "https://random.example.com/foo/bar",
111 + "https://cdn.makenot.work",
112 + Some("my-bucket"),
113 + Some("https://fsn1.your-objectstorage.com"),
114 + );
115 + assert_eq!(key, None);
116 + }
117 +
118 + #[test]
119 + fn extract_key_does_not_misparse_keys_containing_projects_substring() {
120 + // Regression: the old heuristic would have returned just
121 + // "projects/x" from this URL, dropping the user-scoped prefix.
122 + let key = extract_s3_key_from_url(
123 + "https://cdn.makenot.work/u/me/projects/x",
124 + "https://cdn.makenot.work",
125 + None,
126 + None,
127 + );
128 + assert_eq!(key.as_deref(), Some("u/me/projects/x"));
129 + }
130 +
131 + #[test]
132 + fn test_generate_key() {
133 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
134 + let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
135 +
136 + let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3");
137 + assert_eq!(
138 + key,
139 + "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3"
140 + );
141 + }
142 +
143 + #[test]
144 + fn test_generate_version_key_is_unique_per_version() {
145 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
146 + let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
147 + let v1: VersionId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
148 + let v2: VersionId = "44444444-4444-4444-4444-444444444444".parse().unwrap();
149 +
150 + // Two versions of the SAME item sharing a filename must not collide.
151 + let k1 = S3Client::generate_version_key(user_id, item_id, v1, "plugin.zip");
152 + let k2 = S3Client::generate_version_key(user_id, item_id, v2, "plugin.zip");
153 + assert_ne!(k1, k2, "same-filename versions must produce distinct keys");
154 +
155 + assert_eq!(
156 + k1,
157 + "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/download/33333333-3333-3333-3333-333333333333/plugin.zip"
158 + );
159 + // Confirm-handler prefix check is `{user}/{item}/`; the woven key still
160 + // satisfies it.
161 + assert!(
162 + k1.starts_with(
163 + "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/"
164 + )
165 + );
166 + // Filename is still the last path segment (confirm extracts it via rsplit).
167 + assert_eq!(k1.rsplit('/').next(), Some("plugin.zip"));
168 + }
169 +
170 + #[test]
171 + fn test_generate_version_key_sanitizes_filename() {
172 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
173 + let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
174 + let v1: VersionId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
175 +
176 + let key = S3Client::generate_version_key(user_id, item_id, v1, "my release (1).zip");
177 + assert!(key.ends_with("/myrelease1.zip"));
178 + }
179 +
180 + #[test]
181 + fn test_generate_key_sanitizes_filename() {
182 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
183 + let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
184 +
185 + let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "my file (1).mp3");
186 + assert!(key.ends_with("/myfile1.mp3"));
187 + }
188 +
189 + #[test]
190 + fn test_validate_content_type() {
191 + assert!(S3Client::validate_content_type(FileType::Audio, "audio/mpeg").is_ok());
192 + assert!(S3Client::validate_content_type(FileType::Audio, "audio/wav").is_ok());
193 + assert!(S3Client::validate_content_type(FileType::Audio, "image/png").is_err());
194 +
195 + assert!(S3Client::validate_content_type(FileType::Cover, "image/png").is_ok());
196 + assert!(S3Client::validate_content_type(FileType::Cover, "image/jpeg").is_ok());
197 + assert!(S3Client::validate_content_type(FileType::Cover, "audio/mpeg").is_err());
198 + }
199 +
200 + #[test]
201 + fn content_type_for_maps_allowed_extensions() {
202 + // The multipart promote names the destination's type explicitly, so this
203 + // must agree with the allow-list the upload was validated against.
204 + assert_eq!(content_type_for(FileType::Video, "mp4"), "video/mp4");
205 + assert_eq!(content_type_for(FileType::Video, "webm"), "video/webm");
206 + assert_eq!(content_type_for(FileType::Cover, "png"), "image/png");
207 + // Extensions arrive from a key, which may carry any case.
208 + assert_eq!(content_type_for(FileType::Video, "MP4"), "video/mp4");
209 + }
210 +
211 + #[test]
212 + fn content_type_for_falls_back_for_unknown_extension() {
213 + // `key_extension` yields "bin" when a key has no extension; an unknown
214 + // extension must degrade to a generic type, never panic or mis-label.
215 + assert_eq!(
216 + content_type_for(FileType::Video, "bin"),
217 + "application/octet-stream"
218 + );
219 + assert_eq!(
220 + content_type_for(FileType::Video, ""),
221 + "application/octet-stream"
222 + );
223 + }
224 +
225 + #[test]
226 + fn single_copy_ceiling_matches_s3_and_is_distinct_from_the_browser_ceiling() {
227 + // Different limits, and since the browser cap dropped to 2 GiB, different
228 + // numbers too: the copy ceiling is what S3 enforces on a one-shot
229 + // `CopyObject`, the browser cap is a product call about resumability.
230 + // Pinned so a future change to one doesn't silently move the other.
231 + assert_eq!(
232 + crate::constants::S3_SINGLE_COPY_MAX_BYTES,
233 + 5 * 1024 * 1024 * 1024
234 + );
235 + assert_eq!(
236 + crate::constants::BROWSER_UPLOAD_MAX_BYTES,
237 + 2 * 1024 * 1024 * 1024
238 + );
239 + }
240 +
241 + #[test]
242 + fn test_validate_extension() {
243 + assert!(S3Client::validate_extension(FileType::Audio, "episode.mp3").is_ok());
244 + assert!(S3Client::validate_extension(FileType::Audio, "episode.MP3").is_ok());
245 + assert!(S3Client::validate_extension(FileType::Audio, "episode.png").is_err());
246 +
247 + assert!(S3Client::validate_extension(FileType::Cover, "cover.jpg").is_ok());
248 + assert!(S3Client::validate_extension(FileType::Cover, "cover.webp").is_ok());
249 + assert!(S3Client::validate_extension(FileType::Cover, "cover.mp3").is_err());
250 + }
251 +
252 + #[test]
253 + fn test_file_type_from_str() {
254 + assert_eq!(FileType::from_str("audio"), Ok(FileType::Audio));
255 + assert_eq!(FileType::from_str("AUDIO"), Ok(FileType::Audio));
256 + assert_eq!(FileType::from_str("cover"), Ok(FileType::Cover));
257 + assert_eq!(FileType::from_str("image"), Ok(FileType::Cover));
258 + assert!(FileType::from_str("invalid").is_err());
259 + }
260 +
261 + #[test]
262 + fn file_type_as_str() {
263 + assert_eq!(FileType::Audio.as_str(), "audio");
264 + assert_eq!(FileType::Cover.as_str(), "cover");
265 + }
266 +
267 + #[test]
268 + fn file_type_max_size() {
269 + assert_eq!(FileType::Audio.max_size(), 500 * 1024 * 1024);
270 + assert_eq!(FileType::Cover.max_size(), 10 * 1024 * 1024);
271 + }
272 +
273 + #[test]
274 + fn file_type_allowed_types_audio() {
275 + let types = FileType::Audio.allowed_types();
276 + let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect();
277 + assert!(exts.contains(&"mp3"));
278 + assert!(exts.contains(&"wav"));
279 + assert!(exts.contains(&"flac"));
280 + assert!(!exts.contains(&"png"));
281 + }
282 +
283 + #[test]
284 + fn file_type_allowed_types_cover() {
285 + let types = FileType::Cover.allowed_types();
286 + let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect();
287 + assert!(exts.contains(&"jpg"));
288 + assert!(exts.contains(&"png"));
289 + assert!(exts.contains(&"webp"));
290 + assert!(!exts.contains(&"mp3"));
291 + }
292 +
293 + #[test]
294 + fn generate_key_strips_path_traversal() {
295 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
296 + let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
297 +
298 + let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "../../etc/passwd");
299 + // Slashes are stripped, dots kept: "../../etc/passwd" -> "....etcpasswd"
300 + assert!(key.ends_with("/audio/....etcpasswd"));
301 + }
302 +
303 + #[test]
304 + fn generate_key_empty_filename_gets_fallback() {
305 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
306 + let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
307 +
308 + let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "");
309 + assert!(
310 + key.ends_with("/cover/file"),
311 + "expected fallback name 'file', got: {key}"
312 + );
313 + }
314 +
315 + #[test]
316 + fn validate_extension_no_extension() {
317 + assert!(S3Client::validate_extension(FileType::Audio, "noext").is_err());
318 + }
319 +
320 + #[test]
321 + fn validate_extension_double_dot() {
322 + assert!(S3Client::validate_extension(FileType::Audio, "file.backup.mp3").is_ok());
323 + }
324 +
325 + #[test]
326 + fn validate_content_type_empty() {
327 + assert!(S3Client::validate_content_type(FileType::Audio, "").is_err());
328 + }
329 +
330 + #[test]
331 + fn file_type_insertion_from_str() {
332 + assert_eq!(FileType::from_str("insertion"), Ok(FileType::Insertion));
333 + assert_eq!(FileType::from_str("INSERTION"), Ok(FileType::Insertion));
334 + }
335 +
336 + #[test]
337 + fn file_type_insertion_as_str() {
338 + assert_eq!(FileType::Insertion.as_str(), "insertion");
339 + }
340 +
341 + #[test]
342 + fn file_type_insertion_max_size() {
343 + assert_eq!(FileType::Insertion.max_size(), 500 * 1024 * 1024);
344 + }
345 +
346 + #[test]
347 + fn validate_insertion_content_types() {
348 + // Audio clips (the original use).
349 + assert!(S3Client::validate_content_type(FileType::Insertion, "audio/mpeg").is_ok());
350 + assert!(S3Client::validate_content_type(FileType::Insertion, "audio/wav").is_ok());
351 + assert!(S3Client::validate_content_type(FileType::Insertion, "audio/flac").is_ok());
352 + // Video clips (pre/mid/post-roll on video items).
353 + assert!(S3Client::validate_content_type(FileType::Insertion, "video/mp4").is_ok());
354 + assert!(S3Client::validate_content_type(FileType::Insertion, "video/webm").is_ok());
355 + assert!(S3Client::validate_content_type(FileType::Insertion, "video/quicktime").is_ok());
356 + // Neither audio nor video is rejected.
357 + assert!(S3Client::validate_content_type(FileType::Insertion, "image/png").is_err());
358 + }
359 +
360 + #[test]
361 + fn validate_insertion_extensions() {
362 + assert!(S3Client::validate_extension(FileType::Insertion, "intro.mp3").is_ok());
363 + assert!(S3Client::validate_extension(FileType::Insertion, "sponsor.wav").is_ok());
364 + assert!(S3Client::validate_extension(FileType::Insertion, "outro.flac").is_ok());
365 + assert!(S3Client::validate_extension(FileType::Insertion, "bumper.mp4").is_ok());
366 + assert!(S3Client::validate_extension(FileType::Insertion, "bumper.webm").is_ok());
367 + assert!(S3Client::validate_extension(FileType::Insertion, "clip.png").is_err());
368 + }
369 +
370 + #[test]
371 + fn insertion_media_type_classifies_by_mime_family() {
372 + assert_eq!(S3Client::insertion_media_type("audio/mpeg"), "audio");
373 + assert_eq!(S3Client::insertion_media_type("audio/mp4"), "audio");
374 + assert_eq!(S3Client::insertion_media_type("video/mp4"), "video");
375 + assert_eq!(S3Client::insertion_media_type("video/webm"), "video");
376 + }
377 +
378 + #[test]
379 + fn generate_insertion_key_format() {
380 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
381 + let key = S3Client::generate_insertion_key(user_id, "intro.mp3");
382 + assert_eq!(
383 + key,
384 + "11111111-1111-1111-1111-111111111111/insertions/intro.mp3"
385 + );
386 + }
387 +
388 + #[test]
389 + fn generate_insertion_key_sanitizes() {
390 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
391 + let key = S3Client::generate_insertion_key(user_id, "my sponsor read (v2).mp3");
392 + assert_eq!(
393 + key,
394 + "11111111-1111-1111-1111-111111111111/insertions/mysponsorreadv2.mp3"
395 + );
396 + }
397 +
398 + #[test]
399 + fn extension_for_cases() {
400 + assert_eq!(extension_for("plugin.zip"), "zip");
401 + assert_eq!(extension_for("LOUD.WAV"), "wav"); // lowercased
402 + assert_eq!(extension_for("archive.tar.gz"), "gz"); // last segment only
403 + assert_eq!(extension_for("noextension"), "bin"); // fallback
404 + assert_eq!(extension_for("trailing."), "bin"); // empty ext → fallback
405 + assert_eq!(extension_for("weird.z!p"), "zp"); // non-alnum stripped
406 + }
407 +
408 + #[test]
409 + fn key_extension_cases() {
410 + assert_eq!(key_extension("staging/2f9a.zip"), "zip");
411 + assert_eq!(key_extension("uid/c/abcd1234.mp3"), "mp3");
412 + assert_eq!(key_extension("staging/no-dot-basename"), "bin");
413 + assert_eq!(key_extension("dir.with.dot/basename"), "bin"); // dot in dir, not basename
414 + }
415 +
416 + #[test]
417 + fn staging_key_is_unserved_and_carries_extension() {
418 + let key = S3Client::generate_staging_key("release.zip");
419 + assert!(key.as_str().starts_with("staging/"), "staging key: {key}");
420 + assert_eq!(key_extension(key.as_str()), "zip");
421 + // Two calls never collide (random uuid), so a replayed PUT can't target
422 + // another upload's staging object.
423 + let key2 = S3Client::generate_staging_key("release.zip");
424 + assert_ne!(key, key2);
425 + }
426 +
427 + #[test]
428 + fn content_key_is_hash_addressed_and_owner_namespaced() {
429 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
430 + let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
431 + let key = S3Client::content_key(user_id, sha, "zip");
432 + assert_eq!(
433 + key,
434 + "11111111-1111-1111-1111-111111111111/c/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.zip"
435 + );
436 + // Same bytes, different owner → different key (no cross-tenant sharing).
437 + let other: UserId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
438 + assert_ne!(S3Client::content_key(other, sha, "zip"), key);
439 + }
440 +
441 + #[test]
442 + fn generate_key_cover_type() {
443 + let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
444 + let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
445 +
446 + let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "art.png");
447 + assert!(key.contains("/cover/"));
448 + assert!(key.ends_with("art.png"));
449 + }
450 +
451 + // FileType::Download tests
452 +
453 + #[test]
454 + fn file_type_download_from_str() {
455 + assert_eq!(FileType::from_str("download"), Ok(FileType::Download));
456 + assert_eq!(FileType::from_str("DOWNLOAD"), Ok(FileType::Download));
457 + }
458 +
459 + #[test]
460 + fn file_type_download_as_str() {
461 + assert_eq!(FileType::Download.as_str(), "download");
462 + }
463 +
464 + #[test]
465 + fn file_type_download_max_size() {
466 + assert_eq!(FileType::Download.max_size(), 500 * 1024 * 1024);
467 + }
468 +
469 + #[test]
470 + fn validate_download_content_types() {
471 + assert!(
472 + S3Client::validate_content_type(FileType::Download, "application/octet-stream").is_ok()
473 + );
474 + assert!(S3Client::validate_content_type(FileType::Download, "application/zip").is_ok());
475 + assert!(
476 + S3Client::validate_content_type(FileType::Download, "application/x-apple-diskimage")
477 + .is_ok()
478 + );
479 + assert!(S3Client::validate_content_type(FileType::Download, "application/gzip").is_ok());
480 + assert!(S3Client::validate_content_type(FileType::Download, "application/x-tar").is_ok());
481 + // Reject clearly wrong types
482 + assert!(S3Client::validate_content_type(FileType::Download, "text/html").is_err());
483 + assert!(S3Client::validate_content_type(FileType::Download, "image/png").is_err());
484 + }
485 +
486 + #[test]
487 + fn validate_download_extensions() {
488 + assert!(S3Client::validate_extension(FileType::Download, "app.zip").is_ok());
489 + assert!(S3Client::validate_extension(FileType::Download, "app.dmg").is_ok());
490 + assert!(S3Client::validate_extension(FileType::Download, "app.exe").is_ok());
491 + assert!(S3Client::validate_extension(FileType::Download, "app.appimage").is_ok());
492 + assert!(S3Client::validate_extension(FileType::Download, "app.deb").is_ok());
493 + assert!(S3Client::validate_extension(FileType::Download, "app.tar.gz").is_ok());
494 + assert!(S3Client::validate_extension(FileType::Download, "app.clap").is_ok());
495 + assert!(S3Client::validate_extension(FileType::Download, "app.vst3").is_ok());
496 + assert!(S3Client::validate_extension(FileType::Download, "App.ZIP").is_ok());
497 + // Reject invalid extensions
498 + assert!(S3Client::validate_extension(FileType::Download, "app.mp3").is_err());
499 + assert!(S3Client::validate_extension(FileType::Download, "app.txt").is_err());
500 + }
Lines truncated
@@ -1,0 +1,69 @@
1 + //! Going the other way: a stored URL back to a key, and a key forward to a
2 + //! public URL.
3 +
4 + /// Extract the S3 key from a CDN or presigned URL.
5 + ///
6 + /// Accepts two URL shapes:
7 + /// - **CDN**: `https://cdn.example.com/{s3_key}`, caller supplies the
8 + /// CDN base; the function strips it verbatim.
9 + /// - **Path-style S3**: `https://{host}/{bucket}/{s3_key}?...`, caller
10 + /// supplies the bucket name; the function strips host + bucket prefix.
11 + ///
12 + /// Returns `None` if neither prefix matches. Query strings (presigned URL
13 + /// signatures) are stripped before returning.
14 + ///
15 + /// **Why explicit prefixes**: the prior implementation used
16 + /// `find("projects/")` as a heuristic, which would silently mis-key any URL
17 + /// whose path happened to contain the literal substring (e.g. a key with a
18 + /// `projects/` suffix inside a user folder). Passing the known CDN base and
19 + /// bucket eliminates the heuristic entirely.
20 + pub fn extract_s3_key_from_url(
21 + url: &str,
22 + cdn_base: &str,
23 + bucket: Option<&str>,
24 + s3_endpoint: Option<&str>,
25 + ) -> Option<String> {
26 + let no_query = url.split('?').next()?;
27 +
28 + // Try CDN-base prefix first. An empty base matches nothing rather than
29 + // matching everything: `strip_prefix("")` succeeds on any input, so the
30 + // guard is what keeps a caller that passes "" from harvesting a key out of
31 + // an arbitrary host.
32 + if !cdn_base.is_empty() {
33 + let base = cdn_base.trim_end_matches('/');
34 + if let Some(rest) = no_query.strip_prefix(base)
35 + && let Some(key) = rest.strip_prefix('/')
36 + && !key.is_empty()
37 + {
38 + return Some(key.to_string());
39 + }
40 + }
41 +
42 + // Path-style S3: must match the configured `{endpoint}/{bucket}/` exactly.
43 + // Without the endpoint pin, the prior implementation accepted any
44 + // `https://{any-host}/{bucket}/{key}`, so an attacker-controlled URL like
45 + // `https://attacker.example/my-bucket/poisoned` would extract a real-looking
46 + // key and direct downstream code at attacker-chosen storage paths.
47 + if let (Some(bucket), Some(endpoint)) = (bucket, s3_endpoint) {
48 + let endpoint = endpoint.trim_end_matches('/');
49 + let prefix = format!("{endpoint}/{bucket}/");
50 + if let Some(key) = no_query.strip_prefix(&prefix)
51 + && !key.is_empty()
52 + {
53 + return Some(key.to_string());
54 + }
55 + }
56 +
57 + None
58 + }
59 +
60 + /// Build a permanent URL for a project image.
61 + ///
62 + /// Permanent is the whole contract: callers persist the result into
63 + /// `projects.cover_image_url`, which is read forever. There is deliberately no
64 + /// presigned fallback — an expiring URL in a durable column is the bug this
65 + /// signature exists to make unrepresentable. `cdn_base` is required config
66 + /// (`Config::cdn_base_url`), so there is nothing to fall back to.
67 + pub fn build_project_image_url(cdn_base: &str, s3_key: &str) -> String {
68 + format!("{cdn_base}/{s3_key}")
69 + }