summaryrefslogtreecommitdiff
path: root/crates/chat-server/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/src/main.rs')
-rw-r--r--crates/chat-server/src/main.rs129
1 files changed, 129 insertions, 0 deletions
diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs
new file mode 100644
index 0000000..6c7f4e1
--- /dev/null
+++ b/crates/chat-server/src/main.rs
@@ -0,0 +1,129 @@
+// 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/>.
+
+#![doc = "Local experimental chat server with ciphertext-only durable storage."]
+
+mod http;
+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)]
+ init: bool,
+ #[arg(long)]
+ snapshot: Option<PathBuf>,
+}
+
+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 store = store::Store::open(&database)?;
+ std::fs::set_permissions(
+ &database,
+ std::os::unix::fs::PermissionsExt::from_mode(0o600),
+ )?;
+ 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::<std::result::Result<std::collections::HashMap<_, _>, _>>()?;
+ 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
+ }
+ }
+}