Implement SPA paths and redirects
All checks were successful
Build / build (push) Successful in 1m49s

This commit is contained in:
Moritz Ruth 2025-03-02 16:02:53 +01:00
parent 000598d95b
commit 175519f0cb
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
2 changed files with 149 additions and 15 deletions

View file

@ -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
}
]
}

View file

@ -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<SiteConfigRedirect>,
}
#[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<SiteStateVersion>,
@ -21,13 +56,13 @@ pub struct SiteState {
impl SiteState {
async fn read_from_site_directory(site_directory_path: &Utf8Path) -> Result<Self> {
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::<SiteState>(&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<Sites> {
Ok(RwLock::new(sites))
}
async fn read_site_config(content_path: &Utf8Path) -> Result<Option<SiteConfig>> {
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::<SiteConfig>(&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<ureq::Agent>, 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<ureq::Agent>, 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<ureq::Agent>, 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 {