|
|
@@ -0,0 +1,247 @@
|
|
|
+//! Repo file browser behind `/repo/...` — the target of the viewer's toml
|
|
|
+//! links: build dirs under `docker-images/` (Dockerfile, versions.env,
|
|
|
+//! profiles, patches), built-in `host-scripts/`, and recipe-local files
|
|
|
+//! under `recipes/`. The root is the process cwd (the launcher runs from the
|
|
|
+//! repo root); paths are plain names, no traversal, top level restricted.
|
|
|
+//!
|
|
|
+//! `toml_links` scans a recipe.toml for the repo-referencing values and
|
|
|
+//! builds the `{value, href, title}` list the viewer wraps into anchors.
|
|
|
+
|
|
|
+use super::serve::AppState;
|
|
|
+use axum::{extract::Path, response::Response};
|
|
|
+
|
|
|
+fn repo_root_ok() -> Option<std::path::PathBuf> {
|
|
|
+ let cwd = std::env::current_dir().ok()?;
|
|
|
+ (cwd.join("docker-images").is_dir() || cwd.join("host-scripts").is_dir()).then_some(cwd)
|
|
|
+}
|
|
|
+
|
|
|
+fn plain_seg(s: &str) -> bool {
|
|
|
+ !s.contains("..")
|
|
|
+ && s.chars()
|
|
|
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
|
|
+}
|
|
|
+
|
|
|
+fn repo_resolve(path: &str) -> Option<std::path::PathBuf> {
|
|
|
+ let root = repo_root_ok()?;
|
|
|
+ let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
|
|
+ let first = segs.first().copied()?;
|
|
|
+ if !matches!(first, "docker-images" | "host-scripts" | "recipes") {
|
|
|
+ return None;
|
|
|
+ }
|
|
|
+ if !segs.iter().all(|s| plain_seg(s)) {
|
|
|
+ return None;
|
|
|
+ }
|
|
|
+ Some(segs.iter().fold(root, |p, s| p.join(s)))
|
|
|
+}
|
|
|
+
|
|
|
+fn dir_listing(base: &str, name: &str, p: &std::path::Path) -> String {
|
|
|
+ let mut items: Vec<(String, bool, u64)> = Vec::new();
|
|
|
+ if let Ok(rd) = std::fs::read_dir(p) {
|
|
|
+ for e in rd.flatten() {
|
|
|
+ let n = e.file_name().to_string_lossy().to_string();
|
|
|
+ let (dir, size) = if e.path().is_dir() {
|
|
|
+ (true, 0)
|
|
|
+ } else {
|
|
|
+ (false, e.metadata().map(|m| m.len()).unwrap_or(0))
|
|
|
+ };
|
|
|
+ items.push((n, dir, size));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ items.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
|
|
+ let rows: Vec<String> = items
|
|
|
+ .iter()
|
|
|
+ .map(|(n, dir, size)| {
|
|
|
+ let cls = if *dir { "d" } else { "f" };
|
|
|
+ let sz = if *dir {
|
|
|
+ String::from("—")
|
|
|
+ } else if *size >= 1_048_576 {
|
|
|
+ format!("{:.1} MB", (*size as f64) / 1_048_576.0)
|
|
|
+ } else if *size >= 1024 {
|
|
|
+ format!("{} KB", size / 1024)
|
|
|
+ } else {
|
|
|
+ format!("{} B", size)
|
|
|
+ };
|
|
|
+ let url = if *dir {
|
|
|
+ format!("{base}repo/{n}/")
|
|
|
+ } else {
|
|
|
+ format!("{base}repo/{n}")
|
|
|
+ };
|
|
|
+ format!(
|
|
|
+ "<tr><td class=\"{cls}\">{n}</td><td class=\"sz\">{sz}</td><td><a href=\"{url}\">open</a></td></tr>"
|
|
|
+ )
|
|
|
+ })
|
|
|
+ .collect();
|
|
|
+ let esc = |s: &str| s.replace('&', "&").replace('<', "<");
|
|
|
+ format!(
|
|
|
+ "<!doctype html><meta charset=\"utf-8\"><title>{name}</title>\n\
|
|
|
+ <style>body{{font:13px/1.5 system-ui;margin:18px;max-width:720px}}table{{border-collapse:collapse}}td,th{{border-bottom:1px solid #8884;padding:3px 10px;text-align:left}}td.d{{font-weight:600}}td.sz{{opacity:.6}}a{{color:#3b82f6;text-decoration:none}}a:hover{{text-decoration:underline}}.crumb{{margin-bottom:10px;font-size:12px;opacity:.7}}</style>\n\
|
|
|
+ <div class=\"crumb\">{name}</div>\n\
|
|
|
+ <table><tr><th>name</th><th>size</th><th></th></tr>{rows}</table>\n",
|
|
|
+ name = esc(name),
|
|
|
+ rows = rows.join(""),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/// `GET /repo/{...}` — directory listing page, or the file rendered through
|
|
|
+/// the shared viewer (same highlighting/copy as `/r/...`).
|
|
|
+pub(crate) async fn repo_route(
|
|
|
+ axum::extract::State(state): axum::extract::State<AppState>,
|
|
|
+ Path(path): Path<String>,
|
|
|
+) -> Response {
|
|
|
+ use axum::http::StatusCode;
|
|
|
+ use axum::response::IntoResponse;
|
|
|
+ let p = match repo_resolve(&path) {
|
|
|
+ Some(p) => p,
|
|
|
+ None => {
|
|
|
+ return (
|
|
|
+ StatusCode::NOT_FOUND,
|
|
|
+ axum::response::Html(
|
|
|
+ "<!doctype html><meta charset=\"utf-8\">repo path not allowed".to_string(),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ .into_response();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ if p.is_dir() {
|
|
|
+ let name = p
|
|
|
+ .components()
|
|
|
+ .rev()
|
|
|
+ .map(|c| c.as_os_str().to_string_lossy().to_string())
|
|
|
+ .collect::<Vec<_>>()
|
|
|
+ .join("/");
|
|
|
+ return axum::response::Html(dir_listing(&state.base, &name, &p)).into_response();
|
|
|
+ }
|
|
|
+ let text = match std::fs::read_to_string(&p) {
|
|
|
+ Ok(t) => t,
|
|
|
+ Err(_) => {
|
|
|
+ return (
|
|
|
+ StatusCode::NOT_FOUND,
|
|
|
+ axum::response::Html(
|
|
|
+ "<!doctype html><meta charset=\"utf-8\">file not found".to_string(),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ .into_response();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ let clean = crate::ansi::strip_ansi(text.as_bytes());
|
|
|
+ let clean = String::from_utf8_lossy(&clean)
|
|
|
+ .replace("\r\n", "\n")
|
|
|
+ .replace('\r', "\n");
|
|
|
+ let meta = serde_json::json!({
|
|
|
+ "recipe": "",
|
|
|
+ "file": path,
|
|
|
+ "content": clean,
|
|
|
+ "links": serde_json::Value::Null,
|
|
|
+ });
|
|
|
+ let meta = serde_json::to_string(&meta)
|
|
|
+ .unwrap_or_default()
|
|
|
+ .replace('<', "\\u003c");
|
|
|
+ axum::response::Html(super::serve::VIEW_TEMPLATE.replace("{{META}}", &meta)).into_response()
|
|
|
+}
|
|
|
+
|
|
|
+/// Pull the first quoted value of a `key = "…"` line (a bare top-level key
|
|
|
+/// at line start, or `key = "…"` inside a `{ … }` array entry); comments and
|
|
|
+/// non-key occurrences (the key must be preceded only by whitespace or the
|
|
|
+/// array-entry brace) don't match.
|
|
|
+fn quoted_value(line: &str, key: &str) -> Option<String> {
|
|
|
+ let t = line.trim_start();
|
|
|
+ if t.starts_with('#') {
|
|
|
+ return None;
|
|
|
+ }
|
|
|
+ let kpos = t.find(key)?;
|
|
|
+ if !t[..kpos].bytes().all(|b| b == b' ' || b == b'{') {
|
|
|
+ return None;
|
|
|
+ }
|
|
|
+ let rest = t[kpos + key.len()..].trim_start();
|
|
|
+ let rest = rest.strip_prefix('=')?;
|
|
|
+ let s = rest.trim_start();
|
|
|
+ let s = s.strip_prefix('"')?;
|
|
|
+ let end = s.find('"')?;
|
|
|
+ Some(s[..end].to_string())
|
|
|
+}
|
|
|
+
|
|
|
+/// Scan a recipe.toml for repo-referencing values and turn them into viewer
|
|
|
+/// links: `docker_image_dir` → the build dir listing, `build_profile` → the
|
|
|
+/// profile file inside it (image dir defaults to the recipe name),
|
|
|
+/// `venv_setup_script` → the recipe-local script, and every `script = "…"`
|
|
|
+/// entry (built-in alias → `host-scripts/`, other → recipe-local). The
|
|
|
+/// viewer matches each value as a whole quoted string client-side.
|
|
|
+pub(crate) fn toml_links(content: &str, recipe: &str, base: &str) -> Vec<serde_json::Value> {
|
|
|
+ let first = |key: &str| -> Option<String> {
|
|
|
+ content.lines().filter_map(|l| quoted_value(l, key)).next()
|
|
|
+ };
|
|
|
+ let mut out: Vec<(String, String, String)> = Vec::new();
|
|
|
+ if let Some(v) = first("docker_image_dir") {
|
|
|
+ out.push((
|
|
|
+ v.clone(),
|
|
|
+ format!("{base}repo/docker-images/{v}/"),
|
|
|
+ format!("build dir: docker-images/{v}"),
|
|
|
+ ));
|
|
|
+ }
|
|
|
+ let img = first("docker_image_dir").unwrap_or_else(|| recipe.to_string());
|
|
|
+ if let Some(prof) = first("build_profile") {
|
|
|
+ out.push((
|
|
|
+ prof.clone(),
|
|
|
+ format!("{base}repo/docker-images/{img}/profiles/{prof}.env"),
|
|
|
+ format!("build profile: profiles/{prof}.env"),
|
|
|
+ ));
|
|
|
+ }
|
|
|
+ if let Some(script) = first("venv_setup_script") {
|
|
|
+ out.push((
|
|
|
+ script.clone(),
|
|
|
+ format!("{base}repo/recipes/{recipe}/{script}"),
|
|
|
+ format!("venv setup script: recipes/{recipe}/{script}"),
|
|
|
+ ));
|
|
|
+ }
|
|
|
+ for alias in content.lines().filter_map(|l| quoted_value(l, "script")) {
|
|
|
+ let is_builtin = crate::builtin_scripts::resolve(&alias).is_some();
|
|
|
+ let (href, title) = if is_builtin {
|
|
|
+ let file = alias.replace('_', "-") + ".sh";
|
|
|
+ (
|
|
|
+ format!("{base}repo/host-scripts/{file}"),
|
|
|
+ format!("built-in script: host-scripts/{file}"),
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ (
|
|
|
+ format!("{base}repo/recipes/{recipe}/{alias}"),
|
|
|
+ format!("script: recipes/{recipe}/{alias}"),
|
|
|
+ )
|
|
|
+ };
|
|
|
+ if !out.iter().any(|(v, _, _)| v == &alias) {
|
|
|
+ out.push((alias, href, title));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ out.into_iter()
|
|
|
+ .map(|(value, href, title)| {
|
|
|
+ serde_json::json!({ "value": value, "href": href, "title": title })
|
|
|
+ })
|
|
|
+ .collect()
|
|
|
+}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod tests {
|
|
|
+ use super::quoted_value;
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn quoted_value_picks_the_value_not_the_key_mention() {
|
|
|
+ assert_eq!(
|
|
|
+ quoted_value(
|
|
|
+ "docker_image_dir = \"glm52-nf3-hybrid\"",
|
|
|
+ "docker_image_dir"
|
|
|
+ )
|
|
|
+ .as_deref(),
|
|
|
+ Some("glm52-nf3-hybrid")
|
|
|
+ );
|
|
|
+ assert_eq!(
|
|
|
+ quoted_value(" { script = \"stop_dgx_services\" }", "script").as_deref(),
|
|
|
+ Some("stop_dgx_services")
|
|
|
+ );
|
|
|
+ // "venv_setup_script" contains "script" but is not the script key
|
|
|
+ assert_eq!(
|
|
|
+ quoted_value("venv_setup_script = \"venv.sh\"", "script"),
|
|
|
+ None
|
|
|
+ );
|
|
|
+ assert_eq!(quoted_value("# script = \"x\"", "script"), None);
|
|
|
+ assert_eq!(quoted_value("script = unquoted", "script"), None);
|
|
|
+ }
|
|
|
+}
|