- {other ? tx.footOther : tx.footProfile}
+ {other ? tx.footOther : tx.footProfile}{" "}
+ {rich(tx.privacy, { a: (c) => {c} })}
{onCancel && (
diff --git a/site/i18n/en/builderParts.ts b/site/i18n/en/builderParts.ts
index 9c94b295..3f71e36f 100644
--- a/site/i18n/en/builderParts.ts
+++ b/site/i18n/en/builderParts.ts
@@ -186,6 +186,7 @@ export const builderParts = {
fieldHelp: "Example: octocat. Letters, digits and hyphens.",
footOther: "Your text stays in this browser until you copy it.",
footProfile: "Already have a profile README? We'll find it, and you can start from it.",
+ privacy: "Only public GitHub data is used.
What questlog keeps.",
cancel: "Cancel",
checking: "Looking you up",
next: "Next",
diff --git a/site/i18n/en/status.ts b/site/i18n/en/status.ts
index 865f7729..2afc93bc 100644
--- a/site/i18n/en/status.ts
+++ b/site/i18n/en/status.ts
@@ -157,7 +157,16 @@ export const health = {
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.`,
+ findLabel: "Find a person by their GitHub login",
+ findPlaceholder: "Find a login",
+ noMatch: (q: string) => `No login contains "${q}". Clear the search to see everyone.`,
+ pageFailed: (hasList: boolean) =>
+ `This page of people did not load${hasList ? ", so the last list stays on screen" : ""}. It tries again with the next check.`,
+ pagesLabel: "Pages of people",
+ newer: "Newer",
+ older: "Older",
+ showing: (from: string, to: string, total: string, page: number, pages: number) =>
+ `${from} to ${to} of ${total}, page ${page} of ${pages}`,
profile: (login: string) => `${login} on GitHub`,
readme: "In a README",
tried1: "Only tried",
diff --git a/src/people.rs b/src/people.rs
index ac3f6f9e..6fa09c7d 100644
--- a/src/people.rs
+++ b/src/people.rs
@@ -46,6 +46,17 @@ struct Seen {
blocked: BTreeSet
,
}
+/// The numbers at the top of the admin page's people section.
+pub struct Summary {
+ pub since: u64,
+ pub tried: usize,
+ pub in_readme: usize,
+ pub decorations: usize,
+ pub decorations_in_readme: usize,
+ /// Logins removed at the person's request.
+ pub removed: Vec,
+}
+
pub struct People {
path: PathBuf,
seen: Mutex,
@@ -137,22 +148,28 @@ impl People {
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)) {
- self.report_all(shown).0
+ /// The numbers at the top of the admin page's people section.
+ pub fn summary(&self) -> Summary {
+ let s = self.seen.lock().unwrap();
+ Summary {
+ since: s.since,
+ tried: s.people.len(),
+ in_readme: s.people.values().filter(|p| p.cards.values().any(|u| u.readme)).count(),
+ decorations: s.decorations.len(),
+ decorations_in_readme: s.decorations.values().filter(|u| u.readme).count(),
+ removed: s.blocked.iter().cloned().collect(),
+ }
}
- /// `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) {
+ /// One page of people whose login contains `find`, newest first: how many match, and the `per` people on page
+ /// `page` (counted from 1).
+ pub fn page(&self, find: &str, page: usize, per: usize) -> (usize, Vec<(String, Person)>) {
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.blocked.iter().cloned().collect())
+ // ponytail: sorts the matches on every request; fine up to MAX_PEOPLE, an ordered index if that grows
+ let mut hits: Vec<(&String, u64)> = s.people.iter().filter(|(l, _)| l.contains(find)).map(|(l, p)| (l, p.last)).collect();
+ hits.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
+ let list = hits.iter().skip(page.saturating_sub(1) * per).take(per).map(|&(l, _)| (l.clone(), s.people[l].clone())).collect();
+ (hits.len(), list)
}
pub fn save(&self) -> Result<(), BoxError> {
@@ -183,8 +200,10 @@ mod tests {
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 p = People::open(&dir, Vec::new, 999);
+ let s = p.summary();
+ assert_eq!((s.since, s.tried, s.in_readme, s.decorations, s.decorations_in_readme), (100, 2, 1, 1, 1));
+ let (_, list) = p.page("", 1, 10);
let logins: Vec<&str> = list.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(logins, ["ana", "sudi"], "newest first");
let ana = &list[0].1;
@@ -203,16 +222,16 @@ mod tests {
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)));
+ let (tried, list) = p.page("", 1, 10);
+ assert_eq!((list[0].0.as_str(), tried, p.summary().decorations), ("bo", 1, 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");
+ let s = p.summary();
+ assert_eq!((s.tried, s.removed), (0, vec!["bo".to_string()]), "a blocked login is not listed again");
p.allow("bo");
assert!(!p.is_blocked("bo"));
}
@@ -225,12 +244,29 @@ mod tests {
for i in 0..CARDS_EACH + 5 {
p.card("ana", &format!("/ana/stats?n={i}"), false, i as u64);
}
- let (_, list, ..) = p.report(1);
+ let (_, list) = p.page("", 1, 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));
+ let s = People::open(&dir, Vec::new, 5).summary();
+ assert_eq!((s.since, s.tried), (5, 0));
assert!(dir.join("people.json.bad").exists());
}
+
+ #[test]
+ fn pages_newest_first_and_finds_by_login() {
+ let dir = temp_dir("people-page");
+ std::fs::create_dir_all(&dir).unwrap();
+ let p = People::open(&dir, Vec::new, 0);
+ for i in 0..45u64 {
+ p.card(&format!("user{i:02}"), "/stats", false, 100 + i);
+ }
+ let logins = |list: Vec<(String, Person)>| list.into_iter().map(|(l, _)| l).collect::>();
+ let (n, first) = p.page("", 1, 20);
+ assert_eq!((n, first.len(), first[0].0.as_str()), (45, 20, "user44"));
+ assert_eq!(logins(p.page("", 3, 20).1), ["user04", "user03", "user02", "user01", "user00"]);
+ assert!(p.page("", 4, 20).1.is_empty(), "past the last page");
+ let (n, found) = p.page("er1", 1, 20);
+ assert_eq!((n, logins(found).first().cloned()), (10, Some("user19".to_string())));
+ }
}
diff --git a/src/server/admin.rs b/src/server/admin.rs
index 8f1f87cf..b9a0c4ed 100644
--- a/src/server/admin.rs
+++ b/src/server/admin.rs
@@ -14,8 +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;
+/// People on one page of the admin page's list.
+const PEOPLE_PER_PAGE: usize = 5;
/// 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);
@@ -159,17 +159,43 @@ 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));
- let a = app.clone();
- let people = tokio::task::spawn_blocking(move || people(&a.store)).await.unwrap_or_default();
+ let p = app.store.people.summary();
let Json(mut v) = full_health(&app, disk);
- v["people"] = people;
+ v["people"] = serde_json::json!({
+ "since": p.since, "tried": p.tried, "in_readme": p.in_readme, "removed": p.removed,
+ "no_login": {"decorations": p.decorations, "in_readme": p.decorations_in_readme},
+ });
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)), blocked) = store.people.report_all(PEOPLE_SHOWN);
+#[derive(serde::Deserialize)]
+pub(super) struct PeopleQuery {
+ page: Option,
+ q: Option,
+}
+
+/// /admin/people?page=N&q=text: one page of the people list, newest first, filtered by a part of the login.
+pub(super) async fn admin_people(
+ State(app): State>>,
+ Query(q): Query,
+ req: axum::extract::Request,
+) -> Response {
+ if !allowed(&app, req).await {
+ return private((StatusCode::NOT_FOUND, "not found").into_response());
+ }
+ let find: String = q.q.unwrap_or_default().trim().to_ascii_lowercase().chars().take(39).collect();
+ let a = app.clone();
+ let v = tokio::task::spawn_blocking(move || people_page(&a.store, &find, q.page.unwrap_or(1).max(1))).await.unwrap_or_default();
+ private(Json(v).into_response())
+}
+
+/// A page of people, with the cards they made and the GitHub stats saved for them (null once the 30-day sweep
+/// removed them). A page past the end is clamped to the last one.
+fn people_page(store: &Store, find: &str, page: usize) -> serde_json::Value {
+ let (total, list) = store.people.page(find, page, PEOPLE_PER_PAGE);
+ let pages = total.div_ceil(PEOPLE_PER_PAGE).max(1);
+ let (page, list) = if page > pages { (pages, store.people.page(find, pages, PEOPLE_PER_PAGE).1) } else { (page, list) };
+
let list: Vec = list
.into_iter()
.map(|(login, p)| {
@@ -183,10 +209,7 @@ fn people(store: &Store) -> serde_json::Value {
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, "removed": blocked,
- "no_login": {"decorations": decorations, "in_readme": decorations_in_readme},
- })
+ serde_json::json!({"page": page, "pages": pages, "per": PEOPLE_PER_PAGE, "total": total, "list": list})
}
/// A change from the admin page: a logged-in admin, and a header a form on another site can't send, so a page
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 30c0e53f..3c62c8c8 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_allow, admin_health, admin_remove, admin_status};
+use admin::{Outcome, admin_allow, admin_health, admin_people, admin_remove, admin_status};
use cards::*;
use pages::*;
@@ -121,6 +121,7 @@ pub fn router(app: Arc>) -> Router {
.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", get(admin_people::))
.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
@@ -529,7 +530,7 @@ mod tests {
assert_eq!(full["new_users"]["per_hour"], 600);
assert!(full["traffic"].as_array().is_some_and(|t| t.len() == 60) && full["recent"].is_array(), "{full}");
let client = reqwest::Client::new();
- for path in ["/admin/health", "/admin/status", "/static/admin/status.html"] {
+ for path in ["/admin/health", "/admin/people", "/admin/status", "/static/admin/status.html"] {
let r = client.get(format!("{base}{path}")).header("cf-ray", "x").send().await.unwrap();
assert_eq!(r.status(), 404, "{path} through Cloudflare without a login");
}
@@ -559,7 +560,11 @@ mod tests {
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];
+ let page: serde_json::Value = reqwest::get(format!("{base}/admin/people?q=AN&page=9")).await.unwrap().json().await.unwrap();
+ assert_eq!((page["page"].as_u64(), page["pages"].as_u64(), page["total"].as_u64()), (Some(1), Some(1), Some(1)), "{page}");
+ let none: serde_json::Value = reqwest::get(format!("{base}/admin/people?q=zz")).await.unwrap().json().await.unwrap();
+ assert_eq!((none["total"].as_u64(), none["list"].as_array().map(Vec::len)), (Some(0), Some(0)));
+ let ana = &page["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:?}");