use std::{
collections::{HashMap, HashSet},
future::Future,
path::PathBuf,
};
use percent_encoding::percent_decode_str;
use crate::{
BoxFuture,
form::{FieldErrors, FormValues},
schema::Schema,
topcoat_compat::href::encode_path_segment,
};
mod body;
pub(crate) mod carry;
mod key;
mod pending;
pub(crate) mod signed;
pub(crate) use self::body::{Capture, FormParts, parse_body, parse_file_body};
pub trait Uploader: Send + Sync + 'static {
fn store(
&self,
filename: &str,
bytes: &[u8],
) -> impl Future<Output = Result<String, String>> + Send;
fn holds(&self, path: &str) -> impl Future<Output = bool> + Send {
async move {
let _ = path;
false
}
}
fn keep(&self, path: &str) -> impl Future<Output = Result<(), String>> + Send;
fn delete(&self, path: &str) -> impl Future<Output = Result<(), String>> + Send {
async move {
let _ = path;
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub struct DirUploader {
url_prefix: String,
dir: PathBuf,
pending: Option<std::sync::Arc<pending::Pending>>,
}
const MAX_STORED_BASENAME: usize = 255 - 37;
fn is_stored_name(name: &str) -> bool {
let Some((uuid, rest)) = name.split_at_checked(36) else {
return false;
};
uuid::Uuid::try_parse(uuid).is_ok_and(|parsed| parsed.to_string() == uuid)
&& (rest.is_empty() || (rest.len() > 1 && rest.starts_with('-')))
}
impl DirUploader {
pub fn new(url_prefix: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
let url_prefix = url_prefix.into().trim_end_matches('/').to_string();
Self {
url_prefix,
dir: dir.into(),
pending: None,
}
}
pub fn expire_pending(mut self, age: std::time::Duration) -> Self {
self.pending = Some(std::sync::Arc::new(pending::Pending::new(age)));
self
}
fn file_name(&self, path: &str) -> Option<String> {
let segment = path.strip_prefix(&self.url_prefix)?.strip_prefix('/')?;
let name = percent_decode_str(segment).decode_utf8().ok()?;
(!name.is_empty()
&& sanitize_filename(&name) == name
&& encode_path_segment(&name) == segment)
.then(|| name.into_owned())
}
}
impl Uploader for DirUploader {
async fn store(&self, filename: &str, bytes: &[u8]) -> Result<String, String> {
let basename = sanitize_filename(filename);
let mut cut = basename.len().saturating_sub(MAX_STORED_BASENAME);
while !basename.is_char_boundary(cut) {
cut += 1;
}
let name = match &basename[cut..] {
"" => uuid::Uuid::new_v4().to_string(),
basename => format!("{}-{basename}", uuid::Uuid::new_v4()),
};
tokio::fs::create_dir_all(&self.dir)
.await
.map_err(|_| "the upload directory is not writable".to_string())?;
tokio::fs::write(self.dir.join(&name), bytes)
.await
.map_err(|_| "the upload could not be written".to_string())?;
if let Some(pending) = &self.pending {
pending.sweep_if_due(&self.dir);
pending
.mark(&self.dir, &name)
.await
.map_err(|_| "the upload could not be written".to_string())?;
}
Ok(format!(
"{}/{}",
self.url_prefix,
encode_path_segment(&name)
))
}
async fn holds(&self, path: &str) -> bool {
let Some(name) = self.file_name(path) else {
return false;
};
if !is_stored_name(&name) {
return false;
}
tokio::fs::metadata(self.dir.join(name))
.await
.is_ok_and(|metadata| metadata.is_file())
}
async fn keep(&self, path: &str) -> Result<(), String> {
match self.file_name(path).filter(|name| is_stored_name(name)) {
Some(name) => pending::keep(&self.dir, &name)
.await
.map_err(|error| error.to_string()),
None => Ok(()),
}
}
async fn delete(&self, path: &str) -> Result<(), String> {
let Some(name) = self.file_name(path).filter(|name| is_stored_name(name)) else {
return Ok(());
};
match tokio::fs::remove_file(self.dir.join(&name)).await {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
return Err(error.to_string());
}
_ => {}
}
pending::unmark(&self.dir, &name)
.await
.map_err(|error| error.to_string())
}
}
pub fn sanitize_filename(raw: &str) -> String {
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
let clean: String = base
.chars()
.filter(|&c| !c.is_control() && !is_bidi_control(c))
.collect();
let trimmed = clean.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed == "." || trimmed == ".." || is_windows_reserved_name(trimmed) {
return String::new();
}
if trimmed.len() > 255 {
let mut start = trimmed.len() - 255;
while !trimmed.is_char_boundary(start) {
start += 1;
}
trimmed[start..].to_string()
} else {
trimmed.to_string()
}
}
fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
}
fn is_windows_reserved_name(name: &str) -> bool {
let stem = match name.split_once('.') {
Some((stem, _)) => stem,
None => name,
};
let stem = stem.to_ascii_uppercase();
if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") {
return true;
}
let Some(n) = stem
.strip_prefix("COM")
.or_else(|| stem.strip_prefix("LPT"))
else {
return false;
};
n.parse::<u8>().is_ok_and(|n| (1..=9).contains(&n))
}
#[derive(Debug, Clone)]
pub(crate) struct StagedUpload {
pub(crate) filename: String,
pub(crate) bytes: Vec<u8>,
}
pub(crate) struct InstalledUploader(Box<dyn DynUploader + Send + Sync>);
impl InstalledUploader {
pub(crate) fn new(uploader: impl Uploader) -> Self {
Self(Box::new(uploader))
}
}
pub(crate) trait DynUploader: Send + Sync {
fn store<'a>(
&'a self,
filename: &'a str,
bytes: &'a [u8],
) -> BoxFuture<'a, Result<String, String>>;
fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool>;
fn keep<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>>;
fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>>;
}
impl<U: Uploader> DynUploader for U {
fn store<'a>(
&'a self,
filename: &'a str,
bytes: &'a [u8],
) -> BoxFuture<'a, Result<String, String>> {
Box::pin(Uploader::store(self, filename, bytes))
}
fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool> {
Box::pin(Uploader::holds(self, path))
}
fn keep<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>> {
Box::pin(Uploader::keep(self, path))
}
fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<(), String>> {
Box::pin(Uploader::delete(self, path))
}
}
pub(crate) async fn holds(uploader: Option<&InstalledUploader>, path: &str) -> bool {
match uploader {
Some(uploader) => uploader.0.holds(path).await,
None => false,
}
}
pub(crate) async fn keep(
uploader: Option<&InstalledUploader>,
schema: &Schema,
before: Option<&FormValues>,
after: &FormValues,
) -> Result<(), String> {
let Some(InstalledUploader(uploader)) = uploader else {
return Ok(());
};
for field in schema.fields().filter(|field| field.is_file()) {
let Some(path) = after.text(field.name()).map(str::trim) else {
continue;
};
let stored = before.and_then(|before| before.text(field.name()).map(str::trim));
if path.is_empty() || stored == Some(path) || !uploader.holds(path).await {
continue;
}
uploader.keep(path).await?;
}
Ok(())
}
pub(crate) async fn discard(
uploader: Option<&InstalledUploader>,
schema: &Schema,
before: &[FormValues],
after: Option<&FormValues>,
) {
let Some(InstalledUploader(uploader)) = uploader else {
return;
};
let mut gone = HashSet::new();
for field in schema.fields().filter(|field| field.is_file()) {
for values in before {
let Some(path) = values.text(field.name()).map(str::trim) else {
continue;
};
let kept =
after.is_some_and(|after| after.text(field.name()).map(str::trim) == Some(path));
if !path.is_empty() && !kept {
gone.insert(path.to_owned());
}
}
}
for path in gone {
if !uploader.holds(&path).await {
continue;
}
if let Err(reason) = uploader.delete(&path).await {
tracing::error!(path, reason, "an upload the write let go was not deleted");
}
}
}
pub(crate) async fn store_uploads(
uploader: Option<&InstalledUploader>,
schema: &Schema,
files: &HashMap<String, StagedUpload>,
values: &mut FormValues,
) -> (FieldErrors, HashSet<String>) {
let Some(InstalledUploader(uploader)) = uploader else {
return (FieldErrors::new(), HashSet::new());
};
let mut errors = FieldErrors::new();
let mut stored = HashSet::new();
for field in schema.fields() {
let Some(file) = field.as_file() else {
continue;
};
let name = field.name().to_string();
let Some(staged) = files.get(&name) else {
continue;
};
let label = field.label();
let filename = sanitize_filename(&staged.filename);
let stored_path = match file.refusal(&label, &filename, staged.bytes.len()) {
Some(refusal) => Err(refusal),
None => uploader
.store(&staged.filename, &staged.bytes)
.await
.map_err(|reason| {
crate::text!("tablo-field-upload-failed", label = label, reason = reason)
}),
};
match stored_path {
Ok(path) => {
values.insert(name.clone(), path);
stored.insert(name);
}
Err(message) => {
values.remove(&name);
errors.add(name, message);
}
}
}
(errors, stored)
}
#[cfg(test)]
mod tests;