// 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//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//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##""##; /// 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, } #[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 { 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, sessions: Vec, hosts: Vec, /// Phones that turned notifications on. #[serde(default)] subs: Vec, } 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, /// Requests waiting for each host. queues: Mutex>>, arrived: Condvar, /// Where each forwarded request's answer goes. waiting: Mutex>>, /// When each host last asked (in memory; saved now and then). seen: Mutex>, fails: Mutex>, 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 { 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 { 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, 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 = 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::(), "body": v["body"].as_str().unwrap_or("").chars().take(200).collect::(), "bot": v["bot"], "host": h.id}); let subs: Vec = 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 = 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 = 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::>()})) } ("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/-/. ("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::>()})) } ("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 = 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) { 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("

台南

")}, {"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(), "

台南

"); 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); } }