Skip to main content

backbone_bucket/application/service/
cdn_service.rs

1//! CDN Service
2//!
3//! Hand-written — NOT generated. This file is safe from regeneration.
4//!
5//! Manages CDN URL generation and caching for stored files.
6//! Provides signed URL generation with configurable expiry.
7//!
8//! The whole surface is `#[deprecated]` (see [`CdnService`]); allow
9//! directives below are narrowed to the `impl` that references the
10//! deprecated type to avoid self-referential warnings while still
11//! flagging any unrelated deprecated usage.
12
13use std::sync::Arc;
14
15use chrono::{Duration, Utc};
16use hmac::{Hmac, Mac};
17use sha2::Sha256;
18use uuid::Uuid;
19
20use super::error::{ServiceError, ServiceResult};
21use crate::domain::entity::StoredFile;
22use crate::infrastructure::persistence::StoredFileRepository;
23use crate::infrastructure::persistence::BucketRepository;
24
25/// Default CDN URL expiry: 1 hour
26const DEFAULT_CDN_EXPIRY_HOURS: i64 = 1;
27
28/// Environment variable for CDN signing secret
29const CDN_SECRET_ENV: &str = "CDN_SIGNING_SECRET";
30
31/// Default CDN secret (development only)
32const DEFAULT_CDN_SECRET: &str = "bucket-cdn-dev-secret-change-in-production";
33
34/// Service for managing CDN URLs for stored files.
35///
36/// # Deprecated
37///
38/// Signs URLs with a raw module-local HMAC scheme, which is not
39/// compatible with S3/MinIO clients and cannot be validated by a
40/// reverse proxy. Use [`crate::storage::ObjectStorage::presigned_get`]
41/// instead — it emits real SigV4 URLs for S3/MinIO and signed
42/// module-local URLs for `LocalStorage`.
43///
44/// Scheduled for removal in a later release (see `docs/serving.md`).
45#[deprecated(
46    note = "HMAC-signed CDN URLs are not S3-compatible. Use ObjectStorage::presigned_get for real SigV4 URLs."
47)]
48pub struct CdnService {
49    file_repo: Arc<StoredFileRepository>,
50    bucket_repo: Arc<BucketRepository>,
51}
52
53#[allow(deprecated)]
54impl CdnService {
55    pub fn new(
56        file_repo: Arc<StoredFileRepository>,
57        bucket_repo: Arc<BucketRepository>,
58    ) -> Self {
59        Self { file_repo, bucket_repo }
60    }
61
62    /// Get or generate a CDN URL for a file.
63    ///
64    /// If the file already has a valid (non-expired) CDN URL, returns it.
65    /// Otherwise, generates a new URL and caches it on the file record.
66    pub async fn get_or_generate_url(
67        &self,
68        file_id: Uuid,
69        expiry_hours: Option<i64>,
70    ) -> ServiceResult<String> {
71        let file = self.file_repo
72            .find_by_id(&file_id.to_string())
73            .await
74            .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
75            .ok_or(ServiceError::NotFound)?;
76
77        // Check if bucket has CDN enabled
78        let bucket = self.bucket_repo
79            .find_by_id(&file.bucket_id.to_string())
80            .await
81            .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
82            .ok_or(ServiceError::NotFound)?;
83
84        if !bucket.enable_cdn {
85            return Err(ServiceError::Validation(format!("CDN is not enabled for bucket {}", bucket.id)));
86        }
87
88        // Return existing valid URL
89        if let (Some(ref url), Some(ref expires)) = (&file.cdn_url, &file.cdn_url_expires_at) {
90            if *expires > Utc::now() {
91                return Ok(url.clone());
92            }
93        }
94
95        // Generate new CDN URL
96        let hours = expiry_hours.unwrap_or(DEFAULT_CDN_EXPIRY_HOURS);
97        let expires_at = Utc::now() + Duration::hours(hours);
98
99        // Build the CDN URL from file path and storage key
100        // In production, this would call the actual CDN provider API
101        let cdn_url = self.generate_signed_url(&file, expires_at);
102
103        // TODO: file_repo.update_cdn_url — implement custom repository method
104        let _ = (&cdn_url, expires_at);
105
106        Ok(cdn_url)
107    }
108
109    /// Invalidate the cached CDN URL for a file.
110    pub async fn invalidate(&self, _file_id: Uuid) -> ServiceResult<()> {
111        // TODO: file_repo.update_cdn_url — implement custom repository method
112        Ok(())
113    }
114
115    /// Invalidate all CDN URLs for files in a bucket.
116    pub async fn invalidate_bucket(&self, _bucket_id: Uuid) -> ServiceResult<u64> {
117        // TODO: file_repo.invalidate_cdn_urls_by_bucket — implement custom repository method
118        Ok(0)
119    }
120
121    // ---- internal ----
122
123    /// Generate an HMAC-SHA256 signed CDN URL for a file.
124    ///
125    /// The signature covers the path and expiry timestamp to prevent
126    /// URL tampering or expiry manipulation.
127    fn generate_signed_url(
128        &self,
129        file: &StoredFile,
130        expires_at: chrono::DateTime<Utc>,
131    ) -> String {
132        let timestamp = expires_at.timestamp();
133        let path = format!("/cdn/files/{}/{}", file.bucket_id, file.id);
134        let signature = Self::sign_url(&path, timestamp);
135        format!("{}?expires={}&sig={}", path, timestamp, signature)
136    }
137
138    /// Compute HMAC-SHA256 signature over path and expiry.
139    fn sign_url(path: &str, expires_timestamp: i64) -> String {
140        let secret = std::env::var(CDN_SECRET_ENV)
141            .unwrap_or_else(|_| DEFAULT_CDN_SECRET.to_string());
142        let message = format!("{}:{}", path, expires_timestamp);
143
144        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
145            .expect("HMAC accepts any key length");
146        mac.update(message.as_bytes());
147        let result = mac.finalize();
148        hex::encode(result.into_bytes())
149    }
150}