AnyBot/relay/src/main.rs

773 lines
41 KiB
Rust
Raw Normal View History

// AnyBot 帳號伺服器 (the thin backend).
//
// What it keeps: accounts (name + PBKDF2 password hash), signed-in phones (session tokens, stored
// hashed), the user's AnyBots ("hosts", tokens stored hashed), and one encrypted backup per account
// (sealed on the user's computer with a key made from the password; this server can't open it).
//
// What it does: each AnyBot keeps asking `GET /relay/host/next` (an outgoing connection, so the
// computer needs no open port). A phone signed in to the account calls `/h/<host>/api/...`; the
// request is handed to that AnyBot and its answer comes back. AnyBots of the same account can
// also reach each other (裝置連結) through `/relay/hosts/<host>/api/device/run`.
//
// Run: anybot-relay [--port 8080] [--data ./relay-data] (put HTTPS in front, e.g. Caddy)
// Sign-ups: the first account is free; after that ANYBOT_RELAY_INVITE must be set and given.
#[path = "../../src/httpd.rs"]
mod httpd;
mod push;
use httpd::{Req, Resp};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::mpsc::{channel, Sender};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
const PAGE: &str = include_str!("../../mobile/index.html");
const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"}]}"##;
const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="#161616"/><circle cx="48" cy="48" r="24" fill="none" stroke="#f5f5f5" stroke-width="7"/><circle cx="48" cy="48" r="7" fill="#f5f5f5"/></svg>"##;
/// How long a host's "next request?" waits before answering "nothing".
const HOST_POLL_SECS: u64 = 25;
/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s).
const FORWARD_SECS: u64 = 35;
/// A host counts as online when it asked within this many seconds.
const ONLINE_SECS: u64 = 60;
/// Sessions last this long without use.
const SESSION_DAYS: i64 = 90;
const MAX_FAILS: u32 = 8;
#[derive(Clone, Serialize, Deserialize, Default)]
struct User {
name: String,
pass: String,
created: i64,
}
#[derive(Clone, Serialize, Deserialize, Default)]
struct Session {
hash: String,
user: String,
device: String,
created: i64,
last: i64,
}
#[derive(Clone, Serialize, Deserialize, Default)]
struct Host {
id: String,
name: String,
user: String,
token_hash: String,
created: i64,
#[serde(default)]
last_seen: i64,
/// 團隊: other accounts this AnyBot is shared with, and what they may do.
#[serde(default)]
members: Vec<Member>,
}
#[derive(Clone, Serialize, Deserialize, Default)]
struct Member {
user: String,
/// "admin": everything the owner can do on the phone (approve, screens);
/// "member": chat with the bots and attach files only.
role: String,
}
impl Host {
/// The account's role on this AnyBot (None = no access).
fn role_of(&self, user: &str) -> Option<String> {
if self.user == user {
return Some("owner".into());
}
self.members.iter().find(|m| m.user == user).map(|m| m.role.clone())
}
}
#[derive(Clone, Serialize, Deserialize, Default)]
struct Sub {
user: String,
sub: Value,
created: i64,
}
#[derive(Serialize, Deserialize, Default)]
struct Data {
users: Vec<User>,
sessions: Vec<Session>,
hosts: Vec<Host>,
/// Phones that turned notifications on.
#[serde(default)]
subs: Vec<Sub>,
}
const SW: &str = r#"self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
self.addEventListener("push", (e) => {
let d = {};
try { d = e.data ? e.data.json() : {}; } catch (_) { d = {body: e.data ? e.data.text() : ""}; }
e.waitUntil(self.registration.showNotification(d.title || "AnyBot", {body: d.body || "", tag: "bot-" + (d.bot || 0), renotify: true, data: d, icon: "icon.svg", badge: "icon.svg"}));
});
self.addEventListener("notificationclick", (e) => {
e.notification.close();
const d = e.notification.data || {};
const url = "./#host=" + encodeURIComponent(d.host || "") + "&bot=" + (d.bot || "");
e.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then((cs) => {
for (const c of cs) { c.postMessage({open: d}); return c.focus(); }
return self.clients.openWindow(url);
}));
});
"#;
struct Relay {
dir: PathBuf,
data: Mutex<Data>,
/// Requests waiting for each host.
queues: Mutex<HashMap<String, VecDeque<Value>>>,
arrived: Condvar,
/// Where each forwarded request's answer goes.
waiting: Mutex<HashMap<String, Sender<Value>>>,
/// When each host last asked (in memory; saved now and then).
seen: Mutex<HashMap<String, Instant>>,
fails: Mutex<HashMap<String, (u32, Instant)>>,
invite: String,
vapid: push::Vapid,
contact: String,
}
fn now() -> i64 {
chrono::Utc::now().timestamp()
}
impl Relay {
fn load(dir: PathBuf) -> Relay {
let _ = std::fs::create_dir_all(dir.join("backups"));
let data: Data = std::fs::read(dir.join("data.json")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let vapid = push::Vapid::load(&dir);
Relay {
dir,
data: Mutex::new(data),
queues: Mutex::new(HashMap::new()),
arrived: Condvar::new(),
waiting: Mutex::new(HashMap::new()),
seen: Mutex::new(HashMap::new()),
fails: Mutex::new(HashMap::new()),
invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(),
vapid,
// Push services want a way to reach whoever runs the server.
contact: std::env::var("ANYBOT_RELAY_CONTACT").ok().filter(|c| c.starts_with("mailto:") || c.starts_with("https://")).unwrap_or_else(|| "mailto:anybot-relay@example.com".into()),
}
}
fn save(&self, d: &Data) {
let tmp = self.dir.join("data.json.tmp");
if std::fs::write(&tmp, serde_json::to_vec_pretty(d).unwrap_or_default()).is_ok() {
let _ = std::fs::rename(&tmp, self.dir.join("data.json"));
}
}
fn session(&self, req: &Req) -> Option<String> {
let t = req.bearer();
if t.is_empty() {
return None;
}
let h = httpd::sha256_hex(&t);
let mut d = self.data.lock().unwrap();
let cutoff = now() - SESSION_DAYS * 86_400;
let s = d.sessions.iter_mut().find(|s| s.hash == h && s.last > cutoff)?;
// Count use at most once a minute (saves writing on every request).
if now() - s.last > 60 {
s.last = now();
let user = s.user.clone();
self.save(&d);
return Some(user);
}
Some(s.user.clone())
}
fn host(&self, req: &Req) -> Option<Host> {
let t = req.bearer();
if t.is_empty() {
return None;
}
let h = httpd::sha256_hex(&t);
let d = self.data.lock().unwrap();
d.hosts.iter().find(|x| x.token_hash == h).cloned()
}
fn online(&self, id: &str) -> bool {
self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false)
}
fn too_many_fails(&self, key: &str) -> bool {
let f = self.fails.lock().unwrap();
matches!(f.get(key), Some((n, t)) if *n >= MAX_FAILS && t.elapsed() < Duration::from_secs(300))
}
fn failed(&self, key: &str) {
let mut f = self.fails.lock().unwrap();
let e = f.entry(key.to_string()).or_insert((0, Instant::now()));
if e.1.elapsed() > Duration::from_secs(300) {
*e = (0, Instant::now());
}
e.0 += 1;
e.1 = Instant::now();
}
/// Hand a request to a host and wait for its answer.
fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap<String, String>, body: &[u8], who: (&str, &str)) -> Resp {
use base64::Engine;
if !self.online(host_id) {
return Resp::error(503, "那台 AnyBot 目前離線(電腦關機、睡眠,或 AnyBot 沒開)");
}
let id = httpd::random_hex(12);
let (tx, rx) = channel();
self.waiting.lock().unwrap().insert(id.clone(), tx);
let job = json!({"id": id, "method": method, "path": path, "query": query, "body": base64::engine::general_purpose::STANDARD.encode(body), "user": who.0, "role": who.1});
self.queues.lock().unwrap().entry(host_id.to_string()).or_default().push_back(job);
self.arrived.notify_all();
let r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS));
self.waiting.lock().unwrap().remove(&id);
// Not picked up in time: drop it so it doesn't run late.
if let Some(q) = self.queues.lock().unwrap().get_mut(host_id) {
q.retain(|j| j["id"].as_str() != Some(id.as_str()));
}
match r {
Ok(v) => {
let mut resp = Resp::bytes(v["ctype"].as_str().unwrap_or("application/octet-stream"), base64::engine::general_purpose::STANDARD.decode(v["body"].as_str().unwrap_or("")).unwrap_or_default());
resp.status = v["status"].as_u64().unwrap_or(502) as u16;
for h in v["headers"].as_array().into_iter().flatten() {
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
// Only harmless headers pass through.
if k.eq_ignore_ascii_case("x-size") {
resp.headers.push((k.into(), val.into()));
}
}
}
resp
}
Err(_) => Resp::error(504, "那台 AnyBot 沒有及時回應"),
}
}
fn handle(&self, req: Req) -> Resp {
let parts: Vec<String> = req.parts().iter().map(|s| s.to_string()).collect();
let p: Vec<&str> = parts.iter().map(|s| s.as_str()).collect();
match (req.method.as_str(), p.as_slice()) {
("GET", []) | ("GET", ["index.html"]) => Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec())
.with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'")
.with_header("Strict-Transport-Security", "max-age=31536000"),
("GET", ["manifest.webmanifest"]) => Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()),
("GET", ["icon.svg"]) => Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()),
("GET", ["api", "hello"]) => Resp::json(&json!({"app": "anybot-relay"})),
("GET", ["sw.js"]) => Resp::bytes("text/javascript; charset=utf-8", SW.as_bytes().to_vec()).with_header("Service-Worker-Allowed", "/"),
("GET", ["relay", "push", "key"]) => Resp::json(&json!({"key": self.vapid.public_b64})),
("POST", ["relay", "push", "subscribe"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let sub = req.json();
let endpoint = sub["endpoint"].as_str().unwrap_or("").to_string();
if !endpoint.starts_with("https://") || sub["keys"]["p256dh"].as_str().is_none() || sub["keys"]["auth"].as_str().is_none() {
return Resp::error(400, "通知訂閱資料不完整");
}
let mut d = self.data.lock().unwrap();
d.subs.retain(|s| s.sub["endpoint"].as_str() != Some(endpoint.as_str()));
d.subs.push(Sub { user, sub, created: now() });
self.save(&d);
Resp::json(&json!({"ok": true}))
}
("POST", ["relay", "push", "unsubscribe"]) => {
let endpoint = req.json()["endpoint"].as_str().unwrap_or("").to_string();
let mut d = self.data.lock().unwrap();
d.subs.retain(|s| s.sub["endpoint"].as_str() != Some(endpoint.as_str()));
self.save(&d);
Resp::json(&json!({"ok": true}))
}
// An AnyBot tells its person something (a bot needs them, or finished while they were away).
("POST", ["relay", "host", "notify"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let v = req.json();
let payload = json!({"title": v["title"].as_str().unwrap_or("AnyBot").chars().take(80).collect::<String>(), "body": v["body"].as_str().unwrap_or("").chars().take(200).collect::<String>(), "bot": v["bot"], "host": h.id});
let subs: Vec<Sub> = self.data.lock().unwrap().subs.iter().filter(|s| matches!(h.role_of(&s.user).as_deref(), Some("owner") | Some("admin"))).cloned().collect();
let mut gone = vec![];
let mut sent = 0;
for s in &subs {
match push::send(&self.vapid, &s.sub, &payload, &self.contact) {
Ok(()) => sent += 1,
Err(true) => gone.push(s.sub["endpoint"].as_str().unwrap_or("").to_string()),
Err(false) => {}
}
}
if !gone.is_empty() {
let mut d = self.data.lock().unwrap();
d.subs.retain(|s| !gone.iter().any(|g| s.sub["endpoint"].as_str() == Some(g.as_str())));
self.save(&d);
}
Resp::json(&json!({"sent": sent}))
}
("POST", ["relay", "signup"]) => self.signup(&req),
("POST", ["relay", "login"]) => self.login(&req),
("POST", ["relay", "logout"]) => {
let h = httpd::sha256_hex(&req.bearer());
let mut d = self.data.lock().unwrap();
d.sessions.retain(|s| s.hash != h);
self.save(&d);
Resp::json(&json!({"ok": true}))
}
("GET", ["relay", "me"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let d = self.data.lock().unwrap();
let hosts: Vec<Value> = d
.hosts
.iter()
.filter_map(|h| h.role_of(&user).map(|r| (h, r)))
.map(|(h, r)| {
let mut v = self.host_json(h);
v["role"] = json!(r);
if r != "owner" {
v["name"] = json!(format!("{}({} 的)", h.name, h.user));
}
v
})
.collect();
let sessions: Vec<Value> = d.sessions.iter().filter(|s| s.user == user).map(|s| json!({"device": s.device, "created": s.created, "last": s.last})).collect();
Resp::json(&json!({"user": user, "hosts": hosts, "sessions": sessions, "backup": self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&user))).exists()}))
}
("POST", ["relay", "hosts"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let name: String = req.json()["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect();
let token = httpd::random_hex(32);
let h = Host { id: httpd::random_hex(8), name: name.clone(), user: user.clone(), token_hash: httpd::sha256_hex(&token), created: now(), last_seen: 0, members: vec![] };
let mut d = self.data.lock().unwrap();
// Signing the same computer in again replaces its old key (keeping who it's shared with).
let mut h = h;
if let Some(old) = d.hosts.iter().find(|x| x.user == user && x.name == name) {
h.members = old.members.clone();
}
d.hosts.retain(|x| !(x.user == user && x.name == name));
d.hosts.push(h.clone());
self.save(&d);
Resp::json(&json!({"id": h.id, "token": token}))
}
("POST", ["relay", "hosts", id, "remove"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let mut d = self.data.lock().unwrap();
d.hosts.retain(|x| !(x.user == user && x.id == *id));
self.save(&d);
Resp::json(&json!({"ok": true}))
}
("GET", ["relay", "host", "next"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
let q = self.queues.lock().unwrap();
let (mut q, _) = self.arrived.wait_timeout_while(q, Duration::from_secs(HOST_POLL_SECS), |q| q.get(&h.id).map(|x| x.is_empty()).unwrap_or(true)).unwrap();
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
match q.get_mut(&h.id).and_then(|x| x.pop_front()) {
Some(job) => Resp::json(&job),
None => Resp::empty(204),
}
}
("POST", ["relay", "host", "reply", id]) => {
if self.host(&req).is_none() {
return Resp::error(401, "這台電腦的金鑰無效");
}
if let Some(tx) = self.waiting.lock().unwrap().remove(*id) {
let _ = tx.send(req.json());
}
Resp::json(&json!({"ok": true}))
}
("GET", ["relay", "host", "members"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
Resp::json(&json!({"members": h.members.iter().map(|m| json!({"user": m.user, "role": m.role})).collect::<Vec<_>>()}))
}
("POST", ["relay", "host", "members"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let v = req.json();
let user = v["user"].as_str().unwrap_or("").trim().to_lowercase();
let role = v["role"].as_str().unwrap_or("member").to_string();
let mut d = self.data.lock().unwrap();
if role != "remove" && !d.users.iter().any(|u| u.name == user) {
return Resp::error(404, "這台伺服器上沒有這個帳號(請對方先建立帳號)");
}
if user == h.user {
return Resp::error(400, "這是你自己的帳號");
}
if !matches!(role.as_str(), "member" | "admin" | "remove") {
return Resp::error(400, "角色只能是 member 或 admin");
}
if let Some(x) = d.hosts.iter_mut().find(|x| x.id == h.id) {
x.members.retain(|m| m.user != user);
if role != "remove" {
x.members.push(Member { user, role });
}
}
self.save(&d);
Resp::json(&json!({"ok": true}))
}
// 發布網站: an AnyBot publishes a folder; it is served at /s/<user>-<name>/.
("POST", ["relay", "host", "sites", name]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let name = name.to_lowercase();
if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Resp::error(400, "網站名稱只能用英文字母、數字和 -(最多 40 個字)");
}
let site = format!("{}-{name}", h.user.replace(|c: char| !c.is_ascii_alphanumeric(), "-"));
let dir = self.dir.join("sites").join(&site);
let v = req.json();
let _ = std::fs::remove_dir_all(&dir);
if v["remove"].as_bool() == Some(true) {
return Resp::json(&json!({"ok": true}));
}
let mut n = 0;
for f in v["files"].as_array().into_iter().flatten() {
let rel = f["path"].as_str().unwrap_or("");
if rel.is_empty() || rel.split('/').any(|c| c.is_empty() || c == "." || c == "..") {
continue;
}
let Ok(bytes) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, f["data"].as_str().unwrap_or("")) else { continue };
let path = dir.join(rel);
let _ = std::fs::create_dir_all(path.parent().unwrap_or(&dir));
if std::fs::write(&path, bytes).is_ok() {
n += 1;
}
}
let public = std::env::var("ANYBOT_RELAY_PUBLIC_URL").unwrap_or_default();
Resp::json(&json!({"files": n, "path": format!("/s/{site}/"), "url": if public.is_empty() { String::new() } else { format!("{}/s/{site}/", public.trim_end_matches('/')) }}))
}
("GET", ["s", site, rest @ ..]) => {
if site.contains("..") || rest.iter().any(|c| c.is_empty() || *c == "." || *c == "..") {
return Resp::error(404, "找不到");
}
let mut path = self.dir.join("sites").join(site).join(rest.join("/"));
if path.is_dir() || rest.is_empty() {
path = path.join("index.html");
}
let ext = path.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
let ctype = match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"json" => "application/json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"ico" => "image/x-icon",
"txt" | "md" => "text/plain; charset=utf-8",
"pdf" => "application/pdf",
"woff2" => "font/woff2",
"mp4" => "video/mp4",
_ => "application/octet-stream",
};
match std::fs::read(&path) {
// Sandboxed: a published page runs in its own opaque origin, so it can never
// read the phone app's sign-in on this same address.
Ok(b) => Resp::bytes(ctype, b).with_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"),
Err(_) => Resp::error(404, "找不到這個網頁"),
}
}
("GET", ["relay", "host", "peers"]) => {
let Some(me) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let d = self.data.lock().unwrap();
Resp::json(&json!({"hosts": d.hosts.iter().filter(|h| h.user == me.user).map(|h| self.host_json(h)).collect::<Vec<_>>()}))
}
("PUT", ["relay", "backup"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let path = self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&h.user)));
let tmp = path.with_extension("tmp");
match std::fs::write(&tmp, &req.body).and_then(|_| std::fs::rename(&tmp, &path)) {
Ok(()) => Resp::json(&json!({"ok": true, "bytes": req.body.len()})),
Err(e) => Resp::error(500, &e.to_string()),
}
}
("GET", ["relay", "backup"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
match std::fs::read(self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&user)))) {
Ok(b) => Resp::bytes("application/octet-stream", b),
Err(_) => Resp::error(404, "還沒有備份"),
}
}
// 裝置連結: one AnyBot asks another AnyBot of the same account.
("POST", ["relay", "hosts", target, "api", "device", "run"]) => {
let Some(me) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let same = self.data.lock().unwrap().hosts.iter().any(|h| h.id == *target && h.user == me.user);
if !same || *target == me.id {
return Resp::error(403, "不是同一個帳號的電腦");
}
self.forward(target, "POST", "/api/device/run", &HashMap::new(), &req.body, (&me.user, "device"))
}
// 大腦: one AnyBot of the account shows (and edits) another one's bots and chats.
(m, ["relay", "hosts", target, "api", "sync", rest @ ..]) => {
let Some(me) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let same = self.data.lock().unwrap().hosts.iter().any(|h| h.id == *target && h.user == me.user);
if !same || *target == me.id {
return Resp::error(403, "不是同一個帳號的電腦");
}
let path = if rest.is_empty() { "/api/sync".to_string() } else { format!("/api/sync/{}", rest.join("/")) };
self.forward(target, m, &path, &req.query, &req.body, (&me.user, "mirror"))
}
// The phone, through to one of the account's AnyBots.
(m, ["h", host, rest @ ..]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let role = self.data.lock().unwrap().hosts.iter().find(|h| h.id == *host).and_then(|h| h.role_of(&user));
let Some(role) = role else { return Resp::error(404, "找不到這台 AnyBot") };
if rest.first() != Some(&"api") || rest.get(1) == Some(&"device") {
return Resp::error(404, "沒有這個網址");
}
let mut q = req.query.clone();
q.remove("t");
self.forward(host, m, &format!("/{}", rest.join("/")), &q, &req.body, (&user, &role))
}
_ => Resp::error(404, "沒有這個網址"),
}
}
fn host_json(&self, h: &Host) -> Value {
let online = self.online(&h.id);
json!({"id": h.id, "name": h.name, "online": online, "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }})
}
fn signup(&self, req: &Req) -> Resp {
let v = req.json();
let user = v["user"].as_str().unwrap_or("").trim().to_lowercase();
let pw = v["password"].as_str().unwrap_or("");
if user.len() < 2 || user.len() > 64 || !user.chars().all(|c| c.is_ascii_alphanumeric() || "._@-".contains(c)) {
return Resp::error(400, "帳號只能用英文字母、數字和 . _ @ -(2–64 個字)");
}
if pw.chars().count() < 8 {
return Resp::error(400, "密碼至少 8 個字");
}
let mut d = self.data.lock().unwrap();
if !d.users.is_empty() && (self.invite.is_empty() || v["invite"].as_str() != Some(self.invite.as_str())) {
return Resp::error(403, if self.invite.is_empty() { "這台伺服器不開放註冊" } else { "邀請碼不對" });
}
if d.users.iter().any(|u| u.name == user) {
return Resp::error(409, "這個帳號已經有人用了");
}
d.users.push(User { name: user, pass: httpd::hash_password(pw), created: now() });
self.save(&d);
Resp::json(&json!({"ok": true}))
}
fn login(&self, req: &Req) -> Resp {
let v = req.json();
let user = v["user"].as_str().unwrap_or("").trim().to_lowercase();
let key = format!("{}|{user}", req.header("x-forwarded-for").split(',').next().unwrap_or(&req.peer).trim());
if self.too_many_fails(&key) {
return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試");
}
let stored = self.data.lock().unwrap().users.iter().find(|u| u.name == user).map(|u| u.pass.clone());
let ok = stored.map(|p| httpd::check_password(v["password"].as_str().unwrap_or(""), &p)).unwrap_or(false);
if !ok {
self.failed(&key);
std::thread::sleep(Duration::from_millis(500));
return Resp::error(401, "帳號或密碼不對");
}
let token = httpd::random_hex(32);
let mut d = self.data.lock().unwrap();
d.sessions.push(Session { hash: httpd::sha256_hex(&token), user, device: v["device"].as_str().unwrap_or("").chars().take(40).collect(), created: now(), last: now() });
let cutoff = now() - SESSION_DAYS * 86_400;
d.sessions.retain(|s| s.last > cutoff);
self.save(&d);
Resp::json(&json!({"token": token}))
}
/// Remember when hosts were last seen (for "離線(上次…)").
fn save_seen(&self) {
let seen: Vec<(String, u64)> = self.seen.lock().unwrap().iter().map(|(k, v)| (k.clone(), v.elapsed().as_secs())).collect();
let mut d = self.data.lock().unwrap();
let mut changed = false;
for (id, ago) in seen {
if let Some(h) = d.hosts.iter_mut().find(|h| h.id == id) {
let t = now() - ago as i64;
if t - h.last_seen > 60 {
h.last_seen = t;
changed = true;
}
}
}
if changed {
self.save(&d);
}
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let arg = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
let port: u16 = arg("--port").and_then(|p| p.parse().ok()).or_else(|| std::env::var("PORT").ok().and_then(|p| p.parse().ok())).unwrap_or(8080);
let bind = arg("--bind").unwrap_or_else(|| "0.0.0.0".into());
let dir = PathBuf::from(arg("--data").unwrap_or_else(|| "relay-data".into()));
let relay = Arc::new(Relay::load(dir.clone()));
let listener = std::net::TcpListener::bind((bind.as_str(), port)).unwrap_or_else(|e| {
eprintln!("無法使用連接埠 {port}:{e}");
std::process::exit(1)
});
eprintln!("AnyBot 帳號伺服器:http://{bind}:{port}(資料在 {})", dir.display());
if relay.data.lock().unwrap().users.is_empty() {
eprintln!("還沒有帳號:第一個註冊的人會成為這台伺服器的使用者。");
}
let r2 = relay.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(60));
r2.save_seen();
});
let r = relay.clone();
httpd::serve(listener, Arc::new(move |req| r.handle(req)), Arc::new(|| true));
}
#[cfg(test)]
mod tests {
use super::*;
fn post(port: u16, path: &str, token: &str, body: Value) -> (u16, Value) {
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut r = agent.post(&format!("http://127.0.0.1:{port}{path}")).header("Authorization", &format!("Bearer {token}")).send(body.to_string().as_bytes()).unwrap();
let st = r.status().as_u16();
(st, serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null))
}
fn get(port: u16, path: &str, token: &str) -> (u16, Vec<u8>) {
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut r = agent.get(&format!("http://127.0.0.1:{port}{path}")).header("Authorization", &format!("Bearer {token}")).call().unwrap();
(r.status().as_u16(), r.body_mut().read_to_vec().unwrap_or_default())
}
#[test]
fn account_host_and_phone_round_trip() {
let dir = std::env::temp_dir().join(format!("anybot-relay-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let relay = Arc::new(Relay::load(dir.clone()));
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let r = relay.clone();
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
assert_eq!(post(port, "/relay/signup", "", json!({"user": "Daniel", "password": "12345678"})).0, 200);
// Second sign-up without an invite: refused.
assert_eq!(post(port, "/relay/signup", "", json!({"user": "eve", "password": "12345678"})).0, 403);
assert_eq!(post(port, "/relay/login", "", json!({"user": "daniel", "password": "wrong-pw"})).0, 401);
let (_, v) = post(port, "/relay/login", "", json!({"user": "daniel", "password": "12345678", "device": "Mac"}));
let session = v["token"].as_str().unwrap().to_string();
let (_, h) = post(port, "/relay/hosts", &session, json!({"name": "工作室 Mac"}));
let (host_id, host_token) = (h["id"].as_str().unwrap().to_string(), h["token"].as_str().unwrap().to_string());
// Offline host: the phone is told so.
let (st, _) = get(port, &format!("/h/{host_id}/api/state"), &session);
assert_eq!(st, 503);
// The "AnyBot": answers whatever comes in with the path it got.
let ht = host_token.clone();
std::thread::spawn(move || loop {
let (st, body) = get(port, "/relay/host/next", &ht);
if st == 200 {
let job: Value = serde_json::from_slice(&body).unwrap();
let answer = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"path": job["path"], "q": job["query"]}).to_string()), "headers": [["X-Size", "10x20"], ["Set-Cookie", "no"]]});
post(port, &format!("/relay/host/reply/{}", job["id"].as_str().unwrap()), &ht, answer);
}
});
std::thread::sleep(Duration::from_millis(300));
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut resp = agent.get(&format!("http://127.0.0.1:{port}/h/{host_id}/api/state?since=3")).header("Authorization", &format!("Bearer {session}")).call().unwrap();
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers().get("x-size").unwrap(), "10x20");
assert!(resp.headers().get("set-cookie").is_none());
let v: Value = serde_json::from_str(&resp.body_mut().read_to_string().unwrap()).unwrap();
assert_eq!(v["path"], "/api/state");
assert_eq!(v["q"]["since"], "3");
// The device endpoint is only for the account's other AnyBots, never the phone.
assert_eq!(get(port, &format!("/h/{host_id}/api/device/run"), &session).0, 404);
// Without a session: refused.
assert_eq!(get(port, &format!("/h/{host_id}/api/state"), "nope").0, 401);
// Backup: the host uploads, the person downloads after signing in.
let agent2: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let st = agent2.put(&format!("http://127.0.0.1:{port}/relay/backup")).header("Authorization", &format!("Bearer {host_token}")).send(&[1u8, 2, 3][..]).unwrap().status().as_u16();
assert_eq!(st, 200);
assert_eq!(get(port, "/relay/backup", &session).1, vec![1, 2, 3]);
let (_, me) = get(port, "/relay/me", &session);
let me: Value = serde_json::from_slice(&me).unwrap();
assert_eq!(me["hosts"][0]["online"], true);
// Data survives a restart; tokens are stored hashed.
let saved = std::fs::read_to_string(dir.join("data.json")).unwrap();
assert!(!saved.contains(&session) && !saved.contains(&host_token) && !saved.contains("12345678"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn team_members_get_their_role() {
let dir = std::env::temp_dir().join(format!("anybot-relay-team-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut relay = Relay::load(dir.clone());
relay.invite = "join-us".into();
let relay = Arc::new(relay);
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let r = relay.clone();
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"}));
assert_eq!(post(port, "/relay/signup", "", json!({"user": "amy", "password": "12345678", "invite": "nope"})).0, 403);
assert_eq!(post(port, "/relay/signup", "", json!({"user": "amy", "password": "12345678", "invite": "join-us"})).0, 200);
let boss = post(port, "/relay/login", "", json!({"user": "boss", "password": "12345678"})).1["token"].as_str().unwrap().to_string();
let amy = post(port, "/relay/login", "", json!({"user": "amy", "password": "12345678"})).1["token"].as_str().unwrap().to_string();
let h = post(port, "/relay/hosts", &boss, json!({"name": "辦公室"})).1;
let (host_id, host_token) = (h["id"].as_str().unwrap().to_string(), h["token"].as_str().unwrap().to_string());
// Not shared yet: Amy can't see it.
assert_eq!(get(port, &format!("/h/{host_id}/api/state"), &amy).0, 404);
assert_eq!(post(port, "/relay/host/members", &host_token, json!({"user": "ghost", "role": "member"})).0, 404);
assert_eq!(post(port, "/relay/host/members", &host_token, json!({"user": "amy", "role": "member"})).0, 200);
let me: Value = serde_json::from_slice(&get(port, "/relay/me", &amy).1).unwrap();
assert_eq!(me["hosts"][0]["role"], "member");
// The host hears who asked and in which role.
let ht = host_token.clone();
std::thread::spawn(move || loop {
let (st, body) = get(port, "/relay/host/next", &ht);
if st == 200 {
let job: Value = serde_json::from_slice(&body).unwrap();
let answer = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"user": job["user"], "role": job["role"]}).to_string()), "headers": []});
post(port, &format!("/relay/host/reply/{}", job["id"].as_str().unwrap()), &ht, answer);
}
});
std::thread::sleep(Duration::from_millis(300));
let v: Value = serde_json::from_slice(&get(port, &format!("/h/{host_id}/api/state"), &amy).1).unwrap();
assert_eq!((v["user"].as_str(), v["role"].as_str()), (Some("amy"), Some("member")));
let v: Value = serde_json::from_slice(&get(port, &format!("/h/{host_id}/api/state"), &boss).1).unwrap();
assert_eq!(v["role"], "owner");
// Removing ends access.
post(port, "/relay/host/members", &host_token, json!({"user": "amy", "role": "remove"}));
assert_eq!(get(port, &format!("/h/{host_id}/api/state"), &amy).0, 404);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn published_sites_are_sandboxed() {
let dir = std::env::temp_dir().join(format!("anybot-relay-sites-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let relay = Arc::new(Relay::load(dir.clone()));
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let r = relay.clone();
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"}));
let s = post(port, "/relay/login", "", json!({"user": "boss", "password": "12345678"})).1["token"].as_str().unwrap().to_string();
let ht = post(port, "/relay/hosts", &s, json!({"name": "m"})).1["token"].as_str().unwrap().to_string();
let b = |t: &str| base64::Engine::encode(&base64::engine::general_purpose::STANDARD, t);
let (st, v) = post(port, "/relay/host/sites/trip", &ht, json!({"files": [{"path": "index.html", "data": b("<h1>台南</h1>")}, {"path": "css/a.css", "data": b("h1{}")}, {"path": "../evil", "data": b("x")}]}));
assert_eq!((st, v["files"].as_u64()), (200, Some(2)));
assert_eq!(v["path"], "/s/boss-trip/");
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut r = agent.get(&format!("http://127.0.0.1:{port}/s/boss-trip/")).call().unwrap();
assert!(r.headers().get("content-security-policy").unwrap().to_str().unwrap().starts_with("sandbox"));
assert_eq!(r.body_mut().read_to_string().unwrap(), "<h1>台南</h1>");
assert_eq!(get(port, "/s/boss-trip/css/a.css", "").0, 200);
assert_eq!(get(port, "/s/boss-trip/../../data.json", "").0, 404);
assert!(!dir.join("evil").exists() && !dir.join("sites").join("evil").exists());
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400);
let _ = std::fs::remove_dir_all(&dir);
}
}