1macro_rules! build_app {
28 ($state:expr) => {{
29 let state = $state.clone();
30 let config = &state.app.config;
31 let domain = config.server.domain.clone();
32 let statics = state.statics.clone();
33
34 let base_path = match config.server.base_path.as_str() {
37 "" => "/",
38 path => path,
39 };
40 let mut scope = $crate::ntex_web::scope(base_path);
41 if let Some(g) = $crate::host_guard(&domain) {
42 scope = scope.guard(g);
43 }
44 if config.docs.enabled {
47 scope = scope
48 .route(
49 "/openapi.json",
50 $crate::ntex_web::get().to($crate::openapi_spec),
51 )
52 .route(
53 config.docs.path.as_str(),
54 $crate::ntex_web::get().to($crate::docs_page),
55 );
56 }
57 let mut scope = scope
58 .route("/_health", $crate::ntex_web::get().to($crate::health))
59 .route(
60 "/auth/register",
61 $crate::ntex_web::post().to($crate::auth_routes::register),
62 )
63 .route(
64 "/auth/login",
65 $crate::ntex_web::post().to($crate::auth_routes::login),
66 )
67 .route(
68 "/auth/me",
69 $crate::ntex_web::get().to($crate::auth_routes::me),
70 )
71 .route(
72 "/auth/apikeys",
73 $crate::ntex_web::post().to($crate::auth_routes::create_api_key),
74 );
75
76 if let Some(storage) = &state.storage {
79 scope = scope.service(
80 $crate::ntex_web::resource("/uploads")
81 .state($crate::ntex_web::types::PayloadConfig::new(
82 storage.max_bytes() as usize,
83 ))
84 .route($crate::ntex_web::post().to($crate::storage_routes::upload)),
85 );
86 }
87
88 scope = scope.route(
94 "/queues/{topic}",
95 $crate::ntex_web::post().to($crate::queue_routes::publish),
96 );
97
98 if state.invitations_enabled() {
104 scope = scope
105 .route(
106 "/auth/invitations",
107 $crate::ntex_web::post().to($crate::email_auth::create_invitation),
108 )
109 .route(
110 "/auth/invitations/{token}",
111 $crate::ntex_web::get().to($crate::email_auth::preview_invitation),
112 )
113 .route(
114 "/auth/invitations/{token}/accept",
115 $crate::ntex_web::post().to($crate::email_auth::accept_invitation),
116 );
117 }
118 if state.requires_email_verification() {
119 scope = scope
120 .route(
121 "/auth/verify-email",
122 $crate::ntex_web::post().to($crate::email_auth::verify_email),
123 )
124 .route(
125 "/auth/verify-email/resend",
126 $crate::ntex_web::post().to($crate::email_auth::resend_verification),
127 );
128 }
129 if state.password_reset_enabled() {
130 scope = scope
131 .route(
132 "/auth/password/forgot",
133 $crate::ntex_web::post().to($crate::email_auth::forgot_password),
134 )
135 .route(
136 "/auth/password/reset",
137 $crate::ntex_web::post().to($crate::email_auth::reset_password),
138 );
139 }
140
141 if state.oauth_enabled() {
147 scope = scope
148 .route(
149 "/auth/oauth",
150 $crate::ntex_web::get().to($crate::oauth_routes::providers),
151 )
152 .route(
153 "/auth/oauth/{provider}/start",
154 $crate::ntex_web::get().to($crate::oauth_routes::start_redirect),
155 )
156 .route(
157 "/auth/oauth/{provider}/start",
158 $crate::ntex_web::post().to($crate::oauth_routes::start_json),
159 )
160 .route(
161 "/auth/oauth/{provider}/callback",
162 $crate::ntex_web::get().to($crate::oauth_routes::callback_redirect),
163 )
164 .route(
165 "/auth/oauth/{provider}/callback",
166 $crate::ntex_web::post().to($crate::oauth_routes::callback_json),
167 )
168 .route(
169 "/auth/oauth/{provider}",
170 $crate::ntex_web::delete().to($crate::oauth_routes::unlink),
171 );
172 }
173
174 if state.payments_enabled() {
178 scope = scope
179 .route(
180 "/billing/config",
181 $crate::ntex_web::get().to($crate::billing::config),
182 )
183 .route(
184 "/billing/checkout",
185 $crate::ntex_web::post().to($crate::billing::checkout),
186 )
187 .route(
188 "/billing/portal",
189 $crate::ntex_web::post().to($crate::billing::portal),
190 )
191 .route(
197 "/billing/webhook",
198 $crate::ntex_web::post().to($crate::billing::webhook),
199 );
200 }
201
202 if state.ai_enabled() {
206 scope = scope
207 .route(
208 "/ai/config",
209 $crate::ntex_web::get().to($crate::ai_routes::config),
210 )
211 .route(
212 "/ai/agents/{name}/chat",
213 $crate::ntex_web::post().to($crate::agent_routes::chat),
214 )
215 .route(
216 "/ai/chat",
217 $crate::ntex_web::post().to($crate::ai_routes::chat),
218 );
219 }
220
221 let mut scope = scope
222 .route(
228 "/functions/{name}/stream",
229 $crate::ntex_web::route().to($crate::function_routes::stream),
230 )
231 .route(
232 "/functions/{name}",
233 $crate::ntex_web::route().to($crate::function_routes::invoke),
234 )
235 .service(
236 $crate::ntex_web::resource("/{resource}")
237 .route($crate::ntex_web::get().to($crate::crud::list))
238 .route($crate::ntex_web::post().to($crate::crud::create)),
239 )
240 .service(
241 $crate::ntex_web::resource("/{resource}/{id}")
242 .route($crate::ntex_web::get().to($crate::crud::get))
243 .route($crate::ntex_web::patch().to($crate::crud::update))
244 .route($crate::ntex_web::put().to($crate::crud::update))
245 .route($crate::ntex_web::delete().to($crate::crud::delete)),
246 )
247 .route(
249 "/{parent}/{id}/{child}",
250 $crate::ntex_web::get().to($crate::crud::nested_list),
251 );
252
253 if statics.not_found_page.is_some() {
256 scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
257 }
258
259 let mut app = $crate::ntex_web::App::new().state(state.clone());
260
261 macro_rules! guarded {
264 ($path:expr) => {{
265 let resource = $crate::ntex_web::resource($path);
266 match $crate::host_guard(&domain) {
267 Some(g) => resource.guard(g),
268 None => resource,
269 }
270 }};
271 }
272
273 if let Some(admin_path) = &statics.admin_path {
274 app = app
275 .service(
276 guarded!(format!("{admin_path}/"))
277 .route($crate::ntex_web::get().to($crate::admin_index)),
278 )
279 .service(
280 guarded!(format!("{admin_path}/{{path:.*}}"))
281 .route($crate::ntex_web::get().to($crate::admin_asset)),
282 )
283 .service(
286 guarded!(admin_path.as_str())
287 .route($crate::ntex_web::get().to($crate::admin_redirect)),
288 );
289 }
290
291 if let Some(base) = &statics.storage_base {
295 app = app.service(
299 guarded!(format!("{base}/{{key}}*"))
300 .route($crate::ntex_web::get().to($crate::storage_routes::serve)),
301 );
302 }
303
304 for route in &statics.public_routes {
305 app = app.service(
306 guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
307 );
308 }
309
310 app = app.service(scope);
311 if statics.not_found_page.is_some() {
312 app = app.default_service($crate::ntex_web::to($crate::not_found_route));
313 }
314 app
315 }};
316}
317
318pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
321 if domains.is_empty() {
322 return None;
323 }
324 Some(ntex_guard::AnyGuard(
325 domains
326 .iter()
327 .map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
328 .collect(),
329 ))
330}
331
332pub mod access;
333pub mod admin;
334mod agent_routes;
335mod ai_routes;
336mod auth_routes;
337mod banner;
338mod billing;
339pub mod builtins;
340pub mod cabi;
341pub mod call;
342mod crud;
343pub mod email_auth;
344mod emails;
345mod function_routes;
346pub mod functions;
347pub mod hooks;
348mod oauth_routes;
349mod openapi;
350mod queue_routes;
351pub mod queues;
352mod response;
353mod sse;
354mod state;
355mod storage_routes;
356#[cfg(test)]
357mod tests;
358
359use std::sync::Arc;
360use std::{fs, path::Component, path::Path, path::PathBuf};
361
362use apiplant_auth::Authenticator;
363use apiplant_core::{App, TlsPaths};
364use apiplant_db::Db;
365use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
366
367pub(crate) use ntex::web as ntex_web;
370pub(crate) use ntex::web::guard as ntex_guard;
371use uuid::Uuid;
372
373use functions::FunctionRegistry;
374use state::{AppState, Statics};
375
376async fn health() -> HttpResponse {
377 HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
378}
379
380async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
382 HttpResponse::Ok()
383 .content_type("application/json")
384 .body(state.openapi_json.as_str().to_owned())
385}
386
387async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
389 HttpResponse::Ok()
390 .content_type("text/html; charset=utf-8")
391 .body(state.docs_html.as_str().to_owned())
392}
393
394async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
395 serve_admin(&state, "index.html")
396}
397
398async fn admin_asset(
399 state: web::types::State<AppState>,
400 path: web::types::Path<String>,
401) -> HttpResponse {
402 let path = path.into_inner();
403 serve_admin(&state, &path)
404}
405
406fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
412 let requested = requested.trim_start_matches('/');
413
414 if requested == admin::MANIFEST_FILE {
415 return HttpResponse::Ok()
416 .content_type("application/json")
417 .body(state.admin_manifest.as_str().to_owned());
418 }
419
420 match admin::asset(requested) {
421 Some(bytes) => HttpResponse::Ok()
422 .content_type(apiplant_assets::content_type(requested))
423 .body(bytes.into_owned()),
424 None => HttpResponse::NotFound().finish(),
425 }
426}
427
428async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
434 let Some(root) = state.statics.public_dir.as_deref() else {
435 return not_found(&state);
436 };
437 serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
438}
439
440async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
442 not_found(&state)
443}
444
445fn not_found(state: &AppState) -> HttpResponse {
446 let Some(page) = state.statics.not_found_page.as_deref() else {
447 return HttpResponse::NotFound().finish();
448 };
449 match fs::read(page) {
450 Ok(bytes) => HttpResponse::NotFound()
451 .content_type(content_type_for(page))
452 .body(bytes),
453 Err(error) => {
454 tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
455 HttpResponse::NotFound().finish()
456 }
457 }
458}
459
460fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
462 let path = resolve_static_path(root, requested)?;
463 match fs::read(&path) {
464 Ok(bytes) => Some(
465 HttpResponse::Ok()
466 .content_type(content_type_for(&path))
467 .body(bytes),
468 ),
469 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
470 Err(error) => {
471 tracing::error!(path = %path.display(), error = %error, "failed to read static file");
472 Some(HttpResponse::InternalServerError().finish())
473 }
474 }
475}
476
477fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
478 let mut path = root.to_path_buf();
479 let requested = requested.trim_matches('/');
480
481 if requested.is_empty() {
482 path.push("index.html");
483 return Some(path);
484 }
485
486 for component in Path::new(requested).components() {
487 match component {
488 Component::Normal(segment) => path.push(segment),
489 Component::CurDir => {}
490 _ => return None,
491 }
492 }
493
494 if path.is_dir() {
495 path.push("index.html");
496 }
497 Some(path)
498}
499
500fn content_type_for(path: &Path) -> &'static str {
501 apiplant_assets::content_type(&path.to_string_lossy())
502}
503
504async fn admin_redirect(req: HttpRequest) -> HttpResponse {
506 HttpResponse::PermanentRedirect()
507 .header("location", format!("{}/", req.path()))
508 .finish()
509}
510
511fn public_routes(relative: &str) -> Vec<String> {
518 if relative
519 .split('/')
520 .any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
521 {
522 tracing::warn!(
523 file = relative,
524 "skipping public file: its name can't be a route"
525 );
526 return Vec::new();
527 }
528
529 let mut routes = vec![format!("/{relative}")];
530 if let Some(directory) = relative.strip_suffix("index.html") {
531 let directory = directory.trim_end_matches('/');
532 if directory.is_empty() {
533 routes.push("/".to_string());
534 } else {
535 routes.push(format!("/{directory}/"));
536 routes.push(format!("/{directory}"));
537 }
538 }
539 routes
540}
541
542fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
549 let entries = match fs::read_dir(root) {
550 Ok(entries) => entries,
551 Err(error) => {
552 tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
553 return;
554 }
555 };
556 for entry in entries.flatten() {
557 let name = entry.file_name().to_string_lossy().into_owned();
558 let relative = if prefix.is_empty() {
559 name
560 } else {
561 format!("{prefix}/{name}")
562 };
563 if entry.path().is_dir() {
564 walk_public(&entry.path(), &relative, into);
565 } else {
566 into.push(relative);
567 }
568 }
569}
570
571pub async fn run(app: App) -> anyhow::Result<()> {
573 run_with(app, Options::default()).await
574}
575
576#[derive(Debug, Clone, Default)]
578pub struct Options {
579 pub seed: bool,
583}
584
585pub async fn run_with(app: App, options: Options) -> anyhow::Result<()> {
587 let db_url = app.config.database.resolved_url();
589 tracing::info!("connecting to database");
590 let db = Db::connect(&db_url, app.config.database.max_connections).await?;
591 if app.config.database.auto_migrate {
592 tracing::info!("running migrations");
593 apiplant_db::migrate(db.connection(), &app).await?;
594 }
595 if options.seed {
596 let report = apiplant_db::seed::seed(db.connection(), &app).await?;
600 if report.is_empty() {
601 tracing::warn!("--seed was given but there is no seed/ directory to load");
602 } else {
603 tracing::info!(
604 inserted = report.inserted(),
605 already_present = report.skipped(),
606 "seeded"
607 );
608 }
609 }
610
611 let secret = if app.config.auth.jwt_secret.is_empty() {
613 tracing::warn!(
614 "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
615 );
616 format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
617 } else {
618 app.config.auth.jwt_secret.clone().into_bytes()
619 };
620 let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
621
622 let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
627 match &mailer {
628 Some(mailer) => tracing::info!(
629 " email -> {} (from {})",
630 mailer.provider().as_str(),
631 app.config.email.from
632 ),
633 None => tracing::debug!("no email provider configured"),
634 }
635
636 let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
637 match &cache {
638 Some(_) => tracing::info!(
639 " cache -> redis (prefix {:?})",
640 app.config.cache.prefix.as_str()
641 ),
642 None => tracing::debug!("no cache configured"),
643 }
644
645 let storage = apiplant_storage::Storage::connect(&app.config.storage, &app.root)
646 .map_err(|e| apiplant_core::Error::Message(e.to_string()))?;
647 match &storage {
648 Some(storage) => tracing::info!(
649 " storage -> {} ({}), served at {}/",
650 storage.kind(),
651 storage.location(),
652 storage.public_base()
653 ),
654 None => tracing::debug!("no storage configured"),
655 }
656
657 let queue = apiplant_queue::Queue::new(&db, &app);
661 if app.config.queues.is_active() {
662 for (topic, subscribers) in &app.config.queues.subscribe {
663 tracing::info!(" topic {topic} -> {}", subscribers.join(", "));
664 }
665 }
666
667 let ai = apiplant_ai::Ai::from_config(&app.config.ai)?;
668 let agent_ais = app
669 .agents
670 .values()
671 .filter_map(|agent| {
672 agent.ai.as_ref().map(|_| {
673 apiplant_ai::Ai::from_config(&agent.merged_ai_config(&app.config.ai))
674 .map(|ai| (agent.meta.name.clone(), ai))
675 })
676 })
677 .collect::<Result<Vec<_>, _>>()?
678 .into_iter()
679 .filter_map(|(name, ai)| ai.map(|ai| (name, ai)))
680 .collect();
681 match &ai {
682 Some(ai) => tracing::info!(
683 " ai -> {} ({} at {})",
684 ai.provider().as_str(),
685 match ai.model() {
686 "" => "the server's own model",
687 model => model,
688 },
689 ai.url()
690 ),
691 None => tracing::debug!("no ai provider configured"),
692 }
693
694 let billing_landing = match app.config.admin.enabled {
698 true => format!(
699 "{}{}/#/billing",
700 app.config.server.public_origin(),
701 app.config.admin.path.trim_end_matches('/')
702 ),
703 false => app.config.server.public_origin(),
704 };
705 let payments =
706 apiplant_payments::Payments::from_config(&app.config.payments, &billing_landing)?;
707 match &payments {
708 Some(payments) => tracing::info!(
709 " payments -> {} ({}, automatic tax {})",
710 payments.provider().as_str(),
711 app.config.payments.default_currency(),
712 match app.config.payments.automatic_tax {
713 true => "on",
714 false => "off",
715 }
716 ),
717 None => tracing::debug!("no payment provider configured"),
718 }
719
720 oauth_routes::check_resources(&app).map_err(apiplant_core::Error::Message)?;
726 let callback_base = format!(
727 "{}{}/auth/oauth",
728 app.config.server.public_origin(),
729 app.config.server.base_path.trim_end_matches('/'),
730 );
731 let oauth = apiplant_oauth::Providers::from_config(&app.config.oauth, &callback_base)
732 .map_err(|e| apiplant_core::Error::Message(e.to_string()))?;
733 match &oauth {
734 Some(providers) => {
735 for provider in providers.iter() {
736 tracing::info!(
737 " oauth {} -> {}/auth/oauth/{}/start (redirect URI: {})",
738 provider.label,
739 app.config.server.base_path,
740 provider.key,
741 provider.redirect_uri,
742 );
743 }
744 }
745 None => tracing::debug!("no oauth providers configured"),
746 }
747
748 let registry = FunctionRegistry::load(&app);
750 for f in registry.iter() {
751 if f.manifest.visibility == apiplant_abi::Visibility::Private {
754 tracing::info!(" fn {} (private — no endpoint)", f.manifest.name);
755 } else {
756 tracing::info!(
757 " fn {} -> {}/functions/{}",
758 f.manifest.name,
759 app.config.server.base_path,
760 f.manifest.name
761 );
762 }
763 }
764
765 for name in app.config.queues.subscribed_functions() {
770 if registry.get(name).is_none() {
771 tracing::error!(
772 function = name,
773 "a [queues.subscribe] entry names a function that is not loaded — \
774 messages on its topic will retry and then fail"
775 );
776 }
777 }
778
779 for resource in app.resources.values() {
782 for (event, function) in resource.hooks.iter() {
783 if registry.get(function).is_some() {
784 tracing::info!(
785 " hook {}.{} -> {}",
786 resource.meta.name,
787 event.as_str(),
788 function
789 );
790 } else {
791 tracing::error!(
792 resource = %resource.meta.name,
793 hook = event.as_str(),
794 function = function,
795 "hook function is not loaded — this resource's {} requests will fail with 500",
796 event.action()
797 );
798 }
799 }
800 for (event, function) in resource.hooks.auth_iter() {
801 if registry.get(function).is_some() {
802 tracing::info!(" hook auth.{} -> {}", event.as_str(), function);
803 } else {
804 tracing::error!(
805 hook = event.as_str(),
806 function = function,
807 "auth hook function is not loaded — {} requests will fail with 500",
808 event.action()
809 );
810 }
811 }
812 }
813
814 let base_path = app.config.server.base_path.clone();
816 let spec_url = format!("{base_path}/openapi.json");
817 let spec = openapi::build(&app, ®istry, mailer.is_some());
818 let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
819 let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
820 if app.config.docs.enabled {
821 tracing::info!(
822 " docs -> {base_path}{} (spec: {spec_url})",
823 app.config.docs.path
824 );
825 }
826
827 let host = app.config.server.host.clone();
829 let port = app.config.server.port;
830 let banner_docs_path = app
831 .config
832 .docs
833 .enabled
834 .then(|| app.config.docs.path.clone());
835 let banner_domains = app.config.server.domain.clone();
836 let banner_name = app.display_name();
837 let workers = app.config.server.workers;
838 let tls = app.tls.clone();
839
840 let statics = Statics::resolve(&app);
849 let banner_admin_path = statics.admin_path.clone();
850 let banner_site = !statics.public_routes.is_empty();
851 let admin_manifest = match &statics.admin_path {
852 Some(path) => {
853 tracing::info!(" admin -> {path}/");
854 admin::manifest_json(&app, ®istry, base_path.clone(), mailer.is_some()).unwrap_or_else(|error| {
855 tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
856 "{}".to_string()
857 })
858 }
859 None => String::new(),
860 };
861 if let Some(dir) = &statics.public_dir {
862 tracing::info!(
863 routes = statics.public_routes.len(),
864 " public -> / (from {})",
865 dir.display()
866 );
867 }
868 if let Some(page) = &statics.not_found_page {
869 tracing::info!(" 404 -> {}", page.display());
870 }
871
872 let state = AppState {
873 app: Arc::new(app),
874 db,
875 auth: authr,
876 functions: Arc::new(registry),
877 mailer,
878 cache,
879 storage,
880 payments,
881 ai,
882 oauth: oauth.map(Arc::new),
883 queue: queue.clone(),
884 agent_ais: Arc::new(agent_ais),
885 statics: Arc::new(statics),
886 admin_manifest: Arc::new(admin_manifest),
887 openapi_json: Arc::new(openapi_json),
888 docs_html: Arc::new(docs_html),
889 };
890
891 if state.app.config.queues.is_active() {
900 let subscriber = queues::Subscriber {
901 db: state.db.clone(),
902 queue: queue.clone(),
903 functions: Arc::clone(&state.functions),
904 mailer: state.mailer.clone(),
905 cache: state.cache.clone(),
906 payments: state.payments.clone(),
907 ai: state.ai.clone(),
908 database_url: db_url.clone(),
909 worker: format!("{}:{}", hostname(), std::process::id()),
910 };
911 tokio::spawn(queues::run(subscriber));
912 }
913
914 let base_path_log = base_path.clone();
915 let mut server = HttpServer::new(move || build_app!(state));
916
917 if let Some(w) = workers {
918 server = server.workers(w);
919 }
920
921 let addr = format!("{host}:{port}");
922 let scheme = if tls.is_some() { "https" } else { "http" };
923 let server = match tls {
924 Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
925 None => server.bind(&addr)?,
926 };
927
928 tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
929 banner::Banner {
930 name: banner_name,
931 scheme,
932 addr: addr.clone(),
933 base_path: base_path_log.clone(),
934 docs_path: banner_docs_path,
935 admin_path: banner_admin_path,
936 site: banner_site,
937 domains: banner_domains,
938 }
939 .print();
940 server.run().await?;
941 Ok(())
942}
943
944fn hostname() -> String {
950 std::env::var("HOSTNAME")
951 .ok()
952 .filter(|h| !h.trim().is_empty())
953 .unwrap_or_else(|| "unknown".to_string())
954}
955
956fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
958 use std::io::BufReader;
959
960 let _ = rustls::crypto::ring::default_provider().install_default();
962
963 let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
964 let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
965
966 let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
967 let key = rustls_pemfile::private_key(&mut key_reader)?
968 .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
969
970 let config = rustls::ServerConfig::builder()
971 .with_no_client_auth()
972 .with_single_cert(certs, key)?;
973 Ok(config)
974}
975
976#[cfg(test)]
977mod route_tests {
978 use super::*;
979
980 #[test]
981 fn an_index_answers_for_its_directory_too() {
982 assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
983 assert_eq!(
984 public_routes("guide/index.html"),
985 ["/guide/index.html", "/guide/", "/guide"]
986 );
987 assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
988 }
989
990 #[test]
991 fn names_that_cannot_be_routes_are_skipped() {
992 assert!(public_routes("weird{name}.html").is_empty());
993 assert!(public_routes("../escape.html").is_empty());
994 }
995
996 #[test]
997 fn static_paths_resolve_under_the_root_and_never_above_it() {
998 let root = Path::new("/srv/app/public");
999 assert_eq!(
1000 resolve_static_path(root, "/css/app.css"),
1001 Some(root.join("css/app.css"))
1002 );
1003 assert_eq!(
1004 resolve_static_path(root, "/"),
1005 Some(root.join("index.html"))
1006 );
1007 assert_eq!(resolve_static_path(root, "/../main.toml"), None);
1008 }
1009}