// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . use crate::files; use crate::project::{Mode, Project, Settings}; use anyhow::Result; use stack_config::Config; use std::fs; use std::path::PathBuf; pub(crate) fn directory(project: &Project, settings: &Settings) -> PathBuf { project.root.join(match settings.mode { Mode::Local => "var/local", Mode::Production => "var/production", }) } pub(crate) fn prepare(project: &Project, settings: &Settings) -> Result<()> { let config = Config::load(&project.root)?; let local = (settings.mode == Mode::Local).then_some(settings.port); let output = directory(project, settings); let web = output.join("web"); if web.exists() { files::regular_tree(&web)?; fs::remove_dir_all(&web)?; } files::copy_tree(&project.root.join("web"), &web)?; let file = web.join("index.html"); files::write( &file, config .render(&fs::read_to_string(&file)?, local)? .as_bytes(), 0o644, )?; let template = project.root.join(if settings.mode == Mode::Local { "infra/Caddyfile.local" } else { "infra/Caddyfile" }); files::write( &output.join("Caddyfile"), config .render(&fs::read_to_string(template)?, local)? .as_bytes(), 0o644, )?; let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?; let mut rendered = config.render(&cgit, local)?; if let Some(port) = local { rendered = rendered.replace( "clone-prefix=/git", &format!("clone-prefix=http://localhost:{port}/git"), ); } files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644) } #[cfg(test)] mod tests { use super::*; #[test] fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> { let root = tempfile::tempdir()?; for name in ["web", "infra"] { files::copy_tree( &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name), &root.path().join(name), )?; } fs::write( root.path().join(".env"), "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n", )?; let project = Project::at(root.path())?; let mut settings = project.settings()?; settings.mode = Mode::Production; prepare(&project, &settings)?; let output = directory(&project, &settings); let caddy = fs::read_to_string(output.join("Caddyfile"))?; assert!(caddy.contains("https://home.example.net:8443")); assert!(caddy.contains("http_port 8181")); assert!(!caddy.contains("do-not-publish")); let homepage = fs::read_to_string(output.join("web/index.html"))?; assert!(homepage.contains("https://git.example.net:8443")); let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?; assert!(homepage.contains(&format!("/styles.css?v={stylesheet}"))); assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt")); settings.mode = Mode::Local; settings.port = 8180; prepare(&project, &settings)?; let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?; assert!(preview.contains("href=\"/git/\"")); assert!(preview.contains("http://chat.localhost:8180/LIMITATIONS.txt")); assert!( fs::read_to_string(directory(&project, &settings).join("cgitrc"))? .contains("clone-prefix=http://localhost:8180/git") ); Ok(()) } }