1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, RwLock};
9
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::TcpListener;
12
13const PORT: u16 = 8642;
15
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct AppEntry {
22 pub space: String,
23 pub name: String,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub served_from: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct AppRegistry {
32 inner: Arc<RwLock<HashMap<String, AppEntry>>>,
33 path: std::path::PathBuf,
34}
35
36impl AppRegistry {
37 pub fn load(spaces_root: &Path) -> Self {
38 let path = spaces_root.join("_apps.json");
39 let mut map: HashMap<String, AppEntry> = match std::fs::read_to_string(&path) {
40 Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
41 Err(_) => HashMap::new(),
42 };
43 if let Ok(rd) = std::fs::read_dir(spaces_root) {
46 for entry in rd.filter_map(std::result::Result::ok) {
47 let space_path = entry.path();
48 if !space_path.is_dir() {
49 continue;
50 }
51 let space_name = match space_path.file_name().and_then(|n| n.to_str()) {
52 Some(n) => n.to_string(),
53 None => continue,
54 };
55 let apps_dir = space_path.join("apps");
56 if !apps_dir.is_dir() {
57 continue;
58 }
59 if let Ok(ad) = std::fs::read_dir(&apps_dir) {
60 for app_entry in ad.filter_map(std::result::Result::ok) {
61 if !app_entry.path().is_dir() {
62 continue;
63 }
64 let Ok(app_name) = app_entry.file_name().into_string() else {
65 continue;
66 };
67 let already = map
68 .values()
69 .any(|e| e.space == space_name && e.name == app_name);
70 if !already {
71 let uuid = uuid::Uuid::new_v4().to_string();
72 map.insert(
73 uuid,
74 AppEntry {
75 space: space_name.clone(),
76 name: app_name,
77 served_from: None,
78 },
79 );
80 }
81 }
82 }
83 }
84 }
85 let registry = Self {
86 inner: Arc::new(RwLock::new(map)),
87 path,
88 };
89 let _ = registry.save();
90 registry
91 }
92
93 pub fn assign(&self, space: &str, name: &str) -> String {
94 let uuid = uuid::Uuid::new_v4().to_string();
95 {
96 let mut map = self.inner.write().unwrap();
97 map.insert(
98 uuid.clone(),
99 AppEntry {
100 space: space.to_string(),
101 name: name.to_string(),
102 served_from: None,
103 },
104 );
105 }
106 let _ = self.save();
107 uuid
108 }
109
110 pub fn lookup(&self, uuid: &str) -> Option<AppEntry> {
111 self.inner.read().unwrap().get(uuid).cloned()
112 }
113
114 pub fn resolve(&self, space: &str, name: &str) -> Option<String> {
115 self.inner
116 .read()
117 .unwrap()
118 .iter()
119 .find(|(_, e)| e.space == space && e.name == name)
120 .map(|(u, _)| u.clone())
121 }
122
123 pub fn set_served_from(&self, uuid: &str, subdir: &str) {
126 {
127 let mut map = self.inner.write().unwrap();
128 if let Some(entry) = map.get_mut(uuid) {
129 entry.served_from = Some(subdir.to_string());
130 }
131 }
132 let _ = self.save();
133 }
134
135 pub fn rename_space(&self, old: &str, new: &str) {
136 let mut map = self.inner.write().unwrap();
137 for entry in map.values_mut() {
138 if entry.space == old {
139 entry.space = new.to_string();
140 }
141 }
142 drop(map);
143 let _ = self.save();
144 }
145
146 fn save(&self) -> Result<(), std::io::Error> {
147 let json = serde_json::to_string_pretty(&*self.inner.read().unwrap())?;
148 let tmp = self.path.with_extension("json.tmp");
149 std::fs::write(&tmp, &json)?;
150 std::fs::rename(&tmp, &self.path)?;
151 Ok(())
152 }
153}
154
155#[derive(Clone)]
160pub struct AppServer {
161 port: u16,
162 registry: AppRegistry,
163 public_base: Option<String>,
164}
165
166impl AppServer {
167 pub async fn start(spaces_root: PathBuf) -> Option<Self> {
170 let listener = match TcpListener::bind(("127.0.0.1", PORT)).await {
171 Ok(l) => l,
172 Err(_) => TcpListener::bind(("127.0.0.1", 0)).await.ok()?,
173 };
174 let port = listener.local_addr().ok()?.port();
175 let registry = AppRegistry::load(&spaces_root);
176 let srv = Self {
177 port,
178 registry: registry.clone(),
179 public_base: None,
180 };
181 tokio::spawn(async move {
182 loop {
183 let Ok((stream, _)) = listener.accept().await else {
184 continue;
185 };
186 let root = spaces_root.clone();
187 let reg = registry.clone();
188 tokio::spawn(async move {
189 let _ = handle(stream, &root, ®).await;
190 });
191 }
192 });
193 Some(srv)
194 }
195
196 pub const fn port(&self) -> u16 {
197 self.port
198 }
199
200 pub fn set_public_base(&mut self, base: Option<String>) {
203 self.public_base = base.map(|value| {
204 value
205 .split_once(['?', '#'])
206 .map_or(value.as_str(), |(prefix, _)| prefix)
207 .trim_end_matches('/')
208 .to_string()
209 });
210 }
211
212 pub fn app_url(&self, uuid: &str) -> String {
216 self.public_base.as_ref().map_or_else(
217 || format!("http://127.0.0.1:{}/{}/", self.port(), uuid),
218 |base| public_app_url(base, uuid),
219 )
220 }
221
222 pub fn public_base(&self) -> Option<&str> {
224 self.public_base.as_deref()
225 }
226
227 pub const fn registry(&self) -> &AppRegistry {
228 &self.registry
229 }
230}
231
232async fn handle(
233 mut stream: tokio::net::TcpStream,
234 spaces_root: &Path,
235 registry: &AppRegistry,
236) -> std::io::Result<()> {
237 let mut buf = Vec::with_capacity(1024);
238 let mut chunk = [0u8; 1024];
239 while !buf.windows(4).any(|w| w == b"\r\n\r\n") && buf.len() < 8192 {
240 let n = stream.read(&mut chunk).await?;
241 if n == 0 {
242 break;
243 }
244 buf.extend_from_slice(&chunk[..n]);
245 }
246
247 let Some(header_end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
248 return respond(&mut stream, 400, "text/plain", b"bad request", false).await;
249 };
250 let Ok(header_str) = std::str::from_utf8(&buf[..header_end]) else {
251 return respond(&mut stream, 400, "text/plain", b"bad request", false).await;
252 };
253
254 let request_line = header_str.lines().next().unwrap_or("");
255 let mut parts = request_line.split_whitespace();
256 let method = parts.next().unwrap_or("");
257 let raw_path = parts.next().unwrap_or("/");
258 let head = method == "HEAD";
259
260 if method == "OPTIONS" {
261 return respond(&mut stream, 204, "text/plain", b"", false).await;
262 }
263
264 let content_length = parse_header_value(header_str, "content-length")
265 .and_then(|v| v.parse::<usize>().ok())
266 .unwrap_or(0);
267
268 if content_length > 10 * 1024 * 1024 {
269 return respond(
270 &mut stream,
271 413,
272 "text/plain",
273 b"request entity too large",
274 false,
275 )
276 .await;
277 }
278
279 let content_type = parse_header_value(header_str, "content-type")
280 .unwrap_or("")
281 .to_string();
282
283 let body: Vec<u8> = if content_length > 0 {
284 let body_start = header_end + 4;
285 let in_buf = &buf[body_start..];
286 let already = in_buf.len().min(content_length);
287 let mut body = in_buf[..already].to_vec();
288 if already < content_length {
289 body.resize(content_length, 0);
290 stream.read_exact(&mut body[already..]).await?;
291 }
292 body
293 } else {
294 Vec::new()
295 };
296
297 if raw_path.contains("/_api/") {
298 return handle_api(
299 &mut stream,
300 spaces_root,
301 registry,
302 method,
303 raw_path,
304 &body,
305 &content_type,
306 )
307 .await;
308 }
309
310 if method != "GET" && !head {
311 return respond(&mut stream, 405, "text/plain", b"method not allowed", head).await;
312 }
313
314 match resolve(spaces_root, registry, raw_path) {
315 Some(file) => {
316 let mime = mime_for(&file);
317 match tokio::fs::read(&file).await {
318 Ok(b) => respond(&mut stream, 200, mime, &b, head).await,
319 Err(_) => respond(&mut stream, 404, "text/plain", b"not found", head).await,
320 }
321 }
322 None => respond(&mut stream, 404, "text/plain", b"not found", head).await,
323 }
324}
325
326fn resolve(spaces_root: &Path, registry: &AppRegistry, raw_path: &str) -> Option<PathBuf> {
330 let path = raw_path.split(['?', '#']).next().unwrap_or("");
331 let decoded = percent_decode(path);
332 let segs: Vec<&str> = decoded.split('/').filter(|s| !s.is_empty()).collect();
333 if segs.is_empty() {
334 return None;
335 }
336
337 for seg in &segs {
338 if *seg == ".." || *seg == "." || seg.contains('\\') {
339 return None;
340 }
341 }
342
343 if segs.len() >= 2 && segs[1] == "_api" {
345 let entry = registry.lookup(segs[0])?;
346 let app_dir = spaces_root
347 .join(&entry.space)
348 .join("apps")
349 .join(&entry.name);
350 return app_dir.is_dir().then_some(app_dir);
351 }
352
353 let (space, app, path_start, served_from) = if let Some(entry) = registry.lookup(segs[0]) {
354 (entry.space, entry.name, 1usize, entry.served_from)
355 } else {
356 if segs.len() < 2 {
357 return None;
358 }
359 let served_from = registry
360 .resolve(segs[0], segs[1])
361 .and_then(|uuid| registry.lookup(&uuid))
362 .and_then(|entry| entry.served_from);
363 (
364 segs[0].to_string(),
365 segs[1].to_string(),
366 2usize,
367 served_from,
368 )
369 };
370 let mut file = spaces_root.join(&space).join("apps").join(&app);
371 if let Some(sub) = served_from {
372 let root_data = segs
376 .get(path_start)
377 .is_some_and(|s| matches!(*s, "_images" | "_uploads"));
378 if !root_data {
379 file.push(sub);
380 }
381 }
382 for seg in &segs[path_start..] {
383 file.push(seg);
384 }
385 if file.is_dir() || path_start >= segs.len() {
386 file.push("index.html");
387 }
388 let canon = file.canonicalize().ok()?;
391 let root = spaces_root.canonicalize().ok()?;
392 canon.starts_with(&root).then_some(canon)
393}
394
395async fn respond(
396 stream: &mut tokio::net::TcpStream,
397 status: u16,
398 mime: &str,
399 body: &[u8],
400 head: bool,
401) -> std::io::Result<()> {
402 let reason = match status {
403 200 => "OK",
404 204 => "No Content",
405 400 => "Bad Request",
406 405 => "Method Not Allowed",
407 413 => "Request Entity Too Large",
408 501 => "Not Implemented",
409 _ => "Not Found",
410 };
411 let mut header = format!(
412 "HTTP/1.1 {status} {reason}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n",
413 body.len(),
414 );
415 if mime.starts_with("application/json") || status != 200 {
416 header.push_str("Access-Control-Allow-Origin: *\r\n");
417 }
418 header.push_str("\r\n");
419 stream.write_all(header.as_bytes()).await?;
420 if !head && !body.is_empty() {
421 stream.write_all(body).await?;
422 }
423 stream.shutdown().await
424}
425
426pub(crate) fn public_app_url(base: &str, uuid: &str) -> String {
430 let base = base
431 .split_once(['?', '#'])
432 .map_or(base, |(prefix, _)| prefix)
433 .trim_end_matches('/');
434 format!("{base}/apps/{uuid}/")
435}
436
437fn parse_header_value<'a>(headers: &'a str, name: &str) -> Option<&'a str> {
438 for line in headers.lines().skip(1) {
439 if let Some(pos) = line.find(':') {
440 let key = line[..pos].trim();
441 if key.eq_ignore_ascii_case(name) {
442 return Some(line[pos + 1..].trim());
443 }
444 }
445 }
446 None
447}
448
449async fn handle_api(
450 stream: &mut tokio::net::TcpStream,
451 spaces_root: &Path,
452 registry: &AppRegistry,
453 method: &str,
454 raw_path: &str,
455 body: &[u8],
456 content_type: &str,
457) -> std::io::Result<()> {
458 let path = raw_path.split(['?', '#']).next().unwrap_or("");
459 let decoded = percent_decode(path);
460 let segs: Vec<&str> = decoded.split('/').filter(|s| !s.is_empty()).collect();
461 if segs.len() < 3 || segs[1] != "_api" {
462 return respond(stream, 404, "text/plain", b"not found", false).await;
463 }
464 let Some(entry) = registry.lookup(segs[0]) else {
465 return respond(stream, 404, "text/plain", b"unknown app", false).await;
466 };
467 let app_dir = spaces_root
468 .join(&entry.space)
469 .join("apps")
470 .join(&entry.name);
471 if !app_dir.is_dir() {
472 return respond(stream, 404, "text/plain", b"app not found on disk", false).await;
473 }
474
475 match segs.get(2).copied() {
476 Some("kv") => handle_kv(stream, &app_dir, method, &segs[3..], body).await,
477 Some("upload") if method == "POST" => {
478 handle_upload(stream, &app_dir, segs[0], body, content_type).await
479 }
480 _ => respond(stream, 404, "text/plain", b"unknown api endpoint", false).await,
481 }
482}
483
484async fn handle_kv(
485 stream: &mut tokio::net::TcpStream,
486 app_dir: &Path,
487 method: &str,
488 segs: &[&str],
489 body: &[u8],
490) -> std::io::Result<()> {
491 let db_path = app_dir.join("_store.db");
492 let (status, mime, body_bytes) = kv_op(&db_path, method, segs, body);
493 respond(stream, status, mime, &body_bytes, false).await
494}
495
496fn kv_op(db_path: &Path, method: &str, segs: &[&str], body: &[u8]) -> (u16, &'static str, Vec<u8>) {
497 let conn = match rusqlite::Connection::open(db_path) {
498 Ok(c) => {
499 let _ =
500 c.execute_batch("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)");
501 c
502 }
503 Err(e) => return (500, "text/plain", format!("db error: {e}").into_bytes()),
504 };
505
506 match method {
507 "GET" if segs.is_empty() => {
508 let keys: Vec<String> = match conn.prepare("SELECT key FROM kv ORDER BY key") {
509 Ok(mut stmt) => match stmt.query_map([], |r| r.get::<_, String>(0)) {
510 Ok(rows) => rows.filter_map(std::result::Result::ok).collect(),
511 Err(e) => return (500, "text/plain", format!("query error: {e}").into_bytes()),
512 },
513 Err(e) => return (500, "text/plain", format!("query error: {e}").into_bytes()),
514 };
515 let json = serde_json::to_string(&keys).unwrap_or_else(|_| "[]".to_string());
516 (200, "application/json", json.into_bytes())
517 }
518 "GET" => {
519 let key = percent_decode(segs.join("/").as_str());
520 match conn.query_row("SELECT value FROM kv WHERE key = ?1", [&key], |r| {
521 r.get::<_, String>(0)
522 }) {
523 Ok(value) => (200, "text/plain", value.into_bytes()),
524 Err(rusqlite::Error::QueryReturnedNoRows) => {
525 (404, "text/plain", b"not found".to_vec())
526 }
527 Err(e) => (500, "text/plain", format!("read error: {e}").into_bytes()),
528 }
529 }
530 "PUT" => {
531 let key = percent_decode(segs.join("/").as_str());
532 let value = std::str::from_utf8(body).unwrap_or("");
533 match conn.execute(
534 "INSERT OR REPLACE INTO kv (key, value) VALUES (?1, ?2)",
535 rusqlite::params![key, value],
536 ) {
537 Ok(_) => (200, "text/plain", b"ok".to_vec()),
538 Err(e) => (500, "text/plain", format!("write error: {e}").into_bytes()),
539 }
540 }
541 "DELETE" => {
542 let key = percent_decode(segs.join("/").as_str());
543 match conn.execute("DELETE FROM kv WHERE key = ?1", [key]) {
544 Ok(0) => (404, "text/plain", b"not found".to_vec()),
545 Ok(_) => (200, "text/plain", b"deleted".to_vec()),
546 Err(e) => (500, "text/plain", format!("delete error: {e}").into_bytes()),
547 }
548 }
549 _ => (405, "text/plain", b"method not allowed".to_vec()),
550 }
551}
552
553async fn handle_upload(
554 stream: &mut tokio::net::TcpStream,
555 app_dir: &Path,
556 app_uuid: &str,
557 body: &[u8],
558 content_type: &str,
559) -> std::io::Result<()> {
560 let boundary = match content_type
561 .split(';')
562 .find_map(|p| p.trim().strip_prefix("boundary="))
563 {
564 Some(b) => b.trim_matches('"').to_string(),
565 None => {
566 return respond(
567 stream,
568 400,
569 "text/plain",
570 b"missing boundary in Content-Type",
571 false,
572 )
573 .await;
574 }
575 };
576 if boundary.is_empty() {
577 return respond(stream, 400, "text/plain", b"empty boundary", false).await;
578 }
579
580 let Ok(body_str) = std::str::from_utf8(body) else {
581 return respond(
582 stream,
583 400,
584 "text/plain",
585 b"upload body is not valid UTF-8",
586 false,
587 )
588 .await;
589 };
590
591 let part_header = format!("--{boundary}\r\n");
592 let part_end = format!("\r\n--{boundary}");
593
594 let Some(header_start) = body_str.find(&part_header) else {
595 return respond(stream, 400, "text/plain", b"no multipart part found", false).await;
596 };
597 let after_header_marker = header_start + part_header.len();
598
599 let Some(hdr_body_sep) = body_str[after_header_marker..].find("\r\n\r\n") else {
600 return respond(
601 stream,
602 400,
603 "text/plain",
604 b"malformed multipart part: no header-body separator",
605 false,
606 )
607 .await;
608 };
609 let hdr_end = after_header_marker + hdr_body_sep;
610 let part_headers = &body_str[after_header_marker..hdr_end];
611
612 let filename = part_headers
613 .split(';')
614 .find_map(|p| p.trim().strip_prefix("filename="))
615 .map_or_else(
616 || "upload.bin".to_string(),
617 |f| f.trim_matches('"').to_string(),
618 );
619
620 let content_start = hdr_end + 4;
621 let content_end = body_str[content_start..]
622 .find(&part_end)
623 .map_or(body_str.len(), |d| content_start + d);
624
625 let mut file_body = &body[content_start..content_end];
626 if file_body.ends_with(b"\r\n") {
627 file_body = &file_body[..file_body.len() - 2];
628 }
629
630 let ext = std::path::Path::new(&filename)
631 .extension()
632 .and_then(|e| e.to_str())
633 .map(str::to_lowercase)
634 .filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric()))
635 .unwrap_or_else(|| "bin".to_string());
636
637 let uploads_dir = app_dir.join("_uploads");
638 if let Err(e) = std::fs::create_dir_all(&uploads_dir) {
639 return respond(
640 stream,
641 500,
642 "text/plain",
643 format!("cannot create uploads dir: {e}").as_bytes(),
644 false,
645 )
646 .await;
647 }
648
649 let file_id = uuid::Uuid::new_v4().to_string();
650 let save_path = uploads_dir.join(format!("{file_id}.{ext}"));
651 if let Err(e) = std::fs::write(&save_path, file_body) {
652 return respond(
653 stream,
654 500,
655 "text/plain",
656 format!("cannot save upload: {e}").as_bytes(),
657 false,
658 )
659 .await;
660 }
661
662 let url = format!("/{app_uuid}/_uploads/{file_id}.{ext}");
663 let json = serde_json::json!({"name": filename, "url": url});
664 let body_out = serde_json::to_string(&json).unwrap_or_default();
665 respond(stream, 200, "application/json", body_out.as_bytes(), false).await
666}
667
668fn mime_for(path: &Path) -> &'static str {
669 match path
670 .extension()
671 .and_then(|e| e.to_str())
672 .unwrap_or("")
673 .to_lowercase()
674 .as_str()
675 {
676 "html" | "htm" => "text/html; charset=utf-8",
677 "css" => "text/css",
678 "js" | "mjs" => "text/javascript",
679 "json" => "application/json",
680 "png" => "image/png",
681 "jpg" | "jpeg" => "image/jpeg",
682 "gif" => "image/gif",
683 "svg" => "image/svg+xml",
684 "webp" => "image/webp",
685 "ico" => "image/x-icon",
686 "wasm" => "application/wasm",
687 "txt" | "md" => "text/plain; charset=utf-8",
688 _ => "application/octet-stream",
689 }
690}
691
692fn percent_decode(s: &str) -> String {
695 let bytes = s.as_bytes();
696 let mut out = Vec::with_capacity(bytes.len());
697 let mut i = 0;
698 while i < bytes.len() {
699 if bytes[i] == b'%'
700 && i + 2 < bytes.len()
701 && let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16)
702 {
703 out.push(b);
704 i += 3;
705 } else {
706 out.push(bytes[i]);
707 i += 1;
708 }
709 }
710 String::from_utf8_lossy(&out).into_owned()
711}
712
713#[cfg(test)]
715pub(crate) fn encode(seg: &str) -> String {
716 seg.bytes()
717 .map(|b| match b {
718 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
719 (b as char).to_string()
720 }
721 _ => format!("%{b:02X}"),
722 })
723 .collect()
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 fn setup() -> PathBuf {
731 let tmp = std::env::temp_dir().join(format!("nexus-appserver-{}", uuid::Uuid::new_v4()));
732 let app = tmp.join("default").join("apps").join("deck");
733 std::fs::create_dir_all(&app).unwrap();
734 std::fs::write(app.join("index.html"), "<h1>slides</h1>").unwrap();
735 std::fs::write(app.join("style.css"), "h1{color:red}").unwrap();
736 tmp
737 }
738
739 #[test]
740 fn resolve_maps_and_guards() {
741 let root = &setup();
742 let reg = AppRegistry::load(root);
743 let f = resolve(root, ®, "/default/deck/style.css").unwrap();
744 assert!(f.ends_with("default/apps/deck/style.css"));
745 let f = resolve(root, ®, "/default/deck/").unwrap();
747 assert!(f.ends_with("deck/index.html"));
748 let f = resolve(root, ®, "/default/deck").unwrap();
749 assert!(f.ends_with("deck/index.html"));
750 assert!(resolve(root, ®, "/default/deck/../../secret").is_none());
752 assert!(resolve(root, ®, "/default/%2e%2e/apps/deck/index.html").is_none());
753 assert!(resolve(root, ®, "/default").is_none());
754 assert!(resolve(root, ®, "/default/deck/missing.js").is_none());
755 }
756
757 #[test]
758 fn resolve_uuid() {
759 let root = &setup();
760 let reg = AppRegistry::load(root);
761 let uuid = reg.assign("default", "deck");
762
763 let f = resolve(root, ®, &format!("/{uuid}/style.css")).unwrap();
764 assert!(f.ends_with("default/apps/deck/style.css"));
765
766 let f = resolve(root, ®, &format!("/{uuid}/")).unwrap();
767 assert!(f.ends_with("deck/index.html"));
768
769 let f = resolve(root, ®, &format!("/{uuid}")).unwrap();
770 assert!(f.ends_with("deck/index.html"));
771 }
772
773 #[test]
774 fn resolve_uses_served_from_after_build() {
775 let root = &setup();
776 let dist = root.join("default/apps/deck/dist");
778 std::fs::create_dir_all(&dist).unwrap();
779 std::fs::write(dist.join("index.html"), "<h1>built</h1>").unwrap();
780 let reg = AppRegistry::load(root);
781 let uuid = reg.resolve("default", "deck").unwrap();
784 reg.set_served_from(&uuid, "dist");
785
786 let f = resolve(root, ®, &format!("/{uuid}/")).unwrap();
787 assert!(f.ends_with("deck/dist/index.html"), "{f:?}");
788 let f = resolve(root, ®, "/default/deck/").unwrap();
789 assert!(f.ends_with("deck/dist/index.html"), "{f:?}");
790 assert!(resolve(root, ®, "/default/deck/style.css").is_none());
792 assert!(resolve(root, ®, &format!("/{uuid}/_api/")).is_some());
794 let images = root.join("default/apps/deck/_images");
797 std::fs::create_dir_all(&images).unwrap();
798 std::fs::write(images.join("pic.png"), "x").unwrap();
799 let uploads = root.join("default/apps/deck/_uploads");
800 std::fs::create_dir_all(&uploads).unwrap();
801 std::fs::write(uploads.join("f.txt"), "x").unwrap();
802 assert!(resolve(root, ®, &format!("/{uuid}/_images/pic.png")).is_some());
803 assert!(resolve(root, ®, "/default/deck/_images/pic.png").is_some());
804 assert!(resolve(root, ®, &format!("/{uuid}/_uploads/f.txt")).is_some());
805 }
806
807 #[test]
808 fn served_from_persists_across_registry_reloads() {
809 let root = &setup();
810 let reg = AppRegistry::load(root);
811 let uuid = reg.assign("default", "deck");
812 reg.set_served_from(&uuid, "dist");
813 let reloaded = AppRegistry::load(root);
814 assert_eq!(
815 reloaded.lookup(&uuid).unwrap().served_from.as_deref(),
816 Some("dist")
817 );
818 let classic = reg.assign("default", "plain");
820 let reloaded = AppRegistry::load(root);
821 assert_eq!(reloaded.lookup(&classic).unwrap().served_from, None);
822 }
823
824 #[test]
825 fn resolve_api() {
826 let root = &setup();
827 let reg = AppRegistry::load(root);
828 let uuid = reg.assign("default", "deck");
829
830 let f = resolve(root, ®, &format!("/{uuid}/_api/"));
832 assert!(f.is_some());
833 }
834
835 #[test]
836 fn percent_roundtrip() {
837 assert_eq!(percent_decode("my%20space/deck"), "my space/deck");
838 assert_eq!(encode("my space"), "my%20space");
839 assert_eq!(percent_decode("100%"), "100%");
840 }
841
842 #[tokio::test]
843 async fn serves_files_with_mime_404_and_no_store() {
844 let srv = AppServer::start(setup()).await.unwrap();
845 let base = format!("http://127.0.0.1:{}", srv.port());
846 let c = reqwest::Client::new();
847
848 let r = c.get(format!("{base}/default/deck/")).send().await.unwrap();
849 assert_eq!(r.status(), 200);
850 assert_eq!(r.headers()["content-type"], "text/html; charset=utf-8");
851 assert_eq!(r.headers()["cache-control"], "no-store");
852 assert_eq!(r.text().await.unwrap(), "<h1>slides</h1>");
853
854 let r = c
855 .get(format!("{base}/default/deck/style.css"))
856 .send()
857 .await
858 .unwrap();
859 assert_eq!(r.headers()["content-type"], "text/css");
860
861 let r = c
862 .get(format!("{base}/default/deck/nope.js"))
863 .send()
864 .await
865 .unwrap();
866 assert_eq!(r.status(), 404);
867
868 let r = c
869 .post(format!("{base}/default/deck/"))
870 .send()
871 .await
872 .unwrap();
873 assert_eq!(r.status(), 405);
874 }
875
876 #[test]
877 fn app_url_format() {
878 let reg = AppRegistry::load(&PathBuf::from("/tmp"));
879 let s = AppServer {
880 port: 9999,
881 registry: reg.clone(),
882 public_base: None,
883 };
884 assert_eq!(s.app_url("some-uuid"), "http://127.0.0.1:9999/some-uuid/");
885
886 let mut public = AppServer {
887 port: 9999,
888 registry: reg,
889 public_base: None,
890 };
891 public.set_public_base(Some("https://hub.example.test?token=abc".into()));
892 assert_eq!(
893 public.app_url("some-uuid"),
894 "https://hub.example.test/apps/some-uuid/"
895 );
896 }
897
898 #[tokio::test]
899 async fn options_request_returns_204_with_cors() {
900 let srv = AppServer::start(setup()).await.unwrap();
901 let base = format!("http://127.0.0.1:{}", srv.port());
902 let c = reqwest::Client::new();
903
904 let r = c
905 .request(reqwest::Method::OPTIONS, format!("{base}/default/deck/"))
906 .send()
907 .await
908 .unwrap();
909 assert_eq!(r.status(), 204);
910 assert_eq!(r.headers()["access-control-allow-origin"], "*");
911 }
912
913 #[tokio::test]
914 async fn put_outside_api_is_405() {
915 let srv = AppServer::start(setup()).await.unwrap();
916 let base = format!("http://127.0.0.1:{}", srv.port());
917 let c = reqwest::Client::new();
918
919 let r = c
920 .put(format!("{base}/default/deck/index.html"))
921 .body("new content")
922 .send()
923 .await
924 .unwrap();
925 assert_eq!(r.status(), 405);
926 }
927
928 #[tokio::test]
929 async fn api_methods_routed_to_handle_api() {
930 let srv = AppServer::start(setup()).await.unwrap();
931 let uuid = srv.registry().assign("default", "deck");
932 let base = format!("http://127.0.0.1:{}", srv.port());
933 let c = reqwest::Client::new();
934
935 let r = c
936 .post(format!("{base}/{uuid}/_api/upload"))
937 .send()
938 .await
939 .unwrap();
940 assert_eq!(r.status(), 400);
941 }
942
943 #[tokio::test]
944 async fn kv_roundtrip() {
945 let srv = AppServer::start(setup()).await.unwrap();
946 let uuid = srv.registry().assign("default", "deck");
947 let base = format!("http://127.0.0.1:{}", srv.port());
948 let c = reqwest::Client::new();
949
950 let r = c
952 .put(format!("{base}/{uuid}/_api/kv/hello"))
953 .body("world")
954 .send()
955 .await
956 .unwrap();
957 assert_eq!(r.status(), 200);
958
959 let r = c
961 .get(format!("{base}/{uuid}/_api/kv/hello"))
962 .send()
963 .await
964 .unwrap();
965 assert_eq!(r.status(), 200);
966 assert_eq!(r.text().await.unwrap(), "world");
967
968 let r = c
970 .delete(format!("{base}/{uuid}/_api/kv/hello"))
971 .send()
972 .await
973 .unwrap();
974 assert_eq!(r.status(), 200);
975
976 let r = c
978 .get(format!("{base}/{uuid}/_api/kv/hello"))
979 .send()
980 .await
981 .unwrap();
982 assert_eq!(r.status(), 404);
983 }
984
985 #[tokio::test]
986 async fn kv_list_keys() {
987 let srv = AppServer::start(setup()).await.unwrap();
988 let uuid = srv.registry().assign("default", "deck");
989 let base = format!("http://127.0.0.1:{}", srv.port());
990 let c = reqwest::Client::new();
991
992 c.put(format!("{base}/{uuid}/_api/kv/a"))
993 .body("1")
994 .send()
995 .await
996 .unwrap();
997 c.put(format!("{base}/{uuid}/_api/kv/b"))
998 .body("2")
999 .send()
1000 .await
1001 .unwrap();
1002 c.put(format!("{base}/{uuid}/_api/kv/c"))
1003 .body("3")
1004 .send()
1005 .await
1006 .unwrap();
1007
1008 let r = c
1009 .get(format!("{base}/{uuid}/_api/kv"))
1010 .send()
1011 .await
1012 .unwrap();
1013 assert_eq!(r.status(), 200);
1014 let keys: Vec<String> = r.json().await.unwrap();
1015 assert_eq!(keys, vec!["a", "b", "c"]);
1016 }
1017
1018 #[tokio::test]
1019 async fn file_upload_and_serve() {
1020 let srv = AppServer::start(setup()).await.unwrap();
1021 let uuid = srv.registry().assign("default", "deck");
1022 let c = reqwest::Client::new();
1023 let base = format!("http://127.0.0.1:{}", srv.port());
1024
1025 let boundary = "----TestBoundary123";
1026 let body = format!(
1027 "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\n\r\nHello World!\r\n--{boundary}--\r\n"
1028 );
1029 let r = c
1030 .post(format!("{base}/{uuid}/_api/upload"))
1031 .header(
1032 "Content-Type",
1033 format!("multipart/form-data; boundary={boundary}"),
1034 )
1035 .body(body)
1036 .send()
1037 .await
1038 .unwrap();
1039 assert_eq!(r.status(), 200);
1040 let resp: serde_json::Value = r.json().await.unwrap();
1041 let url = resp["url"].as_str().unwrap().to_string();
1042 assert!(url.starts_with(&format!("/{uuid}/_uploads/")), "url: {url}");
1043 assert!(
1044 std::path::Path::new(&url)
1045 .extension()
1046 .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")),
1047 "url: {url}"
1048 );
1049
1050 let r = c.get(format!("{base}{url}")).send().await.unwrap();
1051 assert_eq!(r.status(), 200);
1052 assert_eq!(r.text().await.unwrap(), "Hello World!");
1053 }
1054}