max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
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 | + | } |