use std::future::Future;
use crate::sandbox::paths;
pub const ATTACHMENTS_DIR: &str = "attachments";
#[derive(Debug, Clone, PartialEq)]
pub struct RawAttachment {
pub id: String,
pub name: String,
pub url: String,
pub size: u64,
pub content_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Taken {
pub path: String,
pub bytes: Vec<u8>,
pub content_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Refused {
pub name: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Outcome {
pub taken: Vec<Taken>,
pub refused: Vec<Refused>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Limits {
pub max_bytes: u64,
pub max_count: usize,
}
fn safe_name(name: &str) -> String {
let bare = name
.replace(['/', '\\'], "_")
.trim_start_matches('.')
.trim()
.to_owned();
let cleaned: String = bare
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
character
} else {
'_'
}
})
.take(80)
.collect();
if cleaned.is_empty() {
"attachment".to_owned()
} else {
cleaned
}
}
fn write_new(project_path: &str, name: &str, bytes: &[u8]) -> std::io::Result<String> {
let dot = name.rfind('.');
let extension = match dot {
Some(at) if at > 0 => &name[at..],
_ => "",
};
let stem = &name[..name.len() - extension.len()];
for next in 1..=MAX_NAME_ATTEMPTS {
let candidate = if next == 1 {
name.to_owned()
} else {
format!("{stem}-{next}{extension}")
};
let opened = paths::open_beneath(
project_path,
&format!("{ATTACHMENTS_DIR}/{candidate}"),
&paths::OpenOptions::create_new(),
);
match opened {
Ok(mut file) => {
use std::io::Write;
file.write_all(bytes)?;
return Ok(candidate);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
}
Err(std::io::Error::other(
"no free name is left in the attachments directory",
))
}
const MAX_NAME_ATTEMPTS: usize = 512;
pub fn is_image(content_type: Option<&str>, name: &str) -> bool {
if content_type.is_some_and(|kind| kind.starts_with("image/")) {
return true;
}
let lower = name.to_lowercase();
[".png", ".jpg", ".jpeg", ".gif", ".webp"]
.iter()
.any(|extension| lower.ends_with(extension))
}
pub async fn receive<F, Fut>(
attachments: &[RawAttachment],
project_path: &str,
limits: Limits,
fetch_file: F,
) -> Outcome
where
F: Fn(String) -> Fut,
Fut: Future<Output = Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>>>,
{
let mut taken = Vec::new();
let mut refused = Vec::new();
let too_big = format!("larger than the {} byte limit", limits.max_bytes);
for (index, file) in attachments.iter().enumerate() {
if index >= limits.max_count {
refused.push(Refused {
name: file.name.clone(),
reason: format!("more than {} file(s) on one message", limits.max_count),
});
continue;
}
if file.size > limits.max_bytes {
refused.push(Refused {
name: file.name.clone(),
reason: too_big.clone(),
});
continue;
}
let bytes = match fetch_file(file.url.clone()).await {
Ok(bytes) => bytes,
Err(error) => {
refused.push(Refused {
name: file.name.clone(),
reason: format!("could not be fetched: {error}"),
});
continue;
}
};
if bytes.len() as u64 > limits.max_bytes {
refused.push(Refused {
name: file.name.clone(),
reason: too_big.clone(),
});
continue;
}
let directory = paths::host_path_under(project_path, project_path, ATTACHMENTS_DIR);
let Some(directory) = directory else {
refused.push(Refused {
name: file.name.clone(),
reason: "the project has nowhere to put it".to_owned(),
});
continue;
};
let saved = std::fs::create_dir_all(&directory)
.and_then(|()| write_new(project_path, &safe_name(&file.name), &bytes));
match saved {
Ok(name) => {
taken.push(Taken {
path: format!("{ATTACHMENTS_DIR}/{name}"),
bytes,
content_type: file.content_type.clone(),
});
}
Err(error) => refused.push(Refused {
name: file.name.clone(),
reason: format!("could not be saved: {error}"),
}),
}
}
Outcome { taken, refused }
}
#[cfg(test)]
mod tests;