From 2b223ee292faad48147723ddeac7dc02e84d47f8 Mon Sep 17 00:00:00 2001 From: BaranDev Date: Wed, 28 Oct 2026 10:39:05 +0200 Subject: [PATCH 1/3] Admin status: people who tried it, their profiles and the cards they made Counts each GitHub login a person's browser or GitHub's image proxy got a card for (bots, the owner, the example logins and the admin page's own previews are left out), with the card addresses they used, and counts the decorations made in the builder or found in a README. Kept in people.json, saved hourly and at shutdown; users saved before the counting began are listed from the start. /admin/health gains a people section and the admin status page shows it. --- site/components/Health.module.css | 89 ++++++++++++++++ site/components/Health.tsx | 123 ++++++++++++++++++++++ site/i18n/en/status.ts | 29 +++++ src/lib.rs | 1 + src/main.rs | 7 ++ src/people.rs | 169 ++++++++++++++++++++++++++++++ src/server/admin.rs | 31 +++++- src/server/cards.rs | 16 ++- src/server/mod.rs | 41 +++++++- src/store.rs | 23 +++- 10 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 src/people.rs diff --git a/site/components/Health.module.css b/site/components/Health.module.css index d0df0fb7..6645b8fb 100644 --- a/site/components/Health.module.css +++ b/site/components/Health.module.css @@ -403,3 +403,92 @@ align-items: flex-start; } } + +.counts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2.5rem; +} + +.people { + display: grid; + gap: 1rem; + list-style: none; + padding: 0; +} + +.person { + display: grid; + gap: 0.75rem; + padding: 1rem 1.25rem; + border: 1px solid var(--line); + border-radius: 12px; +} + +.personHead { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.who { + display: inline-flex; + align-items: center; + gap: 0.75rem; + color: var(--gold); + font-size: 1.05rem; + overflow-wrap: anywhere; +} + +.who img { + flex: none; + border-radius: 50%; +} + +.made summary { + cursor: pointer; + color: var(--muted); + font-size: 0.95rem; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); + gap: 1rem; + margin-top: 0.75rem; + list-style: none; + padding: 0; +} + +.cards li { + display: grid; + gap: 0.35rem; + align-content: start; +} + +.cards img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 5 / 2; + object-fit: contain; + image-rendering: pixelated; + border-radius: 6px; + background: rgba(242, 230, 201, 0.04); +} + +.cards code { + font-size: 0.8rem; + color: var(--muted); + overflow-wrap: anywhere; +} + +.where { + font-family: var(--tiny); + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--faint); +} diff --git a/site/components/Health.tsx b/site/components/Health.tsx index 59ce15a4..5bf314c0 100644 --- a/site/components/Health.tsx +++ b/site/components/Health.tsx @@ -32,6 +32,34 @@ type HealthData = { github: { remaining: number | null; limit: number; resets_at: number | null; paused_secs: number }; new_users: { this_hour: number; per_hour: number; oldest_frees_in_secs: number | null }; waiting: { new: number; new_capacity: number; refresh: number; refresh_capacity: number }; + people: PeopleData; +}; + +type PeopleData = { + since: number; + tried: number; + in_readme: number; + list: Person[]; + no_login: { decorations: number; in_readme: number }; +}; + +/** first is 0 for someone saved before the counting began. */ +type Person = { + login: string; + first: number; + last: number; + cards: { address: string; first: number; last: number; readme: boolean }[]; + stats: { + level: number; + class: string; + commits: number; + stars: number; + followers: number; + current_streak: number; + best_streak: number; + languages: [string, number][]; + fetched_at: number; + } | null; }; type Minute = { minute: number; cache: number; drawn: number; busy: number; error: number; other: number }; @@ -386,12 +414,107 @@ function Panels({ d, now }: { d: HealthData; now: number }) { + {d.people && } ); } +const P = H.people; +const day = (secs: number) => H.utc(new Date(secs * 1000).toISOString().slice(0, 16).replace("T", " ")); + +/** Everyone who tried it, newest first: their GitHub profile, the stats saved for them, and the cards they made. */ +function People({ p }: { p: PeopleData }) { + return ( +
+
+

{P.title}

+ {P.since(day(p.since))} +
+
+

+ {num(p.tried)} + {P.tried} +

+

+ {num(p.in_readme)} + {P.inReadme(p.in_readme)} +

+

+ {num(p.no_login.decorations)} + {P.noLogin(p.no_login.decorations)} +

+
+
+

{rich(P.noLoginNote(num(p.no_login.in_readme)), { strong: (c) => {c} })}

+

{P.rules}

+ {p.list.length < p.tried &&

{P.shown(num(p.list.length), num(p.tried))}

} +
+ {p.list.length ? ( +
    + {p.list.map((u) => ( + + ))} +
+ ) : ( +

{P.empty}

+ )} +
+ ); +} + +function PersonRow({ u }: { u: Person }) { + const st = u.stats; + const readme = u.cards.some((c) => c.readme); + const profile = `https://github.com/${encodeURIComponent(u.login)}`; + // addresses come from requests: only same-site paths become images and links + const cards = u.cards.filter((c) => c.address.startsWith("/") && !c.address.startsWith("//")); + return ( +
  • +
    + + + {u.login} + + {u.first > 0 && ( + + {readme ? P.readme : P.tried1} + + )} +
    +
    + {st ? ( +

    + {P.level(st.level, st.class)}. {P.facts(num(st.commits), num(st.stars), num(st.followers))}.{" "} + {P.streak(st.current_streak, st.best_streak)}. + {st.languages.length > 0 && <> {st.languages.slice(0, 3).map(([l]) => l).join(", ")}.} {P.fetched(day(st.fetched_at))}. +

    + ) : ( +

    {P.noStats}

    + )} +

    {u.first > 0 ? P.seen(day(u.first), day(u.last)) : P.before(u.last > 0 ? day(u.last) : null)}

    +
    + {cards.length > 0 && ( +
    + {P.cardsTitle(cards.length)} +
      + {cards.map((c) => ( +
    • + + {P.cardAlt(c.address)} + + {c.address} + {c.readme ? P.inReadmeShort : P.inBrowser} +
    • + ))} +
    +
    + )} +
  • + ); +} + const memTone = (m: { used: number | null; limit: number | null }): Tone => m.used && m.limit ? toneOfUsed(m.used / m.limit) : "good"; diff --git a/site/i18n/en/status.ts b/site/i18n/en/status.ts index fe9f405b..49cee23d 100644 --- a/site/i18n/en/status.ts +++ b/site/i18n/en/status.ts @@ -145,6 +145,35 @@ export const health = { now: "now", note: "Requests Cloudflare answers from its own cache never reach the server, so they are not counted here.", }, + people: { + aria: "People who used questlog", + title: "People", + since: (date: string) => `Counted since ${date}`, + tried: "tried it with their GitHub login", + inReadme: (n: number) => (n === 1 ? "has a card in a README" : "have a card in a README"), + noLogin: (n: number) => (n === 1 ? "decoration made without a login" : "decorations made without a login"), + noLoginNote: (readme: string) => + `Banners made in the builder or found in a README, each counted once, with no names. ${readme} of them are in a README.`, + rules: + "Counted when a browser or GitHub's image proxy gets one of their cards. Bots, the example logins, your own login and this page's previews are left out.", + empty: "Nobody has tried it yet. The first person to make a card shows up here.", + shown: (shown: string, all: string) => `The ${shown} most recent of ${all} people are listed.`, + profile: (login: string) => `${login} on GitHub`, + readme: "In a README", + tried1: "Only tried", + level: (level: number, cls: string) => `Level ${level} ${cls}`, + facts: (commits: string, stars: string, followers: string) => `${commits} commits, ${stars} stars, ${followers} followers`, + streak: (now: number, best: number) => `Streak ${now} ${now === 1 ? "day" : "days"}, best ${best}`, + fetched: (when: string) => `GitHub data from ${when}`, + noStats: "Their saved GitHub data was removed after 30 days without a request.", + seen: (first: string, last: string) => `First seen ${first}, last seen ${last}.`, + before: (last: string | null) => + `Saved before counting began, so their first visit is not known.${last ? ` Last seen ${last}.` : " Not seen since."}`, + cardsTitle: (n: number) => (n === 1 ? "1 card they made" : `${n} cards they made`), + cardAlt: (address: string) => `The card at ${address}`, + inReadmeShort: "README", + inBrowser: "browser", + }, log: { aria: "Recent log lines", title: "Recent log", diff --git a/src/lib.rs b/src/lib.rs index b70128eb..4495194b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cache; pub mod canvas; pub mod card; pub mod params; +pub mod people; pub mod dates; pub mod decorations; pub mod encode; diff --git a/src/main.rs b/src/main.rs index d6cbd524..095314bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,9 @@ async fn main() -> Result<(), questlog::BoxError> { Ok(n) => questlog::info!("cache", "hourly sweep removed {n} users nobody asked for in a long time"), Err(e) => questlog::error!("cache", "hourly sweep of saved users failed: {e}"), } + if let Err(e) = sweeper.people.save() { + questlog::error!("people", "could not save the people list: {e}"); + } } }); @@ -42,6 +45,7 @@ async fn main() -> Result<(), questlog::BoxError> { .with_disk(std::path::Path::new(&data).join("renders"), env!("RENDER_VERSION")); let access = questlog::server::Access::from_env(); let admin = if access.is_some() { "admin pages open to ADMIN_EMAILS through Cloudflare Access" } else { "admin pages local only" }; + let people = store.clone(); let app = Arc::new(App::with_cache(Arc::new(Assets::embedded()?), store, queue, cache).with_access(access)); let trim = app.clone(); tokio::spawn(async move { @@ -75,6 +79,9 @@ async fn main() -> Result<(), questlog::BoxError> { ); let service = router(app).into_make_service_with_connect_info::(); axum::serve(listener, service).with_graceful_shutdown(shutdown()).await?; + if let Err(e) = people.people.save() { + questlog::error!("people", "could not save the people list at shutdown: {e}"); + } Ok(()) } diff --git a/src/people.rs b/src/people.rs new file mode 100644 index 00000000..2d5af1b8 --- /dev/null +++ b/src/people.rs @@ -0,0 +1,169 @@ +//! Who has used questlog, for the admin page: the GitHub logins people asked cards for, the card addresses each +//! one made, and the decorations made without a login. Only a person's browser or GitHub's image proxy counts; +//! bots, the owner and the example logins don't, and no IPs are kept. Kept in memory and written to people.json +//! by the hourly sweep and at shutdown. +use crate::BoxError; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// The owner and the logins the site and its README use as examples. +const NOT_PEOPLE: [&str; 4] = ["barandev", "octocat", "you", "demo-adventurer"]; +// ponytail: past these caps new entries are dropped (a person's last visit still moves); about 100 MB at worst +const CARDS_EACH: usize = 50; +const MAX_PEOPLE: usize = 20_000; +const MAX_DECORATIONS: usize = 20_000; +const ADDRESS_CHARS: usize = 300; + +/// One card or decoration address: when it was first and last asked for, and whether it is in a README. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Use { + pub first: u64, + pub last: u64, + pub readme: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Person { + /// 0 when they were saved before this tracking began, so their first visit is not known. + pub first: u64, + pub last: u64, + pub cards: HashMap, +} + +#[derive(Default, Serialize, Deserialize)] +struct Seen { + /// When the tracking began. + since: u64, + people: HashMap, + decorations: HashMap, +} + +pub struct People { + path: PathBuf, + seen: Mutex, +} + +fn touch(uses: &mut HashMap, address: &str, readme: bool, now: u64, cap: usize) { + let address: String = address.chars().take(ADDRESS_CHARS).collect(); + let room = uses.len() < cap; + match uses.get_mut(&address) { + Some(u) => { + u.last = now; + u.readme |= readme; + } + None if room => { + uses.insert(address, Use { first: now, last: now, readme }); + } + None => {} + } +} + +impl People { + /// Loads `/people.json`. The first time there is none, it starts from the logins `saved` returns (users + /// saved before the tracking), so nobody who already tried it is missing. An unreadable file is kept aside as + /// people.json.bad rather than overwritten. + pub fn open(dir: &Path, saved: impl FnOnce() -> Vec, now: u64) -> People { + let path = dir.join("people.json"); + let seen = match std::fs::read(&path) { + Ok(b) => serde_json::from_slice(&b).unwrap_or_else(|e| { + crate::error!("people", "could not read {}: {e}; kept it as people.json.bad and started over", path.display()); + let _ = std::fs::rename(&path, path.with_extension("json.bad")); + Seen { since: now, ..Default::default() } + }), + Err(_) => Seen { + since: now, + people: saved().into_iter().filter(|l| !NOT_PEOPLE.contains(&l.as_str())).map(|l| (l, Person::default())).collect(), + decorations: HashMap::new(), + }, + }; + People { path, seen: Mutex::new(seen) } + } + + /// A person got the card for `login` at `address`; `readme` when GitHub's image proxy asked (it is in a README). + pub fn card(&self, login: &str, address: &str, readme: bool, now: u64) { + if NOT_PEOPLE.contains(&login) { + return; + } + let mut s = self.seen.lock().unwrap(); + if s.people.len() >= MAX_PEOPLE && !s.people.contains_key(login) { + return; + } + let p = s.people.entry(login.to_string()).or_insert_with(|| Person { first: now, ..Default::default() }); + p.last = now; + touch(&mut p.cards, address, readme, now, CARDS_EACH); + } + + /// A decoration made without a login: from the builder, or in a README when `readme`. + pub fn decoration(&self, address: &str, readme: bool, now: u64) { + touch(&mut self.seen.lock().unwrap().decorations, address, readme, now, MAX_DECORATIONS); + } + + /// For the admin page: when the tracking began, the `shown` most recent people (newest first), how many tried + /// it and how many have a card in a README, and the decorations (made, in a README). + pub fn report(&self, shown: usize) -> (u64, Vec<(String, Person)>, usize, usize, (usize, usize)) { + let s = self.seen.lock().unwrap(); + let in_readme = s.people.values().filter(|p| p.cards.values().any(|u| u.readme)).count(); + let decorations = (s.decorations.len(), s.decorations.values().filter(|u| u.readme).count()); + let mut list: Vec<(String, Person)> = s.people.iter().map(|(l, p)| (l.clone(), p.clone())).collect(); + list.sort_by(|a, b| b.1.last.cmp(&a.1.last).then_with(|| a.0.cmp(&b.0))); + list.truncate(shown); + (s.since, list, s.people.len(), in_readme, decorations) + } + + pub fn save(&self) -> Result<(), BoxError> { + let bytes = serde_json::to_vec(&*self.seen.lock().unwrap())?; + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, &self.path)?; // atomic: a crash mid-write never leaves half a file + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::temp_dir; + + #[test] + fn counts_people_and_their_cards_and_survives_a_restart() { + let dir = temp_dir("people"); + std::fs::create_dir_all(&dir).unwrap(); + let p = People::open(&dir, || vec!["sudi".into(), "barandev".into()], 100); + p.card("ana", "/ana/stats", false, 200); + p.card("ana", "/ana/stats", true, 300); + p.card("ana", "/ana/streak/forest", false, 400); + p.card("barandev", "/barandev/stats", true, 500); // the owner is not counted + p.card("octocat", "/octocat/stats", true, 500); // nor an example login + p.decoration("/decorations/reading-nook?title=Hi", false, 600); + p.decoration("/decorations/reading-nook?title=Hi", true, 700); + p.save().unwrap(); + + let (since, list, tried, in_readme, decorations) = People::open(&dir, Vec::new, 999).report(10); + assert_eq!((since, tried, in_readme, decorations), (100, 2, 1, (1, 1))); + let logins: Vec<&str> = list.iter().map(|(l, _)| l.as_str()).collect(); + assert_eq!(logins, ["ana", "sudi"], "newest first"); + let ana = &list[0].1; + assert_eq!((ana.first, ana.last, ana.cards.len()), (200, 400, 2)); + assert_eq!(ana.cards["/ana/stats"], Use { first: 200, last: 300, readme: true }); + assert_eq!(list[1].1.first, 0, "saved before the tracking"); + } + + #[test] + fn caps_cards_per_person_and_keeps_a_bad_file() { + let dir = temp_dir("people-cap"); + std::fs::create_dir_all(&dir).unwrap(); + let p = People::open(&dir, Vec::new, 0); + for i in 0..CARDS_EACH + 5 { + p.card("ana", &format!("/ana/stats?n={i}"), false, i as u64); + } + let (_, list, ..) = p.report(1); + assert_eq!((list[0].1.cards.len(), list[0].1.last), (CARDS_EACH, (CARDS_EACH + 4) as u64)); + + std::fs::write(dir.join("people.json"), b"not json").unwrap(); + let (since, _, tried, ..) = People::open(&dir, Vec::new, 5).report(1); + assert_eq!((since, tried), (5, 0)); + assert!(dir.join("people.json.bad").exists()); + } +} diff --git a/src/server/admin.rs b/src/server/admin.rs index cf34fcad..b2526ddb 100644 --- a/src/server/admin.rs +++ b/src/server/admin.rs @@ -14,6 +14,8 @@ use tokio::sync::RwLock; /// Never kept by a browser or an edge: it is behind a login. const ADMIN: &str = "private, no-store"; +/// People listed on the admin page, the most recent first. +const PEOPLE_SHOWN: usize = 200; /// Access's signing keys are kept this long, and fetched again at most this often for an unknown key id. const KEYS_FOR: Duration = Duration::from_secs(3600); const REFETCH_AFTER: Duration = Duration::from_secs(60); @@ -157,7 +159,34 @@ pub(super) async fn admin_health(State(app): State>>, req } let a = app.clone(); let disk = tokio::task::spawn_blocking(move || (a.cache.disk_bytes(), a.store.saved_count())).await.unwrap_or((None, None)); - private(full_health(&app, disk).into_response()) + let a = app.clone(); + let people = tokio::task::spawn_blocking(move || people(&a.store)).await.unwrap_or_default(); + let Json(mut v) = full_health(&app, disk); + v["people"] = people; + private(Json(v).into_response()) +} + +/// Everyone who tried it, newest first, with the cards they made and the GitHub stats saved for them (null once +/// the 30-day sweep removed them); and the decorations made without a login, as numbers only. +fn people(store: &Store) -> serde_json::Value { + let (since, list, tried, in_readme, (decorations, decorations_in_readme)) = store.people.report(PEOPLE_SHOWN); + let list: Vec = list + .into_iter() + .map(|(login, p)| { + let mut cards: Vec<_> = p.cards.into_iter().collect(); + cards.sort_by(|a, b| b.1.last.cmp(&a.1.last).then_with(|| a.0.cmp(&b.0))); + let cards: Vec<_> = cards + .into_iter() + .map(|(address, u)| serde_json::json!({"address": address, "first": u.first, "last": u.last, "readme": u.readme})) + .collect(); + let stats = store.saved(&login); + serde_json::json!({"login": login, "first": p.first, "last": p.last, "cards": cards, "stats": stats}) + }) + .collect(); + serde_json::json!({ + "since": since, "tried": tried, "in_readme": in_readme, "list": list, + "no_login": {"decorations": decorations, "in_readme": decorations_in_readme}, + }) } fn private(mut r: Response) -> Response { diff --git a/src/server/cards.rs b/src/server/cards.rs index 0eebb2c6..283cc91a 100644 --- a/src/server/cards.rs +++ b/src/server/cards.rs @@ -7,6 +7,7 @@ pub(super) async fn card_at( State(app): State>>, Path(parts): Path>, Query(mut q): Query>, + uri: Uri, headers: HeaderMap, ) -> Response { let mut parts = parts.into_iter(); @@ -19,22 +20,24 @@ pub(super) async fn card_at( q.insert(key.into(), v); } } - serve_card(app, widget, q, headers).await + serve_card(app, widget, q, &uri, headers).await } pub(super) async fn card( State(app): State>>, Path(path): Path, Query(q): Query>, + uri: Uri, headers: HeaderMap, ) -> Response { - serve_card(app, path, q, headers).await + serve_card(app, path, q, &uri, headers).await } pub(super) async fn serve_card( app: Arc>, path: String, q: HashMap, + uri: &Uri, headers: HeaderMap, ) -> Response { let Some(w) = Widget::from_path(&path) else { return (StatusCode::NOT_FOUND, "not found").into_response() }; @@ -49,6 +52,9 @@ pub(super) async fn serve_card( if !fresh { app.queue.refresh(&req.user); // its own lane: never counts against an IP or the new-login cap } + if let Some(readme) = person_asking(&headers) { + app.store.people.card(&req.user, &uri.to_string(), readme, now); + } return draw(&app, req, stats, fresh, ip).await; } if app.store.is_unknown(&req.user, now) { @@ -290,6 +296,7 @@ pub(super) async fn decoration( State(app): State>>, Path(name): Path, Query(q): Query>, + uri: Uri, headers: HeaderMap, ) -> Response { let ip = client_ip(&headers); @@ -304,6 +311,11 @@ pub(super) async fn decoration( names.sort(); return error(format!("Unknown decoration '{}' (use {})", params::clip(&name), names.join(", "))).await; } + // the site's own pages show decorations too: only the builder and READMEs are someone making one + let from_builder = headers.get(header::REFERER).and_then(|v| v.to_str().ok()).is_some_and(|r| r.contains("/builder")); + if let Some(readme) = person_asking(&headers).filter(|&readme| readme || from_builder) { + app.store.people.decoration(&uri.to_string(), readme, now_secs()); + } let key = cache::key(&["decoration", &name, &format!("{opts:?}")]); let assets = app.assets.clone(); render_image(&app, key, Widget::Stats, ip, DEMO, "card", move || { diff --git a/src/server/mod.rs b/src/server/mod.rs index d12d136f..d519f13f 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -20,7 +20,7 @@ use crate::stats::UserStats; use crate::store::Store; use crate::widgets::{self, Look, Widget}; use axum::extract::{Path, Query, State}; -use axum::http::{HeaderMap, HeaderName, StatusCode, header}; +use axum::http::{HeaderMap, HeaderName, StatusCode, Uri, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::{Json, Router}; @@ -221,6 +221,17 @@ fn client_kind(ua: &str) -> String { } } +/// Whether a person asked, for the admin page's people: Some(true) for GitHub's image proxy (the card is in a +/// README), Some(false) for a browser, None for bots and scripts, and for the admin page's own previews. +fn person_asking(h: &HeaderMap) -> Option { + let hv = |k: header::HeaderName| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + if hv(header::REFERER).contains("/admin") { + return None; + } + let client = client_kind(&hv(header::USER_AGENT)); + if client == "GitHub image proxy" { Some(true) } else { client.starts_with("browser").then_some(false) } +} + /// A status line every 15 minutes, so the log shows the service's state without opening /health. pub fn log_status(app: &App) { let (entries, bytes, hits, misses) = app.cache.stats(); @@ -522,6 +533,34 @@ mod tests { assert!(!scenes.contains(&serde_json::json!("_shared")), "shared art is not a scene"); } + #[tokio::test] + async fn admin_health_lists_the_people_who_used_it() { + let (base, app) = serve(QueueConfig { workers: 0, ..Default::default() }).await; + app.store.put(derive(&RawUser { login: "ana".into(), followers: 7, ..Default::default() }, 0, now_secs())).unwrap(); + let browser = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/140.0"; + let asks: [(&str, &[(&str, &str)]); 7] = [ + ("/ana/stats/forest", &[("user-agent", browser)]), + ("/streak?user=ana", &[("user-agent", "github-camo (abc)")]), + ("/ana/languages", &[("user-agent", "Mozilla/5.0 (compatible; Googlebot/2.1)")]), // a bot + ("/ana/streak", &[("user-agent", browser), ("referer", "https://questlog.cc/admin/status")]), // the admin page + ("/decorations/reading-nook?title=Hi", &[("user-agent", browser), ("referer", "https://questlog.cc/builder")]), + ("/decorations/reading-nook", &[("user-agent", browser), ("referer", "https://questlog.cc/wiki")]), // the site's own page + ("/decorations/workbench", &[("user-agent", "github-camo (abc)")]), + ]; + for (path, headers) in asks { + get(&format!("{base}{path}"), headers).await; + } + let v: serde_json::Value = reqwest::get(format!("{base}/admin/health")).await.unwrap().json().await.unwrap(); + let p = &v["people"]; + assert_eq!((p["tried"].as_u64(), p["in_readme"].as_u64()), (Some(1), Some(1)), "{p}"); + assert_eq!(p["no_login"], serde_json::json!({"decorations": 2, "in_readme": 1})); + let ana = &p["list"][0]; + assert_eq!((ana["login"].as_str(), ana["stats"]["followers"].as_u64()), (Some("ana"), Some(7))); + let cards: Vec<(&str, bool)> = ana["cards"].as_array().unwrap().iter().map(|c| (c["address"].as_str().unwrap(), c["readme"] == true)).collect(); + assert_eq!(cards.len(), 2, "{cards:?}"); + assert!(cards.contains(&("/ana/stats/forest", false)) && cards.contains(&("/streak?user=ana", true)), "{cards:?}"); + } + #[tokio::test] async fn hostile_text_renders_fast() { let (base, app) = serve(QueueConfig { workers: 0, ..Default::default() }).await; diff --git a/src/store.rs b/src/store.rs index 3796034f..72db08a5 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,6 +1,7 @@ //! Per-user stats cache: one small JSON file per login under /users/, mirrored in memory, plus a short //! memory of logins GitHub doesn't know (so typos don't cost API calls). use crate::BoxError; +use crate::people::People; use crate::stats::UserStats; use std::collections::HashMap; use std::path::PathBuf; @@ -13,6 +14,8 @@ pub const EVICT_SECS: u64 = 30 * 86400; pub struct Store { dir: PathBuf, mem: Mutex, + /// Who has used it, for the admin page. + pub people: People, } #[derive(Default)] @@ -35,7 +38,14 @@ impl Store { .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); - Ok(Store { dir, mem: Mutex::new(Mem { touched, ..Default::default() }) }) + let users = dir.join("users"); + let saved = || { + let names = std::fs::read_dir(&users).into_iter().flatten().flatten(); + let paths = names.map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")); + paths.filter_map(|p| p.file_stem()?.to_str().map(str::to_string)).collect() + }; + let people = People::open(&dir, saved, crate::dates::now_secs()); + Ok(Store { dir, mem: Mutex::new(Mem { touched, ..Default::default() }), people }) } fn path(&self, login: &str) -> PathBuf { @@ -69,6 +79,17 @@ impl Store { Some((s, fresh)) } + /// A user's saved stats for the admin page, without counting as a request. + pub fn saved(&self, login: &str) -> Option { + if !safe(login) { + return None; + } + if let Some(s) = self.mem.lock().unwrap().users.get(login) { + return Some(s.clone()); + } + serde_json::from_slice(&std::fs::read(self.path(login)).ok()?).ok() + } + pub fn put(&self, stats: UserStats) -> Result<(), BoxError> { if !safe(&stats.login) { return Err(format!("unsafe login {:?}", stats.login).into()); From 992d84c7cc6c276c0e3f6bf3c48ae01d27489cef Mon Sep 17 00:00:00 2001 From: BaranDev Date: Wed, 28 Oct 2026 10:53:16 +0200 Subject: [PATCH 2/3] Privacy page, 12-month limit on the people list, and removal on request A /privacy page (linked from the footer, in the sitemap) says what questlog keeps, why, for how long, who else handles it, and how to have it removed. The people list forgets anyone not seen for 12 months. The admin page can remove a login at the person's request: its saved stats and list entry are deleted, it is never fetched or saved again, and its cards show a notice until it is allowed again. The change endpoints need a header a form on another site can't send. --- site/app/privacy/page.tsx | 58 +++++++++++++++ site/app/privacy/privacy.module.css | 50 +++++++++++++ site/components/Footer.tsx | 1 + site/components/Health.module.css | 34 +++++++++ site/components/Health.tsx | 109 +++++++++++++++++++++++++--- site/i18n/en/index.ts | 3 +- site/i18n/en/privacy.ts | 81 +++++++++++++++++++++ site/i18n/en/site.ts | 1 + site/i18n/en/status.ts | 12 +++ site/public/sitemap.xml | 6 ++ src/main.rs | 4 + src/people.rs | 77 ++++++++++++++++++-- src/server/admin.rs | 55 +++++++++++++- src/server/cards.rs | 3 + src/server/mod.rs | 32 +++++++- src/store.rs | 5 ++ 16 files changed, 510 insertions(+), 21 deletions(-) create mode 100644 site/app/privacy/page.tsx create mode 100644 site/app/privacy/privacy.module.css create mode 100644 site/i18n/en/privacy.ts diff --git a/site/app/privacy/page.tsx b/site/app/privacy/page.tsx new file mode 100644 index 00000000..82bfee47 --- /dev/null +++ b/site/app/privacy/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import Nav from "@/components/Nav"; +import Footer from "@/components/Footer"; +import { m, rich } from "@/i18n"; +import { ogImages } from "@/components/data"; +import p from "./privacy.module.css"; + +const P = m.privacy; + +export const metadata: Metadata = { + title: P.meta.title, + description: P.meta.description, + alternates: { canonical: "/privacy" }, + openGraph: { + url: "/privacy", + title: P.meta.ogTitle, + description: P.meta.ogDescription, + images: ogImages("home", P.meta.ogImageAlt), + }, +}; + +const MAIL = "mailto:contact@cevdetbaran.com"; +const GITHUB = "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"; +const CLOUDFLARE = "https://www.cloudflare.com/privacypolicy/"; + +export default function PrivacyPage() { + return ( + <> + + {m.site.skip} + + diff --git a/site/components/Health.module.css b/site/components/Health.module.css index 6645b8fb..c1fe51ef 100644 --- a/site/components/Health.module.css +++ b/site/components/Health.module.css @@ -492,3 +492,37 @@ text-transform: uppercase; color: var(--faint); } + +.confirm { + display: grid; + gap: 0.75rem; + padding: 0.9rem 1rem; + border: 1px solid #f0a08f; + border-radius: 10px; + color: var(--parchment); + font-size: 0.95rem; +} + +.danger { + color: #f0a08f; + border-color: currentColor; +} + +.subhead { + font-size: 1rem; + color: var(--gold); +} + +.removed { + display: grid; + gap: 0.5rem; + list-style: none; + padding: 0; +} + +.removed li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} diff --git a/site/components/Health.tsx b/site/components/Health.tsx index 5bf314c0..832b9942 100644 --- a/site/components/Health.tsx +++ b/site/components/Health.tsx @@ -40,6 +40,7 @@ type PeopleData = { tried: number; in_readme: number; list: Person[]; + removed: string[]; no_login: { decorations: number; in_readme: number }; }; @@ -242,7 +243,7 @@ export default function Health({ full = false }: { full?: boolean }) { ) : "fetching" in data ? ( ) : ( - + )}

    @@ -291,7 +292,7 @@ function PublicPanels({ d, now }: { d: PublicData; now: number }) { ); } -function Panels({ d, now }: { d: HealthData; now: number }) { +function Panels({ d, now, reload }: { d: HealthData; now: number; reload: () => Promise }) { const g = d.github; const left = g.remaining; const resetIn = g.resets_at ? g.resets_at - now : null; @@ -414,7 +415,7 @@ function Panels({ d, now }: { d: HealthData; now: number }) { - {d.people && } + {d.people && } @@ -424,8 +425,14 @@ function Panels({ d, now }: { d: HealthData; now: number }) { const P = H.people; const day = (secs: number) => H.utc(new Date(secs * 1000).toISOString().slice(0, 16).replace("T", " ")); +/** A change to the people list; the header is one a form on another site can't send. */ +async function change(login: string, what: "remove" | "allow") { + const r = await fetch(`/admin/people/${encodeURIComponent(login)}/${what}`, { method: "POST", headers: { "x-questlog-admin": "1" } }); + if (!r.ok) throw new Error(String(r.status)); +} + /** Everyone who tried it, newest first: their GitHub profile, the stats saved for them, and the cards they made. */ -function People({ p }: { p: PeopleData }) { +function People({ p, reload }: { p: PeopleData; reload: () => Promise }) { return (

    @@ -454,17 +461,72 @@ function People({ p }: { p: PeopleData }) { {p.list.length ? (
      {p.list.map((u) => ( - + ))}
    ) : (

    {P.empty}

    )} + {(p.removed ?? []).length > 0 && ( +
    +

    {P.removedTitle}

    +

    {P.removedNote}

    +
      + {p.removed.map((login) => ( + + ))} +
    +
    + )}
    ); } -function PersonRow({ u }: { u: Person }) { +function Allow({ login, reload }: { login: string; reload: () => Promise }) { + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const allow = async () => { + setBusy(true); + setFailed(false); + try { + await change(login, "allow"); + await reload(); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + }; + return ( +
  • + {login} + + {failed && ( + + {P.allowFailed} + + )} +
  • + ); +} + +function PersonRow({ u, reload }: { u: Person; reload: () => Promise }) { + const [ask, setAsk] = useState(false); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const remove = async () => { + setBusy(true); + setFailed(false); + try { + await change(u.login, "remove"); + await reload(); + } catch { + setFailed(true); + setBusy(false); + } + }; const st = u.stats; const readme = u.cards.some((c) => c.readme); const profile = `https://github.com/${encodeURIComponent(u.login)}`; @@ -477,12 +539,37 @@ function PersonRow({ u }: { u: Person }) { {u.login} - {u.first > 0 && ( - - {readme ? P.readme : P.tried1} - - )} +
    + {u.first > 0 && ( + + {readme ? P.readme : P.tried1} + + )} + {!ask && ( + + )} +
    + {ask && ( +
    +

    {P.removeAsk(u.login)}

    +
    + + +
    + {failed && ( +

    + {P.removeFailed} +

    + )} +
    + )}
    {st ? (

    diff --git a/site/i18n/en/index.ts b/site/i18n/en/index.ts index cd37dcfe..4279705c 100644 --- a/site/i18n/en/index.ts +++ b/site/i18n/en/index.ts @@ -4,5 +4,6 @@ import { wiki } from "./wiki"; import { builder } from "./builder"; import { builderParts } from "./builderParts"; import { health, allCards, attributions } from "./status"; +import { privacy } from "./privacy"; -export const en = { site, meta, home, catalog, wiki, builder, builderParts, health, allCards, attributions }; +export const en = { site, meta, home, catalog, wiki, builder, builderParts, health, allCards, attributions, privacy }; diff --git a/site/i18n/en/privacy.ts b/site/i18n/en/privacy.ts new file mode 100644 index 00000000..284abea0 --- /dev/null +++ b/site/i18n/en/privacy.ts @@ -0,0 +1,81 @@ +// The privacy page: what questlog keeps, why, for how long, and how to have it removed. + +export const privacy = { + meta: { + title: "Privacy: what questlog keeps and why", + description: "What questlog keeps about the GitHub logins it draws cards for, why, for how long, and how to have it removed.", + ogTitle: "questlog privacy", + ogDescription: "What questlog keeps, why, for how long, and how to have it removed.", + ogImageAlt: "questlog, pixel art cards for your GitHub profile", + }, + title: "Privacy", + updated: "Last updated on 28 October 2026.", + lede: "questlog draws pixel art cards from public GitHub data. This page says what it keeps, why, for how long, and how to have it removed.", + sections: [ + { + id: "who", + title: "Who runs questlog", + body: [ + "questlog is run by Cevdet Baran Oral (BaranDev), who decides what is kept and why. Write to contact@cevdetbaran.com about anything on this page.", + ], + }, + { + id: "kept", + title: "What questlog keeps", + body: [ + "Public GitHub numbers. When a card for a login is asked for, questlog reads that login's public numbers from GitHub (commits, pull requests, issues, reviews, stars, followers, languages and contribution days) and saves them to draw the card. They are read again every 12 hours while the card is in use, and deleted after 30 days with no request.", + "Who has used it. A list only the owner sees: each login a card was made for, the card addresses asked for, when they were first and last asked for, and whether the card sits in a README or was opened in a browser. Bots are left out. It shows how many people use questlog. Each entry is deleted 12 months after its last request.", + "Decorations. For banners made without a login, only the banner's address is kept, with no name attached.", + "Server logs. Each request's address, time and answer, the kind of client (such as a browser on Windows, or GitHub's image proxy), the country Cloudflare reports, and Cloudflare's request id. They help keep the service running and find faults. The log has a fixed size, so old lines are overwritten, usually within a few weeks.", + "Your IP address is held in memory for up to an hour, to limit how many new logins and fresh cards one address can ask for. It is never written to disk or to the log.", + "Finished cards. Drawn card pictures are cached so they need not be drawn again. The cache drops the oldest pictures as it fills, and is emptied with each new version of questlog.", + ], + }, + { + id: "not", + title: "What it does not do", + body: [ + "No cookies, no analytics or tracking scripts, and no ads. Nothing is sold or shared, and the list of who has used questlog is never used to contact anyone. Fonts and pictures come from questlog itself.", + ], + }, + { + id: "browser", + title: "In your browser", + body: [ + "The profile builder keeps your draft in your browser's local storage, on your device only. Start over deletes it.", + "To look up a login and its profile README, the builder asks GitHub directly from your browser (api.github.com, raw.githubusercontent.com, and github.com for avatars). Those requests are covered by GitHub's privacy statement.", + ], + }, + { + id: "others", + title: "Who else handles it", + body: [ + "Cloudflare sits in front of the site as its network and cache, and sees your IP address as any web host would (Cloudflare's privacy policy).", + "netcup hosts the server, in Germany.", + "GitHub is the source of the numbers, and shows the cards in READMEs through its image proxy.", + "Cloudflare and GitHub are based in the United States and may handle data there, under their own safeguards.", + ], + }, + { + id: "why", + title: "Why this is allowed", + body: [ + "questlog relies on legitimate interest (Article 6(1)(f) of the GDPR): drawing the cards people ask for, keeping the service running and safe, and knowing how many people use it. It uses only public data, and no more than it needs.", + ], + }, + { + id: "rights", + title: "Your rights", + body: [ + "You can ask what questlog keeps about your login, have it removed, or object to its use. Write to contact@cevdetbaran.com with your GitHub login; you may be asked to show that the login is yours.", + "Removal deletes the saved numbers and the login's entry in the list, and questlog stops drawing cards for that login: a card left in a README then shows a short notice instead. Copies already in caches run out within two days.", + "You can also complain to a data protection authority, such as the one where you live.", + ], + }, + { + id: "changes", + title: "Changes", + body: ["When this page changes, the date at the top changes with it."], + }, + ], +}; diff --git a/site/i18n/en/site.ts b/site/i18n/en/site.ts index f291d5e3..3e1d45aa 100644 --- a/site/i18n/en/site.ts +++ b/site/i18n/en/site.ts @@ -19,6 +19,7 @@ export const site = { allCards: "All cards", wiki: "Wiki", health: "Health", + privacy: "Privacy", github: "GitHub", fine: "questlog by Cevdet Baran Oral. Code under the MIT license, paintings under CC BY-NC 4.0 (commercial use needs permission).", }, diff --git a/site/i18n/en/status.ts b/site/i18n/en/status.ts index 49cee23d..865f7729 100644 --- a/site/i18n/en/status.ts +++ b/site/i18n/en/status.ts @@ -173,6 +173,18 @@ export const health = { cardAlt: (address: string) => `The card at ${address}`, inReadmeShort: "README", inBrowser: "browser", + remove: "Remove", + removeAsk: (login: string) => + `Remove ${login}? Their saved GitHub data and their entry here are deleted, and their cards show a "not shown" notice from now on. Use this when they ask to be removed.`, + removeYes: "Remove and stop their cards", + cancel: "Cancel", + removing: "Removing", + removeFailed: "Removing did not work. Check that you are still signed in, then try again.", + removedTitle: "Removed at their request", + removedNote: "Their cards show a notice instead. Allow one again if they ask for their cards back.", + allow: (login: string) => `Allow ${login} again`, + allowing: "Allowing", + allowFailed: "Allowing did not work. Check that you are still signed in, then try again.", }, log: { aria: "Recent log lines", diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 0d0848ef..de2d1767 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -30,4 +30,10 @@ daily 0.3 + + https://questlog.cc/privacy + 2026-10-28 + yearly + 0.2 + diff --git a/src/main.rs b/src/main.rs index 095314bc..29ce3bd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,10 @@ async fn main() -> Result<(), questlog::BoxError> { Ok(n) => questlog::info!("cache", "hourly sweep removed {n} users nobody asked for in a long time"), Err(e) => questlog::error!("cache", "hourly sweep of saved users failed: {e}"), } + let n = sweeper.people.sweep(now_secs()); + if n > 0 { + questlog::info!("people", "hourly sweep forgot {n} people not seen for 12 months"); + } if let Err(e) = sweeper.people.save() { questlog::error!("people", "could not save the people list: {e}"); } diff --git a/src/people.rs b/src/people.rs index 2d5af1b8..ac3f6f9e 100644 --- a/src/people.rs +++ b/src/people.rs @@ -1,10 +1,11 @@ //! Who has used questlog, for the admin page: the GitHub logins people asked cards for, the card addresses each //! one made, and the decorations made without a login. Only a person's browser or GitHub's image proxy counts; //! bots, the owner and the example logins don't, and no IPs are kept. Kept in memory and written to people.json -//! by the hourly sweep and at shutdown. +//! by the hourly sweep and at shutdown, and dropped 12 months after a person's last visit. A login removed at the +//! person's request stays blocked: its stats are not fetched or saved again and its cards say so. use crate::BoxError; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -15,6 +16,8 @@ const CARDS_EACH: usize = 50; const MAX_PEOPLE: usize = 20_000; const MAX_DECORATIONS: usize = 20_000; const ADDRESS_CHARS: usize = 300; +/// People and decorations nobody asked for in this long are forgotten. +pub const KEEP_SECS: u64 = 365 * 86400; /// One card or decoration address: when it was first and last asked for, and whether it is in a README. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] @@ -38,6 +41,9 @@ struct Seen { since: u64, people: HashMap, decorations: HashMap, + /// Logins removed at the person's request. + #[serde(default)] + blocked: BTreeSet, } pub struct People { @@ -75,7 +81,7 @@ impl People { Err(_) => Seen { since: now, people: saved().into_iter().filter(|l| !NOT_PEOPLE.contains(&l.as_str())).map(|l| (l, Person::default())).collect(), - decorations: HashMap::new(), + ..Default::default() }, }; People { path, seen: Mutex::new(seen) } @@ -87,7 +93,7 @@ impl People { return; } let mut s = self.seen.lock().unwrap(); - if s.people.len() >= MAX_PEOPLE && !s.people.contains_key(login) { + if s.blocked.contains(login) || (s.people.len() >= MAX_PEOPLE && !s.people.contains_key(login)) { return; } let p = s.people.entry(login.to_string()).or_insert_with(|| Person { first: now, ..Default::default() }); @@ -95,6 +101,37 @@ impl People { touch(&mut p.cards, address, readme, now, CARDS_EACH); } + /// Removes a login from the list (its account is gone, or the person asked). + pub fn remove(&self, login: &str) { + self.seen.lock().unwrap().people.remove(login); + } + + /// Removes a login and blocks it, at the person's request; `allow` undoes the block. + pub fn block(&self, login: &str) { + let mut s = self.seen.lock().unwrap(); + s.people.remove(login); + s.blocked.insert(login.to_string()); + } + + pub fn allow(&self, login: &str) { + self.seen.lock().unwrap().blocked.remove(login); + } + + pub fn is_blocked(&self, login: &str) -> bool { + self.seen.lock().unwrap().blocked.contains(login) + } + + /// Forgets people and decorations not asked for in KEEP_SECS; someone saved before the tracking counts from + /// when it began. Returns how many people were forgotten. + pub fn sweep(&self, now: u64) -> usize { + let mut s = self.seen.lock().unwrap(); + let since = s.since; + let before = s.people.len(); + s.people.retain(|_, p| now.saturating_sub(p.last.max(since)) <= KEEP_SECS); + s.decorations.retain(|_, u| now.saturating_sub(u.last) <= KEEP_SECS); + before - s.people.len() + } + /// A decoration made without a login: from the builder, or in a README when `readme`. pub fn decoration(&self, address: &str, readme: bool, now: u64) { touch(&mut self.seen.lock().unwrap().decorations, address, readme, now, MAX_DECORATIONS); @@ -103,13 +140,19 @@ impl People { /// For the admin page: when the tracking began, the `shown` most recent people (newest first), how many tried /// it and how many have a card in a README, and the decorations (made, in a README). pub fn report(&self, shown: usize) -> (u64, Vec<(String, Person)>, usize, usize, (usize, usize)) { + self.report_all(shown).0 + } + + /// `report`, plus the blocked logins. + #[allow(clippy::type_complexity)] + pub fn report_all(&self, shown: usize) -> ((u64, Vec<(String, Person)>, usize, usize, (usize, usize)), Vec) { let s = self.seen.lock().unwrap(); let in_readme = s.people.values().filter(|p| p.cards.values().any(|u| u.readme)).count(); let decorations = (s.decorations.len(), s.decorations.values().filter(|u| u.readme).count()); let mut list: Vec<(String, Person)> = s.people.iter().map(|(l, p)| (l.clone(), p.clone())).collect(); list.sort_by(|a, b| b.1.last.cmp(&a.1.last).then_with(|| a.0.cmp(&b.0))); list.truncate(shown); - (s.since, list, s.people.len(), in_readme, decorations) + ((s.since, list, s.people.len(), in_readme, decorations), s.blocked.iter().cloned().collect()) } pub fn save(&self) -> Result<(), BoxError> { @@ -150,6 +193,30 @@ mod tests { assert_eq!(list[1].1.first, 0, "saved before the tracking"); } + #[test] + fn forgets_after_a_year_and_blocks_on_request() { + let dir = temp_dir("people-keep"); + std::fs::create_dir_all(&dir).unwrap(); + let p = People::open(&dir, || vec!["old".into()], 1000); + p.card("ana", "/ana/stats", false, 1000); + p.card("bo", "/bo/stats", false, 1000 + KEEP_SECS); + p.decoration("/decorations/workbench", true, 1000); + assert_eq!(p.sweep(1000 + KEEP_SECS), 0, "a year to the second is kept"); + assert_eq!(p.sweep(1001 + KEEP_SECS), 2, "ana, and old counted from when the tracking began"); + let (_, list, tried, _, decorations) = p.report(10); + assert_eq!((list[0].0.as_str(), tried, decorations), ("bo", 1, (0, 0))); + + p.block("bo"); + p.card("bo", "/bo/stats", false, 2000 + KEEP_SECS); + p.save().unwrap(); + let p = People::open(&dir, Vec::new, 0); + assert!(p.is_blocked("bo")); + let ((_, _, tried, ..), blocked) = p.report_all(10); + assert_eq!((tried, blocked), (0, vec!["bo".to_string()]), "a blocked login is not listed again"); + p.allow("bo"); + assert!(!p.is_blocked("bo")); + } + #[test] fn caps_cards_per_person_and_keeps_a_bad_file() { let dir = temp_dir("people-cap"); diff --git a/src/server/admin.rs b/src/server/admin.rs index b2526ddb..8f1f87cf 100644 --- a/src/server/admin.rs +++ b/src/server/admin.rs @@ -169,7 +169,7 @@ pub(super) async fn admin_health(State(app): State>>, req /// Everyone who tried it, newest first, with the cards they made and the GitHub stats saved for them (null once /// the 30-day sweep removed them); and the decorations made without a login, as numbers only. fn people(store: &Store) -> serde_json::Value { - let (since, list, tried, in_readme, (decorations, decorations_in_readme)) = store.people.report(PEOPLE_SHOWN); + let ((since, list, tried, in_readme, (decorations, decorations_in_readme)), blocked) = store.people.report_all(PEOPLE_SHOWN); let list: Vec = list .into_iter() .map(|(login, p)| { @@ -184,11 +184,62 @@ fn people(store: &Store) -> serde_json::Value { }) .collect(); serde_json::json!({ - "since": since, "tried": tried, "in_readme": in_readme, "list": list, + "since": since, "tried": tried, "in_readme": in_readme, "list": list, "removed": blocked, "no_login": {"decorations": decorations, "in_readme": decorations_in_readme}, }) } +/// A change from the admin page: a logged-in admin, and a header a form on another site can't send, so a page +/// elsewhere can't make the admin's browser do it. +async fn change_allowed(app: &App, req: axum::extract::Request) -> bool { + req.headers().get("x-questlog-admin").is_some_and(|v| v == "1") && allowed(app, req).await +} + +/// Removes everything kept about a login and stops drawing its cards, at the person's request. +pub(super) async fn admin_remove( + State(app): State>>, + Path(login): Path, + req: axum::extract::Request, +) -> Response { + let login = login.trim().to_ascii_lowercase(); + if !change_allowed(&app, req).await || !params::valid_login(&login) { + return private((StatusCode::NOT_FOUND, "not found").into_response()); + } + let a = app.clone(); + let l = login.clone(); + let saved = tokio::task::spawn_blocking(move || { + a.store.people.block(&l); // first, so a fetch still running can't save the stats again + a.store.forget(&l); + a.store.people.save() + }) + .await + .unwrap_or_else(|e| Err(e.into())); + if let Err(e) = saved { + crate::error!("admin", "removed {login} but saving the people list failed: {e}"); + } + crate::info!("admin", "removed {login} at their request: saved stats deleted, cards no longer drawn"); + private(Json(serde_json::json!({"ok": true})).into_response()) +} + +/// Draws a removed login's cards again. +pub(super) async fn admin_allow( + State(app): State>>, + Path(login): Path, + req: axum::extract::Request, +) -> Response { + let login = login.trim().to_ascii_lowercase(); + if !change_allowed(&app, req).await || !params::valid_login(&login) { + return private((StatusCode::NOT_FOUND, "not found").into_response()); + } + app.store.people.allow(&login); + let a = app.clone(); + if let Err(e) = tokio::task::spawn_blocking(move || a.store.people.save()).await.unwrap_or_else(|e| Err(e.into())) { + crate::error!("admin", "allowed {login} again but saving the people list failed: {e}"); + } + crate::info!("admin", "allowed {login} again: their cards are drawn again"); + private(Json(serde_json::json!({"ok": true})).into_response()) +} + fn private(mut r: Response) -> Response { r.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static(ADMIN)); r diff --git a/src/server/cards.rs b/src/server/cards.rs index 283cc91a..af866a69 100644 --- a/src/server/cards.rs +++ b/src/server/cards.rs @@ -48,6 +48,9 @@ pub(super) async fn serve_card( Err(e) => return message_cached(&app, w, e.look, e.message, "error", DEMO, ip).await, }; let now = now_secs(); + if app.store.people.is_blocked(&req.user) { + return message_cached(&app, w, req.look, REMOVED.into(), "removed", PAGE, ip).await; + } if let Some((stats, fresh)) = app.store.get(&req.user, now) { if !fresh { app.queue.refresh(&req.user); // its own lane: never counts against an IP or the new-login cap diff --git a/src/server/mod.rs b/src/server/mod.rs index d519f13f..30c0e53f 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -5,7 +5,7 @@ mod cards; mod pages; pub use admin::Access; -use admin::{Outcome, admin_health, admin_status}; +use admin::{Outcome, admin_allow, admin_health, admin_remove, admin_status}; use cards::*; use pages::*; @@ -57,6 +57,7 @@ pub const ASSET: &str = "public, max-age=604800"; const UNKNOWN: &str = "No adventurer by that name"; const BUSY: &str = "The scriptorium is busy, try again soon"; const FAILED: &str = "The road is closed"; +const REMOVED: &str = "Not shown, at this adventurer's request"; pub struct App { pub assets: Arc, @@ -115,10 +116,13 @@ pub fn router(app: Arc>) -> Router { .route("/builder", get(|| async { static_file("builder.html", PAGE) })) .route("/status", get(|| async { static_file("status.html", PAGE) })) .route("/cards", get(|| async { static_file("cards.html", PAGE) })) + .route("/privacy", get(|| async { static_file("privacy.html", PAGE) })) .route("/robots.txt", get(|| async { static_file("robots.txt", PAGE) })) .route("/sitemap.xml", get(|| async { static_file("sitemap.xml", PAGE) })) .route("/admin/status", get(admin_status::)) .route("/admin/health", get(admin_health::)) + .route("/admin/people/{login}/remove", axum::routing::post(admin_remove::)) + .route("/admin/people/{login}/allow", axum::routing::post(admin_allow::)) // the admin page's shell is only served through its guarded address .route( "/static/{*path}", @@ -198,6 +202,7 @@ fn describe(kind: &str) -> &str { "busy" => "busy card", "failed" => "\"road is closed\" (GitHub fetches for this user keep failing)", "error" => "error card (bad options in the address)", + "removed" => "\"not shown\" (removed at the person's request)", k => k, } } @@ -442,7 +447,7 @@ mod tests { async fn pages_and_static_files() { let (base, _) = serve(QueueConfig::default()).await; assert_eq!(content_type("cards/demo_stats.scale=2.webp"), "image/webp", "the site's own card copies"); - for (path, html) in [("/", true), ("/wiki", true), ("/builder", true), ("/static/index.html", true)] { + for (path, html) in [("/", true), ("/wiki", true), ("/builder", true), ("/privacy", true), ("/static/index.html", true)] { let (st, ct, cc, body) = raw(&format!("{base}{path}")).await; assert_eq!((st, ct.as_str()), (200, "text/html; charset=utf-8"), "{path}"); assert_eq!(cc, if path == "/static/index.html" { ASSET } else { PAGE }, "{path}"); @@ -559,6 +564,29 @@ mod tests { let cards: Vec<(&str, bool)> = ana["cards"].as_array().unwrap().iter().map(|c| (c["address"].as_str().unwrap(), c["readme"] == true)).collect(); assert_eq!(cards.len(), 2, "{cards:?}"); assert!(cards.contains(&("/ana/stats/forest", false)) && cards.contains(&("/streak?user=ana", true)), "{cards:?}"); + + // removing at the person's request: only from the admin page, then the data is gone and the cards say so + let client = reqwest::Client::new(); + let post = |path: &str, headers: &[(&str, &str)]| { + let mut r = client.post(format!("{base}{path}")); + for (k, v) in headers { + r = r.header(*k, *v); + } + r.send() + }; + for (why, headers) in [("no admin header", &[][..]), ("through Cloudflare without a login", &[("x-questlog-admin", "1"), ("cf-ray", "x")][..])] { + assert_eq!(post("/admin/people/ana/remove", headers).await.unwrap().status(), 404, "{why}"); + } + assert!(app.store.saved("ana").is_some()); + assert_eq!(post("/admin/people/ana/remove", &[("x-questlog-admin", "1")]).await.unwrap().status(), 200); + assert!(app.store.saved("ana").is_none() && app.store.get("ana", now_secs()).is_none(), "saved stats deleted"); + let (kind, ..) = get(&format!("{base}/ana/stats"), &[("user-agent", browser)]).await; + assert_eq!(kind, "removed"); + assert_eq!(app.queue.len(), 0, "a removed login is never fetched again"); + let v: serde_json::Value = reqwest::get(format!("{base}/admin/health")).await.unwrap().json().await.unwrap(); + assert_eq!((v["people"]["tried"].as_u64(), &v["people"]["removed"]), (Some(0), &serde_json::json!(["ana"]))); + assert_eq!(post("/admin/people/ana/allow", &[("x-questlog-admin", "1")]).await.unwrap().status(), 200); + assert!(!app.store.people.is_blocked("ana")); } #[tokio::test] diff --git a/src/store.rs b/src/store.rs index 72db08a5..dcf50720 100644 --- a/src/store.rs +++ b/src/store.rs @@ -94,6 +94,9 @@ impl Store { if !safe(&stats.login) { return Err(format!("unsafe login {:?}", stats.login).into()); } + if self.people.is_blocked(&stats.login) { + return Err(format!("{} was removed at the person's request", stats.login).into()); + } let path = self.path(&stats.login); let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, serde_json::to_vec(&stats)?)?; @@ -114,6 +117,8 @@ impl Store { let _ = std::fs::remove_file(self.dir.join("years").join(format!("{login}.json"))); // the past years github.rs keeps m.users.remove(login); m.touched.remove(login); + drop(m); + self.people.remove(login); } /// (users in memory, logins remembered as not on GitHub), for the admin page. From f38182d6b14bd0a00e7c66ad60b42fa6d1a1a019 Mon Sep 17 00:00:00 2001 From: BaranDev Date: Wed, 28 Oct 2026 10:54:46 +0200 Subject: [PATCH 3/3] Center the README badge next to the Remove button --- site/components/Health.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/components/Health.module.css b/site/components/Health.module.css index c1fe51ef..913572a7 100644 --- a/site/components/Health.module.css +++ b/site/components/Health.module.css @@ -526,3 +526,7 @@ align-items: center; gap: 0.75rem; } + +.personHead .tools { + align-items: center; +}