From 15033ef97ec0e3e299f012905e580e45816ce293 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:23:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E6=B5=8F=E8=A7=88=E5=99=A8=E6=97=B6?= =?UTF-8?q?=E9=92=9F=E4=B8=8D=E5=87=86=E6=97=B6=E6=89=B9=E9=87=8F=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=9A=84=E5=AE=89=E8=A3=85=E5=91=BD=E4=BB=A4=E7=85=A7?= =?UTF-8?q?=E5=B8=B8=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.rs | 22 +++++++++++++++++----- web-admin/src/components/Admin.tsx | 18 +++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/api.rs b/src/api.rs index f6bcc36..f051739 100644 --- a/src/api.rs +++ b/src/api.rs @@ -974,11 +974,22 @@ pub async fn open_register(_: Admin, State(app): State, headers: HeaderM let key = random_token(); let until = (Utc::now().timestamp() + REGISTER_WINDOW).to_string(); match app.db.set("register_key", &key).and_then(|()| app.db.set("register_until", &until)) { - Ok(()) => Json(json!({"register_key": key, "register_until": until})).into_response(), + Ok(()) => Json(json!({"register_key": key, "register_left": REGISTER_WINDOW})).into_response(), Err(e) => fail(e), } } +/// Seconds the registration window has left, 0 when none is open. What the +/// panel counts down from, rather than the stored deadline: compared with the +/// browser's clock, a deadline is off by as much as that clock is. A browser +/// eight hours fast -- Windows and Linux sharing a machine in UTC+8 -- would hide +/// the command of an open window, and one eight hours slow would show it for +/// eight hours after the key stopped working. +fn register_left(app: &App) -> i64 { + let until = app.db.get("register_until").and_then(|v| v.parse::().ok()).unwrap_or(0); + (until - Utc::now().timestamp()).clamp(0, REGISTER_WINDOW) +} + /// Closes the window early, before the hour elapses. pub async fn close_register(_: Admin, State(app): State) -> Response { match app.db.set("register_key", "").and_then(|()| app.db.set("register_until", "0")) { @@ -2023,10 +2034,9 @@ pub async fn settings(_: Admin, State(app): State) -> Json { ); // Read-only here. A window is opened and closed through its own route, so the // key is always one the hub generated, and `save_settings` continues to refuse - // both names. - for key in ["register_key", "register_until"] { - out.insert(key.into(), json!(app.db.get(key).unwrap_or_default())); - } + // both stored names. + out.insert("register_key".into(), json!(app.db.get("register_key").unwrap_or_default())); + out.insert("register_left".into(), json!(register_left(&app))); crate::notify::settings(&app, &mut out); Json(Value::Object(out)) } @@ -3226,6 +3236,7 @@ mod tests { assert_eq!(open_register(Admin, State(app.clone()), panel_headers()).await.status(), StatusCode::OK); let key = app.db.get("register_key").unwrap(); + assert!(register_left(&app) > REGISTER_WINDOW - 5, "the panel counts down from the full hour"); assert_eq!(register(Some("guess"), "a").await.status(), StatusCode::FORBIDDEN); assert_eq!(register(None, "a").await.status(), StatusCode::FORBIDDEN); assert!(app.db.nodes().unwrap().is_empty()); @@ -3246,6 +3257,7 @@ mod tests { // An hour later the same key is worthless, which is what makes leaving the // window open harmless. app.db.set("register_until", &(Utc::now().timestamp() - 1).to_string()).unwrap(); + assert_eq!(register_left(&app), 0); assert_eq!(register(Some(&key), "b").await.status(), StatusCode::FORBIDDEN); // Reopened, then closed manually: the key from the open window stops diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 421e451..7ee8d22 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1229,15 +1229,24 @@ function useVersions() { } // The window lives on the hub; this reads it back and counts down, which is also -// what makes an expired one disappear from the panel without interaction. +// what makes an expired one disappear from the panel without interaction. The hub +// reports the seconds left rather than the deadline, so the countdown runs from +// the moment its answer arrives and needs this browser's clock to keep time, not +// to agree with the hub's. function useRegisterWindow() { const [key, setKey] = useState("") const [until, setUntil] = useState(0) const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)) + const begin = (key: string, left: number) => { + const at = Math.floor(Date.now() / 1000) + setKey(key) + setNow(at) + setUntil(at + left) + } useEffect(() => { api("/settings") - .then((s) => { setKey(String(s.register_key ?? "")); setUntil(Number(s.register_until ?? 0)) }) + .then((s) => begin(String(s.register_key ?? ""), Number(s.register_left ?? 0))) .catch(() => {}) const timer = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000) return () => clearInterval(timer) @@ -1248,9 +1257,8 @@ function useRegisterWindow() { left: key === "" ? 0 : Math.max(0, until - now), async open() { try { - const w = await api<{ register_key: string; register_until: string }>("/register-window", { method: "POST" }) - setKey(w.register_key) - setUntil(Number(w.register_until)) + const w = await api<{ register_key: string; register_left: number }>("/register-window", { method: "POST" }) + begin(w.register_key, w.register_left) } catch (e) { toast.error((e as Error).message) } From 03b3db184b76539e4401301ae905ece5a5e5c5ae Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:09:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=89=93=E5=BC=80=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=97=B6=E9=87=8D=E6=96=B0=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E7=AA=97=E5=8F=A3=EF=BC=8C=E8=BF=9F=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E5=9B=9E=E5=A4=8D=E4=B8=8D=E8=A6=86=E7=9B=96=E6=96=B0?= =?UTF-8?q?=E5=BC=80=E7=9A=84=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.rs | 11 ++++++++--- web-admin/src/components/Admin.tsx | 23 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/api.rs b/src/api.rs index f051739..5124d21 100644 --- a/src/api.rs +++ b/src/api.rs @@ -912,7 +912,7 @@ pub async fn agent_register( let closed = || { 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 until = register_until(&app); let Some(key) = app.db.get("register_key").filter(|k| !k.is_empty() && Utc::now().timestamp() < until) else { return closed(); @@ -986,8 +986,13 @@ pub async fn open_register(_: Admin, State(app): State, headers: HeaderM /// the command of an open window, and one eight hours slow would show it for /// eight hours after the key stopped working. fn register_left(app: &App) -> i64 { - let until = app.db.get("register_until").and_then(|v| v.parse::().ok()).unwrap_or(0); - (until - Utc::now().timestamp()).clamp(0, REGISTER_WINDOW) + (register_until(app) - Utc::now().timestamp()).clamp(0, REGISTER_WINDOW) +} + +/// The stored deadline, 0 when none was ever set. One reading for the gate in +/// `agent_register` and the panel's countdown, so the two cannot disagree. +fn register_until(app: &App) -> i64 { + app.db.get("register_until").and_then(|v| v.parse::().ok()).unwrap_or(0) } /// Closes the window early, before the hour elapses. diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 7ee8d22..9c6a76d 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1237,17 +1237,27 @@ function useRegisterWindow() { const [key, setKey] = useState("") const [until, setUntil] = useState(0) const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)) + // Advanced by every read sent and every change that lands, so an answer + // overtaken by either is dropped rather than restoring a replaced key. + const epoch = useRef(0) const begin = (key: string, left: number) => { const at = Math.floor(Date.now() / 1000) setKey(key) setNow(at) setUntil(at + left) } - - useEffect(() => { + // Also run as the dialog opens: the window may have been closed or reopened + // from another device since this page loaded, and the command shown must + // carry the key the hub holds now. + const sync = () => { + const at = ++epoch.current api("/settings") - .then((s) => begin(String(s.register_key ?? ""), Number(s.register_left ?? 0))) + .then((s) => at === epoch.current && begin(String(s.register_key ?? ""), Number(s.register_left ?? 0))) .catch(() => {}) + } + + useEffect(() => { + sync() const timer = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000) return () => clearInterval(timer) }, []) @@ -1255,9 +1265,11 @@ function useRegisterWindow() { return { key, left: key === "" ? 0 : Math.max(0, until - now), + sync, async open() { try { const w = await api<{ register_key: string; register_left: number }>("/register-window", { method: "POST" }) + epoch.current++ begin(w.register_key, w.register_left) } catch (e) { toast.error((e as Error).message) @@ -1266,6 +1278,7 @@ function useRegisterWindow() { async close() { try { await api("/register-window", { method: "DELETE" }) + epoch.current++ setKey("") setUntil(0) toast.success("注册窗口已关闭") @@ -1601,7 +1614,7 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = {/* An open window is visible from the list itself, so nobody has to remember they left one open. */} -