use crate::error::ApiError;
use crate::extension::{DataEnvelope, ExtensionStepInfo, StepOutcome};
use crate::state::{AppState, SiteOverride};
use axum::extract::{ConnectInfo, Path, Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
const THEMES: &[ThemeEntry] = &[
ThemeEntry {
id: "paper",
name_ko: "종이",
name_en: "Paper",
mode: "light",
description_ko: "따뜻한 종이 배경",
description_en: "Warm paper background",
preview_colors: ["#fafaf5", "#f5f2ed", "#2d2934", "#2d7a5c"],
},
ThemeEntry {
id: "midnight",
name_ko: "한밤",
name_en: "Midnight",
mode: "dark",
description_ko: "깊은 밤하늘",
description_en: "Deep night sky",
preview_colors: ["#1a1a2e", "#16213e", "#e0e0e0", "#4fc3f7"],
},
ThemeEntry {
id: "sepia",
name_ko: "세피아",
name_en: "Sepia",
mode: "light",
description_ko: "오래된 책장",
description_en: "Old bookshelf",
preview_colors: ["#f5f0e8", "#ede0d4", "#3d3529", "#b8860b"],
},
ThemeEntry {
id: "forest",
name_ko: "숲",
name_en: "Forest",
mode: "dark",
description_ko: "이끼 낀 숲",
description_en: "Mossy forest",
preview_colors: ["#1b2b1b", "#243624", "#e0e8e0", "#2ecc71"],
},
];
#[derive(Deserialize)]
pub struct SiteInput {
pub name: String,
pub base_url: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ExtensionsInput {
pub enabled: Vec<String>,
}
#[derive(Deserialize)]
pub struct ThemeInput {
pub theme_id: String,
pub lobby_mode: Option<String>,
}
#[derive(Serialize)]
pub struct CompleteResult {
pub ok: bool,
pub message: String,
}
#[derive(Serialize)]
pub struct StatusResult {
pub setup_mode: bool,
pub completed_steps: Vec<String>,
pub available_extensions: Vec<ExtInfo>,
pub available_themes: Vec<ThemeEntry>,
pub extension_wizards: Vec<ExtensionWizardInfo>,
}
#[derive(Serialize)]
pub struct ExtInfo {
pub id: String,
pub display_name: ExtDisplayName,
}
#[derive(Serialize)]
pub struct ExtDisplayName {
pub ko: String,
pub en: String,
}
#[derive(Serialize)]
pub struct ExtensionWizardInfo {
pub extension_id: String,
pub display_name: ExtDisplayName,
pub steps: Vec<ExtensionStepInfo>,
}
#[derive(Serialize, Clone)]
pub struct ThemeEntry {
pub id: &'static str,
pub name_ko: &'static str,
pub name_en: &'static str,
pub mode: &'static str,
pub description_ko: &'static str,
pub description_en: &'static str,
pub preview_colors: [&'static str; 4],
}
#[derive(Serialize)]
pub struct SimpleOk {
pub ok: bool,
}
async fn get_completed_steps(state: &AppState) -> Result<Vec<String>, ApiError> {
let mut steps = Vec::new();
if let Ok((Some(_),)) =
sqlx::query_as::<_, (Option<String>,)>("SELECT site_name FROM setup_state WHERE id = 1")
.fetch_one(&state.db)
.await
{
steps.push("site".into());
}
steps.push("admin".into());
match sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM extension_state WHERE enabled = 1")
.fetch_one(&state.db)
.await
{
Ok((count,)) if count > 0 => steps.push("extensions".into()),
_ => {}
}
steps.push("theme".into());
Ok(steps)
}
fn update_toml_site(name: &str, base_url: &str) -> anyhow::Result<()> {
let config_path = std::env::var("OXIPAGE_CONFIG")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("oxipage.toml"));
if !config_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&config_path)?;
let mut toml_val: toml::Value = content.parse::<toml::Value>()?;
if let Some(table) = toml_val.get_mut("site").and_then(|s| s.as_table_mut()) {
table.insert("name".into(), toml::Value::String(name.into()));
table.insert("base_url".into(), toml::Value::String(base_url.into()));
}
let out = toml::to_string(&toml_val)?;
std::fs::write(&config_path, out)?;
Ok(())
}
pub fn setup_routes(api: Router<AppState>) -> Router<AppState> {
api.route("/setup/status", get(setup_status_handler))
.route("/setup/site", post(setup_site_handler))
.route("/setup/extensions", post(setup_extensions_handler))
.route(
"/setup/extension-step/{ext_id}/{step_id}",
post(setup_extension_step_handler),
)
.route("/setup/theme", post(setup_theme_handler))
.route("/setup/complete", post(setup_complete_handler))
}
pub async fn is_setup_needed(db: &sqlx::SqlitePool) -> bool {
matches!(
sqlx::query_as::<_, (Option<String>,)>(
"SELECT setup_completed_at FROM setup_state WHERE id = 1",
)
.fetch_one(db)
.await,
Ok((None,))
)
}
pub async fn setup_gate(State(state): State<AppState>, request: Request, next: Next) -> Response {
let path = request.uri().path();
let is_setup = path.contains("/setup/");
if is_setup {
let is_loopback = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.is_some_and(|ci| ci.0.ip().is_loopback());
if !is_loopback {
return ApiError::new(
StatusCode::FORBIDDEN,
"setup_loopback_only",
"setup API is only available from localhost",
)
.into_response();
}
match sqlx::query_as::<_, (Option<String>,)>(
"SELECT setup_completed_at FROM setup_state WHERE id = 1",
)
.fetch_one(&state.db)
.await
{
Ok((Some(_),)) => {
return ApiError::new(
StatusCode::GONE,
"setup_completed",
"setup has already been completed",
)
.into_response();
}
Err(e) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&format!("setup state check failed: {e}"),
)
.into_response();
}
_ => {}
}
}
next.run(request).await
}
pub async fn setup_status_handler(
State(state): State<AppState>,
) -> Result<Json<DataEnvelope<StatusResult>>, ApiError> {
let completed_steps = get_completed_steps(&state).await?;
let snapshot = state.registry.iter();
let mut available_extensions: Vec<ExtInfo> = Vec::with_capacity(snapshot.len());
let mut extension_wizards: Vec<ExtensionWizardInfo> = Vec::new();
for ext in snapshot {
available_extensions.push(ExtInfo {
id: ext.id().to_string(),
display_name: ExtDisplayName {
ko: ext.display_name(crate::extension::Lang::Ko),
en: ext.display_name(crate::extension::Lang::En),
},
});
if !state.registry.is_active(ext.id()).await {
continue;
}
if let Some(wizard) = ext.setup_wizard() {
let steps: Vec<ExtensionStepInfo> = wizard
.steps
.iter()
.map(ExtensionStepInfo::from_step)
.collect();
extension_wizards.push(ExtensionWizardInfo {
extension_id: ext.id().to_string(),
display_name: ExtDisplayName {
ko: ext.display_name(crate::extension::Lang::Ko),
en: ext.display_name(crate::extension::Lang::En),
},
steps,
});
}
}
Ok(Json(DataEnvelope {
data: StatusResult {
setup_mode: true,
completed_steps,
available_extensions,
available_themes: THEMES.to_vec(),
extension_wizards,
},
}))
}
pub async fn setup_site_handler(
State(state): State<AppState>,
Json(input): Json<SiteInput>,
) -> Result<Json<DataEnvelope<SimpleOk>>, ApiError> {
let name = input.name.trim().to_string();
if name.is_empty() || name.len() > 50 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid_site_name",
"site name must be 1-50 characters",
));
}
let base_url = input
.base_url
.unwrap_or_else(|| "http://127.0.0.1:8787".into());
let _ = update_toml_site(&name, &base_url);
sqlx::query("UPDATE setup_state SET site_name = ?1, base_url = ?2 WHERE id = 1")
.bind(&name)
.bind(&base_url)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
*state.site_override.write().await = Some(SiteOverride {
name: name.clone(),
base_url: base_url.clone(),
});
Ok(Json(DataEnvelope {
data: SimpleOk { ok: true },
}))
}
pub async fn setup_extensions_handler(
State(state): State<AppState>,
Json(input): Json<ExtensionsInput>,
) -> Result<Json<DataEnvelope<ExtensionsInput>>, ApiError> {
for id in &input.enabled {
if state.registry.find(id).is_none() {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"unknown_extension",
&format!("unknown extension: {id}"),
));
}
}
sqlx::query("UPDATE extension_state SET enabled = 0")
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
for id in &input.enabled {
let count = sqlx::query("UPDATE extension_state SET enabled = 1 WHERE extension_id = ?1")
.bind(id)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
if count.rows_affected() == 0 {
sqlx::query(
"INSERT INTO extension_state (extension_id, enabled, purged) VALUES (?1, 1, 0)",
)
.bind(id)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
}
}
for ext in state.registry.iter().into_iter() {
let enabled = input.enabled.iter().any(|id| id == ext.id());
let _ = state
.registry
.set_enabled(&state.db, ext.id(), enabled)
.await;
}
Ok(Json(DataEnvelope {
data: ExtensionsInput {
enabled: input.enabled.clone(),
},
}))
}
pub async fn setup_extension_step_handler(
State(state): State<AppState>,
Path((ext_id, step_id)): Path<(String, String)>,
Json(form): Json<serde_json::Map<String, serde_json::Value>>,
) -> Result<Json<DataEnvelope<StepOutcome>>, ApiError> {
for ext in state.registry.iter() {
if ext.id() != ext_id {
continue;
}
if !state.registry.is_active(ext.id()).await {
continue;
}
if let Some(wizard) = ext.setup_wizard()
&& let Some(step) = wizard.steps.iter().find(|s| s.id == step_id)
{
let outcome = step
.save_handler
.save(&state, &form)
.await
.map_err(ApiError::internal)?;
return Ok(Json(DataEnvelope { data: outcome }));
}
}
Err(ApiError::new(
StatusCode::NOT_FOUND,
"unknown_step",
&format!("no such extension setup step: {ext_id}/{step_id}"),
))
}
pub async fn setup_theme_handler(
State(state): State<AppState>,
Json(input): Json<ThemeInput>,
) -> Result<Json<DataEnvelope<SimpleOk>>, ApiError> {
if !THEMES.iter().any(|t| t.id == input.theme_id) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid_theme",
&format!("unknown theme: {}", input.theme_id),
));
}
sqlx::query("UPDATE theme_config SET theme_id = ?1, updated_at = datetime('now') WHERE id = 1")
.bind(&input.theme_id)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
if let Some(ref mode) = input.lobby_mode {
if !matches!(mode.as_str(), "canvas" | "grid" | "list") {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid_lobby_mode",
"lobby mode must be canvas|grid|list",
));
}
sqlx::query("UPDATE lobby_config SET display_mode = ?1")
.bind(mode)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
}
Ok(Json(DataEnvelope {
data: SimpleOk { ok: true },
}))
}
pub async fn setup_complete_handler(
State(state): State<AppState>,
) -> Result<Json<DataEnvelope<CompleteResult>>, ApiError> {
sqlx::query(
"UPDATE setup_state SET setup_completed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = 1",
)
.execute(&state.db)
.await
.map_err(|e| ApiError::internal(anyhow::anyhow!(e)))?;
for ext in state.registry.iter() {
if !state.registry.is_active(ext.id()).await {
continue;
}
if let Err(e) = ext.seed_sample_data(&state).await {
tracing::warn!(extension = ext.id(), error = %e, "seed_sample_data failed");
}
}
Ok(Json(DataEnvelope {
data: CompleteResult {
ok: true,
message: "설정이 완료되었습니다.".into(),
},
}))
}