Skip to main content

nexus_core/
appserver.rs

1//! Tiny always-on static file server for model-created apps. Serves
2//! `GET /<space>/<app>/<path…>` from `spaces/<space>/apps/<app>/<path…>`,
3//! localhost only, GET/HEAD only. Hand-rolled on tokio — no framework dep
4//! for ~150 lines of static serving.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, RwLock};
9
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::TcpListener;
12
13/// Preferred fixed port so links stay stable across restarts.
14const PORT: u16 = 8642;
15
16// ---------------------------------------------------------------------------
17// App registry — maps UUID ↔ (space, name), persisted to spaces_root/_apps.json
18// ---------------------------------------------------------------------------
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct AppEntry {
22    pub space: String,
23    pub name: String,
24    /// Subdirectory the app's files are served from (e.g. "dist" after a
25    /// framework build). None = served from the app root (classic apps).
26    #[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        // Scan for orphan apps (app dirs not yet in the registry) and assign
44        // UUIDs so old space-name URLs keep working.
45        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    /// Record that an app's served files live under `subdir` (e.g. "dist")
124    /// after a successful framework build.
125    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// ---------------------------------------------------------------------------
156// App server
157// ---------------------------------------------------------------------------
158
159#[derive(Clone)]
160pub struct AppServer {
161    port: u16,
162    registry: AppRegistry,
163    public_base: Option<String>,
164}
165
166impl AppServer {
167    /// Bind (preferring 8642, falling back to an ephemeral port) and start
168    /// serving `spaces_root` in a background task. None if even `:0` fails.
169    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, &reg).await;
190                });
191            }
192        });
193        Some(srv)
194    }
195
196    pub const fn port(&self) -> u16 {
197        self.port
198    }
199
200    /// Set the externally reachable base URL used in generated app links.
201    /// `None` restores the local URL. The value is trimmed of trailing `/`.
202    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    /// The app URL for a registry UUID. A host/tunnel base uses the public
213    /// capability `/apps/<uuid>/` route; local links keep the historical
214    /// direct app-server shape.
215    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    /// The public base currently used in generated links, if any.
223    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
326/// Map a request path to a file under `spaces_root`. The first path segment
327/// is either a UUID (looked up in the registry) or a space name (with the
328/// second segment as the app name).
329fn 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    // API route: /<uuid>/_api/...
344    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        // User data written to the app root by copy_images (_images/) and
373        // the upload API (_uploads/) stays there even when the app is
374        // served from dist/ after a framework build.
375        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    // Backstop against traversal tricks the segment filter missed: the
389    // canonical path must stay under the canonical spaces root.
390    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
426/// The public host route for an app UUID under a base. Query and fragment
427/// components are intentionally discarded: host bearer credentials never
428/// belong in generated app URLs.
429pub(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
692/// Decode %XX escapes (space names may contain spaces). Invalid escapes pass
693/// through literally.
694fn 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/// Encode a path segment for a URL (space names may contain spaces).
714#[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, &reg, "/default/deck/style.css").unwrap();
744        assert!(f.ends_with("default/apps/deck/style.css"));
745        // dir root → index.html
746        let f = resolve(root, &reg, "/default/deck/").unwrap();
747        assert!(f.ends_with("deck/index.html"));
748        let f = resolve(root, &reg, "/default/deck").unwrap();
749        assert!(f.ends_with("deck/index.html"));
750        // traversal + malformed rejected
751        assert!(resolve(root, &reg, "/default/deck/../../secret").is_none());
752        assert!(resolve(root, &reg, "/default/%2e%2e/apps/deck/index.html").is_none());
753        assert!(resolve(root, &reg, "/default").is_none());
754        assert!(resolve(root, &reg, "/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, &reg, &format!("/{uuid}/style.css")).unwrap();
764        assert!(f.ends_with("default/apps/deck/style.css"));
765
766        let f = resolve(root, &reg, &format!("/{uuid}/")).unwrap();
767        assert!(f.ends_with("deck/index.html"));
768
769        let f = resolve(root, &reg, &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        // Simulate a built app: the real files live in dist/.
777        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        // Use the entry the load-time orphan scan created — assign() would
782        // add a second UUID for the same app (test-only artifact).
783        let uuid = reg.resolve("default", "deck").unwrap();
784        reg.set_served_from(&uuid, "dist");
785
786        let f = resolve(root, &reg, &format!("/{uuid}/")).unwrap();
787        assert!(f.ends_with("deck/dist/index.html"), "{f:?}");
788        let f = resolve(root, &reg, "/default/deck/").unwrap();
789        assert!(f.ends_with("deck/dist/index.html"), "{f:?}");
790        // Source files outside dist are no longer served.
791        assert!(resolve(root, &reg, "/default/deck/style.css").is_none());
792        // The KV API still resolves to the app root.
793        assert!(resolve(root, &reg, &format!("/{uuid}/_api/")).is_some());
794        // copy_images (_images/) and upload (_uploads/) user data stay in
795        // the app root and keep resolving in both URL forms.
796        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, &reg, &format!("/{uuid}/_images/pic.png")).is_some());
803        assert!(resolve(root, &reg, "/default/deck/_images/pic.png").is_some());
804        assert!(resolve(root, &reg, &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        // Classic apps round-trip with served_from unset.
819        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        // API route returns the app directory path
831        let f = resolve(root, &reg, &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        // PUT
951        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        // GET
960        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        // DELETE
969        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        // GET after delete = 404
977        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}