diff --git a/src/api.rs b/src/api.rs index f6bcc36..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(); @@ -974,11 +974,27 @@ 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 { + (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. 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 +2039,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 +3241,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 +3262,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..9c6a76d 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1229,16 +1229,35 @@ 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)) - - useEffect(() => { + // 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) + } + // 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) => { setKey(String(s.register_key ?? "")); setUntil(Number(s.register_until ?? 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) }, []) @@ -1246,11 +1265,12 @@ function useRegisterWindow() { return { key, left: key === "" ? 0 : Math.max(0, until - now), + sync, 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" }) + epoch.current++ + begin(w.register_key, w.register_left) } catch (e) { toast.error((e as Error).message) } @@ -1258,6 +1278,7 @@ function useRegisterWindow() { async close() { try { await api("/register-window", { method: "DELETE" }) + epoch.current++ setKey("") setUntil(0) toast.success("注册窗口已关闭") @@ -1593,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. */} -