1use std::{
5 collections::{HashMap, HashSet},
6 future::Future,
7 path::PathBuf,
8};
9
10use percent_encoding::percent_decode_str;
11use topcoat::context::Cx;
12
13use crate::{
14 BoxFuture,
15 form::{FieldErrors, FormValues},
16 panel::state::current,
17 schema::Schema,
18 topcoat_compat::href::encode_path_segment,
19};
20
21pub trait Uploader: Send + Sync + 'static {
28 fn store(
31 &self,
32 filename: &str,
33 bytes: &[u8],
34 ) -> impl Future<Output = Result<String, String>> + Send;
35
36 fn holds(&self, path: &str) -> impl Future<Output = bool> + Send {
43 async move {
44 let _ = path;
45 false
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
61pub struct DirUploader {
62 url_prefix: String,
63 dir: PathBuf,
64}
65
66const MAX_STORED_BASENAME: usize = 255 - 37;
68
69fn is_stored_name(name: &str) -> bool {
72 let Some((uuid, rest)) = name.split_at_checked(36) else {
73 return false;
74 };
75 uuid::Uuid::try_parse(uuid).is_ok_and(|parsed| parsed.to_string() == uuid)
76 && (rest.is_empty() || (rest.len() > 1 && rest.starts_with('-')))
77}
78
79impl DirUploader {
80 pub fn new(url_prefix: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
82 let url_prefix = url_prefix.into().trim_end_matches('/').to_string();
83 Self {
84 url_prefix,
85 dir: dir.into(),
86 }
87 }
88
89 fn file_name(&self, path: &str) -> Option<String> {
91 let segment = path.strip_prefix(&self.url_prefix)?.strip_prefix('/')?;
92 let name = percent_decode_str(segment).decode_utf8().ok()?;
93 (!name.is_empty()
94 && sanitize_filename(&name) == name
95 && encode_path_segment(&name) == segment)
96 .then(|| name.into_owned())
97 }
98}
99
100impl Uploader for DirUploader {
101 async fn store(&self, filename: &str, bytes: &[u8]) -> Result<String, String> {
102 let basename = sanitize_filename(filename);
103 let mut cut = basename.len().saturating_sub(MAX_STORED_BASENAME);
104 while !basename.is_char_boundary(cut) {
105 cut += 1;
106 }
107 let name = match &basename[cut..] {
108 "" => uuid::Uuid::new_v4().to_string(),
109 basename => format!("{}-{basename}", uuid::Uuid::new_v4()),
110 };
111 tokio::fs::create_dir_all(&self.dir)
112 .await
113 .map_err(|_| "the upload directory is not writable".to_string())?;
114 tokio::fs::write(self.dir.join(&name), bytes)
115 .await
116 .map_err(|_| "the upload could not be written".to_string())?;
117 Ok(format!(
118 "{}/{}",
119 self.url_prefix,
120 encode_path_segment(&name)
121 ))
122 }
123
124 async fn holds(&self, path: &str) -> bool {
128 let Some(name) = self.file_name(path) else {
129 return false;
130 };
131 if !is_stored_name(&name) {
132 return false;
133 }
134 tokio::fs::metadata(self.dir.join(name))
135 .await
136 .is_ok_and(|metadata| metadata.is_file())
137 }
138}
139
140pub fn sanitize_filename(raw: &str) -> String {
156 let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
157 let clean: String = base.chars().filter(|c| !c.is_control()).collect();
158 let trimmed = clean.trim();
159 if trimmed.is_empty() {
160 return String::new();
161 }
162 if trimmed == "." || trimmed == ".." || is_windows_reserved_name(trimmed) {
163 return String::new();
164 }
165 if trimmed.len() > 255 {
167 let mut start = trimmed.len() - 255;
168 while !trimmed.is_char_boundary(start) {
169 start += 1;
170 }
171 trimmed[start..].to_string()
172 } else {
173 trimmed.to_string()
174 }
175}
176
177fn is_windows_reserved_name(name: &str) -> bool {
179 let stem = match name.split_once('.') {
180 Some((stem, _)) => stem,
181 None => name,
182 };
183 let stem = stem.to_ascii_uppercase();
184 if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") {
185 return true;
186 }
187 let Some(n) = stem
188 .strip_prefix("COM")
189 .or_else(|| stem.strip_prefix("LPT"))
190 else {
191 return false;
192 };
193 n.parse::<u8>().is_ok_and(|n| (1..=9).contains(&n))
194}
195
196#[derive(Debug, Clone)]
198pub(crate) struct StagedUpload {
199 pub(crate) filename: String,
200 pub(crate) bytes: Vec<u8>,
201}
202
203pub(crate) struct InstalledUploader(Box<dyn DynUploader + Send + Sync>);
205
206impl InstalledUploader {
207 pub(crate) fn new(uploader: impl Uploader) -> Self {
208 Self(Box::new(uploader))
209 }
210}
211
212pub(crate) trait DynUploader: Send + Sync {
214 fn store<'a>(
215 &'a self,
216 filename: &'a str,
217 bytes: &'a [u8],
218 ) -> BoxFuture<'a, Result<String, String>>;
219 fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool>;
220}
221
222impl<U: Uploader> DynUploader for U {
223 fn store<'a>(
224 &'a self,
225 filename: &'a str,
226 bytes: &'a [u8],
227 ) -> BoxFuture<'a, Result<String, String>> {
228 Box::pin(Uploader::store(self, filename, bytes))
229 }
230
231 fn holds<'a>(&'a self, path: &'a str) -> BoxFuture<'a, bool> {
232 Box::pin(Uploader::holds(self, path))
233 }
234}
235
236pub(crate) fn installed(cx: &Cx) -> bool {
238 installed_uploader(cx).is_some()
239}
240
241type DynUploaderRef<'a> = &'a (dyn DynUploader + Send + Sync);
242
243fn installed_uploader<'a>(cx: &'a Cx) -> Option<DynUploaderRef<'a>> {
244 current(cx)
245 .and_then(|panel| panel.uploads.as_ref())
246 .map(|installed| &*installed.0)
247}
248
249pub(crate) async fn holds(cx: &Cx, path: &str) -> bool {
251 match installed_uploader(cx) {
252 Some(uploader) => uploader.holds(path).await,
253 None => false,
254 }
255}
256
257pub(crate) async fn store_uploads(
262 cx: &Cx,
263 schema: &Schema,
264 files: &HashMap<String, StagedUpload>,
265 values: &mut FormValues,
266) -> (FieldErrors, HashSet<String>) {
267 let Some(uploader) = installed_uploader(cx) else {
268 return (FieldErrors::new(), HashSet::new());
269 };
270 let mut errors = FieldErrors::new();
271 let mut stored = HashSet::new();
272 for field in schema.fields().filter(|field| field.is_file()) {
275 let name = field.name().to_string();
276 let Some(staged) = files.get(&name) else {
277 continue;
278 };
279 match uploader.store(&staged.filename, &staged.bytes).await {
280 Ok(path) => {
281 values.insert(name.clone(), path);
282 stored.insert(name);
283 }
284 Err(reason) => {
285 values.remove(&name);
286 errors.add(
287 name,
288 format!("{} could not be uploaded: {reason}", field.label_str()),
289 );
290 }
291 }
292 }
293 (errors, stored)
294}
295
296#[cfg(test)]
297mod tests;