From 175519f0cba432d55c21e1e69d8490750f9af220 Mon Sep 17 00:00:00 2001 From: Moritz Ruth Date: Sun, 2 Mar 2025 16:02:53 +0100 Subject: [PATCH] Implement SPA paths and redirects --- src/caddy.rs | 90 +++++++++++++++++++++++++++++++++++++++++++++++----- src/sites.rs | 74 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 149 insertions(+), 15 deletions(-) diff --git a/src/caddy.rs b/src/caddy.rs index eacb2b5..13a5187 100644 --- a/src/caddy.rs +++ b/src/caddy.rs @@ -1,3 +1,4 @@ +use crate::sites::{SITE_VERSION_CONFIG_FILE_NAME, SiteConfig, SiteConfigPathsMode, SiteConfigRedirectKind}; use async_std::io::WriteExt; use async_std::os::unix::net::UnixStream; use async_std::process::Command; @@ -18,7 +19,85 @@ pub struct CaddyController { } impl CaddyController { - pub async fn upsert_site_configuration(&mut self, domain: &String, content_path: &Utf8Path) -> Result<()> { + pub async fn upsert_site_configuration(&mut self, domain: &String, content_path: &Utf8Path, config: &SiteConfig) -> Result<()> { + let mut routes_array = vec![]; + + routes_array.push(json!({ + "handle": [{ "handler": "vars", "root": content_path.to_string() }] + })); + + for redirect in &config.redirects { + let handler = match redirect.kind { + SiteConfigRedirectKind::Temporary => json!({ + "handler": "static_response", + "headers": { + "Location": [redirect.to] + }, + "status_code": 307 + }), + SiteConfigRedirectKind::Permanent => json!({ + "handler": "static_response", + "headers": { + "Location": [redirect.to] + }, + "status_code": 308 + }), + SiteConfigRedirectKind::Rewrite => json!({ + "handler": "rewrite", + "uri": redirect.to + }) + }; + + routes_array.push(json!({ + "match": [{ "path": [redirect.from] }], + "handle": [handler] + })) + } + + if config.paths_mode == SiteConfigPathsMode::Spa { + // Redirect to URL without trailing slashes and "/index.html" + routes_array.push(json!({ + "match": [{ "path": ["*/index.html", "*/"], "not": [{ "path": ["/"] }] }], + "handle": [ + { + "handler": "rewrite", + "strip_path_suffix": "/index.html" + }, + { + "handler": "rewrite", + "strip_path_suffix": "/" + }, + { + "handler": "static_response", + "headers": { + "Location": ["{http.request.uri}"] + }, + "status_code": 308 + } + ] + })); + + routes_array.push(json!({ + "match": [{ "file": { "try_files": ["{http.request.uri.path}", "{http.request.uri.path}/index.html", "/index.html"]} }], + "handle": [{ "handler": "rewrite", "uri": "{http.matchers.file.relative}" }] + })); + } + + routes_array.push(json!({ + "handle": [{ + "handler": "file_server", + "index_names": if config.paths_mode == SiteConfigPathsMode::Normal { json!(["index.html"]) } else { json!([]) }, + "precompressed": { + "br": {}, + "gzip": {} + }, + "hide": [ + content_path.join(SITE_VERSION_CONFIG_FILE_NAME).to_string() + ], + "precompressed_order": ["br", "gzip"] + }] + })); + let configuration_object = json!({ "@id": domain, "match": [{ "host": [domain] }], @@ -42,14 +121,9 @@ impl CaddyController { { "group": "1", "handle": [ - { "handler": "vars", "root": content_path.to_string() }, { - "handler": "file_server", - "precompressed": { - "br": {}, - "gzip": {} - }, - "precompressed_order": ["br", "gzip"] + "handler": "subroute", + "routes": routes_array } ] } diff --git a/src/sites.rs b/src/sites.rs index 448d43b..e8ce75c 100644 --- a/src/sites.rs +++ b/src/sites.rs @@ -7,12 +7,47 @@ use async_std::{fs, task}; use camino::{Utf8Path, Utf8PathBuf}; use color_eyre::Result; use color_eyre::eyre::{WrapErr, eyre}; -use log::{debug, info}; +use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::io::ErrorKind; use std::sync::Arc; use std::time::Duration; +pub const SITE_STATE_FILE_NAME: &str = "state.toml"; +pub const SITE_VERSION_CONFIG_FILE_NAME: &str = "site.toml"; + +#[derive(Deserialize, Default)] +pub struct SiteConfig { + #[serde(default)] + pub paths_mode: SiteConfigPathsMode, + #[serde(default)] + pub redirects: Vec, +} + +#[derive(Deserialize, Default, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SiteConfigPathsMode { + #[default] + Normal, + Spa, +} + +#[derive(Deserialize)] +pub struct SiteConfigRedirect { + pub from: String, + pub to: String, + pub kind: SiteConfigRedirectKind, +} + +#[derive(Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SiteConfigRedirectKind { + Temporary, + Permanent, + Rewrite, +} + #[derive(Serialize, Deserialize)] pub struct SiteState { active_version: Option, @@ -21,13 +56,13 @@ pub struct SiteState { impl SiteState { async fn read_from_site_directory(site_directory_path: &Utf8Path) -> Result { - let string = fs::read_to_string(site_directory_path.join("site.toml").into_std_path_buf()).await?; + let string = fs::read_to_string(site_directory_path.join(SITE_STATE_FILE_NAME).into_std_path_buf()).await?; Ok(toml::from_str::(&string)?) } async fn write_to_site_directory(&self, site_directory_path: &Utf8Path) -> Result<()> { fs::create_dir_all(site_directory_path.as_std_path()).await?; - let mut file = fs::File::create(site_directory_path.join("site.toml").into_std_path_buf()).await?; + let mut file = fs::File::create(site_directory_path.join(SITE_STATE_FILE_NAME).into_std_path_buf()).await?; file.write_all(toml::to_string(&self).unwrap().as_bytes()).await?; Ok(()) } @@ -125,6 +160,15 @@ pub async fn load_sites(sites_directory_path: &Utf8Path) -> Result { Ok(RwLock::new(sites)) } +async fn read_site_config(content_path: &Utf8Path) -> Result> { + let path = content_path.join(SITE_VERSION_CONFIG_FILE_NAME).into_std_path_buf(); + match fs::read_to_string(path).await { + Ok(config_string) => Ok(toml::from_str::(&config_string).ok()), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(Some(SiteConfig::default())), + Err(e) => Err(e.into()), + } +} + async fn handle_download(ureq_agent: Arc, sites: &Sites, caddy_controller: &mut CaddyController, domain: String) -> Result<()> { let site = { let sites = sites.read().await; @@ -200,6 +244,17 @@ async fn handle_download(ureq_agent: Arc, sites: &Sites, caddy_cont }) .await?; + // Load configuration + let site_config = if let Some(c) = read_site_config(&extraction_directory_path).await? { + c + } else { + error!( + "The configuration file of {domain} ({}) is invalid. The new version will be ignored.", + current_version.id + ); + return Ok(()); + }; + // Update and write state let current_version = current_version.clone(); let old_active_version = state.active_version.replace(current_version); @@ -207,7 +262,9 @@ async fn handle_download(ureq_agent: Arc, sites: &Sites, caddy_cont // Update Caddy configuration info!("Download for {domain} successful, now updating proxy configuration…"); - caddy_controller.upsert_site_configuration(&domain, &extraction_directory_path).await?; + caddy_controller + .upsert_site_configuration(&domain, &extraction_directory_path, &site_config) + .await?; // Cleanup fs::remove_file(archive_file_path.as_std_path()).await?; @@ -253,9 +310,12 @@ pub async fn start_sites_worker(sites_directory_path: Utf8PathBuf, mut caddy_con if let Some(active_version) = &state.active_version { is_outdated = current_version.id != active_version.id; - caddy_controller - .upsert_site_configuration(&site.domain, &site.path.join(&active_version.id)) - .await?; + let content_path = &site.path.join(&active_version.id); + if let Some(site_config) = read_site_config(&content_path).await? { + caddy_controller.upsert_site_configuration(&site.domain, content_path, &site_config).await?; + } else { + error!("The configuration file of {} ({}) is invalid.", site.domain, current_version.id); + }; } if is_outdated {