diff --git a/src/agent_ws.rs b/src/agent_ws.rs index 15b0bc0f..8b6032b3 100644 --- a/src/agent_ws.rs +++ b/src/agent_ws.rs @@ -12,7 +12,7 @@ use anyhow::Result; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{ConnectInfo, State}; use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; +use axum::response::Response; use chrono::{DateTime, Local}; use serde::Deserialize; use serde_json::json; @@ -266,11 +266,11 @@ pub async fn handler( upgrade: WebSocketUpgrade, ) -> Response { let Some(token) = bearer(&headers) else { - return (StatusCode::UNAUTHORIZED, "missing token").into_response(); + return crate::api::answer(StatusCode::UNAUTHORIZED, "missing token"); }; let Ok(Some(node_id)) = app.db.node_by_token(token) else { // The same response whether the token is malformed or merely unknown. - return (StatusCode::UNAUTHORIZED, "invalid token").into_response(); + return crate::api::answer(StatusCode::UNAUTHORIZED, "invalid token"); }; let ip = node_ip(&headers, peer.ip()).to_string(); diff --git a/src/api.rs b/src/api.rs index a6ebeb20..eb2c3845 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,7 +12,7 @@ use axum::Json; use chrono::{Local, NaiveDate, Utc}; use serde::Deserialize; use serde_json::{json, Value}; -use tracing::debug; +use tracing::{debug, info, warn}; use crate::agent_ws::Agent; use crate::auth::{ @@ -26,27 +26,80 @@ use crate::{agent_ws, App, Shared}; pub struct Admin; impl FromRequestParts for Admin { - type Rejection = StatusCode; + type Rejection = Response; async fn from_request_parts(parts: &mut Parts, app: &Shared) -> Result { if authed(app, &parts.headers) { Ok(Admin) } else { - Err(StatusCode::UNAUTHORIZED) + Err(answer(StatusCode::UNAUTHORIZED, "登录已失效,请重新登录")) } } } -fn fail(e: impl std::fmt::Display) -> Response { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() +/// Marks a response whose text was written for the reader; see [`plain_errors`]. +#[derive(Clone)] +struct Written; + +/// An error response carrying `text` as written. Every error the hub composes +/// is built here. +pub(crate) fn answer(status: StatusCode, text: impl Into) -> Response { + let mut response = (status, text.into()).into_response(); + response.extensions_mut().insert(Written); + response +} + +/// What a failure this hub cannot explain to the reader says instead. +pub(crate) const INTERNAL: &str = "hub 内部出错,详细原因见 hub 日志"; + +/// Answers an error: 400 with the outermost [`crate::Shown`] message in its +/// chain, or, without one, 500 with [`INTERNAL`]. The chain is logged in full +/// either way, the underlying cause of a shown message included. +pub(crate) fn fail(e: impl Into) -> Response { + let e = e.into(); + match e.downcast_ref::() { + Some(shown) => { + info!("request refused: {e:#}"); + answer(StatusCode::BAD_REQUEST, shown.0.clone()) + } + None => { + warn!("request failed: {e:#}"); + answer(StatusCode::INTERNAL_SERVER_ERROR, INTERNAL) + } + } } fn bad(message: &str) -> Response { - (StatusCode::BAD_REQUEST, message.to_owned()).into_response() + answer(StatusCode::BAD_REQUEST, message) } fn no_such_node() -> Response { - (StatusCode::NOT_FOUND, "no such node").into_response() + answer(StatusCode::NOT_FOUND, "节点不存在,可能已被删除") +} + +/// The last step of every response. An error the hub composed passes as it is; +/// any other -- an extractor's rejection, the body limit's 413, a bare status -- +/// would carry axum's English wording or nothing, and is given a fixed text for +/// its status instead. +pub async fn plain_errors(response: Response) -> Response { + let status = response.status(); + if !(status.is_client_error() || status.is_server_error()) + || response.extensions().get::().is_some() + { + return response; + } + let text = match status { + StatusCode::UNAUTHORIZED => "登录已失效,请重新登录", + StatusCode::NOT_FOUND => "请求的内容不存在", + StatusCode::PAYLOAD_TOO_LARGE => "提交的内容过大", + s if s.is_server_error() => INTERNAL, + _ => "请求格式不对", + }; + // Only the body is replaced: a 405 keeps its `Allow`, for one. + let (mut parts, _) = response.into_parts(); + parts.headers.remove(header::CONTENT_LENGTH); + parts.headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static("text/plain; charset=utf-8")); + Response::from_parts(parts, text.into()) } // ---- read paths, shared between the panel and the public page ---- @@ -258,7 +311,7 @@ fn visible_nodes(app: &App, full: bool) -> Result, anyhow::Error> { pub async fn nodes(State(app): State, headers: HeaderMap) -> Response { let full = authed(&app, &headers); if !full && !app.public_page() { - return (StatusCode::UNAUTHORIZED, "sign-in required").into_response(); + return answer(StatusCode::UNAUTHORIZED, "需要登录后查看"); } // The same rendered frame the browser streams receive, for the same reason: // otherwise every visitor would rebuild every node's row against the @@ -323,13 +376,12 @@ pub async fn metrics( ) -> Response { let full = authed(&app, &headers); if !readable(&app, full, id) { - return (StatusCode::UNAUTHORIZED, "sign-in required").into_response(); + return answer(StatusCode::UNAUTHORIZED, "需要登录后查看"); } // After the two point lookups above, so an unauthorised caller is told so // rather than asked to retry later. let Ok(_permit) = HISTORY_GATE.try_acquire() else { - return (StatusCode::SERVICE_UNAVAILABLE, "too many history queries in flight, try again") - .into_response(); + return answer(StatusCode::SERVICE_UNAVAILABLE, "查询历史的请求太多,稍后再试"); }; let hours = w.hours.clamp(1, if full { ADMIN_HOURS } else { PUBLIC_HOURS }); let since = Utc::now().timestamp() - hours * 3_600; @@ -487,7 +539,7 @@ pub async fn live_ws(State(app): State, headers: HeaderMap, upgrade: Web // running, and only the row it names can report whether it has. let session = current_session(&headers).filter(|hash| app.db.session_valid(hash)); if session.is_none() && !app.public_page() { - return (StatusCode::UNAUTHORIZED, "sign-in required").into_response(); + return answer(StatusCode::UNAUTHORIZED, "需要登录后查看"); } upgrade .read_buffer_size(SOCKET_BUFFER) @@ -606,10 +658,10 @@ fn provisioning_allowed(app: &App, headers: &HeaderMap) -> Result<(), &'static s /// only because `period_start` clamps what it reads. fn node_limits(reset_day: Option, price: Option, limit: Option) -> Option<&'static str> { if reset_day.is_some_and(|d| !(1..=31).contains(&d)) { - return Some("reset day must be from 1 to 31"); + return Some("流量重置日要在 1 到 31 之间"); } if price.is_some_and(|v| !v.is_finite() || v < 0.0) || limit.is_some_and(|v| v < 0) { - return Some("price and traffic limit must be non-negative"); + return Some("价格和流量上限不能是负数"); } None } @@ -623,7 +675,7 @@ const MAX_GROUP: usize = 13; fn group_error(group: &mut String) -> Option<&'static str> { *group = group.trim().to_owned(); if group.chars().count() > MAX_GROUP || group.chars().any(char::is_control) { - return Some("group must be at most 13 characters, without control characters"); + return Some("分组名最多 13 个字,不能含控制字符"); } None } @@ -635,7 +687,7 @@ fn patch_error(node: &mut NodePatch) -> Option<&'static str> { if let Some(name) = &mut node.name { *name = name.trim().to_owned(); if name.is_empty() { - return Some("name is required"); + return Some("请填写节点名称"); } } if let Some(group) = &mut node.group { @@ -655,7 +707,7 @@ fn pins(node: &mut NodePatch) -> Option<&'static str> { if let Some(cc) = &mut node.country_pin { *cc = cc.trim().to_ascii_uppercase(); if !cc.is_empty() && !(cc.len() == 2 && cc.bytes().all(|b| b.is_ascii_uppercase())) { - return Some("country must be two letters, or empty to look it up"); + return Some("国家要填两个字母的代码,留空则自动识别"); } } for (pin, v6) in [(&mut node.ipv4_pin, false), (&mut node.ipv6_pin, true)] { @@ -669,8 +721,8 @@ fn pins(node: &mut NodePatch) -> Option<&'static str> { *pin = match parsed { Ok(ip) => ip.to_string(), Err(_) if typed.is_empty() => String::new(), - Err(_) if v6 => return Some("IPv6 must be an IPv6 address, or empty"), - Err(_) => return Some("IPv4 must be an IPv4 address, or empty"), + Err(_) if v6 => return Some("IPv6 要填一个 IPv6 地址,或者留空"), + Err(_) => return Some("IPv4 要填一个 IPv4 地址,或者留空"), }; } None @@ -701,11 +753,11 @@ pub async fn create_node( body: Result, JsonRejection>, ) -> Response { if let Err(refusal) = provisioning_allowed(&app, &headers) { - return (StatusCode::FORBIDDEN, refusal).into_response(); + return answer(StatusCode::FORBIDDEN, refusal); } - let Ok(Json(mut node)) = body else { return bad("invalid node") }; + let Ok(Json(mut node)) = body else { return bad("节点数据格式不对") }; if node.name.trim().is_empty() { - return bad("name is required"); + return bad("请填写节点名称"); } if let Some(message) = node_limits(Some(node.traffic_reset_day), Some(node.price), Some(node.traffic_limit)) @@ -782,7 +834,7 @@ pub async fn agent_register( // stale key is a misconfigured deploy rather than an attack on the panel, and // a shared counter would lock the operator out of their own hub for LOCKOUT. if app.registrations.locked(ip) { - return (StatusCode::TOO_MANY_REQUESTS, "too many attempts, try again later").into_response(); + return answer(StatusCode::TOO_MANY_REQUESTS, "too many attempts, try again later"); } // A rerun on a registered machine sends the token it already holds and // receives it back while that token still opens a node, so the rerun adds no @@ -796,14 +848,13 @@ pub async fn agent_register( Ok(None) => {} // Read as "no node", a failed lookup would register a second node for // a machine whose node is intact. - Err(e) => return fail(e), + Err(e) => return script_fail(e), } } // One answer for both "no window is open" and "that key is wrong": the // difference is only useful to someone who has neither. let closed = || { - (StatusCode::FORBIDDEN, "registration is closed; open a new window from the panel's node list") - .into_response() + answer(StatusCode::FORBIDDEN, "registration is closed; open a new window from the panel's node list") }; let until = app.db.get("register_until").and_then(|v| v.parse::().ok()).unwrap_or(0); let Some(key) = app.db.get("register_key").filter(|k| !k.is_empty() && Utc::now().timestamp() < until) @@ -819,13 +870,12 @@ pub async fn agent_register( } match app.db.nodes_created_since(until - REGISTER_WINDOW) { Ok(n) if n >= REGISTER_LIMIT => { - return ( + return answer( StatusCode::FORBIDDEN, "this window has registered enough nodes; open a new window from the panel's node list", ) - .into_response() } - Err(e) => return fail(e), + Err(e) => return script_fail(e), Ok(_) => {} } @@ -839,7 +889,7 @@ pub async fn agent_register( // and a node registered here must match one added through the panel. let node = match serde_json::from_value::(json!({ "name": name })) { Ok(node) => node, - Err(e) => return fail(e), + Err(e) => return script_fail(e), }; let token = random_token(); match app.db.create_node(&node, &token) { @@ -848,15 +898,22 @@ pub async fn agent_register( invalidate_snapshot(&app); token.into_response() } - Err(e) => fail(e), + Err(e) => script_fail(e), } } +/// [`fail`] for `install.sh`, whose own output is English and which prints the +/// reply in one line after its own. +fn script_fail(e: impl Into) -> Response { + warn!("registration failed: {:#}", e.into()); + answer(StatusCode::INTERNAL_SERVER_ERROR, "the hub hit an internal error; its log has the details") +} + /// Opens a registration window with a fresh key. Any previous key stops working /// the moment this returns. pub async fn open_register(_: Admin, State(app): State, headers: HeaderMap) -> Response { if let Err(refusal) = provisioning_allowed(&app, &headers) { - return (StatusCode::FORBIDDEN, refusal).into_response(); + return answer(StatusCode::FORBIDDEN, refusal); } let key = random_token(); let until = (Utc::now().timestamp() + REGISTER_WINDOW).to_string(); @@ -880,7 +937,7 @@ pub async fn update_node( Path(id): Path, body: Result, JsonRejection>, ) -> Response { - let Ok(Json(mut node)) = body else { return bad("invalid node") }; + let Ok(Json(mut node)) = body else { return bad("节点数据格式不对") }; if let Some(message) = patch_error(&mut node) { return bad(message); } @@ -918,12 +975,12 @@ pub async fn update_nodes( body: Result, JsonRejection>, ) -> Response { let Ok(Json(NodeBatch { mut ids, patch })) = body else { - return bad("invalid batch: only group and notify apply to several nodes at once"); + return bad("批量修改只支持分组和通知"); }; ids.sort_unstable(); ids.dedup(); if ids.is_empty() { - return bad("no nodes selected"); + return bad("没有选中节点"); } let mut patch = NodePatch { group: patch.group, notify: patch.notify, ..Default::default() }; if let Some(message) = patch_error(&mut patch) { @@ -934,9 +991,7 @@ pub async fn update_nodes( invalidate_snapshot(&app); Json(json!({"updated": ids.len()})).into_response() } - Ok(false) => { - (StatusCode::NOT_FOUND, "有节点已被删除,没有做任何修改;刷新后重新选择").into_response() - } + Ok(false) => answer(StatusCode::NOT_FOUND, "有节点已被删除,没有做任何修改;刷新后重新选择"), Err(e) => fail(e), } } @@ -955,8 +1010,7 @@ pub async fn reorder_nodes(_: Admin, State(app): State, Json(order): Jso invalidate_snapshot(&app); Json(json!({"ok": true})).into_response() } - // Every failure here indicates a malformed list from the caller. - Err(e) => bad(&e.to_string()), + Err(e) => fail(e), } } @@ -1011,7 +1065,7 @@ pub async fn patch_traffic( Json(p): Json, ) -> Response { if [p.total_rx, p.total_tx, p.month_rx, p.month_tx].into_iter().flatten().any(|v| v < 0) { - return bad("traffic must be non-negative"); + return bad("流量不能是负数"); } match app.db.set_traffic(id, &p) { Ok(true) => { @@ -1035,7 +1089,7 @@ pub async fn ping_tasks(_: Admin, State(app): State) -> Response { pub async fn reorder_ping_tasks(_: Admin, State(app): State, Json(order): Json) -> Response { match app.db.reorder_ping_tasks(&order.ids) { Ok(()) => Json(json!({"ok": true})).into_response(), - Err(e) => bad(&e.to_string()), + Err(e) => fail(e), } } @@ -1072,12 +1126,12 @@ pub async fn save_ping_task(_: Admin, State(app): State, Json(mut task): task.name = task.name.trim().to_owned(); task.target = task.target.trim().to_owned(); if task.name.is_empty() || task.target.is_empty() { - return bad("name and target are required"); + return bad("请填写名称和目标"); } // A TCP probe requires an explicit port; a bare host would silently never // connect. if !valid_target(&task.target) { - return bad("target must be host:port, for example 1.1.1.1:443 or [2606:4700:4700::1111]:443"); + return bad("目标要写成「主机:端口」,例如 1.1.1.1:443 或 [2606:4700:4700::1111]:443"); } // Refused rather than clamped, for the reason `setting_error` gives for // `retention_days`: the agent clamps this again on arrival, so an @@ -1087,17 +1141,14 @@ pub async fn save_ping_task(_: Admin, State(app): State, Json(mut task): // task is assigned to; the panel reaches 0 simply by having its interval // field cleared. if !(Db::MIN_PROBE_INTERVAL..=3_600).contains(&task.interval) { - return bad(&format!("interval must be from {} to 3600 seconds", Db::MIN_PROBE_INTERVAL)); + return bad(&format!("间隔要在 {} 到 3600 秒之间", Db::MIN_PROBE_INTERVAL)); } match app.db.save_ping_task(&task) { Ok(id) => { agent_ws::push_ping_tasks(&app); Json(json!({"id": id})).into_response() } - // Every failure here originates with the caller: a node id that does not - // exist, or more probes on one node than the agent will run. The same - // reasoning as `reorder_nodes`. - Err(e) => bad(&e.to_string()), + Err(e) => fail(e), } } @@ -1172,10 +1223,10 @@ pub struct Chunk { /// and would save about a second on a 6.7 MB backup. async fn receive(path: &str, chunk: &Chunk, max: u64, body: axum::body::Body) -> Result { if chunk.total == 0 || chunk.total > max { - anyhow::bail!("文件必须在 1 字节到 {} MiB 之间", max / 1024 / 1024); + refuse!("文件必须在 1 字节到 {} MiB 之间", max / 1024 / 1024); } if chunk.offset > chunk.total { - anyhow::bail!("分片位置越过了文件末尾"); + refuse!("分片位置越过了文件末尾"); } let mut options = std::fs::OpenOptions::new(); @@ -1191,14 +1242,14 @@ async fn receive(path: &str, chunk: &Chunk, max: u64, body: axum::body::Body) -> let mut file = match options.open(path) { Ok(file) => file, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - anyhow::bail!("这次上传已经不在了,请从头开始") + refuse!("这次上传已经不在了,请从头开始") } Err(e) => return Err(e.into()), }; let already = file.metadata()?.len(); if already != chunk.offset { - anyhow::bail!("分片接不上:已经收到 {already} 字节,这一片却从 {} 开始", chunk.offset); + refuse!("分片接不上:已经收到 {already} 字节,这一片却从 {} 开始", chunk.offset); } match append(&mut file, chunk, body).await { @@ -1220,6 +1271,7 @@ async fn append( chunk: &Chunk, body: axum::body::Body, ) -> Result { + use anyhow::Context; use std::io::Write; use std::pin::Pin; @@ -1228,10 +1280,11 @@ async fn append( while let Some(piece) = std::future::poll_fn(|cx| futures_core::Stream::poll_next(Pin::new(&mut stream), cx)).await { - let piece = piece?; + // The client went away mid-piece, as a cancelled upload does. + let piece = piece.context(crate::Shown("上传中断了,请重新上传".into()))?; received += piece.len() as u64; if chunk.offset + received > chunk.total { - anyhow::bail!("这一片超出了声明的文件大小"); + refuse!("这一片超出了声明的文件大小"); } file.write_all(&piece)?; } @@ -1323,7 +1376,7 @@ pub async fn db_restore( let path = format!("{}.upload", app.db.file()); let received = match receive(&path, &chunk, MAX_RESTORE, body).await { Ok(received) => received, - Err(e) => return bad(&format!("{e:#}")), + Err(e) => return fail(e), }; if received < chunk.total { return Json(json!({"received": received})).into_response(); @@ -1346,7 +1399,7 @@ pub async fn db_restore( let source = scratch_path(&app, "restoring"); if let Err(e) = std::fs::rename(&path, &source) { let _ = std::fs::remove_file(&path); - return bad(&format!("上传收齐了却取不到文件:{e}")); + return fail(e); } let outcome = restore(&app, &source).await; @@ -1372,7 +1425,7 @@ pub async fn db_restore( }; with_cookies(Json(json!({"ok": true})), [cookie]) } - Err(e) => bad(&format!("{e:#}")), + Err(e) => fail(e), } } @@ -1423,7 +1476,7 @@ pub async fn upload_theme( let name = path.to_string_lossy().into_owned(); let received = match receive(&name, &chunk, MAX_THEME, body).await { Ok(received) => received, - Err(e) => return bad(&format!("{e:#}")), + Err(e) => return fail(e), }; if received < chunk.total { return Json(json!({"received": received})).into_response(); @@ -1437,7 +1490,7 @@ pub async fn upload_theme( let source = app.themes.join(format!(".installing-{}.tar.gz", &random_token()[..16])); if let Err(e) = std::fs::rename(&path, &source) { let _ = std::fs::remove_file(&path); - return bad(&format!("上传收齐了却取不到文件:{e}")); + return fail(e); } // Off the runtime: gunzip plus a few thousand small writes. @@ -1451,7 +1504,7 @@ pub async fn upload_theme( let _ = std::fs::remove_file(&source); match installed.map_err(|e| anyhow::anyhow!(e)).and_then(|r| r) { Ok(theme) => Json(json!({"theme": theme})).into_response(), - Err(e) => bad(&format!("{e:#}")), + Err(e) => fail(e), } } @@ -1584,23 +1637,34 @@ fn path_segment(segment: &str) -> bool { pub async fn update_theme(_: Admin, State(app): State, Path(short): Path) -> Response { match update(&app, &short).await { Ok((updated, version)) => Json(json!({"updated": updated, "version": version})).into_response(), - Err(e) => bad(&format!("{e:#}")), + Err(e) => fail(e), } } async fn update(app: &App, short: &str) -> Result<(bool, String), anyhow::Error> { - use anyhow::{bail, Context}; + use anyhow::Context; - let installed = crate::frontend::themes(app)? - .into_iter() - .find(|theme| theme.short == short) - .context("没有这个主题")?; - let (owner, repo) = github_repo(&installed.url) - .context("这个主题的 url 不是 https://github.com//,只能手动上传新包")?; + let Some(installed) = crate::frontend::themes(app)?.into_iter().find(|theme| theme.short == short) else { + refuse!("没有这个主题"); + }; + let Some((owner, repo)) = github_repo(&installed.url) else { + refuse!("这个主题的 url 不是 https://github.com//,只能手动上传新包"); + }; - let release = latest_release(app, &format!("{owner}/{repo}")) - .await - .with_context(|| format!("读不到 {owner}/{repo} 的最新 release"))?; + let release = match latest_release(app, &format!("{owner}/{repo}")).await { + Ok(release) => release, + Err(e) => { + let why = match e.status().map(|s| s.as_u16()) { + None if e.is_decode() => "GitHub 的回复无法识别,稍后再试", + Some(404) => "这个仓库还没有正式 release", + // Unauthenticated callers get 60 requests an hour per address. + Some(403 | 429) => "GitHub 限制了这台机器的请求次数,过一小时再试", + Some(_) => "GitHub 接口出错,稍后再试", + None => "hub 连不上 api.github.com,检查它的网络", + }; + return Err(e).context(crate::Shown(format!("读不到 {owner}/{repo} 的最新 release:{why}"))); + } + }; // Tags read `v1.2.3` while manifests carry `1.2.3`. Equal means up to date; // anything else is installed, including a deliberate downgrade, since the @@ -1610,12 +1674,12 @@ async fn update(app: &App, short: &str) -> Result<(bool, String), anyhow::Error> return Ok((false, installed.version)); } if !path_segment(tag) { - bail!("release 的 tag {tag:?} 不能出现在下载地址里"); + refuse!("release 的 tag {tag:?} 不能出现在下载地址里"); } // Checked here rather than by downloading and reading a 404: the asset name is // the contract, and stating so is the entire error message. if !release.assets.iter().any(|asset| asset.name == ARCHIVE) { - bail!("release {tag} 里没有 {ARCHIVE}"); + refuse!("release {tag} 里没有 {ARCHIVE}"); } // Through the panel's GitHub proxy when one is configured, the archive being @@ -1624,17 +1688,26 @@ async fn update(app: &App, short: &str) -> Result<(bool, String), anyhow::Error> // has the upload path. let url = crate::proxied(app, format!("https://github.com/{owner}/{repo}/releases/download/{tag}/{ARCHIVE}")); - let response = - app.http.get(url).timeout(std::time::Duration::from_secs(120)).send().await?.error_for_status()?; + let unreachable = || crate::Shown(format!("下载 {ARCHIVE} 失败,检查 hub 的网络或面板里的 GitHub 代理")); + let response = app + .http + .get(url) + .timeout(std::time::Duration::from_secs(120)) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .with_context(unreachable)?; // The transfer stops at Content-Length, so checking it checks the body: a // header understating the archive cannot make more arrive. GitHub always // sends one; a proxy that omits it is refused rather than read unbounded. match response.content_length() { Some(size) if size <= MAX_THEME => {} - Some(size) => bail!("主题包 {} MiB,超过 {} MiB 的上限", size / 1024 / 1024, MAX_THEME / 1024 / 1024), - None => bail!("下载没有给出大小,无法确认它在 {} MiB 以内", MAX_THEME / 1024 / 1024), + Some(size) => { + refuse!("主题包 {} MiB,超过 {} MiB 的上限", size / 1024 / 1024, MAX_THEME / 1024 / 1024) + } + None => refuse!("下载没有给出大小,无法确认它在 {} MiB 以内", MAX_THEME / 1024 / 1024), } - let archive = response.bytes().await?; + let archive = response.bytes().await.with_context(unreachable)?; // The same unpacking, validation and atomic replace an upload undergoes, // constrained to the theme it may replace. The built-in theme has no @@ -1669,7 +1742,7 @@ pub async fn theme_preview(_: Admin, State(app): State, Path(short): Pat pub async fn delete_theme(_: Admin, State(app): State, Path(short): Path) -> Response { match crate::frontend::remove(&app.themes, &short) { Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => bad(&format!("{e:#}")), + Err(e) => fail(e), } } @@ -1700,7 +1773,7 @@ pub async fn theme_config( Path(short): Path, ) -> Response { if !authed(&app, &headers) && !app.public_page() { - return (StatusCode::UNAUTHORIZED, "sign-in required").into_response(); + return answer(StatusCode::UNAUTHORIZED, "需要登录后查看"); } if !crate::frontend::valid_short(&short) { return StatusCode::NOT_FOUND.into_response(); @@ -1724,7 +1797,7 @@ pub async fn save_theme_config( Json(values): Json>, ) -> Response { if !crate::frontend::valid_short(&short) || !crate::frontend::selectable(&app, &short) { - return bad("theme is not installed"); + return bad("主题没有安装"); } match app.db.set(&theme_config_key(&short), &Value::Object(values).to_string()) { Ok(()) => StatusCode::NO_CONTENT.into_response(), @@ -1809,13 +1882,13 @@ fn setting_error(app: &App, key: &str, value: &Value) -> Option { // `{"public_page": false}`, `{"retention_days": 7}` -- was formerly skipped by // a bare `continue`, so nothing was written while the response reported // success. - let Some(value) = value.as_str() else { return Some(format!("{key} must be a string")) }; + let Some(value) = value.as_str() else { return Some(format!("设置 {key} 的值格式不对")) }; match key { - "theme" if !crate::frontend::selectable(app, value) => Some("theme is not installed".into()), + "theme" if !crate::frontend::selectable(app, value) => Some("主题没有安装".into()), // Housekeeping clamps whatever it reads, so an unparsable value would be // stored, echoed back, and silently mean 7 days indefinitely. "retention_days" if !value.parse::().is_ok_and(|d| (1..=3_650).contains(&d)) => { - Some("retention days must be a number from 1 to 3650".into()) + Some("历史保留天数要在 1 到 3650 之间".into()) } // The hub fetches this URL itself, so it must be one: a scheme it cannot // speak turns every agent download into a 502 that says nothing about the @@ -1827,13 +1900,13 @@ fn setting_error(app: &App, key: &str, value: &Value) -> Option { // chooses that binary, while the node still sees a valid TLS connection to // the hub. "github_proxy" if !(value.is_empty() || value.starts_with("https://")) => { - Some("GitHub proxy must start with https://: the agent binary is fetched through it and installed on every node".into()) + Some("GitHub 代理必须以 https:// 开头:agent 程序经它下载,再安装到每个节点".into()) } - "admin_password" if value.len() < 12 => Some("password must be at least 12 characters".into()), + "admin_password" if value.len() < 12 => Some("密码至少 12 位".into()), "admin_password" => None, k if k.starts_with("notify_") => crate::notify::setting_error(k, value), k if READABLE_SETTINGS.contains(&k) || k == "github_client_secret" => None, - _ => Some(format!("unknown setting: {key}")), + _ => Some(format!("没有这个设置项:{key}")), } } @@ -1843,7 +1916,7 @@ pub async fn save_settings( headers: HeaderMap, Json(body): Json, ) -> Response { - let Some(map) = body.as_object() else { return bad("expected an object") }; + let Some(map) = body.as_object() else { return bad("设置格式不对") }; for (key, value) in map { if let Some(message) = setting_error(&app, key, value) { return bad(&message); @@ -3353,4 +3426,34 @@ mod tests { assert!(!body.to_string().contains(secret), "{secret}"); } } + + /// What every error response is held to: text written for the reader passes + /// as it is, and nothing else -- a library's error, axum's rejection wording + /// -- reaches the body. + #[tokio::test] + async fn only_written_text_reaches_an_error_response() { + let read = |r: Response| async move { + let r = plain_errors(r).await; + let body = axum::body::to_bytes(r.into_body(), usize::MAX).await.unwrap(); + String::from_utf8(body.to_vec()).unwrap() + }; + let raw = || std::io::Error::other("/opt/monitor/data/themes/.staging-1: incomplete deflate stream"); + + let internal = fail(raw()); + assert_eq!(internal.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(read(internal).await, INTERNAL); + + // The shown message, wherever it sits in the chain, and never its cause. + let wrapped = + anyhow::Error::from(raw()).context(crate::Shown("主题包损坏".into())).context("installing"); + let refused = fail(wrapped); + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + assert_eq!(read(refused).await, "主题包损坏"); + + let rejection = + (StatusCode::UNPROCESSABLE_ENTITY, "Failed to deserialize the JSON body").into_response(); + assert_eq!(read(rejection).await, "请求格式不对"); + assert_eq!(read(StatusCode::UNAUTHORIZED.into_response()).await, "登录已失效,请重新登录"); + assert_eq!(read(bad("请填写节点名称")).await, "请填写节点名称"); + } } diff --git a/src/auth.rs b/src/auth.rs index e9394cdb..ff4ee09b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use std::sync::Mutex; use std::time::{Duration, Instant}; -use anyhow::{bail, Context, Result}; +use anyhow::{anyhow, Context, Result}; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::Argon2; use axum::extract::{ConnectInfo, Query, State}; @@ -21,7 +21,8 @@ use sha2::{Digest, Sha256}; use tokio::sync::Semaphore; use tracing::{info, warn}; -use crate::App; +use crate::api::{answer, fail}; +use crate::{App, Shown}; pub const COOKIE: &str = "monitor_session"; const STATE_COOKIE: &str = "monitor_oauth_state"; @@ -168,18 +169,18 @@ pub async fn login( ) -> Response { let ip = client_ip(&headers, peer.ip()); if app.throttle.locked(ip) { - return (StatusCode::TOO_MANY_REQUESTS, "too many attempts, try again later").into_response(); + return answer(StatusCode::TOO_MANY_REQUESTS, "尝试次数过多,稍后再试"); } // Held across the check below, which is its purpose. let Ok(_permit) = PASSWORD_GATE.try_acquire() else { - return (StatusCode::TOO_MANY_REQUESTS, "too many attempts, try again later").into_response(); + return answer(StatusCode::TOO_MANY_REQUESTS, "尝试次数过多,稍后再试"); }; let Some(stored) = app.db.get("admin_password_hash") else { - return (StatusCode::FORBIDDEN, "password login is disabled").into_response(); + return answer(StatusCode::FORBIDDEN, "没有设置应急密码,无法用密码登录"); }; if !verify_password(&body.password, &stored) { app.throttle.record_failure(ip); - return (StatusCode::UNAUTHORIZED, "invalid password").into_response(); + return answer(StatusCode::UNAUTHORIZED, "密码错误"); } app.throttle.clear(ip); match issue_session(&app, &headers) { @@ -187,7 +188,7 @@ pub async fn login( crate::notify::signed_in(&app, "应急密码", ip); with_cookies(Json(serde_json::json!({"ok": true})), [cookie]) } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => fail(e), } } @@ -205,7 +206,7 @@ pub async fn logout(State(app): State, headers: HeaderMap) -> Res /// to GitHub. The nonce returns in step two and must match. pub async fn github_start(State(app): State, headers: HeaderMap) -> Response { let Some(client_id) = app.db.get("github_client_id").filter(|v| !v.is_empty()) else { - return (StatusCode::PRECONDITION_FAILED, "GitHub sign-in is not configured").into_response(); + return answer(StatusCode::PRECONDITION_FAILED, "没有配置 GitHub 登录"); }; let state = random_token(); let url = format!( @@ -238,38 +239,45 @@ pub async fn github_callback( // GitHub reports a refusal in the query string rather than the body. if let Some(error) = &query.error { let reason = query.error_description.as_deref().unwrap_or(error); - return sign_in_failed(&app, &headers, &format!("GitHub returned {error}: {reason}")); - } - // Reject a callback the browser did not initiate. - let state = query.state.as_deref().unwrap_or_default(); - if state.is_empty() || cookie_value(&headers, STATE_COOKIE).as_deref() != Some(state) { + let shown = if error == "access_denied" { + "在 GitHub 上取消了授权" + } else { + "GitHub 拒绝了这次登录,原因见 hub 日志" + }; return sign_in_failed( &app, &headers, - "state mismatch or missing; start again from the sign-in page", + anyhow!("GitHub returned {error}: {reason}").context(Shown(shown.into())), ); } + // Reject a callback the browser did not initiate. + let state = query.state.as_deref().unwrap_or_default(); + if state.is_empty() || cookie_value(&headers, STATE_COOKIE).as_deref() != Some(state) { + return sign_in_failed(&app, &headers, anyhow!(Shown("登录已过期,请从登录页重新开始".into()))); + } let Some(code) = query.code.as_deref().filter(|c| !c.is_empty()) else { - return sign_in_failed(&app, &headers, "GitHub sent no authorization code"); + return sign_in_failed(&app, &headers, anyhow!(Shown("GitHub 没有返回授权码,请重新登录".into()))); }; let user = match github_login(&app, code).await { Ok(user) => user, - Err(e) => return sign_in_failed(&app, &headers, &e.to_string()), + Err(e) => return sign_in_failed(&app, &headers, e), }; let session = match issue_session(&app, &headers) { Ok(cookie) => cookie, - Err(e) => return sign_in_failed(&app, &headers, &e.to_string()), + Err(e) => return sign_in_failed(&app, &headers, e), }; crate::notify::signed_in(&app, &format!("GitHub {user}"), client_ip(&headers, peer.ip())); with_cookies(Redirect::to("/admin"), [clear_state(&app, &headers), session]) } /// Redirects the browser back to the sign-in page with the reason, rather than -/// leaving a bare 401 at a callback URL offering no way forward. -fn sign_in_failed(app: &App, headers: &HeaderMap, reason: &str) -> Response { +/// leaving a bare 401 at a callback URL offering no way forward. The reason is +/// the error's [`Shown`] message, the same rule `api::fail` applies. +fn sign_in_failed(app: &App, headers: &HeaderMap, e: anyhow::Error) -> Response { // A rejected sign-in must leave a server-side record; the browser sees only // the redirect. - warn!("GitHub sign-in rejected: {reason}"); + warn!("GitHub sign-in rejected: {e:#}"); + let reason = e.downcast_ref::().map_or(crate::api::INTERNAL, |shown| shown.0.as_str()); let target = format!("/admin?login_error={}", urlencode(reason)); with_cookies(Redirect::to(&target), [clear_state(app, headers), String::new()]) } @@ -291,7 +299,7 @@ pub fn with_cookies(response: impl IntoResponse, cookies: [Strin Ok(value) => { response.headers_mut().append(header::SET_COOKIE, value); } - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "bad cookie").into_response(), + Err(e) => return fail(e), } } response @@ -309,19 +317,30 @@ fn urlencode(value: &str) -> String { .collect() } +/// What a sign-in says when a request to GitHub fails: unanswered is the hub's +/// network, an answer that does not decode is GitHub's. +fn github_failed(e: reqwest::Error) -> anyhow::Error { + let shown = if e.is_decode() { + "GitHub 的回复无法识别,稍后重新登录" + } else { + "hub 连不上 GitHub,检查它的网络后重新登录" + }; + anyhow::Error::from(e).context(Shown(shown.into())) +} + /// Exchanges the code for a token and checks the login against the allow list, /// returning the accepted login. async fn github_login(app: &App, code: &str) -> Result { let (Some(id), Some(secret)) = (app.db.get("github_client_id"), app.db.get("github_client_secret")) else { - bail!("not configured"); + refuse!("没有配置 GitHub 登录"); }; let allowed = app.db.get("github_allowed_users").unwrap_or_default(); let allowed: Vec = allowed.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect(); if allowed.is_empty() { // Without an allow list, any GitHub account could sign in. - bail!("no allowed GitHub users configured"); + refuse!("没有设置允许登录的 GitHub 用户"); } #[derive(Deserialize)] @@ -336,12 +355,15 @@ async fn github_login(app: &App, code: &str) -> Result { .json(&serde_json::json!({"client_id": id, "client_secret": secret, "code": code})) .send() .await - .context("token request")? + .map_err(github_failed)? .json() .await - .context("token response")?; + .map_err(github_failed)?; let Some(access) = token.access_token else { - bail!("{}", token.error_description.unwrap_or_else(|| "no access token".into())); + // Typically an expired code, or a client secret that no longer matches. + let reason = token.error_description.unwrap_or_else(|| "no access token".into()); + return Err(anyhow!(reason) + .context(Shown("GitHub 没有发放令牌,检查 Client Secret 是否正确后重新登录".into()))); }; #[derive(Deserialize)] @@ -355,21 +377,21 @@ async fn github_login(app: &App, code: &str) -> Result { .header(header::USER_AGENT, "monitor-hub") .send() .await - .context("user request")?; + .map_err(github_failed)?; let status = response.status(); - let body = response.text().await.context("user response")?; + let body = response.text().await.map_err(github_failed)?; // Decoding an error page into GithubUser would report "missing field login" // instead of GitHub's actual message. - let user: GithubUser = serde_json::from_str(&body).with_context(|| { - format!("user response ({status}): {}", body.chars().take(200).collect::()) - })?; + let user: GithubUser = serde_json::from_str(&body) + .with_context(|| format!("user response ({status}): {}", body.chars().take(200).collect::())) + .context(Shown(format!("GitHub 没有返回用户信息(HTTP {}),重新登录再试", status.as_u16())))?; if !allowed.contains(&user.login.to_lowercase()) { // The list stays in the log and out of the reason, which travels back in // a query string: any GitHub account can reach that page, and a reason // carrying the allow list would disclose the accounts worth phishing. warn!("GitHub user {} is not on the allowed list {allowed:?}", user.login); - bail!("GitHub user {} is not on the allowed list", user.login); + refuse!("GitHub 用户 {} 不在允许登录的名单里", user.login); } info!("GitHub sign-in accepted for {}", user.login); Ok(user.login) diff --git a/src/db.rs b/src/db.rs index cfe780d2..27085b83 100644 --- a/src/db.rs +++ b/src/db.rs @@ -798,18 +798,18 @@ impl Db { fn reorder(&self, table: &str, ids: &[i64]) -> Result<()> { let unique: HashSet<_> = ids.iter().collect(); if unique.len() != ids.len() { - anyhow::bail!("排序里有重复的条目"); + refuse!("排序里有重复的条目"); } let mut conn = self.conn(); let tx = conn.transaction()?; let count: i64 = tx.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?; if count as usize != ids.len() { - anyhow::bail!("列表已在别处改动,刷新后再排序"); + refuse!("列表已在别处改动,刷新后再排序"); } let sql = format!("UPDATE {table} SET sort=?2 WHERE id=?1"); for (sort, id) in ids.iter().enumerate() { if tx.execute(&sql, params![id, sort as i64])? != 1 { - anyhow::bail!("列表已在别处改动,刷新后再排序"); + refuse!("列表已在别处改动,刷新后再排序"); } } tx.commit()?; @@ -1304,7 +1304,7 @@ impl Db { // would fail on the task's foreign key and be reported against a // node, or, with none, the save would report success. if updated == 0 { - anyhow::bail!("监控不存在,可能已被删除"); + refuse!("监控不存在,可能已被删除"); } t.id } else { @@ -1337,7 +1337,12 @@ impl Db { "INSERT OR IGNORE INTO ping_node (task_id, node_id) VALUES (?1,?2)", params![id, node], ) - .with_context(|| format!("节点 {node} 不存在"))?; + .map_err(|e| match e.sqlite_error_code() { + Some(rusqlite::ErrorCode::ConstraintViolation) => { + anyhow::Error::from(e).context(crate::Shown(format!("节点 {node} 不存在,可能已被删除"))) + } + _ => e.into(), + })?; } // Queried from the table after the rows are in rather than counted from // the request: an update changes this task's own assignments, so @@ -1354,7 +1359,7 @@ impl Db { ) .optional()?; if let Some(node) = crowded { - anyhow::bail!( + refuse!( "节点「{node}」会被分配超过 {} 个探测任务,agent 最多只跑这么多,多出来的会被静默丢掉", Self::MAX_PROBES_PER_NODE ); @@ -1363,10 +1368,7 @@ impl Db { let joining: i64 = tx.query_row("SELECT COUNT(*) FROM ping_task WHERE auto_join", [], |r| r.get(0))?; if joining > Self::MAX_PROBES_PER_NODE { - anyhow::bail!( - "新节点会自动加入 {joining} 个探测任务,agent 最多只跑 {} 个", - Self::MAX_PROBES_PER_NODE - ); + refuse!("新节点会自动加入 {joining} 个探测任务,agent 最多只跑 {} 个", Self::MAX_PROBES_PER_NODE); } tx.commit()?; Ok(id) @@ -1678,15 +1680,17 @@ impl Db { /// while the live database is still untouched. The caller owns that file and /// deletes it in either case. pub fn check_backup(&self, src: &str) -> Result<()> { + const NOT_A_BACKUP: &str = "这不是 hub 导出的备份文件"; // Read-write rather than read-only: a plain copy of a running hub's // database is in WAL mode, and SQLite cannot open such a file read-only // without its -shm companion. let candidate = Connection::open(src)?; let health: String = candidate .query_row("PRAGMA integrity_check", [], |r| r.get(0)) - .map_err(|e| anyhow::anyhow!("not a readable SQLite database: {e}"))?; + .context(crate::Shown(NOT_A_BACKUP.into()))?; if health != "ok" { - anyhow::bail!("the file is a damaged database: {health}"); + info!("uploaded backup fails integrity_check: {health}"); + refuse!("备份文件已损坏,数据库完整性检查没有通过"); } // Pages are copied verbatim, so whatever schema the file carries becomes // the schema this hub runs its statements against. A view or trigger @@ -1698,7 +1702,7 @@ impl Db { |r| r.get(0), )?; if plotted > 0 { - anyhow::bail!("the file carries views or triggers, which a hub backup never does"); + refuse!("文件里有视图或触发器,不是 hub 导出的备份"); } for table in TABLES { let found: i64 = candidate.query_row( @@ -1707,21 +1711,19 @@ impl Db { |r| r.get(0), )?; if found == 0 { - anyhow::bail!("the file is not a hub backup: no {table} table"); + refuse!("{NOT_A_BACKUP}:缺少 {table} 表"); } } let version: i64 = candidate.query_row("PRAGMA user_version", [], |r| r.get(0))?; if version > SCHEMA_VERSION { - anyhow::bail!( - "the backup is from a newer hub (schema {version}, this one reads {SCHEMA_VERSION}); upgrade first" - ); + refuse!("备份来自更新版本的 hub(数据库版本 {version},这台只认到 {SCHEMA_VERSION}),先升级 hub 再恢复"); } // The online backup API refuses a page size change while the destination // is in WAL mode; an explicit message is clearer than SQLITE_READONLY. let theirs: i64 = candidate.query_row("PRAGMA page_size", [], |r| r.get(0))?; let ours: i64 = self.conn().query_row("PRAGMA page_size", [], |r| r.get(0))?; if theirs != ours { - anyhow::bail!("the backup uses a {theirs}-byte page, this database uses {ours}"); + refuse!("备份的页大小是 {theirs} 字节,这台 hub 是 {ours} 字节,无法恢复"); } // Brought up to this build's schema here, on the upload. Run after the // copy instead, a failed migration would leave the hub on a database it @@ -1752,7 +1754,7 @@ impl Db { let mut missing: Vec<&str> = want.difference(&got).map(String::as_str).collect(); if !missing.is_empty() { missing.sort_unstable(); - anyhow::bail!("the file's {table} table is missing {}", missing.join(", ")); + refuse!("{NOT_A_BACKUP}:{table} 表缺少字段 {}", missing.join("、")); } } Ok(()) @@ -2034,14 +2036,20 @@ mod tests { let scratch = Scratch::new(); let db = Db::open(&scratch.0).unwrap(); let bad = format!("{}.copy", scratch.0); + // Each refusal carries a message the panel shows, never a bare 500. + let refused = |why: &str| { + let e = db.check_backup(&bad).unwrap_err(); + assert!(e.downcast_ref::().is_some(), "{why}: {e:#}"); + e.to_string() + }; std::fs::write(&bad, b"this is not a database at all").unwrap(); - assert!(db.check_backup(&bad).is_err(), "not SQLite"); + refused("not SQLite"); let _ = std::fs::remove_file(&bad); let empty = Connection::open(&bad).unwrap(); empty.execute_batch("CREATE TABLE unrelated (a)").unwrap(); - assert!(db.check_backup(&bad).is_err(), "SQLite, but not this schema"); + refused("SQLite, but not this schema"); // A file carrying its own code where a table belongs: the restore copies // pages, so that schema would become the one the hub runs every statement @@ -2052,7 +2060,7 @@ mod tests { "DROP TABLE session; CREATE VIEW session AS SELECT 1 AS token_hash, 2 AS expires_at", ) .unwrap(); - assert!(db.check_backup(&bad).is_err(), "a view where a table belongs"); + refused("a view where a table belongs"); // Eight tables with the right names and none of the right columns. Every // gate above passes: it is a healthy SQLite file, it carries no view or @@ -2069,15 +2077,15 @@ mod tests { shaped.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION}")).unwrap(); // Which table fails first follows the order of TABLES and is incidental; // naming the table and the columns is what matters. - let refused = db.check_backup(&bad).unwrap_err().to_string(); - assert!(refused.contains("table is missing"), "{refused}"); + let missing = refused("tables without their columns"); + assert!(missing.contains("表缺少字段"), "{missing}"); // From a hub carrying a schema this build has never seen. let _ = std::fs::remove_file(&bad); let newer = Connection::open(&bad).unwrap(); newer.execute_batch(SCHEMA).unwrap(); newer.execute_batch(&format!("PRAGMA user_version = {}", SCHEMA_VERSION + 1)).unwrap(); - assert!(db.check_backup(&bad).is_err(), "from a newer hub"); + refused("from a newer hub"); newer.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION}")).unwrap(); db.check_backup(&bad).unwrap(); @@ -2446,6 +2454,14 @@ mod tests { assert!(db.node(id).unwrap().is_none()); assert_eq!(db.metrics(id, 0, 60).unwrap().len(), 0); assert!(!db.all_traffic().contains_key(&id)); + // Ticked in an editor opened before the delete: named, not a 500. + let gone = db.save_ping_task(&probe(vec![id])).unwrap_err(); + let expected = format!("节点 {id} 不存在,可能已被删除"); + assert_eq!( + gone.downcast_ref::().map(|s| s.0.as_str()), + Some(expected.as_str()), + "{gone:#}" + ); // `ping_record` has no foreign key to cascade through, and SQLite reassigns // the deleted id to the next node created: without the sweep in diff --git a/src/frontend.rs b/src/frontend.rs index 9f06ba6e..c1d22756 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -4,7 +4,7 @@ use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use axum::extract::State; use axum::http::{header, HeaderMap, StatusCode, Uri}; @@ -13,6 +13,7 @@ use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::api::answer; use crate::auth::random_token; use crate::{App, Shared}; @@ -60,16 +61,12 @@ pub async fn serve(State(app): State, headers: HeaderMap, uri: Uri) -> R let path = uri.path().trim_start_matches('/'); let known = headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()); if is_api_path(path) { - return (StatusCode::NOT_FOUND, format!("no such endpoint: /{path}")).into_response(); + return answer(StatusCode::NOT_FOUND, format!("没有这个接口:/{path}")); } if path == "admin" || path.starts_with("admin/") { let path = path.strip_prefix("admin").unwrap_or(path).trim_start_matches('/'); - return embedded::( - path, - "the panel is not built; run `npm run build` in web-admin/", - known, - ); + return embedded::(path, "面板没有构建,在 web-admin/ 下运行 npm run build", known); } let theme = app.db.get("theme").unwrap_or_default(); @@ -78,7 +75,7 @@ pub async fn serve(State(app): State, headers: HeaderMap, uri: Uri) -> R return response; } } - embedded::(path, "the default theme is missing; run scripts/theme.sh", known) + embedded::(path, "默认主题缺失,运行 scripts/theme.sh", known) } fn is_api_path(path: &str) -> bool { @@ -100,11 +97,11 @@ fn embedded(requested: &str, remedy: &str, known: Option<&str>) -> return asset(path, file.data.into_owned(), known); } if is_asset(path) { - return (StatusCode::NOT_FOUND, format!("no such asset: /{path}")).into_response(); + return answer(StatusCode::NOT_FOUND, format!("没有这个文件:/{path}")); } match T::get("index.html") { Some(index) => asset("index.html", index.data.into_owned(), known), - None => (StatusCode::NOT_FOUND, remedy.to_owned()).into_response(), + None => answer(StatusCode::NOT_FOUND, remedy), } } @@ -281,36 +278,61 @@ pub fn install(themes: &Path, archive: R, expect: Option<&str>) -> Resu installed } +/// The answer to any archive that cannot be read to the end: a download cut +/// short, which is how a partial `theme.tar.gz` fails, or a file that is not a +/// gzip'd tar at all. +const DAMAGED: &str = "主题包损坏或不完整(可能没下载完),重新下载 theme.tar.gz 再试"; + +/// The answer to an archive whose entries cannot all be written: a file and a +/// directory under one name, or a name the filesystem refuses. +const TANGLED: &str = "主题包里有同名的文件和目录,或者文件名过长,包本身有问题,请联系主题作者"; + +/// Tells a failure the archive caused from one of this machine's. Reading fails +/// with `UnexpectedEof` where the stream stops short and `InvalidInput` for a +/// corrupt or non-gzip one; a layout that cannot be written fails with the +/// second group. Anything else -- a full disk, a permission -- is this machine's +/// to fix. +fn archive_error(e: std::io::Error) -> anyhow::Error { + use std::io::ErrorKind::*; + let shown = match e.kind() { + UnexpectedEof | InvalidInput | InvalidData => DAMAGED, + NotADirectory | IsADirectory | AlreadyExists | InvalidFilename => TANGLED, + _ => return e.into(), + }; + anyhow::Error::from(e).context(crate::Shown(shown.into())) +} + fn unpack(archive: R, into: &Path) -> Result<()> { fs::create_dir(into)?; let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(archive)); let mut expanded = 0u64; for (seen, entry) in archive.entries()?.enumerate() { - let mut entry = entry.context("主题包不是有效的 tar.gz")?; + // Only reading happens here, so every failure is the archive's. + let mut entry = entry.context(crate::Shown(DAMAGED.into()))?; if seen >= MAX_ENTRIES { - bail!("主题包里的条目超过 {MAX_ENTRIES} 个"); + refuse!("主题包里的条目超过 {MAX_ENTRIES} 个"); } // Only the entry types a theme consists of. A symlink, hard link or // device node belongs in none, and each is a route to writing where the // path check below cannot see. let kind = entry.header().entry_type(); if !kind.is_file() && !kind.is_dir() { - bail!("主题包里有不支持的条目:{}", entry.path()?.display()); + refuse!("主题包里有不支持的条目:{}", entry.path()?.display()); } let size = entry.size(); if size > MAX_FILE { - bail!("{} 超过单个文件 {} MiB 的上限", entry.path()?.display(), MAX_FILE >> 20); + refuse!("{} 超过单个文件 {} MiB 的上限", entry.path()?.display(), MAX_FILE >> 20); } // Subtraction, because summing two entry sizes can overflow; `size` is // already known to be the smaller of the two. if expanded > MAX_EXPANDED - size { - bail!("主题包解压后超过 {} MiB", MAX_EXPANDED >> 20); + refuse!("主题包解压后超过 {} MiB", MAX_EXPANDED >> 20); } expanded += size; // Rejects an entry whose path escapes `into` -- absolute, `..`, or via // a symlinked parent -- reporting `false` rather than an error. - if !entry.unpack_in(into)? { - bail!("主题包里的路径越出了主题目录"); + if !entry.unpack_in(into).map_err(archive_error)? { + refuse!("主题包里的路径越出了主题目录"); } } Ok(()) @@ -319,24 +341,25 @@ fn unpack(archive: R, into: &Path) -> Result<()> { /// Checks the unpacked directory is a theme this hub can actually serve, then /// moves it into place under the name its manifest asks for. fn publish(themes: &Path, staging: &Path, expect: Option<&str>) -> Result { - let manifest = read_inside(staging, "theme.json").context("主题包里没有 theme.json")?; + let Some(manifest) = read_inside(staging, "theme.json") else { refuse!("主题包里没有 theme.json") }; if manifest.len() > 64 * 1024 { - bail!("theme.json 过大"); + refuse!("theme.json 过大"); } - let theme: Theme = serde_json::from_slice(&manifest).context("theme.json 格式不对")?; + let theme: Theme = + serde_json::from_slice(&manifest).context(crate::Shown("theme.json 格式不对".into()))?; if !valid_short(&theme.short) { - bail!("theme.json 里的 short 不能作为目录名:{:?}", theme.short); + refuse!("theme.json 里的 short 不能作为目录名:{:?}", theme.short); } // An update replaces the theme it was invoked for. A package whose manifest // carries a different `short` would instead install a second theme, or // overwrite an unrelated one, while reporting success for the update. if let Some(expected) = expect.filter(|&expected| expected != theme.short) { - bail!("这个包里是主题 {:?},不是 {expected:?}", theme.short); + refuse!("这个包里是主题 {:?},不是 {expected:?}", theme.short); } // The one file `serve` requires. Without it every request falls through to // the built-in theme, indistinguishable from the upload having no effect. if !staging.join("dist").join("index.html").is_file() { - bail!("主题包里没有 dist/index.html"); + refuse!("主题包里没有 dist/index.html"); } let destination = themes.join(&theme.short); @@ -392,14 +415,15 @@ const PREVIEW: &str = "preview.png"; /// request, the same path a broken theme already takes. pub fn remove(themes: &Path, short: &str) -> Result<()> { if !valid_short(short) { - bail!("没有这个主题"); + refuse!("没有这个主题"); } let base = themes.canonicalize()?; - let root = base.join(short).canonicalize()?; + // Already gone, as when two panels delete the same theme. + let Ok(root) = base.join(short).canonicalize() else { refuse!("没有这个主题") }; // Canonical on both sides: a symlink leading out of the themes directory // must not be deleted through. if !root.starts_with(&base) || !root.is_dir() { - bail!("没有这个主题"); + refuse!("没有这个主题"); } fs::remove_dir_all(root)?; Ok(()) @@ -489,17 +513,27 @@ mod tests { assert_eq!(fs::read(base.join("aurora/dist/index.html")).unwrap(), b"v3"); assert!(!base.join("aurora/dist/old.js").exists(), "the replaced theme must not leave files behind"); - // A theme the hub cannot serve, a name that cannot be a directory, and - // a symlink -- the entry type that writes where the path check cannot - // look. + // A file where a directory must go, a theme the hub cannot serve, a name + // that cannot be a directory, and a symlink -- the entry type that + // writes where the path check cannot look. for bad in [ + pack(&[("theme.json", manifest), ("dist", b"x"), ("dist/index.html", b"x")], None), pack(&[("theme.json", manifest)], None), pack(&[("theme.json", r#"{"name":"x","short":"../evil","description":"","version":"1","author":"a","url":""}"#.as_bytes()), ("dist/index.html", b"x")], None), pack(&[("theme.json", manifest), ("dist/index.html", b"x")], Some(("dist/link", "/etc/passwd"))), pack(&[("dist/index.html", b"x")], None), ] { - assert!(install(&base, bad, None).is_err()); + // A reason for the panel, never the 500 of a failure on this machine. + let Err(e) = install(&base, bad, None) else { panic!("a bad package installed") }; + assert!(e.downcast_ref::().is_some(), "{e:#}"); } + // A download cut short says so, rather than quoting the decoder. + pack(&[("theme.json", manifest), ("dist/index.html", b"v9")], None); + let whole = fs::read(&archive).unwrap(); + let Err(e) = install(&base, &whole[..whole.len() / 2], None) else { + panic!("half an archive installed") + }; + assert_eq!(e.downcast_ref::().map(|s| s.0.as_str()), Some(DAMAGED), "{e:#}"); // None of that affected the theme being served or left a staging // directory behind. diff --git a/src/main.rs b/src/main.rs index ae592018..b7223f42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,26 @@ //! and the database path is configured in the panel and stored in SQLite, //! leaving no config file to track and no secrets in plaintext TOML. +/// Error text written for whoever reads the reply: the only error wording a +/// response may carry. `api::fail` answers an error with the outermost one in +/// its chain, and an error without one with a fixed message, logging the chain +/// in full; a file path, a SQL error or a library's wording stays in the log. +#[derive(Debug)] +pub struct Shown(pub String); + +impl std::fmt::Display for Shown { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// `anyhow::bail!` with a [`Shown`] message. +macro_rules! refuse { + ($($arg:tt)*) => { + return Err(anyhow::Error::msg($crate::Shown(format!($($arg)*)))) + }; +} + mod agent_ws; mod api; mod auth; @@ -262,10 +282,10 @@ impl futures_core::Stream for Metered { /// releases. async fn agent_binary(State(app): State, Path(arch): Path) -> Response { if !matches!(arch.as_str(), "x86_64" | "aarch64") { - return (StatusCode::NOT_FOUND, "unknown architecture").into_response(); + return api::answer(StatusCode::NOT_FOUND, "unknown architecture"); } let Ok(Ok(permit)) = tokio::time::timeout(RELAY_WAIT, RELAY_GATE.acquire()).await else { - return (StatusCode::SERVICE_UNAVAILABLE, "too many downloads in flight, try again").into_response(); + return api::answer(StatusCode::SERVICE_UNAVAILABLE, "too many downloads in flight, try again"); }; let url = release_url(&app, &arch); // The default client timeout is sized for API calls, not a 1.8 MB download. @@ -279,10 +299,18 @@ async fn agent_binary(State(app): State, Path(arch): Path) -> Re axum::body::Body::from_stream(metered(Box::pin(res.bytes_stream()), permit)), ) .into_response(), - Ok(res) => { - (StatusCode::BAD_GATEWAY, format!("release download failed: {}", res.status())).into_response() + Ok(res) => api::answer( + StatusCode::BAD_GATEWAY, + format!("GitHub answered {} for the agent release", res.status()), + ), + // English, as `install.sh` prints it after its own English line. + Err(e) => { + warn!("relaying the agent from {url} failed: {e:#}"); + api::answer( + StatusCode::BAD_GATEWAY, + "the hub could not reach GitHub or its GitHub proxy; its log has the details", + ) } - Err(e) => (StatusCode::BAD_GATEWAY, format!("release download failed: {e}")).into_response(), } } @@ -443,11 +471,6 @@ async fn main() -> Result<()> { tokio::spawn(notify::watch(app.clone())); let router = Router::new() - // Agents. - .route("/api/agent/ws", get(agent_ws::handler)) - .route("/api/agent/register", post(api::agent_register)) - .route("/install.sh", get(install_script)) - .route("/agent/{arch}", get(agent_binary)) // Read paths; the public page reaches these unauthenticated. .route("/api/me", get(api::me)) .route("/api/nodes", get(api::nodes)) @@ -499,6 +522,18 @@ async fn main() -> Result<()> { .layer(tower_http::limit::RequestBodyLimitLayer::new(api::MAX_CHUNK)) .with_state(app.clone()), ) + .layer(axum::middleware::map_response(api::plain_errors)) + // Agents, merged after that layer: `install.sh` and the agent print these + // replies beside their own English output, so they are left as written. + .merge( + Router::new() + .route("/api/agent/ws", get(agent_ws::handler)) + .route("/api/agent/register", post(api::agent_register)) + .route("/install.sh", get(install_script)) + .route("/agent/{arch}", get(agent_binary)) + .layer(tower_http::limit::RequestBodyLimitLayer::new(64 * 1024)) + .with_state(app.clone()), + ) // Excludes the agent binary and database backups: both are already // compressed and both are megabytes, so deflating them would consume the // cores argon2 and the SQLite writer share for no gain. diff --git a/src/notify.rs b/src/notify.rs index ecc76dc7..01b4a500 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -22,7 +22,7 @@ use serde_json::{json, Value}; use tokio::sync::mpsc; use tracing::{debug, warn}; -use crate::api::Admin; +use crate::api::{answer, Admin}; use crate::{App, Shared}; /// One alert. `node` lists the node names it concerns, comma-separated, and is @@ -67,21 +67,21 @@ pub const DEFAULT_BODY: &str = r#"{"event":"{{event}}","node":"{{node}}","title":"{{title}}","message":"{{message}}"}"#; pub const DEFAULT_TEXT: &str = "{{title}}\n{{message}}"; -/// Numeric settings as `(key, min, max, default)`. -const NUMBERS: [(&str, i64, i64, i64); 3] = [ +/// Numeric settings as `(key, min, max, default, the panel's name for it)`. +const NUMBERS: [(&str, i64, i64, i64, &str); 3] = [ // Minutes a node may stay away before it is reported. Agents reconnect within // seconds of a network or hub interruption, which one minute already covers. // Capped at FLAP_GRACE: a longer grace would outwait a flapping node too, and // the absence clock, kept in memory, restarts with the hub, so every restart // during an outage would delay its alert by up to one more grace period. // Nodes expected to stay down for hours have their alerts switched off. - ("notify_grace", 1, FLAP_GRACE / 60, 3), + ("notify_grace", 1, FLAP_GRACE / 60, 3, "离线宽限期"), // Percent of the allowance that raises the first traffic alert; 0 disables // traffic alerts. - ("notify_traffic", 0, 100, 80), + ("notify_traffic", 0, 100, 80, "流量提醒"), // Days ahead an expiry is listed; 0 disables both expiry reminders and // renewal notices. - ("notify_expiry", 0, 365, 7), + ("notify_expiry", 0, 365, 7, "到期提醒"), ]; /// Credentials, reported to the panel only as set or unset. A webhook URL is @@ -89,7 +89,7 @@ const NUMBERS: [(&str, i64, i64, i64); 3] = [ const SECRETS: [&str; 3] = ["notify_telegram_token", "notify_webhook_url", "notify_webhook_headers"]; fn number(app: &App, key: &str) -> i64 { - let (_, min, max, default) = + let (_, min, max, default, _) = NUMBERS.iter().copied().find(|(k, ..)| *k == key).expect("a numeric setting"); app.db.get(key).and_then(|v| v.parse().ok()).filter(|n| (min..=max).contains(n)).unwrap_or(default) } @@ -121,16 +121,16 @@ pub fn settings(app: &App, out: &mut serde_json::Map) { /// Why a notification setting cannot be stored, or `None` when it can. pub fn setting_error(key: &str, value: &str) -> Option { - if let Some((_, min, max, _)) = NUMBERS.iter().find(|(k, ..)| *k == key) { + if let Some((_, min, max, _, label)) = NUMBERS.iter().find(|(k, ..)| *k == key) { let fits = value.parse::().is_ok_and(|n| (*min..=*max).contains(&n)); - return (!fits).then(|| format!("{key} must be a number from {min} to {max}")); + return (!fits).then(|| format!("{label}要填 {min} 到 {max} 之间的整数")); } let only = |s: &str, extra: &[u8]| { !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || extra.contains(&b)) }; let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); let problem = match key { - "notify_login" => (!matches!(value, "on" | "off")).then_some("notify_login must be on or off"), + "notify_login" => (!matches!(value, "on" | "off")).then_some("登录提醒只能是 on 或 off"), "notify_webhook_headers" => return parse_headers(value).err(), // Empty clears a channel's field, or restores a template's default. "notify_telegram_token" @@ -147,24 +147,24 @@ pub fn setting_error(key: &str, value: &str) -> Option { // is accepted. "notify_telegram_token" => { (!value.split_once(':').is_some_and(|(id, secret)| digits(id) && only(secret, b"_-"))) - .then_some("Telegram bot token must look like 123456:ABC-DEF") + .then_some("Telegram Bot Token 的格式应为 123456:ABC-DEF") } "notify_telegram_chat" => { let valid = match value.strip_prefix('@') { Some(name) => only(name, b"_"), None => digits(value.strip_prefix('-').unwrap_or(value)), }; - (!valid).then_some("Telegram chat must be a numeric id or an @username") + (!valid).then_some("Telegram Chat ID 要填数字 ID 或 @用户名") } // Plain http is accepted: a relay on the hub's own host or network is a // common target, and only an admin can set this. "notify_webhook_url" => (!reqwest::Url::parse(value) .is_ok_and(|u| matches!(u.scheme(), "http" | "https"))) - .then_some("webhook URL must start with http:// or https://"), + .then_some("Webhook URL 必须以 http:// 或 https:// 开头"), "notify_webhook_body" => serde_json::from_str::(&render(value, &sample(), r#"s"i\te"#, true)) .is_err() - .then_some("webhook body must be valid JSON once filled in; keep each placeholder inside quotes"), - _ => return Some(format!("unknown setting: {key}")), + .then_some("Webhook 请求体填入内容后必须是合法的 JSON,占位符要放在引号里"), + _ => return Some(format!("没有这个设置项:{key}")), }; problem.map(Into::into) } @@ -187,12 +187,11 @@ fn parse_headers(text: &str) -> Result, String> { .map(str::trim) .filter(|line| !line.is_empty()) .map(|line| { - let (name, value) = - line.split_once(':').ok_or("every webhook header line must read Name: value")?; + let (name, value) = line.split_once(':').ok_or("Webhook 请求头每行要写成「名称: 值」")?; let name = HeaderName::try_from(name.trim()) - .map_err(|_| format!("invalid header name {:?}", name.trim()))?; + .map_err(|_| format!("Webhook 请求头名称 {:?} 不合法", name.trim()))?; let value = HeaderValue::try_from(value.trim()) - .map_err(|_| format!("invalid value for header {name}"))?; + .map_err(|_| format!("Webhook 请求头 {name} 的值不合法"))?; Ok((name, value)) }) .collect() @@ -244,8 +243,8 @@ enum Channel { impl Channel { fn name(&self) -> &'static str { match self { - Channel::Telegram { .. } => "telegram", - Channel::Webhook { .. } => "webhook", + Channel::Telegram { .. } => "Telegram", + Channel::Webhook { .. } => "Webhook", } } } @@ -271,9 +270,13 @@ fn channels(app: &App) -> Vec { /// Why a delivery failed, and whether another attempt could change that. A /// refused credential or a malformed request fails identically every time, and /// retrying it would hold the queue for 20 s per alert. +/// +/// `reason` is the far end's own answer, for the log; `shown` is what the panel +/// says, composed here from the status rather than quoted from the answer. struct Failure { retry: bool, reason: String, + shown: String, } /// The client alerts are sent with, separate from `App::http` because it must not @@ -308,7 +311,12 @@ async fn post(app: &App, channel: &Channel, note: &Note) -> Result<(), Failure> // default instead of in its place. let mut map = HeaderMap::new(); map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - for (name, value) in parse_headers(headers).map_err(|reason| Failure { retry: false, reason })? { + let headers = parse_headers(headers).map_err(|reason| Failure { + retry: false, + shown: reason.clone(), + reason, + })?; + for (name, value) in headers { map.insert(name, value); } client().post(url).headers(map).body(render(body, note, &site, true)) @@ -319,21 +327,31 @@ async fn post(app: &App, channel: &Channel, note: &Note) -> Result<(), Failure> // and the panel. let mut response = request.send().await.map_err(|e| Failure { retry: true, + shown: if e.is_timeout() { "请求超时" } else { "连不上对方服务器" }.into(), reason: format!("{:#}", anyhow::Error::from(e.without_url())), })?; let status = response.status(); if status.is_success() { return Ok(()); } - if status.is_redirection() { - let reason = format!("{status}: the URL redirects; enter the address it redirects to"); - return Err(Failure { retry: false, reason }); - } // The first chunk carries the reason (Telegram's `description`, Discord's // `message`) without reading an error page of arbitrary length. let head = response.chunk().await.ok().flatten().unwrap_or_default(); + let code = status.as_u16(); + let why = match (channel, code) { + (_, 300..=399) => "地址发生了跳转,请填写跳转后的地址", + (Channel::Telegram { .. }, 401 | 404) => "Bot Token 不对", + (Channel::Telegram { .. }, 400) => "Chat ID 不对,或者 bot 还没有加入这个会话", + (Channel::Telegram { .. }, 403) => "bot 被这个会话移除或屏蔽了", + (_, 401 | 403) => "对方拒绝了鉴权,检查 URL 或请求头里的凭据", + (_, 404) => "地址不存在,检查 URL", + (_, 429) => "发送太频繁,被对方限流", + (_, 500..) => "对方服务器出错", + _ => "对方拒收了这条消息,检查请求体格式", + }; Err(Failure { retry: status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS, + shown: format!("{why}(HTTP {code})"), reason: format!( "{status}: {}", String::from_utf8_lossy(&head).chars().take(300).collect::().trim() @@ -380,7 +398,7 @@ pub async fn deliver(app: Shared, mut inbox: mpsc::Receiver) { pub async fn test(_: Admin, State(app): State) -> Response { let channels = channels(&app); if channels.is_empty() { - return (StatusCode::BAD_REQUEST, "no notification channel is configured").into_response(); + return answer(StatusCode::BAD_REQUEST, "还没有配置通知渠道"); } let note = Note { event: "test", @@ -392,13 +410,14 @@ pub async fn test(_: Admin, State(app): State) -> Response { let mut failed = Vec::new(); for channel in &channels { if let Err(f) = post(&app, channel, ¬e).await { - failed.push(format!("{}: {}", channel.name(), f.reason)); + warn!("{} test alert not delivered: {}", channel.name(), f.reason); + failed.push(format!("{}:{}", channel.name(), f.shown)); } } if failed.is_empty() { Json(json!({"sent": channels.iter().map(Channel::name).collect::>()})).into_response() } else { - (StatusCode::BAD_GATEWAY, failed.join("; ")).into_response() + answer(StatusCode::BAD_GATEWAY, failed.join(";")) } } diff --git a/web-admin/src/App.tsx b/web-admin/src/App.tsx index 25b24749..b8604831 100644 --- a/web-admin/src/App.tsx +++ b/web-admin/src/App.tsx @@ -83,13 +83,9 @@ export default function App() { const { nodes, admin, error, refresh } = useNodes() const loadMe = useCallback(() => { - // `|| "..."` because an empty message reads as no error: api() falls back to - // res.statusText, which HTTP/2 and HTTP/3 removed, so a bodiless 502 from a - // proxy arrives as "". The check below would then take the loading branch and - // the retry button would never render. return api("/me") .then((next) => { setMe(next); setMeError("") }) - .catch((e: Error) => setMeError(e.message || "网络错误")) + .catch((e: Error) => setMeError(e.message)) }, []) useEffect(() => { loadMe() diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 93e52682..9142f49a 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -358,13 +358,49 @@ export class ApiError extends Error { } } +/** + * Every error the hub answers is one line of plain text written for the reader. + * Anything else came from something in front of it -- a proxy's error page, a + * CDN's challenge, an empty 502 -- and is described by its status instead. + */ +async function failure(res: Response): Promise { + const text = res.headers.get("content-type")?.startsWith("text/plain") ? (await res.text()).trim() : "" + if (text) return new ApiError(res.status, text) + return new ApiError( + res.status, + res.status >= 500 + ? `hub 没有正常响应(HTTP ${res.status}),检查 hub 是否在运行、反向代理是否指向它` + : `请求被拦截(HTTP ${res.status}),不是 hub 的回复,检查反向代理或 CDN 的设置`, + ) +} + +/** `fetch`, failing with a message for the reader when nothing answers at all. */ +async function send(url: string, init?: RequestInit): Promise { + try { + return await fetch(url, init) + } catch (e) { + // An abort was asked for, and the caller tells it apart by name. + if ((e as Error).name === "AbortError") throw e + throw new ApiError(0, "连不上 hub,检查网络后重试") + } +} + +/** A success from the hub is JSON; a 200 carrying HTML is a proxy's page. */ +async function json(res: Response): Promise { + try { + return await res.json() + } catch { + throw new ApiError(res.status, "收到的不是 hub 的回复,检查反向代理是否把 /api 转给了 hub") + } +} + export async function api(path: string, init?: RequestInit): Promise { - const res = await fetch(`/api${path}`, { + const res = await send(`/api${path}`, { ...init, headers: init?.body ? { "content-type": "application/json", ...init?.headers } : init?.headers, }) - if (!res.ok) throw new ApiError(res.status, (await res.text()) || res.statusText) - return res.status === 204 ? (undefined as T) : res.json() + if (!res.ok) throw await failure(res) + return res.status === 204 ? (undefined as T) : json(res) } /** @@ -392,26 +428,22 @@ export async function upload( // the last piece lands, and `offset = 0` truncates whatever an abandoned // attempt left behind, so aborting here leaves the state unchanged. if (signal?.aborted) throw new DOMException("aborted", "AbortError") - const res = await fetch(`/api${path}?offset=${offset}&total=${file.size}`, { + const res = await send(`/api${path}?offset=${offset}&total=${file.size}`, { method: "POST", headers: { "content-type": "application/octet-stream" }, body: file.slice(offset, offset + CHUNK), signal, }) - if (!res.ok) { - // A 413 never reached the hub: the proxy in front answered, and only its - // own logs record it. The message names the setting responsible. - throw new ApiError( - res.status, - res.status === 413 - ? "反向代理拒收了 4 MiB 的分片,把 nginx 的 client_max_body_size 调到 8m" - : (await res.text()) || res.statusText, - ) + // A 413 never reached the hub: the proxy in front answered, and only its + // own logs record it. The message names the setting responsible. + if (res.status === 413) { + throw new ApiError(413, "反向代理拒收了 4 MiB 的分片,把 nginx 的 client_max_body_size 调到 8m") } + if (!res.ok) throw await failure(res) last = res onProgress?.(Math.min(offset + CHUNK, file.size)) } - return last!.json() + return json(last!) } /**