Skip to main content

max / makenotwork

39.5 KB · 1048 lines History Blame Raw
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/files`
7
8 use std::str::FromStr;
9
10 use crate::config::StorageConfig;
11 use crate::db::{ItemId, ProjectId, UserId};
12 use crate::error::{AppError, Result, ResultExt};
13
14 /// Allowed audio file extensions and their MIME types
15 const ALLOWED_AUDIO_TYPES: &[(&str, &str)] = &[
16 ("mp3", "audio/mpeg"),
17 ("wav", "audio/wav"),
18 ("m4a", "audio/mp4"),
19 ("ogg", "audio/ogg"),
20 ("flac", "audio/flac"),
21 ("aac", "audio/aac"),
22 ];
23
24 /// Allowed image file extensions and their MIME types
25 const ALLOWED_IMAGE_TYPES: &[(&str, &str)] = &[
26 ("jpg", "image/jpeg"),
27 ("jpeg", "image/jpeg"),
28 ("png", "image/png"),
29 ("webp", "image/webp"),
30 ("gif", "image/gif"),
31 ];
32
33 /// Allowed video file extensions and their MIME types
34 const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[
35 ("mp4", "video/mp4"),
36 ("webm", "video/webm"),
37 ("mov", "video/quicktime"),
38 ];
39
40 /// MIME types accepted for video uploads
41 const ALLOWED_VIDEO_MIMES: &[&str] = &[
42 "video/mp4",
43 "video/webm",
44 "video/quicktime",
45 ];
46
47 /// Allowed download file extensions and their MIME types
48 /// Browsers are inconsistent about MIME types for binary downloads,
49 /// so we accept several common types for each extension.
50 const ALLOWED_DOWNLOAD_TYPES: &[(&str, &str)] = &[
51 ("zip", "application/zip"),
52 ("dmg", "application/x-apple-diskimage"),
53 ("exe", "application/octet-stream"),
54 ("appimage", "application/octet-stream"),
55 ("deb", "application/octet-stream"),
56 ("clap", "application/octet-stream"),
57 ("vst3", "application/octet-stream"),
58 ];
59
60 /// MIME types accepted for download uploads (browsers vary widely)
61 const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[
62 "application/octet-stream",
63 "application/zip",
64 "application/x-zip-compressed",
65 "application/x-apple-diskimage",
66 "application/x-diskcopy",
67 "application/x-msi",
68 "application/x-ole-storage",
69 "application/gzip",
70 "application/x-tar",
71 "application/x-gtar",
72 "application/x-compressed",
73 "application/x-executable",
74 "application/x-deb",
75 "application/vnd.debian.binary-package",
76 ];
77
78 /// Allowed download file extensions (checked separately from MIME)
79 const ALLOWED_DOWNLOAD_EXTENSIONS: &[&str] = &[
80 "zip", "dmg", "exe", "msi", "appimage", "deb", "tar.gz", "clap", "vst3",
81 ];
82
83 /// Maximum file sizes in bytes
84 const MAX_AUDIO_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
85 const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
86 const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
87 const MAX_INSERTION_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
88 const MAX_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
89 const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
90 const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
91
92 /// Default presigned URL expiration.
93 /// 1 hour balances usability (large uploads over slow connections) against
94 /// security (limiting the window for URL leakage). Overridable per-call.
95 const PRESIGN_EXPIRY_SECS: u64 = 3600;
96
97 /// File type categories for upload
98 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
99 pub enum FileType {
100 Audio,
101 Cover,
102 Download,
103 Insertion,
104 Video,
105 /// Media library image (user-scoped, for inline markdown content).
106 MediaImage,
107 /// Media library video (user-scoped, for inline markdown content).
108 MediaVideo,
109 }
110
111 impl FileType {
112 pub fn as_str(&self) -> &'static str {
113 match self {
114 FileType::Audio => "audio",
115 FileType::Cover => "cover",
116 FileType::Download => "download",
117 FileType::Insertion => "insertion",
118 FileType::Video => "video",
119 FileType::MediaImage => "media_image",
120 FileType::MediaVideo => "media_video",
121 }
122 }
123
124 pub fn max_size(&self) -> u64 {
125 match self {
126 FileType::Audio => MAX_AUDIO_SIZE,
127 FileType::Cover => MAX_IMAGE_SIZE,
128 FileType::Download => MAX_DOWNLOAD_SIZE,
129 FileType::Insertion => MAX_INSERTION_SIZE,
130 FileType::Video => MAX_VIDEO_SIZE,
131 FileType::MediaImage => MAX_MEDIA_IMAGE_SIZE,
132 FileType::MediaVideo => MAX_MEDIA_VIDEO_SIZE,
133 }
134 }
135
136 pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] {
137 match self {
138 FileType::Audio | FileType::Insertion => ALLOWED_AUDIO_TYPES,
139 FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES,
140 FileType::Download => ALLOWED_DOWNLOAD_TYPES,
141 FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES,
142 }
143 }
144 }
145
146 impl FromStr for FileType {
147 type Err = String;
148
149 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
150 match s.to_lowercase().as_str() {
151 "audio" => Ok(FileType::Audio),
152 "cover" | "image" => Ok(FileType::Cover),
153 "download" => Ok(FileType::Download),
154 "insertion" => Ok(FileType::Insertion),
155 "video" => Ok(FileType::Video),
156 "media_image" => Ok(FileType::MediaImage),
157 "media_video" => Ok(FileType::MediaVideo),
158 _ => Err(format!("Invalid file type: {}", s)),
159 }
160 }
161 }
162
163 /// Cache-Control value for immutable content (builds, audio, covers).
164 /// One year with immutable directive — Cloudflare and browsers cache indefinitely.
165 pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
166
167 /// Abstract storage backend — implemented by `S3Client` (production) and
168 /// `InMemoryStorage` (tests). Routes access storage through this trait.
169 #[async_trait::async_trait]
170 pub trait StorageBackend: Send + Sync {
171 /// Generate a presigned upload URL. `max_bytes`, when set, is signed into
172 /// the URL as `Content-Length` so S3 itself enforces the size cap at the
173 /// protocol level (prevents oversized PUTs from burning bandwidth before
174 /// hitting the post-PUT delete-and-charge fallback).
175 async fn presign_upload(&self, s3_key: &str, content_type: &str, expiry_secs: Option<u64>, cache_control: Option<&str>, max_bytes: Option<i64>) -> Result<String>;
176 async fn presign_download(&self, s3_key: &str, expiry_secs: Option<u64>) -> Result<String>;
177 async fn object_exists(&self, s3_key: &str) -> Result<bool>;
178 async fn object_size(&self, s3_key: &str) -> Result<Option<i64>>;
179 async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>>;
180 /// Stream the object body without buffering the whole payload. Callers
181 /// drive the stream to disk (scanner spool) or to a layer that consumes
182 /// chunks directly.
183 async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>;
184 async fn upload_object(&self, s3_key: &str, content_type: &str, data: Vec<u8>, cache_control: Option<&str>) -> Result<()>;
185 async fn delete_object(&self, s3_key: &str) -> Result<()>;
186 /// Delete a batch of objects in a single S3 `DeleteObjects` request
187 /// (up to 1000 keys/call). Default loops `delete_object` so test backends
188 /// don't have to implement it, but production should override.
189 async fn delete_objects(&self, keys: &[String]) -> Result<()> {
190 for k in keys {
191 if let Err(e) = self.delete_object(k).await {
192 tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed");
193 }
194 }
195 Ok(())
196 }
197 /// Delete all objects under a key prefix. Default logs a warning (no-op).
198 async fn delete_prefix(&self, _prefix: &str) -> Result<()> {
199 tracing::warn!("delete_prefix called on a storage backend that does not implement it");
200 Ok(())
201 }
202 /// Upload a file via S3 multipart upload. Default falls back to single upload.
203 async fn upload_multipart(&self, s3_key: &str, content_type: &str, file_path: &std::path::Path) -> Result<()> {
204 let data = tokio::fs::read(file_path)
205 .await
206 .context("read multipart upload source file")?;
207 self.upload_object(s3_key, content_type, data, None).await
208 }
209 async fn check_connectivity(&self) -> std::result::Result<(), String>;
210 fn bucket(&self) -> &str;
211 }
212
213 /// S3 client wrapper for presigned URL operations.
214 /// Delegates S3 operations to `s3_storage::S3Client`.
215 #[derive(Clone)]
216 pub struct S3Client {
217 inner: s3_storage::S3Client,
218 }
219
220 impl S3Client {
221 /// Create a new S3 client from storage configuration.
222 ///
223 /// Configures CORS on the bucket at startup so browser PUT uploads to
224 /// presigned URLs work without manual bucket configuration.
225 pub async fn new(config: &StorageConfig, host_url: &str) -> Result<Self> {
226 let s3_config = s3_storage::S3Config {
227 endpoint: config.endpoint.clone(),
228 bucket: config.bucket.clone(),
229 access_key: config.access_key.clone(),
230 secret_key: config.secret_key.clone(),
231 region: config.region.clone(),
232 };
233
234 let inner = s3_storage::S3Client::new(&s3_config)
235 .await
236 .map_err(AppError::Storage)?;
237
238 inner.configure_cors(host_url).await;
239
240 Ok(S3Client { inner })
241 }
242
243 /// Generate a consistent S3 key for an object
244 /// Format: {user_id}/{item_id}/{file_type}/{filename}
245 pub fn generate_key(
246 user_id: UserId,
247 item_id: ItemId,
248 file_type: FileType,
249 filename: &str,
250 ) -> String {
251 let safe_filename = sanitize_filename(filename);
252 format!(
253 "{}/{}/{}/{}",
254 user_id,
255 item_id,
256 file_type.as_str(),
257 safe_filename
258 )
259 }
260
261 /// Generate an S3 key for a reusable insertion clip (not tied to any item).
262 /// Format: {user_id}/insertions/{filename}
263 pub fn generate_insertion_key(user_id: UserId, filename: &str) -> String {
264 let safe_filename = sanitize_filename(filename);
265 format!("{}/insertions/{}", user_id, safe_filename)
266 }
267
268 /// Generate an S3 key for a media library file.
269 /// Format: `{user_id}/media/{folder}/{filename}` (or `{user_id}/media/{filename}` for root folder).
270 pub fn generate_media_key(user_id: UserId, folder: &str, filename: &str) -> String {
271 let safe_filename = sanitize_filename(filename);
272 let safe_folder = sanitize_folder(folder);
273 if safe_folder.is_empty() {
274 format!("{}/media/{}", user_id, safe_filename)
275 } else {
276 format!("{}/media/{}/{}", user_id, safe_folder, safe_filename)
277 }
278 }
279
280 /// Generate an S3 key for a project image (logo/avatar).
281 /// Format: projects/{project_id}/image/{sanitized_filename}
282 pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> String {
283 let safe_filename = sanitize_filename(filename);
284 format!("projects/{}/image/{}", project_id, safe_filename)
285 }
286
287 /// Validate content type for the given file type
288 pub fn validate_content_type(file_type: FileType, content_type: &str) -> Result<()> {
289 let is_valid = if file_type == FileType::Download {
290 ALLOWED_DOWNLOAD_MIMES.contains(&content_type)
291 } else if file_type == FileType::Video {
292 ALLOWED_VIDEO_MIMES.contains(&content_type)
293 } else {
294 let allowed = file_type.allowed_types();
295 allowed.iter().any(|(_, mime)| *mime == content_type)
296 };
297
298 if !is_valid {
299 let allowed_list = if file_type == FileType::Download {
300 ALLOWED_DOWNLOAD_MIMES.join(", ")
301 } else if file_type == FileType::Video {
302 ALLOWED_VIDEO_MIMES.join(", ")
303 } else {
304 let allowed = file_type.allowed_types();
305 allowed.iter().map(|(_, m)| *m).collect::<Vec<_>>().join(", ")
306 };
307 return Err(AppError::InvalidFileType(format!(
308 "Content type '{}' not allowed. Allowed types: {}",
309 content_type,
310 allowed_list
311 )));
312 }
313
314 Ok(())
315 }
316
317 /// Validate file extension for the given file type
318 pub fn validate_extension(file_type: FileType, filename: &str) -> Result<()> {
319 if file_type == FileType::Download {
320 let lower = filename.to_lowercase();
321 let is_valid = ALLOWED_DOWNLOAD_EXTENSIONS.iter().any(|ext| lower.ends_with(&format!(".{}", ext)));
322 if !is_valid {
323 return Err(AppError::InvalidFileType(format!(
324 "File extension not allowed. Allowed extensions: {}",
325 ALLOWED_DOWNLOAD_EXTENSIONS.join(", ")
326 )));
327 }
328 return Ok(());
329 }
330
331 let extension = filename
332 .rsplit('.')
333 .next()
334 .map(|s| s.to_lowercase())
335 .unwrap_or_default();
336
337 let allowed = file_type.allowed_types();
338 let is_valid = allowed.iter().any(|(ext, _)| *ext == extension);
339
340 if !is_valid {
341 let allowed_exts: Vec<&str> = allowed.iter().map(|(e, _)| *e).collect();
342 return Err(AppError::InvalidFileType(format!(
343 "File extension '.{}' not allowed. Allowed extensions: {}",
344 extension,
345 allowed_exts.join(", ")
346 )));
347 }
348
349 Ok(())
350 }
351
352 /// Generate a presigned URL for uploading a file. `max_bytes`, when set,
353 /// binds `Content-Length` into the signature — S3 will reject any PUT
354 /// whose actual body length differs from `max_bytes`.
355 pub async fn presign_upload(
356 &self,
357 s3_key: &str,
358 content_type: &str,
359 expiry_secs: Option<u64>,
360 cache_control: Option<&str>,
361 max_bytes: Option<i64>,
362 ) -> Result<String> {
363 self.inner
364 .presign_upload(s3_key, content_type, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS), cache_control, max_bytes)
365 .await
366 .map_err(AppError::Storage)
367 }
368
369 /// Generate a presigned URL for downloading/streaming a file
370 pub async fn presign_download(
371 &self,
372 s3_key: &str,
373 expiry_secs: Option<u64>,
374 ) -> Result<String> {
375 self.inner
376 .presign_download(s3_key, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS))
377 .await
378 .map_err(AppError::Storage)
379 }
380
381 /// Check if an object exists in S3
382 pub async fn object_exists(&self, s3_key: &str) -> Result<bool> {
383 self.inner.object_exists(s3_key).await.map_err(AppError::Storage)
384 }
385
386 /// Get the size of an object in S3 (bytes), or None if not found.
387 pub async fn object_size(&self, s3_key: &str) -> Result<Option<i64>> {
388 self.inner.object_size(s3_key).await.map_err(AppError::Storage)
389 }
390
391 /// Download an object's bytes from S3
392 pub async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>> {
393 self.inner
394 .download(s3_key)
395 .await
396 .map(|(bytes, _content_type)| bytes)
397 .map_err(AppError::Storage)
398 }
399
400 /// Stream an object's body from S3 without buffering. See trait docs.
401 pub async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream> {
402 self.inner
403 .download_stream(s3_key)
404 .await
405 .map_err(AppError::Storage)
406 }
407
408 /// Upload an object to S3 from bytes
409 pub async fn upload_object(
410 &self,
411 s3_key: &str,
412 content_type: &str,
413 data: Vec<u8>,
414 cache_control: Option<&str>,
415 ) -> Result<()> {
416 self.inner
417 .upload(s3_key, content_type, data, cache_control)
418 .await
419 .map_err(AppError::Storage)
420 }
421
422 /// Delete an object from S3
423 pub async fn delete_object(&self, s3_key: &str) -> Result<()> {
424 self.inner.delete(s3_key).await.map_err(AppError::Storage)
425 }
426
427 /// Batched S3 delete (`DeleteObjects`, up to 1000 keys per call).
428 /// Chunks larger slices into 1000-key batches and logs per-key failures
429 /// without bubbling — the pending_s3_deletions queue is the safety net.
430 pub async fn delete_objects(&self, keys: &[String]) -> Result<()> {
431 if keys.is_empty() {
432 return Ok(());
433 }
434 for chunk in keys.chunks(1000) {
435 match self.inner.delete_objects(chunk).await {
436 Ok(failures) => {
437 for (k, msg) in failures {
438 tracing::warn!(key = %k, error = %msg, "S3 delete_objects: key-level failure");
439 }
440 }
441 Err(e) => return Err(AppError::Storage(e)),
442 }
443 }
444 Ok(())
445 }
446
447 /// Upload a file to S3 using multipart upload (10 MB parts).
448 pub async fn upload_multipart(&self, s3_key: &str, content_type: &str, file_path: &std::path::Path) -> Result<()> {
449 self.inner
450 .upload_multipart(s3_key, content_type, file_path, None)
451 .await
452 .map_err(AppError::Storage)
453 }
454
455 /// Lightweight connectivity check — issues a list with max_keys(0).
456 pub async fn check_connectivity(&self) -> std::result::Result<(), String> {
457 self.inner.check_connectivity().await
458 }
459 }
460
461 /// Sanitize a filename: keep only alphanumeric, dots, dashes, and underscores.
462 /// Prevents path traversal, shell injection, and S3 key encoding issues.
463 /// Falls back to "file" if the sanitized result has no basename (only extension or empty).
464 ///
465 /// **By design**: the sanitizer keeps `.`/`-`/`_` and strips everything else,
466 /// so e.g. `"../etc/passwd"` collapses to `"..etcpasswd"` — preserved as a
467 /// literal filename, not as a directory traversal. The unit test pins this
468 /// behavior: we don't reject names containing `..`, we just guarantee the
469 /// output has no path separators. S3 keys are namespaced by user/item ID
470 /// upstream, so a flat literal here can't escape the user's prefix.
471 fn sanitize_filename(filename: &str) -> String {
472 let sanitized: String = filename
473 .chars()
474 .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
475 .collect();
476 // Ensure the result has a non-empty basename (not just ".ext" or empty)
477 let stem = std::path::Path::new(&sanitized)
478 .file_stem()
479 .and_then(|s| s.to_str())
480 .unwrap_or("");
481 if stem.is_empty() {
482 let ext = std::path::Path::new(&sanitized)
483 .extension()
484 .and_then(|s| s.to_str())
485 .unwrap_or("");
486 if ext.is_empty() {
487 "file".to_string()
488 } else {
489 format!("file.{ext}")
490 }
491 } else {
492 sanitized
493 }
494 }
495
496 /// Sanitize a folder name: keep only alphanumeric, dashes, and underscores.
497 /// Rejects path traversal (`..`) and slashes. Returns empty string for root folder.
498 pub fn sanitize_folder(folder: &str) -> String {
499 let trimmed = folder.trim();
500 if trimmed.is_empty() {
501 return String::new();
502 }
503 // Reject any path traversal
504 if trimmed.contains("..") {
505 return String::new();
506 }
507 trimmed
508 .chars()
509 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
510 .collect()
511 }
512
513 /// Extract the S3 key from a CDN or presigned URL.
514 ///
515 /// Accepts two URL shapes:
516 /// - **CDN**: `https://cdn.example.com/{s3_key}` — caller supplies the
517 /// CDN base; the function strips it verbatim.
518 /// - **Path-style S3**: `https://{host}/{bucket}/{s3_key}?...` — caller
519 /// supplies the bucket name; the function strips host + bucket prefix.
520 ///
521 /// Returns `None` if neither prefix matches. Query strings (presigned URL
522 /// signatures) are stripped before returning.
523 ///
524 /// **Why explicit prefixes**: the prior implementation used
525 /// `find("projects/")` as a heuristic, which would silently mis-key any URL
526 /// whose path happened to contain the literal substring (e.g. a key with a
527 /// `projects/` suffix inside a user folder). Passing the known CDN base and
528 /// bucket eliminates the heuristic entirely.
529 pub fn extract_s3_key_from_url(
530 url: &str,
531 cdn_base: Option<&str>,
532 bucket: Option<&str>,
533 s3_endpoint: Option<&str>,
534 ) -> Option<String> {
535 let no_query = url.split('?').next()?;
536
537 // Try CDN-base prefix first.
538 if let Some(base) = cdn_base {
539 let base = base.trim_end_matches('/');
540 if let Some(rest) = no_query.strip_prefix(base)
541 && let Some(key) = rest.strip_prefix('/')
542 && !key.is_empty()
543 {
544 return Some(key.to_string());
545 }
546 }
547
548 // Path-style S3: must match the configured `{endpoint}/{bucket}/` exactly.
549 // Without the endpoint pin, the prior implementation accepted any
550 // `https://{any-host}/{bucket}/{key}` — so an attacker-controlled URL like
551 // `https://attacker.example/my-bucket/poisoned` would extract a real-looking
552 // key and direct downstream code at attacker-chosen storage paths.
553 if let (Some(bucket), Some(endpoint)) = (bucket, s3_endpoint) {
554 let endpoint = endpoint.trim_end_matches('/');
555 let prefix = format!("{endpoint}/{bucket}/");
556 if let Some(key) = no_query.strip_prefix(&prefix)
557 && !key.is_empty()
558 {
559 return Some(key.to_string());
560 }
561 }
562
563 None
564 }
565
566 /// Build a permanent URL for a project image.
567 /// CDN configured: permanent CDN URL. No CDN: 24-hour presigned S3 URL.
568 pub async fn build_project_image_url(
569 s3: &dyn StorageBackend,
570 cdn_base_url: Option<&str>,
571 s3_key: &str,
572 ) -> Result<String> {
573 if let Some(cdn_base) = cdn_base_url {
574 return Ok(format!("{}/{}", cdn_base, s3_key));
575 }
576 s3.presign_download(s3_key, Some(86400)).await
577 }
578
579 #[async_trait::async_trait]
580 impl StorageBackend for S3Client {
581 async fn presign_upload(&self, s3_key: &str, content_type: &str, expiry_secs: Option<u64>, cache_control: Option<&str>, max_bytes: Option<i64>) -> Result<String> {
582 self.presign_upload(s3_key, content_type, expiry_secs, cache_control, max_bytes).await
583 }
584
585 async fn presign_download(&self, s3_key: &str, expiry_secs: Option<u64>) -> Result<String> {
586 self.presign_download(s3_key, expiry_secs).await
587 }
588
589 async fn object_exists(&self, s3_key: &str) -> Result<bool> {
590 self.object_exists(s3_key).await
591 }
592
593 async fn object_size(&self, s3_key: &str) -> Result<Option<i64>> {
594 self.object_size(s3_key).await
595 }
596
597 async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>> {
598 self.download_object(s3_key).await
599 }
600
601 async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream> {
602 self.download_stream(s3_key).await
603 }
604
605 async fn upload_object(&self, s3_key: &str, content_type: &str, data: Vec<u8>, cache_control: Option<&str>) -> Result<()> {
606 self.upload_object(s3_key, content_type, data, cache_control).await
607 }
608
609 async fn delete_object(&self, s3_key: &str) -> Result<()> {
610 self.delete_object(s3_key).await
611 }
612
613 async fn delete_objects(&self, keys: &[String]) -> Result<()> {
614 self.delete_objects(keys).await
615 }
616
617 async fn delete_prefix(&self, prefix: &str) -> Result<()> {
618 self.inner.delete_prefix(prefix).await
619 .map_err(AppError::Storage)
620 }
621
622 async fn upload_multipart(&self, s3_key: &str, content_type: &str, file_path: &std::path::Path) -> Result<()> {
623 self.upload_multipart(s3_key, content_type, file_path).await
624 }
625
626 async fn check_connectivity(&self) -> std::result::Result<(), String> {
627 self.check_connectivity().await
628 }
629
630 fn bucket(&self) -> &str {
631 self.inner.bucket()
632 }
633 }
634
635 #[cfg(test)]
636 mod tests {
637 use super::*;
638
639 #[test]
640 fn extract_key_cdn_form() {
641 let key = extract_s3_key_from_url(
642 "https://cdn.makenot.work/projects/abc/image/cover.png",
643 Some("https://cdn.makenot.work"),
644 None,
645 None,
646 );
647 assert_eq!(key.as_deref(), Some("projects/abc/image/cover.png"));
648 }
649
650 #[test]
651 fn extract_key_cdn_with_trailing_slash_in_base() {
652 let key = extract_s3_key_from_url(
653 "https://cdn.makenot.work/foo/bar",
654 Some("https://cdn.makenot.work/"),
655 None,
656 None,
657 );
658 assert_eq!(key.as_deref(), Some("foo/bar"));
659 }
660
661 #[test]
662 fn extract_key_strips_query_string() {
663 let key = extract_s3_key_from_url(
664 "https://cdn.makenot.work/foo/bar?X-Amz-Signature=zzz",
665 Some("https://cdn.makenot.work"),
666 None,
667 None,
668 );
669 assert_eq!(key.as_deref(), Some("foo/bar"));
670 }
671
672 #[test]
673 fn extract_key_path_style_s3() {
674 let key = extract_s3_key_from_url(
675 "https://fsn1.your-objectstorage.com/my-bucket/u/123/image/cover.png?X-Amz=...",
676 None,
677 Some("my-bucket"),
678 Some("https://fsn1.your-objectstorage.com"),
679 );
680 assert_eq!(key.as_deref(), Some("u/123/image/cover.png"));
681 }
682
683 #[test]
684 fn extract_key_path_style_rejects_attacker_host() {
685 // Attacker-controlled host with the legitimate bucket name in the
686 // path must NOT be accepted. The endpoint pin closes the gap.
687 let key = extract_s3_key_from_url(
688 "https://attacker.example/my-bucket/poisoned",
689 None,
690 Some("my-bucket"),
691 Some("https://fsn1.your-objectstorage.com"),
692 );
693 assert_eq!(key, None);
694 }
695
696 #[test]
697 fn extract_key_path_style_requires_endpoint() {
698 // Without the endpoint, the path-style branch must not fire — bucket
699 // name alone is not enough to identify a trustworthy host.
700 let key = extract_s3_key_from_url(
701 "https://fsn1.your-objectstorage.com/my-bucket/u/123/key",
702 None,
703 Some("my-bucket"),
704 None,
705 );
706 assert_eq!(key, None);
707 }
708
709 #[test]
710 fn extract_key_returns_none_when_no_prefix_matches() {
711 // Neither the CDN base nor the bucket name is present in the URL.
712 let key = extract_s3_key_from_url(
713 "https://random.example.com/foo/bar",
714 Some("https://cdn.makenot.work"),
715 Some("my-bucket"),
716 Some("https://fsn1.your-objectstorage.com"),
717 );
718 assert_eq!(key, None);
719 }
720
721 #[test]
722 fn extract_key_does_not_misparse_keys_containing_projects_substring() {
723 // Regression: the old heuristic would have returned just
724 // "projects/x" from this URL, dropping the user-scoped prefix.
725 let key = extract_s3_key_from_url(
726 "https://cdn.makenot.work/u/me/projects/x",
727 Some("https://cdn.makenot.work"),
728 None,
729 None,
730 );
731 assert_eq!(key.as_deref(), Some("u/me/projects/x"));
732 }
733
734 #[test]
735 fn test_generate_key() {
736 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
737 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
738
739 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3");
740 assert_eq!(
741 key,
742 "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3"
743 );
744 }
745
746 #[test]
747 fn test_generate_key_sanitizes_filename() {
748 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
749 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
750
751 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "my file (1).mp3");
752 assert!(key.ends_with("/myfile1.mp3"));
753 }
754
755 #[test]
756 fn test_validate_content_type() {
757 assert!(S3Client::validate_content_type(FileType::Audio, "audio/mpeg").is_ok());
758 assert!(S3Client::validate_content_type(FileType::Audio, "audio/wav").is_ok());
759 assert!(S3Client::validate_content_type(FileType::Audio, "image/png").is_err());
760
761 assert!(S3Client::validate_content_type(FileType::Cover, "image/png").is_ok());
762 assert!(S3Client::validate_content_type(FileType::Cover, "image/jpeg").is_ok());
763 assert!(S3Client::validate_content_type(FileType::Cover, "audio/mpeg").is_err());
764 }
765
766 #[test]
767 fn test_validate_extension() {
768 assert!(S3Client::validate_extension(FileType::Audio, "episode.mp3").is_ok());
769 assert!(S3Client::validate_extension(FileType::Audio, "episode.MP3").is_ok());
770 assert!(S3Client::validate_extension(FileType::Audio, "episode.png").is_err());
771
772 assert!(S3Client::validate_extension(FileType::Cover, "cover.jpg").is_ok());
773 assert!(S3Client::validate_extension(FileType::Cover, "cover.webp").is_ok());
774 assert!(S3Client::validate_extension(FileType::Cover, "cover.mp3").is_err());
775 }
776
777 #[test]
778 fn test_file_type_from_str() {
779 assert_eq!(FileType::from_str("audio"), Ok(FileType::Audio));
780 assert_eq!(FileType::from_str("AUDIO"), Ok(FileType::Audio));
781 assert_eq!(FileType::from_str("cover"), Ok(FileType::Cover));
782 assert_eq!(FileType::from_str("image"), Ok(FileType::Cover));
783 assert!(FileType::from_str("invalid").is_err());
784 }
785
786 #[test]
787 fn file_type_as_str() {
788 assert_eq!(FileType::Audio.as_str(), "audio");
789 assert_eq!(FileType::Cover.as_str(), "cover");
790 }
791
792 #[test]
793 fn file_type_max_size() {
794 assert_eq!(FileType::Audio.max_size(), 500 * 1024 * 1024);
795 assert_eq!(FileType::Cover.max_size(), 10 * 1024 * 1024);
796 }
797
798 #[test]
799 fn file_type_allowed_types_audio() {
800 let types = FileType::Audio.allowed_types();
801 let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect();
802 assert!(exts.contains(&"mp3"));
803 assert!(exts.contains(&"wav"));
804 assert!(exts.contains(&"flac"));
805 assert!(!exts.contains(&"png"));
806 }
807
808 #[test]
809 fn file_type_allowed_types_cover() {
810 let types = FileType::Cover.allowed_types();
811 let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect();
812 assert!(exts.contains(&"jpg"));
813 assert!(exts.contains(&"png"));
814 assert!(exts.contains(&"webp"));
815 assert!(!exts.contains(&"mp3"));
816 }
817
818 #[test]
819 fn generate_key_strips_path_traversal() {
820 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
821 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
822
823 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "../../etc/passwd");
824 // Slashes are stripped, dots kept: "../../etc/passwd" -> "....etcpasswd"
825 assert!(key.ends_with("/audio/....etcpasswd"));
826 }
827
828 #[test]
829 fn generate_key_empty_filename_gets_fallback() {
830 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
831 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
832
833 let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "");
834 assert!(key.ends_with("/cover/file"), "expected fallback name 'file', got: {}", key);
835 }
836
837 #[test]
838 fn validate_extension_no_extension() {
839 assert!(S3Client::validate_extension(FileType::Audio, "noext").is_err());
840 }
841
842 #[test]
843 fn validate_extension_double_dot() {
844 assert!(S3Client::validate_extension(FileType::Audio, "file.backup.mp3").is_ok());
845 }
846
847 #[test]
848 fn validate_content_type_empty() {
849 assert!(S3Client::validate_content_type(FileType::Audio, "").is_err());
850 }
851
852 #[test]
853 fn file_type_insertion_from_str() {
854 assert_eq!(FileType::from_str("insertion"), Ok(FileType::Insertion));
855 assert_eq!(FileType::from_str("INSERTION"), Ok(FileType::Insertion));
856 }
857
858 #[test]
859 fn file_type_insertion_as_str() {
860 assert_eq!(FileType::Insertion.as_str(), "insertion");
861 }
862
863 #[test]
864 fn file_type_insertion_max_size() {
865 assert_eq!(FileType::Insertion.max_size(), 500 * 1024 * 1024);
866 }
867
868 #[test]
869 fn validate_insertion_content_types() {
870 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/mpeg").is_ok());
871 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/wav").is_ok());
872 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/flac").is_ok());
873 assert!(S3Client::validate_content_type(FileType::Insertion, "image/png").is_err());
874 }
875
876 #[test]
877 fn validate_insertion_extensions() {
878 assert!(S3Client::validate_extension(FileType::Insertion, "intro.mp3").is_ok());
879 assert!(S3Client::validate_extension(FileType::Insertion, "sponsor.wav").is_ok());
880 assert!(S3Client::validate_extension(FileType::Insertion, "outro.flac").is_ok());
881 assert!(S3Client::validate_extension(FileType::Insertion, "clip.png").is_err());
882 }
883
884 #[test]
885 fn generate_insertion_key_format() {
886 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
887 let key = S3Client::generate_insertion_key(user_id, "intro.mp3");
888 assert_eq!(key, "11111111-1111-1111-1111-111111111111/insertions/intro.mp3");
889 }
890
891 #[test]
892 fn generate_insertion_key_sanitizes() {
893 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
894 let key = S3Client::generate_insertion_key(user_id, "my sponsor read (v2).mp3");
895 assert_eq!(key, "11111111-1111-1111-1111-111111111111/insertions/mysponsorreadv2.mp3");
896 }
897
898 #[test]
899 fn generate_key_cover_type() {
900 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
901 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
902
903 let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "art.png");
904 assert!(key.contains("/cover/"));
905 assert!(key.ends_with("art.png"));
906 }
907
908 // FileType::Download tests
909
910 #[test]
911 fn file_type_download_from_str() {
912 assert_eq!(FileType::from_str("download"), Ok(FileType::Download));
913 assert_eq!(FileType::from_str("DOWNLOAD"), Ok(FileType::Download));
914 }
915
916 #[test]
917 fn file_type_download_as_str() {
918 assert_eq!(FileType::Download.as_str(), "download");
919 }
920
921 #[test]
922 fn file_type_download_max_size() {
923 assert_eq!(FileType::Download.max_size(), 500 * 1024 * 1024);
924 }
925
926 #[test]
927 fn validate_download_content_types() {
928 assert!(S3Client::validate_content_type(FileType::Download, "application/octet-stream").is_ok());
929 assert!(S3Client::validate_content_type(FileType::Download, "application/zip").is_ok());
930 assert!(S3Client::validate_content_type(FileType::Download, "application/x-apple-diskimage").is_ok());
931 assert!(S3Client::validate_content_type(FileType::Download, "application/gzip").is_ok());
932 assert!(S3Client::validate_content_type(FileType::Download, "application/x-tar").is_ok());
933 // Reject clearly wrong types
934 assert!(S3Client::validate_content_type(FileType::Download, "text/html").is_err());
935 assert!(S3Client::validate_content_type(FileType::Download, "image/png").is_err());
936 }
937
938 #[test]
939 fn validate_download_extensions() {
940 assert!(S3Client::validate_extension(FileType::Download, "app.zip").is_ok());
941 assert!(S3Client::validate_extension(FileType::Download, "app.dmg").is_ok());
942 assert!(S3Client::validate_extension(FileType::Download, "app.exe").is_ok());
943 assert!(S3Client::validate_extension(FileType::Download, "app.appimage").is_ok());
944 assert!(S3Client::validate_extension(FileType::Download, "app.deb").is_ok());
945 assert!(S3Client::validate_extension(FileType::Download, "app.tar.gz").is_ok());
946 assert!(S3Client::validate_extension(FileType::Download, "app.clap").is_ok());
947 assert!(S3Client::validate_extension(FileType::Download, "app.vst3").is_ok());
948 assert!(S3Client::validate_extension(FileType::Download, "App.ZIP").is_ok());
949 // Reject invalid extensions
950 assert!(S3Client::validate_extension(FileType::Download, "app.mp3").is_err());
951 assert!(S3Client::validate_extension(FileType::Download, "app.txt").is_err());
952 }
953
954 #[test]
955 fn generate_key_download_type() {
956 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
957 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
958
959 let key = S3Client::generate_key(user_id, item_id, FileType::Download, "plugin-v1.0.zip");
960 assert!(key.contains("/download/"));
961 assert!(key.ends_with("plugin-v1.0.zip"));
962 }
963
964 // CDN tests
965
966 #[test]
967 fn cache_control_immutable_format() {
968 assert!(CACHE_CONTROL_IMMUTABLE.contains("public"));
969 assert!(CACHE_CONTROL_IMMUTABLE.contains("max-age=31536000"));
970 assert!(CACHE_CONTROL_IMMUTABLE.contains("immutable"));
971 }
972
973 #[test]
974 fn generate_project_image_key_format() {
975 let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
976 let key = S3Client::generate_project_image_key(project_id, "logo.png");
977 assert_eq!(key, "projects/33333333-3333-3333-3333-333333333333/image/logo.png");
978 }
979
980 #[test]
981 fn generate_project_image_key_sanitizes() {
982 let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
983 let key = S3Client::generate_project_image_key(project_id, "my logo (v2).png");
984 assert_eq!(key, "projects/33333333-3333-3333-3333-333333333333/image/mylogov2.png");
985 }
986
987 #[test]
988 fn cdn_url_from_s3_key() {
989 let cdn_base = "https://cdn.makenot.work";
990 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
991 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
992 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3");
993 let cdn_url = format!("{}/{}", cdn_base, key);
994 assert_eq!(
995 cdn_url,
996 "https://cdn.makenot.work/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3"
997 );
998 }
999
1000 // FileType::Video tests
1001
1002 #[test]
1003 fn file_type_video_from_str() {
1004 assert_eq!(FileType::from_str("video"), Ok(FileType::Video));
1005 assert_eq!(FileType::from_str("VIDEO"), Ok(FileType::Video));
1006 }
1007
1008 #[test]
1009 fn file_type_video_as_str() {
1010 assert_eq!(FileType::Video.as_str(), "video");
1011 }
1012
1013 #[test]
1014 fn file_type_video_max_size() {
1015 assert_eq!(FileType::Video.max_size(), 20 * 1024 * 1024 * 1024);
1016 }
1017
1018 #[test]
1019 fn validate_video_content_types() {
1020 assert!(S3Client::validate_content_type(FileType::Video, "video/mp4").is_ok());
1021 assert!(S3Client::validate_content_type(FileType::Video, "video/webm").is_ok());
1022 assert!(S3Client::validate_content_type(FileType::Video, "video/quicktime").is_ok());
1023 assert!(S3Client::validate_content_type(FileType::Video, "audio/mpeg").is_err());
1024 assert!(S3Client::validate_content_type(FileType::Video, "application/octet-stream").is_err());
1025 assert!(S3Client::validate_content_type(FileType::Video, "text/html").is_err());
1026 }
1027
1028 #[test]
1029 fn validate_video_extensions() {
1030 assert!(S3Client::validate_extension(FileType::Video, "clip.mp4").is_ok());
1031 assert!(S3Client::validate_extension(FileType::Video, "clip.webm").is_ok());
1032 assert!(S3Client::validate_extension(FileType::Video, "clip.mov").is_ok());
1033 assert!(S3Client::validate_extension(FileType::Video, "Clip.MP4").is_ok());
1034 assert!(S3Client::validate_extension(FileType::Video, "clip.avi").is_err());
1035 assert!(S3Client::validate_extension(FileType::Video, "clip.mp3").is_err());
1036 }
1037
1038 #[test]
1039 fn generate_key_video_type() {
1040 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1041 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1042
1043 let key = S3Client::generate_key(user_id, item_id, FileType::Video, "tutorial.mp4");
1044 assert!(key.contains("/video/"));
1045 assert!(key.ends_with("tutorial.mp4"));
1046 }
1047 }
1048