pub mod actions;
pub mod assets;
pub mod cors;
mod events;
pub mod middleware;
mod ops;
mod proxy;
mod redirects;
pub mod start;
pub mod templates;
use hashbrown::HashMap;
use ordinary_config::{
CompressionAlgorithm, HttpCache, HttpEtag, HttpEtagAlgorithm, OrdinaryApiLimits, RedirectMethod,
};
use axum::Router;
use axum::extract::State;
use axum::http::{HeaderName, Request, StatusCode};
use axum::http::{HeaderValue, Uri};
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::{get, post};
use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;
use std::sync::{Arc, OnceLock};
use bytes::{Bytes, BytesMut};
use ordinary_auth::Auth;
use ordinary_integration::Integration;
use axum::http::header::{CONTENT_TYPE, ETAG, EXPIRES, VARY};
use ordinary_storage::{CacheDependency, Storage};
use tokio::net::TcpListener;
use ordinary_action::{Action, Engine, PrivilegedComponents};
use ordinary_config::OrdinaryConfig;
use ordinary_template::{Template, TemplateResult};
use ordinary_types::ContentObject;
use tower::ServiceBuilder;
use tracing::{Instrument, Span};
use crate::server::ops::assets::get_asset_to_res;
use anyhow::bail;
use async_compression::Level;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as b64};
use blake2::digest::{FixedOutput, Mac};
use getrandom::SysRng;
use hyper::header::{CONTENT_ENCODING, LAST_MODIFIED};
use hyper::{HeaderMap, header};
use mime::Mime;
use ordinary_assets::{APP_V1_IMG_PX, APP_V1_JS, APP_V1_JS_PX, APP_V1_WASM};
use ordinary_utils::compression::{get_compressed, get_compressed_all};
use ordinary_utils::json::JsonValuable;
use ordinary_utils::middleware::{
ServiceKind, apply_common_middleware, check_if_none_match, get_etag_hash,
modify_etag_for_encoding, x_via,
};
use ordinary_utils::{
GMT_FORMAT, REPORTING_ENDPOINTS, SecurityMode, WrappedRedactedHashingAlg, get_host,
};
use ordinary_utils::{SERVER, dns};
use parking_lot::RwLock;
use rand_chacha::rand_core::{Rng, SeedableRng};
use reqwest::redirect::Policy;
use time::UtcDateTime;
use tokio_rustls::rustls::ServerConfig;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::validate_request::ValidateRequestHeaderLayer;
use uuid::Uuid;
pub(crate) type OrdinaryAppRouter = Router<Arc<OrdinaryAppServerState>>;
static V1_JS_COMPRESSION: OnceLock<HashMap<String, Bytes>> = OnceLock::new();
static V1_JS_PX_COMPRESSION: OnceLock<HashMap<String, Bytes>> = OnceLock::new();
static V1_WASM_COMPRESSION: OnceLock<HashMap<String, Bytes>> = OnceLock::new();
pub struct OrdinaryAppServer {
pub config: Arc<OrdinaryConfig>,
state: Arc<OrdinaryAppServerState>,
limits: OrdinaryApiLimits,
pub service: Router,
pub proxies: Arc<HashMap<String, (Router, Option<u16>)>>,
pub log_ips: bool,
pub log_headers: bool,
}
#[derive(Clone)]
pub(crate) struct OrdinaryAppServerState {
schema: Option<String>,
config: Arc<OrdinaryConfig>,
auth: Arc<Auth>,
storage: Arc<Storage>,
templates: Vec<Template>,
actions: Arc<Vec<Action>>,
template_route_map: HashMap<String, usize>,
action_route_map: HashMap<String, usize>,
registration_actions: Vec<u8>,
error_template_idx: Option<u8>,
mfa_totp_template_idx: Option<u8>,
html_asset_csp: HeaderValue,
html_asset_reporting_endpoints: (HeaderName, HeaderValue),
secure_cookies: bool,
is_killed: Arc<RwLock<bool>>,
reqwest_client: reqwest::Client,
redacted_hash: Arc<Option<WrappedRedactedHashingAlg>>,
log_headers: bool,
}
fn get_precompressed_router(
body: Bytes,
path: &str,
shared_last_modified: &HeaderValue,
shared_last_modified_ts: i64,
prebuilt_http_cache: &Arc<HttpCache>,
content_type: HeaderValue,
compression_map: &OnceLock<HashMap<String, Bytes>>,
) -> anyhow::Result<OrdinaryAppRouter> {
let shared_last_modified_clone = shared_last_modified.clone();
let prebuilt_http_cache_clone = prebuilt_http_cache.clone();
let compression_map = compression_map.clone();
let schema_etag_str = get_etag_hash(body.as_ref(), Some(prebuilt_http_cache));
let schema_etag = HeaderValue::from_str(schema_etag_str.as_str())?;
Ok(Router::new().route(
path,
get(move |headers: HeaderMap| async move {
let mut header_map = HeaderMap::with_capacity(5);
if let Some(expires_s) = prebuilt_http_cache_clone.expires {
let future = UtcDateTime::now() + time::Duration::seconds(expires_s.cast_signed());
if let Ok(formatted) = future.format(&GMT_FORMAT)
&& let Ok(expires) = HeaderValue::from_str(formatted.as_str())
{
header_map.insert(EXPIRES, expires);
}
}
header_map.insert(
VARY,
HeaderValue::from_static(header::ACCEPT_ENCODING.as_str()),
);
header_map.insert(LAST_MODIFIED, shared_last_modified_clone);
if let Some(etag) = check_if_none_match(&headers, &schema_etag_str)
&& let Ok(etag_header) = HeaderValue::from_str(etag)
{
header_map.insert(ETAG, etag_header);
return (StatusCode::NOT_MODIFIED, header_map).into_response();
}
header_map.insert(ETAG, schema_etag);
if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE)
&& let Ok(if_modified_since_str) = if_modified_since.to_str()
&& let Ok(if_modified_since) =
UtcDateTime::parse(if_modified_since_str, &GMT_FORMAT)
&& if_modified_since.unix_timestamp() >= shared_last_modified_ts
{
return (StatusCode::NOT_MODIFIED, header_map).into_response();
}
header_map.insert(CONTENT_TYPE, content_type.clone());
if let Some(compressions) = headers.get(header::ACCEPT_ENCODING)
&& let Ok(compressions_str) = compressions.to_str()
&& let Some(compression_str) =
compressions_str.split(',').collect::<Vec<_>>().first()
&& let Some(compression_map) = compression_map.get()
&& let Some(compressed) = compression_map.get(&compression_str.to_string())
&& let Ok(compression_header) = HeaderValue::from_str(compression_str)
{
header_map.insert(CONTENT_ENCODING, compression_header);
return (StatusCode::OK, header_map, compressed.clone()).into_response();
}
(StatusCode::OK, header_map, body).into_response()
})
.route_layer(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
ETAG,
modify_etag_for_encoding,
))
.layer(axum::middleware::from_fn(x_via)),
),
))
}
impl OrdinaryAppServer {
#[must_use]
pub fn get_is_killed(&self) -> bool {
*self.state.is_killed.read()
}
pub fn set_killed(&self, val: bool) {
let mut lock = self.state.is_killed.write();
*lock = val;
}
pub async fn verify_custom_domains(config: &OrdinaryConfig) -> anyhow::Result<()> {
let domain = &config.domain;
if let Some(proxies) = &config.proxies {
for proxy in proxies {
if let Some(proxy_domain) = &proxy.domain {
let span = tracing::info_span!("lookup", proxy.domain = %proxy_domain);
async { dns::custom_domain_lookup(config, domain, proxy_domain, true).await? }
.instrument(span)
.await?;
}
}
}
if let Some(cnames) = &config.cnames {
for cname in cnames {
let span = tracing::info_span!("lookup", %cname);
async { dns::custom_domain_lookup(config, domain, cname, false).await? }
.instrument(span)
.await?;
}
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::fn_params_excessive_bools,
clippy::needless_pass_by_value,
clippy::result_large_err
)]
pub async fn new(
mut config: OrdinaryConfig,
limits: OrdinaryApiLimits,
auth: Arc<Auth>,
storage: Arc<Storage>,
secure: bool,
secure_cookies: bool,
log_headers: bool,
log_ips: bool,
server_span: Option<Span>,
app_span: Option<Span>,
privileged_components: Option<PrivilegedComponents>,
api_domain: Option<String>,
) -> anyhow::Result<OrdinaryAppServer> {
let app_span = if let Some(app_span) = app_span {
app_span
} else if let Some(server_span) = server_span.clone() {
server_span.in_scope(|| tracing::info_span!("app", domain = %config.domain))
} else {
tracing::info_span!("app", domain = %config.domain)
};
async {
if let Some(assets) = &mut config.assets {
assets.init("max-age=600")?;
}
let schema = if config.hide_schema == Some(true) {
None
} else {
let mut config = config.clone();
if config.hide_contacts != Some(false) {
config.contacts = None;
}
config.hide_contacts = None;
config.storage_size = None;
config.port = None;
config.redirect_port = None;
let mut schema_str = serde_json::to_string(&config)?;
if let Some(secrets) = &config.secrets {
let mut salt = [0u8; 32];
let mut rng = rand_chacha::ChaCha20Rng::try_from_rng(&mut SysRng)?;
rng.fill_bytes(&mut salt[..]);
for secret in secrets {
let hash: [u8; 32] = blake2::Blake2sMac256::new_from_slice(&salt[..])?
.chain_update(secret.name.as_bytes())
.finalize_fixed()
.into();
schema_str = schema_str.replace(&secret.name, &b64.encode(&hash[0..9]));
}
}
Some(schema_str)
};
let mut secrets = BTreeMap::new();
if let Some(secret_configs) = &config.secrets {
for secret in secret_configs {
secrets.insert(secret.name.clone(), secret);
}
}
let mut integrations = Vec::with_capacity(match &config.integrations {
Some(i) => i.len(),
None => 0,
});
if let Some(mut integration_configs) = config.integrations.clone() {
integration_configs.sort_by_key(|a| a.idx);
for (i, config) in integration_configs.iter().enumerate() {
if i != config.idx as usize {
tracing::error!("gap in or duplicate integration indexes");
}
if let Ok(integration) =
Integration::new(config.clone(), storage.clone(), &secrets)
{
integrations.push(integration);
}
}
}
let integrations = Arc::new(integrations);
let engine = Engine::default();
let model_ct = match &config.models {
Some(i) => i.len(),
None => 0,
};
let content_ct = match &config.content {
Some(i) => i.definitions.len(),
None => 0,
};
let mut model_map = HashMap::with_capacity(model_ct);
let mut content_map = HashMap::with_capacity(content_ct);
if let Some(models) = config.models.clone() {
for model in models {
model_map.insert(model.name.clone(), model);
}
}
if let Some(content) = &config.content {
for content_def in content.definitions.clone() {
content_map.insert(content_def.name.clone(), content_def);
}
}
let (actions, action_route_map, registration_actions) = actions::setup(
&mut config,
&auth,
&storage,
&privileged_components,
&integrations,
&engine,
&mut model_map,
&mut content_map,
);
let (templates, template_route_map, error_template_idx, mfa_totp_template_idx) =
templates::setup(
&mut config,
&auth,
&storage,
secure,
&engine,
&mut model_map,
&mut content_map,
);
let redacted_hash = if let Some(logging) = &config.logging
&& let Some(server) = &logging.server
&& let Some(credentials) = &server.credentials
{
Arc::new(Some(WrappedRedactedHashingAlg(credentials.clone())))
} else {
Arc::new(None)
};
let html_asset_csp =
config
.assets
.as_ref()
.map_or("default-src 'self'".to_string(), |a| {
a.html_csp.clone().unwrap_or_default().build_string(
&config.csp.clone().unwrap_or_default(),
None,
None,
None,
secure,
config.has_ordinary_actions() || config.auth.is_some(),
)
});
let reporting_endpoints = format!(
"csp=\"http{}://{}/.ordinary/reports/csp\"",
if secure { "s" } else { "" },
config.domain,
);
let is_killed = Arc::new(RwLock::new(false));
let config = Arc::new(config);
let state = Arc::new(OrdinaryAppServerState {
config: config.clone(),
schema,
auth,
storage,
templates,
template_route_map,
action_route_map,
registration_actions,
actions: Arc::new(actions),
error_template_idx,
mfa_totp_template_idx,
secure_cookies,
html_asset_csp: HeaderValue::from_str(html_asset_csp.as_str())?,
html_asset_reporting_endpoints: (
REPORTING_ENDPOINTS,
HeaderValue::from_str(reporting_endpoints.as_str())?,
),
is_killed: is_killed.clone(),
reqwest_client: reqwest::Client::builder()
.use_rustls_tls()
.no_brotli()
.no_deflate()
.no_gzip()
.no_zstd()
.redirect(Policy::none())
.user_agent(SERVER)
.build()?,
redacted_hash: redacted_hash.clone(),
log_headers,
});
let mut router = Router::new();
router = router.route(
"/.ordinary/version",
get(
|State(state): State<Arc<OrdinaryAppServerState>>| async move {
(
StatusCode::OK,
[("content-type", "text/plain")],
state.config.version.clone(),
)
.into_response()
},
),
);
router = router.route(
"/.ordinary/reports/csp",
post(|mut body: BytesMut| async move {
if let Ok(report) = simd_json::from_slice::<serde_json::Value>(body.as_mut()) {
let csp_span = tracing::info_span!("csp");
csp_span.in_scope(|| {
#[cfg(tracing_unstable)]
tracing::error!(
report = tracing::field::valuable(&JsonValuable(report))
);
#[cfg(not(tracing_unstable))]
tracing::error!(report = tracing::field::debug(&report));
});
StatusCode::OK
} else {
tracing::error!("failed to parse report");
StatusCode::UNSUPPORTED_MEDIA_TYPE
}
}),
);
if let Some(events_router) = events::setup_routes(&config) {
router = router.merge(events_router);
}
let prebuilt_http_cache = Arc::new(HttpCache {
cache_control: None,
expires: Some(60 * 60 * 3),
etag: Some(HttpEtag {
alg: Some(HttpEtagAlgorithm::AHash),
}),
});
let shared_last_modified_date = UtcDateTime::now();
let shared_last_modified_ts = shared_last_modified_date.unix_timestamp();
let Ok(shared_last_modified) = shared_last_modified_date.format(&GMT_FORMAT) else {
bail!("failed to format last_modified");
};
let Ok(shared_last_modified) = HeaderValue::from_str(&shared_last_modified) else {
bail!("failed to get header value for last_modified");
};
if state.config.hide_schema != Some(true)
&& let Some(schema) = &state.schema
{
let compression_map = get_compressed_all(schema.as_bytes()).await;
let schema_router = get_precompressed_router(
Bytes::copy_from_slice(schema.as_bytes()),
"/.ordinary/schema",
&shared_last_modified,
shared_last_modified_ts,
&prebuilt_http_cache,
HeaderValue::from_static("application/json"),
&OnceLock::from(compression_map),
)?;
router = router.merge(schema_router);
}
if V1_JS_COMPRESSION.get().is_none() {
let compression_map = get_compressed_all(APP_V1_JS.as_bytes()).await;
let _ = V1_JS_COMPRESSION.set(compression_map);
}
let v1_js_router = get_precompressed_router(
Bytes::copy_from_slice(APP_V1_JS.as_bytes()),
"/.ordinary/v1/js",
&shared_last_modified,
shared_last_modified_ts,
&prebuilt_http_cache,
HeaderValue::from_static("text/javascript"),
&V1_JS_COMPRESSION,
)?;
if V1_JS_PX_COMPRESSION.get().is_none() {
let compression_map = get_compressed_all(APP_V1_JS_PX.as_bytes()).await;
let _ = V1_JS_PX_COMPRESSION.set(compression_map);
}
let v1_js_px_router = get_precompressed_router(
Bytes::copy_from_slice(APP_V1_JS_PX.as_bytes()),
"/.ordinary/v1/js/px",
&shared_last_modified,
shared_last_modified_ts,
&prebuilt_http_cache,
HeaderValue::from_static("text/javascript"),
&V1_JS_PX_COMPRESSION,
)?;
router = router.merge(v1_js_router).merge(v1_js_px_router).route(
"/.ordinary/v1/img/px",
get(|| async {
(
StatusCode::OK,
[(CONTENT_TYPE, HeaderValue::from_static("img/png"))],
APP_V1_IMG_PX,
)
}),
);
if state.config.auth.is_some() || state.config.has_ordinary_actions() {
if V1_WASM_COMPRESSION.get().is_none() {
let compression_map = get_compressed_all(APP_V1_WASM).await;
let _ = V1_WASM_COMPRESSION.set(compression_map);
}
let v1_wasm_router = get_precompressed_router(
Bytes::copy_from_slice(APP_V1_WASM),
"/.ordinary/v1/wasm",
&shared_last_modified,
shared_last_modified_ts,
&prebuilt_http_cache,
HeaderValue::from_static("application/wasm"),
&V1_WASM_COMPRESSION,
)?;
router = router.merge(v1_wasm_router);
}
if state.config.auth.is_some() {
router = router
.route("/.ordinary/v1/accounts/access", get(ops::token::access))
.route(
"/.ordinary/v1/accounts/registration/start",
post(ops::registration::start),
)
.route(
"/.ordinary/v1/accounts/registration/finish",
post(ops::registration::finish),
)
.route(
"/.ordinary/v1/accounts/login/start",
post(ops::login::start),
)
.route(
"/.ordinary/v1/accounts/login/finish",
post(ops::login::finish),
)
.route(
"/.ordinary/v1/accounts/registration/js",
post(ops::registration::hash_only),
)
.route(
"/.ordinary/v1/accounts/login/js",
post(ops::login::hash_only),
)
.route(
"/.ordinary/v1/accounts/password/reset/login/start",
post(ops::password::reset_login_start),
)
.route(
"/.ordinary/v1/accounts/password/reset/login/finish",
post(ops::password::reset_login_finish),
)
.route(
"/.ordinary/v1/accounts/password/reset/registration/start",
post(ops::password::reset_registration_start),
)
.route(
"/.ordinary/v1/accounts/password/reset/registration/finish",
post(ops::password::reset_registration_finish),
)
.route(
"/.ordinary/v1/accounts/password/forgot/start",
post(ops::password::forgot_start),
)
.route(
"/.ordinary/v1/accounts/password/forgot/finish",
post(ops::password::forgot_finish),
)
.route(
"/.ordinary/v1/accounts/mfa/totp/reset/start",
post(ops::mfa::reset_totp_start),
)
.route(
"/.ordinary/v1/accounts/mfa/totp/reset/finish",
post(ops::mfa::reset_totp_finish),
)
.route(
"/.ordinary/v1/accounts/mfa/totp/lost/start",
post(ops::mfa::lost_totp_start),
)
.route(
"/.ordinary/v1/accounts/mfa/totp/lost/finish",
post(ops::mfa::lost_totp_finish),
)
.route(
"/.ordinary/v1/accounts/delete/start",
post(ops::account::delete_start),
)
.route(
"/.ordinary/v1/accounts/delete/finish",
post(ops::account::delete_finish),
)
.route(
"/.ordinary/v1/accounts/recovery-codes/reset/start",
post(ops::recovery::reset_codes_start),
)
.route(
"/.ordinary/v1/accounts/recovery-codes/reset/finish",
post(ops::recovery::reset_codes_finish),
);
if state.auth.config.cookies_enabled {
router = router
.route(
"/.ordinary/v1/accounts/registration/form",
post(ops::registration::form),
)
.route("/.ordinary/v1/accounts/login/form", post(ops::login::form))
.route(
"/.ordinary/v1/accounts/access/redirect",
get(ops::token::access_redirect),
)
.route(
"/.ordinary/v1/accounts/access/cookies",
get(ops::token::access_cookies),
);
}
}
if let Some(redirect_router) = redirects::setup_routes(&config) {
router = router.merge(redirect_router);
}
let (proxy_services, proxy_router) = proxy::setup_services(
secure,
log_headers,
log_ips,
&server_span,
&redacted_hash,
&config,
&state,
&api_domain,
)?;
router = router.merge(proxy_router);
let forwarded_by = format!("_{}", Uuid::now_v7());
let forwarded_proto = format!("http{}", if secure { "s" } else { "" });
if let Some(actions_router) = actions::setup_router(
&config,
&state,
&api_domain,
&forwarded_by,
&forwarded_proto,
) {
router = router.merge(actions_router);
}
if let Some(templates_router) = templates::setup_router(
&config,
&state,
&api_domain,
&forwarded_by,
&forwarded_proto,
) {
router = router.merge(templates_router);
}
if let Some(assets_router) = assets::setup_router(
&config,
&state,
&api_domain,
&forwarded_by,
&forwarded_proto,
) {
router = router.merge(assets_router);
}
router =
router.fallback(
|State(state): State<Arc<OrdinaryAppServerState>>,
uri: Uri,
headers: HeaderMap| async move {
if let Some(idx) = state.error_template_idx
&& let Some(err_template) = &state.templates.get(idx as usize)
{
let Some(host) = get_host(&headers, &uri) else {
tracing::error!("no host");
return StatusCode::BAD_REQUEST.into_response();
};
match err_template.render(
host.as_str(),
"/404".into(),
None,
Some(("Not found".into(), 404)),
None,
&None,
) {
Ok(res) => {
if let TemplateResult::Result(bytes) = res {
return (
StatusCode::NOT_FOUND,
[(CONTENT_TYPE, err_template.mime.clone())],
bytes,
)
.into_response();
}
}
Err(err) => tracing::warn!("{err}"),
}
} else if let Some(error_config) = &state.config.error
&& let Some(asset_name) = &error_config.asset
{
return get_asset_to_res(
state.clone(),
Some(asset_name.clone()),
uri,
headers,
true,
)
.into_response();
}
StatusCode::NOT_FOUND.into_response()
},
);
let domain_clone = config.domain.clone();
let mut service = apply_common_middleware(
router,
&state,
server_span,
domain_clone.clone(),
log_headers,
log_ips,
redacted_hash,
ServiceKind::App,
Some(api_domain.unwrap_or(domain_clone)),
);
if let Some(redirects) = &config.redirects
&& let Some(host_redirects) = &redirects.host
{
let mut redirect_map = HashMap::new();
for host_redirect in host_redirects {
redirect_map.insert(
host_redirect.from.clone(),
(host_redirect.to.clone(), host_redirect.method.clone()),
);
}
let redirect_map = Arc::new(redirect_map.clone());
let redirect_map_clone = redirect_map.clone();
service = service.layer(ServiceBuilder::new().layer(
ValidateRequestHeaderLayer::custom(
move |request: &mut Request<_>| -> Result<(), Response<_>> {
let uri = request.uri();
if let Some(host) = get_host(request.headers(), uri) {
return match redirect_map_clone.get(&host) {
Some((to, method)) => {
if let Ok(location) = Uri::builder()
.scheme("https")
.authority(to.clone())
.path_and_query(
uri.path_and_query().map_or("/", |pq| pq.as_str()),
)
.build()
{
match method {
RedirectMethod::Permanent => Err((
StatusCode::PERMANENT_REDIRECT,
[(header::LOCATION, location.to_string())],
)
.into_response()),
RedirectMethod::Temporary => Err((
StatusCode::TEMPORARY_REDIRECT,
[(header::LOCATION, location.to_string())],
)
.into_response()),
}
} else {
Ok(())
}
}
None => Ok(()),
};
}
Ok(())
},
),
));
}
let is_killed_clone = is_killed.clone();
service = service.layer(ServiceBuilder::new().layer(
ValidateRequestHeaderLayer::custom(
move |_: &mut Request<_>| -> Result<(), Response<_>> {
let lock = is_killed_clone.read();
if *lock {
Err((
StatusCode::SERVICE_UNAVAILABLE,
[(CONTENT_TYPE, "text/html")],
"<h1>503 Service Unavailable</h1>",
)
.into_response())
} else {
Ok(())
}
},
),
));
if secure {
service = service.layer(SetResponseHeaderLayer::if_not_present(
header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static("max-age=31536000"),
));
}
Ok(OrdinaryAppServer {
config,
state,
limits,
service,
proxies: Arc::new(proxy_services),
log_ips,
log_headers,
})
}
.instrument(app_span)
.await
}
pub fn get_invite(
&self,
api_domain: &str,
api_account: &str,
custom_claims: Option<Bytes>,
) -> anyhow::Result<Bytes> {
self.state
.auth
.api_invite_get(api_domain, api_account, custom_claims)
}
pub fn list_accounts(&self) -> anyhow::Result<Bytes> {
self.state.auth.list_accounts()
}
pub fn list_items(&self, model_idx: u8, cursor: Option<&[u8]>) -> anyhow::Result<Bytes> {
self.state.storage.model.list_model(model_idx, cursor)
}
pub async fn update_content(&self, objects: &Vec<ContentObject>) -> anyhow::Result<()> {
let storage_span = tracing::info_span!("storage");
let span = storage_span.in_scope(|| tracing::info_span!("content"));
span.in_scope(|| self.state.storage.content.update(objects))?;
let span = storage_span.in_scope(|| tracing::info_span!("cache"));
async {
self.state
.storage
.cache
.dependency_evict(vec![CacheDependency::Content])
.await
}
.instrument(span)
.await?;
Ok(())
}
pub fn put_secret(&self, name: &str, secret: &[u8]) -> anyhow::Result<()> {
let storage_span = tracing::info_span!("storage");
let span = storage_span.in_scope(|| tracing::info_span!("secrets"));
span.in_scope(|| self.state.storage.secrets.put(name, secret))?;
Ok(())
}
#[allow(clippy::too_many_lines)]
pub async fn put_asset(&self, path: &str, content: &[u8]) -> anyhow::Result<()> {
let storage_span = tracing::info_span!("storage");
let span = storage_span.in_scope(|| tracing::info_span!("asset"));
async {
if let Some(assets_config) = &self.config.assets {
let segments = path.split('/');
for segment in segments {
let escaped_segment: String =
url::form_urlencoded::byte_serialize(segment.as_bytes()).collect();
if segment != escaped_segment {
bail!("path segment '{segment}' is not url safe");
}
}
let mut no_compress = false;
let mime = if let Some(ext_str) = path.rsplit_once('.').map(|(_, ext)| ext) {
if ext_str == "map" {
if let Some(path_without_map) = path.strip_suffix(".map")
&& let Some(ext) = path_without_map.rsplit_once('.').map(|(_, ext)| ext)
{
if ext == "css" {
if !self
.limits
.storage
.assets
.allowed_extensions
.contains(&"css.map".to_owned())
{
bail!("extension '.css.map' is not allowed");
}
mime::APPLICATION_JSON
} else {
bail!("extension '.{ext}.map' is not allowed");
}
} else {
bail!("extension '.map' is not allowed");
}
} else {
if !self
.limits
.storage
.assets
.allowed_extensions
.contains(&ext_str.to_string())
{
bail!("extension '.{ext_str}' is not allowed");
}
match ext_str {
"otf" => {
no_compress = true;
Mime::from_str("font/otf")?
}
"ttf" => {
no_compress = true;
Mime::from_str("font/ttf")?
}
"woff" => {
no_compress = true;
mime::FONT_WOFF
}
"woff2" => {
no_compress = true;
mime::FONT_WOFF2
}
"txt" => mime::TEXT_PLAIN_UTF_8,
"xml" => mime::TEXT_XML,
"html" => mime::TEXT_HTML_UTF_8,
"css" => mime::TEXT_CSS_UTF_8,
"csv" => mime::TEXT_CSV_UTF_8,
"js" => mime::TEXT_JAVASCRIPT,
"png" => {
no_compress = true;
mime::IMAGE_PNG
}
"apng" => {
no_compress = true;
Mime::from_str("image/apng")?
}
"gif" => {
no_compress = true;
mime::IMAGE_GIF
}
"svg" => mime::IMAGE_SVG,
"jpg" | "jpeg" => {
no_compress = true;
mime::IMAGE_JPEG
}
"bmp" => {
no_compress = true;
mime::IMAGE_BMP
}
"tif" | "tiff" => {
no_compress = true;
Mime::from_str("image/tiff")?
}
"webp" => {
no_compress = true;
Mime::from_str("image/webp")?
}
"avif" => {
no_compress = true;
Mime::from_str("image/avif")?
}
"ico" => {
no_compress = true;
Mime::from_str("image/vnd.microsoft.icon")?
}
"pdf" => {
no_compress = true; mime::APPLICATION_PDF
}
"json" => mime::APPLICATION_JSON,
"wasm" => Mime::from_str("application/wasm")?,
_ => mime::APPLICATION_OCTET_STREAM,
}
}
} else {
mime::APPLICATION_OCTET_STREAM
};
if !no_compress && let Some(precompression) = &assets_config.internal_precompression
{
for alg in precompression {
self.put_asset_with_compression(path, content, Some(alg), &mime)
.await?;
}
}
self.put_asset_with_compression(path, content, None, &mime)
.await?;
Ok(())
} else {
bail!("no assets configured for this project")
}
}
.instrument(span)
.await
}
#[allow(clippy::too_many_lines)]
async fn put_asset_with_compression(
&self,
path: &str,
content: &[u8],
compression: Option<&CompressionAlgorithm>,
mime: &Mime,
) -> anyhow::Result<()> {
let mut asset_builder = flexbuffers::Builder::new(&flexbuffers::BuilderOptions::SHARE_NONE);
let mut asset_builder_vec = asset_builder.start_vector();
asset_builder_vec.push(mime.as_ref());
let mut etag = if let Some(config) = &self.config.assets
&& let Some(http_cache) = &config.http
{
get_etag_hash(content, Some(http_cache))
} else {
get_etag_hash(content, None)
};
let pre_compression_size = content.len();
if let Some(compression) = compression {
if let CompressionAlgorithm::All = compression {
tracing::error!("should not be able to hit 'CompressionAlgorithm::All'");
}
etag.push(compression.as_char());
match compression {
CompressionAlgorithm::All => {
bail!("should not be able to hit 'CompressionAlgorithm::All'");
}
CompressionAlgorithm::Zstd { level } => {
let result = get_compressed(
content,
compression.as_str(),
Some(Level::Precise(i32::from(*level))),
)
.await;
asset_builder_vec.push(flexbuffers::Blob(result.as_ref()));
}
_ => {
let result = get_compressed(content, compression.as_str(), None).await;
asset_builder_vec.push(flexbuffers::Blob(result.as_ref()));
}
}
} else {
asset_builder_vec.push(flexbuffers::Blob(content));
}
asset_builder_vec.push(etag.as_str());
asset_builder_vec.push(UtcDateTime::now().format(&GMT_FORMAT)?.as_str());
asset_builder_vec.end_vector();
self.state.storage.asset.put(
path,
asset_builder.view(),
compression.map(|c| (c, pre_compression_size)),
)?;
Ok(())
}
pub async fn set_template(
&self,
pos: u8,
bin: &[u8],
csp_style: Option<Vec<String>>,
csp_script: Option<Vec<String>>,
secure: bool,
) -> anyhow::Result<String> {
let span = tracing::info_span!(
"template",
nm = tracing::field::Empty,
i = tracing::field::Empty
);
async {
if let Some(template) = self.state.templates.get(pos as usize) {
span.record("nm", tracing::field::display(&template.config.name));
span.record("i", template.config.idx);
template
.set_wasm(bin, &self.config, csp_style, csp_script, secure)
.await?;
Ok(template.config.name.clone())
} else {
bail!("no template with idx: {pos}")
}
}
.instrument(span.clone())
.await
}
pub async fn set_action(&self, pos: u8, bin: &[u8]) -> anyhow::Result<String> {
let span = tracing::info_span!(
"action",
nm = tracing::field::Empty,
i = tracing::field::Empty
);
if let Some(action) = self.state.actions.get(pos as usize) {
span.record("nm", tracing::field::display(&action.config.name));
span.record("i", action.config.idx);
async { action.set_wasm(bin).await }
.instrument(span)
.await?;
Ok(action.config.name.clone())
} else {
span.in_scope(|| bail!("no action with idx: {pos}"))
}
}
pub async fn start<P, F>(
&self,
listener: TcpListener,
redirect_listener: Option<TcpListener>,
proxy_listeners: Option<HashMap<String, TcpListener>>,
mode: SecurityMode<P>,
configs: Option<(Arc<ServerConfig>, Arc<ServerConfig>)>,
signal: fn() -> F,
) -> anyhow::Result<()>
where
P: AsRef<Path>,
F: Future<Output = ()> + Send + 'static,
{
start::start(
self,
listener,
redirect_listener,
proxy_listeners,
mode,
configs,
signal,
)
.await
}
}