use std::{
collections::{HashMap, HashSet},
future::Future,
path::PathBuf,
};
use percent_encoding::percent_decode_str;
use topcoat::context::Cx;
use crate::{
BoxFuture,
form::{FieldErrors, FormValues},
panel::state::current,
schema::Schema,
topcoat_compat::href::encode_path_segment,
};
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
}
}
}
#[derive(Debug, Clone)]
pub struct DirUploader {
url_prefix: String,
dir: PathBuf,
}
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(),
}
}
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())?;
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())
}
}
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()).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_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>;
}
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))
}
}
pub(crate) fn installed(cx: &Cx) -> bool {
installed_uploader(cx).is_some()
}
type DynUploaderRef<'a> = &'a (dyn DynUploader + Send + Sync);
fn installed_uploader<'a>(cx: &'a Cx) -> Option<DynUploaderRef<'a>> {
current(cx)
.and_then(|panel| panel.uploads.as_ref())
.map(|installed| &*installed.0)
}
pub(crate) async fn holds(cx: &Cx, path: &str) -> bool {
match installed_uploader(cx) {
Some(uploader) => uploader.holds(path).await,
None => false,
}
}
pub(crate) async fn store_uploads(
cx: &Cx,
schema: &Schema,
files: &HashMap<String, StagedUpload>,
values: &mut FormValues,
) -> (FieldErrors, HashSet<String>) {
let Some(uploader) = installed_uploader(cx) else {
return (FieldErrors::new(), HashSet::new());
};
let mut errors = FieldErrors::new();
let mut stored = HashSet::new();
for field in schema.fields().filter(|field| field.is_file()) {
let name = field.name().to_string();
let Some(staged) = files.get(&name) else {
continue;
};
match uploader.store(&staged.filename, &staged.bytes).await {
Ok(path) => {
values.insert(name.clone(), path);
stored.insert(name);
}
Err(reason) => {
values.remove(&name);
errors.add(
name,
format!("{} could not be uploaded: {reason}", field.label_str()),
);
}
}
}
(errors, stored)
}
#[cfg(test)]
mod tests;