#![cfg(feature = "admin-ui")]
use std::path::{Path as StdPath, PathBuf};
use axum::Json;
use axum::body::Body;
use axum::extract::{Path, Request, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::admin_ui::AdminUiInfo;
use crate::server::AppState;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfo {
pub version: String,
pub index_sha256: String,
pub file_count: u32,
pub mode: String,
}
pub async fn build_info(State(state): State<AppState>) -> Json<BuildInfo> {
let mode = state.config.read().await.admin_ui.mode.clone();
let info = AdminUiInfo::from_embedded(&mode);
Json(BuildInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
index_sha256: (*info.index_sha256).clone(),
file_count: info.file_count,
mode: (*info.mode).clone(),
})
}
pub async fn serve_spa(req: Request<Body>) -> Response {
crate::admin_ui::serve(req).await
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifestEntry {
pub id: String,
pub label: String,
pub path: String,
pub entry: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginsManifestResponse {
pub plugins: Vec<PluginManifestEntry>,
}
pub async fn plugins_manifest(State(state): State<AppState>) -> Json<PluginsManifestResponse> {
let plugin_dir = state.config.read().await.admin_ui.plugin_dir.clone();
let Some(plugin_dir) = plugin_dir else {
return Json(PluginsManifestResponse { plugins: vec![] });
};
Json(PluginsManifestResponse {
plugins: scan_plugin_dir(&plugin_dir),
})
}
fn scan_plugin_dir(plugin_dir: &StdPath) -> Vec<PluginManifestEntry> {
let entries = match std::fs::read_dir(plugin_dir) {
Ok(e) => e,
Err(e) => {
warn!(
path = %plugin_dir.display(),
error = %e,
"admin_ui.plugin_dir is set but unreadable — no third-party plugins served"
);
return Vec::new();
}
};
let mut plugins = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(id) = path.file_name().and_then(|f| f.to_str()) else {
continue;
};
if !is_valid_plugin_id(id) {
warn!(id, "skipping plugin: id does not match ^[a-z][a-z0-9-]*$");
continue;
}
let manifest_path = path.join("manifest.json");
let raw = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(e) => {
warn!(
id,
path = %manifest_path.display(),
error = %e,
"skipping plugin: manifest.json unreadable"
);
continue;
}
};
let mut manifest: DiskManifest = match serde_json::from_str(&raw) {
Ok(m) => m,
Err(e) => {
warn!(id, error = %e, "skipping plugin: manifest.json malformed");
continue;
}
};
if manifest.id.as_deref() != Some(id) {
manifest.id = Some(id.to_string());
}
let entry_file = manifest.entry.trim_start_matches('/');
if entry_file.contains("..") || entry_file.is_empty() {
warn!(
id,
entry = entry_file,
"skipping plugin: entry path traversal"
);
continue;
}
plugins.push(PluginManifestEntry {
id: id.to_string(),
label: manifest.label,
path: manifest.path,
entry: format!("/admin/plugins/{id}/{entry_file}"),
icon: manifest.icon,
scopes: manifest.scopes.unwrap_or_default(),
});
}
plugins
}
fn is_valid_plugin_id(id: &str) -> bool {
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DiskManifest {
#[serde(default)]
id: Option<String>,
label: String,
path: String,
entry: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
scopes: Option<Vec<String>>,
}
pub async fn plugin_asset(
State(state): State<AppState>,
Path((id, rel_path)): Path<(String, String)>,
) -> Response {
if !is_valid_plugin_id(&id) {
return (StatusCode::NOT_FOUND, "plugin id invalid").into_response();
}
if rel_path.contains("..") {
return (StatusCode::NOT_FOUND, "plugin path traversal").into_response();
}
let Some(plugin_dir) = state.config.read().await.admin_ui.plugin_dir.clone() else {
return (StatusCode::NOT_FOUND, "no plugin_dir configured").into_response();
};
let absolute: PathBuf = plugin_dir.join(&id).join(&rel_path);
let canonical_root = match plugin_dir.canonicalize() {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "plugin_dir not resolvable").into_response(),
};
let canonical_abs = match absolute.canonicalize() {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "plugin asset not found").into_response(),
};
if !canonical_abs.starts_with(&canonical_root) {
return (StatusCode::NOT_FOUND, "plugin path escapes root").into_response();
}
let bytes = match std::fs::read(&canonical_abs) {
Ok(b) => b,
Err(_) => return (StatusCode::NOT_FOUND, "plugin asset not found").into_response(),
};
let mime = mime_guess::from_path(&canonical_abs)
.first_or_octet_stream()
.to_string();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, "public, max-age=300")
.body(Body::from(bytes))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build").into_response())
}