oxinbox-backend 0.1.6

HTTP/WS API server for the oxinbox GTD productivity system
Documentation
use std::path::PathBuf;

use axum::{
    body::Body,
    extract::Request,
    http::StatusCode,
    response::{IntoResponse, Response},
    Router,
};
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;

use oxinbox_backend::{ai, auth, database, db, push, routes};

#[tokio::main]
async fn main() {
    if std::env::var("TRACING_FORMAT").as_deref() == Ok("json") {
        tracing_subscriber::fmt()
            .json()
            .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
            .init();
    } else {
        tracing_subscriber::fmt()
            .pretty()
            .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
            .with_target(true)
            .with_file(true)
            .with_line_number(true)
            .init();
    }

    dotenvy::dotenv().ok();
    tracing::info!("oxinbox backend starting");

    let pool = if let Ok(database_url) = std::env::var("DATABASE_URL") {
        let pool = db::create_pool(&database_url)
            .await
            .expect("failed to connect to database");
        db::run_migrations(&pool)
            .await
            .expect("failed to run migrations");
        tracing::info!("database connected and migrated");
        Some(pool)
    } else {
        tracing::warn!("DATABASE_URL not set, running without database");
        None
    };

    let db_repo = pool
        .as_ref()
        .map(|p| database::ParadeDbRepository::arc_new(p.clone()));

    let ai_provider = match ai::create_provider() {
        Ok(provider) => {
            tracing::info!("AI provider ready");
            Some(provider)
        }
        Err(e) => {
            tracing::warn!("AI provider not configured: {e}");
            None
        }
    };

    let push_service = push::PushService::new();
    let auth_state = auth::AuthState::new(ai_provider, db_repo, push_service);
    tracing::info!(
        "WebAuthn configured: rp_id={rp_id}",
        rp_id = std::env::var("RP_ID").unwrap_or_else(|_| "localhost".into())
    );

    push::start_background_worker(auth_state.clone());

    let frontend_dir = std::env::var("FRONTEND_DIR")
        .unwrap_or_else(|_| "frontend/dist".into());
    let frontend_path: PathBuf = frontend_dir.into();
    let frontend_path2 = frontend_path.clone();

    let app = Router::new()
        .merge(routes::api_routes(&auth_state))
        .nest_service(
            "/assets",
            ServeDir::new(frontend_path.join("assets")),
        )
        .fallback(move |req: Request<Body>| {
            frontend_fallback(req, frontend_path2.clone())
        })
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive())
        .with_state(auth_state);

    let port = std::env::var("PORT").unwrap_or_else(|_| "3300".into());
    let addr = format!("0.0.0.0:{port}");
    let listener = tokio::net::TcpListener::bind(&addr)
        .await
        .expect("failed to bind");

    tracing::info!("listening on {}", listener.local_addr().unwrap());

    axum::serve(listener, app).await.expect("server error");
}

async fn frontend_fallback(req: Request<Body>, frontend_path: PathBuf) -> Response {
    let path = req.uri().path().trim_start_matches('/');

    let file_path = frontend_path.join(path);
    if file_path.exists() && file_path.is_file() {
        let data = match tokio::fs::read(&file_path).await {
            Ok(d) => d,
            Err(_) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
        };
        let ext = file_path
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");
        let mime = match ext {
            "css" => "text/css; charset=utf-8",
            "js" => "application/javascript; charset=utf-8",
            "json" => "application/json",
            "png" => "image/png",
            "svg" => "image/svg+xml",
            "ico" => "image/x-icon",
            "wasm" => "application/wasm",
            "html" => "text/html; charset=utf-8",
            _ => "application/octet-stream",
        };
        return Response::builder()
            .header("content-type", mime)
            .body(Body::from(data))
            .unwrap_or_else(|_| (StatusCode::NOT_FOUND, "Not Found").into_response());
    }

    let index_path = frontend_path.join("index.html");
    match tokio::fs::read_to_string(&index_path).await {
        Ok(html) => Response::builder()
            .header("content-type", "text/html; charset=utf-8")
            .body(Body::from(html))
            .unwrap_or_else(|_| (StatusCode::NOT_FOUND, "Not Found").into_response()),
        Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_response(),
    }
}