use crate::auth::{self, AdminAuth, PatRow};
use crate::build::build_site;
use crate::build_writer::write_build_output;
use crate::setup;
use crate::error::ApiError;
use crate::extension::{CliArgSpec, CliCommandManifest, CliCommandSpec, CliSubcommandSpec, Extension, Lang};
use crate::search::SearchHit;
use crate::state::AppState;
use std::sync::Arc;
use axum::extract::{Path, Query, Request, State};
use axum::http::StatusCode;
use axum::http::{Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use axum::middleware::Next;
use rust_embed::RustEmbed;
use tower_http::trace::TraceLayer;
#[derive(RustEmbed)]
#[folder = "embedded-spa"]
struct Assets;
#[derive(serde::Serialize)]
struct DataEnvelope<T: serde::Serialize> {
data: T,
}
#[derive(serde::Serialize)]
struct ManifestSite {
name: String,
base_url: String,
default_lang: String,
languages: Vec<String>,
}
#[derive(serde::Serialize)]
struct ManifestLocalized {
ko: String,
en: String,
}
#[derive(serde::Serialize)]
struct ManifestExtension {
id: String,
display_name: ManifestLocalized,
lobby: LobbyConfigInfo,
}
#[derive(serde::Serialize, Clone, Default)]
struct LobbyConfigInfo {
enabled: bool,
display_mode: String,
display_order: i64,
style_params: serde_json::Value,
}
#[derive(serde::Serialize)]
struct Manifest {
site: ManifestSite,
extensions: Vec<ManifestExtension>,
}
pub fn build_app(state: AppState) -> Router {
let mut api = Router::new()
.route("/lobby/manifest", get(lobby_manifest))
.route("/lobby/config", get(lobby_config_list))
.route("/lobby/config/{ext_id}", axum::routing::put(lobby_config_update))
.route("/auth/tokens", get(auth_tokens_list).post(auth_tokens_create))
.route("/auth/tokens/{id}", axum::routing::delete(auth_tokens_revoke))
.route("/search", get(search_handler))
.route("/docs", get(docs_ui))
.route("/docs/openapi.json", get(docs_spec))
.route("/extensions", get(extensions_list))
.route("/extensions/{id}/enable", axum::routing::post(extension_enable))
.route("/extensions/{id}/disable", axum::routing::post(extension_disable))
.route("/extensions/{id}", axum::routing::delete(extension_purge))
.route("/extensions/install", axum::routing::post(extension_install))
.route("/backup/snapshot", axum::routing::post(backup_snapshot))
.route("/cli/commands", get(cli_commands_handler))
.route("/cli/exec/{ext_id}/{sub_command}", axum::routing::post(cli_exec_handler))
.route("/theme", get(theme_get).put(theme_put))
.route("/themes", get(theme_catalog))
.route("/build", axum::routing::post(build_handler))
.route("/cache/refresh", axum::routing::post(cache_refresh_handler));
api = setup::setup_routes(api);
api = api.layer(axum::middleware::from_fn_with_state(
state.clone(),
setup::setup_gate,
));
for ext in state.registry.iter() {
if ext.route_dispatcher().is_some() {
continue;
}
api = api.nest(&format!("/{}", ext.id()), ext.routes());
}
let api = api
.fallback(api_fallback)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
extension_gate,
));
let limiter = crate::rate_limit::RateLimiter::new(120); Router::new()
.route("/healthz", get(healthz))
.nest("/api/console", api)
.fallback(static_handler)
.with_state(state)
.layer(axum::middleware::from_fn_with_state(
limiter,
crate::rate_limit::rate_limit_middleware,
))
.layer(TraceLayer::new_for_http())
}
async fn healthz() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
async fn docs_spec(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(crate::openapi::openapi_spec(&state.config.site.base_url))
}
async fn build_handler(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
let config = &state.config;
let out_dir = config.server.data_dir.join("out");
let media_dir = config.server.data_dir.join("media");
let web_dist = std::path::PathBuf::from("web/dist");
let output = build_site(&state.db, &state.builders)
.map_err(|e| ApiError::internal(anyhow::anyhow!("{}", e)))?;
write_build_output(&output, &out_dir, &media_dir, &web_dist)
.map_err(|e| ApiError::internal(anyhow::anyhow!("{}", e)))?;
Ok(Json(serde_json::json!({
"success": true,
"pages": output.pages.len(),
"extensions": output.extensions_data.len(),
"out_dir": out_dir.to_string_lossy(),
})))
}
async fn cache_refresh_handler(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
let mut scheduler = crate::scheduler::Scheduler::new();
for ext in state.registry.iter() {
for job in ext.background_jobs() {
scheduler.register(job);
}
}
let job_count = scheduler.jobs().len();
scheduler.run_all_once(&state).await;
Ok(Json(serde_json::json!({
"success": true,
"jobs_run": job_count,
})))
}
async fn docs_ui() -> axum::response::Response {
let html = crate::openapi::swagger_ui_html("/api/console/docs/openapi.json");
(
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response()
}
async fn api_fallback(State(state): State<AppState>, request: Request) -> Response {
let path = request.uri().path().to_string();
let method = request.method().to_string();
if let Some(ext_id) = extension_id_from_path(&path)
&& let Some(ext) = state.registry.find(&ext_id)
&& let Some(dispatcher) = ext.route_dispatcher()
{
let prefix_len = 1 + ext_id.len(); let sub_path = if path.len() > prefix_len {
&path[prefix_len..]
} else {
"/"
};
let body = axum::body::to_bytes(request.into_body(), 1024 * 1024)
.await
.unwrap_or_default()
.to_vec();
let resp = dispatcher.dispatch(&method, sub_path, body, &state).await;
return (
StatusCode::from_u16(resp.status).unwrap_or(StatusCode::OK),
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
resp.body,
)
.into_response();
}
ApiError::new(StatusCode::NOT_FOUND, "not_found", "resource not found").into_response()
}
#[derive(serde::Deserialize)]
struct SearchQuery {
q: String,
lang: Option<String>,
limit: Option<i64>,
}
async fn search_handler(
State(state): State<AppState>,
Query(q): Query<SearchQuery>,
) -> Result<Json<DataEnvelope<Vec<SearchHit>>>, ApiError> {
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let hits = crate::search::search(&state.db, &q.q, q.lang.as_deref(), limit)
.await
.map_err(ApiError::internal)?;
Ok(Json(DataEnvelope { data: hits }))
}
async fn lobby_manifest(State(state): State<AppState>) -> Json<DataEnvelope<Manifest>> {
let mut extensions = Vec::new();
for (idx, e) in state.registry.iter().into_iter().enumerate() {
if !state.registry.is_active(e.id()).await {
continue;
}
let lobby = lobby_config_for(&state, e.id(), idx as i64).await;
extensions.push(ManifestExtension {
id: e.id().to_string(),
display_name: ManifestLocalized {
ko: e.display_name(Lang::Ko),
en: e.display_name(Lang::En),
},
lobby,
});
}
Json(DataEnvelope {
data: Manifest {
site: ManifestSite {
name: state.effective_site_name().await,
base_url: state.effective_base_url().await,
default_lang: state.config.site.default_lang.clone(),
languages: state.config.site.languages.clone(),
},
extensions,
},
})
}
async fn lobby_config_for(state: &AppState, ext_id: &str, default_order: i64) -> LobbyConfigInfo {
let row: Option<(bool, String, i64, String)> = sqlx::query_as(
"SELECT enabled, display_mode, display_order, style_params
FROM lobby_config WHERE extension_id = ?",
)
.bind(ext_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
match row {
Some((enabled, mode, order, params)) => LobbyConfigInfo {
enabled,
display_mode: mode,
display_order: order,
style_params: serde_json::from_str(¶ms).unwrap_or_default(),
},
None => LobbyConfigInfo {
enabled: true,
display_mode: state.config.lobby.default_mode.clone(),
display_order: default_order,
style_params: serde_json::json!({}),
},
}
}
async fn lobby_config_list(
State(state): State<AppState>,
) -> Json<DataEnvelope<Vec<LobbyConfigEntry>>> {
let mut entries = Vec::new();
for (idx, e) in state.registry.iter().into_iter().enumerate() {
if !state.registry.is_active(e.id()).await {
continue;
}
let info = lobby_config_for(&state, e.id(), idx as i64).await;
entries.push(LobbyConfigEntry {
extension_id: e.id().to_string(),
enabled: info.enabled,
display_mode: info.display_mode,
display_order: info.display_order,
style_params: info.style_params,
});
}
Json(DataEnvelope { data: entries })
}
#[derive(serde::Serialize)]
struct LobbyConfigEntry {
extension_id: String,
enabled: bool,
display_mode: String,
display_order: i64,
style_params: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct LobbyConfigUpdate {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
display_mode: Option<String>,
#[serde(default)]
display_order: Option<i64>,
#[serde(default)]
style_params: Option<serde_json::Value>,
}
async fn lobby_config_update(
_auth: AdminAuth,
State(state): State<AppState>,
Path(ext_id): Path<String>,
Json(input): Json<LobbyConfigUpdate>,
) -> Result<Json<DataEnvelope<LobbyConfigEntry>>, ApiError> {
if state.registry.find(&ext_id).is_none() {
return Err(ApiError::new(
StatusCode::NOT_FOUND,
"not_found",
"extension not registered",
));
}
if let Some(ref mode) = input.display_mode
&& !matches!(mode.as_str(), "canvas" | "grid" | "list")
{
return Err(ApiError::validation(
"display_mode",
"display_mode must be canvas|grid|list",
));
}
let info = lobby_config_for(&state, &ext_id, 0).await;
let enabled = input.enabled.unwrap_or(info.enabled);
let mode = input.display_mode.unwrap_or(info.display_mode);
let order = input.display_order.unwrap_or(info.display_order);
let params = input.style_params.unwrap_or(info.style_params);
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".into());
sqlx::query(
"INSERT INTO lobby_config (extension_id, enabled, display_mode, display_order, style_params)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (extension_id) DO UPDATE SET
enabled = ?2, display_mode = ?3, display_order = ?4, style_params = ?5",
)
.bind(&ext_id)
.bind(enabled)
.bind(&mode)
.bind(order)
.bind(¶ms_str)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
Ok(Json(DataEnvelope {
data: LobbyConfigEntry {
extension_id: ext_id,
enabled,
display_mode: mode,
display_order: order,
style_params: params,
},
}))
}
#[derive(serde::Deserialize)]
struct PatCreate {
label: String,
#[serde(default)]
scopes: Vec<String>,
}
#[derive(serde::Serialize)]
struct PatCreated {
plain_token: String,
id: i64,
label: String,
token_prefix: String,
scopes: Vec<String>,
}
async fn auth_tokens_create(
auth: AdminAuth,
State(state): State<AppState>,
Json(input): Json<PatCreate>,
) -> Result<Json<DataEnvelope<PatCreated>>, ApiError> {
auth.require_scope("admin")?;
if input.label.trim().is_empty() {
return Err(ApiError::validation("label", "label must not be empty"));
}
for s in &input.scopes {
if !matches!(s.as_str(), "post:write" | "post:publish" | "read" | "admin") {
return Err(ApiError::validation(
"scopes",
"scope must be post:write|post:publish|read|admin",
));
}
}
let (plain, row) = auth::create_pat(&state, &input.label, &input.scopes)
.await
.map_err(ApiError::internal)?;
Ok(Json(DataEnvelope {
data: PatCreated {
plain_token: plain,
id: row.id,
label: row.label,
token_prefix: row.token_prefix,
scopes: input.scopes,
},
}))
}
async fn auth_tokens_list(
auth: AdminAuth,
State(state): State<AppState>,
) -> Result<Json<DataEnvelope<Vec<PatRow>>>, ApiError> {
auth.require_scope("admin")?;
let rows = auth::list_pats(&state)
.await
.map_err(ApiError::internal)?;
Ok(Json(DataEnvelope { data: rows }))
}
async fn auth_tokens_revoke(
auth: AdminAuth,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<DataEnvelope<serde_json::Value>>, ApiError> {
auth.require_scope("admin")?;
let removed = auth::revoke_pat(&state, id)
.await
.map_err(ApiError::internal)?;
if !removed {
return Err(ApiError::new(
StatusCode::NOT_FOUND,
"not_found",
"active token with that id not found",
));
}
Ok(Json(DataEnvelope {
data: serde_json::json!({ "id": id, "revoked": true }),
}))
}
async fn backup_snapshot(
auth: AdminAuth,
State(state): State<AppState>,
) -> Result<Json<DataEnvelope<serde_json::Value>>, ApiError> {
auth.require_scope("admin")?;
let backups_dir = state.config.server.data_dir.join("backups");
tokio::fs::create_dir_all(&backups_dir)
.await
.map_err(|e| ApiError::internal(e.into()))?;
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let filename = crate::backup::snapshot_filename(epoch);
let dest = backups_dir.join(&filename);
crate::backup::vacuum_into(&state.db, &dest)
.await
.map_err(ApiError::internal)?;
Ok(Json(DataEnvelope {
data: serde_json::json!({
"path": dest.display().to_string(),
"filename": filename,
}),
}))
}
async fn static_handler(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
serve_asset(path)
.or_else(|| serve_asset("index.html"))
.unwrap_or_else(|| StatusCode::NOT_FOUND.into_response())
}
fn serve_asset(path: &str) -> Option<Response> {
Assets::get(path).map(|content| {
let mime = mime_guess::from_path(path).first_or_octet_stream();
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
})
}
pub fn spa_index_html() -> Option<String> {
Assets::get("index.html")
.and_then(|f| std::str::from_utf8(f.data.as_ref()).ok().map(str::to_owned))
}
#[derive(serde::Serialize)]
struct ExtensionInfo {
id: String,
display_name: ManifestLocalized,
enabled: bool,
purged: bool,
}
async fn extension_info(state: &AppState, ext: &Arc<dyn Extension>) -> ExtensionInfo {
let s = state.registry.status_of(ext.id()).await;
ExtensionInfo {
id: ext.id().to_string(),
display_name: ManifestLocalized {
ko: ext.display_name(Lang::Ko),
en: ext.display_name(Lang::En),
},
enabled: s.map(|s| s.enabled).unwrap_or(false),
purged: s.map(|s| s.purged).unwrap_or(false),
}
}
async fn extensions_list(
auth: AdminAuth,
State(state): State<AppState>,
) -> Result<Json<DataEnvelope<Vec<ExtensionInfo>>>, ApiError> {
auth.require_scope("admin")?;
let snapshot = state.registry.status_snapshot().await;
let mut out = Vec::new();
for e in state.registry.iter() {
let s = snapshot.get(e.id()).copied();
out.push(ExtensionInfo {
id: e.id().to_string(),
display_name: ManifestLocalized {
ko: e.display_name(Lang::Ko),
en: e.display_name(Lang::En),
},
enabled: s.map(|s| s.enabled).unwrap_or(false),
purged: s.map(|s| s.purged).unwrap_or(false),
});
}
Ok(Json(DataEnvelope { data: out }))
}
async fn extension_enable(
auth: AdminAuth,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<DataEnvelope<ExtensionInfo>>, ApiError> {
auth.require_scope("admin")?;
let ext = state
.registry.find(&id)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "extension_not_found", "unknown extension id"))?;
let prev = state.registry.status_of(&id).await;
let was_purged = prev.map(|s| s.purged).unwrap_or(false);
let was_enabled = prev.map(|s| s.enabled).unwrap_or(false);
if was_purged {
state.registry.set_purged(&state.db, &id, false).await?;
}
state.registry.set_enabled(&state.db, &id, true).await?;
if !was_enabled || was_purged {
match ext.on_startup(&state).await {
Ok(_) => {}
Err(e) => tracing::warn!(extension = %id, error = %e, "on_startup failed"),
}
}
Ok(Json(DataEnvelope {
data: extension_info(&state, &ext).await,
}))
}
async fn extension_disable(
auth: AdminAuth,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<DataEnvelope<ExtensionInfo>>, ApiError> {
auth.require_scope("admin")?;
let ext = state
.registry.find(&id)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "extension_not_found", "unknown extension id"))?;
let prev = state.registry.set_enabled(&state.db, &id, false).await?;
if prev.map(|s| s.enabled).unwrap_or(false) {
match ext.on_disable(&state).await {
Ok(_) => {}
Err(e) => tracing::warn!(extension = %id, error = %e, "on_disable failed"),
}
}
Ok(Json(DataEnvelope {
data: extension_info(&state, &ext).await,
}))
}
async fn extension_purge(
auth: AdminAuth,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<DataEnvelope<serde_json::Value>>, ApiError> {
auth.require_scope("admin")?;
let ext = state
.registry.find(&id)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "extension_not_found", "unknown extension id"))?;
state.registry.set_enabled(&state.db, &id, false).await?;
let _ = ext.on_disable(&state).await;
for table in ext.table_names() {
if !is_safe_ident(table) {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"unsafe_table_name",
"extension returned an invalid table name",
));
}
sqlx::query(&format!("DROP TABLE IF EXISTS {table}"))
.execute(&state.db)
.await
.map_err(|e| ApiError::from(anyhow::anyhow!(e)))?;
}
let media = state.config.server.data_dir.join("media").join(&id);
if media.exists() {
match std::fs::remove_dir_all(&media) {
Ok(_) => {}
Err(e) => tracing::warn!(path = %media.display(), error = %e, "failed to remove media dir during purge"),
}
}
sqlx::query("DELETE FROM schema_migrations WHERE extension = ?")
.bind(&id)
.execute(&state.db)
.await
.map_err(|e| ApiError::from(anyhow::anyhow!(e)))?;
state.registry.set_purged(&state.db, &id, true).await?;
Ok(Json(DataEnvelope {
data: serde_json::json!({ "extension_id": id, "purged": true }),
}))
}
const REGISTRY_INDEX_JSON: &str = include_str!("../_registry.json");
const DEMO_WASM_BYTES: &[u8] =
include_bytes!("../_wasm-demo.wasm");
const TRUSTED_WASM_PUBKEY_B64: &str = "Yokw8k5OJv9Ty0b1PhaZ3zYitU9tY7q9Yvebjxa8aiA=";
#[derive(serde::Deserialize)]
struct RegistryIndex {
extensions: Vec<RegistryEntry>,
}
#[derive(serde::Deserialize)]
struct RegistryEntry {
name: String,
#[serde(default)]
runtime_loadable: bool,
#[serde(default)]
wasm_url: Option<String>,
#[serde(default)]
signature: Option<String>,
}
fn verify_wasm_signature(bytes: &[u8], signature_b64: &str) -> Result<(), ApiError> {
use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let pubkey_raw = base64::engine::general_purpose::STANDARD
.decode(TRUSTED_WASM_PUBKEY_B64)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "sig_key_error", &e.to_string()))?;
let pubkey_arr: [u8; 32] = pubkey_raw
.as_slice()
.try_into()
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "sig_key_error", "pubkey not 32 bytes"))?;
let pubkey = VerifyingKey::from_bytes(&pubkey_arr)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "sig_key_error", &e.to_string()))?;
let sig_raw = base64::engine::general_purpose::STANDARD
.decode(signature_b64)
.map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, "bad_signature", &e.to_string()))?;
let sig = Signature::from_slice(&sig_raw)
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "bad_signature", "malformed signature bytes"))?;
pubkey
.verify(bytes, &sig)
.map_err(|_| ApiError::new(StatusCode::CONFLICT, "signature_mismatch", "wasm artifact signature verification failed"))
}
#[derive(serde::Deserialize)]
struct InstallInput {
name: String,
}
async fn extension_install(
auth: AdminAuth,
State(state): State<AppState>,
Json(input): Json<InstallInput>,
) -> Result<Json<DataEnvelope<serde_json::Value>>, ApiError> {
auth.require_scope("admin")?;
let name = input.name;
if !is_safe_extension_name(&name) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"bad_request",
"invalid extension name",
));
}
let index: RegistryIndex = serde_json::from_str(REGISTRY_INDEX_JSON)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "registry_error", &e.to_string()))?;
let entry = index
.extensions
.iter()
.find(|e| e.name == name)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "not_found", "unknown extension name"))?;
if !entry.runtime_loadable {
return Err(ApiError::new(
StatusCode::CONFLICT,
"not_runtime_loadable",
"this extension is compile-time only; it cannot be installed at runtime",
));
}
let bytes: Vec<u8> = if name == "wasm-demo" {
DEMO_WASM_BYTES.to_vec()
} else {
let url = entry
.wasm_url
.as_deref()
.ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "no_wasm_url", "registry entry has no wasm_url"))?;
let resp = reqwest::get(url)
.await
.map_err(|e| ApiError::new(StatusCode::BAD_GATEWAY, "download_failed", &e.to_string()))?;
if !resp.status().is_success() {
return Err(ApiError::new(
StatusCode::BAD_GATEWAY,
"download_failed",
&format!("registry returned {}", resp.status()),
));
}
resp.bytes()
.await
.map_err(|e| ApiError::new(StatusCode::BAD_GATEWAY, "download_failed", &e.to_string()))?
.to_vec()
};
if let Some(sig) = &entry.signature {
verify_wasm_signature(&bytes, sig)?;
}
let dir = state.config.server.data_dir.join("extensions");
std::fs::create_dir_all(&dir)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "fs_error", &e.to_string()))?;
let path = dir.join(format!("{name}.wasm"));
std::fs::write(&path, &bytes)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "fs_error", &e.to_string()))?;
if let Some(loader) = &state.wasm_loader {
match loader.load(&path) {
Ok(ext) => {
sqlx::query(
"INSERT INTO extension_state (extension_id, enabled, purged)
VALUES (?1, 1, 0)
ON CONFLICT(extension_id) DO UPDATE SET purged = 0, enabled = 1",
)
.bind(&name)
.execute(&state.db)
.await
.map_err(|e| ApiError::from(anyhow::anyhow!(e)))?;
let id = state.registry.register_and_activate(ext.clone()).await;
if let Err(e) = ext.on_startup(&state).await {
tracing::warn!(extension = %id, error = %e, "on_startup failed for live-loaded extension");
}
tracing::info!(
extension = %id,
path = %path.display(),
bytes = bytes.len(),
"installed wasm extension (live activated)"
);
return Ok(Json(DataEnvelope {
data: serde_json::json!({
"name": name,
"path": path.display().to_string(),
"bytes": bytes.len(),
"activated": true,
}),
}));
}
Err(e) => {
tracing::warn!(
extension = %name,
error = %e,
"wasm live-activation failed — restart to retry"
);
}
}
}
sqlx::query(
"INSERT INTO extension_state (extension_id, enabled, purged)
VALUES (?1, 0, 0)
ON CONFLICT(extension_id) DO UPDATE SET purged = 0",
)
.bind(&name)
.execute(&state.db)
.await
.map_err(|e| ApiError::from(anyhow::anyhow!(e)))?;
tracing::info!(
extension = %name,
path = %path.display(),
bytes = bytes.len(),
"installed wasm extension (restart to activate)"
);
Ok(Json(DataEnvelope {
data: serde_json::json!({
"name": name,
"path": path.display().to_string(),
"bytes": bytes.len(),
"activated": false,
"note": "restart oxipage-console (built with --features wasm) to activate",
}),
}))
}
fn is_safe_ident(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn is_safe_extension_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn extension_id_from_path(path: &str) -> Option<String> {
let seg = path.trim_start_matches('/').split('/').next()?;
if seg.is_empty() {
None
} else {
Some(seg.to_string())
}
}
async fn extension_gate(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if let Some(ext_id) = extension_id_from_path(request.uri().path())
&& state.registry.find(&ext_id).is_some()
&& !state.registry.is_active(&ext_id).await
{
return ApiError::new(
StatusCode::NOT_FOUND,
"extension_disabled",
"extension is disabled or purged",
)
.into_response();
}
next.run(request).await
}
async fn cli_commands_handler(
State(state): State<AppState>,
) -> Json<CliCommandManifest> {
let statuses = state.registry.status_snapshot().await;
let extensions: Vec<CliCommandSpec> = state
.registry
.iter()
.into_iter()
.filter(|ext| {
statuses
.get(ext.id())
.map(|s| s.active())
.unwrap_or(false)
})
.flat_map(|ext| {
let id = ext.id().to_string();
ext.cli_commands().into_iter().map(move |cmd| CliCommandSpec {
extension_id: id.clone(),
name: cmd.name.to_string(),
about: cmd.about.to_string(),
subcommands: cmd
.subcommands
.into_iter()
.map(|sub| CliSubcommandSpec {
name: sub.name.to_string(),
about: sub.about.to_string(),
args: sub
.args
.into_iter()
.map(|a| CliArgSpec {
long: a.long.to_string(),
short: a.short,
help: a.help.to_string(),
required: a.required,
})
.collect(),
})
.collect(),
})
})
.collect();
Json(CliCommandManifest { extensions })
}
#[derive(serde::Deserialize)]
struct CliExecInput {
args: std::collections::BTreeMap<String, String>,
}
async fn cli_exec_handler(
State(state): State<AppState>,
Path((ext_id, sub_command)): Path<(String, String)>,
Json(input): Json<CliExecInput>,
) -> Result<Json<serde_json::Value>, ApiError> {
let ext = state
.registry
.find(&ext_id)
.ok_or_else(|| {
let msg = format!("extension '{ext_id}' not found");
ApiError::new(
axum::http::StatusCode::NOT_FOUND,
"extension_not_found",
&msg,
)
})?;
let cmd = ext
.cli_commands()
.into_iter()
.find_map(|c| {
c.subcommands
.into_iter()
.find(|s| s.name == sub_command)
})
.ok_or_else(|| {
let msg = format!(
"subcommand '{sub_command}' not found in extension '{ext_id}'"
);
ApiError::new(
axum::http::StatusCode::NOT_FOUND,
"subcommand_not_found",
&msg,
)
})?;
if cmd.handler.is_some() {
let msg = format!(
"command '{ext_id} {sub_command}' has a native handler and cannot be proxied"
);
return Err(ApiError::new(
axum::http::StatusCode::BAD_REQUEST,
"handler_not_proxyable",
&msg,
));
}
tracing::info!(
"cli exec proxy: ext={ext_id} sub={sub_command} args={:?}",
input.args
);
Ok(Json(serde_json::json!({
"status": "stub",
"ext_id": ext_id,
"sub_command": sub_command,
"args": input.args,
})))
}
struct ThemeCatalogEntry {
id: &'static str,
name_ko: &'static str,
name_en: &'static str,
mode: &'static str,
accent_hue: f64,
description_ko: &'static str,
description_en: &'static str,
}
const THEMES: &[ThemeCatalogEntry] = &[
ThemeCatalogEntry {
id: "paper",
name_ko: "종이",
name_en: "Paper",
mode: "light",
accent_hue: 290.0,
description_ko: "따뜻한 종이 배경, 인디고-바이올렛 악센트",
description_en: "Warm paper background, indigo-violet accent",
},
ThemeCatalogEntry {
id: "midnight",
name_ko: "한밤",
name_en: "Midnight",
mode: "dark",
accent_hue: 230.0,
description_ko: "깊은 밤하늘, 시안-블루 악센트",
description_en: "Deep night sky, cyan-blue accent",
},
ThemeCatalogEntry {
id: "sepia",
name_ko: "세피아",
name_en: "Sepia",
mode: "light",
accent_hue: 70.0,
description_ko: "오래된 책장, 앰버-골드 악센트",
description_en: "Old bookshelf, amber-gold accent",
},
ThemeCatalogEntry {
id: "forest",
name_ko: "숲",
name_en: "Forest",
mode: "dark",
accent_hue: 155.0,
description_ko: "이끼 낀 숲, 에메랄드 악센트",
description_en: "Mossy forest, emerald accent",
},
];
async fn theme_catalog() -> Json<DataEnvelope<Vec<serde_json::Value>>> {
let list: Vec<serde_json::Value> = THEMES
.iter()
.map(|t| {
serde_json::json!({
"id": t.id,
"name": { "ko": t.name_ko, "en": t.name_en },
"mode": t.mode,
"accent_hue": t.accent_hue,
"description": { "ko": t.description_ko, "en": t.description_en },
})
})
.collect();
Json(DataEnvelope { data: list })
}
async fn theme_get(State(state): State<AppState>) -> Result<Json<DataEnvelope<serde_json::Value>>, ApiError> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT theme_id FROM theme_config WHERE id = 1",
)
.fetch_optional(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!("db: {e}")))?;
let theme_id = row.map(|r| r.0).unwrap_or_else(|| "paper".to_string());
Ok(Json(DataEnvelope {
data: serde_json::json!({"theme_id": theme_id}),
}))
}
#[derive(serde::Deserialize)]
struct ThemePutInput {
theme_id: String,
}
async fn theme_put(
auth: AdminAuth,
State(state): State<AppState>,
Json(input): Json<ThemePutInput>,
) -> Result<Json<DataEnvelope<serde_json::Value>>, ApiError> {
auth.require_scope("admin")?;
let valid = THEMES.iter().any(|t| t.id == input.theme_id);
if !valid {
return Err(ApiError::new(
axum::http::StatusCode::BAD_REQUEST,
"invalid_theme",
&format!("'{}' is not a valid theme", input.theme_id),
));
}
sqlx::query(
"INSERT INTO theme_config (id, theme_id, updated_at) VALUES (1, ?1, datetime('now'))
ON CONFLICT(id) DO UPDATE SET theme_id = ?1, updated_at = datetime('now')",
)
.bind(&input.theme_id)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!("db: {e}")))?;
Ok(Json(DataEnvelope {
data: serde_json::json!({"theme_id": input.theme_id}),
}))
}