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
29 changes: 23 additions & 6 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i64>().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();
Expand Down Expand Up @@ -974,11 +974,27 @@ pub async fn open_register(_: Admin, State(app): State<Shared>, 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::<i64>().ok()).unwrap_or(0)
}

/// Closes the window early, before the hour elapses.
pub async fn close_register(_: Admin, State(app): State<Shared>) -> Response {
match app.db.set("register_key", "").and_then(|()| app.db.set("register_until", "0")) {
Expand Down Expand Up @@ -2023,10 +2039,9 @@ pub async fn settings(_: Admin, State(app): State<Shared>) -> Json<Value> {
);
// 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))
}
Expand Down Expand Up @@ -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());
Expand All @@ -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
Expand Down
39 changes: 30 additions & 9 deletions web-admin/src/components/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1229,35 +1229,56 @@ 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>("/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)
}, [])

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)
}
},
async close() {
try {
await api("/register-window", { method: "DELETE" })
epoch.current++
setKey("")
setUntil(0)
toast.success("注册窗口已关闭")
Expand Down Expand Up @@ -1593,7 +1614,7 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () =
</Button>
{/* An open window is visible from the list itself, so nobody has to
remember they left one open. */}
<Button variant="outline" disabled={!!refusal} onClick={() => setRegistering(true)}>
<Button variant="outline" disabled={!!refusal} onClick={() => { reg.sync(); setRegistering(true) }}>
<Server /> 批量添加{reg.left > 0 && ` · ${Math.ceil(reg.left / 60)} 分`}
</Button>
<Button disabled={!!refusal} onClick={() => setCreating(true)}>
Expand Down Expand Up @@ -2584,7 +2605,7 @@ function Themes() {
)
}

type Settings = Record<string, string | boolean>
type Settings = Record<string, string | boolean | number>

// Two pages write settings, and each loads only what it displays.
function useSettings() {
Expand Down
Loading