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.oauth_enabled() {
123 scope = scope
124 .route(
125 "/auth/oauth",
126 $crate::ntex_web::get().to($crate::oauth_routes::providers),
127 )
128 .route(
129 "/auth/oauth/{provider}/start",
130 $crate::ntex_web::get().to($crate::oauth_routes::start_redirect),
131 )
132 .route(
133 "/auth/oauth/{provider}/start",
134 $crate::ntex_web::post().to($crate::oauth_routes::start_json),
135 )
136 .route(
137 "/auth/oauth/{provider}/callback",
138 $crate::ntex_web::get().to($crate::oauth_routes::callback_redirect),
139 )
140 .route(
141 "/auth/oauth/{provider}/callback",
142 $crate::ntex_web::post().to($crate::oauth_routes::callback_json),
143 )
144 .route(
145 "/auth/oauth/{provider}",
146 $crate::ntex_web::delete().to($crate::oauth_routes::unlink),
147 );
148 }
149
150 if state.payments_enabled() {
154 scope = scope
155 .route(
156 "/billing/config",
157 $crate::ntex_web::get().to($crate::billing::config),
158 )
159 .route(
160 "/billing/checkout",
161 $crate::ntex_web::post().to($crate::billing::checkout),
162 )
163 .route(
164 "/billing/portal",
165 $crate::ntex_web::post().to($crate::billing::portal),
166 )
167 .route(
173 "/billing/webhook",
174 $crate::ntex_web::post().to($crate::billing::webhook),
175 );
176 }
177
178 if state.ai_enabled() {
182 scope = scope
183 .route(
184 "/ai/config",
185 $crate::ntex_web::get().to($crate::ai_routes::config),
186 )
187 .route(
188 "/ai/agents/{name}/chat",
189 $crate::ntex_web::post().to($crate::agent_routes::chat),
190 )
191 .route(
192 "/ai/chat",
193 $crate::ntex_web::post().to($crate::ai_routes::chat),
194 );
195 }
196
197 let mut scope = scope
198 .route(
204 "/functions/{name}/stream",
205 $crate::ntex_web::route().to($crate::function_routes::stream),
206 )
207 .route(
208 "/functions/{name}",
209 $crate::ntex_web::route().to($crate::function_routes::invoke),
210 )
211 .service(
212 $crate::ntex_web::resource("/{resource}")
213 .route($crate::ntex_web::get().to($crate::crud::list))
214 .route($crate::ntex_web::post().to($crate::crud::create)),
215 )
216 .service(
217 $crate::ntex_web::resource("/{resource}/{id}")
218 .route($crate::ntex_web::get().to($crate::crud::get))
219 .route($crate::ntex_web::patch().to($crate::crud::update))
220 .route($crate::ntex_web::put().to($crate::crud::update))
221 .route($crate::ntex_web::delete().to($crate::crud::delete)),
222 )
223 .route(
225 "/{parent}/{id}/{child}",
226 $crate::ntex_web::get().to($crate::crud::nested_list),
227 );
228
229 if statics.not_found_page.is_some() {
232 scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
233 }
234
235 let mut app = $crate::ntex_web::App::new().state(state.clone());
236
237 macro_rules! guarded {
240 ($path:expr) => {{
241 let resource = $crate::ntex_web::resource($path);
242 match $crate::host_guard(&domain) {
243 Some(g) => resource.guard(g),
244 None => resource,
245 }
246 }};
247 }
248
249 if let Some(admin_path) = &statics.admin_path {
250 app = app
251 .service(
252 guarded!(format!("{admin_path}/"))
253 .route($crate::ntex_web::get().to($crate::admin_index)),
254 )
255 .service(
256 guarded!(format!("{admin_path}/{{path:.*}}"))
257 .route($crate::ntex_web::get().to($crate::admin_asset)),
258 )
259 .service(
262 guarded!(admin_path.as_str())
263 .route($crate::ntex_web::get().to($crate::admin_redirect)),
264 );
265 }
266
267 for route in &statics.public_routes {
268 app = app.service(
269 guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
270 );
271 }
272
273 app = app.service(scope);
274 if statics.not_found_page.is_some() {
275 app = app.default_service($crate::ntex_web::to($crate::not_found_route));
276 }
277 app
278 }};
279}
280
281pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
284 if domains.is_empty() {
285 return None;
286 }
287 Some(ntex_guard::AnyGuard(
288 domains
289 .iter()
290 .map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
291 .collect(),
292 ))
293}
294
295pub mod access;
296pub mod admin;
297mod agent_routes;
298mod ai_routes;
299mod auth_routes;
300mod banner;
301mod billing;
302pub mod builtins;
303pub mod cabi;
304mod crud;
305pub mod email_auth;
306mod emails;
307mod function_routes;
308pub mod functions;
309pub mod hooks;
310mod oauth_routes;
311mod openapi;
312mod response;
313mod sse;
314mod state;
315#[cfg(test)]
316mod tests;
317
318use std::sync::Arc;
319use std::{fs, path::Component, path::Path, path::PathBuf};
320
321use apiplant_auth::Authenticator;
322use apiplant_core::{App, TlsPaths};
323use apiplant_db::Db;
324use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
325
326pub(crate) use ntex::web as ntex_web;
329pub(crate) use ntex::web::guard as ntex_guard;
330use uuid::Uuid;
331
332use functions::FunctionRegistry;
333use state::{AppState, Statics};
334
335async fn health() -> HttpResponse {
336 HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
337}
338
339async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
341 HttpResponse::Ok()
342 .content_type("application/json")
343 .body(state.openapi_json.as_str().to_owned())
344}
345
346async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
348 HttpResponse::Ok()
349 .content_type("text/html; charset=utf-8")
350 .body(state.docs_html.as_str().to_owned())
351}
352
353async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
354 serve_admin(&state, "index.html")
355}
356
357async fn admin_asset(
358 state: web::types::State<AppState>,
359 path: web::types::Path<String>,
360) -> HttpResponse {
361 let path = path.into_inner();
362 serve_admin(&state, &path)
363}
364
365fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
371 let requested = requested.trim_start_matches('/');
372
373 if requested == admin::MANIFEST_FILE {
374 return HttpResponse::Ok()
375 .content_type("application/json")
376 .body(state.admin_manifest.as_str().to_owned());
377 }
378
379 match admin::asset(requested) {
380 Some(bytes) => HttpResponse::Ok()
381 .content_type(apiplant_assets::content_type(requested))
382 .body(bytes.into_owned()),
383 None => HttpResponse::NotFound().finish(),
384 }
385}
386
387async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
393 let Some(root) = state.statics.public_dir.as_deref() else {
394 return not_found(&state);
395 };
396 serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
397}
398
399async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
401 not_found(&state)
402}
403
404fn not_found(state: &AppState) -> HttpResponse {
405 let Some(page) = state.statics.not_found_page.as_deref() else {
406 return HttpResponse::NotFound().finish();
407 };
408 match fs::read(page) {
409 Ok(bytes) => HttpResponse::NotFound()
410 .content_type(content_type_for(page))
411 .body(bytes),
412 Err(error) => {
413 tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
414 HttpResponse::NotFound().finish()
415 }
416 }
417}
418
419fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
421 let path = resolve_static_path(root, requested)?;
422 match fs::read(&path) {
423 Ok(bytes) => Some(
424 HttpResponse::Ok()
425 .content_type(content_type_for(&path))
426 .body(bytes),
427 ),
428 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
429 Err(error) => {
430 tracing::error!(path = %path.display(), error = %error, "failed to read static file");
431 Some(HttpResponse::InternalServerError().finish())
432 }
433 }
434}
435
436fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
437 let mut path = root.to_path_buf();
438 let requested = requested.trim_matches('/');
439
440 if requested.is_empty() {
441 path.push("index.html");
442 return Some(path);
443 }
444
445 for component in Path::new(requested).components() {
446 match component {
447 Component::Normal(segment) => path.push(segment),
448 Component::CurDir => {}
449 _ => return None,
450 }
451 }
452
453 if path.is_dir() {
454 path.push("index.html");
455 }
456 Some(path)
457}
458
459fn content_type_for(path: &Path) -> &'static str {
460 apiplant_assets::content_type(&path.to_string_lossy())
461}
462
463async fn admin_redirect(req: HttpRequest) -> HttpResponse {
465 HttpResponse::PermanentRedirect()
466 .header("location", format!("{}/", req.path()))
467 .finish()
468}
469
470fn public_routes(relative: &str) -> Vec<String> {
477 if relative
478 .split('/')
479 .any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
480 {
481 tracing::warn!(
482 file = relative,
483 "skipping public file: its name can't be a route"
484 );
485 return Vec::new();
486 }
487
488 let mut routes = vec![format!("/{relative}")];
489 if let Some(directory) = relative.strip_suffix("index.html") {
490 let directory = directory.trim_end_matches('/');
491 if directory.is_empty() {
492 routes.push("/".to_string());
493 } else {
494 routes.push(format!("/{directory}/"));
495 routes.push(format!("/{directory}"));
496 }
497 }
498 routes
499}
500
501fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
508 let entries = match fs::read_dir(root) {
509 Ok(entries) => entries,
510 Err(error) => {
511 tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
512 return;
513 }
514 };
515 for entry in entries.flatten() {
516 let name = entry.file_name().to_string_lossy().into_owned();
517 let relative = if prefix.is_empty() {
518 name
519 } else {
520 format!("{prefix}/{name}")
521 };
522 if entry.path().is_dir() {
523 walk_public(&entry.path(), &relative, into);
524 } else {
525 into.push(relative);
526 }
527 }
528}
529
530pub async fn run(app: App) -> anyhow::Result<()> {
532 run_with(app, Options::default()).await
533}
534
535#[derive(Debug, Clone, Default)]
537pub struct Options {
538 pub seed: bool,
542}
543
544pub async fn run_with(app: App, options: Options) -> anyhow::Result<()> {
546 let db_url = app.config.database.resolved_url();
548 tracing::info!("connecting to database");
549 let db = Db::connect(&db_url, app.config.database.max_connections).await?;
550 if app.config.database.auto_migrate {
551 tracing::info!("running migrations");
552 apiplant_db::migrate(db.connection(), &app).await?;
553 }
554 if options.seed {
555 let report = apiplant_db::seed::seed(db.connection(), &app).await?;
559 if report.is_empty() {
560 tracing::warn!("--seed was given but there is no seed/ directory to load");
561 } else {
562 tracing::info!(
563 inserted = report.inserted(),
564 already_present = report.skipped(),
565 "seeded"
566 );
567 }
568 }
569
570 let secret = if app.config.auth.jwt_secret.is_empty() {
572 tracing::warn!(
573 "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
574 );
575 format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
576 } else {
577 app.config.auth.jwt_secret.clone().into_bytes()
578 };
579 let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
580
581 let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
586 match &mailer {
587 Some(mailer) => tracing::info!(
588 " email -> {} (from {})",
589 mailer.provider().as_str(),
590 app.config.email.from
591 ),
592 None => tracing::debug!("no email provider configured"),
593 }
594
595 let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
596 match &cache {
597 Some(_) => tracing::info!(
598 " cache -> redis (prefix {:?})",
599 app.config.cache.prefix.as_str()
600 ),
601 None => tracing::debug!("no cache configured"),
602 }
603
604 let ai = apiplant_ai::Ai::from_config(&app.config.ai)?;
605 let agent_ais = app
606 .agents
607 .values()
608 .filter_map(|agent| {
609 agent.ai.as_ref().map(|_| {
610 apiplant_ai::Ai::from_config(&agent.merged_ai_config(&app.config.ai))
611 .map(|ai| (agent.meta.name.clone(), ai))
612 })
613 })
614 .collect::<Result<Vec<_>, _>>()?
615 .into_iter()
616 .filter_map(|(name, ai)| ai.map(|ai| (name, ai)))
617 .collect();
618 match &ai {
619 Some(ai) => tracing::info!(
620 " ai -> {} ({} at {})",
621 ai.provider().as_str(),
622 match ai.model() {
623 "" => "the server's own model",
624 model => model,
625 },
626 ai.url()
627 ),
628 None => tracing::debug!("no ai provider configured"),
629 }
630
631 let billing_landing = match app.config.admin.enabled {
635 true => format!(
636 "{}{}/#/billing",
637 app.config.server.public_origin(),
638 app.config.admin.path.trim_end_matches('/')
639 ),
640 false => app.config.server.public_origin(),
641 };
642 let payments =
643 apiplant_payments::Payments::from_config(&app.config.payments, &billing_landing)?;
644 match &payments {
645 Some(payments) => tracing::info!(
646 " payments -> {} ({}, automatic tax {})",
647 payments.provider().as_str(),
648 app.config.payments.default_currency(),
649 match app.config.payments.automatic_tax {
650 true => "on",
651 false => "off",
652 }
653 ),
654 None => tracing::debug!("no payment provider configured"),
655 }
656
657 oauth_routes::check_resources(&app).map_err(apiplant_core::Error::Message)?;
663 let callback_base = format!(
664 "{}{}/auth/oauth",
665 app.config.server.public_origin(),
666 app.config.server.base_path.trim_end_matches('/'),
667 );
668 let oauth = apiplant_oauth::Providers::from_config(&app.config.oauth, &callback_base)
669 .map_err(|e| apiplant_core::Error::Message(e.to_string()))?;
670 match &oauth {
671 Some(providers) => {
672 for provider in providers.iter() {
673 tracing::info!(
674 " oauth {} -> {}/auth/oauth/{}/start (redirect URI: {})",
675 provider.label,
676 app.config.server.base_path,
677 provider.key,
678 provider.redirect_uri,
679 );
680 }
681 }
682 None => tracing::debug!("no oauth providers configured"),
683 }
684
685 let registry = FunctionRegistry::load(&app);
687 for f in registry.iter() {
688 if f.manifest.visibility == apiplant_abi::Visibility::Private {
691 tracing::info!(" fn {} (private — no endpoint)", f.manifest.name);
692 } else {
693 tracing::info!(
694 " fn {} -> {}/functions/{}",
695 f.manifest.name,
696 app.config.server.base_path,
697 f.manifest.name
698 );
699 }
700 }
701
702 for resource in app.resources.values() {
705 for (event, function) in resource.hooks.iter() {
706 if registry.get(function).is_some() {
707 tracing::info!(
708 " hook {}.{} -> {}",
709 resource.meta.name,
710 event.as_str(),
711 function
712 );
713 } else {
714 tracing::error!(
715 resource = %resource.meta.name,
716 hook = event.as_str(),
717 function = function,
718 "hook function is not loaded — this resource's {} requests will fail with 500",
719 event.action()
720 );
721 }
722 }
723 for (event, function) in resource.hooks.auth_iter() {
724 if registry.get(function).is_some() {
725 tracing::info!(" hook auth.{} -> {}", event.as_str(), function);
726 } else {
727 tracing::error!(
728 hook = event.as_str(),
729 function = function,
730 "auth hook function is not loaded — {} requests will fail with 500",
731 event.action()
732 );
733 }
734 }
735 }
736
737 let base_path = app.config.server.base_path.clone();
739 let spec_url = format!("{base_path}/openapi.json");
740 let spec = openapi::build(&app, ®istry, mailer.is_some());
741 let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
742 let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
743 if app.config.docs.enabled {
744 tracing::info!(
745 " docs -> {base_path}{} (spec: {spec_url})",
746 app.config.docs.path
747 );
748 }
749
750 let host = app.config.server.host.clone();
752 let port = app.config.server.port;
753 let banner_docs_path = app
754 .config
755 .docs
756 .enabled
757 .then(|| app.config.docs.path.clone());
758 let banner_domains = app.config.server.domain.clone();
759 let banner_name = app.display_name();
760 let workers = app.config.server.workers;
761 let tls = app.tls.clone();
762
763 let statics = Statics::resolve(&app);
772 let banner_admin_path = statics.admin_path.clone();
773 let banner_site = !statics.public_routes.is_empty();
774 let admin_manifest = match &statics.admin_path {
775 Some(path) => {
776 tracing::info!(" admin -> {path}/");
777 admin::manifest_json(&app, ®istry, base_path.clone(), mailer.is_some()).unwrap_or_else(|error| {
778 tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
779 "{}".to_string()
780 })
781 }
782 None => String::new(),
783 };
784 if let Some(dir) = &statics.public_dir {
785 tracing::info!(
786 routes = statics.public_routes.len(),
787 " public -> / (from {})",
788 dir.display()
789 );
790 }
791 if let Some(page) = &statics.not_found_page {
792 tracing::info!(" 404 -> {}", page.display());
793 }
794
795 let state = AppState {
796 app: Arc::new(app),
797 db,
798 auth: authr,
799 functions: Arc::new(registry),
800 mailer,
801 cache,
802 payments,
803 ai,
804 oauth: oauth.map(Arc::new),
805 agent_ais: Arc::new(agent_ais),
806 statics: Arc::new(statics),
807 admin_manifest: Arc::new(admin_manifest),
808 openapi_json: Arc::new(openapi_json),
809 docs_html: Arc::new(docs_html),
810 };
811
812 let base_path_log = base_path.clone();
813 let mut server = HttpServer::new(move || build_app!(state));
814
815 if let Some(w) = workers {
816 server = server.workers(w);
817 }
818
819 let addr = format!("{host}:{port}");
820 let scheme = if tls.is_some() { "https" } else { "http" };
821 let server = match tls {
822 Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
823 None => server.bind(&addr)?,
824 };
825
826 tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
827 banner::Banner {
828 name: banner_name,
829 scheme,
830 addr: addr.clone(),
831 base_path: base_path_log.clone(),
832 docs_path: banner_docs_path,
833 admin_path: banner_admin_path,
834 site: banner_site,
835 domains: banner_domains,
836 }
837 .print();
838 server.run().await?;
839 Ok(())
840}
841
842fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
844 use std::io::BufReader;
845
846 let _ = rustls::crypto::ring::default_provider().install_default();
848
849 let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
850 let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
851
852 let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
853 let key = rustls_pemfile::private_key(&mut key_reader)?
854 .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
855
856 let config = rustls::ServerConfig::builder()
857 .with_no_client_auth()
858 .with_single_cert(certs, key)?;
859 Ok(config)
860}
861
862#[cfg(test)]
863mod route_tests {
864 use super::*;
865
866 #[test]
867 fn an_index_answers_for_its_directory_too() {
868 assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
869 assert_eq!(
870 public_routes("guide/index.html"),
871 ["/guide/index.html", "/guide/", "/guide"]
872 );
873 assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
874 }
875
876 #[test]
877 fn names_that_cannot_be_routes_are_skipped() {
878 assert!(public_routes("weird{name}.html").is_empty());
879 assert!(public_routes("../escape.html").is_empty());
880 }
881
882 #[test]
883 fn static_paths_resolve_under_the_root_and_never_above_it() {
884 let root = Path::new("/srv/app/public");
885 assert_eq!(
886 resolve_static_path(root, "/css/app.css"),
887 Some(root.join("css/app.css"))
888 );
889 assert_eq!(
890 resolve_static_path(root, "/"),
891 Some(root.join("index.html"))
892 );
893 assert_eq!(resolve_static_path(root, "/../main.toml"), None);
894 }
895}