1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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
}
}
}
|