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 .route(
76 "/functions/{name}",
77 $crate::ntex_web::route().to($crate::function_routes::invoke),
78 )
79 .service(
80 $crate::ntex_web::resource("/{resource}")
81 .route($crate::ntex_web::get().to($crate::crud::list))
82 .route($crate::ntex_web::post().to($crate::crud::create)),
83 )
84 .service(
85 $crate::ntex_web::resource("/{resource}/{id}")
86 .route($crate::ntex_web::get().to($crate::crud::get))
87 .route($crate::ntex_web::patch().to($crate::crud::update))
88 .route($crate::ntex_web::put().to($crate::crud::update))
89 .route($crate::ntex_web::delete().to($crate::crud::delete)),
90 )
91 .route(
93 "/{parent}/{id}/{child}",
94 $crate::ntex_web::get().to($crate::crud::nested_list),
95 );
96
97 if statics.not_found_page.is_some() {
100 scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
101 }
102
103 let mut app = $crate::ntex_web::App::new().state(state.clone());
104
105 macro_rules! guarded {
108 ($path:expr) => {{
109 let resource = $crate::ntex_web::resource($path);
110 match $crate::host_guard(&domain) {
111 Some(g) => resource.guard(g),
112 None => resource,
113 }
114 }};
115 }
116
117 if let Some(admin_path) = &statics.admin_path {
118 app = app
119 .service(
120 guarded!(format!("{admin_path}/"))
121 .route($crate::ntex_web::get().to($crate::admin_index)),
122 )
123 .service(
124 guarded!(format!("{admin_path}/{{path:.*}}"))
125 .route($crate::ntex_web::get().to($crate::admin_asset)),
126 )
127 .service(
130 guarded!(admin_path.as_str())
131 .route($crate::ntex_web::get().to($crate::admin_redirect)),
132 );
133 }
134
135 for route in &statics.public_routes {
136 app = app.service(
137 guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
138 );
139 }
140
141 app = app.service(scope);
142 if statics.not_found_page.is_some() {
143 app = app.default_service($crate::ntex_web::to($crate::not_found_route));
144 }
145 app
146 }};
147}
148
149pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
152 if domains.is_empty() {
153 return None;
154 }
155 Some(ntex_guard::AnyGuard(
156 domains
157 .iter()
158 .map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
159 .collect(),
160 ))
161}
162
163pub mod admin;
164mod auth_routes;
165pub mod builtins;
166pub mod cabi;
167mod crud;
168mod function_routes;
169pub mod functions;
170pub mod hooks;
171mod openapi;
172mod response;
173mod state;
174#[cfg(test)]
175mod tests;
176
177use std::sync::Arc;
178use std::{fs, path::Component, path::Path, path::PathBuf};
179
180use apiplant_auth::Authenticator;
181use apiplant_core::{App, TlsPaths};
182use apiplant_db::Db;
183use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
184
185pub(crate) use ntex::web as ntex_web;
188pub(crate) use ntex::web::guard as ntex_guard;
189use uuid::Uuid;
190
191use functions::FunctionRegistry;
192use state::{AppState, Statics};
193
194async fn health() -> HttpResponse {
195 HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
196}
197
198async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
200 HttpResponse::Ok()
201 .content_type("application/json")
202 .body(state.openapi_json.as_str().to_owned())
203}
204
205async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
207 HttpResponse::Ok()
208 .content_type("text/html; charset=utf-8")
209 .body(state.docs_html.as_str().to_owned())
210}
211
212async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
213 serve_admin(&state, "index.html")
214}
215
216async fn admin_asset(
217 state: web::types::State<AppState>,
218 path: web::types::Path<String>,
219) -> HttpResponse {
220 let path = path.into_inner();
221 serve_admin(&state, &path)
222}
223
224fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
230 let requested = requested.trim_start_matches('/');
231
232 if requested == admin::MANIFEST_FILE {
233 return HttpResponse::Ok()
234 .content_type("application/json")
235 .body(state.admin_manifest.as_str().to_owned());
236 }
237
238 match admin::asset(requested) {
239 Some(bytes) => HttpResponse::Ok()
240 .content_type(apiplant_assets::content_type(requested))
241 .body(bytes.into_owned()),
242 None => HttpResponse::NotFound().finish(),
243 }
244}
245
246async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
252 let Some(root) = state.statics.public_dir.as_deref() else {
253 return not_found(&state);
254 };
255 serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
256}
257
258async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
260 not_found(&state)
261}
262
263fn not_found(state: &AppState) -> HttpResponse {
264 let Some(page) = state.statics.not_found_page.as_deref() else {
265 return HttpResponse::NotFound().finish();
266 };
267 match fs::read(page) {
268 Ok(bytes) => HttpResponse::NotFound()
269 .content_type(content_type_for(page))
270 .body(bytes),
271 Err(error) => {
272 tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
273 HttpResponse::NotFound().finish()
274 }
275 }
276}
277
278fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
280 let path = resolve_static_path(root, requested)?;
281 match fs::read(&path) {
282 Ok(bytes) => Some(
283 HttpResponse::Ok()
284 .content_type(content_type_for(&path))
285 .body(bytes),
286 ),
287 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
288 Err(error) => {
289 tracing::error!(path = %path.display(), error = %error, "failed to read static file");
290 Some(HttpResponse::InternalServerError().finish())
291 }
292 }
293}
294
295fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
296 let mut path = root.to_path_buf();
297 let requested = requested.trim_matches('/');
298
299 if requested.is_empty() {
300 path.push("index.html");
301 return Some(path);
302 }
303
304 for component in Path::new(requested).components() {
305 match component {
306 Component::Normal(segment) => path.push(segment),
307 Component::CurDir => {}
308 _ => return None,
309 }
310 }
311
312 if path.is_dir() {
313 path.push("index.html");
314 }
315 Some(path)
316}
317
318fn content_type_for(path: &Path) -> &'static str {
319 apiplant_assets::content_type(&path.to_string_lossy())
320}
321
322async fn admin_redirect(req: HttpRequest) -> HttpResponse {
324 HttpResponse::PermanentRedirect()
325 .header("location", format!("{}/", req.path()))
326 .finish()
327}
328
329fn public_routes(relative: &str) -> Vec<String> {
336 if relative
337 .split('/')
338 .any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
339 {
340 tracing::warn!(
341 file = relative,
342 "skipping public file: its name can't be a route"
343 );
344 return Vec::new();
345 }
346
347 let mut routes = vec![format!("/{relative}")];
348 if let Some(directory) = relative.strip_suffix("index.html") {
349 let directory = directory.trim_end_matches('/');
350 if directory.is_empty() {
351 routes.push("/".to_string());
352 } else {
353 routes.push(format!("/{directory}/"));
354 routes.push(format!("/{directory}"));
355 }
356 }
357 routes
358}
359
360fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
367 let entries = match fs::read_dir(root) {
368 Ok(entries) => entries,
369 Err(error) => {
370 tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
371 return;
372 }
373 };
374 for entry in entries.flatten() {
375 let name = entry.file_name().to_string_lossy().into_owned();
376 let relative = if prefix.is_empty() {
377 name
378 } else {
379 format!("{prefix}/{name}")
380 };
381 if entry.path().is_dir() {
382 walk_public(&entry.path(), &relative, into);
383 } else {
384 into.push(relative);
385 }
386 }
387}
388
389pub async fn run(app: App) -> anyhow::Result<()> {
391 let db_url = app.config.database.resolved_url();
393 tracing::info!("connecting to database");
394 let db = Db::connect(&db_url, app.config.database.max_connections).await?;
395 if app.config.database.auto_migrate {
396 tracing::info!("running migrations");
397 apiplant_db::migrate(db.connection(), &app).await?;
398 }
399
400 let secret = if app.config.auth.jwt_secret.is_empty() {
402 tracing::warn!(
403 "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
404 );
405 format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
406 } else {
407 app.config.auth.jwt_secret.clone().into_bytes()
408 };
409 let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
410
411 let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
416 match &mailer {
417 Some(mailer) => tracing::info!(
418 " email -> {} (from {})",
419 mailer.provider().as_str(),
420 app.config.email.from
421 ),
422 None => tracing::debug!("no email provider configured"),
423 }
424
425 let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
426 match &cache {
427 Some(_) => tracing::info!(
428 " cache -> redis (prefix {:?})",
429 app.config.cache.prefix.as_str()
430 ),
431 None => tracing::debug!("no cache configured"),
432 }
433
434 let registry = FunctionRegistry::load(&app);
436 for f in registry.iter() {
437 if f.manifest.visibility == apiplant_abi::Visibility::Private {
440 tracing::info!(" fn {} (private — no endpoint)", f.manifest.name);
441 } else {
442 tracing::info!(
443 " fn {} -> {}/functions/{}",
444 f.manifest.name,
445 app.config.server.base_path,
446 f.manifest.name
447 );
448 }
449 }
450
451 for resource in app.resources.values() {
454 for (event, function) in resource.hooks.iter() {
455 if registry.get(function).is_some() {
456 tracing::info!(
457 " hook {}.{} -> {}",
458 resource.meta.name,
459 event.as_str(),
460 function
461 );
462 } else {
463 tracing::error!(
464 resource = %resource.meta.name,
465 hook = event.as_str(),
466 function = function,
467 "hook function is not loaded — this resource's {} requests will fail with 500",
468 event.action()
469 );
470 }
471 }
472 for (event, function) in resource.hooks.auth_iter() {
473 if registry.get(function).is_some() {
474 tracing::info!(" hook auth.{} -> {}", event.as_str(), function);
475 } else {
476 tracing::error!(
477 hook = event.as_str(),
478 function = function,
479 "auth hook function is not loaded — {} requests will fail with 500",
480 event.action()
481 );
482 }
483 }
484 }
485
486 let base_path = app.config.server.base_path.clone();
488 let spec_url = format!("{base_path}/openapi.json");
489 let spec = openapi::build(&app, ®istry);
490 let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
491 let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
492 if app.config.docs.enabled {
493 tracing::info!(
494 " docs -> {base_path}{} (spec: {spec_url})",
495 app.config.docs.path
496 );
497 }
498
499 let host = app.config.server.host.clone();
501 let port = app.config.server.port;
502 let workers = app.config.server.workers;
503 let tls = app.tls.clone();
504
505 let statics = Statics::resolve(&app);
514 let admin_manifest = match &statics.admin_path {
515 Some(path) => {
516 tracing::info!(" admin -> {path}/");
517 admin::manifest_json(&app, ®istry, base_path.clone()).unwrap_or_else(|error| {
518 tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
519 "{}".to_string()
520 })
521 }
522 None => String::new(),
523 };
524 if let Some(dir) = &statics.public_dir {
525 tracing::info!(
526 routes = statics.public_routes.len(),
527 " public -> / (from {})",
528 dir.display()
529 );
530 }
531 if let Some(page) = &statics.not_found_page {
532 tracing::info!(" 404 -> {}", page.display());
533 }
534
535 let state = AppState {
536 app: Arc::new(app),
537 db,
538 auth: authr,
539 functions: Arc::new(registry),
540 mailer,
541 cache,
542 statics: Arc::new(statics),
543 admin_manifest: Arc::new(admin_manifest),
544 openapi_json: Arc::new(openapi_json),
545 docs_html: Arc::new(docs_html),
546 };
547
548 let base_path_log = base_path.clone();
549 let mut server = HttpServer::new(move || build_app!(state));
550
551 if let Some(w) = workers {
552 server = server.workers(w);
553 }
554
555 let addr = format!("{host}:{port}");
556 let scheme = if tls.is_some() { "https" } else { "http" };
557 let server = match tls {
558 Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
559 None => server.bind(&addr)?,
560 };
561
562 tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
563 server.run().await?;
564 Ok(())
565}
566
567fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
569 use std::io::BufReader;
570
571 let _ = rustls::crypto::ring::default_provider().install_default();
573
574 let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
575 let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
576
577 let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
578 let key = rustls_pemfile::private_key(&mut key_reader)?
579 .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
580
581 let config = rustls::ServerConfig::builder()
582 .with_no_client_auth()
583 .with_single_cert(certs, key)?;
584 Ok(config)
585}
586
587#[cfg(test)]
588mod route_tests {
589 use super::*;
590
591 #[test]
592 fn an_index_answers_for_its_directory_too() {
593 assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
594 assert_eq!(
595 public_routes("guide/index.html"),
596 ["/guide/index.html", "/guide/", "/guide"]
597 );
598 assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
599 }
600
601 #[test]
602 fn names_that_cannot_be_routes_are_skipped() {
603 assert!(public_routes("weird{name}.html").is_empty());
604 assert!(public_routes("../escape.html").is_empty());
605 }
606
607 #[test]
608 fn static_paths_resolve_under_the_root_and_never_above_it() {
609 let root = Path::new("/srv/app/public");
610 assert_eq!(
611 resolve_static_path(root, "/css/app.css"),
612 Some(root.join("css/app.css"))
613 );
614 assert_eq!(
615 resolve_static_path(root, "/"),
616 Some(root.join("index.html"))
617 );
618 assert_eq!(resolve_static_path(root, "/../main.toml"), None);
619 }
620}