AnyBot/relay/src/main.rs

763 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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