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 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 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 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 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 let registry = FunctionRegistry::load(&app);
596 for f in registry.iter() {
597 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 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 let base_path = app.config.server.base_path.clone();
648 let spec_url = format!("{base_path}/openapi.json");
649 let spec = openapi::build(&app, ®istry, 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 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 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, ®istry, 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
750fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
752 use std::io::BufReader;
753
754 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}