All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
245 lines
6.3 KiB
Rust
245 lines
6.3 KiB
Rust
use std::{collections::BTreeMap, ops::Deref, path::Path, sync::Arc};
|
|
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::storage::CompressionAlg;
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct Config {
|
|
i: Arc<ConfigInner>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ConfigInner {
|
|
pub server: ServerCfg,
|
|
pub compression: CompressionCfg,
|
|
pub keys: BTreeMap<String, KeyCfg>,
|
|
}
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum ConfigError {
|
|
#[error("io error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("parsing error: {0}")]
|
|
Parse(#[from] toml::de::Error),
|
|
}
|
|
|
|
type Result<T> = std::result::Result<T, ConfigError>;
|
|
|
|
impl Deref for Config {
|
|
type Target = ConfigInner;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.i
|
|
}
|
|
}
|
|
|
|
impl Serialize for Config {
|
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
ConfigInner::serialize(self, serializer)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for Config {
|
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
ConfigInner::deserialize(deserializer).map(|c| Self { i: c.into() })
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn new(cfg: ConfigInner) -> Self {
|
|
Self { i: cfg.into() }
|
|
}
|
|
|
|
/// Read the configuration from the given file
|
|
/// or create a default config at the given location
|
|
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
let path = path.as_ref();
|
|
if path.is_file() {
|
|
let cfg_str = std::fs::read_to_string(path)?;
|
|
Ok(toml::from_str::<Config>(&cfg_str)?)
|
|
} else {
|
|
let cfg = Self::default();
|
|
if let Ok(cfg_str) = toml::to_string_pretty(&cfg) {
|
|
std::fs::write(path, cfg_str)
|
|
.unwrap_or_else(|e| log::error!("Could not write default config file: {e}"));
|
|
}
|
|
Ok(cfg)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ServerCfg {
|
|
/// Address to bind the server to
|
|
///
|
|
/// Default: `0.0.0.0:3000`
|
|
pub address: String,
|
|
/// Root domain (and port if non-standard) under which Talon is available
|
|
///
|
|
/// Default: `localhost:3000`
|
|
pub root_domain: String,
|
|
/// Subdomain used for Talon internals (API, assets)
|
|
///
|
|
/// Default: `talon`
|
|
pub internal_subdomain: String,
|
|
/// URL under which the internals are available
|
|
///
|
|
/// Default: `http://talon.localhost:3000`
|
|
pub internal_url: String,
|
|
/// Interval in minutes between file storage purges
|
|
///
|
|
/// Default: 1440 (24h)
|
|
pub purge_interval: u32,
|
|
}
|
|
|
|
impl Default for ServerCfg {
|
|
fn default() -> Self {
|
|
Self {
|
|
address: "0.0.0.0:3000".to_owned(),
|
|
root_domain: "localhost:3000".to_owned(),
|
|
internal_subdomain: "talon".to_owned(),
|
|
internal_url: "http://talon.localhost:3000".to_owned(),
|
|
purge_interval: 1440,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CompressionCfg {
|
|
/// Enable gzip compression
|
|
pub gzip_en: bool,
|
|
/// Gzip compression level (0-9)
|
|
pub gzip_level: u8,
|
|
/// Enable brotli compression
|
|
pub brotli_en: bool,
|
|
/// Brozli compression level (0-11)
|
|
pub brotli_level: u8,
|
|
}
|
|
|
|
impl Default for CompressionCfg {
|
|
fn default() -> Self {
|
|
Self {
|
|
gzip_en: true,
|
|
gzip_level: 6,
|
|
brotli_en: true,
|
|
brotli_level: 7,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CompressionCfg {
|
|
pub fn enabled(&self) -> bool {
|
|
self.gzip_en || self.brotli_en
|
|
}
|
|
|
|
pub fn algs(&self) -> Vec<CompressionAlg> {
|
|
let mut res = vec![CompressionAlg::None];
|
|
if self.gzip_en {
|
|
res.push(CompressionAlg::Gzip)
|
|
}
|
|
if self.brotli_en {
|
|
res.push(CompressionAlg::Brotli)
|
|
}
|
|
res
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct KeyCfg {
|
|
#[serde(skip_serializing_if = "Domains::is_none")]
|
|
pub domains: Domains,
|
|
pub upload: bool,
|
|
pub modify: bool,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum Domains {
|
|
#[default]
|
|
None,
|
|
Single(String),
|
|
Multiple(Vec<String>),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Access {
|
|
/// Dont modify anything
|
|
Read,
|
|
/// Update a new website version
|
|
Upload,
|
|
/// Create, update or delete websites
|
|
Modify,
|
|
}
|
|
|
|
impl Domains {
|
|
fn is_none(&self) -> bool {
|
|
matches!(self, Domains::None)
|
|
}
|
|
|
|
fn pattern_matches_domain(pattern: &str, domain: &str) -> bool {
|
|
if pattern == "*" {
|
|
true
|
|
} else if pattern.starts_with('/') && pattern.ends_with('/') {
|
|
let regex_str = &pattern[1..pattern.len() - 1];
|
|
let re = match Regex::new(regex_str) {
|
|
Ok(re) => re,
|
|
Err(e) => {
|
|
log::error!("could not parse regex `{regex_str}`, error: {e}");
|
|
return false;
|
|
}
|
|
};
|
|
re.is_match(domain)
|
|
} else {
|
|
domain == pattern
|
|
}
|
|
}
|
|
|
|
pub fn matches_domain(&self, domain: &str) -> bool {
|
|
match self {
|
|
Domains::None => false,
|
|
Domains::Single(pattern) => Self::pattern_matches_domain(pattern, domain),
|
|
Domains::Multiple(patterns) => patterns
|
|
.iter()
|
|
.any(|pattern| Self::pattern_matches_domain(pattern, domain)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl KeyCfg {
|
|
pub fn allows(&self, access: Access) -> bool {
|
|
match access {
|
|
Access::Read => true,
|
|
Access::Upload => self.upload,
|
|
Access::Modify => self.modify,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use rstest::rstest;
|
|
|
|
#[rstest]
|
|
#[case("*", "hello-world", true)]
|
|
#[case("hello-world", "hello-world", true)]
|
|
#[case("hello-world", "hello-world2", false)]
|
|
#[case("/^talon-\\d+/", "talon-1", true)]
|
|
#[case("/^talon-\\d+/", "talon-x", false)]
|
|
fn pattern_matches_domain(#[case] pattern: &str, #[case] domain: &str, #[case] expect: bool) {
|
|
assert_eq!(Domains::pattern_matches_domain(pattern, domain), expect);
|
|
}
|
|
}
|