//! S3-compatible storage client for Hetzner Object Storage //! //! Provides presigned URL generation for client-direct uploads/downloads. //! Delegates S3 operations to the shared `s3_storage` crate. //! //! See also: `/docs/guide/audio`, `/docs/guide/video`, `/docs/guide/software` use std::str::FromStr; use crate::config::StorageConfig; use crate::db::{ItemId, ProjectId, UserId}; use crate::error::{AppError, Result, ResultExt}; /// Allowed audio file extensions and their MIME types const ALLOWED_AUDIO_TYPES: &[(&str, &str)] = &[ ("mp3", "audio/mpeg"), ("wav", "audio/wav"), ("m4a", "audio/mp4"), ("ogg", "audio/ogg"), ("flac", "audio/flac"), ("aac", "audio/aac"), ]; /// Allowed image file extensions and their MIME types const ALLOWED_IMAGE_TYPES: &[(&str, &str)] = &[ ("jpg", "image/jpeg"), ("jpeg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp"), ("gif", "image/gif"), ]; /// Allowed video file extensions and their MIME types const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[ ("mp4", "video/mp4"), ("webm", "video/webm"), ("mov", "video/quicktime"), ]; /// MIME types accepted for video uploads const ALLOWED_VIDEO_MIMES: &[&str] = &[ "video/mp4", "video/webm", "video/quicktime", ]; /// Allowed download file extensions and their MIME types /// Browsers are inconsistent about MIME types for binary downloads, /// so we accept several common types for each extension. const ALLOWED_DOWNLOAD_TYPES: &[(&str, &str)] = &[ ("zip", "application/zip"), ("dmg", "application/x-apple-diskimage"), ("exe", "application/octet-stream"), ("appimage", "application/octet-stream"), ("deb", "application/octet-stream"), ("clap", "application/octet-stream"), ("vst3", "application/octet-stream"), ]; /// MIME types accepted for download uploads (browsers vary widely) const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[ "application/octet-stream", "application/zip", "application/x-zip-compressed", "application/x-apple-diskimage", "application/x-diskcopy", "application/x-msi", "application/x-ole-storage", "application/gzip", "application/x-tar", "application/x-gtar", "application/x-compressed", "application/x-executable", "application/x-deb", "application/vnd.debian.binary-package", ]; /// Allowed download file extensions (checked separately from MIME) const ALLOWED_DOWNLOAD_EXTENSIONS: &[&str] = &[ "zip", "dmg", "exe", "msi", "appimage", "deb", "tar.gz", "clap", "vst3", ]; /// Maximum file sizes in bytes const MAX_AUDIO_SIZE: u64 = 500 * 1024 * 1024; // 500 MB const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; // 500 MB const MAX_INSERTION_SIZE: u64 = 500 * 1024 * 1024; // 500 MB const MAX_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB /// Default presigned URL expiration. /// 1 hour balances usability (large uploads over slow connections) against /// security (limiting the window for URL leakage). Overridable per-call. const PRESIGN_EXPIRY_SECS: u64 = 3600; /// File type categories for upload #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileType { Audio, Cover, Download, Insertion, Video, /// Media library image (user-scoped, for inline markdown content). MediaImage, /// Media library video (user-scoped, for inline markdown content). MediaVideo, } /// How the generic `/api/upload/confirm` handler confirms a file type onto an /// `items` row. Returned by [`FileType::generic_item_confirm`], whose `match` /// is exhaustive — adding a `FileType` variant fails the build until its /// posture is declared here, so a new type can't silently fall into the wrong /// column set (the `Cover` branch that wrote `cover_s3_key` but never /// `cover_image_url`, leaving an invisible cover — Run #13). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GenericItemConfirm { /// Confirmable by the generic handler: write these two columns on the item. /// Only types fully described by `(s3_key, size)` belong here — anything /// that needs an extra column (e.g. a CDN render URL) must use a dedicated /// route instead. Columns { s3_key: &'static str, size: &'static str }, /// Not confirmable by the generic handler — use this dedicated route. The /// handler rejects the request (after cleaning up the staged object) so a /// misrouted confirm never half-writes the row. UseRoute(&'static str), } impl FileType { /// Declare, exhaustively, how `/api/upload/confirm` treats this type. /// See [`GenericItemConfirm`] for why this is the single source of truth. pub fn generic_item_confirm(self) -> GenericItemConfirm { match self { FileType::Audio => GenericItemConfirm::Columns { s3_key: "audio_s3_key", size: "audio_file_size_bytes", }, FileType::Video => GenericItemConfirm::Columns { s3_key: "video_s3_key", size: "video_file_size_bytes", }, // Cover IS confirmable, but it must also set `cover_image_url` (the // CDN render source). The generic two-column writer can't, so covers // go through the dedicated route that writes all three atomically. FileType::Cover => GenericItemConfirm::UseRoute("/api/items/image/confirm"), FileType::Download => { GenericItemConfirm::UseRoute("/api/versions/{version_id}/upload/*") } FileType::Insertion => { GenericItemConfirm::UseRoute("/api/users/me/insertions/*") } FileType::MediaImage | FileType::MediaVideo => { GenericItemConfirm::UseRoute("/api/media/*") } } } pub fn as_str(&self) -> &'static str { match self { FileType::Audio => "audio", FileType::Cover => "cover", FileType::Download => "download", FileType::Insertion => "insertion", FileType::Video => "video", FileType::MediaImage => "media_image", FileType::MediaVideo => "media_video", } } pub fn max_size(&self) -> u64 { match self { FileType::Audio => MAX_AUDIO_SIZE, FileType::Cover => MAX_IMAGE_SIZE, FileType::Download => MAX_DOWNLOAD_SIZE, FileType::Insertion => MAX_INSERTION_SIZE, FileType::Video => MAX_VIDEO_SIZE, FileType::MediaImage => MAX_MEDIA_IMAGE_SIZE, FileType::MediaVideo => MAX_MEDIA_VIDEO_SIZE, } } pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] { match self { FileType::Audio | FileType::Insertion => ALLOWED_AUDIO_TYPES, FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES, FileType::Download => ALLOWED_DOWNLOAD_TYPES, FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES, } } } impl FromStr for FileType { type Err = String; fn from_str(s: &str) -> std::result::Result { match s.to_lowercase().as_str() { "audio" => Ok(FileType::Audio), "cover" | "image" => Ok(FileType::Cover), "download" => Ok(FileType::Download), "insertion" => Ok(FileType::Insertion), "video" => Ok(FileType::Video), "media_image" => Ok(FileType::MediaImage), "media_video" => Ok(FileType::MediaVideo), _ => Err(format!("Invalid file type: {}", s)), } } } /// Cache-Control value for immutable content (builds, audio, covers). /// One year with immutable directive — Cloudflare and browsers cache indefinitely. pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable"; /// Abstract storage backend — implemented by `S3Client` (production) and /// `InMemoryStorage` (tests). Routes access storage through this trait. #[async_trait::async_trait] pub trait StorageBackend: Send + Sync { /// Generate a presigned upload URL. `max_bytes`, when set, is signed into /// the URL as `Content-Length` so S3 itself enforces the size cap at the /// protocol level (prevents oversized PUTs from burning bandwidth before /// hitting the post-PUT delete-and-charge fallback). async fn presign_upload(&self, s3_key: &str, content_type: &str, expiry_secs: Option, cache_control: Option<&str>, max_bytes: Option) -> Result; async fn presign_download(&self, s3_key: &str, expiry_secs: Option) -> Result; async fn object_exists(&self, s3_key: &str) -> Result; async fn object_size(&self, s3_key: &str) -> Result>; async fn download_object(&self, s3_key: &str) -> Result>; /// Stream the object body without buffering the whole payload. Callers /// drive the stream to disk (scanner spool) or to a layer that consumes /// chunks directly. async fn download_stream(&self, s3_key: &str) -> Result; async fn upload_object(&self, s3_key: &str, content_type: &str, data: Vec, cache_control: Option<&str>) -> Result<()>; async fn delete_object(&self, s3_key: &str) -> Result<()>; /// Delete a batch of objects in a single S3 `DeleteObjects` request /// (up to 1000 keys/call). Default loops `delete_object` so test backends /// don't have to implement it, but production should override. async fn delete_objects(&self, keys: &[String]) -> Result<()> { for k in keys { if let Err(e) = self.delete_object(k).await { tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed"); } } Ok(()) } /// Delete all objects under a key prefix. Default logs a warning (no-op). async fn delete_prefix(&self, _prefix: &str) -> Result<()> { tracing::warn!("delete_prefix called on a storage backend that does not implement it"); Ok(()) } /// Upload a file via S3 multipart upload. Default falls back to single upload. async fn upload_multipart(&self, s3_key: &str, content_type: &str, file_path: &std::path::Path) -> Result<()> { let data = tokio::fs::read(file_path) .await .context("read multipart upload source file")?; self.upload_object(s3_key, content_type, data, None).await } async fn check_connectivity(&self) -> std::result::Result<(), String>; fn bucket(&self) -> &str; } /// S3 client wrapper for presigned URL operations. /// Delegates S3 operations to `s3_storage::S3Client`. #[derive(Clone)] pub struct S3Client { inner: s3_storage::S3Client, } impl S3Client { /// Create a new S3 client from storage configuration. /// /// Configures CORS on the bucket at startup so browser PUT uploads to /// presigned URLs work without manual bucket configuration. pub async fn new(config: &StorageConfig, host_url: &str) -> Result { let s3_config = s3_storage::S3Config { endpoint: config.endpoint.clone(), bucket: config.bucket.clone(), access_key: config.access_key.clone(), secret_key: config.secret_key.clone(), region: config.region.clone(), }; let inner = s3_storage::S3Client::new(&s3_config) .await .map_err(AppError::Storage)?; inner.configure_cors(host_url).await; Ok(S3Client { inner }) } /// Generate a consistent S3 key for an object /// Format: {user_id}/{item_id}/{file_type}/{filename} pub fn generate_key( user_id: UserId, item_id: ItemId, file_type: FileType, filename: &str, ) -> String { let safe_filename = sanitize_filename(filename); format!( "{}/{}/{}/{}", user_id, item_id, file_type.as_str(), safe_filename ) } /// Generate an S3 key for a reusable insertion clip (not tied to any item). /// Format: {user_id}/insertions/{filename} pub fn generate_insertion_key(user_id: UserId, filename: &str) -> String { let safe_filename = sanitize_filename(filename); format!("{}/insertions/{}", user_id, safe_filename) } /// Generate an S3 key for a media library file. /// Format: `{user_id}/media/{folder}/{filename}` (or `{user_id}/media/{filename}` for root folder). pub fn generate_media_key(user_id: UserId, folder: &str, filename: &str) -> String { let safe_filename = sanitize_filename(filename); let safe_folder = sanitize_folder(folder); if safe_folder.is_empty() { format!("{}/media/{}", user_id, safe_filename) } else { format!("{}/media/{}/{}", user_id, safe_folder, safe_filename) } } /// Generate an S3 key for a project image (logo/avatar). /// Format: projects/{project_id}/image/{sanitized_filename} pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> String { let safe_filename = sanitize_filename(filename); format!("projects/{}/image/{}", project_id, safe_filename) } /// Generate an S3 key for an item gallery image. A per-image uuid segment /// keeps multiple gallery uploads from colliding (unlike the single cover, /// which has a fixed `cover/` path). /// Format: {user_id}/{item_id}/gallery/{image_uuid}/{sanitized_filename} pub fn generate_item_gallery_key( user_id: UserId, item_id: ItemId, image_uuid: uuid::Uuid, filename: &str, ) -> String { let safe_filename = sanitize_filename(filename); format!("{}/{}/gallery/{}/{}", user_id, item_id, image_uuid, safe_filename) } /// Generate an S3 key for a project gallery image. /// Format: projects/{project_id}/gallery/{image_uuid}/{sanitized_filename} pub fn generate_project_gallery_key( project_id: ProjectId, image_uuid: uuid::Uuid, filename: &str, ) -> String { let safe_filename = sanitize_filename(filename); format!("projects/{}/gallery/{}/{}", project_id, image_uuid, safe_filename) } /// Validate content type for the given file type pub fn validate_content_type(file_type: FileType, content_type: &str) -> Result<()> { let is_valid = if file_type == FileType::Download { ALLOWED_DOWNLOAD_MIMES.contains(&content_type) } else if file_type == FileType::Video { ALLOWED_VIDEO_MIMES.contains(&content_type) } else { let allowed = file_type.allowed_types(); allowed.iter().any(|(_, mime)| *mime == content_type) }; if !is_valid { let allowed_list = if file_type == FileType::Download { ALLOWED_DOWNLOAD_MIMES.join(", ") } else if file_type == FileType::Video { ALLOWED_VIDEO_MIMES.join(", ") } else { let allowed = file_type.allowed_types(); allowed.iter().map(|(_, m)| *m).collect::>().join(", ") }; return Err(AppError::InvalidFileType(format!( "Content type '{}' not allowed. Allowed types: {}", content_type, allowed_list ))); } Ok(()) } /// Validate file extension for the given file type pub fn validate_extension(file_type: FileType, filename: &str) -> Result<()> { if file_type == FileType::Download { let lower = filename.to_lowercase(); let is_valid = ALLOWED_DOWNLOAD_EXTENSIONS.iter().any(|ext| lower.ends_with(&format!(".{}", ext))); if !is_valid { return Err(AppError::InvalidFileType(format!( "File extension not allowed. Allowed extensions: {}", ALLOWED_DOWNLOAD_EXTENSIONS.join(", ") ))); } return Ok(()); } let extension = filename .rsplit('.') .next() .map(|s| s.to_lowercase()) .unwrap_or_default(); let allowed = file_type.allowed_types(); let is_valid = allowed.iter().any(|(ext, _)| *ext == extension); if !is_valid { let allowed_exts: Vec<&str> = allowed.iter().map(|(e, _)| *e).collect(); return Err(AppError::InvalidFileType(format!( "File extension '.{}' not allowed. Allowed extensions: {}", extension, allowed_exts.join(", ") ))); } Ok(()) } /// Generate a presigned URL for uploading a file. `max_bytes`, when set, /// binds `Content-Length` into the signature — S3 will reject any PUT /// whose actual body length differs from `max_bytes`. pub async fn presign_upload( &self, s3_key: &str, content_type: &str, expiry_secs: Option, cache_control: Option<&str>, max_bytes: Option, ) -> Result { self.inner .presign_upload(s3_key, content_type, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS), cache_control, max_bytes) .await .map_err(AppError::Storage) } /// Generate a presigned URL for downloading/streaming a file pub async fn presign_download( &self, s3_key: &str, expiry_secs: Option, ) -> Result { self.inner .presign_download(s3_key, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS)) .await .map_err(AppError::Storage) } /// Check if an object exists in S3 pub async fn object_exists(&self, s3_key: &str) -> Result { self.inner.object_exists(s3_key).await.map_err(AppError::Storage) } /// Get the size of an object in S3 (bytes), or None if not found. pub async fn object_size(&self, s3_key: &str) -> Result> { self.inner.object_size(s3_key).await.map_err(AppError::Storage) } /// Download an object's bytes from S3 pub async fn download_object(&self, s3_key: &str) -> Result> { self.inner .download(s3_key) .await .map(|(bytes, _content_type)| bytes) .map_err(AppError::Storage) } /// Stream an object's body from S3 without buffering. See trait docs. pub async fn download_stream(&self, s3_key: &str) -> Result { self.inner .download_stream(s3_key) .await .map_err(AppError::Storage) } /// Upload an object to S3 from bytes pub async fn upload_object( &self, s3_key: &str, content_type: &str, data: Vec, cache_control: Option<&str>, ) -> Result<()> { self.inner .upload(s3_key, content_type, data, cache_control) .await .map_err(AppError::Storage) } /// Delete an object from S3 pub async fn delete_object(&self, s3_key: &str) -> Result<()> { self.inner.delete(s3_key).await.map_err(AppError::Storage) } /// Batched S3 delete (`DeleteObjects`, up to 1000 keys per call). /// Chunks larger slices into 1000-key batches and logs per-key failures /// without bubbling — the pending_s3_deletions queue is the safety net. pub async fn delete_objects(&self, keys: &[String]) -> Result<()> { if keys.is_empty() { return Ok(()); } for chunk in keys.chunks(1000) { match self.inner.delete_objects(chunk).await { Ok(failures) => { for (k, msg) in failures { tracing::warn!(key = %k, error = %msg, "S3 delete_objects: key-level failure"); } } Err(e) => return Err(AppError::Storage(e)), } } Ok(()) } /// Upload a file to S3 using multipart upload (10 MB parts). pub async fn upload_multipart(&self, s3_key: &str, content_type: &str, file_path: &std::path::Path) -> Result<()> { self.inner .upload_multipart(s3_key, content_type, file_path, None) .await .map_err(AppError::Storage) } /// Lightweight connectivity check — issues a list with max_keys(0). pub async fn check_connectivity(&self) -> std::result::Result<(), String> { self.inner.check_connectivity().await } } /// Sanitize a filename: keep only alphanumeric, dots, dashes, and underscores. /// Prevents path traversal, shell injection, and S3 key encoding issues. /// Falls back to "file" if the sanitized result has no basename (only extension or empty). /// /// **By design**: the sanitizer keeps `.`/`-`/`_` and strips everything else, /// so e.g. `"../etc/passwd"` collapses to `"..etcpasswd"` — preserved as a /// literal filename, not as a directory traversal. The unit test pins this /// behavior: we don't reject names containing `..`, we just guarantee the /// output has no path separators. S3 keys are namespaced by user/item ID /// upstream, so a flat literal here can't escape the user's prefix. fn sanitize_filename(filename: &str) -> String { let sanitized: String = filename .chars() .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_') .collect(); // Ensure the result has a non-empty basename (not just ".ext" or empty) let stem = std::path::Path::new(&sanitized) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(""); if stem.is_empty() { let ext = std::path::Path::new(&sanitized) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); if ext.is_empty() { "file".to_string() } else { format!("file.{ext}") } } else { sanitized } } /// Sanitize a folder name: keep only alphanumeric, dashes, and underscores. /// Rejects path traversal (`..`) and slashes. Returns empty string for root folder. pub fn sanitize_folder(folder: &str) -> String { let trimmed = folder.trim(); if trimmed.is_empty() { return String::new(); } // Reject any path traversal if trimmed.contains("..") { return String::new(); } trimmed .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect() } /// Extract the S3 key from a CDN or presigned URL. /// /// Accepts two URL shapes: /// - **CDN**: `https://cdn.example.com/{s3_key}` — caller supplies the /// CDN base; the function strips it verbatim. /// - **Path-style S3**: `https://{host}/{bucket}/{s3_key}?...` — caller /// supplies the bucket name; the function strips host + bucket prefix. /// /// Returns `None` if neither prefix matches. Query strings (presigned URL /// signatures) are stripped before returning. /// /// **Why explicit prefixes**: the prior implementation used /// `find("projects/")` as a heuristic, which would silently mis-key any URL /// whose path happened to contain the literal substring (e.g. a key with a /// `projects/` suffix inside a user folder). Passing the known CDN base and /// bucket eliminates the heuristic entirely. pub fn extract_s3_key_from_url( url: &str, cdn_base: Option<&str>, bucket: Option<&str>, s3_endpoint: Option<&str>, ) -> Option { let no_query = url.split('?').next()?; // Try CDN-base prefix first. if let Some(base) = cdn_base { let base = base.trim_end_matches('/'); if let Some(rest) = no_query.strip_prefix(base) && let Some(key) = rest.strip_prefix('/') && !key.is_empty() { return Some(key.to_string()); } } // Path-style S3: must match the configured `{endpoint}/{bucket}/` exactly. // Without the endpoint pin, the prior implementation accepted any // `https://{any-host}/{bucket}/{key}` — so an attacker-controlled URL like // `https://attacker.example/my-bucket/poisoned` would extract a real-looking // key and direct downstream code at attacker-chosen storage paths. if let (Some(bucket), Some(endpoint)) = (bucket, s3_endpoint) { let endpoint = endpoint.trim_end_matches('/'); let prefix = format!("{endpoint}/{bucket}/"); if let Some(key) = no_query.strip_prefix(&prefix) && !key.is_empty() { return Some(key.to_string()); } } None } /// Build a permanent URL for a project image. /// CDN configured: permanent CDN URL. No CDN: 24-hour presigned S3 URL. pub async fn build_project_image_url( s3: &dyn StorageBackend, cdn_base_url: Option<&str>, s3_key: &str, ) -> Result { if let Some(cdn_base) = cdn_base_url { return Ok(format!("{}/{}", cdn_base, s3_key)); } s3.presign_download(s3_key, Some(86400)).await } #[async_trait::async_trait] impl StorageBackend for S3Client { async fn presign_upload(&self, s3_key: &str, content_type: &str, expiry_secs: Option, cache_control: Option<&str>, max_bytes: Option) -> Result { self.presign_upload(s3_key, content_type, expiry_secs, cache_control, max_bytes).await } async fn presign_download(&self, s3_key: &str, expiry_secs: Option) -> Result { self.presign_download(s3_key, expiry_secs).await } async fn object_exists(&self, s3_key: &str) -> Result { self.object_exists(s3_key).await } async fn object_size(&self, s3_key: &str) -> Result> { self.object_size(s3_key).await } async fn download_object(&self, s3_key: &str) -> Result> { self.download_object(s3_key).await } async fn download_stream(&self, s3_key: &str) -> Result { self.download_stream(s3_key).await } async fn upload_object(&self, s3_key: &str, content_type: &str, data: Vec, cache_control: Option<&str>) -> Result<()> { self.upload_object(s3_key, content_type, data, cache_control).await } async fn delete_object(&self, s3_key: &str) -> Result<()> { self.delete_object(s3_key).await } async fn delete_objects(&self, keys: &[String]) -> Result<()> { self.delete_objects(keys).await } async fn delete_prefix(&self, prefix: &str) -> Result<()> { self.inner.delete_prefix(prefix).await .map_err(AppError::Storage) } async fn upload_multipart(&self, s3_key: &str, content_type: &str, file_path: &std::path::Path) -> Result<()> { self.upload_multipart(s3_key, content_type, file_path).await } async fn check_connectivity(&self) -> std::result::Result<(), String> { self.check_connectivity().await } fn bucket(&self) -> &str { self.inner.bucket() } } #[cfg(test)] mod tests { use super::*; #[test] fn extract_key_cdn_form() { let key = extract_s3_key_from_url( "https://cdn.makenot.work/projects/abc/image/cover.png", Some("https://cdn.makenot.work"), None, None, ); assert_eq!(key.as_deref(), Some("projects/abc/image/cover.png")); } #[test] fn extract_key_cdn_with_trailing_slash_in_base() { let key = extract_s3_key_from_url( "https://cdn.makenot.work/foo/bar", Some("https://cdn.makenot.work/"), None, None, ); assert_eq!(key.as_deref(), Some("foo/bar")); } #[test] fn extract_key_strips_query_string() { let key = extract_s3_key_from_url( "https://cdn.makenot.work/foo/bar?X-Amz-Signature=zzz", Some("https://cdn.makenot.work"), None, None, ); assert_eq!(key.as_deref(), Some("foo/bar")); } #[test] fn extract_key_path_style_s3() { let key = extract_s3_key_from_url( "https://fsn1.your-objectstorage.com/my-bucket/u/123/image/cover.png?X-Amz=...", None, Some("my-bucket"), Some("https://fsn1.your-objectstorage.com"), ); assert_eq!(key.as_deref(), Some("u/123/image/cover.png")); } #[test] fn extract_key_path_style_rejects_attacker_host() { // Attacker-controlled host with the legitimate bucket name in the // path must NOT be accepted. The endpoint pin closes the gap. let key = extract_s3_key_from_url( "https://attacker.example/my-bucket/poisoned", None, Some("my-bucket"), Some("https://fsn1.your-objectstorage.com"), ); assert_eq!(key, None); } #[test] fn extract_key_path_style_requires_endpoint() { // Without the endpoint, the path-style branch must not fire — bucket // name alone is not enough to identify a trustworthy host. let key = extract_s3_key_from_url( "https://fsn1.your-objectstorage.com/my-bucket/u/123/key", None, Some("my-bucket"), None, ); assert_eq!(key, None); } #[test] fn extract_key_returns_none_when_no_prefix_matches() { // Neither the CDN base nor the bucket name is present in the URL. let key = extract_s3_key_from_url( "https://random.example.com/foo/bar", Some("https://cdn.makenot.work"), Some("my-bucket"), Some("https://fsn1.your-objectstorage.com"), ); assert_eq!(key, None); } #[test] fn extract_key_does_not_misparse_keys_containing_projects_substring() { // Regression: the old heuristic would have returned just // "projects/x" from this URL, dropping the user-scoped prefix. let key = extract_s3_key_from_url( "https://cdn.makenot.work/u/me/projects/x", Some("https://cdn.makenot.work"), None, None, ); assert_eq!(key.as_deref(), Some("u/me/projects/x")); } #[test] fn test_generate_key() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3"); assert_eq!( key, "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3" ); } #[test] fn test_generate_key_sanitizes_filename() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "my file (1).mp3"); assert!(key.ends_with("/myfile1.mp3")); } #[test] fn test_validate_content_type() { assert!(S3Client::validate_content_type(FileType::Audio, "audio/mpeg").is_ok()); assert!(S3Client::validate_content_type(FileType::Audio, "audio/wav").is_ok()); assert!(S3Client::validate_content_type(FileType::Audio, "image/png").is_err()); assert!(S3Client::validate_content_type(FileType::Cover, "image/png").is_ok()); assert!(S3Client::validate_content_type(FileType::Cover, "image/jpeg").is_ok()); assert!(S3Client::validate_content_type(FileType::Cover, "audio/mpeg").is_err()); } #[test] fn test_validate_extension() { assert!(S3Client::validate_extension(FileType::Audio, "episode.mp3").is_ok()); assert!(S3Client::validate_extension(FileType::Audio, "episode.MP3").is_ok()); assert!(S3Client::validate_extension(FileType::Audio, "episode.png").is_err()); assert!(S3Client::validate_extension(FileType::Cover, "cover.jpg").is_ok()); assert!(S3Client::validate_extension(FileType::Cover, "cover.webp").is_ok()); assert!(S3Client::validate_extension(FileType::Cover, "cover.mp3").is_err()); } #[test] fn test_file_type_from_str() { assert_eq!(FileType::from_str("audio"), Ok(FileType::Audio)); assert_eq!(FileType::from_str("AUDIO"), Ok(FileType::Audio)); assert_eq!(FileType::from_str("cover"), Ok(FileType::Cover)); assert_eq!(FileType::from_str("image"), Ok(FileType::Cover)); assert!(FileType::from_str("invalid").is_err()); } #[test] fn file_type_as_str() { assert_eq!(FileType::Audio.as_str(), "audio"); assert_eq!(FileType::Cover.as_str(), "cover"); } #[test] fn file_type_max_size() { assert_eq!(FileType::Audio.max_size(), 500 * 1024 * 1024); assert_eq!(FileType::Cover.max_size(), 10 * 1024 * 1024); } #[test] fn file_type_allowed_types_audio() { let types = FileType::Audio.allowed_types(); let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect(); assert!(exts.contains(&"mp3")); assert!(exts.contains(&"wav")); assert!(exts.contains(&"flac")); assert!(!exts.contains(&"png")); } #[test] fn file_type_allowed_types_cover() { let types = FileType::Cover.allowed_types(); let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect(); assert!(exts.contains(&"jpg")); assert!(exts.contains(&"png")); assert!(exts.contains(&"webp")); assert!(!exts.contains(&"mp3")); } #[test] fn generate_key_strips_path_traversal() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "../../etc/passwd"); // Slashes are stripped, dots kept: "../../etc/passwd" -> "....etcpasswd" assert!(key.ends_with("/audio/....etcpasswd")); } #[test] fn generate_key_empty_filename_gets_fallback() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Cover, ""); assert!(key.ends_with("/cover/file"), "expected fallback name 'file', got: {}", key); } #[test] fn validate_extension_no_extension() { assert!(S3Client::validate_extension(FileType::Audio, "noext").is_err()); } #[test] fn validate_extension_double_dot() { assert!(S3Client::validate_extension(FileType::Audio, "file.backup.mp3").is_ok()); } #[test] fn validate_content_type_empty() { assert!(S3Client::validate_content_type(FileType::Audio, "").is_err()); } #[test] fn file_type_insertion_from_str() { assert_eq!(FileType::from_str("insertion"), Ok(FileType::Insertion)); assert_eq!(FileType::from_str("INSERTION"), Ok(FileType::Insertion)); } #[test] fn file_type_insertion_as_str() { assert_eq!(FileType::Insertion.as_str(), "insertion"); } #[test] fn file_type_insertion_max_size() { assert_eq!(FileType::Insertion.max_size(), 500 * 1024 * 1024); } #[test] fn validate_insertion_content_types() { assert!(S3Client::validate_content_type(FileType::Insertion, "audio/mpeg").is_ok()); assert!(S3Client::validate_content_type(FileType::Insertion, "audio/wav").is_ok()); assert!(S3Client::validate_content_type(FileType::Insertion, "audio/flac").is_ok()); assert!(S3Client::validate_content_type(FileType::Insertion, "image/png").is_err()); } #[test] fn validate_insertion_extensions() { assert!(S3Client::validate_extension(FileType::Insertion, "intro.mp3").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "sponsor.wav").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "outro.flac").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "clip.png").is_err()); } #[test] fn generate_insertion_key_format() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let key = S3Client::generate_insertion_key(user_id, "intro.mp3"); assert_eq!(key, "11111111-1111-1111-1111-111111111111/insertions/intro.mp3"); } #[test] fn generate_insertion_key_sanitizes() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let key = S3Client::generate_insertion_key(user_id, "my sponsor read (v2).mp3"); assert_eq!(key, "11111111-1111-1111-1111-111111111111/insertions/mysponsorreadv2.mp3"); } #[test] fn generate_key_cover_type() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "art.png"); assert!(key.contains("/cover/")); assert!(key.ends_with("art.png")); } // FileType::Download tests #[test] fn file_type_download_from_str() { assert_eq!(FileType::from_str("download"), Ok(FileType::Download)); assert_eq!(FileType::from_str("DOWNLOAD"), Ok(FileType::Download)); } #[test] fn file_type_download_as_str() { assert_eq!(FileType::Download.as_str(), "download"); } #[test] fn file_type_download_max_size() { assert_eq!(FileType::Download.max_size(), 500 * 1024 * 1024); } #[test] fn validate_download_content_types() { assert!(S3Client::validate_content_type(FileType::Download, "application/octet-stream").is_ok()); assert!(S3Client::validate_content_type(FileType::Download, "application/zip").is_ok()); assert!(S3Client::validate_content_type(FileType::Download, "application/x-apple-diskimage").is_ok()); assert!(S3Client::validate_content_type(FileType::Download, "application/gzip").is_ok()); assert!(S3Client::validate_content_type(FileType::Download, "application/x-tar").is_ok()); // Reject clearly wrong types assert!(S3Client::validate_content_type(FileType::Download, "text/html").is_err()); assert!(S3Client::validate_content_type(FileType::Download, "image/png").is_err()); } #[test] fn validate_download_extensions() { assert!(S3Client::validate_extension(FileType::Download, "app.zip").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.dmg").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.exe").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.appimage").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.deb").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.tar.gz").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.clap").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.vst3").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "App.ZIP").is_ok()); // Reject invalid extensions assert!(S3Client::validate_extension(FileType::Download, "app.mp3").is_err()); assert!(S3Client::validate_extension(FileType::Download, "app.txt").is_err()); } #[test] fn generate_key_download_type() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Download, "plugin-v1.0.zip"); assert!(key.contains("/download/")); assert!(key.ends_with("plugin-v1.0.zip")); } // CDN tests #[test] fn cache_control_immutable_format() { assert!(CACHE_CONTROL_IMMUTABLE.contains("public")); assert!(CACHE_CONTROL_IMMUTABLE.contains("max-age=31536000")); assert!(CACHE_CONTROL_IMMUTABLE.contains("immutable")); } #[test] fn generate_project_image_key_format() { let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap(); let key = S3Client::generate_project_image_key(project_id, "logo.png"); assert_eq!(key, "projects/33333333-3333-3333-3333-333333333333/image/logo.png"); } #[test] fn generate_project_image_key_sanitizes() { let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap(); let key = S3Client::generate_project_image_key(project_id, "my logo (v2).png"); assert_eq!(key, "projects/33333333-3333-3333-3333-333333333333/image/mylogov2.png"); } #[test] fn cdn_url_from_s3_key() { let cdn_base = "https://cdn.makenot.work"; let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3"); let cdn_url = format!("{}/{}", cdn_base, key); assert_eq!( cdn_url, "https://cdn.makenot.work/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3" ); } // FileType::Video tests #[test] fn file_type_video_from_str() { assert_eq!(FileType::from_str("video"), Ok(FileType::Video)); assert_eq!(FileType::from_str("VIDEO"), Ok(FileType::Video)); } #[test] fn file_type_video_as_str() { assert_eq!(FileType::Video.as_str(), "video"); } #[test] fn file_type_video_max_size() { assert_eq!(FileType::Video.max_size(), 20 * 1024 * 1024 * 1024); } #[test] fn validate_video_content_types() { assert!(S3Client::validate_content_type(FileType::Video, "video/mp4").is_ok()); assert!(S3Client::validate_content_type(FileType::Video, "video/webm").is_ok()); assert!(S3Client::validate_content_type(FileType::Video, "video/quicktime").is_ok()); assert!(S3Client::validate_content_type(FileType::Video, "audio/mpeg").is_err()); assert!(S3Client::validate_content_type(FileType::Video, "application/octet-stream").is_err()); assert!(S3Client::validate_content_type(FileType::Video, "text/html").is_err()); } #[test] fn validate_video_extensions() { assert!(S3Client::validate_extension(FileType::Video, "clip.mp4").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "clip.webm").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "clip.mov").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "Clip.MP4").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "clip.avi").is_err()); assert!(S3Client::validate_extension(FileType::Video, "clip.mp3").is_err()); } #[test] fn generate_key_video_type() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Video, "tutorial.mp4"); assert!(key.contains("/video/")); assert!(key.ends_with("tutorial.mp4")); } }