Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions crates/wb-switch-core/src/modules/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,28 @@ fn upsert_account_in(accounts: &mut Vec<Value>, updated: &Value) {
accounts.push(updated.clone());
}

/// WorkBuddy 5.6 加密信封凭据的可读错误:`access_token` 为信封形态时返回提示文案。
///
/// 信封 token 无法解出明文,不能用于签到 / 积分 / 旅行等 API 请求;此前会经
/// [`build_auth_headers`] 的 `unwrap_or_default()` 兜底成空 `Bearer`,被网关
/// 401 后再把 HTML 错误页原样回显到界面(issue #94)。需要账号身份的请求
/// 发出前应先用本函数短路。
pub fn envelope_token_error(account: &Value) -> Option<String> {
if is_envelope(account, "access_token") {
return Some(
"该账号凭据为 WorkBuddy 加密信封态,无法直接调用签到 / 积分 / Token 统计等接口;\
切换功能不受影响,如需上述功能请删除该账号后改用「OAuth 扫码添加」获取明文凭据。"
.to_string(),
);
}
None
}

/// 构造与官方对齐的请求头。对照 server.py `build_auth_headers`。
///
/// 注意:`access_token` 为加密信封对象时 `get_str` 取不到值,这里会产出空
/// `Bearer`——调用方必须先用 [`envelope_token_error`] 拦截,不要把空凭据
/// 真的发出去(issue #94)。
pub fn build_auth_headers(account: &Value) -> HashMap<String, String> {
let mut headers = HashMap::new();
headers.insert(
Expand Down Expand Up @@ -306,6 +327,28 @@ mod tests {
use super::*;
use serde_json::json;

/// 回归 issue #94:信封凭据要能被识别并给出可读错误,明文/缺字段不误报。
#[test]
fn envelope_token_error_only_fires_on_envelope_access_token() {
let envelope = json!({
"id": "a1",
"access_token": {"$wbEncrypted": true, "envelope": "…"},
"refresh_token": {"$wbEncrypted": true, "envelope": "…"},
});
let err = envelope_token_error(&envelope).expect("信封 access_token 应返回错误");
assert!(err.contains("信封"), "错误文案应可读:{err}");
assert!(err.contains("OAuth"), "应给出扫码重新添加的指引:{err}");

let plain = json!({"id": "a2", "access_token": "SECRET", "refresh_token": "R"});
assert!(envelope_token_error(&plain).is_none(), "明文凭据不应报错");

let legacy = json!({"id": "a3"});
assert!(
envelope_token_error(&legacy).is_none(),
"缺 access_token 的历史账号不在此拦截(保持既有行为)"
);
}

#[test]
fn account_meta_strips_tokens() {
let acc = json!({
Expand Down
22 changes: 21 additions & 1 deletion crates/wb-switch-core/src/modules/checkin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use std::sync::{Mutex, OnceLock};
use std::time::Duration;

use crate::modules::account::{
account_display_name, build_auth_headers, load_accounts, variant_of,
account_display_name, build_auth_headers, envelope_token_error, load_accounts, variant_of,
};
use crate::modules::config::{
add_checkin_log, http_request, is_route_missing, load_checkin_config, load_checkin_logs,
Expand Down Expand Up @@ -135,6 +135,10 @@ fn skips_refresh_before_retry(variant: WbVariant, resp: &Value) -> bool {

/// 发单次签到请求;遇到未授权且存在 refresh token 时刷新一次并重试。
async fn checkin_request_once(path: &str, account: &Value, variant: WbVariant) -> Value {
// 加密信封凭据短路:不发空 Bearer,直接给出可读错误(issue #94)。
if let Some(err) = envelope_token_error(account) {
return json!({"code": -2, "message": err});
}
let url = format!("{}{path}", variant.api_endpoint());
let headers = build_auth_headers(account);
let mut resp = http_request(&url, "POST", Some(json!({})), Some(&headers)).await;
Expand Down Expand Up @@ -830,6 +834,22 @@ async fn checkin_all_rows(accounts: Vec<Value>, cfg: &Value) -> Vec<Value> {
mod tests {
use super::*;

/// 回归 issue #94:信封凭据的签到请求应在入口短路并返回可读错误,
/// 不发出空 Bearer(此前会被网关 401 后把 HTML 原样回显)。
#[tokio::test]
async fn envelope_credentials_short_circuit_before_request() {
let account = json!({
"id": "envelope-only",
"variant": "cn",
"access_token": {"$wbEncrypted": true, "envelope": "…"},
"refresh_token": {"$wbEncrypted": true, "envelope": "…"},
});
let resp = checkin_request_once("/whatever", &account, WbVariant::Cn).await;
assert_eq!(resp["code"], -2);
let msg = resp["message"].as_str().expect("message 应为字符串");
assert!(msg.contains("信封"), "错误文案应可读:{msg}");
}

#[tokio::test]
async fn excluded_account_never_starts_passive_operation() {
let account = json!({"id": "excluded", "variant": "cn"});
Expand Down
45 changes: 44 additions & 1 deletion crates/wb-switch-core/src/modules/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@ pub async fn http_request_with_proxy(
serde_json::from_str(&text).unwrap_or_else(|_| {
json!({
"code": status.as_u16(),
"message": text.chars().take(500).collect::<String>(),
"message": normalize_error_body(&text),
})
})
}
Expand All @@ -1032,6 +1032,25 @@ pub async fn http_request_with_proxy(
}
}

/// 非 JSON 错误响应体归一化:网关(openresty / APISIX 等)的 401/5xx 常返回
/// 整页 HTML,原样截断会把 `<html>…` 整段塞进通知与界面卡片(issue #94)。
/// HTML 提取 `<title>` 作为可读信息;其余保持原有的 500 字符截断。
fn normalize_error_body(text: &str) -> String {
if text.trim_start().starts_with('<') {
let title = text
.split_once("<title>")
.and_then(|(_, rest)| rest.split_once("</title>"))
.map(|(title, _)| title.trim())
.unwrap_or_default();
return if title.is_empty() {
"服务端返回 HTML 错误页(无标题)".to_string()
} else {
format!("服务端返回 HTML 错误页:{title}")
};
}
text.chars().take(500).collect::<String>()
}

/// 通用 HTTP 请求,返回原始响应(状态码 + 响应头 + 响应体),可选是否跟随重定向。
///
/// 供需要读取响应头(如 302 的 `Location`)或自行处理非 JSON 响应的场景使用;
Expand Down Expand Up @@ -1104,6 +1123,30 @@ pub async fn http_request_raw(
mod tests {
use super::*;

/// 回归 issue #94:网关 401 返回的整页 HTML 要归一化为可读信息,
/// 不能把 `<html>…` 原样塞进通知与界面卡片。
#[test]
fn normalize_error_body_extracts_html_title() {
let html = "<html>\n<head><title>401 Authorization Required</title></head>\n\
<body>\n<center><h1>401 Authorization Required</h1></center>\n\
<hr><center>openresty</center>\n</body>\n</html>\n";
assert_eq!(
normalize_error_body(html),
"服务端返回 HTML 错误页:401 Authorization Required"
);

assert_eq!(
normalize_error_body("<!DOCTYPE html><html><body>boom</body></html>"),
"服务端返回 HTML 错误页(无标题)"
);

// 非 HTML 错误体保持原有截断行为。
let plain = "plain gateway error";
assert_eq!(normalize_error_body(plain), plain);
let long = "x".repeat(600);
assert_eq!(normalize_error_body(&long).chars().count(), 500);
}

fn local_timestamp_ms(year: i32, month: u32, day: u32, hour: u32) -> i64 {
Local
.with_ymd_and_hms(year, month, day, hour, 0, 0)
Expand Down
23 changes: 22 additions & 1 deletion crates/wb-switch-core/src/modules/credits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
use serde_json::{json, Value};
use std::collections::HashSet;

use crate::modules::account::{account_display_name, build_auth_headers, variant_of};
use crate::modules::account::{
account_display_name, build_auth_headers, envelope_token_error, variant_of,
};
use crate::modules::config::{
http_request, is_route_missing, load_checkin_config, now_ms, CHECKIN_API_PREFIX,
WORKBUDDY_API_ENDPOINT,
Expand Down Expand Up @@ -368,6 +370,10 @@ fn is_transport_error(response: &Value) -> bool {
/// 新鲜,遇到未授权时使用 refresh token 重试一次。调用方只拿到上游 JSON,
/// 不会把认证字段拼进返回值。
pub async fn authenticated_post(account: &Value, url: &str, body: Value) -> Value {
// 加密信封凭据短路:不发空 Bearer,直接给出可读错误(issue #94)。
if let Some(err) = envelope_token_error(account) {
return json!({"code": -2, "message": err});
}
let config = load_checkin_config();
let mut working_account = ensure_fresh_token(account.clone(), &config).await;
let mut response = post_with_account(&working_account, url, body.clone()).await;
Expand Down Expand Up @@ -831,6 +837,21 @@ fn legacy_credit_result(account: &Value, response: &Value, now: i64) -> Value {
mod tests {
use super::*;

/// 回归 issue #94:信封凭据在 authenticated_post 入口短路,不发空 Bearer,
/// 也不会进入刷新重试链路。
#[tokio::test]
async fn envelope_credentials_short_circuit_before_request() {
let account = json!({
"id": "envelope-only",
"access_token": {"$wbEncrypted": true, "envelope": "…"},
"refresh_token": {"$wbEncrypted": true, "envelope": "…"},
});
let resp = authenticated_post(&account, "https://example.invalid/api", json!({})).await;
assert_eq!(resp["code"], -2);
let msg = resp["message"].as_str().expect("message 应为字符串");
assert!(msg.contains("信封"), "错误文案应可读:{msg}");
}

#[test]
fn parses_cockpit_resource_shape_and_marks_expiry() {
let now = 1_800_000_000_000_i64;
Expand Down
19 changes: 19 additions & 0 deletions crates/wb-switch-core/src/modules/travel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ fn is_unauthorized(resp: &Value) -> bool {

/// 发旅行接口请求;遇到未授权且存在 refresh token 时刷新一次并重试。
async fn travel_request(path: &str, method: &str, body: Option<Value>, account: &Value) -> Value {
// 加密信封凭据短路:不发空 Bearer,直接给出可读错误(issue #94)。
if let Some(err) = account::envelope_token_error(account) {
return json!({"code": -2, "message": err});
}
let url = format!("{WORKBUDDY_API_ENDPOINT}{path}");
let headers = build_travel_headers(account);
let mut resp = http_request(&url, method, body.clone(), Some(&headers)).await;
Expand Down Expand Up @@ -1050,6 +1054,21 @@ pub fn travel_display(account_id: &str) -> Value {
mod tests {
use super::*;

/// 回归 issue #94:信封凭据的旅行请求应在入口短路并返回可读错误,
/// 不发出空 Bearer。
#[tokio::test]
async fn envelope_credentials_short_circuit_before_request() {
let account = json!({
"id": "envelope-only",
"access_token": {"$wbEncrypted": true, "envelope": "…"},
"refresh_token": {"$wbEncrypted": true, "envelope": "…"},
});
let resp = travel_request("/whatever", "POST", Some(json!({})), &account).await;
assert_eq!(resp["code"], -2);
let msg = resp["message"].as_str().expect("message 应为字符串");
assert!(msg.contains("信封"), "错误文案应可读:{msg}");
}

#[test]
fn retryable_skips_are_not_terminal() {
assert!(is_retryable_skip(Some("no-buddy")));
Expand Down
Loading