Skip to main content

rustrails_storage/
lib.rs

1#![allow(clippy::module_name_repetitions)]
2
3//! File storage primitives inspired by Rails ActiveStorage.
4
5pub 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}