// 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 . #![doc = "Local experimental chat server with ciphertext-only durable storage."] mod http; mod maintenance; mod service; mod store; use anyhow::{Context, Result, ensure}; use clap::Parser; use std::io::Write; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; #[derive(Debug, Parser)] #[command(about = "Run the experimental private chat locally; production is not enabled")] struct Arguments { #[arg(long, default_value = ".")] root: PathBuf, #[arg(long, default_value = "127.0.0.1:9080")] bind: SocketAddr, #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint", "snapshot"])] init: bool, #[arg(long, value_name = "FINGERPRINT", conflicts_with_all = ["owner_fingerprint", "snapshot"], help = "Permanently clear the matching owner registration and all chat history after key loss")] reset_owner: Option, #[arg( long, conflicts_with = "snapshot", help = "Print the registered owner's public fingerprint" )] owner_fingerprint: bool, #[arg(long)] snapshot: Option, } fn initialize(root: &std::path::Path) -> Result<()> { let env = root.join(".env"); let existing = if env.exists() { std::fs::read_to_string(&env)? } else { String::new() }; ensure!( !existing .lines() .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), "CHAT_SETUP_TOKEN already exists" ); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&env)?; std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; if !existing .lines() .any(|line| line.starts_with("CHAT_ORIGIN=")) { writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; } Ok(()) } async fn run(arguments: Arguments) -> Result<()> { let root = arguments.root.canonicalize()?; if arguments.init { return initialize(&root); } let data = root.join("var/data/chat"); std::fs::create_dir_all(&data)?; std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; let database = data.join("chat.sqlite"); let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint { Some(maintenance::lock(&data)?) } else { None }; let mut store = store::Store::open(&database)?; std::fs::set_permissions( &database, std::os::unix::fs::PermissionsExt::from_mode(0o600), )?; if arguments.owner_fingerprint { return maintenance::fingerprint(&store); } if let Some(expected) = arguments.reset_owner { return maintenance::reset(&mut store, &expected, arguments.bind); } if let Some(destination) = arguments.snapshot { store.snapshot(&destination)?; std::fs::set_permissions( destination, std::os::unix::fs::PermissionsExt::from_mode(0o600), )?; return Ok(()); } ensure!( arguments.bind.ip().is_loopback(), "Experimental server must bind to loopback" ); let settings = dotenvy::from_path_iter(root.join(".env"))? .collect::, _>>()?; let origin = settings .get("CHAT_ORIGIN") .context("Run --init to configure chat")?; ensure!( origin == &format!("http://{}", arguments.bind), "This development server requires CHAT_ORIGIN to match its loopback bind address" ); let setup = settings .get("CHAT_SETUP_TOKEN") .context("Missing CHAT_SETUP_TOKEN")?; let state = service::Service::new(store, origin.clone(), setup.clone()); let listener = tokio::net::TcpListener::bind(arguments.bind).await?; writeln!( std::io::stdout().lock(), "Experimental chat: {origin} (owner setup: /#setup)" )?; http::serve(listener, state, &root).await } #[tokio::main] async fn main() -> ExitCode { match run(Arguments::parse()).await { Ok(()) => ExitCode::SUCCESS, Err(error) => { drop(writeln!( std::io::stderr().lock(), "Chat server failed: {error:#}" )); ExitCode::FAILURE } } }