use super::{AppState, asset};
use crate::render::escape;
use crate::ui::ISLAND_MARKER;
use axum::Router;
use axum::extract::{Path, State};
use axum::http::{StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use serde_json::{Map, Value as Json};
use std::collections::HashMap;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
const JS: &str = include_str!("../../assets/islands.js");
const LOADER: &str = "/_zeyn/islands.js";
const FILES: &str = "/_zeyn/islands/";
pub(super) const CSP: &str = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
pub(crate) struct Islands {
dir: PathBuf,
files: HashMap<String, &'static str>,
meta: String,
}
impl Islands {
pub(crate) fn load(dir: &FsPath, prefix: &str) -> Result<Islands, String> {
let path = dir.join("manifest.json");
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}; run `zeyn build-islands`", path.display()))?;
let manifest: Json = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let mut files = HashMap::new();
let mut urls = Map::new();
for (name, entry) in manifest["islands"].as_object().into_iter().flatten() {
let (Some(js), Some(wasm)) = (entry["js"].as_str(), entry["wasm"].as_str()) else {
return Err(format!("{}: island `{name}` needs `js` and `wasm`", path.display()));
};
for (file, kind) in [(js, "text/javascript; charset=utf-8"), (wasm, "application/wasm")] {
if !valid_file(file) {
return Err(format!("{}: `{file}` is not a plain file name", path.display()));
}
files.insert(file.to_string(), kind);
}
urls.insert(
name.clone(),
serde_json::json!({ "js": format!("{prefix}{FILES}{js}"), "wasm": format!("{prefix}{FILES}{wasm}") }),
);
}
Ok(Islands { dir: dir.to_path_buf(), files, meta: escape(&Json::Object(urls).to_string()) })
}
}
fn valid_file(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('.')
&& name.len() <= 128
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
pub(super) fn routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {
router
.route(LOADER, get(|| async { asset(JS, "text/javascript; charset=utf-8") }))
.route("/_zeyn/islands/{file}", get(file))
}
pub(super) fn is_asset(path: &str) -> bool {
path == LOADER || path.starts_with(FILES)
}
async fn file(State(st): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
let Some((islands, kind)) = st.islands.as_ref().and_then(|i| i.files.get(&name).map(|k| (i, *k))) else {
return StatusCode::NOT_FOUND.into_response();
};
match tokio::fs::read(islands.dir.join(&name)).await {
Ok(bytes) => {
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=31536000, immutable")], bytes)
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub(super) fn holds(st: &AppState, html: &str) -> bool {
st.islands.is_some() && html.contains(ISLAND_MARKER)
}
pub(super) fn page(st: &AppState, page: String, screen: &str) -> Response {
let Some(islands) = st.islands.as_ref().filter(|_| holds(st, screen)) else {
return Html(page).into_response();
};
let head = format!(
r#"<meta name="zeyn-islands" content="{}"><script type="module" src="{}{LOADER}"></script></head>"#,
islands.meta,
escape(&st.prefix)
);
let mut r = Html(page.replacen("</head>", &head, 1)).into_response();
r.headers_mut().insert(header::CONTENT_SECURITY_POLICY, st.island_csp.clone());
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_names_cannot_escape_the_folder() {
assert!(valid_file("canvas.0123abcd.js"));
assert!(!valid_file("../secret"));
assert!(!valid_file(".env"));
assert!(!valid_file("a/b.js"));
assert!(!valid_file("a\\b.js"));
assert!(!valid_file(""));
}
#[test]
fn load_reads_the_manifest_and_refuses_paths() {
let dir = std::env::temp_dir().join(format!("zeyn-islands-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("manifest.json"), r#"{"islands":{"c":{"js":"c.1.js","wasm":"c_bg.1.wasm"}}}"#).unwrap();
let i = Islands::load(&dir, "/app").unwrap();
assert_eq!(i.files.get("c_bg.1.wasm"), Some(&"application/wasm"));
assert!(i.meta.contains("/app/_zeyn/islands/c.1.js"));
std::fs::write(dir.join("manifest.json"), r#"{"islands":{"c":{"js":"../x.js","wasm":"c.wasm"}}}"#).unwrap();
assert!(Islands::load(&dir, "").is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}