1use crate::*;
2use std::{
3 collections::HashSet,
4 io::Read,
5 os::unix::fs::MetadataExt,
6 path::{Component, Path},
7};
8
9pub fn relative(rel: &str, item: bool) -> Result<String> {
10 if rel.len() > 4096 || rel.contains('\0') || Path::new(rel).is_absolute() {
11 return Err(Error::new(
12 400,
13 "Paths start from the library, so they can't start with /.",
14 ));
15 }
16 let mut path = PathBuf::new();
17 for component in Path::new(rel).components() {
18 match component {
19 Component::CurDir => (),
20 Component::ParentDir => {
21 if path.file_name().is_some_and(|s| s != "..") {
22 path.pop();
23 } else {
24 path.push("..");
25 }
26 }
27 _ => path.push(component.as_os_str()),
28 }
29 }
30 let value = path.to_string_lossy().into_owned();
31 if item && value.is_empty() {
32 return Err(Error::new(
33 400,
34 "Choose something inside the folder, not the folder itself.",
35 ));
36 }
37 Ok(value)
38}
39fn name(value: &Value) -> Result<&str> {
40 value
41 .as_str()
42 .filter(|s| {
43 !s.is_empty() && s.len() <= 255 && !s.contains(['/', '\0']) && *s != "." && *s != ".."
44 })
45 .ok_or_else(|| Error::new(400, "Names can't contain / or be . or .."))
46}
47#[derive(Clone)]
48struct Explorer {
49 root: PathBuf,
50 base: PathBuf,
51 scope: PathBuf,
52}
53impl Explorer {
54 async fn new(root: PathBuf) -> Result<Self> {
55 let store = PathBuf::from(env("STUDIO_STORE_ROOT", "/srv"));
56 let scope = root
57 .strip_prefix(&store)
58 .map_err(|_| Error::new(500, "Library root is outside the store."))?
59 .to_path_buf();
60 let real = tokio::fs::canonicalize(store).await?;
61 Ok(Self {
62 root,
63 base: real.join(&scope),
64 scope,
65 })
66 }
67 async fn resolve(&self, rel: &str) -> Result<PathBuf> {
68 let rel = relative(rel, false)?;
69 if rel == ".." || rel.starts_with("../") {
70 return Err(Error::new(
71 403,
72 format!("That path is outside {}.", self.root.display()),
73 ));
74 }
75 let abs = self.base.join(&rel);
76 let mut ancestor = abs.clone();
77 loop {
78 match tokio::fs::canonicalize(&ancestor).await {
79 Ok(real) => {
80 if !real.starts_with(&self.base) {
81 return Err(Error::new(
82 403,
83 format!("That path is outside {}.", self.root.display()),
84 ));
85 }
86 return Ok(abs);
87 }
88 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
89 if !ancestor.pop() {
90 return Err(e.into());
91 }
92 }
93 Err(e) => return Err(e.into()),
94 }
95 }
96 }
97 fn store(&self, rel: &str) -> String {
98 self.scope.join(rel).to_string_lossy().into_owned()
99 }
100 fn local(&self, rel: &str) -> Result<String> {
101 Path::new(rel)
102 .strip_prefix(&self.scope)
103 .map(|p| p.to_string_lossy().into_owned())
104 .map_err(|_| {
105 Error::new(
106 403,
107 format!("That change touched files outside {}.", self.root.display()),
108 )
109 })
110 }
111 async fn entries(&self, rel: &str, mounts: &Value) -> Result<Vec<Value>> {
112 let mut dir = tokio::fs::read_dir(self.resolve(rel).await?)
113 .await
114 .map_err(file_error)?;
115 let mut found = Vec::new();
116 while let Some(entry) = dir.next_entry().await? {
117 let name = entry.file_name().to_string_lossy().into_owned();
118 let child = Path::new(rel).join(&name).to_string_lossy().into_owned();
119 let Ok(abs) = self.resolve(&child).await else {
120 continue;
121 };
122 let Ok(info) = tokio::fs::metadata(&abs).await else {
123 continue;
124 };
125 let is_dir = info.is_dir();
126 let items = if is_dir {
127 tokio::time::timeout(Duration::from_millis(200), async {
128 let mut dir = tokio::fs::read_dir(&abs).await?;
129 let mut count = 0;
130 while dir.next_entry().await?.is_some() {
131 count += 1;
132 }
133 Ok::<_, std::io::Error>(count)
134 })
135 .await
136 .ok()
137 .and_then(std::result::Result::ok)
138 } else {
139 None
140 };
141 let dataset = if is_dir {
142 array(mounts)
143 .iter()
144 .find(|m| m["target"] == abs.to_string_lossy().as_ref())
145 .map(|m| m["source"].clone())
146 } else {
147 None
148 };
149 found.push(json!({"name":name,"dir":is_dir,"size":if is_dir {None}else{Some(info.len())},"alloc":if is_dir {None}else{Some(info.blocks()*512)},"items":items,"modified":info.mtime(),"inode":info.ino(),"dataset":dataset,"download":apps::file_link(&self.root.join(child).to_string_lossy(),is_dir)}));
150 }
151 Ok(found)
152 }
153 async fn not_dataset(&self, rels: &[String], mounts: &Value) -> Result<()> {
154 for rel in rels {
155 let abs = self.resolve(rel).await?;
156 if let Some(mount) = array(mounts)
157 .iter()
158 .find(|m| Path::new(string(&m["target"])).starts_with(&abs))
159 {
160 return Err(Error::new(
161 409,
162 if mount["target"] == abs.to_string_lossy().as_ref() {
163 format!(
164 "{rel} is the {} dataset. Rename or destroy it with zfs instead.",
165 string(&mount["source"])
166 )
167 } else {
168 format!(
169 "{rel} holds the {} dataset. Move that dataset out with zfs first.",
170 string(&mount["source"])
171 )
172 },
173 ));
174 }
175 }
176 Ok(())
177 }
178 async fn check_moves(&self, pairs: &[(String, String)], mounts: &Value) -> Result<()> {
179 self.not_dataset(
180 &pairs
181 .iter()
182 .map(|(from, _)| from.clone())
183 .collect::<Vec<_>>(),
184 mounts,
185 )
186 .await?;
187 let mut sources = HashSet::new();
188 let mut targets = HashSet::new();
189 for (from, to) in pairs {
190 if to.starts_with(&format!("{from}/")) {
191 return Err(Error::new(400, format!("Can't move {from} into itself.")));
192 }
193 if !sources.insert(from) {
194 return Err(Error::new(
195 400,
196 format!("{from} is listed twice. Pick one place for it."),
197 ));
198 }
199 if !targets.insert(to) {
200 return Err(Error::new(
201 409,
202 format!("More than one item would land at {to}. Rename one first."),
203 ));
204 }
205 let a = self.resolve(from).await?;
206 let b = self.resolve(to).await?;
207 if !tokio::fs::try_exists(&a).await? {
208 return Err(Error::new(
209 409,
210 format!("{from} isn't there anymore. Reload the folder."),
211 ));
212 }
213 if tokio::fs::try_exists(&b).await? {
214 return Err(Error::new(
215 409,
216 format!("{to} already exists. Move or rename it first."),
217 ));
218 }
219 let source = holder(mounts, &a);
220 let target = holder(mounts, b.parent().unwrap());
221 if source.map(|m| &m["source"]) != target.map(|m| &m["source"]) {
222 return Err(Error::new(
223 409,
224 format!(
225 "{from} is on {} and {} is on {}. Moves can't cross datasets, so copy it from a shell instead.",
226 source.map(|m| string(&m["source"])).unwrap_or("no dataset"),
227 b.parent()
228 .and_then(Path::file_name)
229 .unwrap_or_default()
230 .to_string_lossy(),
231 target.map(|m| string(&m["source"])).unwrap_or("no dataset")
232 ),
233 ));
234 }
235 }
236 Ok(())
237 }
238 async fn move_one(&self, from: &str, to: &str) -> Result<Option<String>> {
239 let target = self.resolve(to).await?;
240 let mut made = target.parent().unwrap().to_path_buf();
241 let mut first = None;
242 while !tokio::fs::try_exists(&made).await? {
243 first = Some(made.clone());
244 made.pop();
245 }
246 tokio::fs::create_dir_all(target.parent().unwrap()).await?;
247 if let Err(error) = tokio::fs::rename(self.resolve(from).await?, &target).await {
248 if let Some(first) = &first {
249 remove_empty(first.clone()).await?;
250 }
251 return Err(file_error(error));
252 }
253 Ok(first.map(|p| self.store(&p.strip_prefix(&self.base).unwrap().to_string_lossy())))
254 }
255 async fn folder(&self, rel: &str) -> Result<()> {
256 let abs = self.resolve(rel).await?;
257 if !tokio::fs::metadata(abs).await.is_ok_and(|m| m.is_dir()) {
258 return Err(Error::new(
259 400,
260 format!(
261 "{} isn't a folder. Create it first, or pick an existing folder.",
262 self.root.join(rel).display()
263 ),
264 ));
265 }
266 Ok(())
267 }
268 async fn matches(&self, folder: &str, pattern: &str, app: Arc<App>) -> Result<Vec<String>> {
269 if pattern.is_empty()
270 || pattern.len() > 1024
271 || pattern.starts_with('/')
272 || pattern.split('/').any(|s| s == "..")
273 {
274 return Err(Error::new(400, "Patterns can't start with / or contain .."));
275 }
276 let base = self.resolve(folder).await?;
277 let pattern = pattern.to_owned();
278 let _slot = app.heavy.acquire().await?;
279 let paths = tokio::task::spawn_blocking(move || {
280 let matcher = globset::GlobBuilder::new(&pattern)
281 .literal_separator(true)
282 .build()
283 .map_err(|e| Error::new(400, e.to_string()))?
284 .compile_matcher();
285 Ok::<_, Error>(
286 walkdir::WalkDir::new(&base)
287 .min_depth(1)
288 .follow_links(false)
289 .into_iter()
290 .filter_map(|e| e.ok())
291 .filter_map(|e| {
292 let rel = e.path().strip_prefix(&base).ok()?;
293 matcher
294 .is_match(rel)
295 .then(|| rel.to_string_lossy().into_owned())
296 })
297 .collect::<Vec<_>>(),
298 )
299 })
300 .await??;
301 let mut paths = paths
302 .into_iter()
303 .map(|p| Path::new(folder).join(p).to_string_lossy().into_owned())
304 .collect::<Vec<_>>();
305 paths.sort();
306 let mut valid = Vec::new();
307 for path in paths {
308 if self.resolve(&path).await.is_ok()
309 && !valid
310 .iter()
311 .any(|p: &String| path.starts_with(&format!("{p}/")))
312 {
313 valid.push(path);
314 }
315 }
316 Ok(valid)
317 }
318}
319fn file_error(error: std::io::Error) -> Error {
320 match error.kind() {
321 std::io::ErrorKind::NotFound => Error::new(
322 404,
323 "That file or folder no longer exists. Reload the folder.",
324 ),
325 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty => {
326 Error::new(409, "Something with that name already exists.")
327 }
328 _ if error.raw_os_error() == Some(18) => Error::new(
329 409,
330 "Moves can't cross ZFS datasets. Copy it from a shell instead.",
331 ),
332 _ => error.into(),
333 }
334}
335async fn mounts() -> Result<Value> {
336 let value = host::call(json!({"operation":"storage.mounts"})).await?;
337 let mut values = array(&value["filesystems"]).to_vec();
338 values.sort_by_key(|m| std::cmp::Reverse(string(&m["target"]).len()));
339 Ok(json!(values))
340}
341fn holder<'a>(mounts: &'a Value, abs: &Path) -> Option<&'a Value> {
342 array(mounts)
343 .iter()
344 .find(|m| abs.starts_with(string(&m["target"])))
345}
346fn selection(value: &Value) -> Result<Vec<String>> {
347 if !value.is_array() || array(value).is_empty() || array(value).len() > 10000 {
348 return Err(Error::new(400, "Choose files or folders."));
349 }
350 let paths = array(value)
351 .iter()
352 .map(|p| {
353 p.as_str()
354 .ok_or_else(|| Error::new(400, "Invalid path."))
355 .and_then(|p| relative(p, true))
356 })
357 .collect::<Result<Vec<_>>>()?;
358 let mut seen = HashSet::new();
359 Ok(paths
360 .iter()
361 .filter(|p| {
362 seen.insert((*p).clone())
363 && !Path::new(p)
364 .ancestors()
365 .skip(1)
366 .any(|a| paths.iter().any(|p| p == a.to_string_lossy().as_ref()))
367 })
368 .cloned()
369 .collect())
370}
371fn item_count(count: usize) -> String {
372 format!("{count} {}", if count == 1 { "item" } else { "items" })
373}
374async fn remove_empty(abs: PathBuf) -> Result<bool> {
375 tokio::task::spawn_blocking(move || {
376 fn remove(abs: &Path) -> std::io::Result<bool> {
377 if !std::fs::symlink_metadata(abs).is_ok_and(|m| m.is_dir()) {
378 return Ok(false);
379 }
380 let mut empty = true;
381 for entry in std::fs::read_dir(abs)? {
382 if !remove(&entry?.path())? {
383 empty = false;
384 }
385 }
386 if empty {
387 std::fs::remove_dir(abs)?;
388 }
389 Ok(empty)
390 }
391 Ok::<_, Error>(remove(&abs)?)
392 })
393 .await?
394}
395fn walk_total(abs: &Path, budget: &mut usize) -> Option<Value> {
396 if *budget == 0 {
397 return None;
398 }
399 *budget -= 1;
400 let info = std::fs::symlink_metadata(abs).ok();
401 let Some(info) = info else {
402 return Some(json!({"size":0,"alloc":0,"files":0}));
403 };
404 if !info.is_dir() {
405 return Some(json!({"size":info.len(),"alloc":info.blocks()*512,"files":1}));
406 }
407 let mut total = json!({"size":0,"alloc":0,"files":0});
408 if let Ok(dir) = std::fs::read_dir(abs) {
409 for entry in dir.flatten() {
410 let child = walk_total(&entry.path(), budget)?;
411 for key in ["size", "alloc", "files"] {
412 total[key] = json!(number(&total[key]) + number(&child[key]));
413 }
414 }
415 }
416 Some(total)
417}
418async fn tree(app: Arc<App>, explorer: &Explorer, body: &Value, mounts: &Value) -> Result<Value> {
419 let top = relative(string(&body["path"]), false)?;
420 let mut folders = serde_json::Map::new();
421 let first = explorer.entries(&top, mounts).await?;
422 let mut listed = first.len();
423 folders.insert(top.clone(), json!(first));
424 let mut complete = true;
425 if body["expanded"] == "all" {
426 let mut level = vec![top.clone()];
427 while !level.is_empty() {
428 let next: Vec<_> = level
429 .iter()
430 .flat_map(|rel| {
431 array(&folders[rel])
432 .iter()
433 .filter(|e| e["dir"] == true)
434 .map(|e| {
435 (
436 Path::new(rel)
437 .join(string(&e["name"]))
438 .to_string_lossy()
439 .into_owned(),
440 e["items"].as_u64().map(|n| n as usize),
441 )
442 })
443 })
444 .collect();
445 let Some(total) = next
446 .iter()
447 .try_fold(listed, |total, (_, count)| count.map(|n| total + n))
448 else {
449 complete = false;
450 break;
451 };
452 listed = total;
453 if listed > 3000 {
454 complete = false;
455 break;
456 }
457 level.clear();
458 for (rel, _) in next {
459 let entries = explorer.entries(&rel, mounts).await?;
460 folders.insert(rel.clone(), json!(entries));
461 level.push(rel);
462 }
463 }
464 } else {
465 if !body["expanded"].is_array() || array(&body["expanded"]).len() > 3000 {
466 return Err(Error::new(400, "Choose expanded folders."));
467 }
468 for rel in array(&body["expanded"]) {
469 let rel = relative(string(rel), true)?;
470 if (!top.is_empty() && !rel.starts_with(&format!("{top}/")))
471 || folders.contains_key(&rel)
472 {
473 continue;
474 }
475 if let Ok(entries) = explorer.entries(&rel, mounts).await {
476 folders.insert(rel, json!(entries));
477 }
478 }
479 }
480 let index = app.index.clone();
481 let explorer = explorer.clone();
482 let _slot = app.heavy.acquire().await?;
483 tokio::task::spawn_blocking(move || {
484 fn measure(
485 rel: &str,
486 explorer: &Explorer,
487 index: Option<&crate::index::Index>,
488 folders: &serde_json::Map<String, Value>,
489 sizes: &mut serde_json::Map<String, Value>,
490 budget: &mut usize,
491 from_index: &mut bool,
492 ) -> Result<Option<Value>> {
493 if let Some(entries) = folders.get(rel) {
494 let mut total = json!({"size":0,"alloc":0,"files":0});
495 let mut known = true;
496 for entry in array(entries) {
497 let child = if entry["dir"] == true {
498 let rel = Path::new(rel)
499 .join(string(&entry["name"]))
500 .to_string_lossy()
501 .into_owned();
502 let value =
503 measure(&rel, explorer, index, folders, sizes, budget, from_index)?;
504 if let Some(value) = &value {
505 sizes.insert(rel, value.clone());
506 }
507 value
508 } else {
509 Some(json!({"size":entry["size"],"alloc":entry["alloc"],"files":1}))
510 };
511 if let Some(child) = child {
512 for key in ["size", "alloc", "files"] {
513 total[key] = json!(number(&total[key]) + number(&child[key]));
514 }
515 } else {
516 known = false;
517 }
518 }
519 return Ok(known.then_some(total));
520 }
521 if let Some(index) = index
522 && index.updated()?.is_some()
523 && let Some(total) =
524 index.children(&explorer.scope.join(rel).to_string_lossy(), 0)?
525 {
526 *from_index = true;
527 return Ok(Some(
528 json!({"size":total["size"],"alloc":total["alloc"],"files":total["files"]}),
529 ));
530 }
531 Ok(walk_total(&explorer.base.join(rel), budget))
532 }
533 let mut budget = 200000;
534 let mut sizes = serde_json::Map::new();
535 let mut from_index = false;
536 if let Some(total) = measure(
537 &top,
538 &explorer,
539 index.as_deref(),
540 &folders,
541 &mut sizes,
542 &mut budget,
543 &mut from_index,
544 )? {
545 sizes.insert(top, total);
546 }
547 let updated = if from_index {
548 index.as_ref().map(|i| i.updated()).transpose()?.flatten()
549 } else {
550 None
551 };
552 Ok(json!({"folders":folders,"sizes":sizes,"complete":complete,"updated":updated}))
553 })
554 .await?
555}
556
557async fn journal(app: &App) -> Result<Value> {
558 core::read_json(&app.data.join("file-ops.json"), json!([])).await
559}
560fn mine(explorer: &Explorer, op: &Value) -> bool {
561 array(&op["done"]).iter().all(|step| {
562 if step.is_array() {
563 array(step)
564 .iter()
565 .all(|p| explorer.local(string(p)).is_ok())
566 } else {
567 explorer.local(string(step)).is_ok()
568 }
569 })
570}
571async fn prune(ops: &Value) -> Result<()> {
572 let keep: HashSet<_> = array(ops)
573 .iter()
574 .filter(|op| op["kind"] == "delete" && op["undone"] == false)
575 .map(|op| string(&op["snapshot"]))
576 .collect();
577 let mounts = mounts().await?;
578 let datasets: HashSet<_> = array(&mounts)
579 .iter()
580 .filter(|m| Path::new(string(&m["target"])).starts_with(env("STUDIO_STORE_ROOT", "/srv")))
581 .map(|m| string(&m["source"]))
582 .collect();
583 if datasets.is_empty() {
584 return Ok(());
585 }
586 let listed = host::call(json!({"operation":"files.snapshots","datasets":datasets})).await?;
587 for snapshot in array(&listed).iter().map(string) {
588 if let Some((dataset, name)) = snapshot.split_once('@')
589 && !keep.contains(name)
590 {
591 host::call(json!({"operation":"files.discard","dataset":dataset,"snapshot":name}))
592 .await?;
593 }
594 }
595 Ok(())
596}
597
598pub async fn route(
599 app: Arc<App>,
600 media: bool,
601 method: &Method,
602 parts: &[&str],
603 query: &HashMap<String, String>,
604 body: Value,
605) -> Result<Response> {
606 let root = if media {
607 env(
608 "STUDIO_MEDIA_ROOT",
609 &format!("{}/clover/Media", env("STUDIO_STORE_ROOT", "/srv")),
610 )
611 } else {
612 env("STUDIO_STORE_ROOT", "/srv")
613 };
614 let explorer = Explorer::new(root.into()).await?;
615 if parts == ["refresh"] && media && method == Method::POST {
616 return refresh(app).await;
617 }
618 let value = match parts {
619 ["list"] if method == Method::GET => {
620 let rel = relative(
621 query.get("path").map(String::as_str).unwrap_or_default(),
622 false,
623 )?;
624 let key = format!("files:{}", explorer.root.join(&rel).display());
625 let listing = app
626 .cache
627 .get(key, Duration::from_secs(2), move || async move {
628 let served = explorer.root.join(&rel);
629 let entries = explorer.entries(&rel, &mounts().await?).await?;
630 Ok(json!({"host":served,"link":apps::file_link(&served.to_string_lossy(),false),"zip":apps::file_link(&served.to_string_lossy(),true),"entries":entries}))
631 })
632 .await?;
633 return Ok(listing.response());
634 }
635 ["tree"] if method == Method::POST => tree(app, &explorer, &body, &mounts().await?).await?,
636 ["peek"] if method == Method::GET => {
637 let rel = relative(
638 query.get("path").map(String::as_str).unwrap_or_default(),
639 true,
640 )?;
641 let abs = explorer.resolve(&rel).await?;
642 let _slot = app.heavy.acquire().await?;
643 tokio::task::spawn_blocking(move || {
644 let file = std::fs::File::open(abs).map_err(file_error)?;
645 let size = file.metadata()?.len();
646 let mut bytes = Vec::new();
647 file.take(65536).read_to_end(&mut bytes)?;
648 if bytes.contains(&0) {
649 return Err(Error::new(
650 415,
651 "This file isn't text. Open it in copyparty instead.",
652 ));
653 }
654 Ok::<_, Error>(
655 json!({"text":String::from_utf8_lossy(&bytes),"more":size>bytes.len() as u64}),
656 )
657 })
658 .await??
659 }
660 ["match"] if method == Method::GET => {
661 let folder = relative(
662 query.get("path").map(String::as_str).unwrap_or_default(),
663 false,
664 )?;
665 let pattern = query
666 .get("pattern")
667 .ok_or_else(|| Error::new(400, "Enter a pattern."))?;
668 let found = explorer.matches(&folder, pattern, app.clone()).await?;
669 let paths: Vec<_> = found.iter().map(|p| explorer.base.join(p)).collect();
670 let _slot = app.heavy.acquire().await?;
671 let size = tokio::task::spawn_blocking(move || {
672 let mut budget = 200000;
673 let mut size = Some(0.0);
674 for path in paths {
675 let total = walk_total(&path, &mut budget);
676 size = match (size, total) {
677 (Some(size), Some(total)) => Some(size + number(&total["size"])),
678 _ => None,
679 };
680 }
681 size
682 })
683 .await?;
684 let mut rows = serde_json::Map::new();
685 for rel in &found {
686 let inside = Path::new(rel)
687 .strip_prefix(&folder)
688 .unwrap_or(Path::new(rel));
689 rows.insert(inside.to_string_lossy().into_owned(), json!("self"));
690 for parent in inside
691 .ancestors()
692 .skip(1)
693 .filter(|p| !p.as_os_str().is_empty())
694 {
695 let key = parent.to_string_lossy().into_owned();
696 let count = rows.get(&key).map(number).unwrap_or(0.0) + 1.0;
697 rows.insert(key, json!(count));
698 }
699 }
700 json!({"count":found.len(),"size":size,"sample":found.iter().take(100).map(|p| Path::new(p).strip_prefix(&folder).unwrap_or(Path::new(p)).to_string_lossy().into_owned()).collect::<Vec<_>>(),"rows":rows})
701 }
702 ["ops"] if method == Method::GET => {
703 let ops = journal(&app).await?;
704 json!(array(&ops).iter().filter(|op| mine(&explorer,op)).map(|op| json!({"id":op["id"],"label":op["label"],"at":op["at"],"undone":op["undone"],"sample":array(&op["done"]).iter().take(3).map(|step| explorer.local(if step.is_array(){string(&step[0])}else{string(step)}).unwrap()).collect::<Vec<_>>(),"count":array(&op["done"]).len()})).collect::<Vec<_>>())
705 }
706 _ if method == Method::POST => {
707 let result = change(app.clone(), &explorer, parts, body).await;
708 app.cache.invalidate_prefix("files:");
709 return result;
710 }
711 _ => return Err(Error::new(404, "Not Found")),
712 };
713 Ok(Document::new(value).response())
714}
715
716async fn change(
717 app: Arc<App>,
718 explorer: &Explorer,
719 parts: &[&str],
720 body: Value,
721) -> Result<Response> {
722 if env("STUDIO_FILES_WRITABLE", "") != "1" {
723 return Err(Error::new(
724 501,
725 "The file browser can't change files yet. Use copyparty instead.",
726 ));
727 }
728 let _guard = app.file_changes.lock().await;
729 let mut ops = journal(&app).await?;
730 let mounts = mounts().await?;
731 if let ["ops", id, "undo"] = parts {
732 let position = array(&ops)
733 .iter()
734 .position(|op| op["id"] == *id && mine(explorer, op))
735 .ok_or_else(|| Error::new(404, "That change is too old to undo."))?;
736 let op = ops[position].clone();
737 if op["undone"] == true {
738 return Err(Error::new(409, "That change is already undone."));
739 }
740 revert(explorer, &op, &mounts).await?;
741 ops[position]["undone"] = json!(true);
742 core::write_json(&app.data.join("file-ops.json"), &ops).await?;
743 if let Err(e) = prune(&ops).await {
744 eprintln!("snapshot prune: {}", e.message);
745 }
746 if let Some(index) = &app.index {
747 index.changed();
748 }
749 return Ok(StatusCode::NO_CONTENT.into_response());
750 }
751 let mut pairs = Vec::new();
752 let mut paths = Vec::new();
753 let mut folder = None;
754 let label;
755 match parts {
756 ["folder"] => {
757 let parent = relative(string(&body["path"]), false)?;
758 let name = name(&body["name"])?;
759 explorer.folder(&parent).await?;
760 folder = Some(Path::new(&parent).join(name).to_string_lossy().into_owned());
761 label = format!("Created {name}");
762 }
763 ["rename"] => {
764 if !body["renames"].is_array()
765 || array(&body["renames"]).is_empty()
766 || array(&body["renames"]).len() > 10000
767 {
768 return Err(Error::new(400, "Choose names to change."));
769 }
770 for rename in array(&body["renames"]) {
771 let from = relative(string(&rename["path"]), true)?;
772 let name = name(&rename["name"])?;
773 let to = Path::new(&from)
774 .parent()
775 .unwrap()
776 .join(name)
777 .to_string_lossy()
778 .into_owned();
779 if from != to {
780 pairs.push((from, to));
781 }
782 }
783 if pairs.is_empty() {
784 return Err(Error::new(
785 400,
786 "Every name is unchanged. Edit a name, then rename.",
787 ));
788 }
789 label = if pairs.len() == 1 {
790 format!(
791 "Renamed {} to {}",
792 Path::new(&pairs[0].0)
793 .file_name()
794 .unwrap()
795 .to_string_lossy(),
796 Path::new(&pairs[0].1)
797 .file_name()
798 .unwrap()
799 .to_string_lossy()
800 )
801 } else {
802 format!("Renamed {}", item_count(pairs.len()))
803 };
804 }
805 ["move"] => {
806 let selected = selection(&body["paths"])?;
807 let to = relative(string(&body["to"]), false)?;
808 explorer.folder(&to).await?;
809 pairs = selected
810 .iter()
811 .map(|from| {
812 (
813 from.clone(),
814 Path::new(&to)
815 .join(Path::new(from).file_name().unwrap())
816 .to_string_lossy()
817 .into_owned(),
818 )
819 })
820 .collect();
821 label = format!(
822 "Moved {} to {}",
823 item_count(selected.len()),
824 explorer
825 .root
826 .join(&to)
827 .file_name()
828 .unwrap_or_default()
829 .to_string_lossy()
830 );
831 }
832 ["delete"] => {
833 paths = selection(&body["paths"])?;
834 label = if paths.len() == 1 {
835 format!(
836 "Deleted {}",
837 Path::new(&paths[0]).file_name().unwrap().to_string_lossy()
838 )
839 } else {
840 format!("Deleted {}", item_count(paths.len()))
841 };
842 }
843 ["bulk"] => {
844 let rel = relative(string(&body["path"]), false)?;
845 let pattern = string(&body["pattern"]);
846 let found = explorer.matches(&rel, pattern, app.clone()).await?;
847 let count = number(&body["count"]) as usize;
848 if count == 0 {
849 return Err(Error::new(400, "Choose at least one item."));
850 }
851 if found.len() != count {
852 return Err(Error::new(
853 409,
854 format!(
855 "The pattern matches {} now, not {count}. Go back and check the new matches.",
856 found.len()
857 ),
858 ));
859 }
860 if body["action"] == "delete" {
861 paths = found;
862 label = format!("Deleted {} matching {pattern}", item_count(count));
863 } else if body["action"] == "move" {
864 let to = body["to"]
865 .as_str()
866 .ok_or_else(|| Error::new(400, "Choose a folder to move into."))?;
867 let to = relative(to, false)?;
868 explorer.folder(&to).await?;
869 pairs = found
870 .iter()
871 .map(|from| {
872 (
873 from.clone(),
874 Path::new(&to)
875 .join(Path::new(from).strip_prefix(&rel).unwrap())
876 .to_string_lossy()
877 .into_owned(),
878 )
879 })
880 .collect();
881 label = format!(
882 "Moved {} matching {pattern} to {}",
883 item_count(count),
884 explorer
885 .root
886 .join(&to)
887 .file_name()
888 .unwrap_or_default()
889 .to_string_lossy()
890 );
891 } else {
892 return Err(Error::new(400, "Choose move or delete."));
893 }
894 }
895 _ => return Err(Error::new(404, "Not Found")),
896 }
897 if !pairs.is_empty() {
898 explorer.check_moves(&pairs, &mounts).await?;
899 }
900 let mut snapshot = None;
901 if !paths.is_empty() {
902 explorer.not_dataset(&paths, &mounts).await?;
903 let mut datasets = HashSet::new();
904 for rel in &paths {
905 let abs = explorer.resolve(rel).await?;
906 tokio::fs::symlink_metadata(&abs)
907 .await
908 .map_err(file_error)?;
909 let source = holder(&mounts, &abs).ok_or_else(|| {
910 Error::new(
911 409,
912 format!(
913 "{} isn't on a ZFS dataset, so deleting it couldn't be undone.",
914 abs.display()
915 ),
916 )
917 })?;
918 if datasets.insert(string(&source["source"]).to_owned()) {
919 drop(
920 tokio::fs::read_dir(Path::new(string(&source["target"])).join(".zfs/snapshot"))
921 .await
922 .map_err(|_| {
923 Error::new(
924 409,
925 format!(
926 "Can't delete {rel}. Its undo history isn't accessible here."
927 ),
928 )
929 })?,
930 );
931 }
932 }
933 let name = host::call(json!({"operation":"files.snapshot","datasets":datasets})).await?;
934 snapshot = Some(string(&name).to_owned());
935 }
936 let id = uuid::Uuid::new_v4().to_string();
937 let mut done = Vec::new();
938 let mut created = Vec::new();
939 let result = async {
940 if let Some(folder) = &folder {
941 tokio::fs::create_dir(explorer.resolve(folder).await?)
942 .await
943 .map_err(file_error)?;
944 done.push(json!(explorer.store(folder)));
945 }
946 for (from, to) in &pairs {
947 if let Some(made) = explorer.move_one(from, to).await? {
948 created.push(made);
949 }
950 done.push(json!([explorer.store(from), explorer.store(to)]));
951 }
952 for rel in &paths {
953 let abs = explorer.resolve(rel).await?;
954 let info = tokio::fs::symlink_metadata(&abs)
955 .await
956 .map_err(file_error)?;
957 if info.is_dir() {
958 tokio::fs::remove_dir_all(abs).await.map_err(file_error)?;
959 } else {
960 tokio::fs::remove_file(abs).await.map_err(file_error)?;
961 }
962 done.push(json!(explorer.store(rel)));
963 }
964 Ok::<_, Error>(())
965 }
966 .await;
967 if !done.is_empty() {
968 let mut op = json!({"id":id,"label":label,"at":now().floor(),"undone":false,"done":done,"kind":if folder.is_some(){"folder"}else if snapshot.is_some(){"delete"}else{"move"}});
969 if let Some(snapshot) = snapshot {
970 op["snapshot"] = json!(snapshot);
971 } else if folder.is_none() {
972 op["created"] = json!(created);
973 }
974 ops.as_array_mut().unwrap().insert(0, op);
975 ops.as_array_mut().unwrap().truncate(20);
976 core::write_json(&app.data.join("file-ops.json"), &ops).await?;
977 }
978 if let Err(e) = prune(&ops).await {
979 eprintln!("snapshot prune: {}", e.message);
980 }
981 if let Some(index) = &app.index {
982 index.changed();
983 }
984 result?;
985 Ok(Document::new(json!({"id":id,"label":label})).response())
986}
987async fn revert(explorer: &Explorer, op: &Value, mounts: &Value) -> Result<()> {
988 let paths = array(&op["done"]);
989 match string(&op["kind"]) {
990 "folder" => {
991 let rel = explorer.local(string(&paths[0]))?;
992 let abs = explorer.resolve(&rel).await?;
993 let mut dir = tokio::fs::read_dir(&abs).await?;
994 if dir.next_entry().await?.is_some() {
995 return Err(Error::new(
996 409,
997 format!("{rel} has something in it now. Empty it, then undo."),
998 ));
999 }
1000 tokio::fs::remove_dir(abs).await?;
1001 }
1002 "delete" => {
1003 let mut copies = Vec::new();
1004 for path in paths {
1005 let rel = explorer.local(string(path))?;
1006 let abs = explorer.resolve(&rel).await?;
1007 if tokio::fs::try_exists(&abs).await? {
1008 return Err(Error::new(
1009 409,
1010 format!("{rel} exists again. Move or rename it, then undo."),
1011 ));
1012 }
1013 let target = holder(mounts, &abs)
1014 .map(|m| PathBuf::from(string(&m["target"])))
1015 .unwrap_or(abs.clone());
1016 let saved = target
1017 .join(".zfs/snapshot")
1018 .join(string(&op["snapshot"]))
1019 .join(abs.strip_prefix(&target).unwrap());
1020 if !tokio::fs::try_exists(&saved).await? {
1021 return Err(Error::new(
1022 409,
1023 format!("Can't put {} back. Its snapshot is gone.", abs.display()),
1024 ));
1025 }
1026 copies.push((abs, saved));
1027 }
1028 for (abs, saved) in copies {
1029 tokio::fs::create_dir_all(abs.parent().unwrap()).await?;
1030 command(
1031 "cp",
1032 &[
1033 "-a",
1034 "--reflink=auto",
1035 &saved.to_string_lossy(),
1036 &abs.to_string_lossy(),
1037 ],
1038 None,
1039 )
1040 .await?;
1041 }
1042 }
1043 "move" => {
1044 let pairs = paths
1045 .iter()
1046 .rev()
1047 .map(|p| {
1048 Ok((
1049 explorer.local(string(&p[1]))?,
1050 explorer.local(string(&p[0]))?,
1051 ))
1052 })
1053 .collect::<Result<Vec<_>>>()?;
1054 explorer.check_moves(&pairs, mounts).await?;
1055 for (from, to) in pairs {
1056 explorer.move_one(&from, &to).await?;
1057 }
1058 for rel in array(&op["created"]).iter().rev() {
1059 remove_empty(explorer.resolve(&explorer.local(string(rel))?).await?).await?;
1060 }
1061 }
1062 _ => return Err(Error::new(409, "That change can't be undone.")),
1063 }
1064 Ok(())
1065}
1066async fn refresh(app: Arc<App>) -> Result<Response> {
1067 let base = std::env::var("STUDIO_JELLYFIN_URL")
1068 .map_err(|_| Error::new(501, "Jellyfin isn't connected to the dashboard."))?;
1069 let password = host::call(
1070 json!({"operation":"deploy.secret.get","service":"jellyfin","key":"admin_password"}),
1071 )
1072 .await?;
1073 let password = password
1074 .as_str()
1075 .ok_or_else(|| Error::new(409, "Jellyfin has no generated admin password."))?;
1076 let response = app
1077 .http
1078 .post(format!("{base}/Users/AuthenticateByName"))
1079 .header(
1080 "Authorization",
1081 "MediaBrowser Client=\"home server\", Device=\"server\", DeviceId=\"studio\", Version=\"1\"",
1082 )
1083 .json(&json!({"Username":"snow","Pw":password}))
1084 .send()
1085 .await?;
1086 if !response.status().is_success() {
1087 return Err(Error::new(
1088 502,
1089 format!("Jellyfin sign-in failed ({}).", response.status()),
1090 ));
1091 }
1092 let value: Value = response.json().await?;
1093 let token = value["AccessToken"]
1094 .as_str()
1095 .ok_or_else(|| Error::new(502, "Jellyfin didn't return an access token."))?;
1096 let response = app
1097 .http
1098 .post(format!("{base}/Library/Refresh"))
1099 .header("X-Emby-Token", token)
1100 .send()
1101 .await?;
1102 if !response.status().is_success() {
1103 return Err(Error::new(
1104 502,
1105 format!(
1106 "Jellyfin couldn't start a library scan ({}).",
1107 response.status()
1108 ),
1109 ));
1110 }
1111 Ok(StatusCode::ACCEPTED.into_response())
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 #[test]
1118 fn selection_keeps_order_and_collapses_descendants() {
1119 assert_eq!(
1120 selection(&json!(["b/x", "a", "b", "a", "c/y"])).unwrap(),
1121 vec!["a", "b", "c/y"]
1122 );
1123 assert!(selection(&json!([""])).is_err());
1124 assert!(relative("/absolute", true).is_err());
1125 assert!(relative("a\0b", true).is_err());
1126 assert_eq!(relative("a/../b", true).unwrap(), "b");
1127 }
1128 #[tokio::test]
1129 async fn existing_and_missing_paths_cannot_escape_through_symlinks() {
1130 let dir = std::env::temp_dir().join(format!("snowglobe-files-{}", uuid::Uuid::new_v4()));
1131 let root = dir.join("root");
1132 let outside = dir.join("outside");
1133 std::fs::create_dir_all(&root).unwrap();
1134 std::fs::create_dir_all(&outside).unwrap();
1135 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
1136 let root = std::fs::canonicalize(root).unwrap();
1137 let explorer = Explorer {
1138 root: root.clone(),
1139 base: root,
1140 scope: PathBuf::new(),
1141 };
1142 assert_eq!(
1143 explorer.resolve("../outside").await.unwrap_err().status,
1144 403
1145 );
1146 assert_eq!(
1147 explorer
1148 .resolve("escape/new/file")
1149 .await
1150 .unwrap_err()
1151 .status,
1152 403
1153 );
1154 assert!(explorer.resolve("new/file").await.is_ok());
1155 std::fs::remove_dir_all(dir).unwrap();
1156 }
1157}