1macro_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 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 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 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 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 .route(
140 "/billing/webhook",
141 $crate::ntex_web::post().to($crate::billing::webhook),
142 );
143 }
144
145 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 .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 .route(
192 "/{parent}/{id}/{child}",
193 $crate::ntex_web::get().to($crate::crud::nested_list),
194 );
195
196 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 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 .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
248pub(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
292pub(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
305async 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
312async 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
331fn 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
353async 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
365async 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
385fn 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
429async fn admin_redirect(req: HttpRequest) -> HttpResponse {
431 HttpResponse::PermanentRedirect()
432 .header("location", format!("{}/", req.path()))
433 .finish()
434}
435
436fn 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
467fn 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
496pub async fn run(app: App) -> anyhow::Result<()> {
498 run_with(app, Options::default()).await
499}
500
501#[derive(Debug, Clone, Default)]
503pub struct Options {
504 pub seed: bool,
508}
509
510pub async fn run_with(app: App, options: Options) -> anyhow::Result<()> {
512 let db_url = app.config.database.resolved_url();
514 tracing::info!("connecting to database");
515 let db = Db::connect(&db_url, app.config.database.max_connections).await?;
516 if app.config.database.auto_migrate {
517 tracing::info!("running migrations");
518 apiplant_db::migrate(db.connection(), &app).await?;
519 }
520 if options.seed {
521 let report = apiplant_db::seed::seed(db.connection(), &app).await?;
525 if report.is_empty() {
526 tracing::warn!("--seed was given but there is no seed/ directory to load");
527 } else {
528 tracing::info!(
529 inserted = report.inserted(),
530 already_present = report.skipped(),
531 "seeded"
532 );
533 }
534 }
535
536 let secret = if app.config.auth.jwt_secret.is_empty() {
538 tracing::warn!(
539 "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
540 );
541 format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
542 } else {
543 app.config.auth.jwt_secret.clone().into_bytes()
544 };
545 let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
546
547 let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
552 match &mailer {
553 Some(mailer) => tracing::info!(
554 " email -> {} (from {})",
555 mailer.provider().as_str(),
556 app.config.email.from
557 ),
558 None => tracing::debug!("no email provider configured"),
559 }
560
561 let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
562 match &cache {
563 Some(_) => tracing::info!(
564 " cache -> redis (prefix {:?})",
565 app.config.cache.prefix.as_str()
566 ),
567 None => tracing::debug!("no cache configured"),
568 }
569
570 let ai = apiplant_ai::Ai::from_config(&app.config.ai)?;
571 let agent_ais = app
572 .agents
573 .values()
574 .filter_map(|agent| {
575 agent.ai.as_ref().map(|_| {
576 apiplant_ai::Ai::from_config(&agent.merged_ai_config(&app.config.ai))
577 .map(|ai| (agent.meta.name.clone(), ai))
578 })
579 })
580 .collect::<Result<Vec<_>, _>>()?
581 .into_iter()
582 .filter_map(|(name, ai)| ai.map(|ai| (name, ai)))
583 .collect();
584 match &ai {
585 Some(ai) => tracing::info!(
586 " ai -> {} ({} at {})",
587 ai.provider().as_str(),
588 match ai.model() {
589 "" => "the server's own model",
590 model => model,
591 },
592 ai.url()
593 ),
594 None => tracing::debug!("no ai provider configured"),
595 }
596
597 let billing_landing = match app.config.admin.enabled {
601 true => format!(
602 "{}{}/#/billing",
603 app.config.server.public_origin(),
604 app.config.admin.path.trim_end_matches('/')
605 ),
606 false => app.config.server.public_origin(),
607 };
608 let payments =
609 apiplant_payments::Payments::from_config(&app.config.payments, &billing_landing)?;
610 match &payments {
611 Some(payments) => tracing::info!(
612 " payments -> {} ({}, automatic tax {})",
613 payments.provider().as_str(),
614 app.config.payments.default_currency(),
615 match app.config.payments.automatic_tax {
616 true => "on",
617 false => "off",
618 }
619 ),
620 None => tracing::debug!("no payment provider configured"),
621 }
622
623 let registry = FunctionRegistry::load(&app);
625 for f in registry.iter() {
626 if f.manifest.visibility == apiplant_abi::Visibility::Private {
629 tracing::info!(" fn {} (private — no endpoint)", f.manifest.name);
630 } else {
631 tracing::info!(
632 " fn {} -> {}/functions/{}",
633 f.manifest.name,
634 app.config.server.base_path,
635 f.manifest.name
636 );
637 }
638 }
639
640 for resource in app.resources.values() {
643 for (event, function) in resource.hooks.iter() {
644 if registry.get(function).is_some() {
645 tracing::info!(
646 " hook {}.{} -> {}",
647 resource.meta.name,
648 event.as_str(),
649 function
650 );
651 } else {
652 tracing::error!(
653 resource = %resource.meta.name,
654 hook = event.as_str(),
655 function = function,
656 "hook function is not loaded — this resource's {} requests will fail with 500",
657 event.action()
658 );
659 }
660 }
661 for (event, function) in resource.hooks.auth_iter() {
662 if registry.get(function).is_some() {
663 tracing::info!(" hook auth.{} -> {}", event.as_str(), function);
664 } else {
665 tracing::error!(
666 hook = event.as_str(),
667 function = function,
668 "auth hook function is not loaded — {} requests will fail with 500",
669 event.action()
670 );
671 }
672 }
673 }
674
675 let base_path = app.config.server.base_path.clone();
677 let spec_url = format!("{base_path}/openapi.json");
678 let spec = openapi::build(&app, ®istry, mailer.is_some());
679 let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
680 let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
681 if app.config.docs.enabled {
682 tracing::info!(
683 " docs -> {base_path}{} (spec: {spec_url})",
684 app.config.docs.path
685 );
686 }
687
688 let host = app.config.server.host.clone();
690 let port = app.config.server.port;
691 let banner_docs_path = app
692 .config
693 .docs
694 .enabled
695 .then(|| app.config.docs.path.clone());
696 let banner_domains = app.config.server.domain.clone();
697 let banner_name = app.display_name();
698 let workers = app.config.server.workers;
699 let tls = app.tls.clone();
700
701 let statics = Statics::resolve(&app);
710 let banner_admin_path = statics.admin_path.clone();
711 let banner_site = !statics.public_routes.is_empty();
712 let admin_manifest = match &statics.admin_path {
713 Some(path) => {
714 tracing::info!(" admin -> {path}/");
715 admin::manifest_json(&app, ®istry, base_path.clone(), mailer.is_some()).unwrap_or_else(|error| {
716 tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
717 "{}".to_string()
718 })
719 }
720 None => String::new(),
721 };
722 if let Some(dir) = &statics.public_dir {
723 tracing::info!(
724 routes = statics.public_routes.len(),
725 " public -> / (from {})",
726 dir.display()
727 );
728 }
729 if let Some(page) = &statics.not_found_page {
730 tracing::info!(" 404 -> {}", page.display());
731 }
732
733 let state = AppState {
734 app: Arc::new(app),
735 db,
736 auth: authr,
737 functions: Arc::new(registry),
738 mailer,
739 cache,
740 payments,
741 ai,
742 agent_ais: Arc::new(agent_ais),
743 statics: Arc::new(statics),
744 admin_manifest: Arc::new(admin_manifest),
745 openapi_json: Arc::new(openapi_json),
746 docs_html: Arc::new(docs_html),
747 };
748
749 let base_path_log = base_path.clone();
750 let mut server = HttpServer::new(move || build_app!(state));
751
752 if let Some(w) = workers {
753 server = server.workers(w);
754 }
755
756 let addr = format!("{host}:{port}");
757 let scheme = if tls.is_some() { "https" } else { "http" };
758 let server = match tls {
759 Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
760 None => server.bind(&addr)?,
761 };
762
763 tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
764 banner::Banner {
765 name: banner_name,
766 scheme,
767 addr: addr.clone(),
768 base_path: base_path_log.clone(),
769 docs_path: banner_docs_path,
770 admin_path: banner_admin_path,
771 site: banner_site,
772 domains: banner_domains,
773 }
774 .print();
775 server.run().await?;
776 Ok(())
777}
778
779fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
781 use std::io::BufReader;
782
783 let _ = rustls::crypto::ring::default_provider().install_default();
785
786 let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
787 let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
788
789 let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
790 let key = rustls_pemfile::private_key(&mut key_reader)?
791 .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
792
793 let config = rustls::ServerConfig::builder()
794 .with_no_client_auth()
795 .with_single_cert(certs, key)?;
796 Ok(config)
797}
798
799#[cfg(test)]
800mod route_tests {
801 use super::*;
802
803 #[test]
804 fn an_index_answers_for_its_directory_too() {
805 assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
806 assert_eq!(
807 public_routes("guide/index.html"),
808 ["/guide/index.html", "/guide/", "/guide"]
809 );
810 assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
811 }
812
813 #[test]
814 fn names_that_cannot_be_routes_are_skipped() {
815 assert!(public_routes("weird{name}.html").is_empty());
816 assert!(public_routes("../escape.html").is_empty());
817 }
818
819 #[test]
820 fn static_paths_resolve_under_the_root_and_never_above_it() {
821 let root = Path::new("/srv/app/public");
822 assert_eq!(
823 resolve_static_path(root, "/css/app.css"),
824 Some(root.join("css/app.css"))
825 );
826 assert_eq!(
827 resolve_static_path(root, "/"),
828 Some(root.join("index.html"))
829 );
830 assert_eq!(resolve_static_path(root, "/../main.toml"), None);
831 }
832}