Talon/src/config.rs
ThetaDev 36b80bbbd9
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: purge file storage daily
2023-04-02 17:36:01 +02:00

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);
}
}