Skip to main content

apiplant_server/
lib.rs

1//! # apiplant-server
2//!
3//! Turns a loaded [`App`] into a running HTTP service on [`ntex`]:
4//!
5//! * generic CRUD routes for every resource (`<base>/<resource>[/<id>]`),
6//! * built-in auth routes (`<base>/auth/...`),
7//! * one route per loaded function (`<base>/functions/<name>`),
8//! * [lifecycle hooks](hooks) running functions around each CRUD operation,
9//! * the [admin](admin) dashboard, embedded in the binary and served at
10//!   `/admin/` for every app unless `[admin] enabled = false`,
11//! * the app's `public/` directory served at the site root, with a 404 page,
12//! * TLS inferred from the app's `https/` directory.
13
14/// Build the whole `ntex` application from an [`AppState`].
15///
16/// A macro rather than a function because the type of a fully-assembled `ntex`
17/// app is a tower of generics that can't reasonably be written down. Every
18/// route the server answers is registered here, and only here — `run` and the
19/// tests both go through it, so what the tests exercise is what ships.
20///
21/// Order matters: the dashboard and the public site are registered *before* the
22/// API scope, so their literal paths beat its generic `/{resource}` match. Only
23/// paths that name a real file get a route, which is why `/products` still
24/// reaches the API while `/about.html` reaches the static site.
25macro_rules! build_app {
26    ($state:expr) => {{
27        let state = $state.clone();
28        let config = &state.app.config;
29        let domain = config.server.domain.clone();
30        let statics = state.statics.clone();
31
32        // An empty `base_path` means "mount the API at the root" — but ntex's
33        // `scope("")` matches nothing at all, so it has to be spelled `/`.
34        let base_path = match config.server.base_path.as_str() {
35            "" => "/",
36            path => path,
37        };
38        let mut scope = $crate::ntex_web::scope(base_path);
39        if let Some(g) = $crate::host_guard(&domain) {
40            scope = scope.guard(g);
41        }
42        // Docs routes (literal segments) are registered before the generic
43        // `/{resource}` routes so they win.
44        if config.docs.enabled {
45            scope = scope
46                .route(
47                    "/openapi.json",
48                    $crate::ntex_web::get().to($crate::openapi_spec),
49                )
50                .route(
51                    config.docs.path.as_str(),
52                    $crate::ntex_web::get().to($crate::docs_page),
53                );
54        }
55        let mut scope = scope
56            .route("/_health", $crate::ntex_web::get().to($crate::health))
57            .route(
58                "/auth/register",
59                $crate::ntex_web::post().to($crate::auth_routes::register),
60            )
61            .route(
62                "/auth/login",
63                $crate::ntex_web::post().to($crate::auth_routes::login),
64            )
65            .route(
66                "/auth/me",
67                $crate::ntex_web::get().to($crate::auth_routes::me),
68            )
69            .route(
70                "/auth/apikeys",
71                $crate::ntex_web::post().to($crate::auth_routes::create_api_key),
72            );
73
74        // The flows that reach somebody through their mailbox exist only where
75        // this app can actually send mail. An unmounted route answers 404,
76        // which is the honest answer: there is no password reset here. The
77        // admin manifest carries the same three facts, so no interface offers a
78        // button that would land on one.
79        if state.invitations_enabled() {
80            scope = scope
81                .route(
82                    "/auth/invitations",
83                    $crate::ntex_web::post().to($crate::email_auth::create_invitation),
84                )
85                .route(
86                    "/auth/invitations/{token}",
87                    $crate::ntex_web::get().to($crate::email_auth::preview_invitation),
88                )
89                .route(
90                    "/auth/invitations/{token}/accept",
91                    $crate::ntex_web::post().to($crate::email_auth::accept_invitation),
92                );
93        }
94        if state.requires_email_verification() {
95            scope = scope
96                .route(
97                    "/auth/verify-email",
98                    $crate::ntex_web::post().to($crate::email_auth::verify_email),
99                )
100                .route(
101                    "/auth/verify-email/resend",
102                    $crate::ntex_web::post().to($crate::email_auth::resend_verification),
103                );
104        }
105        if state.password_reset_enabled() {
106            scope = scope
107                .route(
108                    "/auth/password/forgot",
109                    $crate::ntex_web::post().to($crate::email_auth::forgot_password),
110                )
111                .route(
112                    "/auth/password/reset",
113                    $crate::ntex_web::post().to($crate::email_auth::reset_password),
114                );
115        }
116
117        // Billing exists only where a provider does. The `billing_*`
118        // resources are absent in the same case, so an app that takes no
119        // money has neither the endpoints nor the tables.
120        if state.payments_enabled() {
121            scope = scope
122                .route(
123                    "/billing/config",
124                    $crate::ntex_web::get().to($crate::billing::config),
125                )
126                .route(
127                    "/billing/checkout",
128                    $crate::ntex_web::post().to($crate::billing::checkout),
129                )
130                .route(
131                    "/billing/portal",
132                    $crate::ntex_web::post().to($crate::billing::portal),
133                )
134                // Stripe's own deliveries. Not authenticated in the ordinary
135                // sense — the body carries a signature — and mounted even
136                // without a `webhook_secret`, where it refuses everything and
137                // says so, rather than 404ing and leaving an operator to
138                // wonder which half is misconfigured.
139                .route(
140                    "/billing/webhook",
141                    $crate::ntex_web::post().to($crate::billing::webhook),
142                );
143        }
144
145        // The assistant exists only where a provider does, like billing and
146        // like the mailbox flows: an app with no `[ai]` section has no
147        // endpoint to 404 on and no button offering one.
148        if state.ai_enabled() {
149            scope = scope
150                .route(
151                    "/ai/config",
152                    $crate::ntex_web::get().to($crate::ai_routes::config),
153                )
154                .route(
155                    "/ai/agents/{name}/chat",
156                    $crate::ntex_web::post().to($crate::agent_routes::chat),
157                )
158                .route(
159                    "/ai/chat",
160                    $crate::ntex_web::post().to($crate::ai_routes::chat),
161                );
162        }
163
164        let mut scope = scope
165            // Literal `functions` segment is registered before the generic
166            // resource routes so it wins over `/{resource}/{id}`.
167            // The streaming form is registered first: `/functions/{name}`
168            // would otherwise match `/functions/summarise/stream` with a name
169            // of `summarise` and lose the suffix.
170            .route(
171                "/functions/{name}/stream",
172                $crate::ntex_web::route().to($crate::function_routes::stream),
173            )
174            .route(
175                "/functions/{name}",
176                $crate::ntex_web::route().to($crate::function_routes::invoke),
177            )
178            .service(
179                $crate::ntex_web::resource("/{resource}")
180                    .route($crate::ntex_web::get().to($crate::crud::list))
181                    .route($crate::ntex_web::post().to($crate::crud::create)),
182            )
183            .service(
184                $crate::ntex_web::resource("/{resource}/{id}")
185                    .route($crate::ntex_web::get().to($crate::crud::get))
186                    .route($crate::ntex_web::patch().to($crate::crud::update))
187                    .route($crate::ntex_web::put().to($crate::crud::update))
188                    .route($crate::ntex_web::delete().to($crate::crud::delete)),
189            )
190            // Nested has_many: GET /parent/{id}/child
191            .route(
192                "/{parent}/{id}/{child}",
193                $crate::ntex_web::get().to($crate::crud::nested_list),
194            );
195
196        // With the API mounted at the root, its scope swallows every unmatched
197        // path, so the 404 page has to be its default too — not just the app's.
198        if statics.not_found_page.is_some() {
199            scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
200        }
201
202        let mut app = $crate::ntex_web::App::new().state(state.clone());
203
204        // Root-level routes answer for the configured domain only, exactly as
205        // the API scope does.
206        macro_rules! guarded {
207            ($path:expr) => {{
208                let resource = $crate::ntex_web::resource($path);
209                match $crate::host_guard(&domain) {
210                    Some(g) => resource.guard(g),
211                    None => resource,
212                }
213            }};
214        }
215
216        if let Some(admin_path) = &statics.admin_path {
217            app = app
218                .service(
219                    guarded!(format!("{admin_path}/"))
220                        .route($crate::ntex_web::get().to($crate::admin_index)),
221                )
222                .service(
223                    guarded!(format!("{admin_path}/{{path:.*}}"))
224                        .route($crate::ntex_web::get().to($crate::admin_asset)),
225                )
226                // `/admin` without the slash would otherwise 404; the page loads
227                // its assets relatively, so it has to resolve as a directory.
228                .service(
229                    guarded!(admin_path.as_str())
230                        .route($crate::ntex_web::get().to($crate::admin_redirect)),
231                );
232        }
233
234        for route in &statics.public_routes {
235            app = app.service(
236                guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
237            );
238        }
239
240        app = app.service(scope);
241        if statics.not_found_page.is_some() {
242            app = app.default_service($crate::ntex_web::to($crate::not_found_route));
243        }
244        app
245    }};
246}
247
248/// A `Host:` guard matching any of the configured domains, or `None` when no
249/// domains are configured and every host should be answered.
250pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
251    if domains.is_empty() {
252        return None;
253    }
254    Some(ntex_guard::AnyGuard(
255        domains
256            .iter()
257            .map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
258            .collect(),
259    ))
260}
261
262pub mod access;
263pub mod admin;
264mod agent_routes;
265mod ai_routes;
266mod auth_routes;
267mod banner;
268mod billing;
269pub mod builtins;
270pub mod cabi;
271mod crud;
272pub mod email_auth;
273mod emails;
274mod function_routes;
275pub mod functions;
276pub mod hooks;
277mod openapi;
278mod response;
279mod sse;
280mod state;
281#[cfg(test)]
282mod tests;
283
284use std::sync::Arc;
285use std::{fs, path::Component, path::Path, path::PathBuf};
286
287use apiplant_auth::Authenticator;
288use apiplant_core::{App, TlsPaths};
289use apiplant_db::Db;
290use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
291
292// Re-exported under crate-local names so `build_app!` can name them absolutely
293// and expand anywhere in the crate, tests included.
294pub(crate) use ntex::web as ntex_web;
295pub(crate) use ntex::web::guard as ntex_guard;
296use uuid::Uuid;
297
298use functions::FunctionRegistry;
299use state::{AppState, Statics};
300
301async fn health() -> HttpResponse {
302    HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
303}
304
305/// Serve the pre-rendered OpenAPI document.
306async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
307    HttpResponse::Ok()
308        .content_type("application/json")
309        .body(state.openapi_json.as_str().to_owned())
310}
311
312/// Serve the Swagger UI page.
313async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
314    HttpResponse::Ok()
315        .content_type("text/html; charset=utf-8")
316        .body(state.docs_html.as_str().to_owned())
317}
318
319async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
320    serve_admin(&state, "index.html")
321}
322
323async fn admin_asset(
324    state: web::types::State<AppState>,
325    path: web::types::Path<String>,
326) -> HttpResponse {
327    let path = path.into_inner();
328    serve_admin(&state, &path)
329}
330
331/// Serve one file of the dashboard.
332///
333/// Everything comes out of the binary: the files from the embedded build, the
334/// manifest from memory — it describes *this* app, and is built on boot. There
335/// is no directory to generate and none to go stale.
336fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
337    let requested = requested.trim_start_matches('/');
338
339    if requested == admin::MANIFEST_FILE {
340        return HttpResponse::Ok()
341            .content_type("application/json")
342            .body(state.admin_manifest.as_str().to_owned());
343    }
344
345    match admin::asset(requested) {
346        Some(bytes) => HttpResponse::Ok()
347            .content_type(apiplant_assets::content_type(requested))
348            .body(bytes.into_owned()),
349        None => HttpResponse::NotFound().finish(),
350    }
351}
352
353/// Serve a file from the app's `public/` directory.
354///
355/// Routes are registered per file at boot, so the path always names something
356/// that existed then; it is re-resolved here so edits are picked up without a
357/// restart, and a file deleted since boot answers with the 404 page.
358async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
359    let Some(root) = state.statics.public_dir.as_deref() else {
360        return not_found(&state);
361    };
362    serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
363}
364
365/// Anything that matched no route at all: the app's 404 page, or a bare 404.
366async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
367    not_found(&state)
368}
369
370fn not_found(state: &AppState) -> HttpResponse {
371    let Some(page) = state.statics.not_found_page.as_deref() else {
372        return HttpResponse::NotFound().finish();
373    };
374    match fs::read(page) {
375        Ok(bytes) => HttpResponse::NotFound()
376            .content_type(content_type_for(page))
377            .body(bytes),
378        Err(error) => {
379            tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
380            HttpResponse::NotFound().finish()
381        }
382    }
383}
384
385/// Read a file under `root`, or `None` when it isn't there.
386fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
387    let path = resolve_static_path(root, requested)?;
388    match fs::read(&path) {
389        Ok(bytes) => Some(
390            HttpResponse::Ok()
391                .content_type(content_type_for(&path))
392                .body(bytes),
393        ),
394        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
395        Err(error) => {
396            tracing::error!(path = %path.display(), error = %error, "failed to read static file");
397            Some(HttpResponse::InternalServerError().finish())
398        }
399    }
400}
401
402fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
403    let mut path = root.to_path_buf();
404    let requested = requested.trim_matches('/');
405
406    if requested.is_empty() {
407        path.push("index.html");
408        return Some(path);
409    }
410
411    for component in Path::new(requested).components() {
412        match component {
413            Component::Normal(segment) => path.push(segment),
414            Component::CurDir => {}
415            _ => return None,
416        }
417    }
418
419    if path.is_dir() {
420        path.push("index.html");
421    }
422    Some(path)
423}
424
425fn content_type_for(path: &Path) -> &'static str {
426    apiplant_assets::content_type(&path.to_string_lossy())
427}
428
429/// `/admin` → `/admin/`, so relative asset URLs resolve.
430async fn admin_redirect(req: HttpRequest) -> HttpResponse {
431    HttpResponse::PermanentRedirect()
432        .header("location", format!("{}/", req.path()))
433        .finish()
434}
435
436/// The route patterns one public file answers on.
437///
438/// A file is served at its own path; an `index.html` additionally answers for
439/// the directory holding it, with and without the trailing slash. Returns
440/// nothing for names ntex would read as a path pattern (`{`, `}`) or that would
441/// escape the root — those are skipped rather than mis-registered.
442fn public_routes(relative: &str) -> Vec<String> {
443    if relative
444        .split('/')
445        .any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
446    {
447        tracing::warn!(
448            file = relative,
449            "skipping public file: its name can't be a route"
450        );
451        return Vec::new();
452    }
453
454    let mut routes = vec![format!("/{relative}")];
455    if let Some(directory) = relative.strip_suffix("index.html") {
456        let directory = directory.trim_end_matches('/');
457        if directory.is_empty() {
458            routes.push("/".to_string());
459        } else {
460            routes.push(format!("/{directory}/"));
461            routes.push(format!("/{directory}"));
462        }
463    }
464    routes
465}
466
467/// Every file under `root`, as site-root-relative paths (`css/app.css`).
468///
469/// Used to register one route per public file, which is what lets a static site
470/// share the root with the API: an explicit `/about.html` route is matched
471/// before the generic `/{resource}` CRUD route, while `/products` still reaches
472/// the API because no such file exists.
473fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
474    let entries = match fs::read_dir(root) {
475        Ok(entries) => entries,
476        Err(error) => {
477            tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
478            return;
479        }
480    };
481    for entry in entries.flatten() {
482        let name = entry.file_name().to_string_lossy().into_owned();
483        let relative = if prefix.is_empty() {
484            name
485        } else {
486            format!("{prefix}/{name}")
487        };
488        if entry.path().is_dir() {
489            walk_public(&entry.path(), &relative, into);
490        } else {
491            into.push(relative);
492        }
493    }
494}
495
496/// Boot the server for a loaded app and serve until shut down.
497pub async fn run(app: App) -> anyhow::Result<()> {
498    // 1. Database + migrations.
499    let db_url = app.config.database.resolved_url();
500    tracing::info!("connecting to database");
501    let db = Db::connect(&db_url, app.config.database.max_connections).await?;
502    if app.config.database.auto_migrate {
503        tracing::info!("running migrations");
504        apiplant_db::migrate(db.connection(), &app).await?;
505    }
506
507    // 2. Authenticator (ephemeral secret if none configured).
508    let secret = if app.config.auth.jwt_secret.is_empty() {
509        tracing::warn!(
510            "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
511        );
512        format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
513    } else {
514        app.config.auth.jwt_secret.clone().into_bytes()
515    };
516    let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
517
518    // 2b. Optional services a function can reach: the email provider and the
519    //     cache. Both are built here, once, and shared by every worker — and
520    //     both fail the boot when the app asked for one it can't have, rather
521    //     than at the first send or the first lookup.
522    let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
523    match &mailer {
524        Some(mailer) => tracing::info!(
525            "  email -> {} (from {})",
526            mailer.provider().as_str(),
527            app.config.email.from
528        ),
529        None => tracing::debug!("no email provider configured"),
530    }
531
532    let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
533    match &cache {
534        Some(_) => tracing::info!(
535            "  cache -> redis (prefix {:?})",
536            app.config.cache.prefix.as_str()
537        ),
538        None => tracing::debug!("no cache configured"),
539    }
540
541    let ai = apiplant_ai::Ai::from_config(&app.config.ai)?;
542    let agent_ais = app
543        .agents
544        .values()
545        .filter_map(|agent| {
546            agent.ai.as_ref().map(|_| {
547                apiplant_ai::Ai::from_config(&agent.merged_ai_config(&app.config.ai))
548                    .map(|ai| (agent.meta.name.clone(), ai))
549            })
550        })
551        .collect::<Result<Vec<_>, _>>()?
552        .into_iter()
553        .filter_map(|(name, ai)| ai.map(|ai| (name, ai)))
554        .collect();
555    match &ai {
556        Some(ai) => tracing::info!(
557            "  ai -> {} ({} at {})",
558            ai.provider().as_str(),
559            match ai.model() {
560                "" => "the server's own model",
561                model => model,
562            },
563            ai.url()
564        ),
565        None => tracing::debug!("no ai provider configured"),
566    }
567
568    // A buyer Stripe returns to needs somewhere to land, and this crate is
569    // the only thing that knows where that is: the dashboard's billing
570    // screen, or the app's own origin when the dashboard is switched off.
571    let billing_landing = match app.config.admin.enabled {
572        true => format!(
573            "{}{}/#/billing",
574            app.config.server.public_origin(),
575            app.config.admin.path.trim_end_matches('/')
576        ),
577        false => app.config.server.public_origin(),
578    };
579    let payments =
580        apiplant_payments::Payments::from_config(&app.config.payments, &billing_landing)?;
581    match &payments {
582        Some(payments) => tracing::info!(
583            "  payments -> {} ({}, automatic tax {})",
584            payments.provider().as_str(),
585            app.config.payments.default_currency(),
586            match app.config.payments.automatic_tax {
587                true => "on",
588                false => "off",
589            }
590        ),
591        None => tracing::debug!("no payment provider configured"),
592    }
593
594    // 3. Load dynamic functions.
595    let registry = FunctionRegistry::load(&app);
596    for f in registry.iter() {
597        // A `Private` function has no route — it exists to be called from a
598        // hook — so don't advertise one it would answer 404 on.
599        if f.manifest.visibility == apiplant_abi::Visibility::Private {
600            tracing::info!("  fn {} (private — no endpoint)", f.manifest.name);
601        } else {
602            tracing::info!(
603                "  fn {} -> {}/functions/{}",
604                f.manifest.name,
605                app.config.server.base_path,
606                f.manifest.name
607            );
608        }
609    }
610
611    // 4. Report the resource hooks, loudly flagging any that can't resolve —
612    //    a missing hook function fails its requests closed at runtime.
613    for resource in app.resources.values() {
614        for (event, function) in resource.hooks.iter() {
615            if registry.get(function).is_some() {
616                tracing::info!(
617                    "  hook {}.{} -> {}",
618                    resource.meta.name,
619                    event.as_str(),
620                    function
621                );
622            } else {
623                tracing::error!(
624                    resource = %resource.meta.name,
625                    hook = event.as_str(),
626                    function = function,
627                    "hook function is not loaded — this resource's {} requests will fail with 500",
628                    event.action()
629                );
630            }
631        }
632        for (event, function) in resource.hooks.auth_iter() {
633            if registry.get(function).is_some() {
634                tracing::info!("  hook auth.{} -> {}", event.as_str(), function);
635            } else {
636                tracing::error!(
637                    hook = event.as_str(),
638                    function = function,
639                    "auth hook function is not loaded — {} requests will fail with 500",
640                    event.action()
641                );
642            }
643        }
644    }
645
646    // 5. Generate the OpenAPI document + Swagger UI (once; static per boot).
647    let base_path = app.config.server.base_path.clone();
648    let spec_url = format!("{base_path}/openapi.json");
649    let spec = openapi::build(&app, &registry, mailer.is_some());
650    let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
651    let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
652    if app.config.docs.enabled {
653        tracing::info!(
654            "  docs -> {base_path}{}  (spec: {spec_url})",
655            app.config.docs.path
656        );
657    }
658
659    // 6. Assemble shared state and pull out what the closure needs.
660    let host = app.config.server.host.clone();
661    let port = app.config.server.port;
662    let banner_docs_path = app
663        .config
664        .docs
665        .enabled
666        .then(|| app.config.docs.path.clone());
667    let banner_domains = app.config.server.domain.clone();
668    let banner_name = app.display_name();
669    let workers = app.config.server.workers;
670    let tls = app.tls.clone();
671
672    // 7. Work out what is served alongside the API — the dashboard, the public
673    //    site, the 404 page — and build the dashboard's manifest.
674    //
675    //    The dashboard ships inside the binary, so every app has one without
676    //    generating anything; an `admin/` directory in the app (from `apiplant
677    //    admin`) overrides the embedded build file for file. Either way the
678    //    manifest is derived here, from the app being served, and the dashboard
679    //    talks to its own origin — no CORS, and no rebuild after a model change.
680    let statics = Statics::resolve(&app);
681    let banner_admin_path = statics.admin_path.clone();
682    let banner_site = !statics.public_routes.is_empty();
683    let admin_manifest = match &statics.admin_path {
684        Some(path) => {
685            tracing::info!("  admin -> {path}/");
686            admin::manifest_json(&app, &registry, base_path.clone(), mailer.is_some()).unwrap_or_else(|error| {
687                tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
688                "{}".to_string()
689            })
690        }
691        None => String::new(),
692    };
693    if let Some(dir) = &statics.public_dir {
694        tracing::info!(
695            routes = statics.public_routes.len(),
696            "  public -> /  (from {})",
697            dir.display()
698        );
699    }
700    if let Some(page) = &statics.not_found_page {
701        tracing::info!("  404 -> {}", page.display());
702    }
703
704    let state = AppState {
705        app: Arc::new(app),
706        db,
707        auth: authr,
708        functions: Arc::new(registry),
709        mailer,
710        cache,
711        payments,
712        ai,
713        agent_ais: Arc::new(agent_ais),
714        statics: Arc::new(statics),
715        admin_manifest: Arc::new(admin_manifest),
716        openapi_json: Arc::new(openapi_json),
717        docs_html: Arc::new(docs_html),
718    };
719
720    let base_path_log = base_path.clone();
721    let mut server = HttpServer::new(move || build_app!(state));
722
723    if let Some(w) = workers {
724        server = server.workers(w);
725    }
726
727    let addr = format!("{host}:{port}");
728    let scheme = if tls.is_some() { "https" } else { "http" };
729    let server = match tls {
730        Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
731        None => server.bind(&addr)?,
732    };
733
734    tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
735    banner::Banner {
736        name: banner_name,
737        scheme,
738        addr: addr.clone(),
739        base_path: base_path_log.clone(),
740        docs_path: banner_docs_path,
741        admin_path: banner_admin_path,
742        site: banner_site,
743        domains: banner_domains,
744    }
745    .print();
746    server.run().await?;
747    Ok(())
748}
749
750/// Build a rustls server config from PEM cert + key files.
751fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
752    use std::io::BufReader;
753
754    // Install a default crypto provider once (ring); ignore "already set".
755    let _ = rustls::crypto::ring::default_provider().install_default();
756
757    let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
758    let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
759
760    let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
761    let key = rustls_pemfile::private_key(&mut key_reader)?
762        .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
763
764    let config = rustls::ServerConfig::builder()
765        .with_no_client_auth()
766        .with_single_cert(certs, key)?;
767    Ok(config)
768}
769
770#[cfg(test)]
771mod route_tests {
772    use super::*;
773
774    #[test]
775    fn an_index_answers_for_its_directory_too() {
776        assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
777        assert_eq!(
778            public_routes("guide/index.html"),
779            ["/guide/index.html", "/guide/", "/guide"]
780        );
781        assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
782    }
783
784    #[test]
785    fn names_that_cannot_be_routes_are_skipped() {
786        assert!(public_routes("weird{name}.html").is_empty());
787        assert!(public_routes("../escape.html").is_empty());
788    }
789
790    #[test]
791    fn static_paths_resolve_under_the_root_and_never_above_it() {
792        let root = Path::new("/srv/app/public");
793        assert_eq!(
794            resolve_static_path(root, "/css/app.css"),
795            Some(root.join("css/app.css"))
796        );
797        assert_eq!(
798            resolve_static_path(root, "/"),
799            Some(root.join("index.html"))
800        );
801        assert_eq!(resolve_static_path(root, "/../main.toml"), None);
802    }
803}