summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/archive.rs70
-rw-r--r--src/chat.rs163
-rw-r--r--src/files.rs1
-rw-r--r--src/main.rs107
-rw-r--r--src/project.rs138
-rw-r--r--src/release.rs10
-rw-r--r--src/render.rs116
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(())
+ }
+}