1#![allow(clippy::module_name_repetitions)]
2
3pub mod analyzer;
6pub mod attachment;
7pub mod blob;
8pub mod direct_upload;
9pub mod preview;
10pub mod service;
11pub mod transformations;
12pub mod urls;
13pub mod variant;
14
15pub use analyzer::{
16 Analysis, AnalyzerError, AnalyzerRegistry, BlobAnalyzer, ImageAnalyzer, MediaAnalyzer,
17 TextAnalyzer,
18};
19pub use attachment::{
20 Attachment, AttachmentError, HasManyAttached, HasOneAttached, ManyAttachments, OneAttachment,
21 has_many_attached, has_one_attached,
22};
23pub use blob::{Blob, BlobError};
24pub use direct_upload::{
25 DirectUploadError, DirectUploadManager, DirectUploadRequest, DirectUploadTokenClaims,
26};
27pub use preview::{
28 BlobPreviewer, PdfPreviewer, Preview, PreviewError, PreviewRegistry, VideoPreviewer,
29};
30pub use service::{DynStorageService, StorageError, StorageService};
31pub use transformations::{CropTransform, ImageTransformations, ResizeTransform};
32pub use urls::{SignedResource, SignedUrlError, SignedUrlGenerator};
33pub use variant::{Variant, VariantError};
34
35use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
36use sha2::{Digest, Sha256};
37
38pub(crate) fn sha256_hex(data: impl AsRef<[u8]>) -> String {
39 let digest = Sha256::digest(data.as_ref());
40 hex_encode(&digest)
41}
42
43pub(crate) fn hex_encode(data: &[u8]) -> String {
44 let mut output = String::with_capacity(data.len() * 2);
45 for byte in data {
46 use std::fmt::Write as _;
47 let _ = write!(output, "{byte:02x}");
48 }
49 output
50}
51
52pub(crate) fn urlsafe_encode(data: impl AsRef<[u8]>) -> String {
53 URL_SAFE_NO_PAD.encode(data.as_ref())
54}
55
56pub(crate) fn urlsafe_decode(data: &str) -> Result<Vec<u8>, base64::DecodeError> {
57 URL_SAFE_NO_PAD.decode(data)
58}
59
60pub(crate) fn detect_content_type(filename: &str, provided: Option<&str>) -> Option<String> {
61 let normalized = provided.and_then(|value| {
62 let trimmed = value.trim();
63 (!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase())
64 });
65 if let Some(content_type) = normalized.as_deref()
66 && content_type != "application/octet-stream"
67 {
68 return Some(content_type.to_owned());
69 }
70
71 filename.rsplit('.').next().and_then(|extension| {
72 let mime = match extension.to_ascii_lowercase().as_str() {
73 "txt" => "text/plain",
74 "md" => "text/markdown",
75 "json" => "application/json",
76 "csv" => "text/csv",
77 "html" | "htm" => "text/html",
78 "jpg" | "jpeg" => "image/jpeg",
79 "png" => "image/png",
80 "gif" => "image/gif",
81 "webp" => "image/webp",
82 "bmp" => "image/bmp",
83 "tif" | "tiff" => "image/tiff",
84 "ico" => "image/x-icon",
85 "svg" => "image/svg+xml",
86 "pdf" => "application/pdf",
87 "mp4" => "video/mp4",
88 "mov" => "video/quicktime",
89 "mp3" => "audio/mpeg",
90 "wav" => "audio/wav",
91 "ogg" => "audio/ogg",
92 _ => return normalized,
93 };
94 Some(mime.to_owned())
95 })
96}
97
98pub(crate) fn file_extension(filename: &str) -> Option<&str> {
99 filename.rsplit_once('.').map(|(_, extension)| extension)
100}
101
102pub(crate) fn replace_extension(filename: &str, extension: &str) -> String {
103 match filename.rsplit_once('.') {
104 Some((stem, _)) => format!("{stem}.{extension}"),
105 None => format!("{filename}.{extension}"),
106 }
107}
108
109#[cfg(test)]
110pub(crate) mod test_support {
111 pub(crate) use rustrails_support::testing::run_sync_test;
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn test_detect_content_type_prefers_specific_value() {
120 assert_eq!(
121 detect_content_type("file.txt", Some("image/jpeg")).as_deref(),
122 Some("image/jpeg")
123 );
124 }
125
126 #[test]
127 fn test_detect_content_type_uses_filename_for_octet_stream() {
128 assert_eq!(
129 detect_content_type("file.txt", Some("application/octet-stream")).as_deref(),
130 Some("text/plain")
131 );
132 }
133
134 #[test]
135 fn test_detect_content_type_returns_none_for_unknown_extension() {
136 assert_eq!(detect_content_type("file.unknown", None), None);
137 }
138
139 #[test]
140 fn test_replace_extension_rewrites_existing_extension() {
141 assert_eq!(replace_extension("racecar.jpg", "png"), "racecar.png");
142 }
143
144 #[test]
145 fn test_replace_extension_adds_extension_when_missing() {
146 assert_eq!(replace_extension("README", "txt"), "README.txt");
147 }
148}