backbone_bucket/application/service/
cdn_service.rs1use 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
25const DEFAULT_CDN_EXPIRY_HOURS: i64 = 1;
27
28const CDN_SECRET_ENV: &str = "CDN_SIGNING_SECRET";
30
31const DEFAULT_CDN_SECRET: &str = "bucket-cdn-dev-secret-change-in-production";
33
34#[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 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 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 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 let hours = expiry_hours.unwrap_or(DEFAULT_CDN_EXPIRY_HOURS);
97 let expires_at = Utc::now() + Duration::hours(hours);
98
99 let cdn_url = self.generate_signed_url(&file, expires_at);
102
103 let _ = (&cdn_url, expires_at);
105
106 Ok(cdn_url)
107 }
108
109 pub async fn invalidate(&self, _file_id: Uuid) -> ServiceResult<()> {
111 Ok(())
113 }
114
115 pub async fn invalidate_bucket(&self, _bucket_id: Uuid) -> ServiceResult<u64> {
117 Ok(0)
119 }
120
121 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 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}