#![allow(clippy::module_name_repetitions)]
pub mod analyzer;
pub mod attachment;
pub mod blob;
pub mod direct_upload;
pub mod preview;
pub mod service;
pub mod transformations;
pub mod urls;
pub mod variant;
pub use analyzer::{
Analysis, AnalyzerError, AnalyzerRegistry, BlobAnalyzer, ImageAnalyzer, MediaAnalyzer,
TextAnalyzer,
};
pub use attachment::{
Attachment, AttachmentError, HasManyAttached, HasOneAttached, ManyAttachments, OneAttachment,
has_many_attached, has_one_attached,
};
pub use blob::{Blob, BlobError};
pub use direct_upload::{
DirectUploadError, DirectUploadManager, DirectUploadRequest, DirectUploadTokenClaims,
};
pub use preview::{
BlobPreviewer, PdfPreviewer, Preview, PreviewError, PreviewRegistry, VideoPreviewer,
};
pub use service::{DynStorageService, StorageError, StorageService};
pub use transformations::{CropTransform, ImageTransformations, ResizeTransform};
pub use urls::{SignedResource, SignedUrlError, SignedUrlGenerator};
pub use variant::{Variant, VariantError};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
pub(crate) fn sha256_hex(data: impl AsRef<[u8]>) -> String {
let digest = Sha256::digest(data.as_ref());
hex_encode(&digest)
}
pub(crate) fn hex_encode(data: &[u8]) -> String {
let mut output = String::with_capacity(data.len() * 2);
for byte in data {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
}
pub(crate) fn urlsafe_encode(data: impl AsRef<[u8]>) -> String {
URL_SAFE_NO_PAD.encode(data.as_ref())
}
pub(crate) fn urlsafe_decode(data: &str) -> Result<Vec<u8>, base64::DecodeError> {
URL_SAFE_NO_PAD.decode(data)
}
pub(crate) fn detect_content_type(filename: &str, provided: Option<&str>) -> Option<String> {
let normalized = provided.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase())
});
if let Some(content_type) = normalized.as_deref()
&& content_type != "application/octet-stream"
{
return Some(content_type.to_owned());
}
filename.rsplit('.').next().and_then(|extension| {
let mime = match extension.to_ascii_lowercase().as_str() {
"txt" => "text/plain",
"md" => "text/markdown",
"json" => "application/json",
"csv" => "text/csv",
"html" | "htm" => "text/html",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"ico" => "image/x-icon",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
_ => return normalized,
};
Some(mime.to_owned())
})
}
pub(crate) fn file_extension(filename: &str) -> Option<&str> {
filename.rsplit_once('.').map(|(_, extension)| extension)
}
pub(crate) fn replace_extension(filename: &str, extension: &str) -> String {
match filename.rsplit_once('.') {
Some((stem, _)) => format!("{stem}.{extension}"),
None => format!("{filename}.{extension}"),
}
}
#[cfg(test)]
pub(crate) mod test_support {
pub(crate) use rustrails_support::testing::run_sync_test;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_content_type_prefers_specific_value() {
assert_eq!(
detect_content_type("file.txt", Some("image/jpeg")).as_deref(),
Some("image/jpeg")
);
}
#[test]
fn test_detect_content_type_uses_filename_for_octet_stream() {
assert_eq!(
detect_content_type("file.txt", Some("application/octet-stream")).as_deref(),
Some("text/plain")
);
}
#[test]
fn test_detect_content_type_returns_none_for_unknown_extension() {
assert_eq!(detect_content_type("file.unknown", None), None);
}
#[test]
fn test_replace_extension_rewrites_existing_extension() {
assert_eq!(replace_extension("racecar.jpg", "png"), "racecar.png");
}
#[test]
fn test_replace_extension_adds_extension_when_missing() {
assert_eq!(replace_extension("README", "txt"), "README.txt");
}
}