aboutsummaryrefslogtreecommitdiff
path: root/crates/chat-server/src/main.rs
blob: 6c7f4e10ebca7cfbb40cc4e64000b26a9c3a0d1f (plain)
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
        }
    }
}