#![cfg_attr(docsrs, feature(doc_cfg))]
#![doc = include_str!("../README.md")]
#![doc = include_str!("../docs/app-config-reference.md")]
#![doc = include_str!("../docs/host-config-reference.md")]
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]
#[cfg(feature = "docs")]
pub mod jsonschema;
#[cfg(feature = "docs")]
pub use schemars;
mod app;
pub mod auth;
mod host;
mod http;
mod validate;
pub use app::*;
pub use auth::*;
pub use host::*;
pub use http::*;
pub use crate::validate::DOMAIN_REGEX;
use crate::validate::validate;
use anyhow::bail;
use hashbrown::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use smallvec::smallvec;
use std::collections::BTreeMap;
use std::fmt::Write;
use std::path::Path;
use std::process::Command;
use std::{env, fs};
use tracing::instrument;
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ClientLoggingConfig {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
min_delay: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
max_delay: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
max_buffer: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
max_batch: Option<u16>,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone)]
pub enum RedactedHashAlg {
Blake2,
Blake3,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerLoggingConfig {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub ips: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub headers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub credentials: Option<RedactedHashAlg>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub timing: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub sizes: Option<bool>,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct LoggingConfig {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub client: Option<ClientLoggingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub server: Option<ServerLoggingConfig>,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum CompressionAlgorithm {
Uncompressed,
Gzip,
Zstd { level: u8 },
Brotli,
Deflate,
All,
}
impl CompressionAlgorithm {
#[must_use]
pub fn as_u8(&self) -> u8 {
match self {
Self::Uncompressed => 0,
Self::Gzip => 1,
Self::Zstd { level: _ } => 2,
Self::Brotli => 3,
Self::Deflate => 4,
Self::All => 255,
}
}
#[must_use]
pub fn from_u8(val: u8, lvl: Option<u8>) -> Self {
match val {
0 => Self::Uncompressed,
1 => Self::Gzip,
2 => Self::Zstd {
level: lvl.unwrap_or(17),
},
3 => Self::Brotli,
4 => Self::Deflate,
_ => Self::All,
}
}
#[must_use]
pub fn as_char(&self) -> char {
match self {
Self::Uncompressed => '0',
Self::Gzip => '1',
Self::Zstd { level: _ } => '2',
Self::Brotli => '3',
Self::Deflate => '4',
Self::All => 'A',
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Uncompressed => "uncompressed",
Self::Gzip => "gzip",
Self::Zstd { level: _ } => "zstd",
Self::Brotli => "br",
Self::Deflate => "deflate",
Self::All => "all",
}
}
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct ErrorConfig {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub asset: Option<String>,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone)]
pub enum RuntimeMode {
Shared,
SingleThreaded,
MultiThreaded,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct LifecycleBeforeAfterScripts {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub before: Option<Vec<Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub after: Option<Vec<Vec<String>>>,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct TopLevelLifecycle {
pub before_all: Option<Vec<Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub build: Option<LifecycleBeforeAfterScripts>,
}
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct OrdinaryConfig {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub lifecycle: Option<TopLevelLifecycle>,
pub domain: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub cnames: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub canonical: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub http: Option<HttpConfig>,
#[serde(skip)]
#[serde(default)]
pub internal_middlewares: Option<HashMap<String, MiddlewareConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub contacts: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub hide_contacts: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default = "OrdinaryConfig::default_storage_size")]
pub storage_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub runtime: Option<RuntimeMode>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub hide_schema: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub client_events: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub redirect_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub logging: Option<LoggingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub error: Option<ErrorConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub auth: Option<AuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub globals: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub secrets: Option<Vec<Secret>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub database: Option<DatabaseConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub functions: Option<Vec<FunctionConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub assets: Option<AssetsConfig>,
}
impl OrdinaryConfig {
pub fn get(proj_path: impl AsRef<Path>, load_refs: bool) -> anyhow::Result<OrdinaryConfig> {
let path = proj_path.as_ref().join("ordinary.json");
let mut config_json = fs::read(&path)?;
let mut config = match simd_json::from_slice::<OrdinaryConfig>(&mut config_json) {
Ok(config) => config,
Err(err) => bail!("{}: {err}", path.display()),
};
if load_refs {
config.load_refs(proj_path.as_ref())?;
}
config.load_internal();
if let Some(database_config) = config.database.as_mut() {
database_config.models.sort_by_key(|m| m.idx);
for model_config in &mut database_config.models {
model_config.fields.sort_by_key(|m| m.idx);
for field in &mut model_config.fields {
field.kind.sort_sub_fields();
}
}
}
Ok(config)
}
pub fn write(&self, proj_path: &Path) -> anyhow::Result<()> {
use std::io::Write;
let ordinary_json = serde_json::to_string_pretty(self)?;
let mut file = fs::File::create(proj_path.join("ordinary.json"))?;
file.write_all(ordinary_json.as_bytes())?;
Ok(())
}
fn load_refs(&mut self, proj_path: &Path) -> anyhow::Result<()> {
if let Some(functions) = self.functions.as_mut() {
Self::load_function_refs(proj_path, functions)?;
}
Ok(())
}
fn load_function_refs(
proj_path: &Path,
function_configs: &mut Vec<FunctionConfig>,
) -> anyhow::Result<()> {
for base_function_config in function_configs {
if let Some(reference) = &base_function_config.r#ref {
let json_path = proj_path.join(reference);
let mut json_bytes = fs_err::read(json_path)?;
let mut ref_function_config: FunctionConfig =
simd_json::from_slice(json_bytes.as_mut_slice())?;
ref_function_config.load_bindgen();
ref_function_config
.r#ref
.clone_from(&base_function_config.r#ref);
if let Some(name) = &base_function_config.name {
ref_function_config.name = Some(name.clone());
}
if let Some(timeout) = base_function_config.timeout {
ref_function_config.timeout = Some(timeout);
}
*base_function_config = ref_function_config;
}
}
Ok(())
}
#[must_use]
pub fn database_model_map(&self) -> HashMap<String, DatabaseModelConfig> {
let mut model_map = HashMap::new();
let mut database_config = self.database.clone();
if let Some(database_config) = database_config.as_mut() {
database_config.models.sort_by_key(|m| m.idx);
for model_config in &mut database_config.models {
model_config.fields.sort_by_key(|m| m.idx);
for field in &mut model_config.fields {
field.kind.sort_sub_fields();
}
}
}
if let Some(database_config) = database_config {
for model_config in &database_config.models {
model_map.insert(model_config.name.clone(), model_config.clone());
}
}
model_map
}
pub fn load_internal(&mut self) {
self.canonical = Some(
self.canonical.clone().unwrap_or(
self.cnames
.clone()
.unwrap_or_default()
.first()
.map(ToOwned::to_owned)
.unwrap_or(self.domain.clone()),
),
);
self.load_internal_middlewares();
self.load_internal_compression();
self.load_internal_content_types();
}
fn load_internal_middlewares(&mut self) {
if let Some(http_config) = &self.http
&& let Some(middlewares) = &http_config.middlewares
{
let mut map = HashMap::new();
for middleware in middlewares {
map.insert(middleware.name.clone(), middleware.clone());
}
self.internal_middlewares = Some(map);
}
}
fn load_internal_compression(&mut self) {
if let Some(assets) = self.assets.as_mut()
&& let Some(precompression) = &assets.precompression
{
assets.internal_precompression = Some(precompression.get_list());
}
if let Some(http_config) = self.http.as_mut()
&& let Some(http_routes) = http_config.routes.as_mut()
{
for http_route in http_routes {
if let Some(http_config) = http_route.config.as_mut()
&& let Some(http_cache) = http_config.cache.as_mut()
&& let Some(stored_cache) = http_cache.stored.as_mut()
&& let Some(compression) = &stored_cache.compression
{
stored_cache.internal_compressions = Some(compression.get_list());
}
}
}
}
fn load_internal_content_types(&mut self) {
if let Some(http_config) = self.http.as_mut()
&& let Some(http_routes) = http_config.routes.as_mut()
{
for http_route in http_routes {
if let Some(http_config) = http_route.config.as_mut()
&& let Some(http_cache) = http_config.cache.as_mut()
&& let Some(stored_cache) = http_cache.stored.as_mut()
{
stored_cache.internal_content_types = Some(
stored_cache
.content_types
.clone()
.unwrap_or(smallvec!["text/html".into(), "application/json".into()]),
);
}
}
}
}
#[must_use]
pub fn get_middlewares(&self, middleware_names: &Vec<String>) -> Option<Vec<MiddlewareConfig>> {
let mut middleware_configs = vec![];
if let Some(middleware_map) = &self.internal_middlewares {
for middleware in middleware_names {
if let Some(middleware_config) = middleware_map.get(middleware) {
middleware_configs.push(middleware_config.clone());
}
}
}
if !middleware_configs.is_empty() {
return Some(middleware_configs);
}
None
}
pub fn for_send(&self) -> anyhow::Result<OrdinaryConfig> {
let mut config = self.clone();
config.lifecycle = None;
if let Some(assets) = config.assets.as_mut() {
assets.dir_path = None;
}
if let Some(function_configs) = config.functions.as_mut() {
for function_config in function_configs {
function_config.r#ref = None;
function_config.build = None;
function_config.bin = None;
function_config.bindgen = None;
}
}
Ok(config)
}
#[instrument(skip_all, err, level = "debug")]
pub fn validate(&self) -> anyhow::Result<()> {
validate(self)
}
#[must_use]
#[allow(clippy::unnecessary_wraps)]
pub fn default_storage_size() -> Option<u64> {
Some(5_000_000)
}
#[allow(clippy::too_many_lines)]
pub fn check_config_against_limits(
&self,
limits: &OrdinaryHostLimits,
privileged_domains: &HashSet<String>,
) -> anyhow::Result<()> {
check_config_against_limits(self, limits, privileged_domains)
}
pub fn exec_script(
proj_path: &Path,
argument: &Option<String>,
name: &str,
when: &str,
scripts: &Vec<Vec<String>>,
) -> anyhow::Result<()> {
let span = tracing::info_span!("lifecycle", %when, %name);
span.in_scope(|| {
exec_script(proj_path, argument, scripts)?;
anyhow::Ok(())
})
}
pub fn check_function_name_exists(&self, name: &str) -> anyhow::Result<()> {
if let Some(function_configs) = &self.functions {
for function_config in function_configs {
if function_config.name.as_deref() == Some(name) {
bail!("function with name {name} already exists");
}
}
}
Ok(())
}
}
pub fn exec_script(
proj_path: &Path,
argument: &Option<String>,
scripts: &Vec<Vec<String>>,
) -> anyhow::Result<()> {
let curr_dir = env::current_dir()?;
env::set_current_dir(proj_path)?;
for script in scripts {
let mut script_iter = script.iter();
if let Some(command) = script_iter.next() {
let mut command_str = command.clone();
let mut command = Command::new(command);
for arg in script_iter {
write!(command_str, " {arg}")?;
command.arg(arg);
}
tracing::info!(cmd = %command_str, "exec");
let output = match &argument {
Some(arg) => command.arg(arg).output()?,
None => command.output()?,
};
if !output.status.success() {
let stderr = str::from_utf8(&output.stderr)?;
let stdout = str::from_utf8(&output.stdout)?;
tracing::error!(%stderr, %stdout, "failed");
bail!(stderr.to_string());
}
}
}
env::set_current_dir(curr_dir)?;
Ok(())
}