ayb 0.1.12-rc.7

ayb makes it easy to create, host, and share embedded databases like SQLite and DuckDB
Documentation
use crate::ayb_db::db_interfaces::connect_to_ayb_db;
use crate::ayb_db::db_interfaces::AybDb;
use crate::email::create_email_backends;
use crate::error::AybError;
use crate::hosted_db::daemon_registry::DaemonRegistry;
use crate::server::config::read_config;
use crate::server::config::{AybConfig, AybConfigCors, WebHostingMethod};
use crate::server::snapshots::execution::schedule_periodic_snapshots;
use crate::server::tokens::retrieve_and_validate_api_token;
use crate::server::web_frontend::WebFrontendDetails;
use crate::server::{api_endpoints, ui_endpoints};
use actix_cors::Cors;
use actix_web::dev::ServiceRequest;
use actix_web::{middleware, web, App, Error, HttpMessage, HttpServer};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use actix_web_httpauth::middleware::HttpAuthentication;
use dyn_clone::clone_box;
use std::fs;
use std::path::Path;

pub fn config(cfg: &mut web::ServiceConfig, ayb_config: &AybConfig) {
    // Unauthenticated API endpoints
    cfg.service(api_endpoints::health_endpoint)
        .service(api_endpoints::confirm_endpoint)
        .service(api_endpoints::log_in_endpoint)
        .service(api_endpoints::register_endpoint)
        .service(api_endpoints::oauth_token_endpoint);

    // Authenticated API endpoints
    cfg.service(
        web::scope("/v1")
            .wrap(HttpAuthentication::bearer(entity_validator))
            .service(api_endpoints::create_database_endpoint)
            .service(api_endpoints::database_details_endpoint)
            .service(api_endpoints::update_database_endpoint)
            .service(api_endpoints::query_endpoint)
            .service(api_endpoints::entity_details_endpoint)
            .service(api_endpoints::update_profile_endpoint)
            .service(api_endpoints::list_snapshots_endpoint)
            .service(api_endpoints::restore_snapshot_endpoint)
            .service(api_endpoints::share_endpoint)
            .service(api_endpoints::list_database_permissions_endpoint)
            .service(api_endpoints::list_tokens_endpoint)
            .service(api_endpoints::revoke_token_endpoint),
    );

    // Only add UI routes if web frontend is configured for local serving.
    //
    // CSRF note: the cookie-authenticated UI endpoints below rely on the
    // `auth` cookie's `SameSite=Lax` attribute (set in
    // `ui_endpoints/auth.rs`) as their sole CSRF defense. This works because
    // every state-changing UI endpoint uses a non-safe HTTP method
    // (POST/DELETE/PATCH), and browsers do not send `SameSite=Lax` cookies
    // on cross-origin non-safe-method requests. All GET handlers here are
    // read-only, so `Lax` still delivers the cookie on cross-origin safe-
    // method navigations (required for `GET /oauth/authorize` from external
    // OAuth clients) without opening a CSRF hole.
    //
    // TODO(marcua): if a state-changing GET endpoint is ever added below, or
    // if the UI starts accepting state-changing requests via methods other
    // than cookie-authenticated non-safe methods, add explicit CSRF tokens
    // (e.g. a double-submit cookie or a per-session token embedded in forms)
    // — `SameSite=Lax` alone will not cover those cases.
    if let Some(web_config) = &ayb_config.web {
        if web_config.hosting_method == WebHostingMethod::Local {
            cfg.service(ui_endpoints::log_in_endpoint)
                .service(ui_endpoints::log_in_submit_endpoint)
                .service(ui_endpoints::log_out_endpoint)
                .service(ui_endpoints::register_endpoint)
                .service(ui_endpoints::register_submit_endpoint)
                .service(ui_endpoints::confirm_endpoint)
                .service(ui_endpoints::entity_tokens_endpoint)
                .service(ui_endpoints::revoke_token_endpoint)
                .service(ui_endpoints::oauth_authorize_endpoint)
                .service(ui_endpoints::oauth_authorize_submit_endpoint)
                .service(ui_endpoints::entity_details_endpoint)
                .service(ui_endpoints::create_database_endpoint)
                .service(ui_endpoints::update_profile_endpoint)
                .service(ui_endpoints::database_endpoint)
                .service(ui_endpoints::query_endpoint)
                .service(ui_endpoints::update_public_sharing_endpoint)
                .service(ui_endpoints::share_with_entity_endpoint)
                .service(ui_endpoints::database_permissions_endpoint)
                .service(ui_endpoints::database_snapshots_endpoint)
                .service(ui_endpoints::restore_snapshot_endpoint);
        }
    }
}

async fn entity_validator(
    req: ServiceRequest,
    credentials: BearerAuth,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
    match req.app_data::<web::Data<Box<dyn AybDb>>>() {
        Some(ayb_db) => {
            let api_token = retrieve_and_validate_api_token(credentials.token(), ayb_db).await;
            match api_token {
                Ok(api_token) => {
                    let entity = ayb_db.get_entity_by_id(api_token.entity_id).await;
                    match entity {
                        Ok(entity) => {
                            req.extensions_mut().insert(entity);
                            req.extensions_mut().insert(api_token);
                            Ok(req)
                        }
                        Err(e) => Err((e.into(), req)),
                    }
                }
                Err(e) => Err((e.into(), req)),
            }
        }
        None => Err((
            AybError::Other {
                message: "Misconfigured server: no database".to_string(),
            }
            .into(),
            req,
        )),
    }
}

fn build_cors(ayb_cors: AybConfigCors) -> Cors {
    let mut cors = Cors::default()
        .allow_any_header()
        .allow_any_method()
        .max_age(7200);

    if ayb_cors.origin.trim() == "*" {
        cors = cors.allow_any_origin()
    } else {
        cors = cors.allowed_origin(ayb_cors.origin.trim());
    }

    cors
}

pub async fn run_server(config_path: &Path) -> std::io::Result<()> {
    env_logger::init();

    let ayb_conf = read_config(config_path)
        .unwrap_or_else(|e| panic!("unable to read ayb.toml configuration file: {e}"));
    let ayb_conf_for_server = ayb_conf.clone();
    fs::create_dir_all(&ayb_conf.data_path).expect("unable to create data directory");
    let ayb_db = connect_to_ayb_db(ayb_conf.database_url)
        .await
        .expect("unable to connect to ayb database");
    let web_details = WebFrontendDetails::load(ayb_conf_for_server.clone())
        .await
        .expect("failed to load web frontend details");
    let email_backends = create_email_backends(&ayb_conf.email);

    // Create the daemon registry for managing persistent query runner processes
    let daemon_registry = DaemonRegistry::new();
    // Clone for cleanup handler before moving into closure
    let cleanup_daemon_registry = daemon_registry.clone();

    schedule_periodic_snapshots(ayb_conf_for_server.clone(), ayb_db.clone())
        .await
        .expect("unable to start periodic snapshot scheduler");

    println!("Starting server {}:{}...", ayb_conf.host, ayb_conf.port);
    crate::hosted_db::sandbox::print_isolation_status(
        crate::hosted_db::sandbox::detect_isolation_status(),
    );

    let server = HttpServer::new(move || {
        let cors = build_cors(ayb_conf.cors.clone());

        App::new()
            .wrap(middleware::Logger::default())
            .wrap(middleware::Compress::default())
            .wrap(cors)
            .app_data(web::Data::new(web_details.clone()))
            .app_data(web::Data::new(clone_box(&*ayb_db)))
            .app_data(web::Data::new(ayb_conf_for_server.clone()))
            .app_data(web::Data::new(email_backends.clone()))
            .app_data(web::Data::new(daemon_registry.clone()))
            .configure(|cfg| config(cfg, &ayb_conf_for_server.clone()))
    })
    .bind((ayb_conf.host, ayb_conf.port))?
    .run();

    let server_handle = server.handle();

    // Spawn a task to handle shutdown and clean up daemons
    tokio::spawn(async move {
        tokio::signal::ctrl_c().await.ok();
        println!("Shutting down server and cleaning up daemons...");
        cleanup_daemon_registry.shut_down_all().await;
        server_handle.stop(true).await;
    });

    server.await
}