use axum::extract::{Form, State}; use axum::response::{Html, IntoResponse, Redirect, Response}; use serde::Deserialize; use tera::Context; use tracing::warn; use super::common::{insert_mcp_ctx, render, tera_ref, UiError}; use crate::api::AppState; /// Returns `~/.config/ontoref/projects.ncl` path. pub(crate) fn projects_ncl_path() -> std::path::PathBuf { std::env::var_os("HOME") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".config") .join("ontoref") .join("projects.ncl") } /// Read `projects.ncl` and return the set of import paths already registered. pub(crate) fn read_registered_paths(projects_file: &std::path::Path) -> Vec { let Ok(content) = std::fs::read_to_string(projects_file) else { return vec![]; }; content .lines() .filter_map(|l| { let l = l.trim(); if l.starts_with("import ") { let inner = l.trim_start_matches("import").trim(); let inner = inner.trim_matches('"'); Some(inner.to_string()) } else { None } }) .collect() } /// Append a project.ncl import to `projects.ncl`, creating the file if needed. fn persist_project_add( project_root: &std::path::Path, projects_file: &std::path::Path, ) -> std::io::Result<()> { let ncl_path = project_root.join(".ontoref").join("project.ncl"); let ncl_str = ncl_path.to_string_lossy().to_string(); let existing = read_registered_paths(projects_file); if existing.iter().any(|p| p == &ncl_str) { return Ok(()); } let mut all = existing; all.push(ncl_str); write_projects_ncl(projects_file, &all) } /// Remove a project.ncl import from `projects.ncl`. fn persist_project_remove( project_root: &std::path::Path, projects_file: &std::path::Path, ) -> std::io::Result<()> { let ncl_path = project_root.join(".ontoref").join("project.ncl"); let ncl_str = ncl_path.to_string_lossy().to_string(); let remaining: Vec = read_registered_paths(projects_file) .into_iter() .filter(|p| p != &ncl_str) .collect(); write_projects_ncl(projects_file, &remaining) } fn write_projects_ncl(projects_file: &std::path::Path, paths: &[String]) -> std::io::Result<()> { if let Some(parent) = projects_file.parent() { std::fs::create_dir_all(parent)?; } let content = if paths.is_empty() { "# AUTO-GENERATED by ontoref. Do not edit by hand.\n# Add a project: ontoref project-add \ /path/to/project\n\n[]\n" .to_string() } else { let imports = paths .iter() .map(|p| format!(" [import \"{p}\"],")) .collect::>() .join("\n"); format!( "# AUTO-GENERATED by ontoref. Do not edit by hand.\n# Add a project: ontoref \ project-add /path/to/project\n\nstd.array.flatten [\n{imports}\n]\n" ) }; std::fs::write(projects_file, content) } #[derive(Deserialize)] pub struct AddProjectForm { pub root: String, } #[derive(Deserialize)] pub struct RemoveProjectForm { pub slug: String, } pub(crate) fn manage_project_json( ctx: &crate::registry::ProjectContext, registered_paths: &[String], ) -> serde_json::Value { let key_count = ctx.keys.read().map(|k| k.len()).unwrap_or(0); let roles: Vec = ctx .keys .read() .map(|keys| { keys.iter() .map(|k| format!("{:?}", k.role).to_lowercase()) .collect() }) .unwrap_or_default(); let ncl_path = ctx.root.join(".ontoref").join("project.ncl"); let in_projects_ncl = registered_paths.contains(&ncl_path.to_string_lossy().to_string()); let opmode = if ctx.push_only { "push" } else { "daemon" }; serde_json::json!({ "slug": ctx.slug, "root": ctx.root.display().to_string(), "auth": ctx.auth_enabled(), "key_count": key_count, "roles": roles, "push_only": ctx.push_only, "opmode": opmode, "in_projects_ncl": in_projects_ncl, }) } async fn registry_add_and_persist( state: &AppState, entry: crate::registry::RegistryEntry, projects_file: &std::path::Path, ) -> anyhow::Result<()> { let root = entry.root.clone(); state.registry.add_project(entry)?; persist_project_add(&root, projects_file) .map_err(|e| anyhow::anyhow!("added to registry but failed to persist: {e}"))?; Ok(()) } fn make_registry_entry_from_root(root: std::path::PathBuf) -> crate::registry::RegistryEntry { crate::registry::RegistryEntry { slug: root .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(), root, nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: false, } } async fn load_registry_entry( state: &AppState, root: std::path::PathBuf, ) -> crate::registry::RegistryEntry { let project_ncl = root.join(".ontoref").join("project.ncl"); if project_ncl.exists() { match state .cache .export(&project_ncl, state.nickel_import_path.as_deref()) .await { Ok((json, _)) => { serde_json::from_value(json).unwrap_or_else(|_| make_registry_entry_from_root(root)) } Err(_) => make_registry_entry_from_root(root), } } else { make_registry_entry_from_root(root) } } /// Guarded variant: requires `AdminGuard` (any admin session or no auth /// configured). pub async fn manage_page_guarded( State(state): State, _guard: super::super::auth::AdminGuard, ) -> Result, UiError> { manage_page(State(state)).await } pub async fn manage_add_guarded( State(state): State, _guard: super::super::auth::AdminGuard, headers: axum::http::HeaderMap, Form(form): Form, ) -> Result { if !headers.contains_key("hx-request") { return manage_add(State(state), Form(form)).await; } let tera = tera_ref(&state)?; let projects_file = projects_ncl_path(); let root = std::path::PathBuf::from(form.root.trim()); let entry = load_registry_entry(&state, root).await; let manage_error = registry_add_and_persist(&state, entry, &projects_file) .await .err() .map(|e| e.to_string()); let registered = read_registered_paths(&projects_file); let projects: Vec = state .registry .all() .into_iter() .map(|ctx| manage_project_json(&ctx, ®istered)) .collect(); let mut ctx = Context::new(); ctx.insert("projects", &projects); ctx.insert("manage_error", &manage_error); render(tera, "partials/manage_projects_section.html", &ctx) .await .map(IntoResponse::into_response) } pub async fn manage_remove_guarded( State(state): State, _guard: super::super::auth::AdminGuard, headers: axum::http::HeaderMap, Form(form): Form, ) -> Result { if !headers.contains_key("hx-request") { return manage_remove(State(state), Form(form)).await; } let registry = &state.registry; let root = registry.get(&form.slug).map(|ctx| ctx.root.clone()); registry.remove_project(&form.slug); if let Some(root) = root { let projects_file = projects_ncl_path(); if let Err(e) = persist_project_remove(&root, &projects_file) { warn!(slug = %form.slug, error = %e, "removed from registry but failed to persist removal"); } } let tera = tera_ref(&state)?; let projects_file = projects_ncl_path(); let registered = read_registered_paths(&projects_file); let projects: Vec = state .registry .all() .into_iter() .map(|ctx| manage_project_json(&ctx, ®istered)) .collect(); let mut ctx = Context::new(); ctx.insert("projects", &projects); ctx.insert("manage_error", &Option::::None); render(tera, "partials/manage_projects_section.html", &ctx) .await .map(IntoResponse::into_response) } pub async fn manage_page(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let projects_file = projects_ncl_path(); let registered = read_registered_paths(&projects_file); let projects: Vec = state .registry .all() .into_iter() .map(|ctx| manage_project_json(&ctx, ®istered)) .collect(); let mut ctx = Context::new(); ctx.insert("projects", &projects); ctx.insert("base_url", "/ui"); ctx.insert("hide_project_nav", &true); ctx.insert("error", &Option::::None); ctx.insert("daemon_admin_enabled", &state.daemon_admin_hash.is_some()); insert_mcp_ctx(&mut ctx, &state.service_flags); render(tera, "pages/manage.html", &ctx).await } /// HTMX endpoint — toggle a runtime service (mcp/graphql) on or off. /// AdminGuard ensures only authenticated daemon admins can call this. pub async fn service_toggle_ui( State(state): State, _guard: super::super::auth::AdminGuard, axum::extract::Path(service): axum::extract::Path, ) -> impl axum::response::IntoResponse { use std::sync::atomic::Ordering; use axum::http::StatusCode; let new_state = state.service_flags.set(&service, { match service.as_str() { #[cfg(feature = "mcp")] "mcp" => !state.service_flags.mcp.load(Ordering::Relaxed), #[cfg(feature = "graphql")] "graphql" => !state.service_flags.graphql.load(Ordering::Relaxed), _ => return (StatusCode::NOT_FOUND, "unknown service").into_response(), } }); let Some(enabled) = new_state else { return (StatusCode::NOT_FOUND, "unknown or not compiled service").into_response(); }; tracing::info!(service = %service, enabled = enabled, "service toggled via UI"); let badge_class = if enabled { "badge badge-success badge-sm" } else { "badge badge-error badge-sm" }; let label = if enabled { "enabled" } else { "disabled" }; axum::response::Html(format!(r#"{label}"#)).into_response() } pub async fn manage_logout( State(state): State, request: axum::extract::Request, ) -> Response { use axum::http::header; use crate::session::extract_cookie; let cookie_str = request .headers() .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if let Some(token) = extract_cookie(cookie_str, crate::session::COOKIE_NAME) { state.sessions.revoke(&token); } let clear = format!( "{}=; Path=/ui/; HttpOnly; SameSite=Strict; Max-Age=0", crate::session::COOKIE_NAME ); ( [(header::SET_COOKIE, clear)], Redirect::to("/ui/manage/login"), ) .into_response() } pub async fn manage_add( State(state): State, Form(form): Form, ) -> Result { let root = std::path::PathBuf::from(form.root.trim()); let entry = load_registry_entry(&state, root).await; let projects_file = projects_ncl_path(); if let Err(e) = registry_add_and_persist(&state, entry, &projects_file).await { let tera = tera_ref(&state)?; let registered = read_registered_paths(&projects_file); let projects: Vec = state .registry .all() .into_iter() .map(|ctx| manage_project_json(&ctx, ®istered)) .collect(); let mut ctx = Context::new(); ctx.insert("projects", &projects); ctx.insert("base_url", "/ui"); ctx.insert("error", &e.to_string()); ctx.insert("daemon_admin_enabled", &state.daemon_admin_hash.is_some()); return render(tera, "pages/manage.html", &ctx) .await .map(IntoResponse::into_response); } Ok(Redirect::to("/ui/manage").into_response()) } pub async fn manage_remove( State(state): State, Form(form): Form, ) -> Result { let registry = &state.registry; let root = registry.get(&form.slug).map(|ctx| ctx.root.clone()); registry.remove_project(&form.slug); if let Some(root) = root { let projects_file = projects_ncl_path(); if let Err(e) = persist_project_remove(&root, &projects_file) { warn!(slug = %form.slug, error = %e, "removed from registry but failed to persist removal"); } } Ok(Redirect::to("/ui/manage").into_response()) }