pub mod sites_api;
pub mod themes;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use directories::ProjectDirs;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct AdminContext {
pub sites_path: PathBuf,
pub active_site: Arc<RwLock<Option<String>>>,
}
#[derive(Debug, thiserror::Error)]
pub enum AdminError {
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("internal error: {0}")]
Internal(anyhow::Error),
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AdminError::NotFound(_) => (axum::http::StatusCode::NOT_FOUND, "not_found"),
AdminError::BadRequest(_) => (axum::http::StatusCode::BAD_REQUEST, "bad_request"),
AdminError::Internal(_) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
),
};
let msg = self.to_string();
(
status,
Json(serde_json::json!({
"error": { "code": code, "message": msg }
})),
)
.into_response()
}
}
#[derive(RustEmbed)]
#[folder = "embedded-spa"]
struct AdminAssets;
pub async fn run_admin(port: u16) -> anyhow::Result<()> {
let sites_path = sites_path()?;
let ctx = AdminContext {
sites_path,
active_site: Arc::new(RwLock::new(None)),
};
let app = build_admin_router(ctx);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "oxipage admin console starting");
axum::serve(listener, app).await?;
Ok(())
}
fn build_admin_router(ctx: AdminContext) -> Router {
Router::new()
.route(
"/api/admin/sites",
get(sites_api::list).post(sites_api::add),
)
.route(
"/api/admin/sites/active",
get(sites_api::get_active).put(sites_api::set_active),
)
.route(
"/api/admin/sites/{name}",
get(sites_api::update).delete(sites_api::delete),
)
.route("/api/admin/themes", get(themes::catalog_handler))
.fallback(static_handler)
.with_state(ctx)
}
async fn static_handler(uri: axum::http::Uri) -> Response {
let path = uri.path().trim_start_matches('/');
if let Some(asset) = serve_asset(path) {
return asset;
}
if let Some(index) = AdminAssets::get("index.html") {
return Response::builder()
.header("content-type", "text/html; charset=utf-8")
.body(axum::body::Body::from(index.data.into_owned()))
.unwrap_or_else(|_| Response::new(axum::body::Body::empty()));
}
Response::new(axum::body::Body::empty())
}
fn serve_asset(path: &str) -> Option<Response> {
let asset = AdminAssets::get(path)?;
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header("content-type", mime.to_string())
.body(axum::body::Body::from(asset.data.into_owned()))
.ok()
}
fn sites_path() -> anyhow::Result<PathBuf> {
let dirs = ProjectDirs::from("dev", "oxipage", "oxipage")
.ok_or_else(|| anyhow::anyhow!("could not determine project directories"))?;
let dir = dirs.config_dir();
std::fs::create_dir_all(dir)?;
Ok(dir.join("sites.toml"))
}