diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/archive.rs | 70 | ||||
| -rw-r--r-- | src/chat.rs | 163 | ||||
| -rw-r--r-- | src/files.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 107 | ||||
| -rw-r--r-- | src/project.rs | 138 | ||||
| -rw-r--r-- | src/release.rs | 10 | ||||
| -rw-r--r-- | src/render.rs | 116 |
7 files changed, 506 insertions, 99 deletions
diff --git a/src/archive.rs b/src/archive.rs index e7e6bc5..1e58992 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -24,9 +24,6 @@ use serde_json::Value; use crate::project::{Mode, Project, Settings, field}; use crate::{files, process}; -pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6"; -const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp"; - #[derive(Debug)] struct Paused<'a> { project: &'a Project, @@ -87,7 +84,8 @@ fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { ids: Vec::new(), }; for record in records { - if field(record, &["State", "Running"])?.as_bool() == Some(true) + if field(record, &["Config", "Labels", "io.stack.service"])?.as_str() != Some("chat") + && field(record, &["State", "Running"])?.as_bool() == Some(true) && field(record, &["State", "Paused"])?.as_bool() == Some(false) { let id = field(record, &["Id"])? @@ -103,6 +101,25 @@ fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { resume } +fn snapshot_chat(root: &Path, stage: &Path, name: &str) -> Result<()> { + let source = root.join("var/data").join(name).join("chat.sqlite"); + if !source.is_file() { + return Ok(()); + } + let directory = stage.join("var/data").join(name); + files::private_dir(&directory)?; + let database = + rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + database.busy_timeout(std::time::Duration::from_secs(5))?; + let destination = directory.join("chat.sqlite"); + database.backup("main", &destination, None::<fn(rusqlite::backup::Progress)>)?; + fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + Ok(()) +} + fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> { files::copy_source(&project.root, stage)?; files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?; @@ -112,13 +129,19 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< let data = project.root.join("var/data"); for item in fs::read_dir(&data)? { let entry = item?; - if entry.file_name() != "caddy" { + if !["caddy", "chat", "chat-local"] + .iter() + .any(|name| entry.file_name() == *name) + { files::copy_tree( &entry.path(), &stage.join("var/data").join(entry.file_name()), )?; } } + for name in ["chat", "chat-local"] { + snapshot_chat(&project.root, stage, name)?; + } let caddy = records .iter() .find(|record| { @@ -150,6 +173,7 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< .find(|path| path.is_file()) .context("Build a release locally before exporting so the backup includes a portable binary")?; fs::copy(executable, stage.join("bin/stack"))?; + crate::chat::copy_artifacts(project, stage)?; Ok(()) } @@ -172,7 +196,11 @@ pub(crate) fn backup( .tempdir_in(destination)?; let home = temp.path().join("gnupg"); files::private_dir(&home)?; - let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf); + let configuration = stack_config::Config::load(&project.root)?; + let key = public_key.map_or_else( + || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)), + |path| Ok::<_, anyhow::Error>(path.to_path_buf()), + )?; let operation = backup_inner( project, destination, @@ -289,6 +317,10 @@ fn backup_inner( !output.exists(), "An export with this timestamp already exists" ); + fs::set_permissions( + &encrypted, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; fs::rename(&encrypted, &output)?; let digest = format!("{} {name}\n", files::hash_file(&output)?); files::write( @@ -365,6 +397,32 @@ mod tests { use crate::project::{Mode, Project}; #[test] + fn chat_snapshot_is_consistent_and_private() -> Result<()> { + let workspace = tempfile::tempdir()?; + let root = workspace.path().join("source"); + let stage = workspace.path().join("snapshot"); + files::private_dir(&root.join("var/data/chat"))?; + let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?; + connection.execute_batch( + "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');", + )?; + let transaction = connection.unchecked_transaction()?; + transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; + super::snapshot_chat(&root, &stage, "chat")?; + let snapshot = stage.join("var/data/chat/chat.sqlite"); + let restored = rusqlite::Connection::open(&snapshot)?; + let count: i64 = + restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; + assert_eq!(count, 1); + assert_eq!( + std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) & 0o777, + 0o600 + ); + transaction.rollback()?; + Ok(()) + } + + #[test] fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> { let workspace = tempfile::tempdir()?; let source = workspace.path().join("source"); diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..be852d5 --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,163 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// 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 <https://www.gnu.org/licenses/>. + +use crate::project::{Mode, Project, Settings}; +use crate::{files, process}; +use anyhow::{Context, Result, ensure}; +use stack_config::Config; +use std::fs; +use std::net::IpAddr; +use std::path::Path; +use std::process::Command; + +pub(crate) fn web_bind(configuration: &Config) -> Result<String> { + let address: IpAddr = configuration.value("WEB_BIND")?.parse()?; + Ok(match address { + IpAddr::V4(_) => address.to_string(), + IpAddr::V6(_) => format!("[{address}]"), + }) +} + +pub(crate) fn environment( + command: &mut Command, + configuration: &Config, + settings: &Settings, +) -> Result<()> { + let local = (settings.mode == Mode::Local).then_some(settings.port); + let mut public = configuration.public_values(local)?; + let origin = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing chat gateway origin")? + .clone(); + public.insert("CHAT_ORIGIN".into(), origin); + public.insert("CHAT_BIND".into(), "0.0.0.0:8080".into()); + for (key, value) in public { + command.arg("--env").arg(&key).env(key, value); + } + Ok(()) +} + +pub(crate) fn build(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?; + let wasm_build = if project + .root + .join(".tools/openssl-wasi/lib/libcrypto.a") + .is_file() + { + "infra/chat/build-browser.sh" + } else { + "infra/chat/build-openssl-wasi.sh" + }; + process::run(project.command("sh")?.arg(wasm_build))?; + process::run(project.command("sh")?.arg("infra/chat/build-source.sh"))?; + package(project) +} + +pub(crate) fn package(project: &Project) -> Result<()> { + let directory = project.root.join("bin/chat"); + fs::create_dir_all(&directory)?; + for (source, target) in [ + ( + "target/x86_64-unknown-linux-musl/release/stack-chat-server", + "server", + ), + ( + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + "crypto.wasm", + ), + ("var/build/chat-source.tar.gz", "SOURCE.tar.gz"), + ("infra/containers/chat/Containerfile", "Containerfile"), + ("crates/chat-crypto/browser/wasi.mjs", "wasi.mjs"), + ("web/THIRD-PARTY-LICENSES.html", "THIRD-PARTY-LICENSES.html"), + ("docs/CHAT-APPLICATION.txt", "CHAT-APPLICATION.txt"), + ("docs/CHAT-PRODUCTION.txt", "CHAT-PRODUCTION.txt"), + ("LICENSE", "LICENSE"), + ] { + files::copy_tree(&project.root.join(source), &directory.join(target))?; + } + files::copy_tree( + &project.root.join("crates/chat-server/web"), + &directory.join("web"), + )?; + Ok(()) +} + +pub(crate) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { + let artifacts = project.root.join("bin/chat"); + ensure!( + artifacts.join("server").is_file() + && artifacts.join("crypto.wasm").is_file() + && artifacts.join("SOURCE.tar.gz").is_file(), + "Build a complete chat release locally before export" + ); + files::copy_tree(&artifacts, &destination.join("bin/chat")) +} + +pub(crate) fn admin( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + let settings = project.settings()?; + if init { + project.initialize(&settings)?; + return process::line(format_args!( + "Owner setup token is available in the single root .env" + )); + } + ensure!( + reset_owner.is_some() || owner_fingerprint, + "Use up --production to run chat; maintenance options are --owner-fingerprint and --reset-owner" + ); + let configuration = Config::load(&project.root)?; + let services = project.configuration(&settings)?; + let service = services + .services + .get("chat") + .context("Chat is not packaged in this release")?; + let mut command = project.command("podman")?; + command + .args([ + "run", + "--rm", + "--network", + "none", + "--read-only", + "--read-only-tmpfs=false", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--env", + "CHAT_SETUP_TOKEN", + ]) + .env("CHAT_SETUP_TOKEN", configuration.value("CHAT_SETUP_TOKEN")?); + environment(&mut command, &configuration, &settings)?; + command + .arg("--volume") + .arg(format!( + "{}:/app/var/data/chat:rw,z", + project.root.join("var/data/chat").display() + )) + .arg(&service.image); + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} diff --git a/src/files.rs b/src/files.rs index b939311..fa441f0 100644 --- a/src/files.rs +++ b/src/files.rs @@ -33,6 +33,7 @@ pub(crate) const SOURCE: &[&str] = &[ "rust-toolchain.toml", ".cargo", ".gitignore", + ".gitattributes", ".forgejo", "src", "tests", diff --git a/src/main.rs b/src/main.rs index ec8784e..5ee1f9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,11 +16,13 @@ #![doc = "Rust commands for personal web infrastructure."] pub(crate) mod archive; +pub(crate) mod chat; pub(crate) mod files; pub(crate) mod images; pub(crate) mod process; pub(crate) mod project; pub(crate) mod release; +pub(crate) mod render; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -49,10 +51,24 @@ struct Cli { #[derive(Debug, Subcommand)] enum Action { + #[command(about = "Run experimental local chat or administer its packaged deployment")] + Chat { + #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])] + init: bool, + #[arg( + long, + value_name = "FINGERPRINT", + conflicts_with = "owner_fingerprint", + help = "Permanently clear owner registration and chat history after key loss" + )] + reset_owner: Option<String>, + #[arg(long, help = "Print the registered owner's public fingerprint")] + owner_fingerprint: bool, + }, #[command(about = "Start the local development services")] Dev { - #[arg(long, default_value_t = 8080)] - port: u16, + #[arg(long)] + port: Option<u16>, }, #[command(about = "Start or update services in the selected mode")] Up { @@ -65,6 +81,15 @@ enum Action { #[arg(long)] online: bool, }, + #[command( + about = "Render public Caddy, website, and cgit configuration without starting services" + )] + Config { + #[arg(long)] + production: bool, + #[arg(long)] + port: Option<u16>, + }, #[command(about = "Show service status")] Status, #[command(about = "Stop services while retaining data")] @@ -74,8 +99,8 @@ enum Action { destination: Option<PathBuf>, #[arg(long)] include_images: bool, - #[arg(long, default_value = archive::RECIPIENT)] - recipient: String, + #[arg(long)] + recipient: Option<String>, #[arg(long)] key: Option<PathBuf>, }, @@ -127,12 +152,23 @@ fn execute(arguments: Cli) -> Result<()> { return archive::restore(&absolute(archive)?, &absolute(destination)?); } let project = Project::discover(arguments.root.as_deref())?; + if let Action::Chat { + init, + reset_owner, + owner_fingerprint, + } = arguments.command + { + return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); + } + let _operation = project.lock()?; match arguments.command { Action::Dev { port } => { let mut settings = project.settings()?; settings.mode = Mode::Local; - settings.port = port; + if let Some(selected_port) = port { + settings.port = selected_port; + } images::prepare(&project, &mut settings)?; project.up(&settings) } @@ -158,6 +194,23 @@ fn execute(arguments: Cli) -> Result<()> { images::prepare(&project, &mut settings)?; project.up(&settings) } + Action::Config { production, port } => { + let mut settings = project.settings()?; + settings.mode = if production { + Mode::Production + } else { + Mode::Local + }; + if let Some(selected_port) = port { + settings.port = selected_port; + } + stack_config::initialize_public(&project.root)?; + render::prepare(&project, &settings)?; + process::line(format_args!( + "Public configuration: {}", + render::directory(&project, &settings).display() + )) + } Action::Status => project.status(&project.settings()?), Action::Down => project.down(&project.settings()?), Action::Backup { @@ -170,11 +223,14 @@ fn execute(arguments: Cli) -> Result<()> { || Ok(project.root.join("var/exports")), |path| absolute(&path), )?; + let configuration = stack_config::Config::load(&project.root)?; + let selected_recipient = + recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); archive::backup( &project, &output, include_images, - &recipient, + &selected_recipient, key.as_deref(), ) } @@ -186,10 +242,36 @@ fn execute(arguments: Cli) -> Result<()> { Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), Action::Check => check(&project), Action::Licenses => licenses(&project), + Action::Chat { .. } => anyhow::bail!("Chat action was not handled"), Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), } } +fn chat( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + if project.settings()?.mode == Mode::Production { + return chat::admin(project, init, reset_owner, owner_fingerprint); + } + let mut command = project.command("cargo")?; + command + .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) + .arg(&project.root); + if init { + command.arg("--init"); + } + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} + fn absolute(path: &Path) -> Result<PathBuf> { Ok(if path.is_absolute() { path.to_path_buf() @@ -202,6 +284,8 @@ fn licenses(project: &Project) -> Result<()> { process::run(project.command("cargo")?.args([ "about", "generate", + "--workspace", + "--locked", "about.hbs", "--output-file", "web/THIRD-PARTY-LICENSES.html", @@ -243,10 +327,11 @@ fn check(project: &Project) -> Result<()> { "--document-private-items", ]), )?; - process::run( - project - .command("cargo")? - .args(["deny", "--all-features", "check"]), - )?; + process::run(project.command("cargo")?.args([ + "deny", + "--workspace", + "--all-features", + "check", + ]))?; licenses(project) } diff --git a/src/project.rs b/src/project.rs index 0dac2ef..57185b1 100644 --- a/src/project.rs +++ b/src/project.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::env; use std::fs::{self, File, OpenOptions, Permissions}; -use std::io::Read; +use std::io::{Read, Write}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use std::process::Command; @@ -25,6 +25,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail, ensure}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use stack_config::Config; use crate::{files, process}; @@ -64,6 +65,8 @@ pub(crate) struct Service { environment: Vec<String>, #[serde(default)] drop_capabilities: bool, + #[serde(default)] + read_only: bool, health: Vec<String>, mounts: Vec<Mount>, #[serde(default)] @@ -116,11 +119,16 @@ impl Project { pub(crate) fn settings(&self) -> Result<Settings> { let path = self.root.join("var/state.json"); if path.exists() { - return Ok(serde_json::from_reader(File::open(path)?)?); + let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; + let config = Config::load(&self.root)?; + if config.is_explicit("LOCAL_HTTP_PORT") { + settings.port = config.port("LOCAL_HTTP_PORT")?; + } + return Ok(settings); } Ok(Settings { mode: Mode::Local, - port: 8080, + port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?, project: format!( "stack-{}", files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) @@ -230,17 +238,23 @@ impl Project { pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> { files::private_dir(&self.root.join("var/data"))?; let env_file = self.root.join(".env"); - if !env_file.exists() { - let mut random = [0; 32]; - File::open("/dev/urandom")?.read_exact(&mut random)?; - let secret = hex::encode(random); - files::write( - &env_file, - format!("SEARXNG_SECRET={secret}\n").as_bytes(), - 0o600, - )?; + for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] { + if !Config::load(&self.root)?.is_explicit(key) { + let mut random = [0; 32]; + File::open("/dev/urandom")?.read_exact(&mut random)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&env_file)?; + writeln!(file, "\n{key}={}", hex::encode(random))?; + } } + stack_config::initialize_public(&self.root)?; fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; + for name in ["chat", "chat-local"] { + files::private_dir(&self.root.join("var/data").join(name))?; + } for name in ["repos", "caddy/data", "caddy/config", "searxng"] { fs::create_dir_all(self.root.join("var/data").join(name))?; } @@ -268,42 +282,7 @@ impl Project { &self.root.join("infra/searxng"), &self.root.join("var/data/searxng"), )?; - if settings.mode == Mode::Local { - self.local_site(settings.port)?; - } - Ok(()) - } - - fn local_site(&self, port: u16) -> Result<()> { - let generated = self.root.join("var/local/web"); - if generated.exists() { - files::regular_tree(&generated)?; - fs::remove_dir_all(&generated)?; - } - files::copy_tree(&self.root.join("web"), &generated)?; - let index = generated.join("index.html"); - let html = fs::read_to_string(&index)? - .replace("https://git.doan.luuduct.ai", "/git") - .replace("https://search.doan.luuduct.ai", "/search") - .replace("https://openpgpkey.luuduct.ai", ""); - files::write(&index, html.as_bytes(), 0o644)?; - let template = self.root.join("infra/containers/cgit/cgitrc.local"); - let content = fs::read_to_string(&template)? - .lines() - .map(|line| { - if line.starts_with("clone-prefix=") { - format!("clone-prefix=http://localhost:{port}/git") - } else { - line.to_owned() - } - }) - .collect::<Vec<_>>() - .join("\n"); - files::write( - &self.root.join("var/local/cgitrc"), - format!("{content}\n").as_bytes(), - 0o644, - ) + crate::render::prepare(self, settings) } pub(crate) fn container_name(settings: &Settings, service: &str) -> String { @@ -343,9 +322,7 @@ impl Project { .args(["network", "create", &network]), )?; } - let environment: BTreeMap<String, String> = - dotenvy::from_path_iter(self.root.join(".env"))? - .collect::<std::result::Result<_, _>>()?; + let environment = Config::load(&self.root)?; for (name, service) in config .services .iter() @@ -364,7 +341,11 @@ impl Project { process::line(format_args!( "Running {}", if settings.mode == Mode::Production { - "https://doan.luuduct.ai".to_owned() + environment + .public_values(None)? + .get("SITE_ORIGIN") + .context("Missing public site origin")? + .to_owned() } else { format!("http://localhost:{}", settings.port) } @@ -376,6 +357,9 @@ impl Project { && (settings.online || !self.root.join("var/image-lock.json").exists()) && let Some(context) = &service.build { + if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() { + crate::chat::build(self)?; + } return process::run( self.command("podman")? .args(["build", "--tag", &service.image]) @@ -401,7 +385,7 @@ impl Project { network: &str, name: &str, service: &Service, - environment: &BTreeMap<String, String>, + environment: &Config, ) -> Result<()> { let container = Self::container_name(settings, name); let mut command = self.command("podman")?; @@ -426,6 +410,9 @@ impl Project { "--security-opt", "no-new-privileges", ]); + if service.read_only { + command.args(["--read-only", "--read-only-tmpfs=false"]); + } if service.drop_capabilities { command.args(["--cap-drop", "ALL"]); } @@ -439,38 +426,39 @@ impl Project { ] .to_vec() } else { - ["[::]:80:80", "[::]:443:443", "[::]:443:443/udp"] - .map(str::to_owned) - .to_vec() + let http = environment.port("HTTP_PORT")?; + let https = environment.port("HTTPS_PORT")?; + vec![ + format!("{}:{http}:{http}", crate::chat::web_bind(environment)?), + format!("{}:{https}:{https}", crate::chat::web_bind(environment)?), + format!( + "{}:{https}:{https}/udp", + crate::chat::web_bind(environment)? + ), + ] }; for port in ports { command.args(["--publish", &port]); } } for key in &service.environment { - command.arg("--env").arg(key).env( - key, - environment - .get(key) - .with_context(|| format!("Missing secret {key} in .env"))?, - ); + command + .arg("--env") + .arg(key) + .env(key, environment.value(key)?); + } + if name == "chat" { + crate::chat::environment(&mut command, environment, settings)?; } for mount in &service.mounts { let source = match mount.source.as_str() { - "@caddy" => self.root.join(if settings.mode == Mode::Local { - "infra/Caddyfile.local" - } else { - "infra/Caddyfile" - }), - "@web" => self.root.join(if settings.mode == Mode::Local { - "var/local/web" - } else { - "web" - }), - "@cgit" => self.root.join(if settings.mode == Mode::Local { - "var/local/cgitrc" + "@caddy" => crate::render::directory(self, settings).join("Caddyfile"), + "@web" => crate::render::directory(self, settings).join("web"), + "@cgit" => crate::render::directory(self, settings).join("cgitrc"), + "@chat" => self.root.join(if settings.mode == Mode::Local { + "var/data/chat-local" } else { - "infra/containers/cgit/cgitrc" + "var/data/chat" }), other => self.path(other)?, }; diff --git a/src/release.rs b/src/release.rs index 88b4093..92f2df4 100644 --- a/src/release.rs +++ b/src/release.rs @@ -23,13 +23,7 @@ use crate::{files, images, process}; pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { crate::check(project)?; - process::run(project.command("cargo")?.args([ - "build", - "--locked", - "--release", - "--target", - "x86_64-unknown-linux-musl", - ]))?; + crate::chat::build(project)?; files::write( &project.root.join("bin/stack"), &fs::read( @@ -52,6 +46,7 @@ pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { let stage = temp.path().join("stack"); files::private_dir(&stage)?; files::copy_source(&project.root, &stage)?; + crate::chat::copy_artifacts(project, &stage)?; fs::create_dir_all(stage.join("bin"))?; fs::copy( project @@ -120,6 +115,7 @@ pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> { &fs::read(stage.join("bin/stack"))?, 0o755, )?; + crate::chat::copy_artifacts(&pending, &project.root)?; for name in ["images.tar", "image-lock.json"] { files::copy_tree( &stage.join("var").join(name), diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..dea7e28 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,116 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// 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 <https://www.gnu.org/licenses/>. + +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)?; + for name in ["index.html", "message/index.html"] { + let file = web.join(name); + 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(()) + } +} |
