1use crate::action::{ActionMeta, DataQuery, Error};
6use crate::ctx::{Ctx, Identity, Service, Services};
7use crate::feature::{Feature, ScreenEntry};
8use crate::grid::DataGridView;
9use crate::i18n::{I18n, Text};
10use crate::platform::{Config, License, LicenseSource, Tenant};
11use crate::render::{Render, escape};
12use crate::screen::{GridSource, LoadedPage, Screen, ScreenDef};
13use crate::security::{
14 IpRange, MemoryRateStore, RateKey, RateLimit, RateStore, client_ip, parse_ranges,
15};
16use crate::time::{DateTime, TimeZone};
17use crate::ui::NodePath;
18use axum::Router;
19use axum::extract::{Path, Query, State};
20use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
21use axum::response::{Html, IntoResponse, Redirect, Response};
22use axum::routing::{get, post};
23use serde::{Deserialize, Serialize};
24use serde_json::{Map, Value as Json, json};
25use std::collections::HashMap;
26use std::future::Future;
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
29
30#[cfg(feature = "accounts")]
31pub(crate) mod accounts;
32mod agent;
33mod auth;
34#[cfg(feature = "assistant")]
35use agent::{Tool, ToolTarget};
36mod charts;
37mod data;
38mod files;
39mod grid;
40mod guard;
41mod locale;
42mod media;
43mod modules;
44mod ops;
45mod seo;
46mod session;
47mod telemetry;
48mod theme;
49
50pub use theme::Theme;
51#[cfg(feature = "email")]
52mod mail;
53#[cfg(feature = "pdf")]
54mod pdf;
55#[cfg(feature = "xlsx")]
56mod xlsx;
57
58pub use ops::{READY_CACHE, build_id, probe};
59
60const JS: &str = concat!(
69 include_str!("../assets/zeyn/core.js"),
70 include_str!("../assets/zeyn/menus.js"),
71 include_str!("../assets/zeyn/page.js"),
72 include_str!("../assets/zeyn/events.js"),
73);
74
75const GRID_JS: &str = concat!(
86 include_str!("../assets/zeyn/grid-api.js"),
87 include_str!("../assets/zeyn/grid.js"),
88 include_str!("../assets/zeyn/grid-rows.js"),
89 include_str!("../assets/zeyn/grid-columns.js"),
90 include_str!("../assets/zeyn/grid-filters.js"),
91 include_str!("../assets/zeyn/grid-cells.js"),
92 include_str!("../assets/zeyn/grid-exports.js"),
93);
94const CSS: &str = include_str!("../assets/zeyn.css");
95const NAV: &str = include_str!("../assets/nav.js");
96
97#[cfg(feature = "assistant")]
98mod assistant;
99#[cfg(feature = "islands")]
100mod islands;
101mod pro;
102#[cfg(feature = "realtime")]
103pub mod realtime;
104mod site;
105mod ui;
106
107pub trait Authenticator: Send + Sync + 'static {
109 fn authenticate(&self, headers: &HeaderMap) -> Option<Identity>;
110
111 fn prepare<'a>(
114 &'a self,
115 _headers: &'a HeaderMap,
116 ) -> Option<std::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>>> {
117 None
118 }
119
120 fn is_development(&self) -> bool {
122 false
123 }
124}
125
126impl<F> Authenticator for F
127where
128 F: Fn(&HeaderMap) -> Option<Identity> + Send + Sync + 'static,
129{
130 fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
131 self(headers)
132 }
133}
134
135pub struct ForwardAuth {
150 user: &'static str,
151 groups: Option<&'static str>,
152 tenant: Option<&'static str>,
153 time_zone: Option<&'static str>,
154 auth_time: Option<&'static str>,
155 shared: Option<(&'static str, String)>,
156 roles: HashMap<String, Vec<String>>,
157}
158
159impl ForwardAuth {
160 pub fn new(user_header: &'static str) -> Self {
161 ForwardAuth {
162 user: user_header,
163 groups: None,
164 tenant: None,
165 time_zone: None,
166 auth_time: None,
167 shared: None,
168 roles: HashMap::new(),
169 }
170 }
171
172 pub fn groups(mut self, header: &'static str) -> Self {
174 self.groups = Some(header);
175 self
176 }
177
178 pub fn tenant(mut self, header: &'static str) -> Self {
180 self.tenant = Some(header);
181 self
182 }
183
184 pub fn time_zone(mut self, header: &'static str) -> Self {
186 self.time_zone = Some(header);
187 self
188 }
189
190 pub fn auth_time(mut self, header: &'static str) -> Self {
193 self.auth_time = Some(header);
194 self
195 }
196
197 pub fn require_header(mut self, header: &'static str, value: &str) -> Self {
199 self.shared = Some((header, value.to_string()));
200 self
201 }
202
203 pub fn role<I, S>(mut self, group: &str, permissions: I) -> Self
205 where
206 I: IntoIterator<Item = S>,
207 S: Into<String>,
208 {
209 self.roles
210 .entry(group.to_string())
211 .or_default()
212 .extend(permissions.into_iter().map(Into::into));
213 self
214 }
215}
216
217impl Authenticator for ForwardAuth {
218 fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
219 let text = |name: &str| {
220 headers
221 .get(name)
222 .and_then(|v| v.to_str().ok())
223 .map(str::trim)
224 .filter(|v| !v.is_empty())
225 };
226 if let Some((name, value)) = &self.shared
227 && !text(name).is_some_and(|v| same(v, value))
228 {
229 return None;
230 }
231 let mut identity = Identity::user(text(self.user)?);
232 if let Some(groups) = self.groups.and_then(text) {
233 for group in groups.split(',').map(str::trim) {
234 if let Some(perms) = self.roles.get(group) {
235 identity
236 .permissions
237 .extend(perms.iter().cloned());
238 }
239 }
240 }
241 identity.tenant = self
242 .tenant
243 .and_then(text)
244 .map(str::to_string);
245 identity.time_zone = self
246 .time_zone
247 .and_then(text)
248 .map(str::to_string);
249 identity.authenticated_at = self
250 .auth_time
251 .and_then(text)
252 .and_then(|t| match t.parse::<i64>() {
253 Ok(secs) => secs
254 .checked_mul(1000)
255 .map(DateTime::from_unix_ms),
256 Err(_) => DateTime::parse(t),
257 });
258 Some(identity)
259 }
260}
261
262pub trait TenantResolver: Send + Sync + 'static {
264 fn resolve(&self, headers: &HeaderMap) -> Option<String>;
265}
266
267impl<F> TenantResolver for F
268where
269 F: Fn(&HeaderMap) -> Option<String> + Send + Sync + 'static,
270{
271 fn resolve(&self, headers: &HeaderMap) -> Option<String> {
272 self(headers)
273 }
274}
275
276pub struct HeaderTenant(pub &'static str);
278
279impl TenantResolver for HeaderTenant {
280 fn resolve(&self, headers: &HeaderMap) -> Option<String> {
281 headers
282 .get(self.0)
283 .and_then(|v| v.to_str().ok())
284 .map(|v| v.trim().to_lowercase())
285 }
286}
287
288pub struct SubdomainTenant(pub &'static str);
291
292impl TenantResolver for SubdomainTenant {
293 fn resolve(&self, headers: &HeaderMap) -> Option<String> {
294 let host = headers
295 .get(header::HOST)?
296 .to_str()
297 .ok()?
298 .split(':')
299 .next()?
300 .to_lowercase();
301 let label = host
302 .strip_suffix(self.0)?
303 .strip_suffix('.')?;
304 (!label.contains('.')).then(|| label.to_string())
305 }
306}
307
308pub struct DevAuth(Identity);
310
311impl DevAuth {
312 pub fn superuser(name: &str) -> Self {
314 DevAuth(Identity::superuser(name))
315 }
316
317 pub fn identity(identity: Identity) -> Self {
318 DevAuth(identity)
319 }
320}
321
322impl Authenticator for DevAuth {
323 fn authenticate(&self, _headers: &HeaderMap) -> Option<Identity> {
324 let mut identity = self.0.clone();
325 identity
326 .authenticated_at
327 .get_or_insert_with(DateTime::now);
328 Some(identity)
329 }
330
331 fn is_development(&self) -> bool {
332 true
333 }
334}
335
336#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
338#[serde(rename_all = "snake_case")]
339pub enum CallKind {
340 Page,
341 Screen,
342 DataGrid,
343 Form,
344 Api,
345 Reveal,
347 Import,
349 Media,
351 Document,
353 Live,
356 Upload,
358}
359
360#[derive(Clone, Debug, Serialize)]
362pub struct Call {
363 pub kind: CallKind,
364 pub screen: &'static str,
365 pub action: Option<ActionMeta>,
366 pub via_agent: bool,
367}
368
369#[derive(Clone, Debug)]
371pub struct CallOutcome {
372 pub ok: bool,
373 pub error: Option<&'static str>,
375 pub duration: Duration,
376}
377
378pub trait Interceptor: Send + Sync + 'static {
380 fn name(&self) -> &'static str {
382 std::any::type_name::<Self>()
383 }
384
385 fn before(&self, _ctx: &Ctx, _call: &Call) -> crate::Result<()> {
387 Ok(())
388 }
389
390 fn after(&self, _ctx: &Ctx, _call: &Call, _outcome: &CallOutcome) {}
391}
392
393#[derive(Clone, Debug, Serialize)]
395pub struct AuditEntry {
396 pub at_unix_ms: u128,
397 pub user: Option<String>,
398 pub kind: CallKind,
399 pub screen: &'static str,
400 pub action: Option<&'static str>,
401 pub ok: bool,
402 pub error: Option<&'static str>,
403 pub via_agent: bool,
404 pub millis: u128,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub model: Option<&'static str>,
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub record: Option<String>,
411 #[serde(skip_serializing_if = "Vec::is_empty")]
413 pub changes: Vec<crate::audit::FieldChange>,
414 #[serde(skip_serializing_if = "Vec::is_empty")]
416 pub revealed: Vec<crate::audit::FieldRef>,
417 #[serde(skip_serializing_if = "Vec::is_empty")]
419 pub reads: Vec<crate::audit::RecordRef>,
420 #[serde(skip_serializing_if = "Option::is_none")]
422 pub reason: Option<String>,
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub transition: Option<crate::workflow::TransitionRecord>,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 pub export: Option<crate::audit::ExportRecord>,
429}
430
431pub trait AuditSink: Send + Sync + 'static {
433 fn record(&self, entry: AuditEntry);
434}
435
436#[derive(Clone, Default)]
438pub struct MemoryAudit(Arc<Mutex<Vec<AuditEntry>>>);
439
440impl MemoryAudit {
441 pub fn entries(&self) -> Vec<AuditEntry> {
442 self.0
443 .lock()
444 .map(|e| e.clone())
445 .unwrap_or_default()
446 }
447}
448
449impl AuditSink for MemoryAudit {
450 fn record(&self, entry: AuditEntry) {
451 if let Ok(mut e) = self.0.lock() {
452 e.push(entry);
453 }
454 }
455}
456
457pub struct StderrAudit;
459
460impl AuditSink for StderrAudit {
461 fn record(&self, entry: AuditEntry) {
462 if let Ok(line) = serde_json::to_string(&entry) {
463 eprintln!("{line}");
464 }
465 }
466}
467
468pub struct AuditLog<S: AuditSink>(pub S);
471
472impl<S: AuditSink> Interceptor for AuditLog<S> {
473 fn after(&self, ctx: &Ctx, call: &Call, outcome: &CallOutcome) {
474 let trail = ctx.trail();
475 let audited = call.via_agent
476 || call.kind == CallKind::Form
477 || call.kind == CallKind::Reveal
478 || call.kind == CallKind::Import
479 || call.kind == CallKind::Upload
480 || call.action.is_some_and(|a| a.audit)
481 || !trail.revealed.is_empty()
482 || !trail.reads.is_empty()
483 || trail.transition.is_some()
484 || trail.export.is_some();
485 if !audited {
486 return;
487 }
488 self.0.record(AuditEntry {
489 model: trail.record.as_ref().map(|r| r.model),
490 record: trail.record.and_then(|r| r.id),
491 changes: trail.changes,
492 revealed: trail.revealed,
493 reads: trail.reads,
494 reason: trail.reason,
495 transition: trail.transition,
496 export: trail.export,
497 at_unix_ms: SystemTime::now()
498 .duration_since(UNIX_EPOCH)
499 .map(|d| d.as_millis())
500 .unwrap_or(0),
501 user: ctx.identity().user.clone(),
502 kind: call.kind,
503 screen: call.screen,
504 action: call.action.map(|a| a.name),
505 ok: outcome.ok,
506 error: outcome.error,
507 via_agent: call.via_agent,
508 millis: outcome.duration.as_millis(),
509 });
510 }
511}
512
513#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
528pub struct Mcp {
529 writes: bool,
530 server: bool,
531 browser: bool,
532}
533
534impl Mcp {
535 pub fn off() -> Self {
537 Mcp::default()
538 }
539
540 pub fn read_only() -> Self {
542 Mcp {
543 writes: false,
544 server: true,
545 browser: true,
546 }
547 }
548
549 pub fn on() -> Self {
551 Mcp {
552 writes: true,
553 server: true,
554 browser: true,
555 }
556 }
557
558 pub fn read_write() -> Self {
560 Mcp::on()
561 }
562
563 pub fn server(mut self, on: bool) -> Self {
565 self.server = on;
566 self
567 }
568
569 pub fn webmcp(mut self, on: bool) -> Self {
571 self.browser = on;
572 self
573 }
574
575 pub fn writes(&self) -> bool {
577 self.writes
578 }
579
580 pub fn is_on(&self) -> bool {
582 self.server || self.browser
583 }
584}
585
586pub trait BasicCredentials: Send + Sync + 'static {
589 fn verify(&self, user: &str, password: &str) -> crate::BoxFuture<bool>;
590}
591
592pub struct StaticCredentials {
594 user: String,
595 password: crate::Secret<String>,
596}
597
598impl StaticCredentials {
599 pub fn new(user: &str, password: crate::Secret<String>) -> Self {
600 StaticCredentials {
601 user: user.to_string(),
602 password,
603 }
604 }
605}
606
607impl BasicCredentials for StaticCredentials {
608 fn verify(&self, user: &str, password: &str) -> crate::BoxFuture<bool> {
609 let ok = same(user, &self.user) & same(password, self.password.expose());
610 Box::pin(async move { ok })
611 }
612}
613
614pub enum ApiDocs {
616 Open,
618 Basic(Arc<dyn BasicCredentials>),
620 Permission(&'static str),
622}
623
624impl ApiDocs {
625 pub fn open() -> Self {
626 ApiDocs::Open
627 }
628
629 pub fn basic(credentials: impl BasicCredentials) -> Self {
630 ApiDocs::Basic(Arc::new(credentials))
631 }
632
633 pub fn permission(permission: &'static str) -> Self {
634 ApiDocs::Permission(permission)
635 }
636}
637
638fn base64_decode(s: &str) -> Option<Vec<u8>> {
640 let value = |c: u8| -> Option<u32> {
641 match c {
642 b'A'..=b'Z' => Some(u32::from(c - b'A')),
643 b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
644 b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
645 b'+' => Some(62),
646 b'/' => Some(63),
647 _ => None,
648 }
649 };
650 let bytes = s
651 .trim()
652 .trim_end_matches('=')
653 .as_bytes();
654 let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
655 for chunk in bytes.chunks(4) {
656 let mut n = 0u32;
657 for (i, c) in chunk.iter().enumerate() {
658 n |= value(*c)? << (18 - 6 * i);
659 }
660 let take = match chunk.len() {
661 4 => 3,
662 3 => 2,
663 2 => 1,
664 _ => return None,
665 };
666 out.extend_from_slice(&n.to_be_bytes()[1..1 + take]);
667 }
668 Some(out)
669}
670
671pub trait Plugin {
687 fn install(self, app: App) -> App;
688}
689
690#[derive(Clone, Copy, Debug)]
692pub enum TimePolicy {
693 User { default: TimeZone },
695 Organization(TimeZone),
698}
699
700#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
702pub enum Prefetch {
703 #[default]
705 Off,
706 Moderate,
708}
709
710fn is_prefetch(headers: &HeaderMap) -> bool {
713 headers
714 .get("sec-purpose")
715 .and_then(|v| v.to_str().ok())
716 .is_some_and(|v| v.contains("prefetch"))
717 || headers
718 .get("x-zeyn-prefetch")
719 .is_some_and(|v| v == "1")
720}
721
722pub struct App {
732 name: Text,
733 i18n: I18n,
734 services: Services,
735 features: Vec<Feature>,
736 auth: Option<Arc<dyn Authenticator>>,
737 interceptors: Vec<Arc<dyn Interceptor>>,
738 timeout: Duration,
739 secure_cookies: bool,
740 mcp: Mcp,
741 home: Option<&'static str>,
742 errors: ErrorScreens,
743 llms_txt: Option<&'static str>,
744 csp: crate::csp::Csp,
745 robots_txt: Option<&'static str>,
746 site_url: String,
747 time: TimePolicy,
748 fixed_now: Option<DateTime>,
749 zone_picker: bool,
750 api_prefix: String,
751 config: Config,
752 tenancy: Option<Arc<dyn TenantResolver>>,
753 license: Option<(Arc<dyn LicenseSource>, Duration)>,
754 login_url: Option<String>,
755 cors: Vec<String>,
756 api_docs: Option<ApiDocs>,
757 theme: Option<Theme>,
758 base_path: String,
759 public_path: Option<String>,
760 rate: Option<RateLimit>,
761 write_rate: Option<RateLimit>,
762 rate_store: Arc<dyn RateStore>,
763 trusted: Vec<IpRange>,
764 deny: Vec<IpRange>,
765 allow: Vec<IpRange>,
766 setup_problems: Vec<String>,
767 views: Option<Arc<dyn crate::grid::DataGridViewStore>>,
768 native_menu: bool,
769 locale_paths: bool,
770 palette_keys: String,
771 client_prefs: crate::prefs::ClientPrefs,
772 prefetch: Prefetch,
773 extras: ops::Extras,
774 sign_in: auth::Extras,
775 #[cfg(feature = "assistant")]
776 assistant: Option<Arc<crate::assistant::Assistant>>,
777 looks: theme::Settings,
778 #[cfg(feature = "islands")]
779 islands_dir: Option<std::path::PathBuf>,
780 #[cfg(feature = "realtime")]
781 realtime: Option<realtime::Hub>,
782}
783
784impl App {
785 pub fn new(name: impl Into<Text>) -> Self {
786 App {
787 name: name.into(),
788 i18n: I18n::new("en"),
789 services: Services::default(),
790 features: Vec::new(),
791 auth: None,
792 interceptors: Vec::new(),
793 timeout: Duration::from_secs(30),
794 secure_cookies: false,
795 mcp: Mcp::off(),
796 home: None,
797 errors: ErrorScreens::default(),
798 llms_txt: None,
799 csp: crate::csp::Csp::new(),
800 robots_txt: None,
801 site_url: String::new(),
802 time: TimePolicy::User {
803 default: TimeZone::UTC,
804 },
805 fixed_now: None,
806 zone_picker: false,
807 api_prefix: "/api/v1".into(),
808 config: Config::default(),
809 tenancy: None,
810 license: None,
811 login_url: None,
812 cors: Vec::new(),
813 api_docs: None,
814 theme: None,
815 base_path: String::new(),
816 public_path: None,
817 rate: None,
818 write_rate: None,
819 rate_store: Arc::new(MemoryRateStore::default()),
820 trusted: Vec::new(),
821 deny: Vec::new(),
822 allow: Vec::new(),
823 setup_problems: Vec::new(),
824 views: None,
825 native_menu: true,
826 locale_paths: false,
827 palette_keys: crate::command::PALETTE_SHORTCUT.into(),
828 client_prefs: crate::prefs::ClientPrefs::default(),
829 prefetch: Prefetch::Off,
830 extras: ops::Extras::default(),
831 sign_in: auth::Extras::default(),
832 #[cfg(feature = "assistant")]
833 assistant: None,
834 looks: theme::Settings::default(),
835 #[cfg(feature = "islands")]
836 islands_dir: None,
837 #[cfg(feature = "realtime")]
838 realtime: None,
839 }
840 }
841
842 #[cfg(feature = "realtime")]
846 pub fn realtime(mut self, realtime: realtime::Realtime) -> Self {
847 let hub = realtime.install();
848 self.realtime = Some(hub.clone());
849 self.service(hub)
850 }
851
852 #[cfg(feature = "islands")]
857 pub fn islands(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
858 self.islands_dir = Some(dir.into());
859 self
860 }
861
862 pub fn browser_context_menu(mut self, allow: bool) -> Self {
868 self.native_menu = allow;
869 self
870 }
871
872 pub fn locale_paths(mut self) -> Self {
883 self.locale_paths = true;
884 self
885 }
886
887 pub fn palette_shortcut(mut self, keys: &str) -> Self {
891 self.palette_keys = keys.to_string();
892 self
893 }
894
895 pub fn client_prefs(
916 mut self,
917 build: impl FnOnce(crate::prefs::ClientPrefs) -> crate::prefs::ClientPrefs,
918 ) -> Self {
919 self.client_prefs = build(std::mem::take(&mut self.client_prefs));
920 self
921 }
922
923 pub fn prefetch(mut self, prefetch: Prefetch) -> Self {
938 self.prefetch = prefetch;
939 self
940 }
941
942 pub fn client_prefs_table(&self) -> &[crate::prefs::PrefInfo] {
946 &self.client_prefs.keys
947 }
948
949 pub fn datagrid_views(mut self, store: impl crate::grid::DataGridViewStore) -> Self {
952 self.views = Some(Arc::new(store));
953 self
954 }
955
956 pub fn theme(mut self, theme: Theme) -> Self {
958 self.theme = Some(theme);
959 self
960 }
961
962 pub fn base_path(mut self, path: &str) -> Self {
965 self.base_path = normalize_base(path);
966 self
967 }
968
969 pub fn public_path(mut self, path: &str) -> Self {
972 self.public_path = Some(normalize_base(path));
973 self
974 }
975
976 pub fn rate_limit(mut self, limit: RateLimit) -> Self {
978 self.rate = Some(limit);
979 self
980 }
981
982 pub fn write_limit(mut self, limit: RateLimit) -> Self {
984 self.write_rate = Some(limit);
985 self
986 }
987
988 pub fn rate_store(mut self, store: impl RateStore) -> Self {
990 self.rate_store = Arc::new(store);
991 self
992 }
993
994 fn ranges<I, S>(&mut self, what: &str, ranges: I) -> Vec<IpRange>
995 where
996 I: IntoIterator<Item = S>,
997 S: AsRef<str>,
998 {
999 parse_ranges(ranges).unwrap_or_else(|e| {
1000 self.setup_problems
1001 .push(format!("{what}: {e}"));
1002 Vec::new()
1003 })
1004 }
1005
1006 pub fn trusted_proxies<I, S>(mut self, ranges: I) -> Self
1009 where
1010 I: IntoIterator<Item = S>,
1011 S: AsRef<str>,
1012 {
1013 self.trusted = self.ranges("trusted_proxies", ranges);
1014 self
1015 }
1016
1017 pub fn deny_ips<I, S>(mut self, ranges: I) -> Self
1019 where
1020 I: IntoIterator<Item = S>,
1021 S: AsRef<str>,
1022 {
1023 self.deny = self.ranges("deny_ips", ranges);
1024 self
1025 }
1026
1027 pub fn allow_only_ips<I, S>(mut self, ranges: I) -> Self
1029 where
1030 I: IntoIterator<Item = S>,
1031 S: AsRef<str>,
1032 {
1033 self.allow = self.ranges("allow_only_ips", ranges);
1034 self
1035 }
1036
1037 pub fn api_docs(mut self, access: ApiDocs) -> Self {
1049 self.api_docs = Some(access);
1050 self
1051 }
1052
1053 pub fn cors<I, S>(mut self, origins: I) -> Self
1057 where
1058 I: IntoIterator<Item = S>,
1059 S: Into<String>,
1060 {
1061 self.cors
1062 .extend(origins.into_iter().map(Into::into));
1063 self
1064 }
1065
1066 pub fn config(mut self, config: Config) -> Self {
1069 self.services.insert(config.clone());
1070 self.config = config;
1071 self
1072 }
1073
1074 pub fn tenancy(mut self, resolver: impl TenantResolver) -> Self {
1078 self.tenancy = Some(Arc::new(resolver));
1079 self
1080 }
1081
1082 pub fn license(mut self, source: impl LicenseSource, refresh: Duration) -> Self {
1085 self.license = Some((Arc::new(source), refresh));
1086 self
1087 }
1088
1089 pub fn login_url(mut self, url: impl Into<String>) -> Self {
1092 self.login_url = Some(url.into());
1093 self
1094 }
1095
1096 pub fn api_prefix(mut self, prefix: impl Into<String>) -> Self {
1098 self.api_prefix = prefix
1099 .into()
1100 .trim_end_matches('/')
1101 .to_string();
1102 self
1103 }
1104
1105 pub fn time_zone_picker(mut self) -> Self {
1108 self.zone_picker = true;
1109 self
1110 }
1111
1112 pub fn time_policy(mut self, policy: TimePolicy) -> Self {
1114 self.time = policy;
1115 self
1116 }
1117
1118 pub fn fixed_now(mut self, now: DateTime) -> Self {
1120 self.fixed_now = Some(now);
1121 self
1122 }
1123
1124 pub fn home<S: ScreenDef>(mut self, _screen: S) -> Self {
1126 self.home = Some(S::META.name);
1127 self
1128 }
1129
1130 pub fn not_found<S: ScreenDef<Params = ()>>(mut self, _screen: S) -> Self {
1143 self.errors.not_found = Some(crate::feature::ScreenEntry::of::<S>());
1144 self
1145 }
1146
1147 pub fn server_error<S: ScreenDef<Params = ()>>(mut self, _screen: S) -> Self {
1151 self.errors.server_error = Some(crate::feature::ScreenEntry::of::<S>());
1152 self
1153 }
1154
1155 pub fn llms_txt(mut self, text: &'static str) -> Self {
1163 self.llms_txt = Some(text);
1164 self
1165 }
1166
1167 pub fn csp(mut self, policy: impl FnOnce(crate::csp::Csp) -> crate::csp::Csp) -> Self {
1175 self.csp = policy(std::mem::take(&mut self.csp));
1176 self
1177 }
1178
1179 pub fn robots_txt(mut self, text: &'static str) -> Self {
1186 self.robots_txt = Some(text);
1187 self
1188 }
1189
1190 pub fn site_url(mut self, url: &str) -> Self {
1195 let url = url.trim_end_matches('/');
1196 if crate::seo::valid_url(url)
1197 && url.starts_with("https://")
1198 && !url["https://".len()..].contains('/')
1199 {
1200 self.site_url = url.to_string();
1201 } else {
1202 self.setup_problems.push(format!(
1203 "site_url `{}` is not an https origin\n = help: pass only the scheme and host, such as `https://shop.example`",
1204 url.chars().take(80).collect::<String>()
1205 ));
1206 }
1207 self
1208 }
1209
1210 pub fn plugin(self, plugin: impl Plugin) -> Self {
1212 plugin.install(self)
1213 }
1214
1215 pub fn i18n(mut self, i18n: I18n) -> Self {
1217 self.i18n = i18n;
1218 self
1219 }
1220
1221 pub fn service<T: Service>(mut self, service: T) -> Self {
1222 self.services.insert(service);
1223 self
1224 }
1225
1226 pub fn auth(mut self, auth: impl Authenticator) -> Self {
1227 self.auth = Some(Arc::new(auth));
1228 self
1229 }
1230
1231 pub fn interceptor(mut self, interceptor: impl Interceptor) -> Self {
1232 self.interceptors
1233 .push(Arc::new(interceptor));
1234 self
1235 }
1236
1237 pub fn feature(mut self, feature: Feature) -> Self {
1238 self.features.push(feature);
1239 self
1240 }
1241
1242 pub fn timeout(mut self, timeout: Duration) -> Self {
1244 self.timeout = timeout;
1245 self
1246 }
1247
1248 pub fn secure_cookies(mut self, secure: bool) -> Self {
1250 self.secure_cookies = secure;
1251 self
1252 }
1253
1254 pub fn mcp(mut self, mcp: Mcp) -> Self {
1257 self.mcp = mcp;
1258 self
1259 }
1260
1261 #[cfg(feature = "assistant")]
1265 pub fn assistant(mut self, assistant: crate::assistant::Assistant) -> Self {
1266 self.assistant = Some(Arc::new(assistant));
1267 self
1268 }
1269
1270 pub fn check_page(&self, screen: &Screen) -> Result<(), Vec<String>> {
1294 let commands: Vec<(&str, &crate::command::Command)> = self
1295 .features
1296 .iter()
1297 .flat_map(|f| {
1298 f.commands()
1299 .iter()
1300 .map(move |c| (f.name(), c))
1301 })
1302 .collect();
1303 let mut found = Vec::new();
1304 for f in &self.features {
1305 if let Some(shell) = f.site_shell() {
1306 for (s, nodes) in shell.header.slots.iter().enumerate() {
1307 for (i, node) in nodes.iter().enumerate() {
1308 node.all_shortcuts(format!("h{s}.{i}"), &mut found);
1309 }
1310 }
1311 }
1312 }
1313 for (i, node) in screen.nodes().iter().enumerate() {
1314 node.all_shortcuts(i.to_string(), &mut found);
1315 }
1316 let links: Vec<(String, &str)> = found
1317 .into_iter()
1318 .map(|(path, keys)| {
1319 let place = match path.strip_prefix('h') {
1320 Some(p) => format!("in the site header (node {p})"),
1321 None => format!("at node {path}"),
1322 };
1323 (place, keys)
1324 })
1325 .collect();
1326 let problems = crate::command::link_problems(&links, &commands, &self.palette_keys);
1327 if problems.is_empty() {
1328 Ok(())
1329 } else {
1330 Err(problems)
1331 }
1332 }
1333
1334 pub fn check(&self) -> Result<(), Vec<String>> {
1336 let mut problems = self.setup_problems.clone();
1337 problems.extend(self.dev_auth_check());
1338 problems.extend(self.csp.problems().iter().cloned());
1339 let mut paths: HashMap<String, &str> = HashMap::new();
1340 let mut names: HashMap<&str, &str> = HashMap::new();
1341 let sitemaps: Vec<_> = self
1342 .features
1343 .iter()
1344 .filter_map(|f| {
1345 f.sitemap_source()
1346 .map(|s| (f.name(), s))
1347 })
1348 .collect();
1349 if sitemaps.len() > 1 {
1350 problems.push(
1351 "more than one feature has a sitemap\n = help: keep one `Feature::sitemap` that lists every URL"
1352 .into(),
1353 );
1354 }
1355 for (feature, s) in &sitemaps {
1356 if !matches!(s.meta().access, crate::action::Access::Public) {
1357 problems.push(format!(
1358 "the sitemap action `{}` of feature `{feature}` is not public\n = help: `/sitemap.xml` is read by search engines without a session; mark the action `#[action(public)]`",
1359 s.meta().name
1360 ));
1361 }
1362 }
1363 for f in &self.features {
1364 if let Err(p) = f.check() {
1365 problems.extend(
1366 p.into_iter()
1367 .map(|p| format!("feature `{}`: {p}", f.name())),
1368 );
1369 }
1370 for s in f.screens() {
1371 if let Some(other) = names.insert(s.meta.name, f.name())
1372 && other != f.name()
1373 {
1374 problems.push(format!(
1375 "screen `{}` is registered in features `{other}` and `{}`",
1376 s.meta.name,
1377 f.name()
1378 ));
1379 }
1380 if let Some(path) = f.path_of(&s.meta) {
1381 let shape: String = path
1382 .split('/')
1383 .map(|p| if p.starts_with('{') { "{}" } else { p })
1384 .collect::<Vec<_>>()
1385 .join("/");
1386 if let Some(other) = paths.insert(shape, s.meta.name)
1387 && other != s.meta.name
1388 {
1389 problems.push(format!(
1390 "route `{path}` is used by screens `{other}` and `{}`",
1391 s.meta.name
1392 ));
1393 }
1394 }
1395 for dep in (s.deps)() {
1396 if !self.services.contains(dep.id) {
1397 problems.push(format!(
1398 "screen `{}` needs service `{}`, which is not registered\n = help: add `.service(...)` to the App",
1399 s.meta.name, dep.name
1400 ));
1401 }
1402 }
1403 }
1404 }
1405 for f in &self.features {
1406 for (i, a) in f.apis().iter().enumerate() {
1407 if f.apis()[..i]
1408 .iter()
1409 .any(|b| b.meta.name == a.meta.name)
1410 {
1411 problems.push(format!(
1412 "api `{}` is published twice in feature `{}`",
1413 a.meta.name,
1414 f.name()
1415 ));
1416 }
1417 for dep in (a.deps)() {
1418 if !self.services.contains(dep.id) {
1419 problems.push(format!(
1420 "api `{}` needs service `{}`, which is not registered\n = help: add `.service(...)` to the App",
1421 a.meta.name, dep.name
1422 ));
1423 }
1424 }
1425 }
1426 }
1427 let mut media: HashMap<&str, &str> = HashMap::new();
1428 for f in &self.features {
1429 for m in f.media_sources() {
1430 if let Some(other) = media.insert(m.name, f.name())
1431 && other != f.name()
1432 {
1433 problems.push(format!(
1434 "media source `{}` is registered in features `{other}` and `{}`",
1435 m.name,
1436 f.name()
1437 ));
1438 }
1439 }
1440 }
1441 for s in [self.errors.not_found, self.errors.server_error]
1442 .into_iter()
1443 .flatten()
1444 {
1445 for dep in (s.deps)() {
1446 if !self.services.contains(dep.id) {
1447 problems.push(format!(
1448 "error screen `{}` needs service `{}`, which is not registered
1449 = help: add `.service(...)` to the App",
1450 s.meta.name, dep.name
1451 ));
1452 }
1453 }
1454 }
1455 for f in &self.features {
1456 let Some(shell) = f.site_shell() else {
1457 continue;
1458 };
1459 for screen in shell.linked_screens() {
1460 if !names.contains_key(screen) {
1461 problems.push(format!(
1462 "feature `{}`: the site footer links to screen `{screen}`, which is not registered",
1463 f.name()
1464 ));
1465 }
1466 }
1467 for url in shell.bad_urls() {
1468 problems.push(format!(
1469 "feature `{}`: the site footer link `{url}` is not a path, https, http or mailto address",
1470 f.name()
1471 ));
1472 }
1473 }
1474 if let Some(home) = self.home {
1475 if !self.features.iter().any(|f| {
1476 f.screens()
1477 .iter()
1478 .any(|s| s.meta.name == home && s.meta.route.is_some())
1479 }) {
1480 problems.push(format!(
1481 "home screen `{home}` is not registered or has no route"
1482 ));
1483 }
1484 if self
1485 .features
1486 .iter()
1487 .flat_map(|f| f.screens())
1488 .any(|s| s.meta.name == home && crate::screen::route_has_segments(s.meta.route))
1489 {
1490 problems.push(format!(
1491 "home screen `{home}` has path segments in its route
1492 = help: `/` cannot fill them; make a screen without segments the home screen"
1493 ));
1494 }
1495 }
1496 let commands: Vec<(&str, &crate::command::Command)> = self
1497 .features
1498 .iter()
1499 .flat_map(|f| {
1500 f.commands()
1501 .iter()
1502 .map(move |c| (f.name(), c))
1503 })
1504 .collect();
1505 problems.extend(crate::command::problems(
1506 &commands,
1507 false,
1508 &self.palette_keys,
1509 ));
1510 for f in &self.features {
1511 let Some(shell) = f.site_shell() else {
1512 continue;
1513 };
1514 let mut links = Vec::new();
1515 for (s, nodes) in shell.header.slots.iter().enumerate() {
1516 let mut found = Vec::new();
1517 for (i, node) in nodes.iter().enumerate() {
1518 node.all_shortcuts(format!("{s}.{i}"), &mut found);
1519 }
1520 links.extend(found.into_iter().map(|(path, keys)| {
1521 (
1522 format!(
1523 "in the {} slot of the site header of feature `{}` (node {path})",
1524 crate::site::SLOT_NAMES[s],
1525 f.name()
1526 ),
1527 keys,
1528 )
1529 }));
1530 }
1531 problems.extend(crate::command::link_problems(
1532 &links,
1533 &commands,
1534 &self.palette_keys,
1535 ));
1536 }
1537 match crate::command::normalize_shortcut(&self.palette_keys) {
1538 None => problems.push(format!(
1539 "the palette shortcut `{}` cannot be read
1540 = help: write modifiers and one key joined by `+`, such as `Alt+Shift+P`",
1541 self.palette_keys
1542 )),
1543 Some(k)
1544 if crate::command::browser_shortcut(&k)
1545 == Some(crate::command::BrowserShortcut::Reserved) =>
1546 {
1547 problems.push(format!(
1548 "the palette shortcut `{k}` is kept by the browser and never reaches the page
1549 = help: pick a free shortcut such as `Alt+Shift+P`"
1550 ))
1551 }
1552 _ => {}
1553 }
1554 problems.extend(self.theme_problems());
1555 problems.extend(self.client_prefs.problems());
1556 problems.dedup();
1557 if problems.is_empty() {
1558 Ok(())
1559 } else {
1560 Err(problems)
1561 }
1562 }
1563
1564 pub fn describe(&self) -> Json {
1566 json!({
1567 "name": self.i18n.text(self.i18n.default_locale(), &self.name),
1568 "locales": self.i18n.locales(),
1569 "default_locale": self.i18n.default_locale(),
1570 "mcp": self.mcp.is_on().then(|| json!({
1571 "writes": self.mcp.writes,
1572 "server": self.mcp.server,
1573 "webmcp": self.mcp.browser,
1574 })),
1575 "features": self.features.iter().map(|f| json!({
1576 "name": f.name(),
1577 "guards": f.guards().iter().map(|g| g.name()).collect::<Vec<_>>(),
1578 "screens": f.screens().iter().map(|s| json!({
1579 "name": s.meta.name,
1580 "route": s.meta.route,
1581 "path": f.path_of(&s.meta),
1582 "access": s.meta.access,
1583 "deps": (s.deps)().iter().map(|d| d.name).collect::<Vec<_>>(),
1584 })).collect::<Vec<_>>(),
1585 "apis": f.apis().iter().map(|a| json!({
1586 "action": a.meta.name,
1587 "path": format!("{}/{}/{}", self.api_prefix, f.name(), a.meta.name),
1588 "access": a.meta.access,
1589 })).collect::<Vec<_>>(),
1590 "menu": f.menu_groups().iter().map(|g| json!({
1591 "label": self.i18n.text(self.i18n.default_locale(), &g.label),
1592 "icon": g.icon,
1593 "items": g.items.iter().map(|i| json!({
1594 "label": self.i18n.text(self.i18n.default_locale(), &i.label),
1595 "screen": i.screen,
1596 })).collect::<Vec<_>>(),
1597 })).collect::<Vec<_>>(),
1598 "commands": f.commands().iter().map(crate::command::Command::schema).collect::<Vec<_>>(),
1599 })).collect::<Vec<_>>(),
1600 "missing_translations": self.i18n.missing_keys(),
1601 "problems": self.check().err().unwrap_or_default(),
1602 })
1603 }
1604
1605 pub fn router(mut self) -> Result<Router, Vec<String>> {
1607 self.check()?;
1608 let docs_enabled = self.api_docs.is_some();
1609 let mount = self.base_path.clone();
1610 let url_prefix = self
1611 .public_path
1612 .clone()
1613 .unwrap_or_else(|| self.base_path.clone());
1614 auth::resolve(&mut self.sign_in, &url_prefix, &mut self.login_url);
1615 let mut screens = HashMap::new();
1616 let mut routes = HashMap::new();
1617 for (fi, f) in self.features.iter().enumerate() {
1618 for s in f.screens() {
1619 screens.insert(s.meta.name, (fi, *s));
1620 if let Some(path) = f.path_of(&s.meta) {
1621 routes.insert(s.meta.name, path);
1622 }
1623 }
1624 }
1625 let default_locale = self
1626 .locale_paths
1627 .then(|| Arc::from(self.i18n.default_locale()));
1628 self.services
1629 .insert(crate::screen::Routes(Arc::new(routes), default_locale));
1630 self.services
1631 .insert(self.client_prefs.clone());
1632 let look = self.look();
1633 #[cfg(feature = "islands")]
1634 let islands = self
1635 .islands_dir
1636 .as_deref()
1637 .and_then(|dir| match islands::Islands::load(dir, &url_prefix) {
1638 Ok(i) => Some(i),
1639 Err(e) => {
1640 tracing::warn!("islands are not served, so they show their fallback: {e}");
1641 None
1642 }
1643 });
1644 let state = Arc::new(AppState {
1645 errors: self.errors,
1646 name: self.name,
1647 i18n: Arc::new(self.i18n),
1648 services: Arc::new(self.services),
1649 features: self.features,
1650 screens,
1651 auth: self.auth,
1652 interceptors: self.interceptors,
1653 timeout: self.timeout,
1654 secure_cookies: self.secure_cookies,
1655 mcp: self.mcp,
1656 csp_header: HeaderValue::from_str(&self.csp.header(false))
1657 .unwrap_or(HeaderValue::from_static(guard::DEFAULT_CSP)),
1658 #[cfg(feature = "islands")]
1659 island_csp: HeaderValue::from_str(&self.csp.header(true))
1660 .unwrap_or(HeaderValue::from_static(islands::CSP)),
1661 csp: Arc::new(self.csp),
1662 time: self.time,
1663 fixed_now: self.fixed_now,
1664 zone_picker: self.zone_picker,
1665 api_prefix: self.api_prefix,
1666 config: self.config,
1667 tenancy: self.tenancy,
1668 license: self.license,
1669 licenses: Mutex::new(HashMap::new()),
1670 login_url: self.login_url,
1671 rate: self.rate,
1672 write_rate: self.write_rate,
1673 rate_store: self.rate_store,
1674 trusted: self.trusted,
1675 deny: self.deny,
1676 allow: self.allow,
1677 api_docs: self.api_docs,
1678 prefix: url_prefix,
1679 site_url: self.site_url,
1680 look,
1681 views: self.views,
1682 native_menu: self.native_menu,
1683 locale_paths: self.locale_paths,
1684 prefetch: self.prefetch,
1685 palette_keys: crate::command::normalize_shortcut(&self.palette_keys)
1686 .unwrap_or_else(|| crate::command::PALETTE_SHORTCUT.into()),
1687 extras: self.extras,
1688 sign_in: self.sign_in,
1689 #[cfg(feature = "assistant")]
1690 assistant: self.assistant,
1691 #[cfg(feature = "islands")]
1692 islands,
1693 #[cfg(feature = "realtime")]
1694 realtime: self.realtime,
1695 });
1696 ops::start_jobs(&state);
1697 let mut router = Router::new();
1698 for f in &state.features {
1699 for s in f.screens() {
1700 if let Some(path) = f.path_of(&s.meta) {
1701 let name = s.meta.name;
1702 router = if crate::screen::route_has_segments(Some(&path)) {
1703 router.route(
1704 &path,
1705 get(
1706 move |st: State<Arc<AppState>>,
1707 h: HeaderMap,
1708 uri: axum::http::Uri,
1709 Path(segments): Path<Vec<(String, String)>>,
1710 q: Query<ParamsQuery>| {
1711 page(st, h, uri, segments, q, name)
1712 },
1713 ),
1714 )
1715 } else {
1716 router.route(
1717 &path,
1718 get(
1719 move |st: State<Arc<AppState>>,
1720 h: HeaderMap,
1721 uri: axum::http::Uri,
1722 q: Query<ParamsQuery>| {
1723 page(st, h, uri, Vec::new(), q, name)
1724 },
1725 ),
1726 )
1727 };
1728 }
1729 }
1730 }
1731 for (fi, f) in state.features.iter().enumerate() {
1732 for (ai, a) in f.apis().iter().enumerate() {
1733 let path = format!("{}/{}/{}", state.api_prefix, f.name(), a.meta.name);
1734 router = router.route(
1735 &path,
1736 post(
1737 move |st: State<Arc<AppState>>, h: HeaderMap, body: axum::body::Bytes| {
1738 agent::api_call(st, h, body, fi, ai)
1739 },
1740 ),
1741 );
1742 }
1743 }
1744 let cors = self.cors;
1745 if docs_enabled {
1746 router = router
1747 .route(
1748 &format!("{}/openapi.json", state.api_prefix),
1749 get(agent::openapi),
1750 )
1751 .route(
1752 &format!("{}/docs", state.api_prefix),
1753 get(agent::api_reference),
1754 );
1755 }
1756 let home = self.home.and_then(|name| {
1757 state.features.iter().find_map(|f| {
1758 f.screens()
1759 .iter()
1760 .find(|s| s.meta.name == name)
1761 .and_then(|s| f.path_of(&s.meta))
1762 })
1763 });
1764 if let Some(path) = home.filter(|p| p != "/") {
1765 let target = format!("{}{path}", state.prefix);
1766 router = router.route("/", get(move || async move { Redirect::to(&target) }));
1767 }
1768 if let Some(text) = self.llms_txt {
1769 router = router.route(
1770 "/llms.txt",
1771 get(move || async move { asset(text, "text/plain; charset=utf-8") }),
1772 );
1773 }
1774 if let Some(text) = self.robots_txt {
1775 router = router.route(
1776 "/robots.txt",
1777 get(move || async move { asset(text, "text/plain; charset=utf-8") }),
1778 );
1779 }
1780 let router = seo::routes(router, &state);
1781 let router = router
1782 .route(
1783 "/_zeyn/zeyn.js",
1784 get(|| async { asset(JS, "text/javascript; charset=utf-8") }),
1785 )
1786 .route(
1787 "/_zeyn/grid.js",
1788 get(|| async { asset(GRID_JS, "text/javascript; charset=utf-8") }),
1789 )
1790 .route(
1791 "/_zeyn/zeyn.css",
1792 get(|| async { asset(CSS, "text/css; charset=utf-8") }),
1793 )
1794 .route(
1795 "/_zeyn/nav.js",
1796 get(|| async { asset(NAV, "text/javascript; charset=utf-8") }),
1797 )
1798 .route("/_zeyn/theme.css", get(theme_css))
1799 .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
1800 .route("/_zeyn/csrf", get(csrf_token))
1801 .route("/_zeyn/screen/{screen}", get(screen_fragment))
1802 .route("/_zeyn/route/{screen}", get(route_redirect))
1803 .route("/_zeyn/menu", post(menu_call))
1804 .route("/_zeyn/form", post(form_call))
1805 .route("/_zeyn/locale", post(set_locale))
1806 .route("/_zeyn/timezone", post(set_time_zone));
1807 #[cfg(feature = "assistant")]
1808 let router = assistant::routes(router);
1809 let router = auth::routes(router, &state);
1810 #[cfg(feature = "accounts")]
1811 let router = accounts::routes(router, &state);
1812 #[cfg(feature = "islands")]
1813 let router = islands::routes(router);
1814 #[cfg(feature = "realtime")]
1815 let router = realtime::routes(router);
1816 #[cfg(feature = "pdf")]
1817 let router = pdf::routes(router);
1818 let router = modules::routes(site::routes(charts::routes(files::routes(router))));
1819 let router = theme::routes(pro::routes(media::routes(ui::routes(session::routes(
1820 data::routes(ops::routes(agent::routes(grid::routes(router)))),
1821 )))))
1822 .fallback(not_found);
1823 #[cfg(feature = "accounts")]
1824 let router = if state.sign_in.accounts.is_some() {
1825 router.layer(axum::middleware::from_fn_with_state(
1826 state.clone(),
1827 accounts::layer,
1828 ))
1829 } else {
1830 router
1831 };
1832 let router = auth::layers(router, &state);
1833 let router = router
1834 .layer(axum::middleware::from_fn_with_state(
1835 state.clone(),
1836 catch_panics,
1837 ))
1838 .layer(axum::middleware::from_fn_with_state(
1839 state.clone(),
1840 guard::guard_request,
1841 ))
1842 .layer(axum::middleware::from_fn(static_files))
1843 .layer(compression())
1844 .layer(axum::middleware::map_request(identity_for_ranges))
1845 .layer(axum::middleware::map_response_with_state(
1846 state.clone(),
1847 guard::security_headers,
1848 ))
1849 .layer(axum::middleware::map_response(ops::stamp))
1850 .layer(axum::middleware::from_fn(guard::trace_request))
1851 .with_state(state.clone());
1852 let router = if state.locale_paths {
1853 Router::new()
1854 .fallback_service(router)
1855 .layer(axum::middleware::from_fn_with_state(state, locale::layer))
1856 } else {
1857 router
1858 };
1859 let router = if mount.is_empty() {
1860 router
1861 } else {
1862 Router::new().nest(&mount, router)
1863 };
1864 if cors.is_empty() {
1865 return Ok(router);
1866 }
1867 let origins: Vec<HeaderValue> = cors
1868 .iter()
1869 .filter_map(|o| HeaderValue::from_str(o).ok())
1870 .collect();
1871 let layer = tower_http::cors::CorsLayer::new()
1872 .allow_origin(origins)
1873 .allow_methods([axum::http::Method::GET, axum::http::Method::POST])
1874 .allow_headers([
1875 header::AUTHORIZATION,
1876 header::CONTENT_TYPE,
1877 header::ACCEPT_LANGUAGE,
1878 ])
1879 .max_age(Duration::from_secs(600));
1880 Ok(router.layer(layer))
1881 }
1882
1883 pub async fn serve(self, addr: &str) -> std::io::Result<()> {
1885 if std::env::var_os("ZEYN_DESCRIBE").is_some() {
1886 println!(
1887 "{}",
1888 serde_json::to_string_pretty(&self.describe()).unwrap_or_default()
1889 );
1890 return Ok(());
1891 }
1892 let flush = self.start_telemetry();
1893 let router = self.router().map_err(|problems| {
1894 for p in &problems {
1895 tracing::error!("{p}");
1896 }
1897 std::io::Error::other("the app has problems; see the errors above")
1898 })?;
1899 let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| match e.kind() {
1900 std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied => std::io::Error::new(
1901 e.kind(),
1902 format!(
1903 "cannot listen on {addr}: {e}. Another program uses the port, or the system reserves it \
1904 (on Windows see `netsh interface ipv4 show excludedportrange protocol=tcp`). \
1905 Choose another address with `server.addr` in the configuration."
1906 ),
1907 ),
1908 _ => e,
1909 })?;
1910 tracing::info!("Zeyn is listening on http://{}", listener.local_addr()?);
1911 let served = axum::serve(
1912 listener,
1913 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
1914 )
1915 .await;
1916 if let Some(f) = flush {
1917 f.shutdown().await;
1918 }
1919 served
1920 }
1921
1922 fn start_telemetry(&self) -> Option<telemetry::Flush> {
1925 let flush = telemetry::init(
1926 &self.config,
1927 &self
1928 .i18n
1929 .text(self.i18n.default_locale(), &self.name),
1930 );
1931 for (locale, key) in self.i18n.missing_keys() {
1932 tracing::warn!("translation `{key}` is missing in `{locale}`");
1933 }
1934 #[cfg(debug_assertions)]
1935 if let Some(d) = self.extras.latency {
1936 tracing::warn!(
1937 "every call is delayed by {} ms (dev_latency)",
1938 d.as_millis()
1939 );
1940 }
1941 if self
1942 .auth
1943 .as_ref()
1944 .is_some_and(|a| a.is_development())
1945 {
1946 tracing::warn!(
1947 "DevAuth is on; every request has full access. Do not use it in production."
1948 );
1949 }
1950 flush
1951 }
1952}
1953
1954type CachedLicense = (Instant, Option<Arc<License>>);
1956
1957struct AppState {
1958 errors: ErrorScreens,
1959 name: Text,
1960 i18n: Arc<I18n>,
1961 services: Arc<Services>,
1962 features: Vec<Feature>,
1963 screens: HashMap<&'static str, (usize, ScreenEntry)>,
1964 auth: Option<Arc<dyn Authenticator>>,
1965 interceptors: Vec<Arc<dyn Interceptor>>,
1966 timeout: Duration,
1967 secure_cookies: bool,
1968 mcp: Mcp,
1969 csp_header: HeaderValue,
1971 #[cfg(feature = "islands")]
1973 island_csp: HeaderValue,
1974 csp: Arc<crate::csp::Csp>,
1975 time: TimePolicy,
1976 fixed_now: Option<DateTime>,
1977 zone_picker: bool,
1978 api_prefix: String,
1979 config: Config,
1980 tenancy: Option<Arc<dyn TenantResolver>>,
1981 license: Option<(Arc<dyn LicenseSource>, Duration)>,
1982 licenses: Mutex<HashMap<String, CachedLicense>>,
1983 login_url: Option<String>,
1984 rate: Option<RateLimit>,
1985 write_rate: Option<RateLimit>,
1986 rate_store: Arc<dyn RateStore>,
1987 trusted: Vec<IpRange>,
1988 deny: Vec<IpRange>,
1989 allow: Vec<IpRange>,
1990 api_docs: Option<ApiDocs>,
1991 prefix: String,
1992 site_url: String,
1993 look: theme::Look,
1994 views: Option<Arc<dyn crate::grid::DataGridViewStore>>,
1995 native_menu: bool,
1996 locale_paths: bool,
1997 prefetch: Prefetch,
1998 palette_keys: String,
1999 extras: ops::Extras,
2000 sign_in: auth::Extras,
2001 #[cfg(feature = "assistant")]
2002 assistant: Option<Arc<crate::assistant::Assistant>>,
2003 #[cfg(feature = "islands")]
2004 islands: Option<islands::Islands>,
2005 #[cfg(feature = "realtime")]
2006 realtime: Option<realtime::Hub>,
2007}
2008
2009fn percent_decode(s: &str) -> String {
2011 let bytes = s.as_bytes();
2012 let mut out = Vec::with_capacity(bytes.len());
2013 let mut i = 0;
2014 while i < bytes.len() {
2015 if bytes[i] == b'%'
2016 && i + 2 < bytes.len()
2017 && let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16)
2018 {
2019 out.push(b);
2020 i += 3;
2021 continue;
2022 }
2023 out.push(bytes[i]);
2024 i += 1;
2025 }
2026 String::from_utf8_lossy(&out).into_owned()
2027}
2028
2029#[derive(Clone, Copy, Default)]
2031struct ErrorScreens {
2032 not_found: Option<ScreenEntry>,
2033 server_error: Option<ScreenEntry>,
2034}
2035
2036enum Failure {
2039 App(Error),
2040 Timeout,
2041 Status(StatusCode, &'static str, Text),
2042 Moved(&'static str, Json, bool),
2045}
2046
2047impl From<Error> for Failure {
2048 fn from(e: Error) -> Self {
2050 match e {
2051 Error::Denied(Text::Key { ref key, .. }) if key == crate::auth::MFA_REQUIRED => {
2052 Failure::Status(
2053 StatusCode::UNAUTHORIZED,
2054 "mfa_required",
2055 Text::key(crate::auth::MFA_REQUIRED),
2056 )
2057 }
2058 e => Failure::App(e),
2059 }
2060 }
2061}
2062
2063#[derive(Deserialize)]
2064struct ParamsQuery {
2065 params: Option<String>,
2066 return_to: Option<String>,
2069}
2070
2071impl ParamsQuery {
2072 fn value(&self) -> Result<Json, Failure> {
2073 match &self.params {
2074 None if self.return_to.is_some() => Ok(json!({ "return_to": self.return_to })),
2075 None => Ok(Json::Null),
2076 Some(p) => serde_json::from_str(p).map_err(|_| Failure::App(Error::NotFound)),
2077 }
2078 }
2079}
2080
2081pub(crate) fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
2082 headers
2083 .get_all(header::COOKIE)
2084 .iter()
2085 .filter_map(|v| v.to_str().ok())
2086 .flat_map(|v| v.split(';'))
2087 .filter_map(|pair| pair.trim().split_once('='))
2088 .find(|(k, _)| *k == name)
2089 .map(|(_, v)| v)
2090}
2091
2092pub(crate) fn same(a: &str, b: &str) -> bool {
2094 a.len() == b.len()
2095 && a.bytes()
2096 .zip(b.bytes())
2097 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
2098 == 0
2099}
2100
2101pub(crate) fn same_bytes(a: &[u8], b: &[u8]) -> bool {
2103 a.len() == b.len()
2104 && a.iter()
2105 .zip(b)
2106 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
2107 == 0
2108}
2109
2110fn new_token() -> String {
2111 let mut bytes = [0u8; 32];
2112 getrandom::getrandom(&mut bytes).expect("the operating system random source must work");
2113 bytes
2114 .iter()
2115 .map(|b| format!("{b:02x}"))
2116 .collect()
2117}
2118
2119fn valid_token(t: &str) -> bool {
2120 t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())
2121}
2122
2123fn hex_digit(b: u8) -> Option<u8> {
2125 match b {
2126 b'0'..=b'9' => Some(b - b'0'),
2127 b'a'..=b'f' => Some(b - b'a' + 10),
2128 b'A'..=b'F' => Some(b - b'A' + 10),
2129 _ => None,
2130 }
2131}
2132
2133fn hex32(s: &[u8]) -> Option<[u8; 32]> {
2135 if s.len() != 64 {
2136 return None;
2137 }
2138 let mut out = [0u8; 32];
2139 for (i, pair) in s.as_chunks::<2>().0.iter().enumerate() {
2140 out[i] = hex_digit(pair[0])? << 4 | hex_digit(pair[1])?;
2141 }
2142 Some(out)
2143}
2144
2145fn mask_token(token: &str) -> String {
2150 let Some(raw) = unmask_token(token).or_else(|| hex32(token.as_bytes())) else {
2151 return String::new();
2152 };
2153 let mut pad = [0u8; 32];
2154 getrandom::getrandom(&mut pad).expect("the operating system random source must work");
2155 let mut out = String::with_capacity(128);
2156 for b in pad.into_iter().chain(
2157 raw.into_iter()
2158 .zip(pad)
2159 .map(|(r, p)| r ^ p),
2160 ) {
2161 use std::fmt::Write;
2162 let _ = write!(out, "{b:02x}");
2163 }
2164 out
2165}
2166
2167fn unmask_token(masked: &str) -> Option<[u8; 32]> {
2169 let b = masked.as_bytes();
2170 if b.len() != 128 {
2171 return None;
2172 }
2173 let (pad, x) = (hex32(&b[..64])?, hex32(&b[64..])?);
2174 let mut raw = [0u8; 32];
2175 for i in 0..32 {
2176 raw[i] = pad[i] ^ x[i];
2177 }
2178 Some(raw)
2179}
2180
2181pub(crate) fn csrf_matches(cookie: &str, sent: &str) -> bool {
2184 let Some(want) = hex32(cookie.as_bytes()) else {
2185 return false;
2186 };
2187 match unmask_token(sent).or_else(|| hex32(sent.as_bytes())) {
2188 Some(got) => same_bytes(&want, &got),
2189 None => false,
2190 }
2191}
2192
2193const MAX_LICENSES: usize = 4096;
2197
2198impl AppState {
2199 fn url(&self, path: &str) -> String {
2201 format!("{}{path}", self.prefix)
2202 }
2203
2204 fn page_url(&self, screen: &str, params: &Json) -> Option<String> {
2208 let (fi, entry) = self.screens.get(screen)?;
2209 let path = self.features[*fi].path_of(&entry.meta)?;
2210 crate::screen::screen_url(&path, params).map(|u| self.url(&u))
2211 }
2212
2213 fn with_segments(
2216 &self,
2217 screen: &str,
2218 params: Json,
2219 segments: &[(String, String)],
2220 ) -> Result<Json, Failure> {
2221 if segments.is_empty() {
2222 return Ok(params);
2223 }
2224 let (_, entry) = self
2225 .screens
2226 .get(screen)
2227 .ok_or(Failure::App(Error::NotFound))?;
2228 let mut params = if params.is_null() {
2229 Json::Object(Map::new())
2230 } else {
2231 params
2232 };
2233 let fields = params
2234 .as_object_mut()
2235 .ok_or(Failure::App(Error::NotFound))?;
2236 for (name, raw) in segments {
2237 let value = (entry.path_value)(name, raw).ok_or(Failure::App(Error::NotFound))?;
2238 fields.insert(name.clone(), value);
2239 }
2240 Ok(params)
2241 }
2242
2243 fn cookie_path(&self) -> &str {
2245 if self.prefix.is_empty() {
2246 "/"
2247 } else {
2248 &self.prefix
2249 }
2250 }
2251
2252 fn ctx(&self, headers: &HeaderMap) -> Ctx {
2253 let mut identity = self
2254 .auth
2255 .as_ref()
2256 .and_then(|a| a.authenticate(headers))
2257 .unwrap_or_default();
2258 auth::adjust(self, headers, &mut identity);
2259 let accept = headers
2260 .get(header::ACCEPT_LANGUAGE)
2261 .and_then(|v| v.to_str().ok());
2262 let locale = if self.locale_paths {
2263 headers
2264 .get(locale::HEADER)
2265 .and_then(|v| v.to_str().ok())
2266 .filter(|l| {
2267 self.i18n
2268 .locales()
2269 .iter()
2270 .any(|o| o.as_str() == *l)
2271 })
2272 .unwrap_or(self.i18n.default_locale())
2273 .to_string()
2274 } else {
2275 self.i18n
2276 .negotiate(cookie(headers, "zeyn_locale"), accept)
2277 };
2278 let tz = match self.time {
2279 TimePolicy::Organization(zone) => zone,
2280 TimePolicy::User { default } => identity
2281 .time_zone
2282 .as_deref()
2283 .and_then(TimeZone::named)
2284 .or_else(|| {
2285 cookie(headers, "zeyn_tz_manual")
2286 .and_then(|z| TimeZone::named(&percent_decode(z)))
2287 })
2288 .or_else(|| {
2289 cookie(headers, "zeyn_tz").and_then(|z| TimeZone::named(&percent_decode(z)))
2290 })
2291 .unwrap_or(default),
2292 };
2293 let resolved = self
2294 .tenancy
2295 .as_ref()
2296 .and_then(|r| r.resolve(headers))
2297 .and_then(|t| Tenant::new(&t));
2298 let tenant = match resolved {
2299 Some(t) if identity.is_signed_in() && !identity.member_of(t.id()) => {
2300 tracing::debug!(
2301 tenant = t.id(),
2302 "the user is not a member of the request's tenant; signed out"
2303 );
2304 identity = Identity::anonymous();
2305 Some(t)
2306 }
2307 Some(t) => Some(t),
2308 None => identity
2309 .tenant
2310 .as_deref()
2311 .and_then(Tenant::new),
2312 };
2313 let ctx = Ctx::for_request(
2314 identity,
2315 locale,
2316 self.services.clone(),
2317 self.i18n.clone(),
2318 tz,
2319 tenant,
2320 self.config.clone(),
2321 self.look
2322 .pick(cookie(headers, "zeyn_theme")),
2323 self.extras.session.reauth_within,
2324 self.csp.clone(),
2325 );
2326 data::read_reason(&ctx, headers);
2327 let ctx = if is_prefetch(headers) {
2328 ctx.as_prefetch()
2329 } else {
2330 ctx
2331 };
2332 match self.fixed_now {
2333 Some(now) => ctx.with_now(now),
2334 None => ctx,
2335 }
2336 }
2337
2338 async fn licensed(&self, ctx: Ctx) -> Ctx {
2340 let Some((source, refresh)) = &self.license else {
2341 return ctx;
2342 };
2343 let key = ctx
2344 .tenant()
2345 .map(|t| t.id().to_string())
2346 .unwrap_or_default();
2347 let cached = self
2348 .licenses
2349 .lock()
2350 .ok()
2351 .and_then(|c| c.get(&key).cloned());
2352 if let Some((at, license)) = &cached
2353 && at.elapsed() < *refresh
2354 {
2355 return ctx.with_license(license.clone());
2356 }
2357 let license = match source.load(ctx.tenant()).await {
2358 Ok(l) => l.map(Arc::new),
2359 Err(e) => {
2360 tracing::warn!("license source failed, keeping the last license: {e}");
2361 cached.and_then(|(_, l)| l)
2362 }
2363 };
2364 if let Ok(mut c) = self.licenses.lock() {
2365 if c.len() >= MAX_LICENSES && !c.contains_key(&key) {
2366 c.retain(|_, (at, _)| at.elapsed() < *refresh);
2367 if c.len() >= MAX_LICENSES {
2368 c.clear();
2369 }
2370 }
2371 c.insert(key, (Instant::now(), license.clone()));
2372 }
2373 ctx.with_license(license)
2374 }
2375
2376 fn csrf(&self, headers: &HeaderMap) -> (String, Option<String>) {
2378 match cookie(headers, "zeyn_csrf").filter(|t| valid_token(t)) {
2379 Some(t) => (t.to_string(), None),
2380 None => {
2381 let t = new_token();
2382 let secure = if self.secure_cookies { "; Secure" } else { "" };
2383 let c = format!(
2384 "zeyn_csrf={t}; Path={}; HttpOnly; SameSite=Strict{secure}",
2385 self.cookie_path()
2386 );
2387 (t, Some(c))
2388 }
2389 }
2390 }
2391
2392 fn check_csrf(&self, headers: &HeaderMap) -> Result<(), Failure> {
2393 let header = headers
2394 .get("x-zeyn-csrf")
2395 .and_then(|v| v.to_str().ok())
2396 .unwrap_or("");
2397 match cookie(headers, "zeyn_csrf") {
2398 Some(c) if csrf_matches(c, header) => Ok(()),
2399 _ => Err(Failure::App(Error::Denied(Text::key("zeyn.error.csrf")))),
2400 }
2401 }
2402
2403 async fn timed<T>(&self, fut: impl Future<Output = crate::Result<T>>) -> Result<T, Failure> {
2406 use tracing::Instrument;
2407 match tokio::time::timeout(self.timeout, fut)
2408 .instrument(tracing::info_span!("run"))
2409 .await
2410 {
2411 Ok(r) => r.map_err(Failure::App),
2412 Err(_) => Err(Failure::Timeout),
2413 }
2414 }
2415
2416 async fn open(
2418 &self,
2419 ctx: &Ctx,
2420 name: &str,
2421 params: Json,
2422 ) -> Result<(&Feature, ScreenEntry, Screen), Failure> {
2423 let (fi, entry) = *self
2424 .screens
2425 .get(name)
2426 .ok_or(Failure::App(Error::NotFound))?;
2427 let feature = &self.features[fi];
2428 if !ctx.identity().can(&entry.meta.access) {
2429 return Err(Failure::App(Error::Forbidden));
2430 }
2431 feature.check_guards(ctx)?;
2432 data::check_reason(ctx, &entry.meta)?;
2433 let only_return_to = params
2434 .as_object()
2435 .is_some_and(|o| o.len() == 1 && o.contains_key("return_to"));
2436 let mut screen = match self
2437 .timed((entry.build)(ctx.clone(), params))
2438 .await
2439 {
2440 Err(_) if only_return_to => {
2441 self.timed((entry.build)(ctx.clone(), Json::Null))
2442 .await?
2443 }
2444 other => other?,
2445 };
2446 if let Some((to, params, permanent)) = screen.take_moved() {
2447 return Err(Failure::Moved(to, params, permanent));
2448 }
2449 Ok((feature, entry, screen))
2450 }
2451
2452 fn before(&self, ctx: &Ctx, call: &Call) -> Result<(), Failure> {
2453 let span = tracing::Span::current();
2454 if !span.is_disabled() {
2455 span.record("kind", tracing::field::debug(call.kind));
2456 span.record("screen", call.screen);
2457 if let Some(a) = &call.action {
2458 span.record("action", a.name);
2459 }
2460 if let Some(user) = ctx.identity().user.as_deref() {
2461 span.record("user", user);
2462 }
2463 if let Some(tenant) = ctx.tenant() {
2464 span.record("tenant", tenant.id());
2465 }
2466 }
2467 session::check_reauth(self, ctx, call)?;
2468 for i in &self.interceptors {
2469 let _span =
2470 tracing::debug_span!("interceptor", name = i.name(), stage = "before").entered();
2471 i.before(ctx, call)?;
2472 }
2473 Ok(())
2474 }
2475
2476 #[cfg_attr(not(feature = "realtime"), allow(dead_code))]
2477 fn admit_call(&self, ctx: &Ctx, call: &Call) -> Result<(), Failure> {
2480 let started = Instant::now();
2481 let checked = self.before(ctx, call);
2482 self.after(ctx, call, started, &checked);
2483 checked
2484 }
2485
2486 async fn intercept<T, F>(
2492 &self,
2493 ctx: &Ctx,
2494 call: &Call,
2495 work: impl FnOnce() -> F,
2496 ) -> Result<T, Failure>
2497 where
2498 F: Future<Output = Result<T, Failure>>,
2499 {
2500 let started = Instant::now();
2501 let result = async {
2502 self.before(ctx, call)?;
2503 work().await
2504 }
2505 .await;
2506 self.after(ctx, call, started, &result);
2507 result
2508 }
2509
2510 fn after<T>(&self, ctx: &Ctx, call: &Call, started: Instant, result: &Result<T, Failure>) {
2511 let outcome = CallOutcome {
2512 ok: result.is_ok(),
2513 error: match result {
2514 Ok(_) => None,
2515 Err(Failure::Timeout) => Some("timeout"),
2516 Err(Failure::App(e)) => Some(e.code()),
2517 Err(Failure::Status(_, code, _)) => Some(*code),
2518 Err(Failure::Moved(..)) => Some("moved"),
2519 },
2520 duration: started.elapsed(),
2521 };
2522 let span = tracing::Span::current();
2523 if !span.is_disabled() {
2524 span.record("ok", outcome.ok);
2525 span.record("error", outcome.error);
2526 }
2527 tracing::debug!(
2528 kind = ?call.kind,
2529 screen = call.screen,
2530 action = call.action.as_ref().map(|a| a.name),
2531 user = ctx.identity().user.as_deref(),
2532 tenant = ctx.tenant().map(|t| t.id()),
2533 ok = outcome.ok,
2534 error = outcome.error,
2535 ms = outcome.duration.as_millis() as u64,
2536 "call finished"
2537 );
2538 for i in &self.interceptors {
2539 let _span =
2540 tracing::debug_span!("interceptor", name = i.name(), stage = "after").entered();
2541 i.after(ctx, call, &outcome);
2542 }
2543 }
2544
2545 fn view_key(ctx: &Ctx, screen: &str, node: &NodePath) -> String {
2547 format!(
2548 "{}:{screen}#{node}",
2549 ctx.tenant()
2550 .map(|t| t.id().to_string())
2551 .unwrap_or_default()
2552 )
2553 }
2554
2555 async fn saved_view(
2558 &self,
2559 ctx: &Ctx,
2560 grid: &dyn GridSource,
2561 screen: &str,
2562 node: &NodePath,
2563 ) -> Option<DataGridView> {
2564 let store = self.views.as_ref()?;
2565 let user = ctx.identity().user.clone()?;
2566 match self
2567 .timed(store.load(&user, &Self::view_key(ctx, screen, node)))
2568 .await
2569 {
2570 Ok(view) => view.map(|v| grid.clean_view(v)),
2571 Err(_) => {
2572 tracing::warn!("grid view store failed to load a view");
2573 None
2574 }
2575 }
2576 }
2577
2578 async fn preload(
2580 &self,
2581 ctx: &Ctx,
2582 nodes: Vec<(NodePath, &crate::screen::Node)>,
2583 name: &str,
2584 ) -> HashMap<String, LoadedPage> {
2585 let mut loaded = HashMap::new();
2586 let saved = self.views.is_some() && ctx.identity().is_signed_in();
2587 for (i, node) in nodes {
2588 let Some(grid) = node.grid() else { continue };
2589 if !ctx
2590 .identity()
2591 .can(&grid.action().access)
2592 {
2593 continue;
2594 }
2595 let view = self
2596 .saved_view(ctx, grid, name, &i)
2597 .await;
2598 if let Ok(mut page) = self
2599 .timed(grid.fetch_at(ctx.clone(), grid.first_query(view.as_ref()), Vec::new()))
2600 .await
2601 {
2602 page.view = view;
2603 page.saved_views = saved;
2604 loaded.insert(i.to_string(), page);
2605 }
2606 }
2607 loaded
2608 }
2609
2610 fn error_response(&self, ctx: &Ctx, failure: Failure) -> Response {
2611 let (status, code, message) = match &failure {
2612 Failure::Timeout => (
2613 StatusCode::GATEWAY_TIMEOUT,
2614 "timeout",
2615 Text::key("zeyn.error.timeout"),
2616 ),
2617 Failure::Status(status, code, message) => (*status, *code, message.clone()),
2618 Failure::Moved(..) => (
2619 StatusCode::NOT_FOUND,
2620 "not_found",
2621 Text::key("zeyn.error.not_found"),
2622 ),
2623 Failure::App(e) => {
2624 let status = match e {
2625 Error::Forbidden | Error::Denied(_) => StatusCode::FORBIDDEN,
2626 Error::NotFound => StatusCode::NOT_FOUND,
2627 Error::Conflict => StatusCode::CONFLICT,
2628 Error::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
2629 Error::Internal(detail) => {
2630 tracing::error!("{detail}");
2631 StatusCode::INTERNAL_SERVER_ERROR
2632 }
2633 };
2634 (status, e.code(), e.message())
2635 }
2636 };
2637 if let Failure::App(Error::Validation(errors)) = failure {
2638 let list: Vec<Json> = errors
2639 .errors
2640 .iter()
2641 .map(|e| json!({ "field": e.field, "rule": e.rule, "message": ctx.t(&e.message) }))
2642 .collect();
2643 return (status, axum::Json(json!({ "error": code, "errors": list }))).into_response();
2644 }
2645 (
2646 status,
2647 axum::Json(json!({ "error": code, "message": ctx.t(&message) })),
2648 )
2649 .into_response()
2650 }
2651
2652 #[cfg(any(feature = "oidc", feature = "totp"))]
2654 fn error_page(&self, ctx: &Ctx, failure: Failure, csrf: &str) -> Response {
2655 let (status, message) = Self::page_status(failure);
2656 let (title, body) = Self::builtin_error(ctx, &message);
2657 (status, Html(self.shell(ctx, csrf, &title, &body, None))).into_response()
2658 }
2659
2660 fn page_status(failure: Failure) -> (StatusCode, Text) {
2662 match failure {
2663 Failure::Timeout => (StatusCode::GATEWAY_TIMEOUT, Text::key("zeyn.error.timeout")),
2664 Failure::Status(status, _, message) => (status, message),
2665 Failure::Moved(..) => (StatusCode::NOT_FOUND, Text::key("zeyn.error.not_found")),
2666 Failure::App(e) => {
2667 let status = match &e {
2668 Error::Forbidden | Error::Denied(_) => StatusCode::FORBIDDEN,
2669 Error::NotFound => StatusCode::NOT_FOUND,
2670 Error::Conflict => StatusCode::CONFLICT,
2671 Error::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
2672 Error::Internal(detail) => {
2673 tracing::error!("{detail}");
2674 StatusCode::INTERNAL_SERVER_ERROR
2675 }
2676 };
2677 (status, e.message())
2678 }
2679 }
2680 }
2681
2682 fn builtin_error(ctx: &Ctx, message: &Text) -> (String, String) {
2684 let title = ctx.t(message);
2685 let body = format!(
2686 r#"<section class="z-screen z-error" aria-labelledby="z-error-title"><h1 id="z-error-title">{}</h1></section>"#,
2687 escape(&title)
2688 );
2689 (title, body)
2690 }
2691
2692 async fn error_screen(
2697 &self,
2698 ctx: &Ctx,
2699 failure: Failure,
2700 csrf: &str,
2701 partial: bool,
2702 ) -> Response {
2703 let (status, message) = Self::page_status(failure);
2704 let entry = match status {
2705 StatusCode::NOT_FOUND => self.errors.not_found,
2706 s if s.is_server_error() => self.errors.server_error,
2707 _ => None,
2708 };
2709 let own = match entry {
2710 Some(e) if ctx.identity().can(&e.meta.access) => self.render_error_screen(ctx, e).await,
2711 _ => None,
2712 };
2713 let (title, body) = own.unwrap_or_else(|| Self::builtin_error(ctx, &message));
2714 let mut response = if partial {
2715 partial_response(&format!("{title} · {}", ctx.t(&self.name)), body)
2716 } else {
2717 Html(self.shell(ctx, csrf, &title, &body, None)).into_response()
2718 };
2719 *response.status_mut() = status;
2720 response
2721 }
2722
2723 async fn render_error_screen(&self, ctx: &Ctx, entry: ScreenEntry) -> Option<(String, String)> {
2726 let built = catch_panic(self.timed((entry.build)(ctx.clone(), Json::Null))).await;
2727 let Ok(Ok(screen)) = built else {
2728 tracing::error!("error screen `{}` failed", entry.meta.name);
2729 return None;
2730 };
2731 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2732 let r = user_render(entry.meta.name, ctx);
2733 (
2734 ctx.t(screen.title()),
2735 screen.render(&r, &entry.meta, &Json::Null, &HashMap::new()),
2736 )
2737 }))
2738 .ok()
2739 }
2740
2741 fn menu_links(
2744 &self,
2745 ctx: &Ctx,
2746 r: &Render,
2747 current: Option<&str>,
2748 site: bool,
2749 ) -> Vec<(String, String, Vec<String>)> {
2750 let mut groups = Vec::new();
2751 for f in &self.features {
2752 if f.site_shell().is_some() != site || f.check_guards(ctx).is_err() {
2753 continue;
2754 }
2755 for g in f.menu_groups() {
2756 let items: Vec<String> = g
2757 .items
2758 .iter()
2759 .filter_map(|item| {
2760 let entry = f
2761 .screens()
2762 .iter()
2763 .find(|s| s.meta.name == item.screen)?;
2764 if !ctx.identity().can(&entry.meta.access) {
2765 return None;
2766 }
2767 let path = f.path_of(&entry.meta)?;
2768 let aria = if current == Some(item.screen) {
2769 r#" aria-current="page""#
2770 } else {
2771 ""
2772 };
2773 Some(format!(
2774 r#"<li><a href="{}"{aria}>{}</a></li>"#,
2775 escape(&self.url(&locale::path(self, ctx, &path))),
2776 r.text(&item.label)
2777 ))
2778 })
2779 .collect();
2780 if !items.is_empty() {
2781 groups.push((g.icon.html(), r.text(&g.label), items));
2782 }
2783 }
2784 }
2785 groups
2786 }
2787
2788 fn speculation(&self, mut page: Response) -> Response {
2790 if self.prefetch == Prefetch::Moderate
2791 && let Ok(v) = HeaderValue::from_str(&format!(
2792 r#""{}/_zeyn/speculation.json""#,
2793 self.prefix.trim_end_matches('/')
2794 ))
2795 {
2796 page.headers_mut()
2797 .insert("speculation-rules", v);
2798 }
2799 page
2800 }
2801
2802 fn locale_picker(&self, ctx: &Ctx, r: &Render) -> String {
2804 let locales = self.i18n.locales();
2805 if locales.len() < 2 {
2806 return String::new();
2807 }
2808 if self.locale_paths {
2809 return locale::picker(self, ctx, r);
2810 }
2811 let options: String = locales
2812 .iter()
2813 .map(|l| {
2814 let selected = if l == ctx.locale() { " selected" } else { "" };
2815 let name = self
2816 .i18n
2817 .lookup(l, "zeyn.locale.name")
2818 .unwrap_or(l);
2819 format!(
2820 r#"<option value="{}"{selected}>{}</option>"#,
2821 escape(l),
2822 escape(name)
2823 )
2824 })
2825 .collect();
2826 format!(
2827 r#"<div class="z-locale"><span id="z-locale-label">{}</span><div class="z-select" data-text-none=""><select id="z-locale" data-zeyn-locale>{options}</select></div></div>"#,
2828 r.text(&Text::key("zeyn.shell.language"))
2829 )
2830 }
2831
2832 fn shell(
2835 &self,
2836 ctx: &Ctx,
2837 csrf: &str,
2838 title: &str,
2839 main: &str,
2840 current: Option<&str>,
2841 ) -> String {
2842 let r = Render::new("shell", ctx.locale(), ctx.i18n())
2843 .with_tz(ctx.time_zone())
2844 .with_identity(ctx.identity());
2845 let site_shell = current
2846 .and_then(|n| self.screens.get(n))
2847 .and_then(|(fi, _)| {
2848 self.features[*fi]
2849 .site_shell()
2850 .map(|s| (*fi, s))
2851 });
2852 let theme = ctx.theme().unwrap_or("system");
2853 let app = r.text(&self.name);
2854 let (palette, commands) = pro::shell_parts(self, ctx, &r);
2855 let (frame, site_head) = match site_shell {
2856 Some((fi, shell)) => (
2857 site::frame(self, ctx, &r, shell, fi, main, current),
2858 site::head(&escape(&self.prefix)),
2859 ),
2860 None => (
2861 self.app_frame(ctx, &r, theme, &app, &palette, main, current),
2862 String::new(),
2863 ),
2864 };
2865 let (theme_attrs, scheme) = self.look.html(theme);
2866 #[cfg(feature = "assistant")]
2867 let (ai_head, ai_launcher) = (
2868 assistant::head(self, ctx, &escape(&self.prefix)),
2869 assistant::launcher(self, ctx, &r),
2870 );
2871 #[cfg(not(feature = "assistant"))]
2872 let (ai_head, ai_launcher) = (String::new(), String::new());
2873 let base = escape(&self.prefix);
2874 let components = format!(
2875 "{}{}{}{}{}{site_head}{ai_head}",
2876 ui::head(&base),
2877 media::head(&base),
2878 pro::head(&base),
2879 files::head(&base),
2880 charts::head(&base)
2881 );
2882 let native_menu = if self.native_menu {
2883 ""
2884 } else {
2885 r#" data-zeyn-native-menu="off""#
2886 };
2887 let html_attrs = format!("{theme_attrs}{native_menu}");
2888 format!(
2889 r##"<!doctype html><html lang="{lang}"{dir}{html_attrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="{scheme}"><meta name="zeyn-csrf" content="{csrf}"><meta name="zeyn-tz" content="{tz}"><meta name="zeyn-base" content="{base}"><title>{title} · {app}</title><link rel="stylesheet" href="{base}/_zeyn/zeyn.css{v}"><script type="module" src="{base}/_zeyn/zeyn.js{v}"></script><script type="module" src="{base}/_zeyn/nav.js{v}"></script>{components}{theme_link}{extra}{prefetch}</head><body><a class="z-skip" href="#z-main">{skip}</a>{frame}<div class="z-toasts" role="status" aria-live="polite" data-text-dismiss="{dismiss}"></div>{commands}{ai_launcher}</body></html>"##,
2890 lang = escape(ctx.locale()),
2891 dir = if locale::rtl(ctx.locale()) {
2892 r#" dir="rtl""#
2893 } else {
2894 ""
2895 },
2896 csrf = mask_token(csrf),
2897 tz = escape(ctx.time_zone().name()),
2898 v = ops::asset_version(),
2899 theme_link = if self.look.css.is_empty() {
2900 String::new()
2901 } else {
2902 format!(
2903 r#"<link rel="stylesheet" href="{}/_zeyn/theme.css?v={}">"#,
2904 escape(&self.prefix),
2905 self.look.version
2906 )
2907 },
2908 title = escape(title),
2909 extra = ops::head(self, ctx),
2910 prefetch = if self.prefetch == Prefetch::Moderate {
2911 r#"<meta name="zeyn-prefetch" content="moderate">"#
2912 } else {
2913 ""
2914 },
2915 skip = r.text(&Text::key("zeyn.shell.skip")),
2916 dismiss = r.text(&Text::key("zeyn.ui.toast.dismiss")),
2917 )
2918 }
2919
2920 #[allow(clippy::too_many_arguments)]
2925 fn app_frame(
2926 &self,
2927 ctx: &Ctx,
2928 r: &Render,
2929 theme: &str,
2930 app: &str,
2931 palette: &str,
2932 main: &str,
2933 current: Option<&str>,
2934 ) -> String {
2935 let r = *r;
2936 let mut nav = String::new();
2937 for (icon, label, items) in self.menu_links(ctx, &r, current, false) {
2938 nav.push_str(&format!(
2939 r#"<div class="z-nav-group"><h2>{icon}{label}</h2><ul>{}</ul></div>"#,
2940 items.join("")
2941 ));
2942 }
2943 nav.push_str(&self.locale_picker(ctx, &r));
2944 nav.push_str(&self.look.picker(&r, theme));
2945 if self.zone_picker && matches!(self.time, TimePolicy::User { .. }) {
2946 let current = ctx.time_zone().name();
2947 let auto = r.text(&Text::key("zeyn.shell.time_zone_auto"));
2948 let mut options = format!(r#"<option value="">{auto}</option>"#);
2949 let listed = TimeZone::names().any(|n| n == current);
2950 for name in TimeZone::names().chain((!listed).then_some(current)) {
2951 let selected = if name == current { " selected" } else { "" };
2952 options.push_str(&format!(
2953 r#"<option value="{name}"{selected}>{}</option>"#,
2954 name.replace('_', " ")
2955 ));
2956 }
2957 nav.push_str(&format!(
2958 r#"<div class="z-locale"><span id="z-timezone-label">{}</span><div class="z-select" data-text-none="{auto}"><select id="z-timezone" data-zeyn-timezone>{options}</select></div></div>"#,
2959 r.text(&Text::key("zeyn.shell.time_zone"))
2960 ));
2961 }
2962 format!(
2963 r#"<div class="z-app" data-zeyn-shell="app"><nav class="z-nav" aria-label="{menu}"><div class="z-nav-bar"><p class="z-app-name">{app}</p><button type="button" class="z-nav-toggle" aria-expanded="false" aria-controls="z-nav-body" data-zeyn-nav-toggle hidden><span class="z-nav-burger" aria-hidden="true"></span><span class="z-sr-only">{menu}</span></button></div><div class="z-nav-body" id="z-nav-body">{palette}{nav}</div></nav><main class="z-main" id="z-main">{main}</main></div>"#,
2964 menu = r.text(&Text::key("zeyn.shell.menu")),
2965 )
2966 }
2967}
2968
2969fn user_render<'a>(scope: &'a str, ctx: &'a Ctx) -> Render<'a> {
2973 Render::new(scope, ctx.locale(), ctx.i18n())
2974 .with_tz(ctx.time_zone())
2975 .with_identity(ctx.identity())
2976 .with_ctx(ctx)
2977}
2978
2979fn normalize_base(path: &str) -> String {
2980 let trimmed = path.trim().trim_matches('/');
2981 if trimmed.is_empty() {
2982 String::new()
2983 } else {
2984 format!("/{trimmed}")
2985 }
2986}
2987
2988const STATIC_CACHE: usize = 64;
2990
2991type StaticEntry = (HeaderValue, Option<axum::body::Bytes>);
2993
2994type StaticCache = std::sync::Mutex<std::collections::HashMap<(usize, usize), Arc<StaticEntry>>>;
2996
2997static STATIC_FILES: std::sync::OnceLock<StaticCache> = std::sync::OnceLock::new();
2998
2999async fn static_files(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
3006 let path = req.uri().path();
3007 let framework = path.contains("/_zeyn/")
3008 && (path.ends_with(".js") || path.ends_with(".css"))
3009 && !path.contains("/_zeyn/media/")
3010 && !path.contains("/_zeyn/files/");
3011 if req.method() != axum::http::Method::GET || !framework {
3012 return next.run(req).await;
3013 }
3014 let gzip = req
3015 .headers()
3016 .get_all(header::ACCEPT_ENCODING)
3017 .iter()
3018 .filter_map(|v| v.to_str().ok())
3019 .flat_map(|v| v.split(','))
3020 .any(|e| {
3021 let mut p = e.split(';');
3022 p.next()
3023 .is_some_and(|n| n.trim().eq_ignore_ascii_case("gzip"))
3024 && !p.any(|q| {
3025 q.trim()
3026 .strip_prefix("q=")
3027 .is_some_and(|v| {
3028 v.trim()
3029 .parse::<f32>()
3030 .is_ok_and(|v| v == 0.0)
3031 })
3032 })
3033 });
3034 let headers = req.headers().clone();
3035 let current = req.uri().query() == Some(&ops::asset_version()[1..]);
3036 let mut response = next.run(req).await;
3037 if current && response.status() == StatusCode::OK {
3038 response
3039 .headers_mut()
3040 .insert(header::CACHE_CONTROL, HeaderValue::from_static(IMMUTABLE));
3041 }
3042 if response.status() != StatusCode::OK
3043 || response
3044 .headers()
3045 .contains_key(header::ETAG)
3046 || response
3047 .headers()
3048 .contains_key(header::CONTENT_ENCODING)
3049 {
3050 return response;
3051 }
3052 let (mut parts, body) = response.into_parts();
3053 let Ok(bytes) = axum::body::to_bytes(body, 4 << 20).await else {
3054 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
3055 };
3056 let key = (bytes.as_ptr() as usize, bytes.len());
3057 let cache = STATIC_FILES.get_or_init(Default::default);
3058 let known = cache
3059 .lock()
3060 .ok()
3061 .and_then(|c| c.get(&key).cloned());
3062 let entry = known.unwrap_or_else(|| {
3063 let text = std::str::from_utf8(&bytes).unwrap_or("");
3064 let tag =
3065 HeaderValue::from_str(&etag("s", &[text])).unwrap_or(HeaderValue::from_static("\"s\""));
3066 let packed = (bytes.len() >= 1024).then(|| {
3067 use std::io::Write;
3068 let mut z = flate2::write::GzEncoder::new(
3069 Vec::with_capacity(bytes.len() / 3),
3070 flate2::Compression::new(6),
3071 );
3072 z.write_all(&bytes).ok();
3073 z.finish()
3074 .ok()
3075 .map(axum::body::Bytes::from)
3076 });
3077 let entry = Arc::new((tag, packed.flatten()));
3078 if let Ok(mut c) = cache.lock()
3079 && c.len() < STATIC_CACHE
3080 {
3081 c.insert(key, entry.clone());
3082 }
3083 entry
3084 });
3085 let (tag, packed) = &*entry;
3086 parts
3087 .headers
3088 .insert(header::ETAG, tag.clone());
3089 parts
3090 .headers
3091 .insert(header::VARY, HeaderValue::from_static("accept-encoding"));
3092 if etag_matches(&headers, tag.to_str().unwrap_or("")) {
3093 parts.status = StatusCode::NOT_MODIFIED;
3094 parts
3095 .headers
3096 .remove(header::CONTENT_LENGTH);
3097 return Response::from_parts(parts, axum::body::Body::empty());
3098 }
3099 match packed {
3100 Some(z) if gzip => {
3101 parts
3102 .headers
3103 .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
3104 parts
3105 .headers
3106 .insert(header::CONTENT_LENGTH, HeaderValue::from(z.len()));
3107 Response::from_parts(parts, axum::body::Body::from(z.clone()))
3108 }
3109 _ => {
3110 parts
3111 .headers
3112 .insert(header::CONTENT_LENGTH, HeaderValue::from(bytes.len()));
3113 Response::from_parts(parts, axum::body::Body::from(bytes))
3114 }
3115 }
3116}
3117
3118const UNCOMPRESSED: &[&str] = &[
3121 "video/",
3122 "audio/",
3123 "application/octet-stream",
3124 "text/event-stream",
3125 "application/x-ndjson",
3126 "application/grpc",
3127 "application/zip",
3128 "application/gzip",
3129 "application/pdf",
3130 "application/vnd.openxmlformats",
3131 "font/woff",
3132];
3133
3134fn compression()
3141-> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate> {
3142 use tower_http::compression::{
3143 CompressionLayer, CompressionLevel, Predicate, predicate::SizeAbove,
3144 };
3145 fn compressible(
3146 status: StatusCode,
3147 _: axum::http::Version,
3148 headers: &HeaderMap,
3149 _: &axum::http::Extensions,
3150 ) -> bool {
3151 let kind = headers
3152 .get(header::CONTENT_TYPE)
3153 .and_then(|v| v.to_str().ok())
3154 .unwrap_or("");
3155 status != StatusCode::SWITCHING_PROTOCOLS
3156 && (!kind.starts_with("image/") || kind.starts_with("image/svg+xml"))
3157 && !UNCOMPRESSED
3158 .iter()
3159 .any(|p| kind.starts_with(p))
3160 }
3161 CompressionLayer::new()
3162 .quality(CompressionLevel::Precise(2))
3163 .compress_when(SizeAbove::new(1024).and(compressible))
3164}
3165
3166async fn identity_for_ranges(mut req: axum::extract::Request) -> axum::extract::Request {
3169 if req
3170 .headers()
3171 .contains_key(header::RANGE)
3172 {
3173 req.headers_mut()
3174 .remove(header::ACCEPT_ENCODING);
3175 }
3176 req
3177}
3178
3179fn asset(body: &'static str, content_type: &'static str) -> Response {
3180 (
3181 [
3182 (header::CONTENT_TYPE, content_type),
3183 (header::CACHE_CONTROL, "no-cache"),
3184 ],
3185 body,
3186 )
3187 .into_response()
3188}
3189
3190async fn theme_css(State(st): State<Arc<AppState>>, uri: axum::http::Uri) -> Response {
3193 let css = st.look.css.clone();
3194 let current = uri
3195 .query()
3196 .and_then(|q| q.strip_prefix("v="))
3197 .is_some_and(|v| v == st.look.version);
3198 let cache = if current { IMMUTABLE } else { "no-cache" };
3199 (
3200 [
3201 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
3202 (header::CACHE_CONTROL, cache),
3203 ],
3204 css,
3205 )
3206 .into_response()
3207}
3208
3209const IMMUTABLE: &str = "public, max-age=31536000, immutable";
3211
3212fn with_cookie(mut response: Response, cookie: Option<String>) -> Response {
3213 if let Some(c) = cookie.and_then(|c| HeaderValue::from_str(&c).ok()) {
3214 response
3215 .headers_mut()
3216 .append(header::SET_COOKIE, c);
3217 }
3218 response
3219}
3220
3221async fn page(
3222 State(st): State<Arc<AppState>>,
3223 headers: HeaderMap,
3224 uri: axum::http::Uri,
3225 segments: Vec<(String, String)>,
3226 Query(q): Query<ParamsQuery>,
3227 name: &'static str,
3228) -> Response {
3229 let ctx = st.licensed(st.ctx(&headers)).await;
3230 let (ctx, theme_cookie) = theme::remember(&st, ctx, &headers).await;
3231 let meta = st
3232 .screens
3233 .get(name)
3234 .map(|(_, e)| e.meta);
3235 if ctx.is_prefetch() && meta.is_none_or(|m| m.no_prefetch) {
3236 return (
3237 StatusCode::SERVICE_UNAVAILABLE,
3238 [(header::CACHE_CONTROL, "no-store")],
3239 )
3240 .into_response();
3241 }
3242 let open = !ctx.identity().is_signed_in()
3243 && meta.is_some_and(|m| matches!(m.access, crate::action::Access::Public));
3244 let (csrf, set) = if open {
3245 (String::new(), None)
3246 } else {
3247 st.csrf(&headers)
3248 };
3249 let cache = meta.map_or(0, |m| {
3250 m.cache
3251 .min(crate::screen::MAX_SCREEN_CACHE)
3252 });
3253 let started = Instant::now();
3254 let call = Call {
3255 kind: CallKind::Page,
3256 screen: name,
3257 action: None,
3258 via_agent: false,
3259 };
3260 let result = async {
3261 let params = st.with_segments(name, q.value()?, &segments)?;
3262 st.before(&ctx, &call)?;
3263 let (_, entry, screen) = st
3264 .open(&ctx, name, params.clone())
3265 .await?;
3266 data::note_screen_read(&ctx, &entry.meta, ¶ms);
3267 let loaded = st
3268 .preload(&ctx, screen.walk(), name)
3269 .await;
3270 let r = user_render(name, &ctx);
3271 let html = screen.render(&r, &entry.meta, ¶ms, &loaded);
3272 let head = screen.head_html(&r, &st.site_url, &st.prefix);
3273 Ok::<_, Failure>((ctx.t(screen.title()), html, head))
3274 }
3275 .await;
3276 st.after(&ctx, &call, started, &result);
3277 let partial = headers
3278 .get("x-zeyn-partial")
3279 .is_some_and(|v| v == "1");
3280 let response = match result {
3281 Ok((title, html, head)) if partial => {
3282 let full = format!("{title} · {}", ctx.t(&st.name));
3283 #[cfg(feature = "islands")]
3284 let holds_island = islands::holds(&st, &html);
3285 #[cfg(feature = "realtime")]
3286 let holds_live = realtime::holds(&st, &html);
3287 let html = if head.is_empty() {
3288 html
3289 } else {
3290 format!("<template data-zeyn-head>{head}</template>{html}")
3291 };
3292 let tag = (open && cache > 0).then(|| etag("p", &[&full, &html]));
3293 #[allow(unused_mut)]
3294 let mut r = partial_response(&full, html);
3295 let h = r.headers_mut();
3296 let site = st
3297 .screens
3298 .get(name)
3299 .is_some_and(|(fi, _)| st.features[*fi].site_shell().is_some());
3300 h.insert(
3301 "x-zeyn-shell",
3302 HeaderValue::from_static(if site { "site" } else { "app" }),
3303 );
3304 #[cfg(feature = "islands")]
3305 if holds_island {
3306 h.insert("x-zeyn-islands", HeaderValue::from_static("1"));
3307 }
3308 #[cfg(feature = "realtime")]
3309 if holds_live {
3310 h.insert("x-zeyn-live", HeaderValue::from_static("1"));
3311 }
3312 match tag {
3313 Some(tag) => public_cache(r, &headers, &tag, cache),
3314 None => r,
3315 }
3316 }
3317 Ok((title, html, head)) => {
3318 let mut page = st.shell(&ctx, &csrf, &title, &html, Some(name));
3319 if !head.is_empty() {
3320 page = page.replacen("</head>", &format!("{head}</head>"), 1);
3321 }
3322 if html.contains("data-zeyn-grid") {
3323 let preload = format!(
3324 r#"<link rel="modulepreload" href="{}/_zeyn/grid.js{}"></head>"#,
3325 escape(&st.prefix),
3326 ops::asset_version()
3327 );
3328 page = page.replacen("</head>", &preload, 1);
3329 }
3330 #[cfg(feature = "realtime")]
3331 realtime::head(&st, &html, &mut page);
3332 let tag = (open && cache > 0).then(|| etag("f", &[&page]));
3333 #[cfg(feature = "islands")]
3334 let page = islands::page(&st, page, &html);
3335 #[cfg(not(feature = "islands"))]
3336 let page = Html(page).into_response();
3337 let page = st.speculation(page);
3338 match tag {
3339 Some(tag) => public_cache(page, &headers, &tag, cache),
3340 None if cache > 0 => private_page(page),
3341 None => page,
3342 }
3343 }
3344 Err(Failure::App(Error::Forbidden | Error::Denied(_)))
3345 if !ctx.identity().is_signed_in() && st.login_url.is_some() =>
3346 {
3347 let here = st.url(
3348 uri.path_and_query()
3349 .map_or(uri.path(), |p| p.as_str()),
3350 );
3351 let target = st.login_url.clone().unwrap_or_default();
3352 let sep = if target.contains('?') { '&' } else { '?' };
3353 Redirect::to(&format!("{target}{sep}return_to={}", encode(&here))).into_response()
3354 }
3355 Err(Failure::Status(_, "mfa_required", _)) if st.sign_in.mfa_url.is_some() => {
3356 let here = st.url(
3357 uri.path_and_query()
3358 .map_or(uri.path(), |p| p.as_str()),
3359 );
3360 let target = st
3361 .sign_in
3362 .mfa_url
3363 .clone()
3364 .unwrap_or_default();
3365 let sep = if target.contains('?') { '&' } else { '?' };
3366 Redirect::to(&format!("{target}{sep}return_to={}", encode(&here))).into_response()
3367 }
3368 Err(Failure::Status(_, "reason_required", _)) => data::reason_page(&st, &ctx, name, &csrf),
3369 Err(Failure::Moved(to, params, true)) => match st.page_url(to, ¶ms) {
3370 Some(url) => (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, url)]).into_response(),
3371 None => {
3372 st.error_screen(&ctx, Failure::App(Error::NotFound), &csrf, partial)
3373 .await
3374 }
3375 },
3376 Err(Failure::Moved(to, params, false)) => match st.page_url(to, ¶ms) {
3377 Some(url) => (
3378 StatusCode::SEE_OTHER,
3379 [
3380 (header::LOCATION, url),
3381 (header::CACHE_CONTROL, "no-store".to_string()),
3382 ],
3383 )
3384 .into_response(),
3385 None => {
3386 st.error_screen(&ctx, Failure::App(Error::NotFound), &csrf, partial)
3387 .await
3388 }
3389 },
3390 Err(f) => {
3391 st.error_screen(&ctx, f, &csrf, partial)
3392 .await
3393 }
3394 };
3395 let mut response = response;
3396 if let Ok(lang) = HeaderValue::from_str(ctx.locale()) {
3397 response
3398 .headers_mut()
3399 .insert(header::CONTENT_LANGUAGE, lang);
3400 }
3401 with_cookie(with_cookie(response, set), theme_cookie)
3402}
3403
3404fn etag(kind: &str, parts: &[&str]) -> String {
3407 use std::hash::Hasher;
3408 let mut h = std::hash::DefaultHasher::new();
3409 let mut len = 0;
3410 for p in parts {
3411 h.write(p.as_bytes());
3412 h.write_u8(0xff);
3413 len += p.len();
3414 }
3415 format!("\"{kind}{len:x}-{:016x}\"", h.finish())
3416}
3417
3418fn etag_matches(headers: &HeaderMap, tag: &str) -> bool {
3420 headers
3421 .get_all(header::IF_NONE_MATCH)
3422 .iter()
3423 .filter_map(|v| v.to_str().ok())
3424 .flat_map(|v| v.split(','))
3425 .any(|t| {
3426 let t = t.trim();
3427 t == "*" || t.strip_prefix("W/").unwrap_or(t) == tag
3428 })
3429}
3430
3431fn public_cache(mut response: Response, headers: &HeaderMap, tag: &str, seconds: u32) -> Response {
3435 let Ok(tag_value) = HeaderValue::from_str(tag) else {
3436 return private_page(response);
3437 };
3438 if etag_matches(headers, tag) {
3439 response = StatusCode::NOT_MODIFIED.into_response();
3440 }
3441 let h = response.headers_mut();
3442 h.insert(header::ETAG, tag_value);
3443 if let Ok(v) = HeaderValue::from_str(&format!("public, max-age={seconds}")) {
3444 h.insert(header::CACHE_CONTROL, v);
3445 }
3446 h.insert(
3447 header::VARY,
3448 HeaderValue::from_static("x-zeyn-partial, Accept-Language, Cookie"),
3449 );
3450 response
3451}
3452
3453fn private_page(mut response: Response) -> Response {
3455 response.headers_mut().insert(
3456 header::CACHE_CONTROL,
3457 HeaderValue::from_static("private, no-store"),
3458 );
3459 response
3460}
3461
3462async fn csrf_token(State(st): State<Arc<AppState>>, headers: HeaderMap) -> Response {
3466 let site = headers
3467 .get("sec-fetch-site")
3468 .and_then(|v| v.to_str().ok());
3469 if site.is_some_and(|s| s != "same-origin" && s != "none") {
3470 let ctx = st.ctx(&headers);
3471 return st.error_response(&ctx, Failure::App(Error::Forbidden));
3472 }
3473 let (token, set) = st.csrf(&headers);
3474 let mut r = axum::Json(json!({ "token": mask_token(&token) })).into_response();
3475 r.headers_mut()
3476 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
3477 with_cookie(r, set)
3478}
3479
3480async fn screen_fragment(
3481 State(st): State<Arc<AppState>>,
3482 Path(name): Path<String>,
3483 headers: HeaderMap,
3484 Query(q): Query<ParamsQuery>,
3485) -> Response {
3486 let ctx = st.licensed(st.ctx(&headers)).await;
3487 let Some((&name, _)) = st.screens.get_key_value(name.as_str()) else {
3488 return st.error_response(&ctx, Failure::App(Error::NotFound));
3489 };
3490 let call = Call {
3491 kind: CallKind::Screen,
3492 screen: name,
3493 action: None,
3494 via_agent: false,
3495 };
3496 let started = Instant::now();
3497 let result = async {
3498 let params = q.value()?;
3499 st.before(&ctx, &call)?;
3500 let (_, entry, screen) = st
3501 .open(&ctx, name, params.clone())
3502 .await?;
3503 data::note_screen_read(&ctx, &entry.meta, ¶ms);
3504 let loaded = st
3505 .preload(&ctx, screen.walk(), entry.meta.name)
3506 .await;
3507 let r = user_render(entry.meta.name, &ctx);
3508 Ok::<_, Failure>(screen.render(&r, &entry.meta, ¶ms, &loaded))
3509 }
3510 .await;
3511 st.after(&ctx, &call, started, &result);
3512 match result {
3513 Ok(html) => Html(html).into_response(),
3514 Err(f) => st.error_response(&ctx, f),
3515 }
3516}
3517
3518async fn route_redirect(
3519 State(st): State<Arc<AppState>>,
3520 Path(name): Path<String>,
3521 headers: HeaderMap,
3522 Query(q): Query<ParamsQuery>,
3523) -> Response {
3524 let ctx = st.ctx(&headers);
3525 let Some((fi, entry)) = st.screens.get(name.as_str()) else {
3526 return st.error_response(&ctx, Failure::App(Error::NotFound));
3527 };
3528 if st.features[*fi]
3529 .path_of(&entry.meta)
3530 .is_none()
3531 {
3532 return st.error_response(&ctx, Failure::App(Error::NotFound));
3533 }
3534 let params = match &q.params {
3535 Some(p) => match serde_json::from_str::<Json>(p) {
3536 Ok(v) => v,
3537 Err(_) => return st.error_response(&ctx, Failure::App(Error::NotFound)),
3538 },
3539 None => Json::Null,
3540 };
3541 match st.page_url(entry.meta.name, ¶ms) {
3542 Some(target) => Redirect::to(&target).into_response(),
3543 None => st.error_response(&ctx, Failure::App(Error::NotFound)),
3544 }
3545}
3546
3547fn partial_response(full_title: &str, html: String) -> Response {
3549 let mut r = Html(html).into_response();
3550 let h = r.headers_mut();
3551 h.insert("x-zeyn-partial", HeaderValue::from_static("1"));
3552 if let Ok(v) = HeaderValue::from_str(&encode(full_title)) {
3553 h.insert("x-zeyn-title", v);
3554 }
3555 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
3556 h.insert(header::VARY, HeaderValue::from_static("x-zeyn-partial"));
3557 r
3558}
3559
3560async fn catch_panic<F: Future>(fut: F) -> std::thread::Result<F::Output> {
3562 let mut fut = std::pin::pin!(fut);
3563 std::future::poll_fn(|cx| {
3564 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fut.as_mut().poll(cx))) {
3565 Ok(std::task::Poll::Ready(v)) => std::task::Poll::Ready(Ok(v)),
3566 Ok(std::task::Poll::Pending) => std::task::Poll::Pending,
3567 Err(e) => std::task::Poll::Ready(Err(e)),
3568 }
3569 })
3570 .await
3571}
3572
3573fn encode(s: &str) -> String {
3575 s.bytes()
3576 .map(|b| match b {
3577 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3578 (b as char).to_string()
3579 }
3580 _ => format!("%{b:02X}"),
3581 })
3582 .collect()
3583}
3584
3585pub(super) struct Chunks(
3587 pub(super) tokio::sync::mpsc::Receiver<Result<axum::body::Bytes, std::io::Error>>,
3588);
3589
3590impl futures_core::Stream for Chunks {
3591 type Item = Result<axum::body::Bytes, std::io::Error>;
3592
3593 fn poll_next(
3594 mut self: std::pin::Pin<&mut Self>,
3595 cx: &mut std::task::Context<'_>,
3596 ) -> std::task::Poll<Option<Self::Item>> {
3597 self.0.poll_recv(cx)
3598 }
3599}
3600
3601#[derive(Deserialize)]
3602struct MenuCall {
3603 screen: String,
3604 #[serde(default)]
3605 params: Json,
3606 item: String,
3608}
3609
3610#[derive(Deserialize)]
3611struct FormCall {
3612 screen: String,
3613 #[serde(default)]
3614 params: Json,
3615 node: NodePath,
3616 #[serde(default)]
3617 values: Map<String, Json>,
3618 #[serde(default)]
3619 via_agent: bool,
3620 #[serde(default)]
3622 confirmed: bool,
3623}
3624
3625impl AppState {
3626 async fn run_menu(&self, ctx: &Ctx, call: MenuCall) -> Result<Json, Failure> {
3629 if call.item.len() > 32 {
3630 return Err(Failure::App(Error::NotFound));
3631 }
3632 let (_, entry, screen) = self
3633 .open(ctx, &call.screen, call.params)
3634 .await?;
3635 let item = screen
3636 .menu_call(&call.item)
3637 .ok_or(Failure::App(Error::NotFound))?;
3638 if !ctx.identity().can(&item.meta.access) {
3639 return Err(Failure::App(Error::Forbidden));
3640 }
3641 let info = Call {
3642 kind: CallKind::Screen,
3643 screen: entry.meta.name,
3644 action: Some(item.meta),
3645 via_agent: ctx.via_agent(),
3646 };
3647 let result = self
3648 .intercept(ctx, &info, || self.timed((item.run)(ctx.clone())))
3649 .await;
3650 result?;
3651 Ok(json!({ "effects": [] }))
3652 }
3653
3654 async fn run_form(&self, ctx: &Ctx, call: FormCall) -> Result<Json, Failure> {
3655 let (_, entry, screen) = self
3656 .open(ctx, &call.screen, call.params)
3657 .await?;
3658 let form = screen
3659 .node(&call.node)
3660 .and_then(|n| n.form())
3661 .ok_or(Failure::App(Error::NotFound))?;
3662 let action = form.action();
3663 if !ctx.identity().can(&action.access) {
3664 return Err(Failure::App(Error::Forbidden));
3665 }
3666 if ctx.via_agent() {
3667 agent::agent_policy(&entry.meta.ai, &action, call.confirmed)?;
3668 }
3669 let info = Call {
3670 kind: CallKind::Form,
3671 screen: entry.meta.name,
3672 action: Some(action),
3673 via_agent: ctx.via_agent(),
3674 };
3675 let result = self
3676 .intercept(ctx, &info, || {
3677 self.timed(form.submit(ctx.clone(), call.values))
3678 })
3679 .await;
3680 let effects = result?;
3681 let r = user_render(entry.meta.name, ctx);
3682 Ok(json!({ "effects": effects.iter().map(|e| e.to_json(&r)).collect::<Vec<_>>() }))
3683 }
3684}
3685
3686async fn menu_call(
3687 State(st): State<Arc<AppState>>,
3688 headers: HeaderMap,
3689 axum::Json(call): axum::Json<MenuCall>,
3690) -> Response {
3691 let ctx = st.licensed(st.ctx(&headers)).await;
3692 if let Err(f) = st.check_csrf(&headers) {
3693 return st.error_response(&ctx, f);
3694 }
3695 match st.run_menu(&ctx, call).await {
3696 Ok(body) => axum::Json(body).into_response(),
3697 Err(f) => st.error_response(&ctx, f),
3698 }
3699}
3700
3701async fn form_call(
3702 State(st): State<Arc<AppState>>,
3703 headers: HeaderMap,
3704 axum::Json(call): axum::Json<FormCall>,
3705) -> Response {
3706 let mut ctx = st.licensed(st.ctx(&headers)).await;
3707 if let Err(f) = st.check_csrf(&headers) {
3708 return st.error_response(&ctx, f);
3709 }
3710 if call.via_agent {
3711 if !(st.mcp.browser && st.mcp.writes) {
3712 return st.error_response(&ctx, Failure::App(Error::Forbidden));
3713 }
3714 ctx = ctx.as_agent();
3715 }
3716 match st.run_form(&ctx, call).await {
3717 Ok(body) => axum::Json(body).into_response(),
3718 Err(f) => st.error_response(&ctx, f),
3719 }
3720}
3721
3722#[derive(Deserialize)]
3723struct LocaleCall {
3724 locale: String,
3725}
3726
3727async fn set_locale(
3728 State(st): State<Arc<AppState>>,
3729 headers: HeaderMap,
3730 axum::Json(call): axum::Json<LocaleCall>,
3731) -> Response {
3732 let ctx = st.ctx(&headers);
3733 if let Err(f) = st.check_csrf(&headers) {
3734 return st.error_response(&ctx, f);
3735 }
3736 if !st.i18n.locales().contains(&call.locale) {
3737 return st.error_response(&ctx, Failure::App(Error::NotFound));
3738 }
3739 let secure = if st.secure_cookies { "; Secure" } else { "" };
3740 let c = format!(
3741 "zeyn_locale={}; Path={}; Max-Age=31536000; SameSite=Lax{secure}",
3742 call.locale,
3743 st.cookie_path()
3744 );
3745 with_cookie(StatusCode::NO_CONTENT.into_response(), Some(c))
3746}
3747
3748fn problem(ctx: &Ctx, failure: Failure) -> Response {
3750 let (status, code, message) = match &failure {
3751 Failure::Timeout => (
3752 StatusCode::GATEWAY_TIMEOUT,
3753 "timeout",
3754 Text::key("zeyn.error.timeout"),
3755 ),
3756 Failure::Status(status, code, message) => (*status, *code, message.clone()),
3757 Failure::Moved(..) => (
3758 StatusCode::NOT_FOUND,
3759 "not_found",
3760 Text::key("zeyn.error.not_found"),
3761 ),
3762 Failure::App(e) => (
3763 match e {
3764 Error::Forbidden | Error::Denied(_) => StatusCode::FORBIDDEN,
3765 Error::NotFound => StatusCode::NOT_FOUND,
3766 Error::Conflict => StatusCode::CONFLICT,
3767 Error::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
3768 Error::Internal(detail) => {
3769 tracing::error!("{detail}");
3770 StatusCode::INTERNAL_SERVER_ERROR
3771 }
3772 },
3773 e.code(),
3774 e.message(),
3775 ),
3776 };
3777 let mut body = json!({
3778 "type": format!("urn:zeyn:problem:{code}"),
3779 "title": status.canonical_reason().unwrap_or(code),
3780 "status": status.as_u16(),
3781 "detail": ctx.t(&message),
3782 });
3783 if let Failure::App(Error::Validation(errors)) = failure {
3784 body["errors"] = errors
3785 .errors
3786 .iter()
3787 .map(|e| json!({ "field": e.field, "rule": e.rule, "message": ctx.t(&e.message) }))
3788 .collect();
3789 }
3790 let mut response = (status, body.to_string()).into_response();
3791 response.headers_mut().insert(
3792 header::CONTENT_TYPE,
3793 HeaderValue::from_static("application/problem+json"),
3794 );
3795 response
3796}
3797
3798#[derive(Deserialize)]
3799struct ZoneCall {
3800 zone: String,
3802}
3803
3804async fn set_time_zone(
3805 State(st): State<Arc<AppState>>,
3806 headers: HeaderMap,
3807 axum::Json(call): axum::Json<ZoneCall>,
3808) -> Response {
3809 let ctx = st.ctx(&headers);
3810 if let Err(f) = st.check_csrf(&headers) {
3811 return st.error_response(&ctx, f);
3812 }
3813 let secure = if st.secure_cookies { "; Secure" } else { "" };
3814 if call.zone.is_empty() {
3815 let c = format!(
3816 "zeyn_tz_manual=; Path={}; Max-Age=0; SameSite=Lax{secure}",
3817 st.cookie_path()
3818 );
3819 return with_cookie(StatusCode::NO_CONTENT.into_response(), Some(c));
3820 }
3821 let Some(zone) = TimeZone::named(&call.zone) else {
3822 return st.error_response(&ctx, Failure::App(Error::NotFound));
3823 };
3824 let c = format!(
3825 "zeyn_tz_manual={}; Path={}; Max-Age=31536000; SameSite=Lax{secure}",
3826 encode(zone.name()),
3827 st.cookie_path()
3828 );
3829 with_cookie(StatusCode::NO_CONTENT.into_response(), Some(c))
3830}
3831
3832async fn not_found(State(st): State<Arc<AppState>>, headers: HeaderMap) -> Response {
3833 let ctx = st.ctx(&headers);
3834 let (csrf, set) = st.csrf(&headers);
3835 let partial = headers
3836 .get("x-zeyn-partial")
3837 .is_some_and(|v| v == "1");
3838 with_cookie(
3839 st.error_screen(&ctx, Failure::App(Error::NotFound), &csrf, partial)
3840 .await,
3841 set,
3842 )
3843}
3844
3845async fn catch_panics(
3849 State(st): State<Arc<AppState>>,
3850 req: axum::extract::Request,
3851 next: axum::middleware::Next,
3852) -> Response {
3853 let headers = req.headers().clone();
3854 let page = req.method() == axum::http::Method::GET && {
3855 let path = req.uri().path();
3856 !path.contains("/_zeyn/") && !path.starts_with(&st.api_prefix)
3857 };
3858 match catch_panic(next.run(req)).await {
3859 Ok(response) => response,
3860 Err(_) => {
3861 tracing::error!("a request handler panicked");
3862 let ctx = st.ctx(&headers);
3863 let failure = Failure::App(Error::Internal("a request handler panicked".into()));
3864 if !page {
3865 return st.error_response(&ctx, failure);
3866 }
3867 let (csrf, set) = st.csrf(&headers);
3868 let partial = headers
3869 .get("x-zeyn-partial")
3870 .is_some_and(|v| v == "1");
3871 with_cookie(
3872 st.error_screen(&ctx, failure, &csrf, partial)
3873 .await,
3874 set,
3875 )
3876 }
3877 }
3878}