#[cfg(test)]
mod tests;
use crate::request::Request;
use crate::storage::{Storage, StorageError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attachment {
pub key: String,
pub filename: String,
pub content_type: String,
pub size: u64,
}
impl Attachment {
fn new_key(prefix: &str, filename: &str) -> String {
let safe: String = filename
.rsplit(['/', '\\'])
.next()
.unwrap_or("")
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' })
.collect();
let safe = safe.trim_start_matches('.');
let safe = if safe.is_empty() { "file" } else { safe };
let id = unique_id();
let prefix = prefix.trim_matches('/');
if prefix.is_empty() {
format!("{id}/{safe}")
} else {
format!("{prefix}/{id}/{safe}")
}
}
pub fn store(storage: &dyn Storage, prefix: &str, filename: &str, content_type: &str, data: &[u8]) -> Result<Attachment, StorageError> {
let key = storage.put(&Self::new_key(prefix, filename), data, content_type)?;
Ok(Attachment { key, filename: filename.to_string(), content_type: content_type.to_string(), size: data.len() as u64 })
}
pub fn store_stream(storage: &dyn Storage, prefix: &str, filename: &str, content_type: &str, reader: &mut dyn std::io::Read) -> Result<Attachment, StorageError> {
let mut counting = Counting { inner: reader, count: 0 };
let key = storage.put_stream(&Self::new_key(prefix, filename), &mut counting, content_type)?;
Ok(Attachment { key, filename: filename.to_string(), content_type: content_type.to_string(), size: counting.count })
}
pub fn from_multipart(storage: &dyn Storage, prefix: &str, request: &Request, field: &str) -> Result<Option<Attachment>, StorageError> {
use crate::body::multipart_form_data::FormMultipartData;
use crate::header::content_disposition::ContentDisposition;
let content_type = request.header("content-type").map(|h| h.value.to_string()).unwrap_or_default();
if !content_type.to_ascii_lowercase().starts_with("multipart/form-data") {
return Err(StorageError::new("the request body isn't multipart/form-data"));
}
let boundary = FormMultipartData::extract_boundary(&content_type).map_err(StorageError::new)?;
let parts = FormMultipartData::parse(&request.body, boundary).map_err(StorageError::new)?;
for part in parts {
let Some(disposition) = part.header("content-disposition") else { continue };
let Ok(disposition) = ContentDisposition::parse(&disposition.value) else {
continue;
};
if disposition.field_name.as_deref() != Some(field) {
continue;
}
let filename = disposition.file_name.unwrap_or_default();
if filename.is_empty() {
return Ok(None);
}
let part_type = part.header("content-type").map(|h| h.value.to_string()).unwrap_or_else(|| "application/octet-stream".to_string());
return Self::store(storage, prefix, &filename, &part_type, &part.body).map(Some);
}
Ok(None)
}
pub fn url(&self, storage: &dyn Storage) -> String {
storage.url(&self.key)
}
pub fn read(&self, storage: &dyn Storage) -> Result<Vec<u8>, StorageError> {
storage.get(&self.key)
}
pub fn delete(&self, storage: &dyn Storage) -> Result<(), StorageError> {
storage.delete(&self.key)
}
pub fn encode(&self) -> String {
use crate::router::build_path;
let size = self.size.to_string();
let query = build_path("/", &[("key", &self.key), ("name", &self.filename), ("type", &self.content_type), ("size", &size)]).unwrap_or_default();
query.trim_start_matches("/?").to_string()
}
pub fn decode(text: &str) -> Option<Attachment> {
let mut attachment = Attachment { key: String::new(), filename: String::new(), content_type: String::new(), size: 0 };
for pair in text.split('&').filter(|p| !p.is_empty()) {
let (name, value) = pair.split_once('=')?;
let value = percent_decode(value)?;
match name {
"key" => attachment.key = value,
"name" => attachment.filename = value,
"type" => attachment.content_type = value,
"size" => attachment.size = value.parse().ok()?,
_ => {}
}
}
(!attachment.key.is_empty()).then_some(attachment)
}
}
fn unique_id() -> String {
use std::hash::{BuildHasher, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
static SEED: OnceLock<std::collections::hash_map::RandomState> = OnceLock::new();
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
let mut hasher = SEED.get_or_init(Default::default).build_hasher();
hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
hasher.write_u64(nanos);
format!("{nanos:016x}{:016x}", hasher.finish())
}
fn percent_decode(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
out.push(u8::from_str_radix(text.get(i + 1..i + 3)?, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
struct Counting<'a> {
inner: &'a mut dyn std::io::Read,
count: u64,
}
impl std::io::Read for Counting<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.count += n as u64;
Ok(n)
}
}
#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
mod column {
use super::Attachment;
use crate::model::{DbError, FromColumn, ToColumn, Value};
impl FromColumn for Attachment {
fn from_column(v: Value) -> Result<Self, DbError> {
match v {
Value::Text(text) => Attachment::decode(&text).ok_or_else(|| DbError::new(format!("not an attachment: {text:?}"))),
Value::Null => Err(DbError::new("unexpected NULL for Attachment")),
other => Err(DbError::new(format!("cannot convert {other:?} to Attachment"))),
}
}
}
impl ToColumn for Attachment {
fn to_column(&self) -> Value {
Value::Text(self.encode())
}
}
}