feather_reader/web.rs
1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an `axum::Router` over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! `askama` templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET /health` — liveness + version, as `text/plain`.
14//! * `GET /` — the reader: a folders/feeds sidebar (from the PDS records layer)
15//! plus the main article list. Query params pick the scope (`?feed=…` /
16//! `?folder=…` / all) and the view (`?view=unread|all|starred`).
17//! * `GET /entries/{id}` — the clean, distraction-free reader for one entry,
18//! with prev/next within the current list.
19//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
20//! * `POST /entries/{id}/star` — star/unstar; writes a
21//! `community.lexicon.rss.saved` record to the user's PDS.
22//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
23//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
24//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
25//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
26//! * `POST /folders` — create a folder record.
27//! * `POST /folders/{rkey}/rename` — rename a folder record.
28//! * `POST /folders/{rkey}/delete` — delete a folder record.
29//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
30//! subscription records in the PDS.
31//! * `GET /opml/export` — OPML export (records → a downloadable document).
32//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
33//! OAuth sign-in flow (routed through the sidecar).
34//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
35//! reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
36//! (sets the reserving cookie → `/login`).
37//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
38//! code + token/url for the bot to post. Cap-aware (409 when full).
39//!
40//! ## Identity — a cookie-resolved atproto session
41//!
42//! Per-request identity comes from a **signed session cookie** (`fr_session`)
43//! keyed by the logged-in DID, set by `oauth_callback` and read by
44//! `current_session` / `current_did`. For local runs without the sidecar,
45//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
46//! All PDS writes route through the [`crate::atproto::SidecarClient`]; a live-PDS
47//! write needs a real OAuth session, but the full write path is built and unit-
48//! tested to the sidecar boundary.
49
50use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57 extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58 http::{header, HeaderMap, StatusCode},
59 middleware::{self, Next},
60 response::{Html, IntoResponse, Redirect, Response},
61 routing::{get, post},
62 Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::safe_link::SafeLink;
74use crate::{feed, store, AppState, Session, VERSION};
75
76// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
77// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
78// here via an explicit path so the reader's OPML routes can use the canonical
79// `parse_opml` / `to_opml` without duplicating that logic.
80#[path = "opml.rs"]
81mod opml;
82
83/// The name of the signed session cookie.
84const SESSION_COOKIE: &str = "fr_session";
85
86/// The name of the short-lived signed **invite** cookie.
87///
88/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
89/// OAuth callback. It reserves *intent* to redeem a specific code before the
90/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
91/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
92/// signed with the same key as the session cookie.
93const INVITE_COOKIE: &str = "fr_invite";
94
95/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
96///
97/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
98/// a stolen `state` replayed from ANOTHER browser matches just as well as from
99/// the one that started the flow. This cookie is what makes the callback
100/// browser-specific — the pending row stores only its hash, and a callback that
101/// cannot present it is refused.
102const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
103
104/// How long an in-flight login may sit, matching the pending row's own TTL.
105const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
106
107/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
108/// Short enough that a reserved-but-unclaimed seat frees quickly.
109const INVITE_TTL_SECS: i64 = 1800;
110
111/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
112/// sign-in pitch, and `/about`.
113const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
114
115/// The tip / support link (cloud plan public-experiment UI).
116const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
117
118/// The published crate on crates.io — surfaced on the signed-out landing page.
119const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
120
121/// The Content-Security-Policy applied to every response.
122///
123/// Tuned to keep the app fully working while neutralising injected script:
124/// * `default-src 'self'` — same-origin baseline.
125/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
126/// `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
127/// `javascript:` href (F4) cannot execute. (The design's templates carry no
128/// inline event handlers — every control is wired in `keyboard.js`.)
129/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
130/// inline styles htmx toggles for its request indicators.
131/// * `img-src 'self' https: data:` — feed content routinely embeds remote
132/// images; allow https + data URIs but not other schemes.
133/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
134/// down form posts, `<base>` hijacking, and clickjacking.
135/// * `object-src 'none'` — no plugins.
136const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
137 script-src 'self'; \
138 style-src 'self' 'unsafe-inline'; \
139 img-src 'self' https: data:; \
140 font-src 'self'; \
141 connect-src 'self'; \
142 form-action 'self'; \
143 base-uri 'self'; \
144 frame-ancestors 'none'; \
145 object-src 'none'";
146
147/// The resolved identity for the current request.
148///
149/// `did` is the primary key for all per-user local state; `handle` is display
150/// only; `sid` is the opaque server-side session id the cookie carried (needed
151/// so logout can revoke exactly this session). Sourced from the signed cookie
152/// (real login) or, if none, the configured dev DID fallback.
153#[derive(Clone, Debug)]
154struct CurrentUser {
155 did: String,
156 handle: Option<String>,
157 /// The opaque session id, if this identity came from a real cookie session
158 /// (absent for the dev-DID fallback, which has no server-side session row).
159 sid: Option<String>,
160}
161
162/// Resolve the current request's session from the signed cookie, falling back to
163/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
164///
165/// The cookie carries an opaque server-minted session id (not the DID). We
166/// verify its HMAC, look the id up in the registry, and — crucially —
167/// **re-check the DID against the closed-beta gate on every request**
168/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
169/// DID's beta seat takes effect immediately for already-issued cookies. (The
170/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
171/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
172async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
173 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
174 if let Some(session) = state.sessions.get(&sid) {
175 if store::has_beta_access(&state.db, &session.did)
176 .await
177 .unwrap_or(false)
178 {
179 return Some(CurrentUser {
180 did: session.did,
181 handle: session.handle,
182 sid: Some(sid),
183 });
184 }
185 // DID no longer holds a beta seat: treat as logged out (and drop the
186 // stale server-side session so the dead cookie can't linger).
187 state.sessions.remove(&sid);
188 }
189 }
190 // No valid cookie: dev fallback only if explicitly configured *and* still
191 // inside the beta gate (seeded via ensure_seed / a redeemed code).
192 if let Some(did) = state.config.dev_did.clone() {
193 if store::has_beta_access(&state.db, &did)
194 .await
195 .unwrap_or(false)
196 {
197 return Some(CurrentUser {
198 did,
199 handle: None,
200 sid: None,
201 });
202 }
203 }
204 None
205}
206
207/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
208async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
209 current_session(state, headers).await.map(|u| u.did)
210}
211
212/// Build the application router over shared [`AppState`].
213///
214/// Wires the reader routes, the health check, and the `/static` asset mount
215/// (the stylesheet, vendored htmx, and the keyboard handler, served from
216/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
217pub fn router(state: AppState) -> Router {
218 // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
219 // and the write endpoints). One instance is cloned into the state closure of
220 // the `rate_limit` middleware.
221 let limiter = RateLimiter::shared();
222 // The trusted client-IP source for the limiter (a proxy header the operator
223 // controls, or the socket peer when unset). Bundled with the limiter so the
224 // middleware derives a spoof-resistant IP.
225 let rl_state = RateLimitState {
226 limiter,
227 trusted_header: state.config.trusted_ip_header.clone(),
228 };
229
230 Router::new()
231 .route("/health", get(health))
232 .route("/about", get(about))
233 .route("/stats", get(stats))
234 .route("/privacy", get(privacy))
235 .route("/terms", get(terms))
236 .route("/manage", get(manage))
237 .route("/", get(index))
238 .route("/entries/{id}", get(entry_view))
239 .route("/entries/{id}/read", post(mark_read))
240 .route("/entries/{id}/star", post(toggle_star))
241 .route("/saved/{rkey}/delete", post(unsave_record))
242 .route("/read-all", post(mark_all_read))
243 .route("/subscriptions", post(add_subscription))
244 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
245 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
246 .route("/folders", post(create_folder))
247 .route("/folders/{rkey}/rename", post(rename_folder))
248 .route("/folders/{rkey}/delete", post(delete_folder))
249 // OPML import takes untrusted uploads: cap the body so a huge upload
250 // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
251 .route(
252 "/opml",
253 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
254 )
255 .route("/opml/export", get(export_opml))
256 .route("/login", get(login_form).post(login_submit))
257 .route(
258 "/beta/redeem",
259 get(beta_redeem_form).post(beta_redeem_submit),
260 )
261 // The follow→invite bot's claim link: a public skeet points a new
262 // follower here with an opaque token that reserves a pre-minted code.
263 .route("/claim", get(claim))
264 // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
265 // code + returns its token/url for the bot to post.
266 .route("/bot/claims", post(bot_mint_claim))
267 .route("/admin/invites", post(admin_mint_invites))
268 .route("/admin/metrics", get(admin_metrics))
269 .route("/oauth/client-metadata.json", get(oauth_client_metadata))
270 .route("/oauth/jwks.json", get(oauth_jwks))
271 .route("/account/delete", post(account_delete))
272 .route("/oauth/callback", get(oauth_callback))
273 .route("/logout", post(logout))
274 .nest_service("/static", ServeDir::new("static"))
275 // Browsers (and some feed clients) request /favicon.ico at the root
276 // regardless of the <link rel="icon"> tags; serve the same icon that
277 // lives under /static so the bare path stops 404-ing.
278 .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
279 // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
280 // logged-out landing + static assets, `no-store` on anything that
281 // rendered a session's private view. Runs *inside* the security layers so
282 // the CSP/nosniff/frame headers are untouched.
283 .layer(middleware::from_fn(cache_control))
284 // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
285 // as a middleware so it sees the matched path + the peer IP.
286 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
287 .layer(TraceLayer::new_for_http())
288 // Baseline security headers on *every* response (F4). The CSP is the
289 // backstop that neutralises any XSS that slips past sanitization; the
290 // others harden sniffing, framing, and referrer leakage.
291 .layer(static_header_layer(
292 "content-security-policy",
293 CONTENT_SECURITY_POLICY,
294 ))
295 .layer(static_header_layer("x-content-type-options", "nosniff"))
296 .layer(static_header_layer(
297 "referrer-policy",
298 "strict-origin-when-cross-origin",
299 ))
300 .layer(static_header_layer("x-frame-options", "DENY"))
301 .with_state(state)
302}
303
304/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
305/// axum's 2 MiB default.**
306///
307/// The value used to BE the framework default, which made the route's own
308/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
309/// nothing could test it, and the ceiling this route wanted was whatever the
310/// framework happened to pick. Sized to this route instead — one outline is
311/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
312/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
313/// larger is not a subscription list.
314///
315/// Being strictly tighter than the default is what makes the layer both real
316/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
317/// uploads a payload that only this limit refuses.
318const OPML_BODY_LIMIT: usize = 1024 * 1024;
319
320/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
321/// uploads a payload between the two ceilings.
322///
323/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
324/// number is exact — axum does not export it, so it cannot be imported. The
325/// exceeding is what the test's mutation demonstrates: with the route's layer
326/// removed, a payload of this size is accepted. If axum ever lowers its
327/// default below ours, that mutation stops failing and the compile-time
328/// assertion below is the thing to revisit.
329#[cfg(test)]
330const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
331
332/// The route's cap must stay strictly tighter than the framework's, or its
333/// layer is a no-op again. A compile error, not a test failure: this is a
334/// property of the two constants, and nothing should be able to build a binary
335/// where it is false.
336#[cfg(test)]
337const _: () = assert!(
338 OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
339 "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
340);
341
342/// A response-header layer that sets `name: value` on every response, overriding
343/// any existing header of that name. `name`/`value` must be valid static header
344/// tokens (they are, for our fixed security headers).
345fn static_header_layer(
346 name: &'static str,
347 value: &'static str,
348) -> SetResponseHeaderLayer<header::HeaderValue> {
349 SetResponseHeaderLayer::overriding(
350 header::HeaderName::from_static(name),
351 header::HeaderValue::from_static(value),
352 )
353}
354
355// ---------------------------------------------------------------------------
356// Per-IP rate limiting (token bucket, self-contained — no extra crate)
357// ---------------------------------------------------------------------------
358
359/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
360/// kick-off and callback, the invite redeem, logout, the mutating write
361/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
362/// intentionally *not* limited.
363///
364/// The criterion is **does this path make an outbound request**, not "does it
365/// mutate" — the two diverge, and every miss so far has been on the outbound
366/// side. This is an allowlist a new route has to be added to by hand, which is
367/// exactly why it has now been missed three times: `/saved/` (fixed), then
368/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
369/// only path here reachable with no session at all.
370///
371/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
372/// make PDS calls but are ordinary authenticated navigation, and throttling them
373/// would degrade normal reading. They are bounded by needing a valid session.
374fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
375 use axum::http::Method;
376 // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
377 // a claim token in a public URL is grabbable, so it MUST be per-IP limited
378 // like the other abuse-prone entry points — not just `/login`.
379 // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
380 // real outbound round-trip — a sidecar `resolve_session` or a full token
381 // exchange against a PDS. Anyone could spend one outbound request per hit.
382 // It is the only entry point here that needs no session at all.
383 if method != Method::POST
384 && !(method == Method::GET
385 && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
386 {
387 return false;
388 }
389 match path {
390 // `/logout` and `/oauth/callback` are here because they make outbound
391 // calls, not because they mutate: logout revokes at the PDS (up to two
392 // round-trips) and the callback exchanges a code. The list is by
393 // *network cost*, which is what the limiter is actually for.
394 "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
395 | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
396 | "/folders" => true,
397 // Every per-record subscription/folder mutation (delete/rename) and the
398 // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
399 p => {
400 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
401 // Unsaving makes a DPoP-signed deleteRecord round-trip to the
402 // PDS, which is exactly the reason the neighbours above are
403 // limited. It was added as a new route and not added here.
404 || p.starts_with("/saved/")
405 || p.starts_with("/subscriptions/")
406 || p.starts_with("/folders/")
407 }
408 }
409}
410
411/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
412/// client-IP header (if any). Cloned into every request; both fields are cheap.
413#[derive(Clone)]
414struct RateLimitState {
415 limiter: RateLimiter,
416 /// The lowercased proxy header the operator trusts for the client IP, or
417 /// `None` to trust only the socket peer. See [`client_ip`].
418 trusted_header: Option<String>,
419}
420
421/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
422/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
423/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
424/// dependency → no network fetch at build, deterministic offline CI).
425#[derive(Clone)]
426struct RateLimiter {
427 inner: std::sync::Arc<Mutex<RateLimiterState>>,
428}
429
430/// The limiter's shared state: the buckets plus when they were last swept.
431struct RateLimiterState {
432 buckets: HashMap<IpAddr, Bucket>,
433 last_sweep: Instant,
434}
435
436/// One IP's token bucket: a fractional token count + the last-refill instant.
437struct Bucket {
438 tokens: f64,
439 last: Instant,
440}
441
442/// Burst capacity per IP — how many requests can arrive back-to-back.
443const RATE_BURST: f64 = 20.0;
444/// Steady-state refill rate (tokens/sec) once the burst is spent.
445const RATE_REFILL_PER_SEC: f64 = 1.0;
446/// Evict idle buckets older than this so the map can't grow unbounded.
447const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
448
449/// How often the idle sweep may actually run.
450///
451/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
452/// map to find entries that, by construction, can only age out on an hour
453/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
454/// under any volume of distinct source IPs the server spent its single shared
455/// core re-walking a map whose contents had not changed. Once a minute is
456/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
457const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
458
459/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
460/// target, sized so ordinary traffic never reaches it.
461///
462/// The idle eviction above was the only bound, and it is a TIME bound, which
463/// says nothing about how many distinct IPs can arrive inside one hour.
464/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
465/// this one did not.
466const MAX_RATE_BUCKETS: usize = 10_000;
467
468/// When the cap is hit, evict down to this fraction of it rather than removing
469/// a single entry — so the O(n) eviction happens once per `cap/8` requests
470/// instead of once per request while the map sits full.
471const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
472
473impl RateLimiter {
474 /// A fresh, shared limiter (cloned into the middleware state).
475 fn shared() -> Self {
476 Self {
477 inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
478 buckets: HashMap::new(),
479 last_sweep: Instant::now(),
480 })),
481 }
482 }
483
484 /// Charge one token for `ip`; returns `true` if allowed, `false` if the
485 /// bucket is empty (→ 429).
486 fn check(&self, ip: IpAddr) -> bool {
487 self.check_at(ip, Instant::now())
488 }
489
490 /// [`check`](Self::check) with the clock injected, so the sweep and eviction
491 /// paths below are reachable in a test without sleeping through an hour.
492 fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
493 let mut state = match self.inner.lock() {
494 Ok(m) => m,
495 // A poisoned lock shouldn't take the site down — fail open.
496 Err(p) => p.into_inner(),
497 };
498
499 // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
500 if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
501 state
502 .buckets
503 .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
504 state.last_sweep = now;
505 }
506
507 // Hard size bound, independent of the time bound above.
508 //
509 // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
510 // attacker cannot use eviction to clear their OWN throttled bucket: that
511 // bucket is by definition the most recently touched, so it is the last
512 // thing this removes. Going quiet long enough to become the oldest entry
513 // is exactly what the refill already grants for free.
514 if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
515 let mut by_age: Vec<(IpAddr, Instant)> =
516 state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
517 by_age.sort_unstable_by_key(|(_, last)| *last);
518 for (victim, _) in by_age
519 .into_iter()
520 .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
521 {
522 state.buckets.remove(&victim);
523 }
524 warn!(
525 buckets = state.buckets.len(),
526 "rate-limit bucket cap reached; evicted the least recently seen clients"
527 );
528 }
529
530 let bucket = state.buckets.entry(ip).or_insert(Bucket {
531 tokens: RATE_BURST,
532 last: now,
533 });
534 let elapsed = now.duration_since(bucket.last).as_secs_f64();
535 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
536 bucket.last = now;
537 if bucket.tokens >= 1.0 {
538 bucket.tokens -= 1.0;
539 true
540 } else {
541 false
542 }
543 }
544}
545
546/// The **trusted** client IP for a request.
547///
548/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
549/// is fully bypassable — the left-most value is attacker-supplied (any client
550/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
551/// bucket and the per-IP limit never bites. We therefore derive the IP only from
552/// a source the operator controls:
553///
554/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
555/// `CF-Connecting-IP`), we read the client IP from THAT header only — it is
556/// set by the proxy we run in front and overwrites any client-supplied copy.
557/// We take the LAST value if the header happens to be a comma list (the hop
558/// the trusted proxy appended), which is also the correct read for a
559/// right-most-`X-Forwarded-For` deployment where the operator points
560/// `trusted_header` at `x-forwarded-for`.
561/// * Otherwise we ignore all forwarding headers and use the socket peer
562/// (`ConnectInfo`) — correct for a direct bind with no proxy.
563///
564/// Returns `None` only when neither source yields a parseable IP (the limiter
565/// then fails open for that one request).
566fn client_ip(
567 headers: &HeaderMap,
568 conn: Option<&SocketAddr>,
569 trusted_header: Option<&str>,
570) -> Option<IpAddr> {
571 if let Some(name) = trusted_header {
572 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
573 // Right-most hop is the one the trusted proxy appended; earlier
574 // entries may be client-forged, so never trust the left-most.
575 if let Some(last) = raw.split(',').next_back() {
576 if let Ok(ip) = last.trim().parse::<IpAddr>() {
577 return Some(ip);
578 }
579 }
580 }
581 // Trusted header absent/unparseable → fall through to the socket peer.
582 }
583 conn.map(|s| s.ip())
584}
585
586/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
587/// empty; every other request (and every non-guarded path) passes through. The
588/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
589/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
590async fn rate_limit(
591 State(rl): State<RateLimitState>,
592 req: axum::extract::Request,
593 next: Next,
594) -> Response {
595 let path = req.uri().path().to_string();
596 let method = req.method().clone();
597 if is_rate_limited_path(&path, &method) {
598 let conn = req
599 .extensions()
600 .get::<ConnectInfo<SocketAddr>>()
601 .map(|c| c.0);
602 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
603 // Deliberately fail OPEN when no client IP is derivable (no trusted
604 // header / no socket peer): there is no per-IP key to enforce, and a
605 // blanket 429 would self-DoS every guarded path (incl. /login). This is
606 // safe precisely because we never key on an attacker-forged XFF — see
607 // `rate_limit_ignores_spoofed_xff_rotation`.
608 if let Some(ip) = ip {
609 if !rl.limiter.check(ip) {
610 warn!(%ip, %path, "rate limit exceeded");
611 return (
612 StatusCode::TOO_MANY_REQUESTS,
613 [(header::RETRY_AFTER, "1")],
614 "rate limit exceeded\n",
615 )
616 .into_response();
617 }
618 }
619 }
620 next.run(req).await
621}
622
623// ---------------------------------------------------------------------------
624// Cache-Control (viral / CDN vs. private authenticated views)
625// ---------------------------------------------------------------------------
626
627/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
628/// logged-out surfaces (the `/login` landing without a handle, `/about`,
629/// `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
630/// authenticated app pages, so a CDN /
631/// browser can hold the viral landing while never caching a signed-in user's
632/// private view. Never overrides a handler that already set Cache-Control.
633async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
634 let path = req.uri().path().to_string();
635 // The logged-out landing is only cacheable when it's the bare form — a
636 // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
637 let is_login_landing = path == "/login"
638 && req.method() == axum::http::Method::GET
639 && !req.uri().query().unwrap_or("").contains("handle=");
640 let public = is_login_landing
641 || path == "/about"
642 || path == "/privacy"
643 || path == "/terms"
644 || path.starts_with("/static/");
645
646 let mut resp = next.run(req).await;
647 if resp.headers().contains_key(header::CACHE_CONTROL) {
648 return resp;
649 }
650 let value = if public {
651 "public, max-age=300"
652 } else {
653 "no-store"
654 };
655 if let Ok(hv) = header::HeaderValue::from_str(value) {
656 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
657 }
658 resp
659}
660
661// ---------------------------------------------------------------------------
662// Health
663// ---------------------------------------------------------------------------
664
665/// Run `/health`'s database probe. **The single path, so a test cannot assert
666/// on a string the handler is free to ignore** — a named constant alone was not
667/// enough: the test read the constant while the handler passed `query_scalar`
668/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
669async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
670 sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
671 .fetch_optional(pool)
672 .await
673}
674
675/// The statement `/health` uses to prove the database is readable.
676///
677/// **A named constant so the test can assert on the query that actually runs.**
678/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
679/// this string, so degrading the real probe to `SELECT 1` — which opens no page
680/// and therefore cannot detect a broken database — left the suite green.
681const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
682
683/// How long `/health` will wait for its database ping before calling it broken.
684///
685/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
686/// handler chose rather than a timeout Fly inferred — the difference between a
687/// log line that says why and one that says nothing.
688const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
689
690/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
691/// see the handler for why.
692///
693/// The threshold itself is derived from the configured tick
694/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
695/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
696/// stale` in the body the deployment docs now tell them to alert on.
697const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
698
699/// How long without a completed tick before the poller reads as stale: several
700/// tick intervals, floored, so a normally-paced loop never trips it and a
701/// genuinely wedged one always does.
702fn health_tick_stale_secs(tick: Duration) -> i64 {
703 let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
704 tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
705}
706
707/// The poll tick this instance is configured for. Read from the same env var
708/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
709/// handler cannot see its constants.
710fn configured_poll_tick() -> Duration {
711 std::env::var("FEATHERREADER_POLL_TICK_SECS")
712 .ok()
713 .and_then(|v| v.trim().parse::<u64>().ok())
714 .filter(|s| *s > 0)
715 .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
716}
717
718/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
719/// so cannot be imported here. Duplicated deliberately and named, rather than
720/// left as a bare `60` inside the parse chain, so the drift is at least visible
721/// if the scheduler's value ever moves.
722const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
723
724/// Grace period after boot before a poller that has never ticked is called
725/// `stale` rather than `not-yet-ticked`.
726///
727/// Without this the two are indistinguishable forever, which matters precisely
728/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
729/// cycles the poller never reaches its first tick, so `/health` reported the
730/// benign `not-yet-ticked` on every single probe and the heartbeat could not
731/// detect the failure mode it exists for. `run_poller` returning early — a failed
732/// HTTP client build — has the same shape and was equally invisible.
733///
734/// Sized off the poller's own startup delay plus its tick, with slack.
735const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
736
737/// `GET /health` — does this process still work, and what are its loops doing?
738///
739/// This used to return a constant string, touching no database, no pool and no
740/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
741/// sole other failure detector is a child process exiting. It proved the HTTP
742/// listener was up and nothing else.
743///
744/// **What can fail the check: the database, and only the database.** A process
745/// that cannot reach its store serves nothing, so a restart is the right
746/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
747/// mode is not blocked by any writer — so the retention sweep, the poller and a
748/// login burst cannot make this flap. That property is the reason it is a read
749/// and not, say, a write canary.
750///
751/// **What is reported but never fails the check: everything else.** A stale poll
752/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
753/// and none of them a reason to stop serving.
754///
755/// That last clause is the whole justification, and it is NOT the one this
756/// comment used to give. It said "Fly restarts on a failed check", which is
757/// false — verified against Fly's own docs, which state it three times: *"your
758/// Machines won't automatically restart or stop due to failing their health
759/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
760/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
761/// V1 (`restart_limit`) and has no successor on Machines.
762///
763/// The corrected model makes the conclusion stronger, not weaker. With one
764/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
765/// failover — it is a total outage that lasts exactly as long as the condition,
766/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
767/// question the status code answers is not "would a restart fix this" but **"can
768/// this process still serve a useful request at all"**. A stale poller can. A
769/// database it cannot read cannot.
770///
771/// Re-registration is automatic: the proxy keeps probing and routes again the
772/// moment the check passes. That is what makes a 503 recoverable without
773/// intervention — not a restart, which never comes.
774///
775/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
776/// it is publishable on the same terms as `/stats`. It is also the non-session
777/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
778/// (which needs a live admin session) is exactly as unreachable as the thing it
779/// would diagnose, while this is reachable with `curl`.
780async fn health(State(state): State<AppState>) -> Response {
781 let now = chrono::Utc::now().timestamp();
782 let rh = &state.runtime_health;
783
784 use crate::runtime_health::DbProbe;
785 let db = match rh.begin_db_probe() {
786 // A probe is already in flight; report its predecessor rather than
787 // starting a second one. See `RuntimeHealth::begin_db_probe`.
788 Err(borrowed) => borrowed,
789 Ok(probe) => {
790 // **Spawned, so the probe cannot be cancelled by the caller.**
791 //
792 // Axum drops the handler future when a client disconnects. With the
793 // probe inline, that dropped it mid-flight and released the claim
794 // WITHOUT recording a verdict — which let an unauthenticated caller
795 // manufacture the no-verdict state on demand and freeze what every
796 // other caller, Fly's check included, reads. Running it detached
797 // means the verdict is always recorded and the claim is always
798 // released after it.
799 let pool = state.db.clone();
800 let task = tokio::spawn(async move {
801 // **`SELECT 1` was not a database probe.** It compiles to
802 // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
803 // never touches a b-tree, never reads a page, and never consults
804 // the file. Against a corrupted database it returns success
805 // while every real query returns SQLITE_CORRUPT. Reading one row
806 // from a real table costs the same and actually proves what the
807 // check claims. `LIMIT 1` keeps it to a single page; an empty
808 // table still opens the b-tree root, which is the part that
809 // matters.
810 let verdict =
811 match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
812 Ok(Ok(_)) => DbProbe::Ok,
813 // Coarse, not the raw error. An unauthenticated caller
814 // learning exactly which failure it hit is an
815 // attack-progress oracle; the detail belongs in the log,
816 // which gets it here.
817 Ok(Err(err)) => {
818 warn!(%err, "health: database probe failed");
819 DbProbe::Failed("unavailable".to_string())
820 }
821 Err(_) => {
822 warn!(
823 timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
824 "health: database probe timed out (pool exhausted?)"
825 );
826 DbProbe::Failed("timeout".to_string())
827 }
828 };
829 probe.record(verdict.clone());
830 verdict
831 });
832 // A panicking task drops the guard, which releases the claim without
833 // a verdict — the only remaining path to that state, and not one a
834 // caller can drive.
835 task.await.unwrap_or(DbProbe::Unknown)
836 }
837 };
838
839 let uptime = rh.uptime_secs(now);
840 let poller = if !rh.schedulers_enabled() {
841 // Not a fault. Dev runs and the seam tests disable the loops on purpose,
842 // and reporting that as "stale" would be a false alarm on every one.
843 "disabled".to_string()
844 } else {
845 match rh.secs_since_poll_tick(now) {
846 // "Never ticked" is benign right after boot and alarming well after
847 // it — so it is read against UPTIME, not left permanently benign.
848 None => match uptime {
849 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
850 format!("stale never-ticked {up}s")
851 }
852 _ => "not-yet-ticked".to_string(),
853 },
854 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
855 format!("stale {secs}s")
856 }
857 Some(secs) => format!("ok {secs}s"),
858 }
859 };
860
861 // **Only a MEASURED failure fails the check.**
862 //
863 // `Unknown` means no probe has completed — a concurrent request arrived
864 // before the first one finished, or a previous owner was cancelled before
865 // recording. It is reported and returns 200, because an unmeasured database
866 // is not evidence of a broken one, and this endpoint is reachable by
867 // unauthenticated callers who can manufacture that state. Treating it as a
868 // failure handed them a lever on the only signal the platform acts on.
869 let mut body = String::new();
870 let status = match &db {
871 DbProbe::Ok => {
872 body.push_str(&format!("ok featherreader/{VERSION}\n"));
873 body.push_str("db: ok\n");
874 StatusCode::OK
875 }
876 // **Not `ok`.** The first token is the state, and this one is neither
877 // healthy nor failed. It used to print a line byte-identical to the
878 // healthy branch, which mattered because `fly.toml` tells operators to
879 // alert on the BODY for everything the status code deliberately ignores
880 // — so a monitor keying on `^ok` read green in exactly the state this
881 // enum exists to make visible.
882 DbProbe::Unknown => {
883 body.push_str(&format!("unknown featherreader/{VERSION}\n"));
884 body.push_str("db: unknown (no probe has completed yet)\n");
885 StatusCode::OK
886 }
887 DbProbe::Failed(why) => {
888 body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
889 body.push_str(&format!("db: {why}\n"));
890 StatusCode::SERVICE_UNAVAILABLE
891 }
892 };
893 // Uptime answers the first question anyone asks about a container under a
894 // supervisor that tears the machine down whenever a child exits: is this
895 // thing restarting? Nothing else on any surface could tell you.
896 body.push_str(&format!(
897 "uptime: {}\n",
898 match uptime {
899 Some(secs) => format!("{secs}s"),
900 None => "unknown".to_string(),
901 }
902 ));
903 body.push_str(&format!("poller: {poller}\n"));
904 body.push_str(&format!(
905 "polling-paused: {}\n",
906 if rh.watermark_paused() { "yes" } else { "no" }
907 ));
908 // Deliberately NOT the measured database size. `/health` is the one path
909 // exempted from the Caddy origin lock, so it answers direct hits to the Fly
910 // IP that never passed Cloudflare — which caps what belongs here at the
911 // class of facts `/stats` already publishes to anyone. "Polling is paused"
912 // is that; the exact byte count is a precise internal number that adds
913 // nothing an operator cannot get from `/stats` or the logs.
914 body.push_str(&format!(
915 "backend: {}\n",
916 state.config.repo_backend.as_str()
917 ));
918 body.push_str(&format!(
919 "oauth-runtime: {}\n",
920 if state.oauth.is_some() {
921 "built"
922 } else {
923 "absent"
924 }
925 ));
926
927 // Never cached: a stale health response is worse than none, and Cloudflare
928 // sits in front of this.
929 let mut resp = (status, body).into_response();
930 if let Ok(hv) = header::HeaderValue::from_str("no-store") {
931 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
932 }
933 resp
934}
935
936/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
937/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
938/// Readable whether or not a session exists.
939///
940/// Optionally carries one quiet line about network adoption
941/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
942/// default — the handler issues **zero** queries and the page is byte-identical
943/// to what it was before the probe existed.
944async fn about(State(state): State<AppState>) -> Response {
945 let adoption = if state.config.show_adoption {
946 adoption_line(&state).await
947 } else {
948 None
949 };
950 render(&AboutTemplate {
951 version: VERSION,
952 repo_url: REPO_URL,
953 kofi_url: KOFI_URL,
954 adoption,
955 })
956}
957
958/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
959///
960/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
961/// does not have. This deletes the record straight from the repo by its rkey,
962/// and then clears any LOCAL star for the same article.
963///
964/// That second step is not belt-and-braces. "Has no local entry" is how the
965/// starred view classifies a record, and it decides that through `sub_ref` — so
966/// an article that really is cached, and really is starred, lands here whenever
967/// the reader has unsubscribed from its feed. Deleting only the record left
968/// `entry_state.starred = 1` behind: invisible, because the starred list is
969/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
970/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
971/// places it lives.
972async fn unsave_record(
973 State(state): State<AppState>,
974 headers: HeaderMap,
975 Path(rkey): Path<String>,
976) -> Response {
977 let Some(did) = current_did(&state, &headers).await else {
978 return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
979 };
980
981 // Read the record's identity BEFORE deleting it — afterwards there is
982 // nothing left to learn it from. Best-effort: a failure here must not block
983 // the deletion the reader actually asked for, so it degrades to the old
984 // behaviour (record gone, local star possibly stale) and says so.
985 let identity = match state.repo().list_saved(&did).await {
986 Ok(records) => records
987 .into_iter()
988 .find(|(k, _)| *k == rkey)
989 .map(|(_, rec)| (rec.url, rec.entry_id)),
990 Err(err) => {
991 warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
992 a local star for the same article may survive");
993 None
994 }
995 };
996
997 match state.repo().remove_saved(&did, &rkey).await {
998 Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
999 Err(err) => {
1000 warn!(%err, %did, %rkey, "could not remove the saved record");
1001 return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1002 }
1003 }
1004
1005 // Deliberately AFTER the delete: the PDS is the source of truth for what was
1006 // saved, so clearing the local star before knowing the record is gone would
1007 // be the desync in the other direction.
1008 if let Some((url, guid)) = identity {
1009 match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1010 Ok(0) => {}
1011 Ok(n) => {
1012 info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1013 }
1014 Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1015 }
1016 }
1017 // htmx swaps the row out; a plain form post goes back to the starred list.
1018 if is_htmx(&headers) {
1019 return (StatusCode::OK, "").into_response();
1020 }
1021 Redirect::to("/?view=starred").into_response()
1022}
1023
1024/// What the poller is doing, as one word for `/stats`.
1025///
1026/// **Parity with `/health` is the point.** `polling_paused` alone reported
1027/// "running" for three different states including the two where nothing polls,
1028/// on the page added to answer exactly that. The first attempt at fixing it
1029/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1030/// poll loop that ticked once at boot and then WEDGED still read as running.
1031/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1032/// original finding's exact shape surviving its own fix.
1033///
1034/// Shares the staleness threshold with `/health` rather than picking its own, so
1035/// the two pages cannot disagree about what "stale" means.
1036fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1037 if !rh.schedulers_enabled() {
1038 return "off";
1039 }
1040 // Checked before the pause: a wedged poller cannot clear a pause either, so
1041 // reporting "paused" would name the symptom and hide the cause.
1042 match rh.secs_since_poll_tick(now_unix) {
1043 None => {
1044 // Never ticked. Benign at boot, a dead loop long after — read
1045 // against uptime, exactly as `/health` does.
1046 match rh.uptime_secs(now_unix) {
1047 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1048 _ => "starting",
1049 }
1050 }
1051 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1052 _ if rh.watermark_paused() => "paused",
1053 _ => "running",
1054 }
1055}
1056
1057/// `GET /stats` — public poll health.
1058async fn stats(State(state): State<AppState>) -> Response {
1059 let now = chrono::Utc::now();
1060 let health = match store::poll_health(
1061 &state.db,
1062 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1063 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1064 )
1065 .await
1066 {
1067 Ok(health) => health,
1068 Err(err) => {
1069 warn!(%err, "could not compute poll health");
1070 return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1071 }
1072 };
1073
1074 // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1075 // instance is not behind on anything.
1076 let polled_pct = if health.feeds_tracked == 0 {
1077 100
1078 } else {
1079 health.polled_last_hour * 100 / health.feeds_tracked
1080 };
1081
1082 render(&StatsTemplate {
1083 version: VERSION,
1084 repo_url: REPO_URL,
1085 kofi_url: KOFI_URL,
1086 feeds_tracked: health.feeds_tracked,
1087 polled_last_hour: health.polled_last_hour,
1088 polled_pct,
1089 overdue: health.overdue,
1090 last_poll: humanise_ago(health.last_poll_secs_ago),
1091 oldest_poll: if health.never_polled > 0 {
1092 "never".to_string()
1093 } else {
1094 humanise_ago(health.oldest_poll_secs_ago)
1095 },
1096 never_polled: health.never_polled,
1097 poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1098 // **The two states that actually stop feeds updating.**
1099 //
1100 // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1101 // both and distinguish neither — and `overdue` moves the WRONG WAY for
1102 // backoff, since backoff is applied by pushing `next_poll` forward, so a
1103 // feed failing every fetch drops out of the backlog and makes the page
1104 // read healthier. Both of these are machine facts with no per-feed
1105 // detail, so they sit inside the page's stated contract.
1106 in_backoff: health.in_backoff,
1107 badly_broken: health.badly_broken,
1108 failure_kinds: health.failure_kinds,
1109 fetching: fetching_state(&state.runtime_health, now.timestamp()),
1110 })
1111}
1112
1113/// "3h 11m ago", or "never" when there has been no poll at all.
1114///
1115/// `None` must not render as `0` — on a fresh instance that would read as
1116/// "polled just now", which is the opposite of the truth.
1117fn humanise_ago(secs: Option<i64>) -> String {
1118 let Some(secs) = secs else {
1119 return "never".to_string();
1120 };
1121 match secs {
1122 s if s < 60 => format!("{s}s ago"),
1123 s if s < 3600 => format!("{}m ago", s / 60),
1124 s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1125 }
1126}
1127
1128/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1129/// observation of zero, or a store failure).
1130///
1131/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1132/// probe is never allowed to affect the reader, and that rule applies at the
1133/// display end too — a locked or corrupt DB costs the About page one log line,
1134/// not a 500.
1135async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1136 match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1137 // A legitimate zero renders nothing rather than a sad "0 accounts".
1138 Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1139 repos: stat.value,
1140 truncated: stat.truncated,
1141 observed_on: stat
1142 .observed_at
1143 .split('T')
1144 .next()
1145 .unwrap_or_default()
1146 .to_string(),
1147 }),
1148 Ok(_) => None,
1149 Err(err) => {
1150 warn!(%err, "about: adoption stat read failed; omitting the line");
1151 None
1152 }
1153 }
1154}
1155
1156/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1157/// lives in the user's PDS, what the server caches, and the session-token
1158/// handling. A static render; readable whether or not a session exists.
1159async fn privacy() -> Response {
1160 render(&PrivacyTemplate {
1161 version: VERSION,
1162 repo_url: REPO_URL,
1163 kofi_url: KOFI_URL,
1164 })
1165}
1166
1167/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1168/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1169/// static render; readable whether or not a session exists.
1170async fn terms() -> Response {
1171 render(&TermsTemplate {
1172 version: VERSION,
1173 repo_url: REPO_URL,
1174 kofi_url: KOFI_URL,
1175 })
1176}
1177
1178// ---------------------------------------------------------------------------
1179// View models
1180// ---------------------------------------------------------------------------
1181
1182/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1183/// and the PDS subscription rkey for management actions).
1184struct FeedView {
1185 /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1186 rkey: String,
1187 /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1188 url: String,
1189 title: String,
1190 unread: i64,
1191 /// Whether this feed is the currently-selected scope.
1192 selected: bool,
1193 /// The feed's current folder `at://` URI (from its subscription record), or
1194 /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1195 /// rename row so an untouched folder dropdown does not silently un-folder the
1196 /// feed on save.
1197 folder: Option<String>,
1198}
1199
1200/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1201struct FolderView {
1202 /// PDS folder rkey — addresses the record for rename/delete.
1203 rkey: String,
1204 /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1205 uri: String,
1206 name: String,
1207 feeds: Vec<FeedView>,
1208 /// Whether this folder is the currently-selected scope.
1209 selected: bool,
1210}
1211
1212/// One entry as shown in the article list / after an htmx swap.
1213struct EntryRow {
1214 id: i64,
1215 title: String,
1216 feed_title: String,
1217 published: String,
1218 read: bool,
1219 starred: bool,
1220 /// The reader link href, already carrying the scope/view query so opening an
1221 /// entry and paging back stays within the list it came from.
1222 link: SafeLink,
1223 /// Whether the article itself is in this instance's cache.
1224 ///
1225 /// `false` for a saved record that exists in the reader's PDS but whose
1226 /// entry was never cached here — starred in another atproto reader, or
1227 /// starred here and since evicted. There is no local row, so the row has no
1228 /// usable `id`: it links straight out to the article and carries no
1229 /// mark-read control, because there is nothing local to mark.
1230 cached: bool,
1231 /// The PDS record key, for un-saving a row that has no local entry.
1232 rkey: String,
1233}
1234
1235/// A folder as an option in the "move feed to folder" select.
1236struct FolderOption {
1237 uri: String,
1238 name: String,
1239}
1240
1241/// The shared navigation "rail" model: the same DOM element is the
1242/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1243/// manage) renders it from this one struct. Feed management lives on `/manage`,
1244/// not here — the rail is navigation only.
1245struct Nav {
1246 /// `@handle` for the identity chip (falls back to the DID's tail).
1247 handle: String,
1248 /// Two-letter avatar initials for the identity chip.
1249 avatar: String,
1250 /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1251 view: String,
1252 /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1253 /// empty for the unscoped "everything" views.
1254 scope_qs: String,
1255 /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1256 /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1257 folders: Vec<FolderView>,
1258 loose_feeds: Vec<FeedView>,
1259 /// Whether the "Manage feeds" rail tool is the current page.
1260 manage_active: bool,
1261}
1262
1263/// The reader index (`GET /`).
1264#[derive(Template)]
1265#[template(path = "index.html")]
1266struct IndexTemplate {
1267 version: &'static str,
1268 repo_url: &'static str,
1269 kofi_url: &'static str,
1270 flash: String,
1271 /// The shared rail (drawer + desktop sidebar) navigation model.
1272 nav: Nav,
1273 /// The article list for the selected scope + view.
1274 entries: Vec<EntryRow>,
1275 /// The list heading (the selected view/feed/folder name).
1276 heading: String,
1277 /// Whether a feed scope is active (enables per-feed mark-all-read).
1278 feed_scope: Option<String>,
1279 /// Total CACHED entries in this scope + view across ALL pages. The count used
1280 /// to be `entries.len()`, which was the same number only because the list was
1281 /// unpaged — the thing this change exists to stop.
1282 ///
1283 /// The pager is derived from this, so it must not include the uncached PDS
1284 /// rows below: they are appended to the last page rather than paged, and
1285 /// counting them here advertised a page the clamp could never reach.
1286 total: i64,
1287 /// How many of `total` are PDS saved records the cache cannot show.
1288 ///
1289 /// A subset of `total`, not an addition to it — the heading says "N entries
1290 /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1291 /// which double counted once `total` started including them, against an M
1292 /// that had become page-local in the same commit while the template stayed
1293 /// put.
1294 uncached_total: i64,
1295 /// 1-based current page.
1296 page: i64,
1297 /// Total pages, at least 1 (an empty list is page 1 of 1).
1298 page_count: i64,
1299 /// Link to the previous (newer) page, or `None` on the first.
1300 prev_href: Option<String>,
1301 /// Link to the next (older) page, or `None` on the last.
1302 next_href: Option<String>,
1303}
1304
1305/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1306#[derive(Template)]
1307#[template(path = "manage.html")]
1308struct ManageTemplate {
1309 version: &'static str,
1310 repo_url: &'static str,
1311 kofi_url: &'static str,
1312 flash: String,
1313 nav: Nav,
1314 /// All folders as move-targets for the subscribe folder select.
1315 folder_options: Vec<FolderOption>,
1316 /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1317 folders: Vec<FolderView>,
1318 loose_feeds: Vec<FeedView>,
1319}
1320
1321/// The optional one-line adoption fact at the bottom of `/about`
1322/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1323/// probe has succeeded yet, or the read failed — the line then simply does not
1324/// render.
1325struct AdoptionLine {
1326 /// Repos a relay has indexed as holding the subscription collection.
1327 repos: i64,
1328 /// The probe hit its page cap, so the copy must say "at least".
1329 truncated: bool,
1330 /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1331 observed_on: String,
1332}
1333
1334/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1335/// the optional adoption line.
1336#[derive(Template)]
1337#[template(path = "about.html")]
1338struct AboutTemplate {
1339 version: &'static str,
1340 repo_url: &'static str,
1341 kofi_url: &'static str,
1342 adoption: Option<AdoptionLine>,
1343}
1344
1345/// The public `/stats` page — is the poller keeping up?
1346///
1347/// Aggregate only, deliberately. It is published to anyone, so it carries no
1348/// user counts and no per-feed detail: a reader does not need to know how many
1349/// people use an instance or which feeds are failing. What it does answer is the
1350/// question that decides whether an instance can take more readers — whether the
1351/// poller is servicing the feeds it already has.
1352///
1353/// The counts below are aggregate machine facts, which is why they fit that
1354/// contract: "12 feeds are in backoff" names no feed and no reader, while
1355/// answering the question the page was previously unable to answer at all.
1356#[derive(Template)]
1357#[template(path = "stats.html")]
1358struct StatsTemplate {
1359 version: &'static str,
1360 repo_url: &'static str,
1361 kofi_url: &'static str,
1362 feeds_tracked: i64,
1363 polled_last_hour: i64,
1364 polled_pct: i64,
1365 overdue: i64,
1366 last_poll: String,
1367 oldest_poll: String,
1368 never_polled: i64,
1369 poll_interval_mins: i64,
1370 /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1371 in_backoff: i64,
1372 /// Of those, the ones retried hours apart rather than minutes. **Not
1373 /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1374 /// their next successful poll, and most of this instance's did.
1375 badly_broken: i64,
1376 /// Failing feeds by cause, descending — counts only, never which feed.
1377 failure_kinds: Vec<(String, i64)>,
1378 /// What the poller is actually doing: `running`, `paused` (at the size
1379 /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1380 /// disabled). Three of those four used to render as "running".
1381 fetching: &'static str,
1382}
1383
1384/// The public `/privacy` page — what the server holds vs. what lives in the
1385/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1386/// footer include needs.
1387#[derive(Template)]
1388#[template(path = "privacy.html")]
1389struct PrivacyTemplate {
1390 version: &'static str,
1391 repo_url: &'static str,
1392 kofi_url: &'static str,
1393}
1394
1395/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1396/// same fields the shared footer include needs.
1397#[derive(Template)]
1398#[template(path = "terms.html")]
1399struct TermsTemplate {
1400 version: &'static str,
1401 repo_url: &'static str,
1402 kofi_url: &'static str,
1403}
1404
1405/// The signed-out landing page (`GET /` with no session) — the public front
1406/// door at feather-reader.com. A static render, no session required.
1407#[derive(Template)]
1408#[template(path = "landing.html")]
1409struct LandingTemplate {
1410 version: &'static str,
1411 repo_url: &'static str,
1412 crates_url: &'static str,
1413 kofi_url: &'static str,
1414}
1415
1416/// The single-entry reader view (`GET /entries/:id`).
1417#[derive(Template)]
1418#[template(path = "entry.html")]
1419struct EntryTemplate {
1420 version: &'static str,
1421 repo_url: &'static str,
1422 kofi_url: &'static str,
1423 nav: Nav,
1424 id: i64,
1425 title: String,
1426 feed_title: String,
1427 author: Option<String>,
1428 published: String,
1429 /// The entry's own link, for `entry.html`'s two `href`s.
1430 ///
1431 /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1432 /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1433 /// long way from the `href` and holds only while every future writer to
1434 /// `entries.url` remembers to go through `feed.rs` — the same procedural
1435 /// defence that, on the saved-record row, turned out to be deletable with
1436 /// all 679 tests still green. `None` is the refusal: the template's
1437 /// no-URL branch already renders a disabled open-original button.
1438 url: Option<SafeLink>,
1439 content_html: Option<String>,
1440 read: bool,
1441 starred: bool,
1442 /// The query string to carry the reading context back to the list.
1443 back_qs: String,
1444 /// Prev/next entry ids within the current list, for keyboard/paging nav.
1445 prev_id: Option<i64>,
1446 next_id: Option<i64>,
1447 /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1448 oob: bool,
1449}
1450
1451/// The htmx swap fragment for a single entry row (`entry_row.html`).
1452#[derive(Template)]
1453#[template(path = "entry_row.html")]
1454struct EntryRowTemplate {
1455 e: EntryRow,
1456}
1457
1458/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1459/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1460/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1461/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1462#[derive(Template)]
1463#[template(path = "entry_actionbar.html")]
1464struct EntryActionBarTemplate {
1465 id: i64,
1466 read: bool,
1467 starred: bool,
1468 /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1469 oob: bool,
1470}
1471
1472/// The login stub (`GET /login`).
1473#[derive(Template)]
1474#[template(path = "login.html")]
1475struct LoginTemplate {
1476 repo_url: &'static str,
1477 error: String,
1478 /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1479 /// distinct from `error`. Empty renders nothing.
1480 flash: String,
1481}
1482
1483/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1484#[derive(Template)]
1485#[template(path = "beta_redeem.html")]
1486struct BetaRedeemTemplate {
1487 repo_url: &'static str,
1488 error: String,
1489 /// When true the seat cap is full: hide the form and show the "capacity
1490 /// full — try self-hosting" message instead.
1491 capacity_full: bool,
1492}
1493
1494// ---------------------------------------------------------------------------
1495// Rendering + error helpers
1496// ---------------------------------------------------------------------------
1497
1498/// Render an askama template into an HTML response, mapping a render failure to
1499/// a `500` rather than panicking (no `unwrap` in the request path).
1500fn render<T: Template>(tmpl: &T) -> Response {
1501 match tmpl.render() {
1502 Ok(body) => Html(body).into_response(),
1503 Err(err) => {
1504 warn!(%err, "template render failed");
1505 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1506 }
1507 }
1508}
1509
1510/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1511/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1512/// by default; a handler may override the status (e.g. `413` for an over-cap
1513/// upload) via [`WebError::with_status`].
1514struct WebError {
1515 err: anyhow::Error,
1516 status: StatusCode,
1517}
1518
1519impl<E: Into<anyhow::Error>> From<E> for WebError {
1520 fn from(err: E) -> Self {
1521 WebError {
1522 err: err.into(),
1523 status: StatusCode::INTERNAL_SERVER_ERROR,
1524 }
1525 }
1526}
1527
1528impl WebError {
1529 /// Attach an explicit HTTP status to render instead of the default `500`.
1530 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1531 WebError {
1532 err: err.into(),
1533 status,
1534 }
1535 }
1536}
1537
1538impl IntoResponse for WebError {
1539 fn into_response(self) -> Response {
1540 warn!(error = %self.err, status = %self.status, "request failed");
1541 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1542 "internal error"
1543 } else {
1544 self.status.canonical_reason().unwrap_or("error")
1545 };
1546 (self.status, body).into_response()
1547 }
1548}
1549
1550/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1551/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1552/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1553/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1554fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1555 let status = err.status();
1556 WebError::with_status(err, status)
1557}
1558
1559/// A short, human display of a feed/site title for the sidebar/list, falling
1560/// back to the host of a URL and finally to the raw string.
1561fn display_title(title: Option<&str>, url: &str) -> String {
1562 if let Some(t) = title {
1563 let t = t.trim();
1564 if !t.is_empty() {
1565 return t.to_string();
1566 }
1567 }
1568 url::Url::parse(url)
1569 .ok()
1570 .and_then(|u| u.host_str().map(str::to_string))
1571 .unwrap_or_else(|| url.to_string())
1572}
1573
1574/// A display `@handle` for the identity chip: the stored handle if present,
1575/// else the tail of the DID so the chip is never empty.
1576fn display_handle(handle: Option<&str>, did: &str) -> String {
1577 match handle {
1578 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1579 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1580 }
1581}
1582
1583/// Two-letter, lowercase avatar initials from a handle/DID.
1584fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1585 let source = handle
1586 .map(|h| h.trim().trim_start_matches('@'))
1587 .filter(|h| !h.is_empty())
1588 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1589 let letters: String = source
1590 .chars()
1591 .filter(|c| c.is_alphanumeric())
1592 .take(2)
1593 .collect::<String>()
1594 .to_lowercase();
1595 if letters.is_empty() {
1596 "fr".to_string()
1597 } else {
1598 letters
1599 }
1600}
1601
1602/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1603/// low-noise display. Falls back to the raw string if it doesn't look like one.
1604fn display_date(published: Option<&str>) -> String {
1605 // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1606 // multi-byte character, and every caller used to pass a timestamp the feed
1607 // parser had produced. The saved-record path passes `createdAt` straight off
1608 // a PDS record, which the lexicon types as a bare string with no validation
1609 // — written by whatever atproto client the reader used. A `createdAt` of
1610 // "日本語日本語日本" took down the whole starred view, and there is no
1611 // catch-panic layer in the stack, so the page stayed down until the record
1612 // was removed from the very view that would not render.
1613 match published {
1614 Some(p) => p.chars().take(10).collect(),
1615 None => String::new(),
1616 }
1617}
1618
1619/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1620/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1621/// a bare value, and this keeps the scope-preserving links honest.
1622fn qenc(s: &str) -> String {
1623 let mut out = String::with_capacity(s.len() * 3);
1624 for b in s.bytes() {
1625 match b {
1626 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1627 out.push(b as char)
1628 }
1629 _ => out.push_str(&format!("%{b:02X}")),
1630 }
1631 }
1632 out
1633}
1634
1635// ---------------------------------------------------------------------------
1636// Reader: index
1637// ---------------------------------------------------------------------------
1638
1639/// Query for `GET /` — the scope + view selector.
1640#[derive(Debug, Deserialize, Default)]
1641struct IndexQuery {
1642 /// Filter to a single feed by its canonical URL.
1643 #[serde(default)]
1644 feed: Option<String>,
1645 /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1646 #[serde(default)]
1647 folder: Option<String>,
1648 /// `unread` (default) | `all` | `starred`.
1649 #[serde(default)]
1650 view: Option<String>,
1651 /// 1-based page within the selected scope + view. Absent/0 means page 1.
1652 #[serde(default)]
1653 page: Option<u32>,
1654 /// Optional flash message (e.g. after an action redirect).
1655 #[serde(default)]
1656 flash: Option<String>,
1657}
1658
1659/// Rows per page in the reader's list views.
1660///
1661/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1662/// so a page is on the order of tens of kilobytes rather than the tens or
1663/// hundreds of megabytes an unbounded list of full entries could reach. The page
1664/// bound is the second half of that fix: without it, a reader with a long
1665/// backlog still decides how much memory a single request allocates.
1666const ENTRIES_PER_PAGE: i64 = 100;
1667
1668/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1669/// pager reads "1 / 1" rather than "1 / 0".
1670fn page_count_for(total: i64) -> i64 {
1671 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1672}
1673
1674/// Ceiling on the reader's prev/next id list.
1675///
1676/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1677/// reader's position within it — so it is bounded by count rather than paged. At
1678/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1679/// resolving; the article itself still opens, and the list view still pages.
1680const PREV_NEXT_MAX: i64 = 5_000;
1681
1682/// Ceiling on the cached-starred identity set matched against PDS saved records.
1683///
1684/// Deliberately generous: under-reading this set makes a cached article look
1685/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1686/// than un-starring the entry. Truncating here would change what a click
1687/// destroys, so the cap exists only as a backstop against an absurd starred
1688/// count, not as a routine bound.
1689const STARRED_IDENTITY_MAX: i64 = 20_000;
1690
1691/// Most uncached PDS saved records this handler will hold in memory for one
1692/// request.
1693///
1694/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1695/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1696/// this only caps how many are collected before slicing. An earlier version used
1697/// it to cap what was SHOWN, which left everything past it invisible and —
1698/// because the un-save control lives on the row, and nothing else in the app
1699/// lists these — unremovable.
1700///
1701/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1702/// meeting it has thousands of saved records and gets a logged, ordered prefix
1703/// rather than a failure.
1704const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1705
1706/// A subscription resolved against the local cache: the PDS record + its
1707/// (possibly-missing) cached feed row.
1708struct ResolvedSub {
1709 rkey: String,
1710 sub: Subscription,
1711 feed: Option<store::Feed>,
1712}
1713
1714/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1715/// local cache row so unread counts work, and return them resolved. Best-effort
1716/// on the sidecar: a failure falls back to the local cache alone.
1717async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1718 let pool = &state.db;
1719 let subs = match state.repo().list_subscriptions_sorted(did).await {
1720 Ok(s) => s,
1721 Err(err) => {
1722 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
1723 // Fail CLOSED: the PDS is the source of truth for what this DID
1724 // follows. When it is unreachable we must NOT widen the caller's
1725 // authorization surface. Serve from the DID's OWN last-known
1726 // `sub_ref` projection (its own feeds, possibly stale) and leave
1727 // `sub_ref` untouched — never synthesize from every cached feed,
1728 // which would grant cross-tenant read+mutate during any outage.
1729 // A DB failure here is NOT the same as "this DID follows nothing",
1730 // but `unwrap_or_default` rendered it as exactly that: an empty
1731 // sidebar and an empty reader, which arrives as "all my feeds
1732 // vanished". It still degrades to empty — there is nothing better to
1733 // show — but it says so, so the support ticket and the log line can
1734 // be matched up.
1735 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
1736 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
1737 projection could not be read; rendering an EMPTY \
1738 feed list, which is not the same as having none");
1739 Vec::new()
1740 });
1741 return feeds
1742 .into_iter()
1743 .map(|f| ResolvedSub {
1744 rkey: String::new(),
1745 sub: Subscription::new(f.url.clone(), now_rfc3339()),
1746 feed: Some(f),
1747 })
1748 .collect();
1749 }
1750 };
1751
1752 // **Deliberately NOT truncated to `max_subs_per_did`.**
1753 //
1754 // The PDS list is unbounded in practice — any client can write subscription
1755 // records, and only the 20,000-record list ceiling stops it — and the first
1756 // attempt at bounding it truncated the list right here. That was the wrong
1757 // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
1758 // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
1759 // removed the reader's ability to read OR mutate those feeds. A query-shape
1760 // problem would have become an access problem.
1761 //
1762 // The shape problem was the scope filter emitting one SQL placeholder per
1763 // feed; `store::list_query_sql` now passes the whole set as a single
1764 // `json_each` bind, so there is no size to defend against here and nothing
1765 // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
1766 // feeds — rather than becoming a silent read-time filter.
1767 let mut out = Vec::with_capacity(subs.len());
1768 for (rkey, sub) in subs {
1769 let feed = match store::get_feed_by_url(pool, &sub.url).await {
1770 Ok(Some(f)) => Some(f),
1771 Ok(None) => {
1772 // `sub.url` came out of an atproto record. The lexicon is open —
1773 // ANY client can write a subscription into a user's repo — so
1774 // this is untrusted input on the hot path of `GET /`, and it was
1775 // being stored with none of the three checks the add and import
1776 // paths apply. Two of those are capacity ceilings; this one is
1777 // the invariant in `FeedPrivacy`'s doc comment, which promises a
1778 // private feed URL is "never stored". Writing a
1779 // `…/feed/private/<token>` into the SHARED `feeds` table breaks
1780 // that promise even though `net::guarded_get` still refuses to
1781 // fetch it.
1782 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
1783 || feed::classify_feed_privacy(&sub.url).is_private()
1784 {
1785 warn!(
1786 %did,
1787 "skipping cache row for a subscription URL that is private or not http(s)"
1788 );
1789 out.push(ResolvedSub {
1790 rkey,
1791 sub,
1792 feed: None,
1793 });
1794 continue;
1795 }
1796 // Upsert a cache row so the sidebar reflects the real follow-list.
1797 //
1798 // A silent failure here is a support ticket with no evidence: no
1799 // `feeds` row means the poller never selects this subscription,
1800 // so the reader sees "I added a feed and it never updates" while
1801 // the PDS record looks perfect. Logged with the URL so the
1802 // failing subscription is identifiable.
1803 if let Err(err) = store::upsert_feed(
1804 pool,
1805 &store::NewFeed {
1806 url: sub.url.clone(),
1807 title: sub.title.clone(),
1808 site_url: sub.site_url.clone(),
1809 ..Default::default()
1810 },
1811 )
1812 .await
1813 {
1814 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
1815 it will not be polled");
1816 }
1817 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
1818 }
1819 Err(err) => {
1820 warn!(%err, url = %sub.url, "get_feed_by_url failed");
1821 None
1822 }
1823 };
1824 out.push(ResolvedSub { rkey, sub, feed });
1825 }
1826 // Mirror the caller's resolved subscription set into `sub_ref`, so every
1827 // scoped entry/feed read + read/star mutation authorizes against exactly
1828 // the feeds this DID follows right now. This is THE per-DID isolation hook.
1829 sync_sub_refs(pool, did, &out).await;
1830 out
1831}
1832
1833/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
1834/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
1835/// fail closed / show fewer rows), never leaks another user's entries.
1836async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
1837 let feed_ids: Vec<i64> = subs
1838 .iter()
1839 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
1840 .collect();
1841 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
1842 warn!(%err, %did, "failed to sync sub_ref projection");
1843 }
1844}
1845
1846/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
1847/// records layer) and the article list for the selected scope + view.
1848async fn index(
1849 State(state): State<AppState>,
1850 headers: HeaderMap,
1851 Query(q): Query<IndexQuery>,
1852) -> Result<Response, WebError> {
1853 let user = match current_session(&state, &headers).await {
1854 Some(u) => u,
1855 // Signed out: serve the public landing page rather than bouncing to
1856 // /login. /login remains the entry point for the actual OAuth sign-in.
1857 None => {
1858 return Ok(render(&LandingTemplate {
1859 version: VERSION,
1860 repo_url: REPO_URL,
1861 crates_url: CRATES_URL,
1862 kofi_url: KOFI_URL,
1863 }))
1864 }
1865 };
1866 let did = user.did.clone();
1867 let pool = &state.db;
1868
1869 let subs = resolve_subscriptions(&state, &did).await;
1870
1871 // View: unread (default) | all | starred.
1872 let view = match q.view.as_deref() {
1873 Some("all") => "all",
1874 Some("starred") => "starred",
1875 _ => "unread",
1876 }
1877 .to_string();
1878 let list_view = list_view_of(q.view.as_deref());
1879
1880 // Which feed URLs are in scope?
1881 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1882 // …and the feed ids they resolve to. Scope is applied inside the query now,
1883 // so a page is a page of rows the reader will actually see. Filtering after
1884 // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
1885 // any scope narrower than the whole subscription list.
1886 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
1887
1888 let feed_title_by_id = |id: i64| -> String {
1889 subs.iter()
1890 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1891 .map(|s| {
1892 display_title(
1893 s.sub
1894 .title
1895 .as_deref()
1896 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1897 &s.sub.url,
1898 )
1899 })
1900 .unwrap_or_default()
1901 };
1902
1903 // **One page of the chosen view, filtered, ordered and bounded in SQL.**
1904 //
1905 // All three views used to materialize every matching entry — `SELECT e.*`,
1906 // no `LIMIT`, article bodies included — and the "all" view additionally ran
1907 // one such query PER SUBSCRIBED FEED and merged the results in memory. None
1908 // of the row fields below read the body. See `store::EntryListRow`.
1909 // **Saved records the cache cannot show.**
1910 //
1911 // The starred view is built from local `entries`, so a saved record whose
1912 // article was never cached here is invisible — the case that matters is
1913 // starring in ANOTHER atproto reader, which is the portability the shared
1914 // lexicon exists for. Those rows are rendered from the PDS record alone.
1915 let mut uncached: Vec<EntryRow> = Vec::new();
1916 if view == "starred" {
1917 // **Match against every SUBSCRIBED cached starred entry, not `source`.**
1918 //
1919 // `source` has already been filtered by feed/folder. Matching against it
1920 // meant an entry that IS cached but sits outside the current filter
1921 // looked uncached — so it rendered as a "not cached" row whose star
1922 // button deletes the PDS RECORD instead of un-starring the entry. A
1923 // scope filter must not change what is destroyed. Paging is the same
1924 // hazard in a new form: matching against the visible PAGE would make
1925 // every cached article outside it look uncached. Hence a dedicated
1926 // identity query over the whole starred set — urls and guids only, no
1927 // bodies — rather than reusing `source`.
1928 //
1929 // One gap remains BY DESIGN, and is handled at the other end. This query
1930 // still carries the `sub_ref` predicate, so a starred, cached entry in a
1931 // feed the reader has UNSUBSCRIBED from is absent here and its record
1932 // renders as uncached. That is the right rendering — the article is no
1933 // longer part of any feed the reader follows, and the PDS record is what
1934 // still holds it — but it means the un-save button is the record-deleting
1935 // one. `unsave_record` therefore clears the local star too, so the two
1936 // stores agree however the row got classified. Dropping the predicate
1937 // here instead would have made the row link to `/entries/{id}`, which is
1938 // `sub_ref`-scoped and would 404.
1939 //
1940 // **Three ways this can be unusable, and all three fail CLOSED.** With an
1941 // incomplete identity set, a cached article looks uncached and renders an
1942 // un-save button that deletes the PDS RECORD. Showing no uncached rows
1943 // loses rows for one render; getting this wrong loses data permanently,
1944 // so every uncertain case suppresses them.
1945 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
1946 Ok(store::StarredIdentities::All(rows)) => Some(rows),
1947 // The cap is a memory backstop, and reaching it means the set is an
1948 // arbitrary subset. It used to return that subset with no way to
1949 // tell, so every starred article outside it got the destructive
1950 // button.
1951 Ok(store::StarredIdentities::Truncated) => {
1952 warn!(
1953 %did,
1954 cap = STARRED_IDENTITY_MAX,
1955 "cached-starred set exceeded its cap; suppressing uncached saved rows \
1956 rather than rendering record-deleting buttons for cached articles"
1957 );
1958 None
1959 }
1960 Err(err) => {
1961 warn!(%err, %did, "cached-starred identity lookup failed; \
1962 suppressing uncached saved rows this render");
1963 None
1964 }
1965 };
1966 // The escape hatch asks whether this DID has ANY cached starred entry —
1967 // not whether the current SCOPE does. `total` is narrowed by
1968 // `?feed=`/`?folder=` while the identity set spans every feed, so
1969 // comparing them waved the fail-closed condition through for any narrow
1970 // scope: a record whose `feedUrl` matched the filter while its cached
1971 // entry lived under another feed rendered as uncached.
1972 let identities_ok = identities.is_some();
1973 let identities = identities.unwrap_or_default();
1974 let cached_urls: std::collections::HashSet<&str> = identities
1975 .iter()
1976 .filter_map(|(url, _)| url.as_deref())
1977 .collect();
1978 let cached_guids: std::collections::HashSet<&str> =
1979 identities.iter().map(|(_, guid)| guid.as_str()).collect();
1980
1981 // Collected in full here, sliced per page later. They sort after every
1982 // cached row, so the two lists form one sequence that the pager walks —
1983 // see the slice below. Collected BEFORE the page is chosen because the
1984 // page count depends on how many there are.
1985 // Bounded like everything else on this page. These come from the PDS
1986 // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
1987 // `backend=rust`, whose caps are a quarter of the other's) and are
1988 // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
1989 // constrain them at all. The
1990 // cap is generous — a reader with more saved-elsewhere records than this
1991 // is not the case being designed for — but a response has to have a size
1992 // an operator can reason about.
1993 let mut uncached_dropped = 0usize;
1994 match state.repo().list_saved_sorted(&did).await {
1995 Ok(saved) if identities_ok => {
1996 for (rkey, item) in saved {
1997 let known = cached_urls.contains(item.url.as_str())
1998 || item
1999 .entry_id
2000 .as_deref()
2001 .is_some_and(|g| cached_guids.contains(g));
2002 if known {
2003 continue;
2004 }
2005 // And the scope filter applies to these rows too. Without
2006 // it, `?feed=X` still listed saved records from every other
2007 // feed — the filter silently did nothing for them.
2008 if let Some(urls) = &scope_urls {
2009 match item.feed_url.as_deref() {
2010 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2011 // A saved record with no `feedUrl` cannot be placed
2012 // in any feed's scope, so it belongs only to the
2013 // unfiltered view.
2014 _ => continue,
2015 }
2016 }
2017 // **`safe_link` FIRST, and a failure no longer drops the row.**
2018 //
2019 // `item.url` is attacker-controlled — a saved record written
2020 // by any client — and it lands in an `href`. Askama escapes
2021 // HTML metacharacters but not SCHEMES, so `javascript:`
2022 // survives escaping intact. This project already built the
2023 // helper for exactly that, and `feed.rs` uses it on the
2024 // equivalent link; this path was simply not routed through it.
2025 //
2026 // The real defect was what a failure DID: it `continue`d, so
2027 // the row vanished entirely — no badge, no count, nothing —
2028 // and the only trace was a `debug!` below any realistic
2029 // filter. That makes the record unremovable FROM HERE, because
2030 // the un-save button lives on the row; the reader has to open
2031 // a different atproto client to get rid of it. A bad URL is a
2032 // reason to withhold the LINK, not the row.
2033 //
2034 // The check also moved ABOVE the poll nudge. That is ordering
2035 // hygiene rather than a fix: the nudge keys on `feed_url`, not
2036 // on the URL being rejected here, and is already gated on the
2037 // reader actually subscribing to that feed — so it was never
2038 // reachable by an unusable `item.url`. Deciding whether a
2039 // record is renderable before doing anything outbound on its
2040 // behalf is simply the order that stays correct if either of
2041 // those two facts later stops being true.
2042 let link = SafeLink::external(&item.url);
2043 if link.is_empty() {
2044 warn!(
2045 %did, %rkey,
2046 "a saved record has an unusable URL; rendering it without a link \
2047 so it can still be removed"
2048 );
2049 }
2050
2051 // Opportunistic re-fetch: if the reader still subscribes to
2052 // the feed, make it due now. If the article is still inside
2053 // the feed's window the poller caches it normally and this
2054 // row becomes a real entry on its own — no synthetic rows in
2055 // the shared cache, which every subscriber would otherwise
2056 // see as a content-less entry.
2057 // **Bound the WORK, not just the response.** This check sat
2058 // after the nudge and the `subs` scan below, so every render
2059 // still walked all ≤20,000 PDS records, ran a subs-length
2060 // string scan per record, and issued up to that many
2061 // `mark_feed_due` round-trips on a 5-connection pool — then
2062 // discarded everything past the cap. A cap that runs after
2063 // the expensive part is a cap on the output only.
2064 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2065 uncached_dropped += 1;
2066 continue;
2067 }
2068 if let Some(feed_url) = item.feed_url.as_deref() {
2069 if subs.iter().any(|s| s.sub.url == feed_url) {
2070 // Bounded to one nudge per feed per poll interval —
2071 // see `mark_feed_due`. Unbounded, a reload loop here
2072 // becomes outbound amplification.
2073 let stale_before = (chrono::Utc::now()
2074 - chrono::Duration::from_std(state.config.poll_interval)
2075 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2076 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2077 if let Err(err) =
2078 store::mark_feed_due(pool, feed_url, &stale_before).await
2079 {
2080 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2081 }
2082 }
2083 }
2084 uncached.push(EntryRow {
2085 id: 0,
2086 title: item
2087 .title
2088 .clone()
2089 .filter(|t| !t.trim().is_empty())
2090 // Falling back to the URL is fine for a link we are
2091 // willing to render, and wrong for one we are not:
2092 // it would put the exact string `safe_link` just
2093 // rejected into the page as the record's name. The
2094 // rkey is what the un-save button acts on, so it is
2095 // the honest identifier for a row that has nothing
2096 // else trustworthy to show.
2097 .unwrap_or_else(|| {
2098 if link.is_empty() {
2099 format!("Saved item {rkey}")
2100 } else {
2101 item.url.clone()
2102 }
2103 }),
2104 feed_title: item.feed_url.clone().unwrap_or_default(),
2105 published: display_date(Some(&item.created_at)),
2106 read: false,
2107 starred: true,
2108 // Empty = "render this row without an anchor". The
2109 // template branches on it, so the rejected URL never
2110 // reaches an `href` even as an escaped string.
2111 link,
2112 cached: false,
2113 rkey,
2114 });
2115 }
2116 }
2117 // Identity lookup was unusable — see the fail-closed note above.
2118 Ok(_) => {}
2119 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2120 }
2121 if uncached_dropped > 0 {
2122 warn!(
2123 %did,
2124 dropped = uncached_dropped,
2125 cap = MAX_UNCACHED_SAVED_ROWS,
2126 "more saved records than this instance will hold in one response; the \
2127 rest are not reachable from here"
2128 );
2129 }
2130 }
2131
2132 // **One sequence, two sources.** The cached rows come from SQL, the uncached
2133 // PDS records follow them, and the pager walks the concatenation.
2134 //
2135 // The first version appended the uncached rows to the last page only and
2136 // kept them out of `total`, which left everything past a cap invisible AND
2137 // unremovable — the un-save button lives on the row, and there is no other
2138 // surface in the app that lists these. That is the same "unremovable FROM
2139 // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2140 // forty lines later by a bound meant to protect memory.
2141 //
2142 // Paging the concatenation makes every record reachable and needs no cap on
2143 // what is RENDERED — one page is one page either way. The version before
2144 // that inflated `total` while clamping on the cached count, which advertised
2145 // a page the clamp could never reach; both numbers come from the same total
2146 // now, which is what makes that impossible rather than merely fixed.
2147 let total_cached =
2148 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2149 let uncached_len = uncached.len();
2150 let total = total_cached + uncached_len as i64;
2151 // Clamped to the range that exists. Past the end the list is empty, and the
2152 // empty state renders instead of the pager — which would strand a reader who
2153 // typed a page number, or who paged to the end and then marked entries read
2154 // out from under their own URL. Showing the last page is the answer to both.
2155 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2156 let offset = (page - 1) * ENTRIES_PER_PAGE;
2157 // Past the cached rows this returns nothing, which is exactly right: the
2158 // page is then made up entirely of uncached ones.
2159 let source = store::list_entries(
2160 pool,
2161 &did,
2162 list_view,
2163 scope_ids.as_deref(),
2164 ENTRIES_PER_PAGE,
2165 offset,
2166 )
2167 .await?;
2168 // **Both halves of the page are computed from the COUNT alone.**
2169 //
2170 // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2171 // queries, so they can disagree about how many cached rows exist. Any part of
2172 // the page composition that reads `source.len()` inherits that disagreement.
2173 //
2174 // `cached_allotment` is this page's cached share according to the snapshot,
2175 // and it is what the uncached `skip`/`take` are derived from — so consecutive
2176 // pages tile the uncached list exactly, whichever way the count drifted.
2177 // `source` is then truncated to it only to avoid rendering rows the next page
2178 // will also claim.
2179 //
2180 // The previous version took `skip` from the count but `take` from
2181 // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2182 // an un-star or a retention delete landing between the two queries — made
2183 // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2184 // putting twenty rows, each carrying the record-DELETING un-save button, on
2185 // two pages at once. The comment claimed that shape was impossible; it was
2186 // merely rarer.
2187 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2188 let cached_here = cached_allotment.min(source.len());
2189 // Only compose when there is something to compose WITH. `uncached` is empty
2190 // on every view but `starred`, and truncating there just drops trailing rows
2191 // that no page then shows — the poller inserting between the COUNT and the
2192 // SELECT was enough to trigger it.
2193 let source = if uncached_len == 0 {
2194 &source[..]
2195 } else {
2196 &source[..cached_here]
2197 };
2198 let uncached_page: Vec<EntryRow> = {
2199 let skip = (offset - total_cached).max(0) as usize;
2200 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2201 uncached.into_iter().skip(skip).take(take).collect()
2202 };
2203 // This page's slice, used only to append below. The heading needs the
2204 // WHOLE-list figure, which is the set's size before slicing.
2205 let uncached_total = uncached_len as i64;
2206
2207 // The scope/view suffix carried onto every entry link (built once).
2208 let entry_scope_qs = {
2209 let mut parts = Vec::new();
2210 if let Some(f) = q.feed.as_deref() {
2211 parts.push(format!("feed={}", qenc(f)));
2212 }
2213 if let Some(f) = q.folder.as_deref() {
2214 parts.push(format!("folder={}", qenc(f)));
2215 }
2216 if view != "unread" {
2217 parts.push(format!("view={}", qenc(&view)));
2218 }
2219 parts.join("&")
2220 };
2221 let entries: Vec<EntryRow> = source
2222 .iter()
2223 .map(|e| EntryRow {
2224 id: e.id,
2225 title: e
2226 .title
2227 .clone()
2228 .filter(|t| !t.trim().is_empty())
2229 .unwrap_or_else(|| "(untitled)".to_string()),
2230 feed_title: feed_title_by_id(e.feed_id),
2231 published: display_date(e.published.as_deref()),
2232 // Both bits ride along on the row's own `entry_state` join now. They
2233 // used to be membership tests against the full unread and starred
2234 // sets, which is why those two lists were fetched in their entirety
2235 // on every render even when the page showed a hundred rows.
2236 read: e.read,
2237 starred: e.starred,
2238 link: SafeLink::entry(e.id, &entry_scope_qs),
2239 cached: true,
2240 rkey: String::new(),
2241 })
2242 .collect();
2243
2244 // The uncached slice for this page follows the cached rows.
2245 let mut entries = entries;
2246 entries.extend(uncached_page);
2247 let entries = entries;
2248
2249 let selected_feed = q.feed.as_deref();
2250 let selected_folder = q.folder.as_deref();
2251
2252 // Build the shared sidebar (folders + loose feeds, with unread counts).
2253 let (folder_views, loose_feeds, _folder_options) =
2254 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2255
2256 // Heading + scope query-string suffix.
2257 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2258 let name = subs
2259 .iter()
2260 .find(|s| s.sub.url == feed_url)
2261 .map(|s| {
2262 display_title(
2263 s.sub
2264 .title
2265 .as_deref()
2266 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2267 &s.sub.url,
2268 )
2269 })
2270 .unwrap_or_else(|| display_title(None, feed_url));
2271 (name, format!("feed={}", qenc(feed_url)))
2272 } else if let Some(folder_uri) = selected_folder {
2273 let name = folder_views
2274 .iter()
2275 .find(|f| f.uri == folder_uri)
2276 .map(|f| f.name.clone())
2277 .unwrap_or_else(|| "Folder".to_string());
2278 (name, format!("folder={}", qenc(folder_uri)))
2279 } else {
2280 let h = match view.as_str() {
2281 "all" => "All",
2282 "starred" => "Starred",
2283 _ => "Unread",
2284 };
2285 (h.to_string(), String::new())
2286 };
2287
2288 let feed_scope = selected_feed.map(str::to_string);
2289 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2290
2291 // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2292 // page number is the only thing appended — which keeps a paged link
2293 // identical to an unpaged one in every other respect.
2294 let page_href = |n: i64| -> String {
2295 let mut parts = Vec::new();
2296 if !entry_scope_qs.is_empty() {
2297 parts.push(entry_scope_qs.clone());
2298 }
2299 if n > 1 {
2300 parts.push(format!("page={n}"));
2301 }
2302 if parts.is_empty() {
2303 "/".to_string()
2304 } else {
2305 format!("/?{}", parts.join("&"))
2306 }
2307 };
2308 let prev_href = (page > 1).then(|| page_href(page - 1));
2309 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2310
2311 let tmpl = IndexTemplate {
2312 version: VERSION,
2313 repo_url: REPO_URL,
2314 kofi_url: KOFI_URL,
2315 flash: q.flash.unwrap_or_default(),
2316 nav,
2317 entries,
2318 heading,
2319 feed_scope,
2320 total,
2321 // Whole-list figure, so it sits beside `total` without double counting.
2322 // The per-page slice is composed above and is not a heading number.
2323 uncached_total,
2324 page,
2325 page_count: page_count_for(total),
2326 prev_href,
2327 next_href,
2328 };
2329 Ok(render(&tmpl))
2330}
2331
2332/// Query for `GET /manage` — carries an optional flash after an action redirect.
2333#[derive(Debug, Deserialize, Default)]
2334struct ManageQuery {
2335 #[serde(default)]
2336 flash: Option<String>,
2337}
2338
2339/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2340/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2341/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2342/// mutation logic of its own.
2343async fn manage(
2344 State(state): State<AppState>,
2345 headers: HeaderMap,
2346 Query(q): Query<ManageQuery>,
2347) -> Result<Response, WebError> {
2348 let user = match current_session(&state, &headers).await {
2349 Some(u) => u,
2350 None => return Ok(Redirect::to("/login").into_response()),
2351 };
2352 let did = user.did.clone();
2353
2354 let subs = resolve_subscriptions(&state, &did).await;
2355 let (folder_views, loose_feeds, folder_options) =
2356 build_sidebar(&state, &did, &subs, None, None).await;
2357
2358 // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2359 let nav = build_nav(
2360 &user,
2361 "unread",
2362 String::new(),
2363 folder_views.iter().map(clone_folder_view).collect(),
2364 loose_feeds.iter().map(clone_feed_view).collect(),
2365 true,
2366 );
2367
2368 let tmpl = ManageTemplate {
2369 version: VERSION,
2370 repo_url: REPO_URL,
2371 kofi_url: KOFI_URL,
2372 flash: q.flash.unwrap_or_default(),
2373 nav,
2374 folder_options,
2375 folders: folder_views,
2376 loose_feeds,
2377 };
2378 Ok(render(&tmpl))
2379}
2380
2381/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2382/// (`Nav`) and the page body without an extra DB round-trip.
2383fn clone_feed_view(f: &FeedView) -> FeedView {
2384 FeedView {
2385 rkey: f.rkey.clone(),
2386 url: f.url.clone(),
2387 title: f.title.clone(),
2388 unread: f.unread,
2389 selected: f.selected,
2390 folder: f.folder.clone(),
2391 }
2392}
2393
2394fn clone_folder_view(f: &FolderView) -> FolderView {
2395 FolderView {
2396 rkey: f.rkey.clone(),
2397 uri: f.uri.clone(),
2398 name: f.name.clone(),
2399 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2400 selected: f.selected,
2401 }
2402}
2403
2404/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2405/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2406/// unscoped "everything" view. A folder scope takes the feed scope when both are
2407/// somehow present (feed wins, matching the query precedence elsewhere).
2408fn scope_urls_for(
2409 subs: &[ResolvedSub],
2410 feed: Option<&str>,
2411 folder: Option<&str>,
2412) -> Option<Vec<String>> {
2413 if let Some(feed_url) = feed {
2414 Some(vec![feed_url.to_string()])
2415 } else {
2416 folder.map(|folder_uri| {
2417 subs.iter()
2418 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2419 .map(|s| s.sub.url.clone())
2420 .collect()
2421 })
2422 }
2423}
2424
2425/// The `at://` URI for a folder record given the owner DID + rkey.
2426fn folder_uri(did: &str, rkey: &str) -> String {
2427 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2428}
2429
2430/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2431/// DID — the shared source for both the reader index and the rail on every
2432/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2433async fn build_sidebar(
2434 state: &AppState,
2435 did: &str,
2436 subs: &[ResolvedSub],
2437 selected_feed: Option<&str>,
2438 selected_folder: Option<&str>,
2439) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2440 let pool = &state.db;
2441 // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2442 // all — purely to `.filter().count()` them in Rust, on every page that
2443 // renders chrome, which made the sidebar the most frequently executed
2444 // instance of the unbounded-projection problem.
2445 let unread_counts = store::unread_counts_by_feed(pool, did)
2446 .await
2447 .unwrap_or_else(|err| {
2448 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2449 Default::default()
2450 });
2451 let folders = state
2452 .repo()
2453 .list_folders_sorted(did)
2454 .await
2455 .unwrap_or_default();
2456
2457 let unread_count = |feed_id: Option<i64>| -> i64 {
2458 feed_id
2459 .and_then(|id| unread_counts.get(&id).copied())
2460 .unwrap_or(0)
2461 };
2462 let mk_feed_view = |s: &ResolvedSub| FeedView {
2463 rkey: s.rkey.clone(),
2464 url: s.sub.url.clone(),
2465 title: display_title(
2466 s.sub
2467 .title
2468 .as_deref()
2469 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2470 &s.sub.url,
2471 ),
2472 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2473 selected: selected_feed == Some(s.sub.url.as_str()),
2474 folder: s.sub.folder.clone(),
2475 };
2476
2477 let mut folder_views = Vec::with_capacity(folders.len());
2478 for (rkey, folder) in &folders {
2479 let uri = folder_uri(did, rkey);
2480 let feeds: Vec<FeedView> = subs
2481 .iter()
2482 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2483 .map(mk_feed_view)
2484 .collect();
2485 folder_views.push(FolderView {
2486 rkey: rkey.clone(),
2487 uri: uri.clone(),
2488 name: folder.name.clone(),
2489 feeds,
2490 selected: selected_folder == Some(uri.as_str()),
2491 });
2492 }
2493
2494 let known_uris: std::collections::HashSet<String> =
2495 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2496 let loose_feeds: Vec<FeedView> = subs
2497 .iter()
2498 .filter(|s| {
2499 s.sub
2500 .folder
2501 .as_deref()
2502 .map(|f| !known_uris.contains(f))
2503 .unwrap_or(true)
2504 })
2505 .map(mk_feed_view)
2506 .collect();
2507
2508 let folder_options: Vec<FolderOption> = folders
2509 .iter()
2510 .map(|(rkey, folder)| FolderOption {
2511 name: folder.name.clone(),
2512 uri: folder_uri(did, rkey),
2513 })
2514 .collect();
2515
2516 (folder_views, loose_feeds, folder_options)
2517}
2518
2519/// Assemble the shared rail [`Nav`] for a chrome page.
2520fn build_nav(
2521 user: &CurrentUser,
2522 view: &str,
2523 scope_qs: String,
2524 folders: Vec<FolderView>,
2525 loose_feeds: Vec<FeedView>,
2526 manage_active: bool,
2527) -> Nav {
2528 Nav {
2529 handle: display_handle(user.handle.as_deref(), &user.did),
2530 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2531 view: view.to_string(),
2532 scope_qs,
2533 folders,
2534 loose_feeds,
2535 manage_active,
2536 }
2537}
2538
2539// ---------------------------------------------------------------------------
2540// Reader: single entry
2541// ---------------------------------------------------------------------------
2542
2543/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2544/// prev/next and "back" stay within the list the reader came from.
2545#[derive(Debug, Deserialize, Default)]
2546struct EntryQuery {
2547 #[serde(default)]
2548 feed: Option<String>,
2549 #[serde(default)]
2550 folder: Option<String>,
2551 #[serde(default)]
2552 view: Option<String>,
2553}
2554
2555/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2556/// within the current reading list.
2557async fn entry_view(
2558 State(state): State<AppState>,
2559 headers: HeaderMap,
2560 Path(id): Path<i64>,
2561 Query(q): Query<EntryQuery>,
2562) -> Result<Response, WebError> {
2563 let user = match current_session(&state, &headers).await {
2564 Some(u) => u,
2565 None => return Ok(Redirect::to("/login").into_response()),
2566 };
2567 let did = user.did.clone();
2568 let pool = &state.db;
2569
2570 // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2571 // the per-DID entry gate below authorizes against the caller's current PDS
2572 // subscription set (not another user's cached feeds).
2573 let subs = resolve_subscriptions(&state, &did).await;
2574
2575 let entry = match get_entry_by_id(pool, &did, id).await? {
2576 Some(e) => e,
2577 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2578 };
2579
2580 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2581
2582 let read = entry_is_read(pool, &did, id).await?;
2583 let starred = entry_is_starred(pool, &did, id).await?;
2584
2585 // Reconstruct the current list to compute prev/next, so paging in the reader
2586 // matches what the list showed.
2587 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2588
2589 let back_qs = scope_query(&q);
2590
2591 let (folder_views, loose_feeds, _) =
2592 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2593 let nav_view = match q.view.as_deref() {
2594 Some("all") => "all",
2595 Some("starred") => "starred",
2596 _ => "unread",
2597 };
2598 let nav = build_nav(
2599 &user,
2600 nav_view,
2601 back_qs.clone(),
2602 folder_views,
2603 loose_feeds,
2604 false,
2605 );
2606
2607 let tmpl = EntryTemplate {
2608 version: VERSION,
2609 repo_url: REPO_URL,
2610 kofi_url: KOFI_URL,
2611 nav,
2612 id: entry.id,
2613 title: entry
2614 .title
2615 .clone()
2616 .filter(|t| !t.trim().is_empty())
2617 .unwrap_or_else(|| "(untitled)".to_string()),
2618 feed_title,
2619 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2620 published: display_date(entry.published.as_deref()),
2621 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2622 content_html: entry.content_html.clone(),
2623 read,
2624 starred,
2625 back_qs,
2626 prev_id,
2627 next_id,
2628 oob: false,
2629 };
2630 Ok(render(&tmpl))
2631}
2632
2633/// Compute the prev/next entry ids around `current` within the reader's current
2634/// scope + view, so the reader view can offer keyboard/paging navigation.
2635async fn neighbors_in_scope(
2636 state: &AppState,
2637 did: &str,
2638 q: &EntryQuery,
2639 current: i64,
2640) -> (Option<i64>, Option<i64>) {
2641 let idx_q = IndexQuery {
2642 feed: q.feed.clone(),
2643 folder: q.folder.clone(),
2644 view: q.view.clone(),
2645 // Neighbours span the whole list, not the page the reader arrived from.
2646 page: None,
2647 flash: None,
2648 };
2649 let ids = list_entry_ids(state, did, &idx_q).await;
2650 let pos = ids.iter().position(|&x| x == current);
2651 match pos {
2652 Some(p) => {
2653 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2654 let next = ids.get(p + 1).copied();
2655 (prev, next)
2656 }
2657 None => (None, None),
2658 }
2659}
2660
2661/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2662/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2663async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2664 let pool = &state.db;
2665 let subs = resolve_subscriptions(state, did).await;
2666
2667 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2668
2669 // Ids only, and bounded. This used to fetch whole entries — bodies included
2670 // — for all three views and then throw everything but `id` away; the "all"
2671 // branch additionally ran one unbounded query PER FEED and sorted the union
2672 // in memory. Scope is now a feed-id restriction inside the query, so the
2673 // database does the filtering and the ordering exactly once.
2674 store::list_entry_ids(
2675 pool,
2676 did,
2677 list_view_of(q.view.as_deref()),
2678 scoped_feed_ids(&subs, &scope_urls).as_deref(),
2679 PREV_NEXT_MAX,
2680 )
2681 .await
2682 .unwrap_or_else(|err| {
2683 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2684 Vec::new()
2685 })
2686}
2687
2688/// Map the `?view=` query value onto the store's list view. Anything
2689/// unrecognised is the unread default, matching `index`.
2690fn list_view_of(view: Option<&str>) -> store::ListView {
2691 match view {
2692 Some("all") => store::ListView::All,
2693 Some("starred") => store::ListView::Starred,
2694 _ => store::ListView::Unread,
2695 }
2696}
2697
2698/// Translate a feed/folder scope into the feed ids to restrict a list query to.
2699///
2700/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
2701/// matched no local feed, which must return nothing rather than everything — so
2702/// the empty vec is deliberately preserved, not collapsed back into `None`.
2703fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2704 let urls = scope_urls.as_ref()?;
2705 Some(
2706 subs.iter()
2707 .filter(|s| urls.contains(&s.sub.url))
2708 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2709 .collect(),
2710 )
2711}
2712
2713/// Build a `?…` query string that preserves the reading scope + view for links.
2714fn scope_query(q: &EntryQuery) -> String {
2715 let mut parts = Vec::new();
2716 if let Some(f) = q.feed.as_deref() {
2717 parts.push(format!("feed={}", qenc(f)));
2718 }
2719 if let Some(f) = q.folder.as_deref() {
2720 parts.push(format!("folder={}", qenc(f)));
2721 }
2722 if let Some(v) = q.view.as_deref() {
2723 if v != "unread" {
2724 parts.push(format!("view={}", qenc(v)));
2725 }
2726 }
2727 parts.join("&")
2728}
2729
2730// ---------------------------------------------------------------------------
2731// Mark read / unread
2732// ---------------------------------------------------------------------------
2733
2734/// Form body for `POST /entries/:id/read`.
2735#[derive(Debug, Deserialize)]
2736struct ReadForm {
2737 #[serde(default)]
2738 read: Option<String>,
2739}
2740
2741/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
2742async fn mark_read(
2743 State(state): State<AppState>,
2744 Path(id): Path<i64>,
2745 headers: HeaderMap,
2746 Form(form): Form<ReadForm>,
2747) -> Result<Response, WebError> {
2748 let did = match current_did(&state, &headers).await {
2749 Some(d) => d,
2750 None => return Ok(Redirect::to("/login").into_response()),
2751 };
2752 let pool = &state.db;
2753
2754 let read = matches!(
2755 form.read.as_deref(),
2756 Some("true") | Some("1") | Some("on") | None
2757 );
2758
2759 // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2760 // mutation: `mark_read` only writes when `did` subscribes to the entry's
2761 // feed. A non-subscriber gets a 404, never a mutation of someone else's
2762 // (or the shared cache's) state.
2763 resolve_subscriptions(&state, &did).await;
2764 if !store::mark_read(pool, &did, id, read).await? {
2765 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2766 }
2767
2768 if !is_htmx(&headers) {
2769 return Ok(Redirect::to("/").into_response());
2770 }
2771
2772 // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
2773 // in the DOM), so its button's hidden value + aria-pressed update in place
2774 // and a second keypress can reverse the toggle. The list view swaps the row.
2775 if is_reader_request(&headers) {
2776 let starred = entry_is_starred(pool, &did, id).await?;
2777 return Ok(render(&EntryActionBarTemplate {
2778 id,
2779 read,
2780 starred,
2781 oob: true,
2782 }));
2783 }
2784
2785 let row = build_entry_row(pool, &did, id, Some(read)).await?;
2786 match row {
2787 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2788 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2789 }
2790}
2791
2792// ---------------------------------------------------------------------------
2793// Star / save
2794// ---------------------------------------------------------------------------
2795
2796/// Form body for `POST /entries/:id/star`.
2797#[derive(Debug, Deserialize)]
2798struct StarForm {
2799 #[serde(default)]
2800 starred: Option<String>,
2801}
2802
2803/// `POST /entries/:id/star` — star/unstar an entry.
2804///
2805/// Sets the local `starred` bit (fast working copy) and writes/removes a
2806/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
2807/// owning). The PDS write is best-effort — the local star still lands.
2808async fn toggle_star(
2809 State(state): State<AppState>,
2810 Path(id): Path<i64>,
2811 headers: HeaderMap,
2812 Form(form): Form<StarForm>,
2813) -> Result<Response, WebError> {
2814 let did = match current_did(&state, &headers).await {
2815 Some(d) => d,
2816 None => return Ok(Redirect::to("/login").into_response()),
2817 };
2818 let pool = &state.db;
2819
2820 let starred = matches!(
2821 form.starred.as_deref(),
2822 Some("true") | Some("1") | Some("on") | None
2823 );
2824
2825 // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2826 // mutation: `mark_starred` only writes when `did` subscribes to the entry's
2827 // feed. A non-subscriber gets a 404, never a mutation.
2828 resolve_subscriptions(&state, &did).await;
2829 if !store::mark_starred(pool, &did, id, starred).await? {
2830 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2831 }
2832
2833 // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
2834 // to the caller's subscriptions, so this only ever acts on the caller's feed.
2835 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2836 let entry_url = entry.url.clone().unwrap_or_default();
2837 if !entry_url.is_empty() {
2838 if starred {
2839 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2840 saved.title = entry.title.clone();
2841 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2842 saved.entry_id = Some(entry.guid.clone());
2843 match state.repo().add_saved(&did, &saved).await {
2844 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2845 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2846 }
2847 } else {
2848 // Un-star: find and delete the matching saved record by URL.
2849 match state.repo().list_saved(&did).await {
2850 Ok(records) => {
2851 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2852 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2853 warn!(%err, %did, %rkey, "PDS saved delete failed");
2854 }
2855 }
2856 }
2857 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2858 }
2859 }
2860 }
2861 }
2862
2863 if !is_htmx(&headers) {
2864 return Ok(Redirect::to("/").into_response());
2865 }
2866
2867 // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
2868 if is_reader_request(&headers) {
2869 let read = entry_is_read(pool, &did, id).await?;
2870 return Ok(render(&EntryActionBarTemplate {
2871 id,
2872 read,
2873 starred,
2874 oob: true,
2875 }));
2876 }
2877
2878 let row = build_entry_row(pool, &did, id, None).await?;
2879 match row {
2880 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2881 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2882 }
2883}
2884
2885/// The feed URL for a cached feed id, if the row exists.
2886async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2887 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2888 .bind(feed_id)
2889 .fetch_optional(pool)
2890 .await
2891 .ok()
2892 .flatten()
2893}
2894
2895// ---------------------------------------------------------------------------
2896// Mark-all-read
2897// ---------------------------------------------------------------------------
2898
2899/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
2900/// absent means mark everything read.
2901#[derive(Debug, Deserialize, Default)]
2902struct ReadAllQuery {
2903 #[serde(default)]
2904 feed: Option<String>,
2905}
2906
2907/// `POST /read-all` — mark every entry read for the current DID, optionally
2908/// scoped to one feed (mark-all-read per feed or globally).
2909async fn mark_all_read(
2910 State(state): State<AppState>,
2911 headers: HeaderMap,
2912 Query(q): Query<ReadAllQuery>,
2913) -> Result<Response, WebError> {
2914 let did = match current_did(&state, &headers).await {
2915 Some(d) => d,
2916 None => return Ok(Redirect::to("/login").into_response()),
2917 };
2918 let pool = &state.db;
2919
2920 // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
2921 // only ever touch feeds this DID actually subscribes to.
2922 resolve_subscriptions(&state, &did).await;
2923
2924 if let Some(feed_url) = q.feed.as_deref() {
2925 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2926 store::mark_feed_read(pool, &did, feed.id, true).await?;
2927 }
2928 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2929 }
2930
2931 // Global: mark every subscribed feed read. Fan out over the DID's feeds
2932 // (bounded by the per-DID subscription cap) using the batched per-feed path,
2933 // rather than one UPDATE round-trip per unread entry (unbounded) — same end
2934 // state, but O(feeds) statements instead of O(unread entries).
2935 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2936 store::mark_feed_read(pool, &did, feed_id, true).await?;
2937 }
2938 Ok(Redirect::to("/").into_response())
2939}
2940
2941// ---------------------------------------------------------------------------
2942// Subscribe by URL
2943// ---------------------------------------------------------------------------
2944
2945/// Flash for a URL this instance cannot store as a feed — not private, just
2946/// not a kind of feed it supports (an `at://` publication with
2947/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
2948/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
2949/// false promise for a record that may already exist in the user's PDS.
2950const UNSUPPORTED_FEED_URL_REFUSAL: &str =
2951 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
2952
2953/// Shown when an OPML export is refused because the subscription list could not
2954/// be read in full.
2955///
2956/// **An empty export is worse than no export.** This path used to
2957/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
2958/// file — a blank backup, handed over at the moment the reader reached for one.
2959const EXPORT_INCOMPLETE_REFUSAL: &str =
2960 "Could not read your subscriptions in full, so nothing was exported. Your \
2961 feeds are unchanged — try again, and if it keeps failing the list may be \
2962 larger than this reader can page through.";
2963
2964/// Refusal message shown when a private/paid feed is submitted. FeatherReader
2965/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
2966/// only for now — a private feed's secret URL is never saved, fetched, or sent
2967/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
2968/// and the boot-smoke can assert on it.
2969const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
2970 FeatherReader stores your subscriptions in your public PDS, so it supports public \
2971 feeds for now — private-feed support arrives when atproto's private data \
2972 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
2973
2974/// Form body for `POST /subscriptions`.
2975#[derive(Debug, Deserialize)]
2976struct SubscribeForm {
2977 url: String,
2978 /// Optional folder `at://` URI to file the new feed under.
2979 #[serde(default)]
2980 folder: Option<String>,
2981}
2982
2983/// `POST /subscriptions` — subscribe by URL.
2984async fn add_subscription(
2985 State(state): State<AppState>,
2986 headers: HeaderMap,
2987 Form(form): Form<SubscribeForm>,
2988) -> Result<Response, WebError> {
2989 let did = match current_did(&state, &headers).await {
2990 Some(d) => d,
2991 None => return Ok(Redirect::to("/login").into_response()),
2992 };
2993 let pool = &state.db;
2994 let input = form.url.trim().to_string();
2995 if input.is_empty() {
2996 return Ok(Redirect::to("/").into_response());
2997 }
2998
2999 // An `at://` paste is refused here, whatever the flag says: this path must
3000 // FETCH what was pasted to find the feed in it, and nothing fetches
3001 // `at://` until the standard.site reader is wired. Letting a well-formed
3002 // one through produced "Couldn't find a feed" and a `warn!` for an
3003 // expected condition; letting a malformed one reach the privacy arm, which
3004 // fails closed as `Private`, told the reader a typo was a paid feed. Only
3005 // `at://` is pre-checked — an http(s) or scheme-less paste keeps its
3006 // "Couldn't find a feed" path below, which is the accurate answer there.
3007 // Case-insensitive, unlike the storage guards: `Url::parse` folds the
3008 // scheme, so `AT://…` would otherwise skip both this and the classifier's
3009 // at:// arm, parse as `at`, and draw the private/paid flash off the rkey.
3010 // This decides a MESSAGE; nothing about storage keys off it.
3011 if input
3012 .get(..crate::atproto::AT_URI_PREFIX.len())
3013 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX))
3014 {
3015 info!(url = %input, %did, "refused an at:// paste: the add path cannot fetch one (not stored)");
3016 return Ok(
3017 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3018 .into_response(),
3019 );
3020 }
3021
3022 // Block private/paid feeds BEFORE any fetch/resolve so a secret-bearing URL is
3023 // never even requested. Public feeds only until atproto permissioned data
3024 // ships; there is no override and nothing is stored or written.
3025 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
3026 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3027 return Ok(
3028 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3029 );
3030 }
3031
3032 // Per-DID subscription cap: bound one account's storage/poller footprint on
3033 // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3034 // can't even trigger an outbound request. `<= 0` disables the cap.
3035 let cap = state.config.max_subs_per_did;
3036 if cap > 0 {
3037 match store::count_subscriptions_for_did(pool, &did).await {
3038 Ok(n) if n >= cap => {
3039 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3040 return Ok(Redirect::to(&format!(
3041 "/?flash={}",
3042 qenc(&format!(
3043 "Subscription limit reached ({cap}). Remove a feed before adding another."
3044 ))
3045 ))
3046 .into_response());
3047 }
3048 Ok(_) => {}
3049 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3050 }
3051 }
3052
3053 let feed_url = match resolve_feed_url(&state.config, &input).await {
3054 Ok(u) => u,
3055 Err(err) => {
3056 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3057 return Ok(Redirect::to(&format!(
3058 "/?flash={}",
3059 qenc("Couldn't find a feed at that URL")
3060 ))
3061 .into_response());
3062 }
3063 };
3064
3065 // Defensive: resolution may have discovered a feed URL that itself carries a
3066 // secret (e.g. a public site page linking a tokened feed). Re-check the
3067 // resolved URL and refuse before storing/writing anything.
3068 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3069 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3070 return Ok(
3071 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3072 );
3073 }
3074
3075 // The URL about to be STORED is what must be storable — not the one the
3076 // user typed. Autodiscovery already yields only http(s), but this is the
3077 // path that writes the row and the PDS record, so the check lives here too:
3078 // the same gate the OPML and rename paths apply, on the same terms.
3079 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3080 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3081 return Ok(
3082 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3083 .into_response(),
3084 );
3085 }
3086
3087 // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3088 // cache is full (an existing/duplicate feed URL is always fine — it adds no
3089 // row). Bounds total cache size across all users on the box. `<= 0` disables.
3090 let feeds_cap = state.config.max_feeds_global;
3091 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3092 match store::count_feeds(pool).await {
3093 Ok(n) if n >= feeds_cap => {
3094 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3095 return Ok(Redirect::to(&format!(
3096 "/?flash={}",
3097 qenc(
3098 "This instance is at its feed capacity right now. Please try again later."
3099 )
3100 ))
3101 .into_response());
3102 }
3103 Ok(_) => {}
3104 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3105 }
3106 }
3107
3108 store::upsert_feed(
3109 pool,
3110 &store::NewFeed {
3111 url: feed_url.clone(),
3112 ..Default::default()
3113 },
3114 )
3115 .await?;
3116
3117 if let Ok(client) = feed::build_client() {
3118 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3119 match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
3120 {
3121 Ok(outcome) => {
3122 info!(feed = %feed_url, ?outcome, "polled new subscription");
3123 // **This path is not the scheduler, so it must settle the
3124 // error columns itself.** `poll_feed` writes validators and
3125 // `last_polled` and nothing else.
3126 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3127 }
3128 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3129 }
3130 }
3131 }
3132
3133 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3134 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3135 sub.title = feed_row.title.clone();
3136 sub.site_url = feed_row.site_url.clone();
3137 }
3138 sub.folder = form
3139 .folder
3140 .map(|f| f.trim().to_string())
3141 .filter(|f| !f.is_empty());
3142
3143 match state.repo().add_subscription(&did, &sub).await {
3144 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3145 Err(err) => {
3146 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3147 }
3148 }
3149
3150 Ok(Redirect::to("/").into_response())
3151}
3152
3153/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3154async fn delete_subscription(
3155 State(state): State<AppState>,
3156 headers: HeaderMap,
3157 Path(rkey): Path<String>,
3158) -> Result<Response, WebError> {
3159 let did = match current_did(&state, &headers).await {
3160 Some(d) => d,
3161 None => return Ok(Redirect::to("/login").into_response()),
3162 };
3163 match state.repo().remove_subscription(&did, &rkey).await {
3164 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3165 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3166 }
3167 Ok(Redirect::to("/").into_response())
3168}
3169
3170/// Form body for `POST /subscriptions/:rkey/rename`.
3171#[derive(Debug, Deserialize)]
3172struct RenameSubForm {
3173 url: String,
3174 #[serde(default)]
3175 title: Option<String>,
3176 #[serde(default)]
3177 site_url: Option<String>,
3178 #[serde(default)]
3179 folder: Option<String>,
3180}
3181
3182/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3183/// folder, rewriting the whole subscription record via `putRecord`.
3184async fn rename_subscription(
3185 State(state): State<AppState>,
3186 headers: HeaderMap,
3187 Path(rkey): Path<String>,
3188 Form(form): Form<RenameSubForm>,
3189) -> Result<Response, WebError> {
3190 let did = match current_did(&state, &headers).await {
3191 Some(d) => d,
3192 None => return Ok(Redirect::to("/login").into_response()),
3193 };
3194 let feed_url = form.url.trim().to_string();
3195
3196 // Reject an empty/blank resolved URL — a rename with no usable URL must not
3197 // write a junk row to the cache or a malformed subscription record to the
3198 // PDS (add_subscription refuses an empty input the same way).
3199 if feed_url.is_empty() {
3200 return Ok(Redirect::to("/").into_response());
3201 }
3202
3203 // **Read before write — `update_subscription` is a `putRecord`, and a
3204 // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3205 //
3206 // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3207 // and hand that over, so every field the form does not carry was written
3208 // back as its default. `templates/manage_row.html` posts `url`, `title` and
3209 // `folder` — and nothing else — so a rename silently destroyed four fields:
3210 // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3211 //
3212 // `createdAt` is the one that matters most: it is the reader's subscribe
3213 // time, it is the sort key for "when did I subscribe", it lives in THEIR
3214 // repo rather than our cache, and once overwritten it is gone with nothing
3215 // in the UI to say so.
3216 //
3217 // There is no single-record read on `Repo` (no `getRecord`), so this lists
3218 // and filters. That is one extra round trip on an action that is already
3219 // doing a PDS write, and it is bounded; a `get_subscription` would be
3220 // strictly better if this ever measures badly.
3221 //
3222 // **A failed read refuses the rename.** Falling back to the old
3223 // rebuild-from-scratch here would reinstate the data loss on exactly the
3224 // flaky path, which is the worst place to have it. The write below already
3225 // takes this stance — "a failure here means nothing was renamed or moved" —
3226 // and the read gets the same one.
3227 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3228 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3229 Err(err) => {
3230 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3231 return Ok(Redirect::to(&format!(
3232 "/?flash={}",
3233 qenc("Could not reach your PDS — nothing was renamed or moved.")
3234 ))
3235 .into_response());
3236 }
3237 };
3238 let Some(existing) = existing else {
3239 // The rkey is not in the reader's repo. Renaming a record that is not
3240 // there would CREATE one, which is not what "rename" means and would
3241 // give it a fresh `createdAt` — the bug this read exists to prevent.
3242 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3243 return Ok(Redirect::to(&format!(
3244 "/?flash={}",
3245 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3246 ))
3247 .into_response());
3248 };
3249
3250 // The subscription can be repointed at a different feed URL. **Every gate
3251 // on the URL applies to a repoint and only a repoint** — the three below
3252 // were each, at one time, run before this line on the URL as posted, and
3253 // each refused a pure retitle of a record that already existed:
3254 //
3255 // - privacy: the narrowed at:// arm fails closed as `Private` for an
3256 // at-URI that is not a publication (a feed generator another client
3257 // subscribed to), so the record became un-editable with a flash saying
3258 // it "was not saved or sent anywhere";
3259 // - the global feeds ceiling keyed on "URL not in the cache", and an
3260 // at:// record is never cached with the flag off, so at capacity a
3261 // retitle was refused for a row the handler would not insert;
3262 // - storability, the same way.
3263 //
3264 // An unchanged URL is already in the reader's repo; refusing to retitle
3265 // it protects nothing and takes their own record away from them.
3266 // Like for like: the form value is trimmed, and a record another client
3267 // wrote may carry padding — compared raw, every retitle of it was a repoint.
3268 let url_changed = existing.url.trim() != feed_url;
3269
3270 // **Storability, on the same terms as the add and OPML paths — for a
3271 // REPOINT, and FIRST.** A target this instance cannot store gets that
3272 // answer, not "private" (the at:// arm fails closed) or "at capacity"
3273 // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3274 // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3275 // here; a review found it by enumerating every writer of the table. The
3276 // first fix ran this check before the repo lookup, on the URL as posted —
3277 // which refused a pure retitle of a subscription that already IS an
3278 // at-URI, on every instance with the flag off. The flag gates what the
3279 // cache may store, not whether a reader may edit their own record: an
3280 // unchanged non-storable URL keeps its PDS write and simply gets no cache
3281 // row below.
3282 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3283 if url_changed && !storable {
3284 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3285 return Ok(
3286 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3287 .into_response(),
3288 );
3289 }
3290
3291 // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3292 // and rename both upserts it to the local cache AND rewrites the PDS
3293 // subscription record (a public `putRecord`), so without this guard a
3294 // crafted rename could land a secret-bearing URL in the public PDS — the
3295 // exact leak the add and OPML paths already prevent.
3296 if url_changed {
3297 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3298 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3299 return Ok(
3300 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3301 );
3302 }
3303 }
3304
3305 // Global feeds ceiling parity with add_subscription: a repoint to a
3306 // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3307 // shared cache is at capacity (an existing/duplicate URL adds no row and
3308 // is always fine). `<= 0` disables.
3309 let feeds_cap = state.config.max_feeds_global;
3310 if url_changed
3311 && feeds_cap > 0
3312 && store::get_feed_by_url(&state.db, &feed_url)
3313 .await?
3314 .is_none()
3315 {
3316 match store::count_feeds(&state.db).await {
3317 Ok(n) if n >= feeds_cap => {
3318 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3319 return Ok(Redirect::to(&format!(
3320 "/?flash={}",
3321 qenc(
3322 "This instance is at its feed capacity right now. Please try again later."
3323 )
3324 ))
3325 .into_response());
3326 }
3327 Ok(_) => {}
3328 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3329 }
3330 }
3331
3332 let mut sub = existing;
3333 sub.url = feed_url;
3334 sub.title = form
3335 .title
3336 .map(|t| t.trim().to_string())
3337 .filter(|t| !t.is_empty());
3338 sub.folder = form
3339 .folder
3340 .map(|f| f.trim().to_string())
3341 .filter(|f| !f.is_empty());
3342 // `createdAt` and `private` carry over untouched — neither is a property of
3343 // which feed URL the subscription points at.
3344 //
3345 // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3346 // repoint drops them rather than leaving a site link for the old feed
3347 // hanging off the new one. An explicit form value still wins if the form
3348 // ever starts carrying one.
3349 match form
3350 .site_url
3351 .map(|t| t.trim().to_string())
3352 .filter(|t| !t.is_empty())
3353 {
3354 Some(site) => sub.site_url = Some(site),
3355 None if url_changed => sub.site_url = None,
3356 None => {}
3357 }
3358 if url_changed {
3359 sub.fetch_hint = None;
3360 }
3361
3362 // Keep the local cache title in step for the loose-feed fallback path —
3363 // for a row this instance would have. Two cases write nothing:
3364 //
3365 // - not storable (an existing at-URI with the flag off): the record is the
3366 // reader's to edit, the cache row is not this instance's to create;
3367 // - an unchanged URL with no cache row: a retitle is never the write that
3368 // CREATES a row. That covers two findings at once — the ceiling is
3369 // checked on a repoint only, so a retitle must not insert past it; and
3370 // a secret-bearing URL another client subscribed to has no row (the
3371 // privacy gate above runs on a repoint only, and `resolve_subscriptions`
3372 // refuses to cache it), so it cannot enter the shared table here, be
3373 // polled, fail, and be printed on the admin page. A privacy re-check on
3374 // this write was the first draft; mutation showed it dead — the row
3375 // rule already refused every case it would have.
3376 let cache_write =
3377 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3378 if !cache_write {
3379 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3380 } else if let Err(err) = store::upsert_feed(
3381 &state.db,
3382 &store::NewFeed {
3383 url: sub.url.clone(),
3384 title: sub.title.clone(),
3385 site_url: sub.site_url.clone(),
3386 ..Default::default()
3387 },
3388 )
3389 .await
3390 {
3391 // Not fatal to the rename — the PDS record below is the source of truth
3392 // — but a missing `feeds` row means this subscription is never polled.
3393 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3394 }
3395
3396 // **The PDS write decides what the reader is told.**
3397 //
3398 // This used to `warn!` on failure and then redirect exactly as it does on
3399 // success, so a rename that did not happen was indistinguishable from one
3400 // that did — the reader saw their old title come back and had no reason to
3401 // think anything had gone wrong. The PDS record IS the subscription; a
3402 // failure here means nothing was renamed or moved.
3403 match state.repo().update_subscription(&did, &rkey, &sub).await {
3404 Ok(res) => {
3405 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3406 Ok(Redirect::to("/").into_response())
3407 }
3408 Err(err) => {
3409 warn!(%err, %did, %rkey, "PDS subscription update failed");
3410 Ok(Redirect::to(&format!(
3411 "/?flash={}",
3412 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3413 ))
3414 .into_response())
3415 }
3416 }
3417}
3418
3419// ---------------------------------------------------------------------------
3420// Folders
3421// ---------------------------------------------------------------------------
3422
3423/// Form body for `POST /folders`.
3424#[derive(Debug, Deserialize)]
3425struct FolderForm {
3426 name: String,
3427}
3428
3429/// `POST /folders` — create a folder record.
3430async fn create_folder(
3431 State(state): State<AppState>,
3432 headers: HeaderMap,
3433 Form(form): Form<FolderForm>,
3434) -> Result<Response, WebError> {
3435 let did = match current_did(&state, &headers).await {
3436 Some(d) => d,
3437 None => return Ok(Redirect::to("/login").into_response()),
3438 };
3439 let name = form.name.trim();
3440 if name.is_empty() {
3441 return Ok(Redirect::to("/").into_response());
3442 }
3443 let folder = Folder::new(name.to_string(), now_rfc3339());
3444 match state.repo().add_folder(&did, &folder).await {
3445 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3446 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3447 }
3448 Ok(Redirect::to("/").into_response())
3449}
3450
3451/// `POST /folders/:rkey/rename` — rename a folder record.
3452async fn rename_folder(
3453 State(state): State<AppState>,
3454 headers: HeaderMap,
3455 Path(rkey): Path<String>,
3456 Form(form): Form<FolderForm>,
3457) -> Result<Response, WebError> {
3458 let did = match current_did(&state, &headers).await {
3459 Some(d) => d,
3460 None => return Ok(Redirect::to("/login").into_response()),
3461 };
3462 let name = form.name.trim();
3463 if name.is_empty() {
3464 return Ok(Redirect::to("/").into_response());
3465 }
3466 let folder = Folder::new(name.to_string(), now_rfc3339());
3467 match state.repo().rename_folder(&did, &rkey, &folder).await {
3468 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3469 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3470 }
3471 Ok(Redirect::to("/").into_response())
3472}
3473
3474/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3475/// simply become un-foldered).
3476async fn delete_folder(
3477 State(state): State<AppState>,
3478 headers: HeaderMap,
3479 Path(rkey): Path<String>,
3480) -> Result<Response, WebError> {
3481 let did = match current_did(&state, &headers).await {
3482 Some(d) => d,
3483 None => return Ok(Redirect::to("/login").into_response()),
3484 };
3485 match state.repo().remove_folder(&did, &rkey).await {
3486 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3487 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3488 }
3489 Ok(Redirect::to("/").into_response())
3490}
3491
3492/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3493/// feed document we take it as-is; if it yields an HTML page we run
3494/// autodiscovery over its `<link rel="alternate">` tags.
3495async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3496 let parsed =
3497 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3498
3499 let client = feed::build_client()?;
3500 // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3501 // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3502 // loopback / private hosts.
3503 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3504 let final_url = resp.url().clone();
3505 let content_type = resp
3506 .headers()
3507 .get(axum::http::header::CONTENT_TYPE)
3508 .and_then(|v| v.to_str().ok())
3509 .unwrap_or("")
3510 .to_ascii_lowercase();
3511 // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3512 // gzip strips it, and this response is reflected into the UI.
3513 let raw = crate::net::read_capped(resp).await?;
3514 let body = String::from_utf8_lossy(&raw).into_owned();
3515
3516 let looks_like_feed = content_type.contains("xml")
3517 || content_type.contains("rss")
3518 || content_type.contains("atom")
3519 || content_type.contains("application/feed+json")
3520 || {
3521 let head = body.trim_start();
3522 head.starts_with("<?xml")
3523 || head.starts_with("<rss")
3524 || head.starts_with("<feed")
3525 || head.contains("<rss")
3526 || head.contains("<feed")
3527 };
3528 if looks_like_feed {
3529 return Ok(final_url.to_string());
3530 }
3531
3532 match feed::discover_feed(&body, Some(&final_url)) {
3533 Some(u) => Ok(u.to_string()),
3534 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3535 }
3536}
3537
3538// ---------------------------------------------------------------------------
3539// Login (atproto OAuth via the sidecar)
3540// ---------------------------------------------------------------------------
3541
3542/// Query for `GET /login`.
3543#[derive(Debug, Deserialize, Default)]
3544struct LoginQuery {
3545 #[serde(default)]
3546 handle: Option<String>,
3547 #[serde(default)]
3548 error: Option<String>,
3549 #[serde(default)]
3550 flash: Option<String>,
3551}
3552
3553/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3554///
3555/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3556/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3557/// session cookie *or* the submitted handle resolving to a seated DID) or a
3558/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3559/// form (no handle) always renders.
3560async fn login_form(
3561 State(state): State<AppState>,
3562 headers: HeaderMap,
3563 Query(q): Query<LoginQuery>,
3564) -> Response {
3565 if let Some(handle) = q
3566 .handle
3567 .map(|h| h.trim().to_string())
3568 .filter(|h| !h.is_empty())
3569 {
3570 if !may_start_oauth(&state, &headers, &handle).await {
3571 return Redirect::to("/beta/redeem").into_response();
3572 }
3573 return start_oauth(&state, &handle).await;
3574 }
3575 render(&LoginTemplate {
3576 repo_url: REPO_URL,
3577 error: q.error.unwrap_or_default(),
3578 flash: q.flash.unwrap_or_default(),
3579 })
3580}
3581
3582/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3583/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3584async fn login_submit(
3585 State(state): State<AppState>,
3586 headers: HeaderMap,
3587 Form(form): Form<LoginForm>,
3588) -> Response {
3589 let handle = form.handle.trim();
3590 if handle.is_empty() {
3591 return login_error("Enter your atproto handle.");
3592 }
3593 if !may_start_oauth(&state, &headers, handle).await {
3594 return Redirect::to("/beta/redeem").into_response();
3595 }
3596 start_oauth(&state, handle).await
3597}
3598
3599/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3600/// admits, in order of cost:
3601///
3602/// 1. an existing beta member's cookie session whose DID already holds a seat;
3603/// 2. a fresh visitor carrying a valid reserving invite cookie;
3604/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3605/// already holds a seat — this honors the **seeded admin's first login** on a
3606/// fresh deploy (and any returning member who cleared cookies) without a
3607/// session cookie or an invite code.
3608///
3609/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3610/// handle→DID resolution is only attempted when neither applies. It fails
3611/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3612/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3613/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3614/// resolution instead of a burned sidecar handshake (and `/login` is already in
3615/// the rate-limited path set).
3616async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3617 // The production resolver is the app's existing atproto handle→DID path,
3618 // routed through the SSRF guard. Resolution is injected so tests can exercise
3619 // the gate without a live network call (the guard forbids loopback mocks).
3620 may_start_oauth_with(state, headers, handle, |h| async move {
3621 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3622 .await
3623 .ok()
3624 })
3625 .await
3626}
3627
3628/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3629/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3630/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3631/// only called when neither admits — keeping the network round-trip off the hot
3632/// path and preserving the fail-closed contract on resolution failure.
3633async fn may_start_oauth_with<F, Fut>(
3634 state: &AppState,
3635 headers: &HeaderMap,
3636 handle: &str,
3637 resolve: F,
3638) -> bool
3639where
3640 F: FnOnce(String) -> Fut,
3641 Fut: std::future::Future<Output = Option<String>>,
3642{
3643 // 1. An already-beta'd session may re-auth freely.
3644 if let Some(did) = current_did(state, headers).await {
3645 if store::has_beta_access(&state.db, &did)
3646 .await
3647 .unwrap_or(false)
3648 {
3649 return true;
3650 }
3651 }
3652 // 2. A valid reserving invite cookie.
3653 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3654 return true;
3655 }
3656 // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
3657 // a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
3658 // on any resolution error or unresolvable/malformed handle.
3659 match resolve(handle.to_string()).await {
3660 Some(did) => store::has_beta_access(&state.db, &did)
3661 .await
3662 .unwrap_or(false),
3663 None => {
3664 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3665 false
3666 }
3667 }
3668}
3669
3670/// Begin the OAuth handshake for `handle`, on whichever backend is live.
3671///
3672/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
3673/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
3674/// carries `form-action 'self'`. Browsers have historically disagreed about
3675/// whether that directive applies to redirects following a form submission, and
3676/// if it did here, login would break in a browser while every test passed.
3677///
3678/// It does not, and the evidence is the SIDECAR path, which is live in
3679/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
3680/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
3681/// whole redirect chain would already be blocking that. One checking only the
3682/// form's action URL sees `/login` in both cases. The two arms differ only in
3683/// how many same-origin hops precede the cross-origin one, so any policy that
3684/// permits the sidecar flow permits this one.
3685///
3686/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
3687/// its own `/login` and its own callback, so starting a login is one redirect
3688/// and nothing is stored here. The Rust backend pushes the authorization
3689/// request itself, which means this app now holds the pending login — and must
3690/// set the browser-binding cookie that the callback will be checked against.
3691async fn start_oauth(state: &AppState, handle: &str) -> Response {
3692 match state.config.repo_backend {
3693 crate::metrics::Backend::Sidecar => {
3694 let url = state.sidecar.login_url(handle, None);
3695 info!(%handle, "redirecting to OAuth sidecar login");
3696 Redirect::to(&url).into_response()
3697 }
3698 crate::metrics::Backend::Rust => {
3699 let Some(runtime) = state.oauth.as_deref() else {
3700 warn!("the rust backend is live but its OAuth runtime is absent");
3701 return login_error("Login is not available right now.");
3702 };
3703 match crate::oauth::login::start(
3704 runtime,
3705 &state.http,
3706 &state.db,
3707 handle,
3708 crate::store::now_unix(),
3709 )
3710 .await
3711 {
3712 Ok(started) => {
3713 info!(%handle, "pushed authorization request; redirecting to the PDS");
3714 let mut resp = Redirect::to(&started.authorize_url).into_response();
3715 set_cookie(
3716 &mut resp,
3717 &cookie::sign_value(
3718 OAUTH_BINDING_COOKIE,
3719 &started.binding_token,
3720 &state.config.cookie_secret,
3721 OAUTH_BINDING_MAX_AGE_SECS,
3722 ),
3723 );
3724 resp
3725 }
3726 Err(err) => {
3727 // The handle the user typed is logged; the error is not shown
3728 // to them verbatim, since it can name internal hosts.
3729 warn!(%err, %handle, "could not start the OAuth login");
3730 login_error("Could not start login for that handle.")
3731 }
3732 }
3733 }
3734 }
3735}
3736
3737/// Clear the browser-binding cookie. Called on every terminal outcome of a
3738/// callback, successful or not: the pending row is consumed either way, so a
3739/// lingering cookie can only ever match a login that no longer exists.
3740fn clear_binding_cookie(resp: &mut Response) {
3741 set_cookie(
3742 resp,
3743 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3744 );
3745}
3746
3747/// Form body for `POST /login`.
3748#[derive(Debug, Deserialize)]
3749struct LoginForm {
3750 handle: String,
3751}
3752
3753/// Query for `GET /oauth/callback`.
3754///
3755/// Carries BOTH shapes, because the two backends deliver different things to
3756/// the same URL: the sidecar hands back a one-shot `session_id` it has already
3757/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
3758/// for this app to exchange itself. Which fields are populated is decided by
3759/// which backend started the login, not by which is live now — so a flip with a
3760/// login already in flight still lands in the right arm.
3761#[derive(Debug, Deserialize, Default)]
3762struct CallbackQuery {
3763 /// Sidecar backend: the handoff id.
3764 #[serde(default)]
3765 session_id: Option<String>,
3766 /// Rust backend: the authorization code and its envelope.
3767 #[serde(default)]
3768 code: Option<String>,
3769 #[serde(default)]
3770 state: Option<String>,
3771 #[serde(default)]
3772 iss: Option<String>,
3773 /// JARM, which is not supported — carried only so it can be refused
3774 /// explicitly rather than read as "no code".
3775 #[serde(default)]
3776 response: Option<String>,
3777 #[serde(default)]
3778 error: Option<String>,
3779 #[serde(default)]
3780 error_description: Option<String>,
3781}
3782
3783/// `GET /oauth/callback` — establish the cookie session.
3784///
3785/// **Invite gate:** the verified DID must hold beta access. If it already does
3786/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
3787/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
3788/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
3789async fn oauth_callback(
3790 State(state): State<AppState>,
3791 headers: HeaderMap,
3792 Query(q): Query<CallbackQuery>,
3793) -> Response {
3794 // An error response is handled by the SAME arm that would have handled a
3795 // success, not short-circuited here.
3796 //
3797 // Returning early looks obviously right and is wrong on the Rust path: it
3798 // skips `verify_callback`, which validates `iss` BEFORE reporting the error
3799 // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
3800 // error originates from the intended AS". It also leaves the pending row
3801 // unconsumed, so a `state` that has already produced a callback stays usable
3802 // until it expires.
3803 //
3804 // The sidecar arm has no such check to reach, so it is short-circuited
3805 // below, preserving exactly what it did before.
3806 // **The arm is chosen by what the SERVER knows, not by what the caller
3807 // sent.** A `session_id` in the query used to select the sidecar arm on its
3808 // own — so a caller could pick which code path ran, and the sidecar arm has
3809 // no browser-binding check at all. It also short-circuited the error path
3810 // below, skipping the `iss` validation.
3811 //
3812 // Requiring the Rust runtime to be absent, or a sidecar backend to be the
3813 // configured one, means the selection follows this deployment's own
3814 // configuration. A login started before a flip still completes, because the
3815 // Rust arm is reached whenever the Rust runtime exists and can match the
3816 // `state` against a pending row it actually wrote.
3817 // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
3818 // and `?error=…&error_description=…` on its own failure. Keying only on
3819 // `session_id` sent the failure shape down the Rust arm, which then failed
3820 // with "no `state`" and replaced the specific reason with a generic one —
3821 // and `error_description` is exactly what the sidecar Caddy routing matches
3822 // to send that request here in the first place.
3823 let sidecar_shape =
3824 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3825 let sidecar_handoff = sidecar_shape
3826 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3827 if let Some(err) = q.error.clone() {
3828 // **Neither the code nor the description is echoed as sent.**
3829 //
3830 // Both are server-controlled free text arriving on a public GET, so
3831 // anyone who can make a browser fetch this URL chooses them. The raw
3832 // `error` used to go into a `warn!` AND into the rendered login page,
3833 // and `error_description` — arbitrary text, newlines included — went
3834 // into the log verbatim: a log-injection surface on one side and
3835 // attacker-chosen copy in the product's own voice on the other.
3836 //
3837 // `oauth::flow` already decided this exact question for the Rust arm:
3838 // reduce the code to a known slug, drop the description entirely. That
3839 // reasoning is not specific to which arm handles the callback, and this
3840 // one simply never got the same treatment. The description's LENGTH is
3841 // kept, because "the server sent a 4 KB explanation" is occasionally
3842 // worth knowing and cannot be used to inject anything.
3843 let slug = crate::oauth::flow::known_error_slug(&err);
3844 warn!(
3845 error = slug,
3846 desc_len = q.error_description.as_deref().map_or(0, str::len),
3847 "OAuth callback returned an error"
3848 );
3849 if sidecar_handoff || state.oauth.is_none() {
3850 return login_error(&format!("Login failed: {slug}"));
3851 }
3852 // Fall through: the Rust arm consumes the pending row and validates
3853 // `iss` against it, and reports the failure afterwards.
3854 }
3855
3856 // Which arm runs is decided by WHAT ARRIVED, not by which backend is
3857 // currently selected: a login started before a flip must still complete.
3858 let session = if sidecar_handoff {
3859 let session_id = q.session_id.clone().unwrap_or_default();
3860 match state.sidecar.resolve_session(&session_id).await {
3861 Ok(Some(s)) => s,
3862 Ok(None) => {
3863 warn!("OAuth callback session_id did not resolve (expired/unknown)");
3864 return login_error("Login session expired — please try again.");
3865 }
3866 Err(err) => {
3867 warn!(%err, "failed to resolve OAuth session via the sidecar");
3868 return login_error("Login failed talking to the auth service.");
3869 }
3870 }
3871 } else {
3872 let Some(runtime) = state.oauth.as_deref() else {
3873 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3874 return login_error("Login failed: this login could not be completed.");
3875 };
3876 let params = crate::oauth::flow::CallbackParams {
3877 code: q.code.clone(),
3878 state: q.state.clone(),
3879 iss: q.iss.clone(),
3880 // Passed through, NOT dropped: `verify_callback` checks `iss`
3881 // against the pending row's issuer before it reports the error, and
3882 // it cannot do that for an error it never sees.
3883 error: q.error.clone(),
3884 error_description: q.error_description.clone(),
3885 response: q.response.clone(),
3886 };
3887 let binding =
3888 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
3889 match crate::oauth::login::complete(
3890 runtime,
3891 &state.http,
3892 &state.db,
3893 ¶ms,
3894 binding.as_deref(),
3895 crate::store::now_unix(),
3896 )
3897 .await
3898 {
3899 Ok(done) => crate::atproto::SidecarSession {
3900 did: done.did,
3901 handle: done.handle,
3902 },
3903 Err(err) => {
3904 // Never echoed to the browser: the message can name the issuer,
3905 // the PDS, and why a binding check failed.
3906 warn!(%err, "could not complete the OAuth callback");
3907 let mut resp = login_error("Login failed — please try again.");
3908 clear_binding_cookie(&mut resp);
3909 return resp;
3910 }
3911 }
3912 };
3913
3914 // Bind the verified DID to the invite gate. Returns a response only on the
3915 // (rare) failure paths; `Ok(())` means the DID now holds beta access.
3916 let mut clear_invite = false;
3917 if !store::has_beta_access(&state.db, &session.did)
3918 .await
3919 .unwrap_or(false)
3920 {
3921 // Not yet a member: consume the reserved invite code, if any.
3922 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
3923 Some(c) => c,
3924 None => {
3925 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
3926 return Redirect::to("/beta/redeem").into_response();
3927 }
3928 };
3929 match store::redeem_code(
3930 &state.db,
3931 &code,
3932 &session.did,
3933 session.handle.as_deref(),
3934 state.config.beta_cap,
3935 )
3936 .await
3937 {
3938 Ok(Ok(())) => {
3939 clear_invite = true;
3940 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
3941 }
3942 Ok(Err(policy)) => {
3943 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
3944 let mut resp = redeem_bounce(&policy).into_response();
3945 // The reservation is spent/invalid — drop the stale invite cookie.
3946 clear_invite_cookie(&mut resp);
3947 return resp;
3948 }
3949 Err(err) => {
3950 warn!(%err, did = %session.did, "invite redeem infra error at callback");
3951 return login_error("Login failed while confirming your invite.");
3952 }
3953 }
3954 }
3955
3956 // Mint an opaque, random server-side session id and store the identity under
3957 // it; the cookie carries the (HMAC-signed) sid, never the DID.
3958 let sid = state.sessions.create(Session {
3959 did: session.did.clone(),
3960 handle: session.handle.clone(),
3961 });
3962 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
3963 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
3964
3965 let mut resp = Redirect::to("/").into_response();
3966 set_cookie(&mut resp, &cookie);
3967 clear_binding_cookie(&mut resp);
3968 if clear_invite {
3969 clear_invite_cookie(&mut resp);
3970 }
3971 resp
3972}
3973
3974/// Revoke a DID's OAuth session on BOTH backends, best-effort.
3975///
3976/// Not "whichever backend is live": during a cutover a user's tokens can be in
3977/// either store — they logged in under one backend and are logging out under
3978/// the other. Revoking only the live one would leave a live refresh token
3979/// behind in the other, which is the exact failure sign-out exists to prevent,
3980/// and it would be invisible because the sign-out itself looks successful.
3981///
3982/// Both arms are best-effort. The caller has already decided to sign the user
3983/// out, and a network failure must not trap them in a half-logged-out state.
3984/// How long sign-out will wait for a final read-state flush before revoking
3985/// anyway.
3986///
3987/// Bounded because the flush talks to the user's PDS, and a user trying to leave
3988/// must never be held by a server that is not answering. Three seconds is long
3989/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
3990/// and short enough that a dead PDS is an inconvenience rather than a trap.
3991const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
3992
3993/// Flush whatever read-state is still dirty for `did`, then give up quietly.
3994///
3995/// **Called before revoking, because revoking first strands it (#117).**
3996/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
3997/// session cannot be sent by anyone — it parks until the user signs in again,
3998/// which may be never. Flushing first is what stops the common case from
3999/// becoming that.
4000///
4001/// Best-effort by construction: every failure path here falls through to the
4002/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4003/// the parked state the flusher now handles deliberately rather than retrying
4004/// forever.
4005async fn flush_before_revoke(state: &AppState, did: &str) {
4006 match tokio::time::timeout(
4007 SIGN_OUT_FLUSH_BUDGET,
4008 crate::readstate::flush_did(state, did),
4009 )
4010 .await
4011 {
4012 Ok(Ok(())) => {}
4013 Ok(Err(err)) => {
4014 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4015 }
4016 Err(_) => warn!(
4017 %did,
4018 budget = ?SIGN_OUT_FLUSH_BUDGET,
4019 "sign-out: final read-state flush timed out; it will park until next sign-in"
4020 ),
4021 }
4022}
4023
4024async fn revoke_everywhere(state: &AppState, did: &str) {
4025 // **Counted under Backend::Sidecar, not left uncounted.** A review found
4026 // that recording only the rust arm let `oauth_revoke` report a clean success
4027 // while every sidecar revocation failed — and for anyone who logged in before
4028 // the cutover, the sidecar store is the ONLY one that held tokens, so the
4029 // rust arm correctly returns NoSession and the metric reads all-clear while
4030 // live refresh tokens sit at the PDS.
4031 //
4032 // Same op name, different backend: the backend column is what distinguishes
4033 // them, so "no revocation failures" means checking both rows, not one.
4034 let sidecar_started = std::time::Instant::now();
4035 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4036 Ok(res) => {
4037 info!(%did, revoked = res.revoked, "sidecar session revoked");
4038 true
4039 }
4040 Err(err) => {
4041 warn!(%did, %err, "sidecar revoke failed; continuing");
4042 false
4043 }
4044 };
4045 state.metrics.record(
4046 crate::metrics::Backend::Sidecar,
4047 "oauth_revoke",
4048 sidecar_started.elapsed().as_micros() as u64,
4049 sidecar_ok,
4050 );
4051
4052 if let Some(runtime) = state.oauth.as_deref() {
4053 let revoke_started = std::time::Instant::now();
4054 let outcome = crate::oauth::revoke::sign_out_discovering(
4055 runtime,
4056 &state.http,
4057 &state.db,
4058 did,
4059 crate::store::now_unix(),
4060 )
4061 .await;
4062 // **Counted, because a warn! nobody reads is not observability.** Until
4063 // this existed, a revocation failure left exactly one trace: a log line.
4064 // "No revocation failures this week" was therefore a statement about
4065 // nobody having looked, which is not the same claim.
4066 //
4067 // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4068 // there being nothing to revoke is the correct outcome, not a failure,
4069 // and counting it as an error would make the metric noisy in exactly
4070 // the case that is fine. Only `Failed` means the PDS still holds live
4071 // tokens we asked it to drop.
4072 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4073 state.metrics.record(
4074 crate::metrics::Backend::Rust,
4075 "oauth_revoke",
4076 revoke_started.elapsed().as_micros() as u64,
4077 revoke_ok,
4078 );
4079 match outcome {
4080 crate::oauth::revoke::Revocation::Revoked => {
4081 info!(%did, "rust OAuth session revoked at the PDS")
4082 }
4083 crate::oauth::revoke::Revocation::NoSession => {}
4084 crate::oauth::revoke::Revocation::Failed(reason) => {
4085 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4086 }
4087 }
4088 }
4089}
4090
4091/// `POST /logout` — end the session everywhere, not just in this browser.
4092///
4093/// Clearing the cookie only stops *this* device from presenting the session;
4094/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4095/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4096/// access tokens at the PDS and drops the sidecar's session rows. The local
4097/// registry entry is dropped and the cookie cleared regardless of whether the
4098/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4099/// user in a half-logged-out state).
4100async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4101 if let Some(user) = current_session(&state, &headers).await {
4102 // Only a real cookie session (`sid` present) has sidecar-held tokens to
4103 // revoke; the dev-DID fallback never handshook the sidecar.
4104 if let Some(sid) = user.sid {
4105 state.sessions.remove(&sid);
4106 // BEFORE the revoke: afterwards there is no session to send it with.
4107 flush_before_revoke(&state, &user.did).await;
4108 revoke_everywhere(&state, &user.did).await;
4109 }
4110 }
4111 let mut resp = Redirect::to("/login").into_response();
4112 set_cookie(
4113 &mut resp,
4114 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4115 );
4116 resp
4117}
4118
4119/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4120/// `DELETE` into this field for the purge to run.
4121#[derive(Debug, Deserialize)]
4122struct DeleteAccountForm {
4123 #[serde(default)]
4124 confirm: String,
4125}
4126
4127/// The literal a user must type to confirm the destructive delete.
4128const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4129
4130/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4131///
4132/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4133/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4134/// 1. purges **every** local row owned by the caller DID (`entry_state`,
4135/// `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4136/// DID created) via [`store::purge_did_data`], then
4137/// 2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4138/// revoked at the PDS and the sidecar's session rows are dropped, then
4139/// 3. drops the in-memory session and clears the cookie, signing the user out.
4140///
4141/// The subscription/folder/saved *records* in the user's own PDS are
4142/// intentionally left alone — they are the user's data on their own server; the
4143/// `/about` copy and this page's UI both say so, and export stays available.
4144async fn account_delete(
4145 State(state): State<AppState>,
4146 headers: HeaderMap,
4147 Form(form): Form<DeleteAccountForm>,
4148) -> Result<Response, WebError> {
4149 let user = match current_session(&state, &headers).await {
4150 Some(u) => u,
4151 None => return Ok(Redirect::to("/login").into_response()),
4152 };
4153 let did = user.did.clone();
4154
4155 // Confirm-gate: require the exact typed phrase before doing anything.
4156 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4157 return Ok(Redirect::to(&format!(
4158 "/manage?flash={}",
4159 qenc("Type DELETE to confirm — nothing was deleted.")
4160 ))
4161 .into_response());
4162 }
4163
4164 // 1. Purge every local row this DID owns (single transaction).
4165 let counts = store::purge_did_data(&state.db, &did).await?;
4166 info!(
4167 %did,
4168 total = counts.total(),
4169 entry_state = counts.entry_state,
4170 read_cursor = counts.read_cursor,
4171 sub_ref = counts.sub_ref,
4172 beta_access = counts.beta_access,
4173 invite_codes = counts.invite_codes,
4174 "account/delete: local rows purged"
4175 );
4176
4177 // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4178 // rows are already gone; a network blip must not block the sign-out).
4179 revoke_everywhere(&state, &did).await;
4180
4181 // 3. Drop the in-memory session and clear the cookie: sign the user out.
4182 if let Some(sid) = user.sid {
4183 state.sessions.remove(&sid);
4184 }
4185 let mut resp = Redirect::to(&format!(
4186 "/login?flash={}",
4187 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4188 ))
4189 .into_response();
4190 set_cookie(
4191 &mut resp,
4192 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4193 );
4194 Ok(resp)
4195}
4196
4197/// Re-render the login form with an error banner.
4198fn login_error(msg: &str) -> Response {
4199 render(&LoginTemplate {
4200 repo_url: REPO_URL,
4201 error: msg.to_string(),
4202 flash: String::new(),
4203 })
4204}
4205
4206// ---------------------------------------------------------------------------
4207// Closed-beta invite gate (self-serve redeem + admin mint)
4208// ---------------------------------------------------------------------------
4209
4210/// Form body for `POST /beta/redeem`.
4211#[derive(Debug, Deserialize)]
4212struct RedeemForm {
4213 code: String,
4214}
4215
4216/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4217/// already full we render the "capacity full" variant (no form).
4218async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4219 let full = store::count_beta_access(&state.db)
4220 .await
4221 .map(|n| n >= state.config.beta_cap)
4222 .unwrap_or(false);
4223 render(&BetaRedeemTemplate {
4224 repo_url: REPO_URL,
4225 error: String::new(),
4226 capacity_full: full,
4227 })
4228}
4229
4230/// `POST /beta/redeem` — the **pre-handshake** reservation.
4231///
4232/// Validates the pasted code is *redeemable right now* (exists, active,
4233/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4234/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4235/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4236/// OAuth callback later binds the verified DID and atomically consumes the code
4237/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4238/// start OAuth (and burn a sidecar handshake).
4239async fn beta_redeem_submit(
4240 State(state): State<AppState>,
4241 Form(form): Form<RedeemForm>,
4242) -> Response {
4243 let code = form.code.trim().to_uppercase();
4244 if code.is_empty() {
4245 return render(&BetaRedeemTemplate {
4246 repo_url: REPO_URL,
4247 error: "Enter your invite code.".to_string(),
4248 capacity_full: false,
4249 });
4250 }
4251
4252 match preflight_code(&state, &code).await {
4253 Ok(()) => {
4254 let cookie = sign_invite(&code, &state.config.cookie_secret);
4255 let mut resp = Redirect::to("/login").into_response();
4256 set_cookie(&mut resp, &cookie);
4257 info!("invite code preflight OK; reserving intent + redirecting to /login");
4258 resp
4259 }
4260 Err(policy) => {
4261 warn!(?policy, "invite code preflight rejected");
4262 redeem_bounce(&policy)
4263 }
4264 }
4265}
4266
4267/// Read-only preflight of an invite code for the pre-handshake reservation:
4268/// verify it exists, is active, is not past `expires_at`, and that a seat is
4269/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4270/// callback time. Does NOT consume the code or grant a seat. Returns the same
4271/// typed [`store::RedeemError`] variants so the two paths share one message map.
4272async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4273 // Cap check first: a clear "capacity full" beats "code invalid" when both.
4274 // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4275 // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4276 // still backstops the real cap inside its tx, so this is a consistency /
4277 // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4278 // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4279 // that might overrun the cap.
4280 let count = match store::count_beta_access(&state.db).await {
4281 Ok(n) => n,
4282 Err(err) => {
4283 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4284 return Err(store::RedeemError::CapacityFull);
4285 }
4286 };
4287 if count >= state.config.beta_cap {
4288 return Err(store::RedeemError::CapacityFull);
4289 }
4290 // Look up the code's current status + expiry (read-only).
4291 let row = sqlx::query_as::<_, (String, i64)>(
4292 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4293 )
4294 .bind(code)
4295 .fetch_optional(&state.db)
4296 .await
4297 .ok()
4298 .flatten();
4299 let (status, expires_at) = match row {
4300 Some(r) => r,
4301 None => return Err(store::RedeemError::NotFound),
4302 };
4303 let now = chrono::Utc::now().timestamp();
4304 match status.as_str() {
4305 "active" if expires_at >= now => Ok(()),
4306 "active" => Err(store::RedeemError::Expired),
4307 "expired" => Err(store::RedeemError::Expired),
4308 // "redeemed" or anything else non-active.
4309 _ => Err(store::RedeemError::AlreadyRedeemed),
4310 }
4311}
4312
4313/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4314/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4315fn redeem_bounce(policy: &store::RedeemError) -> Response {
4316 use store::RedeemError::*;
4317 let (msg, capacity_full) = match policy {
4318 NotFound => ("That invite code isn't valid.", false),
4319 Expired => ("That invite code has expired.", false),
4320 AlreadyRedeemed => ("That invite code has already been used.", false),
4321 CapacityFull => ("", true),
4322 };
4323 render(&BetaRedeemTemplate {
4324 repo_url: REPO_URL,
4325 error: msg.to_string(),
4326 capacity_full,
4327 })
4328}
4329
4330/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4331#[derive(Debug, Deserialize, Default)]
4332struct MintQuery {
4333 #[serde(default)]
4334 n: Option<u32>,
4335}
4336
4337/// `POST /admin/invites?n=N` — mint N invite codes.
4338///
4339/// `GET /oauth/client-metadata.json` — the client's published identity.
4340///
4341/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4342/// caches it against every existing grant, so it must keep answering at exactly
4343/// this path across the cutover — the sidecar serves the same document at the
4344/// same URL today, proxied by the edge.
4345///
4346/// Served whatever backend is live: a request that arrives here is from a PDS
4347/// resolving our identity, and it has no idea which of our two implementations
4348/// is currently answering repo calls.
4349async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4350 let Some(runtime) = state.oauth.as_deref() else {
4351 // The sidecar is serving this path in front of us, or nothing is.
4352 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4353 };
4354 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4355}
4356
4357/// `GET /oauth/jwks.json` — the client's public signing key.
4358///
4359/// Production only. The localhost dev client is a PUBLIC client: it registers no
4360/// key and signs no assertions, so publishing a JWKS there would advertise a
4361/// credential that is never used — and would make a dev deployment look like a
4362/// confidential client to anyone reading it.
4363async fn oauth_jwks(State(state): State<AppState>) -> Response {
4364 let Some(runtime) = state.oauth.as_deref() else {
4365 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4366 };
4367 match runtime.client_key.as_ref() {
4368 Some(key) => match key.jwks_document() {
4369 Ok(doc) => axum::Json(doc).into_response(),
4370 Err(err) => {
4371 warn!(%err, "could not render the client JWKS");
4372 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4373 }
4374 },
4375 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4376 }
4377}
4378
4379/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4380const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4381
4382/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4383///
4384/// Admin-gated on the same rule as the invite minter: the table names every
4385/// operation the reader performs and how often each fails, which is an
4386/// operational picture rather than public information.
4387///
4388/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4389/// is safe, and the comparison is two rows side by side.
4390async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4391 let did = match current_did(&state, &headers).await {
4392 Some(d) => d,
4393 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4394 };
4395 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4396 warn!(%did, "admin metrics denied: not an admin-seed DID");
4397 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4398 }
4399
4400 // Flush first, so the table includes this process's traffic up to now.
4401 // Then read the PERSISTED rows, which is the only place both backends can
4402 // appear at once -- a flip is a restart, and in-process memory only ever
4403 // holds the backend currently running.
4404 if let Err(err) =
4405 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4406 {
4407 warn!(%err, "could not flush repo timings before rendering");
4408 }
4409 let rows = match crate::metrics::persisted_rows(&state.db).await {
4410 Ok(rows) => rows,
4411 Err(err) => {
4412 warn!(%err, "could not read persisted repo timings");
4413 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4414 }
4415 };
4416
4417 // The live backend is named at the top: a table of two populated rows is
4418 // ambiguous about which one is currently serving users.
4419 // Parked read-state, alongside the timings. The flusher no longer logs
4420 // these every round (#117), so without a number here the state would be
4421 // silent — which is the failure the noisy loop at least did not have.
4422 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4423 Ok(n) => n.to_string(),
4424 Err(err) => {
4425 warn!(%err, "could not count parked read-state DIDs");
4426 "unknown".to_string()
4427 }
4428 };
4429 // **The half the public histogram cannot carry.** `/stats` reports counts by
4430 // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4431 // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4432 // cannot separate "the publishers are gone" from "we are broken". #159 was
4433 // the latter and took a production investigation to establish. Named feeds
4434 // and their error text belong here, behind ALLOWED_DIDS.
4435 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4436 Ok(f) => f,
4437 Err(err) => {
4438 warn!(%err, "could not list failing feeds");
4439 Vec::new()
4440 }
4441 };
4442 let mut failing_block = String::new();
4443 if !failing.is_empty() {
4444 failing_block.push_str("\nfailing feeds (worst first)\n");
4445 for f in &failing {
4446 failing_block.push_str(&format!(
4447 " {:>4}x {:<8} {}\n {}\n",
4448 f.consecutive_errors,
4449 f.kind.as_deref().unwrap_or("unknown"),
4450 f.url,
4451 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4452 ));
4453 }
4454 }
4455
4456 // **Capacity that no other page can show.** The global ceiling counts every
4457 // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4458 // unpollable ones — so an instance can be at its cap with every public
4459 // number saying otherwise. A review found exactly that gap.
4460 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4461 Ok(n) => n,
4462 Err(err) => {
4463 warn!(%err, "could not count unpollable feeds");
4464 -1
4465 }
4466 };
4467 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4468
4469 let body = format!(
4470 "live backend: {}\nparked read-state DIDs: {}\n\
4471 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4472 state.config.repo_backend.as_str(),
4473 parked,
4474 cached,
4475 state.config.max_feeds_global,
4476 unpollable,
4477 crate::metrics::render(&rows),
4478 failing_block,
4479 );
4480 (StatusCode::OK, body).into_response()
4481}
4482
4483/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4484/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4485/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4486async fn admin_mint_invites(
4487 State(state): State<AppState>,
4488 headers: HeaderMap,
4489 Query(q): Query<MintQuery>,
4490) -> Response {
4491 // Require a real, current session (not just a DID string) whose DID is an
4492 // admin-seed DID. `current_did` already re-checks the beta gate.
4493 let did = match current_did(&state, &headers).await {
4494 Some(d) => d,
4495 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4496 };
4497 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4498 warn!(%did, "admin mint denied: not an admin-seed DID");
4499 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4500 }
4501
4502 let n = q.n.unwrap_or(1).clamp(1, 100);
4503 let mut codes = Vec::with_capacity(n as usize);
4504 for _ in 0..n {
4505 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4506 Ok(code) => codes.push(code),
4507 Err(err) => {
4508 warn!(%err, %did, "admin mint_code failed");
4509 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4510 }
4511 }
4512 }
4513 info!(%did, count = codes.len(), "admin minted invite codes");
4514 let mut body = codes.join("\n");
4515 body.push('\n');
4516 (StatusCode::OK, body).into_response()
4517}
4518
4519// ---------------------------------------------------------------------------
4520// Bot claim link: /claim?t=<token> + POST /bot/claims (shared-secret mint)
4521// ---------------------------------------------------------------------------
4522
4523/// Query for `GET /claim`.
4524#[derive(Debug, Deserialize)]
4525struct ClaimQuery {
4526 /// The opaque claim token from the bot's public follow-back skeet.
4527 t: Option<String>,
4528}
4529
4530/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4531///
4532/// The follow→invite bot posts a public skeet mentioning a new follower with a
4533/// link here. The token wraps a pre-minted invite code (never the raw code — see
4534/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4535/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4536/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4537/// callback atomically consumes the code (`store::redeem_code`) — the same
4538/// machinery as a pasted code. On any failure it bounces to the invite page with
4539/// the matching message.
4540///
4541/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4542/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4543/// here rejects an already-used / expired / capacity-full code before reserving,
4544/// so a replayed link past the first successful claim is refused. The residual
4545/// window is the same as any pasted invite code: whoever completes OAuth *first*
4546/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4547/// blunts brute-force enumeration.
4548async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4549 let token = match q.t {
4550 Some(t) if !t.is_empty() => t,
4551 _ => {
4552 warn!("claim link with no token");
4553 return redeem_bounce(&store::RedeemError::NotFound);
4554 }
4555 };
4556
4557 // Unwrap the token → the invite code it reserves. A tampered/forged token
4558 // yields nothing → treat as an invalid code (don't leak whether it parsed).
4559 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4560 Some(c) => c,
4561 None => {
4562 warn!("claim token invalid (bad signature / malformed)");
4563 return redeem_bounce(&store::RedeemError::NotFound);
4564 }
4565 };
4566
4567 // Re-run the same preflight as the pasted-code path: exists, active,
4568 // unexpired, seat free. This is what makes a replayed link past first-claim
4569 // (or past cap) fail cleanly.
4570 match preflight_code(&state, &code).await {
4571 Ok(()) => {
4572 let cookie = sign_invite(&code, &state.config.cookie_secret);
4573 let mut resp = Redirect::to("/login").into_response();
4574 set_cookie(&mut resp, &cookie);
4575 info!("claim token preflight OK; reserving intent + redirecting to /login");
4576 resp
4577 }
4578 Err(policy) => {
4579 warn!(?policy, "claim token preflight rejected");
4580 redeem_bounce(&policy)
4581 }
4582 }
4583}
4584
4585/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4586///
4587/// Passing the follower DID makes the APP the authoritative deduper: the app can
4588/// short-circuit a DID that already holds a seat, and return the SAME code for a
4589/// DID that already has an outstanding claim — so a bot-host state loss cannot
4590/// re-mint or re-post per follower. Handle is advisory (logs only).
4591#[derive(Debug, Default, Deserialize)]
4592struct BotClaimRequest {
4593 /// The follower's DID (the idempotency key). Optional for backward-compat: an
4594 /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4595 #[serde(default)]
4596 did: Option<String>,
4597 /// The follower's handle (advisory; recorded for operator logs only).
4598 #[serde(default)]
4599 #[allow(dead_code)]
4600 handle: Option<String>,
4601}
4602
4603/// The JSON body `POST /bot/claims` returns on success.
4604#[derive(Debug, serde::Serialize)]
4605struct BotClaimResponse {
4606 /// Server-side dedupe outcome, so the bot knows whether to post:
4607 /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
4608 /// already had an outstanding claim; the SAME code/token/url is returned, so an
4609 /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
4610 /// beta access; code/token/url are empty and the bot should post NOTHING).
4611 status: &'static str,
4612 /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
4613 /// store. NEVER post this publicly; post the `url` instead. Empty when
4614 /// `already_seated`.
4615 code: String,
4616 /// The opaque claim token (the code wrapped + signed). Empty when
4617 /// `already_seated`.
4618 token: String,
4619 /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
4620 /// Empty when `already_seated`.
4621 url: String,
4622}
4623
4624/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
4625///
4626/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
4627/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
4628/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
4629/// (503), so a bare/dev instance never exposes an unauthenticated mint.
4630///
4631/// Server-side DID idempotency (the authoritative dedupe backstop): the request
4632/// body carries the follower `did`. The app — not the bot's local SQLite — is the
4633/// source of truth, so a bot-host state loss cannot re-mint or re-post per
4634/// follower:
4635/// * DID already holds beta access → `200 {status:"already_seated"}` (empty
4636/// code/url; the bot marks it handled and posts NOTHING);
4637/// * DID already has an outstanding active claim → `200 {status:"existing"}`
4638/// returning the SAME code/token/url (idempotent — never a second mint);
4639/// * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
4640///
4641/// Cap accounting: the bot must not promise more claims than seats remain, so
4642/// this refuses with `409 Conflict {"error":"full"}` when
4643/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
4644/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
4645/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
4646/// minting past the cap.
4647///
4648/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
4649/// default 14d — the admin browser flow's 30-min TTL would expire before the
4650/// follower taps an async-delivered link).
4651async fn bot_mint_claim(
4652 State(state): State<AppState>,
4653 headers: HeaderMap,
4654 body: axum::body::Bytes,
4655) -> Response {
4656 // 1. The endpoint is OFF unless a bot secret is configured.
4657 let bot_secret = match state.config.bot_secret.as_deref() {
4658 Some(s) => s,
4659 None => {
4660 warn!(
4661 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4662 );
4663 return (
4664 StatusCode::SERVICE_UNAVAILABLE,
4665 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4666 )
4667 .into_response();
4668 }
4669 };
4670
4671 // 2. Constant-time bearer check on the X-Bot-Secret header.
4672 let presented = headers
4673 .get("x-bot-secret")
4674 .and_then(|v| v.to_str().ok())
4675 .unwrap_or("");
4676 if !bot_secret_matches(presented, bot_secret) {
4677 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4678 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4679 }
4680
4681 // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
4682 // (legacy caller) parses to an all-None request; a malformed body is a 400.
4683 let req: BotClaimRequest = if body.is_empty() {
4684 BotClaimRequest::default()
4685 } else {
4686 match serde_json::from_slice(&body) {
4687 Ok(r) => r,
4688 Err(err) => {
4689 warn!(%err, "POST /bot/claims: bad JSON body");
4690 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4691 }
4692 }
4693 };
4694 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4695
4696 // 3. Server-side DID idempotency (only when a DID was supplied):
4697 if let Some(did) = follower_did {
4698 // 3a. Already seated → tell the bot to post nothing.
4699 match store::has_beta_access(&state.db, did).await {
4700 Ok(true) => {
4701 info!("bot mint: DID already holds beta access; already_seated");
4702 return bot_claim_json(BotClaimResponse {
4703 status: "already_seated",
4704 code: String::new(),
4705 token: String::new(),
4706 url: String::new(),
4707 });
4708 }
4709 Ok(false) => {}
4710 Err(err) => {
4711 // Fail closed: a DB error must not fall through to a fresh mint.
4712 warn!(%err, "bot mint: has_beta_access failed");
4713 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4714 }
4715 }
4716 // 3b. Outstanding active claim for this DID → return the SAME code (no
4717 // second mint). This is what survives a bot-host state loss.
4718 match store::find_active_code_for_did(&state.db, did).await {
4719 Ok(Some(code)) => {
4720 info!("bot mint: existing outstanding claim for DID; returning same code");
4721 let token = sign_claim_token(&code, &state.config.cookie_secret);
4722 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4723 return bot_claim_json(BotClaimResponse {
4724 status: "existing",
4725 code,
4726 token,
4727 url,
4728 });
4729 }
4730 Ok(None) => {}
4731 Err(err) => {
4732 warn!(%err, "bot mint: find_active_code_for_did failed");
4733 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4734 }
4735 }
4736 }
4737
4738 // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
4739 // FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
4740 let granted = match store::count_beta_access(&state.db).await {
4741 Ok(n) => n,
4742 Err(err) => {
4743 warn!(%err, "bot mint: count_beta_access failed; failing closed");
4744 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4745 }
4746 };
4747 let outstanding = match store::count_active_codes(&state.db).await {
4748 Ok(n) => n,
4749 Err(err) => {
4750 warn!(%err, "bot mint: count_active_codes failed; failing closed");
4751 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4752 }
4753 };
4754 if granted + outstanding >= state.config.beta_cap {
4755 info!(
4756 granted,
4757 outstanding,
4758 cap = state.config.beta_cap,
4759 "bot mint refused: at capacity"
4760 );
4761 return (
4762 StatusCode::CONFLICT,
4763 [(header::CONTENT_TYPE, "application/json")],
4764 "{\"error\":\"full\"}\n",
4765 )
4766 .into_response();
4767 }
4768
4769 // 5. Mint with the generous claim TTL, recording the follower DID (when given)
4770 // so a re-request for the same DID returns THIS code idempotently.
4771 let bot_did = state
4772 .config
4773 .admin_seed_dids()
4774 .first()
4775 .cloned()
4776 .unwrap_or_else(|| "did:bot:featherreader".to_string());
4777 let minted = match follower_did {
4778 Some(did) => {
4779 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4780 }
4781 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4782 };
4783 let code = match minted {
4784 Ok(c) => c,
4785 // S4: the dedupe check (3b) and this mint are separate statements, so two
4786 // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
4787 // The partial unique index `idx_invite_codes_intended_active` makes the
4788 // loser's INSERT fail (only one active row per intended DID), which
4789 // surfaces here as a conflict. Recover by returning the winner's existing
4790 // code (same shape as the 3b idempotent path) instead of a 500.
4791 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4792 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4793 Ok(Some(code)) => {
4794 info!("bot mint: lost the mint race; returning the concurrently-minted code");
4795 let token = sign_claim_token(&code, &state.config.cookie_secret);
4796 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4797 return bot_claim_json(BotClaimResponse {
4798 status: "existing",
4799 code,
4800 token,
4801 url,
4802 });
4803 }
4804 // The winner's row vanished between the conflict and this lookup
4805 // (redeemed/expired/purged in the gap) — nothing to hand back.
4806 // Fail closed rather than silently mint past the just-hit guard.
4807 Ok(None) => {
4808 warn!("bot mint: conflict but no active code found on recovery");
4809 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4810 }
4811 Err(err) => {
4812 warn!(%err, "bot mint: recovery lookup after conflict failed");
4813 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4814 }
4815 }
4816 }
4817 Err(err) => {
4818 warn!(%err, "bot mint_code failed");
4819 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4820 }
4821 };
4822 let token = sign_claim_token(&code, &state.config.cookie_secret);
4823 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4824 info!("bot minted a claim code + token");
4825
4826 bot_claim_json(BotClaimResponse {
4827 status: "minted",
4828 code,
4829 token,
4830 url,
4831 })
4832}
4833
4834/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
4835/// `500` if serialization somehow fails).
4836fn bot_claim_json(resp: BotClaimResponse) -> Response {
4837 match serde_json::to_string(&resp) {
4838 Ok(body) => (
4839 StatusCode::OK,
4840 [(header::CONTENT_TYPE, "application/json")],
4841 body,
4842 )
4843 .into_response(),
4844 Err(err) => {
4845 warn!(%err, "serializing bot claim response failed");
4846 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4847 }
4848 }
4849}
4850
4851/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
4852/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
4853/// by the HMAC checks so there is one comparator to audit; a length mismatch
4854/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
4855fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4856 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4857}
4858
4859// ---------------------------------------------------------------------------
4860// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
4861// ---------------------------------------------------------------------------
4862
4863/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
4864/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
4865/// itself (base64url) rather than an opaque sid, since the code IS the reserved
4866/// intent the callback consumes.
4867fn sign_invite(code: &str, secret: &str) -> String {
4868 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4869}
4870
4871/// Verify + read the reserved invite code out of the request's invite cookie
4872/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
4873/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
4874/// authority on the code's live status.
4875fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4876 cookie::verify_value(headers, INVITE_COOKIE, secret)
4877}
4878
4879/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
4880/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
4881/// cookie value and vice-versa.
4882const CLAIM_TOKEN_LABEL: &str = "claim-token";
4883
4884/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
4885/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
4886///
4887/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
4888/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
4889/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
4890/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
4891/// code won't verify), the wrapped code is single-use (redeem flips
4892/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
4893/// token one self-contained string needing no server-side token table; it does
4894/// NOT hide the code.
4895fn sign_claim_token(code: &str, secret: &str) -> String {
4896 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
4897}
4898
4899/// Verify a claim token and return the invite code it wraps (`None` on a tampered
4900/// / forged / malformed token). The code's live status (active/unexpired/seat
4901/// free) is re-checked by `preflight_code`; this only proves the token was minted
4902/// by this instance.
4903fn claim_token_code(token: &str, secret: &str) -> Option<String> {
4904 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
4905}
4906
4907/// Clear the invite cookie on a response (after a successful bind, or when the
4908/// reservation turned out to be stale).
4909fn clear_invite_cookie(resp: &mut Response) {
4910 set_cookie(
4911 resp,
4912 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4913 );
4914}
4915
4916// ---------------------------------------------------------------------------
4917// OPML import + export
4918// ---------------------------------------------------------------------------
4919
4920/// `POST /opml` — import subscriptions from an OPML document.
4921///
4922/// Accepts either a multipart file upload (field `file`) or a pasted textarea
4923/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
4924/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
4925/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
4926/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
4927/// they show immediately; polling is left to the background poller.
4928async fn import_opml(
4929 State(state): State<AppState>,
4930 headers: HeaderMap,
4931 mut multipart: Multipart,
4932) -> Result<Response, WebError> {
4933 let did = match current_did(&state, &headers).await {
4934 Some(d) => d,
4935 None => return Ok(Redirect::to("/login").into_response()),
4936 };
4937 let pool = &state.db;
4938
4939 // Collect the OPML text from whichever field carried it. Multipart errors
4940 // are mapped to their axum-native response so that an over-cap upload (the
4941 // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
4942 // `413 Payload Too Large` rather than being swallowed by the blanket
4943 // `WebError` → `500` conversion.
4944 let mut opml_text = String::new();
4945 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
4946 let name = field.name().unwrap_or("").to_string();
4947 if name == "opml" || name == "file" {
4948 let bytes = field.bytes().await.map_err(multipart_response)?;
4949 if !bytes.is_empty() {
4950 opml_text = String::from_utf8_lossy(&bytes).into_owned();
4951 if name == "file" {
4952 break;
4953 }
4954 }
4955 }
4956 }
4957
4958 // A parse FAILURE and an empty-but-valid file are different things, and
4959 // `unwrap_or_default` collapsed them: a malformed export was reported to the
4960 // reader as "No feeds found in that OPML", which sends them looking at their
4961 // old reader for feeds that are right there in the file.
4962 let feeds =
4963 match opml::parse_opml(&opml_text) {
4964 Ok(feeds) => feeds,
4965 Err(err) => {
4966 warn!(%err, %did, "OPML import could not parse the uploaded file");
4967 return Ok(Redirect::to(&format!(
4968 "/?flash={}",
4969 qenc("That file could not be read as OPML. Export it again from your other reader?")
4970 ))
4971 .into_response());
4972 }
4973 };
4974 if feeds.is_empty() {
4975 info!(%did, "OPML import found no feeds");
4976 return Ok(
4977 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
4978 .into_response(),
4979 );
4980 }
4981
4982 // Create any named folders first, mapping folder name → at:// URI so
4983 // subscriptions can reference them.
4984 let now = now_rfc3339();
4985 let mut folder_uris: std::collections::HashMap<String, String> =
4986 std::collections::HashMap::new();
4987 // Reuse existing folders where the name already exists.
4988 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
4989 for (rkey, folder) in existing {
4990 folder_uris
4991 .entry(folder.name.clone())
4992 .or_insert_with(|| folder_uri(&did, &rkey));
4993 }
4994 }
4995 let mut wanted_folders: Vec<String> = feeds
4996 .iter()
4997 .filter_map(|f| f.folder.clone())
4998 .filter(|n| !n.is_empty())
4999 .collect();
5000 wanted_folders.sort();
5001 wanted_folders.dedup();
5002 for name in wanted_folders {
5003 if folder_uris.contains_key(&name) {
5004 continue;
5005 }
5006 let folder = Folder::new(name.clone(), now.clone());
5007 match state.repo().add_folder(&did, &folder).await {
5008 Ok(rkey) => {
5009 folder_uris.insert(name, folder_uri(&did, &rkey));
5010 }
5011 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5012 }
5013 }
5014
5015 // Build one subscription record per PUBLIC feed + upsert the local cache row.
5016 // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5017 // and reported back to the user — the same public-feeds-only stance as the
5018 // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5019 // token onto the public network either.
5020 // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5021 // the remaining headroom (cap − existing) once; public feeds beyond it are
5022 // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5023 let sub_cap = state.config.max_subs_per_did;
5024 let mut headroom: Option<i64> = if sub_cap > 0 {
5025 let existing = store::count_subscriptions_for_did(pool, &did)
5026 .await
5027 .unwrap_or(0);
5028 Some((sub_cap - existing).max(0))
5029 } else {
5030 None
5031 };
5032 let mut trimmed_over_cap: usize = 0;
5033
5034 // Global feeds ceiling: an OPML import must not blow past the shared cache
5035 // ceiling any more than the single-add path may. Seed the remaining global
5036 // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5037 // not already cached) consumes it. Existing/duplicate URLs add no row and
5038 // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5039 // `<= 0` disables the ceiling.
5040 let feeds_cap = state.config.max_feeds_global;
5041 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5042 let existing = store::count_feeds(pool).await.unwrap_or(0);
5043 Some((feeds_cap - existing).max(0))
5044 } else {
5045 None
5046 };
5047 let mut trimmed_over_global: usize = 0;
5048
5049 let mut subs = Vec::with_capacity(feeds.len());
5050 let mut skipped_private: Vec<String> = Vec::new();
5051 // Imported into the PDS but not cached locally, so not pollable until the
5052 // next import touches them. Counted rather than only logged — see below.
5053 let mut uncached: usize = 0;
5054 // Entries this instance cannot store at all (an `at://` publication with
5055 // the flag off, an unsupported scheme). Counted, because the `continue`
5056 // below used to increment nothing while the privacy branch beside it
5057 // produced a label — so an OPML from a standard.site-enabled instance
5058 // imported "successfully" with entries missing and no reason given.
5059 let mut skipped_unsupported: usize = 0;
5060 for f in &feeds {
5061 // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5062 // ever parsed it — the single-add path can't reach here because
5063 // `resolve_feed_url` must parse AND successfully fetch first. So
5064 // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5065 // cached, and published as records to the user's PUBLIC repo. Note that
5066 // `classify_feed_privacy` does not catch these: both parse cleanly, and
5067 // it returns `Public` for anything unparseable by design.
5068 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5069 info!(
5070 %did,
5071 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5072 );
5073 skipped_unsupported += 1;
5074 continue;
5075 }
5076 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5077 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5078 // Report by title where we have one, else the (public-safe) host.
5079 let label = f
5080 .title
5081 .clone()
5082 .filter(|t| !t.trim().is_empty())
5083 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5084 skipped_private.push(label);
5085 continue;
5086 }
5087
5088 // Over-cap: stop importing once headroom is exhausted (count the rest so
5089 // we can tell the user how many were dropped).
5090 if let Some(h) = headroom.as_mut() {
5091 if *h <= 0 {
5092 trimmed_over_cap += 1;
5093 continue;
5094 }
5095 }
5096
5097 // Global ceiling: a brand-new feed URL consumes global headroom. Once
5098 // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5099 // free — they add no row). Checked before decrementing the per-DID
5100 // headroom so a dropped feed doesn't burn the caller's own quota.
5101 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5102 Ok(existing) => existing.is_none(),
5103 // On a lookup error, treat as existing (don't consume global
5104 // headroom) but still allow the upsert to proceed.
5105 Err(err) => {
5106 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5107 false
5108 }
5109 };
5110 if is_new {
5111 if let Some(g) = global_headroom.as_mut() {
5112 if *g <= 0 {
5113 trimmed_over_global += 1;
5114 continue;
5115 }
5116 *g -= 1;
5117 }
5118 }
5119
5120 // Passed both caps: consume the per-DID headroom now that the feed is
5121 // actually being imported.
5122 if let Some(h) = headroom.as_mut() {
5123 *h -= 1;
5124 }
5125
5126 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5127 sub.title = f.title.clone();
5128 sub.site_url = f.site_url.clone();
5129 sub.folder = f
5130 .folder
5131 .as_ref()
5132 .and_then(|name| folder_uris.get(name).cloned());
5133 subs.push(sub);
5134 // Same support ticket as the single-add path: no `feeds` row means the
5135 // poller never selects this subscription, so the import looks like it
5136 // worked and the feed silently never updates. Counted as well as logged,
5137 // because one line per feed in a 200-feed import is not something anyone
5138 // reads — the count goes to the reader.
5139 if let Err(err) = store::upsert_feed(
5140 pool,
5141 &store::NewFeed {
5142 url: f.feed_url.clone(),
5143 title: f.title.clone(),
5144 site_url: f.site_url.clone(),
5145 ..Default::default()
5146 },
5147 )
5148 .await
5149 {
5150 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5151 it will not be polled");
5152 uncached += 1;
5153 }
5154 }
5155
5156 // **A failed PDS write is not an import.**
5157 //
5158 // The subscriptions live in the reader's repo; a local `feeds` row is just a
5159 // poller hint. This used to `warn!` and then report "Imported N feeds"
5160 // regardless, so a total failure read as a total success — and the reader
5161 // would only discover otherwise on their next visit, with an empty sidebar.
5162 let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5163 Ok(rkeys) => {
5164 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5165 true
5166 }
5167 Err(err) => {
5168 warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5169 false
5170 }
5171 };
5172 if !pds_written {
5173 return Ok(Redirect::to(&format!(
5174 "/?flash={}",
5175 qenc(
5176 "Could not save those subscriptions to your PDS, so nothing was imported. \
5177 Try again in a moment."
5178 )
5179 ))
5180 .into_response());
5181 }
5182
5183 // Report the import count, plus any private/paid feeds skipped as unsupported.
5184 let mut flash = format!("Imported {} feeds", subs.len());
5185 if uncached > 0 {
5186 flash.push_str(&format!(
5187 ". {uncached} of them could not be cached locally and may not update until the next import."
5188 ));
5189 }
5190 if trimmed_over_cap > 0 {
5191 flash.push_str(&format!(
5192 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5193 ));
5194 }
5195 if trimmed_over_global > 0 {
5196 flash.push_str(&format!(
5197 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5198 ));
5199 }
5200 if !skipped_private.is_empty() {
5201 flash.push_str(&format!(
5202 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5203 skipped_private.len(),
5204 skipped_private.join(", ")
5205 ));
5206 }
5207 if skipped_unsupported > 0 {
5208 // By count only — the URL is whatever the file said, and unlike the
5209 // private branch there is no public-safe label to give.
5210 flash.push_str(&format!(
5211 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5212 ));
5213 }
5214 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5215}
5216
5217/// A public-safe label for a skipped private feed when it has no title: just the
5218/// host, so we never echo the secret-bearing path/query back to the user.
5219fn private_feed_label(url: &str) -> String {
5220 url::Url::parse(url)
5221 .ok()
5222 .and_then(|u| u.host_str().map(str::to_string))
5223 .unwrap_or_else(|| "a private feed".to_string())
5224}
5225
5226/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5227async fn export_opml(
5228 State(state): State<AppState>,
5229 headers: HeaderMap,
5230) -> Result<Response, WebError> {
5231 let did = match current_did(&state, &headers).await {
5232 Some(d) => d,
5233 None => return Ok(Redirect::to("/login").into_response()),
5234 };
5235
5236 // **An export must never be silently empty.** `unwrap_or_default` here turned
5237 // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5238 // backup, blank, at exactly the moment they reached for it. That was survivable
5239 // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5240 // this is the one caller that converts a refusal into data loss, and it is also
5241 // the recovery route the changelog points a locked-out reader at.
5242 let subs = match state.repo().list_subscriptions_sorted(&did).await {
5243 Ok(subs) => subs,
5244 Err(err) => {
5245 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5246 return Ok(Redirect::to(&format!(
5247 "/manage?flash={}",
5248 qenc(EXPORT_INCOMPLETE_REFUSAL)
5249 ))
5250 .into_response());
5251 }
5252 };
5253 let folders = match state.repo().list_folders_sorted(&did).await {
5254 Ok(folders) => folders,
5255 Err(err) => {
5256 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5257 return Ok(Redirect::to(&format!(
5258 "/manage?flash={}",
5259 qenc(EXPORT_INCOMPLETE_REFUSAL)
5260 ))
5261 .into_response());
5262 }
5263 };
5264 // The exporter matches a subscription's `folder` at-uri against the folder's
5265 // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5266 let folder_pairs: Vec<(String, Folder)> = folders
5267 .into_iter()
5268 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5269 .collect();
5270
5271 let body = opml::to_opml(&subs, &folder_pairs);
5272 let mut resp = (StatusCode::OK, body).into_response();
5273 resp.headers_mut().insert(
5274 header::CONTENT_TYPE,
5275 "text/x-opml; charset=utf-8".parse().unwrap(),
5276 );
5277 resp.headers_mut().insert(
5278 header::CONTENT_DISPOSITION,
5279 "attachment; filename=\"featherreader-subscriptions.opml\""
5280 .parse()
5281 .unwrap(),
5282 );
5283 Ok(resp)
5284}
5285
5286// ---------------------------------------------------------------------------
5287// Signed session cookie (HMAC-SHA256, dependency-free)
5288// ---------------------------------------------------------------------------
5289
5290/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5291fn set_cookie(resp: &mut Response, cookie: &str) {
5292 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5293 resp.headers_mut()
5294 .append(axum::http::header::SET_COOKIE, value);
5295 }
5296}
5297
5298/// Whether the request came from htmx (the `HX-Request` header).
5299fn is_htmx(headers: &HeaderMap) -> bool {
5300 headers
5301 .get("HX-Request")
5302 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5303}
5304
5305/// Whether a mark-read / star request originated from the single-entry READER
5306/// (as opposed to the list view). The reader's forms tag themselves with
5307/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5308/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5309/// isn't in the DOM), the list gets the row (`entry_row.html`).
5310fn is_reader_request(headers: &HeaderMap) -> bool {
5311 headers
5312 .get("X-FR-Reader")
5313 .is_some_and(|v| v.as_bytes() == b"1")
5314}
5315
5316/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5317/// server-minted **session id** (never the DID — so the cookie can't be forged
5318/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5319/// server-side session id).
5320mod cookie {
5321 use super::{HeaderMap, SESSION_COOKIE};
5322
5323 /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5324 pub fn sign_session(sid: &str, secret: &str) -> String {
5325 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5326 }
5327
5328 /// Verify the request's session cookie and return the session id it carries.
5329 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5330 verify_value(headers, SESSION_COOKIE, secret)
5331 }
5332
5333 /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5334 /// value`), so a signature minted for one cookie can't verify under another —
5335 /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5336 /// The NUL separator can't appear in a cookie name, so the encoding is
5337 /// unambiguous.
5338 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5339 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5340 msg.extend_from_slice(name.as_bytes());
5341 msg.push(0);
5342 msg.extend_from_slice(value.as_bytes());
5343 msg
5344 }
5345
5346 /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5347 /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5348 /// generic form behind both the session cookie and the short-lived invite
5349 /// cookie; domain-separating by name keeps a signature valid only for the
5350 /// cookie it was minted for.
5351 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5352 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5353 let b64 = b64url_encode(value.as_bytes());
5354 format!(
5355 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5356 )
5357 }
5358
5359 /// Verify + read a value out of the named signed cookie (`None` on absent /
5360 /// tampered / forged / cross-cookie). The generic form behind both readers.
5361 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5362 let raw = cookie_value(headers, name)?;
5363 let (b64, sig) = raw.split_once('.')?;
5364 let bytes = b64url_decode(b64)?;
5365 let value = String::from_utf8(bytes).ok()?;
5366 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5367 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5368 Some(value)
5369 } else {
5370 None
5371 }
5372 }
5373
5374 /// Sign an arbitrary `value` into an opaque, URL-safe token string
5375 /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5376 /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5377 /// URL query param (the bot's claim link). `label` domain-separates it from
5378 /// the cookies so a token can't be replayed as a cookie value.
5379 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5380 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5381 let b64 = b64url_encode(value.as_bytes());
5382 format!("{b64}.{sig}")
5383 }
5384
5385 /// Verify a token minted by [`sign_token`] and return the wrapped value
5386 /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5387 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5388 let (b64, sig) = token.split_once('.')?;
5389 let bytes = b64url_decode(b64)?;
5390 let value = String::from_utf8(bytes).ok()?;
5391 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5392 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5393 Some(value)
5394 } else {
5395 None
5396 }
5397 }
5398
5399 /// Pull one cookie value out of the `Cookie` request header.
5400 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5401 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5402 for part in header.split(';') {
5403 let part = part.trim();
5404 if let Some((k, v)) = part.split_once('=') {
5405 if k == name {
5406 return Some(v.to_string());
5407 }
5408 }
5409 }
5410 None
5411 }
5412
5413 /// Constant-time byte comparison (avoid signature-timing leaks). Public
5414 /// within the module so the bot-secret bearer check reuses the exact same
5415 /// comparator as the cookie/token HMAC checks (one implementation to audit).
5416 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5417 if a.len() != b.len() {
5418 return false;
5419 }
5420 let mut diff = 0u8;
5421 for (x, y) in a.iter().zip(b.iter()) {
5422 diff |= x ^ y;
5423 }
5424 diff == 0
5425 }
5426
5427 // -- URL-safe base64 (no padding), std-only --------------------------------
5428
5429 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5430
5431 fn b64url_encode(input: &[u8]) -> String {
5432 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5433 for chunk in input.chunks(3) {
5434 let b = [
5435 chunk[0],
5436 *chunk.get(1).unwrap_or(&0),
5437 *chunk.get(2).unwrap_or(&0),
5438 ];
5439 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5440 out.push(B64[((n >> 18) & 63) as usize] as char);
5441 out.push(B64[((n >> 12) & 63) as usize] as char);
5442 if chunk.len() > 1 {
5443 out.push(B64[((n >> 6) & 63) as usize] as char);
5444 }
5445 if chunk.len() > 2 {
5446 out.push(B64[(n & 63) as usize] as char);
5447 }
5448 }
5449 out
5450 }
5451
5452 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5453 fn val(c: u8) -> Option<u32> {
5454 match c {
5455 b'A'..=b'Z' => Some((c - b'A') as u32),
5456 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5457 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5458 b'-' => Some(62),
5459 b'_' => Some(63),
5460 _ => None,
5461 }
5462 }
5463 let bytes = input.as_bytes();
5464 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5465 for chunk in bytes.chunks(4) {
5466 let mut n = 0u32;
5467 let mut valid = 0;
5468 for (i, &c) in chunk.iter().enumerate() {
5469 n |= val(c)? << (18 - 6 * i);
5470 valid += 1;
5471 }
5472 out.push((n >> 16) as u8);
5473 if valid > 2 {
5474 out.push((n >> 8) as u8);
5475 }
5476 if valid > 3 {
5477 out.push(n as u8);
5478 }
5479 }
5480 Some(out)
5481 }
5482
5483 // -- HMAC-SHA256, std-only -------------------------------------------------
5484
5485 /// HMAC-SHA256(key, msg) as lowercase hex.
5486 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5487 const BLOCK: usize = 64;
5488 let mut k = [0u8; BLOCK];
5489 if key.len() > BLOCK {
5490 let d = sha256(key);
5491 k[..32].copy_from_slice(&d);
5492 } else {
5493 k[..key.len()].copy_from_slice(key);
5494 }
5495 let mut ipad = [0x36u8; BLOCK];
5496 let mut opad = [0x5cu8; BLOCK];
5497 for i in 0..BLOCK {
5498 ipad[i] ^= k[i];
5499 opad[i] ^= k[i];
5500 }
5501 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5502 inner.extend_from_slice(&ipad);
5503 inner.extend_from_slice(msg);
5504 let inner_hash = sha256(&inner);
5505 let mut outer = Vec::with_capacity(BLOCK + 32);
5506 outer.extend_from_slice(&opad);
5507 outer.extend_from_slice(&inner_hash);
5508 let mac = sha256(&outer);
5509 let mut hex = String::with_capacity(64);
5510 for b in mac {
5511 hex.push_str(&format!("{b:02x}"));
5512 }
5513 hex
5514 }
5515
5516 /// SHA-256 (FIPS 180-4), std-only.
5517 fn sha256(data: &[u8]) -> [u8; 32] {
5518 const K: [u32; 64] = [
5519 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5520 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5521 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5522 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5523 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5524 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5525 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5526 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5527 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5528 0xc67178f2,
5529 ];
5530 let mut h: [u32; 8] = [
5531 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5532 0x5be0cd19,
5533 ];
5534
5535 let bit_len = (data.len() as u64) * 8;
5536 let mut msg = data.to_vec();
5537 msg.push(0x80);
5538 while msg.len() % 64 != 56 {
5539 msg.push(0);
5540 }
5541 msg.extend_from_slice(&bit_len.to_be_bytes());
5542
5543 for block in msg.chunks(64) {
5544 let mut w = [0u32; 64];
5545 for i in 0..16 {
5546 w[i] = u32::from_be_bytes([
5547 block[i * 4],
5548 block[i * 4 + 1],
5549 block[i * 4 + 2],
5550 block[i * 4 + 3],
5551 ]);
5552 }
5553 for i in 16..64 {
5554 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5555 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5556 w[i] = w[i - 16]
5557 .wrapping_add(s0)
5558 .wrapping_add(w[i - 7])
5559 .wrapping_add(s1);
5560 }
5561 let mut a = h;
5562 for i in 0..64 {
5563 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5564 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5565 let t1 = a[7]
5566 .wrapping_add(s1)
5567 .wrapping_add(ch)
5568 .wrapping_add(K[i])
5569 .wrapping_add(w[i]);
5570 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5571 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5572 let t2 = s0.wrapping_add(maj);
5573 a[7] = a[6];
5574 a[6] = a[5];
5575 a[5] = a[4];
5576 a[4] = a[3].wrapping_add(t1);
5577 a[3] = a[2];
5578 a[2] = a[1];
5579 a[1] = a[0];
5580 a[0] = t1.wrapping_add(t2);
5581 }
5582 for i in 0..8 {
5583 h[i] = h[i].wrapping_add(a[i]);
5584 }
5585 }
5586
5587 let mut out = [0u8; 32];
5588 for (i, word) in h.iter().enumerate() {
5589 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5590 }
5591 out
5592 }
5593
5594 #[cfg(test)]
5595 mod tests {
5596 use super::*;
5597
5598 #[test]
5599 fn sha256_known_vector() {
5600 let d = sha256(b"abc");
5601 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5602 assert_eq!(
5603 hex,
5604 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5605 );
5606 }
5607
5608 #[test]
5609 fn hmac_known_vector() {
5610 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5611 assert_eq!(
5612 mac,
5613 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5614 );
5615 }
5616
5617 #[test]
5618 fn sign_verify_round_trips() {
5619 let secret = "test-secret";
5620 let sid = "9f2c-opaque-session-id";
5621 let cookie = sign_session(sid, secret);
5622 let pair = cookie.split(';').next().unwrap().to_string();
5623 let mut headers = HeaderMap::new();
5624 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5625 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5626 // Wrong secret → rejected (an attacker without the HMAC key can't forge).
5627 assert!(verify_session(&headers, "other-secret").is_none());
5628 }
5629
5630 #[test]
5631 fn forged_and_tampered_cookies_are_rejected() {
5632 let secret = "test-secret";
5633
5634 // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
5635 // the secret, so an arbitrary signature must not verify.
5636 let forged = format!(
5637 "{SESSION_COOKIE}={}.{}",
5638 b64url_encode(b"attacker-chosen-sid"),
5639 "deadbeef".repeat(8) // 64 hex chars, wrong sig
5640 );
5641 let mut headers = HeaderMap::new();
5642 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5643 assert!(verify_session(&headers, secret).is_none());
5644
5645 // 2. A tampered cookie: take a VALID cookie and mutate the sid while
5646 // keeping the original signature — must not verify.
5647 let cookie = sign_session("real-sid", secret);
5648 let pair = cookie.split(';').next().unwrap();
5649 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5650 let tampered = format!(
5651 "{SESSION_COOKIE}={}.{}",
5652 b64url_encode(b"different-sid"),
5653 sig
5654 );
5655 let mut headers2 = HeaderMap::new();
5656 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5657 assert!(verify_session(&headers2, secret).is_none());
5658 }
5659
5660 #[test]
5661 fn b64url_round_trips() {
5662 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5663 let enc = b64url_encode(s.as_bytes());
5664 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5665 }
5666 }
5667 }
5668}
5669
5670// ---------------------------------------------------------------------------
5671// Small store helpers local to the web layer
5672// ---------------------------------------------------------------------------
5673
5674/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
5675///
5676/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
5677/// `did` does not subscribe to its feed. This is the per-DID read gate for the
5678/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
5679/// deduped by URL, but no DID can read another DID's cached article.
5680///
5681/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
5682/// that renders `content_html`, and it fetches exactly one row. The list views
5683/// go through [`store::list_entries`], which is both paged and body-free — see
5684/// [`store::EntryListRow`] for why they had to stop sharing this projection.
5685async fn get_entry_by_id(
5686 pool: &store::Pool,
5687 did: &str,
5688 id: i64,
5689) -> anyhow::Result<Option<store::Entry>> {
5690 let entry = sqlx::query_as::<_, store::Entry>(
5691 r#"
5692 SELECT e.* FROM entries e
5693 WHERE e.id = ?2
5694 AND EXISTS (
5695 SELECT 1 FROM sub_ref sr
5696 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5697 )
5698 "#,
5699 )
5700 .bind(did)
5701 .bind(id)
5702 .fetch_optional(pool)
5703 .await?;
5704 Ok(entry)
5705}
5706
5707/// Whether `entry_id` is marked read for `did` (absent state row = unread).
5708async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5709 let read: Option<bool> =
5710 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5711 .bind(did)
5712 .bind(entry_id)
5713 .fetch_optional(pool)
5714 .await?
5715 .flatten();
5716 Ok(read.unwrap_or(false))
5717}
5718
5719/// Whether `entry_id` is starred for `did` (absent state row = not starred).
5720async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5721 let starred: Option<bool> =
5722 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5723 .bind(did)
5724 .bind(entry_id)
5725 .fetch_optional(pool)
5726 .await?
5727 .flatten();
5728 Ok(starred.unwrap_or(false))
5729}
5730
5731/// Feed display title for one entry's feed id (via a single lookup).
5732async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5733 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5734 .bind(feed_id)
5735 .fetch_optional(pool)
5736 .await
5737 {
5738 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5739 _ => String::new(),
5740 }
5741}
5742
5743/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
5744/// be forced (mark-read path) or looked up (`None` — star path).
5745async fn build_entry_row(
5746 pool: &store::Pool,
5747 did: &str,
5748 id: i64,
5749 read: Option<bool>,
5750) -> anyhow::Result<Option<EntryRow>> {
5751 let entry = match get_entry_by_id(pool, did, id).await? {
5752 Some(e) => e,
5753 None => return Ok(None),
5754 };
5755 let read = match read {
5756 Some(r) => r,
5757 None => entry_is_read(pool, did, id).await?,
5758 };
5759 let starred = entry_is_starred(pool, did, id).await?;
5760 Ok(Some(EntryRow {
5761 id: entry.id,
5762 title: entry
5763 .title
5764 .clone()
5765 .filter(|t| !t.trim().is_empty())
5766 .unwrap_or_else(|| "(untitled)".to_string()),
5767 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5768 published: display_date(entry.published.as_deref()),
5769 read,
5770 starred,
5771 link: SafeLink::entry(id, ""),
5772 cached: true,
5773 rkey: String::new(),
5774 }))
5775}
5776
5777/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
5778fn now_rfc3339() -> String {
5779 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5780}
5781
5782#[cfg(test)]
5783mod tests {
5784 use super::*;
5785
5786 #[test]
5787 fn qenc_encodes_reserved() {
5788 assert_eq!(qenc("a b"), "a%20b");
5789 assert_eq!(
5790 qenc("https://example.com/feed.xml"),
5791 "https%3A%2F%2Fexample.com%2Ffeed.xml"
5792 );
5793 assert_eq!(
5794 qenc("at://did:plc:x/c/r"),
5795 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5796 );
5797 // Unreserved chars pass through untouched.
5798 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5799 }
5800
5801 #[test]
5802 fn folder_uri_shape() {
5803 assert_eq!(
5804 folder_uri("did:plc:abc", "3kfolder"),
5805 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5806 );
5807 }
5808
5809 // -- public-feeds-only: private/paid feeds are refused --------------------
5810
5811 #[test]
5812 fn private_feeds_are_classified_private_across_providers() {
5813 // The add + OPML paths both gate on this classifier; assert it flags a
5814 // spread of paid providers (newsletters + private podcasts) and the
5815 // generic credential-in-URL shapes.
5816 for url in [
5817 "https://author.substack.com/feed/private/deadbeefcafe1234",
5818 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5819 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5820 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5821 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5822 "https://user:pass@example.com/feed",
5823 ] {
5824 assert!(
5825 feed::classify_feed_privacy(url).is_private(),
5826 "expected private: {url}"
5827 );
5828 }
5829 }
5830
5831 #[test]
5832 fn public_feeds_stay_public() {
5833 for url in [
5834 "https://author.substack.com/feed",
5835 "https://wordpress.example.com/feed/",
5836 "https://example.com/rss.xml",
5837 "https://example.org/atom.xml",
5838 // YouTube channel/playlist RSS is fully public — must not false-block.
5839 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5840 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5841 ] {
5842 assert!(
5843 !feed::classify_feed_privacy(url).is_private(),
5844 "expected public: {url}"
5845 );
5846 }
5847 }
5848
5849 #[test]
5850 fn private_feed_label_is_public_safe_host_only() {
5851 // The OPML skip report must never echo the secret path/query, only the host.
5852 let label =
5853 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5854 assert_eq!(label, "author.substack.com");
5855 assert!(!label.contains("deadbeefcafe1234token"));
5856 assert!(!label.contains("/private/"));
5857 // An unparseable URL degrades to a generic label.
5858 assert_eq!(private_feed_label("not a url"), "a private feed");
5859 }
5860
5861 #[test]
5862 fn refusal_message_promises_nothing_stored() {
5863 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5864 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5865 }
5866
5867 #[test]
5868 fn scope_query_preserves_context() {
5869 let q = EntryQuery {
5870 feed: Some("https://example.com/feed.xml".to_string()),
5871 folder: None,
5872 view: Some("all".to_string()),
5873 };
5874 let s = scope_query(&q);
5875 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5876 assert!(s.contains("view=all"));
5877
5878 // Default view is omitted.
5879 let q2 = EntryQuery {
5880 feed: None,
5881 folder: None,
5882 view: Some("unread".to_string()),
5883 };
5884 assert_eq!(scope_query(&q2), "");
5885 }
5886
5887 // -- closed-beta invite gate + rate-limit + cache-control ------------------
5888
5889 use axum::body::Body;
5890 use axum::http::Request;
5891 use tower::ServiceExt; // for `oneshot`
5892
5893 /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
5894 /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
5895 /// can forge matching cookies.
5896 async fn test_state(allowed: &[&str]) -> AppState {
5897 let db = store::init_url("sqlite::memory:").await.unwrap();
5898 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5899 store::ensure_seed(&db, &dids).await.unwrap();
5900 let config = Config {
5901 allowed_dids: dids,
5902 cookie_secret: "test-cookie-secret-000".to_string(),
5903 beta_cap: 3,
5904 ..Config::default()
5905 };
5906 AppState::new(config, db).unwrap()
5907 }
5908
5909 /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
5910 /// looked up in the registry, so create the session first).
5911 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
5912 let sid = state.sessions.create(Session {
5913 did: did.to_string(),
5914 handle: handle.map(str::to_string),
5915 });
5916 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
5917 sc.split(';').next().unwrap().to_string()
5918 }
5919
5920 /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
5921 /// long time to accept distinct source IPs on two unauthenticated guarded
5922 /// routes.
5923 #[test]
5924 fn the_rate_limit_map_is_bounded() {
5925 let rl = RateLimiter::shared();
5926 let now = Instant::now();
5927 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5928 // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
5929 // ordering below is well-defined.
5930 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
5931 rl.check_at(ip, now + Duration::from_millis(i as u64));
5932 }
5933 let len = rl.inner.lock().unwrap().buckets.len();
5934 assert!(
5935 len <= MAX_RATE_BUCKETS,
5936 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
5937 );
5938 }
5939
5940 /// Eviction must not hand a throttled attacker a fresh burst.
5941 ///
5942 /// The bound is LRU, so the one bucket an attacker can never evict is their
5943 /// own — it is the most recently touched thing in the map. If this inverted,
5944 /// the size cap would become a rate-limit bypass: spray addresses until the
5945 /// map overflows, then resume.
5946 #[test]
5947 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
5948 let rl = RateLimiter::shared();
5949 let base = Instant::now();
5950 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
5951 // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
5952 // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
5953 // millisecond step made the whole flood take a second, and the refill —
5954 // working correctly — then looked exactly like an eviction bypass.
5955 let at = |n: u64| base + Duration::from_nanos(n);
5956
5957 // Spend the burst. `RATE_BURST` allowed, then refused.
5958 for i in 0..(RATE_BURST as u64) {
5959 assert!(rl.check_at(attacker, at(i)));
5960 }
5961 assert!(
5962 !rl.check_at(attacker, at(RATE_BURST as u64)),
5963 "burst was not exhausted; the rest of this test proves nothing"
5964 );
5965
5966 // Now overflow the map from other addresses, interleaving the attacker
5967 // so their bucket stays hot — the realistic shape of the attack.
5968 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5969 let t = at(100 + i as u64 * 2);
5970 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
5971 rl.check_at(ip, t);
5972 assert!(
5973 !rl.check_at(attacker, t),
5974 "the attacker got a token back after evictions at i={i}"
5975 );
5976 }
5977 }
5978
5979 /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
5980 /// of the whole map on every guarded request, on one shared core.
5981 #[test]
5982 fn the_idle_sweep_does_not_run_on_every_request() {
5983 let rl = RateLimiter::shared();
5984 let start = Instant::now();
5985 let a: IpAddr = "198.51.100.1".parse().unwrap();
5986 let b: IpAddr = "198.51.100.2".parse().unwrap();
5987
5988 rl.check_at(a, start);
5989 // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
5990 // the sweep interval has elapsed too, so this request does sweep it.
5991 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
5992 assert!(
5993 !rl.inner.lock().unwrap().buckets.contains_key(&a),
5994 "an idle bucket survived a sweep that was due"
5995 );
5996
5997 // A second request moments later must NOT re-sweep — `b` is still there,
5998 // and the recorded sweep time must not have moved.
5999 let before = rl.inner.lock().unwrap().last_sweep;
6000 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6001 assert_eq!(
6002 rl.inner.lock().unwrap().last_sweep,
6003 before,
6004 "the sweep ran again within the interval"
6005 );
6006 }
6007
6008 #[test]
6009 fn rate_limited_paths_match_expected() {
6010 use axum::http::Method;
6011 assert!(is_rate_limited_path("/login", &Method::GET));
6012 assert!(is_rate_limited_path("/login", &Method::POST));
6013 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6014 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6015 assert!(is_rate_limited_path("/opml", &Method::POST));
6016 assert!(is_rate_limited_path("/read-all", &Method::POST));
6017 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6018 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6019 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6020 // Read-only navigation is NOT limited.
6021 assert!(!is_rate_limited_path("/", &Method::GET));
6022 assert!(!is_rate_limited_path("/about", &Method::GET));
6023 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6024 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6025 }
6026
6027 #[test]
6028 fn rate_limiter_allows_burst_then_429s() {
6029 let rl = RateLimiter::shared();
6030 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6031 // The full burst passes.
6032 for _ in 0..(RATE_BURST as usize) {
6033 assert!(rl.check(ip));
6034 }
6035 // The next one (no time elapsed → no refill) is rejected.
6036 assert!(!rl.check(ip));
6037 // A different IP has its own bucket.
6038 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6039 assert!(rl.check(ip2));
6040 }
6041
6042 #[test]
6043 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6044 // With NO trusted header configured, a client-supplied X-Forwarded-For
6045 // must be ignored entirely — the limiter keys on the real socket peer,
6046 // so an attacker can't mint a fresh bucket per forged XFF value.
6047 let mut h = HeaderMap::new();
6048 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6049 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6050 assert_eq!(
6051 client_ip(&h, Some(&sock), None),
6052 Some("203.0.113.55".parse().unwrap()),
6053 "spoofed XFF must not override the socket peer"
6054 );
6055 }
6056
6057 #[test]
6058 fn client_ip_uses_trusted_header_last_hop() {
6059 // With a trusted proxy header configured, the client IP comes from THAT
6060 // header (the proxy overwrites any client copy). On a comma list we take
6061 // the RIGHT-most hop — the one the trusted proxy appended — so a
6062 // client-forged left-most value is ignored.
6063 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6064
6065 let mut h = HeaderMap::new();
6066 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6067 assert_eq!(
6068 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6069 Some("198.51.100.9".parse().unwrap())
6070 );
6071
6072 // Attacker prepends a forged hop; the trusted proxy appends the real one.
6073 let mut h2 = HeaderMap::new();
6074 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6075 assert_eq!(
6076 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6077 Some("198.51.100.9".parse().unwrap()),
6078 "must take the right-most (trusted) hop, not the forged left-most"
6079 );
6080
6081 // Trusted header absent → fall back to the socket peer.
6082 let h3 = HeaderMap::new();
6083 assert_eq!(
6084 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6085 Some("10.0.0.1".parse().unwrap())
6086 );
6087 }
6088
6089 #[test]
6090 fn invite_cookie_round_trips_and_rejects_tamper() {
6091 let secret = "test-cookie-secret-000";
6092 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6093 let pair = sc.split(';').next().unwrap();
6094 let mut h = HeaderMap::new();
6095 h.insert(header::COOKIE, pair.parse().unwrap());
6096 assert_eq!(
6097 invite_cookie_code(&h, secret).as_deref(),
6098 Some("FEATHER-ABCDWXYZ")
6099 );
6100 // Wrong secret → rejected.
6101 assert!(invite_cookie_code(&h, "other").is_none());
6102 }
6103
6104 #[tokio::test]
6105 async fn preflight_valid_expired_and_full() {
6106 let state = test_state(&["did:plc:admin"]).await;
6107 // A minted, active code preflights OK.
6108 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6109 .await
6110 .unwrap();
6111 assert!(preflight_code(&state, &code).await.is_ok());
6112
6113 // A code whose expiry is in the past preflights as Expired. (mint_code
6114 // clamps negative ttl to 0, so back-date the row directly for a
6115 // deterministic past expiry.)
6116 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6117 .await
6118 .unwrap();
6119 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6120 .bind(chrono::Utc::now().timestamp() - 3600)
6121 .bind(&expired)
6122 .execute(&state.db)
6123 .await
6124 .unwrap();
6125 assert_eq!(
6126 preflight_code(&state, &expired).await,
6127 Err(store::RedeemError::Expired)
6128 );
6129
6130 // Unknown code → NotFound.
6131 assert_eq!(
6132 preflight_code(&state, "FEATHER-NOPENOPE").await,
6133 Err(store::RedeemError::NotFound)
6134 );
6135
6136 // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6137 // must report CapacityFull.
6138 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6139 .await
6140 .unwrap();
6141 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6142 .await
6143 .unwrap();
6144 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6145 assert_eq!(
6146 preflight_code(&state, &code).await,
6147 Err(store::RedeemError::CapacityFull)
6148 );
6149 }
6150
6151 // -- Bot claim link + shared-secret mint ---------------------------------
6152
6153 /// A test state with a configured bot secret (so `/bot/claims` is live).
6154 async fn bot_state(bot_secret: &str) -> AppState {
6155 let db = store::init_url("sqlite::memory:").await.unwrap();
6156 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6157 .await
6158 .unwrap();
6159 let config = Config {
6160 allowed_dids: vec!["did:plc:admin".to_string()],
6161 cookie_secret: "test-cookie-secret-000".to_string(),
6162 beta_cap: 3,
6163 bot_secret: Some(bot_secret.to_string()),
6164 public_url: "https://feather-reader.com".to_string(),
6165 ..Config::default()
6166 };
6167 AppState::new(config, db).unwrap()
6168 }
6169
6170 #[test]
6171 fn claim_token_round_trips_and_rejects_tamper() {
6172 let secret = "test-cookie-secret-000";
6173 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6174 // No cookie framing — a bare URL-safe token.
6175 assert!(!token.contains(';'));
6176 assert_eq!(
6177 claim_token_code(&token, secret).as_deref(),
6178 Some("FEATHER-ABCDWXYZ")
6179 );
6180 // Wrong secret → rejected.
6181 assert!(claim_token_code(&token, "other").is_none());
6182 // Tampered token → rejected.
6183 let mut bad = token.clone();
6184 bad.push('x');
6185 assert!(claim_token_code(&bad, secret).is_none());
6186 // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6187 // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6188 // recover it WITHOUT the secret). The security is single-use + HMAC
6189 // integrity + rate-limit, not secrecy of the code. Assert the code half is
6190 // publicly decodable (a plain base64url decode, no secret involved).
6191 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6192 assert_eq!(
6193 test_b64url_decode(b64).as_deref(),
6194 Some("FEATHER-ABCDWXYZ".as_bytes()),
6195 "the code half of the token is plain base64url, decodable by anyone"
6196 );
6197 }
6198
6199 /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6200 /// claim token's code half needs NO secret to recover (it is not confidential).
6201 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6202 fn val(c: u8) -> Option<u32> {
6203 match c {
6204 b'A'..=b'Z' => Some((c - b'A') as u32),
6205 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6206 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6207 b'-' => Some(62),
6208 b'_' => Some(63),
6209 _ => None,
6210 }
6211 }
6212 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6213 for chunk in input.as_bytes().chunks(4) {
6214 let mut n = 0u32;
6215 let mut bits = 0;
6216 for &c in chunk {
6217 n = (n << 6) | val(c)?;
6218 bits += 6;
6219 }
6220 let bytes = bits / 8;
6221 n <<= 24 - bits;
6222 for i in 0..bytes {
6223 out.push((n >> (16 - i * 8)) as u8);
6224 }
6225 }
6226 Some(out)
6227 }
6228
6229 #[tokio::test]
6230 async fn bot_mint_then_claim_grants_a_seat() {
6231 let state = bot_state("bot-secret-abcdef").await;
6232 let app = router(state.clone());
6233
6234 // 1. Mint a claim via the shared-secret endpoint.
6235 let resp = app
6236 .clone()
6237 .oneshot(
6238 Request::builder()
6239 .method("POST")
6240 .uri("/bot/claims")
6241 .header("x-bot-secret", "bot-secret-abcdef")
6242 .body(Body::empty())
6243 .unwrap(),
6244 )
6245 .await
6246 .unwrap();
6247 assert_eq!(resp.status(), StatusCode::OK);
6248 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6249 .await
6250 .unwrap();
6251 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6252 let token = json["token"].as_str().unwrap().to_string();
6253 let url = json["url"].as_str().unwrap();
6254 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6255 // The raw code is returned for the bot's records but not embedded in url.
6256 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6257 assert!(!url.contains("FEATHER-"));
6258
6259 // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6260 let resp = app
6261 .clone()
6262 .oneshot(
6263 Request::builder()
6264 .method("GET")
6265 .uri(format!("/claim?t={}", qenc(&token)))
6266 .body(Body::empty())
6267 .unwrap(),
6268 )
6269 .await
6270 .unwrap();
6271 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6272 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6273 let set_cookie = resp
6274 .headers()
6275 .get(header::SET_COOKIE)
6276 .unwrap()
6277 .to_str()
6278 .unwrap();
6279 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6280
6281 // 3. The reserved cookie carries the same code the token wrapped, and
6282 // redeeming it (the callback's machinery) grants a seat.
6283 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6284 let out = store::redeem_code(
6285 &state.db,
6286 &code,
6287 "did:plc:follower",
6288 None,
6289 state.config.beta_cap,
6290 )
6291 .await
6292 .unwrap();
6293 assert_eq!(out, Ok(()));
6294 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6295 .await
6296 .unwrap());
6297 }
6298
6299 #[tokio::test]
6300 async fn claim_with_invalid_token_bounces() {
6301 let state = bot_state("bot-secret-abcdef").await;
6302 let app = router(state);
6303 let resp = app
6304 .oneshot(
6305 Request::builder()
6306 .method("GET")
6307 .uri("/claim?t=not-a-real-token")
6308 .body(Body::empty())
6309 .unwrap(),
6310 )
6311 .await
6312 .unwrap();
6313 // Renders the invite page (200), NOT a redirect to /login.
6314 assert_eq!(resp.status(), StatusCode::OK);
6315 }
6316
6317 #[tokio::test]
6318 async fn claim_with_used_token_is_refused() {
6319 let state = bot_state("bot-secret-abcdef").await;
6320 // Mint a code + wrap it, then redeem it out from under the token.
6321 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6322 .await
6323 .unwrap();
6324 let token = sign_claim_token(&code, &state.config.cookie_secret);
6325 store::redeem_code(
6326 &state.db,
6327 &code,
6328 "did:plc:someone",
6329 None,
6330 state.config.beta_cap,
6331 )
6332 .await
6333 .unwrap()
6334 .unwrap();
6335 let app = router(state);
6336 let resp = app
6337 .oneshot(
6338 Request::builder()
6339 .method("GET")
6340 .uri(format!("/claim?t={}", qenc(&token)))
6341 .body(Body::empty())
6342 .unwrap(),
6343 )
6344 .await
6345 .unwrap();
6346 // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6347 assert_eq!(resp.status(), StatusCode::OK);
6348 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6349 }
6350
6351 #[tokio::test]
6352 async fn bot_claims_rejects_bad_and_missing_secret() {
6353 let state = bot_state("bot-secret-abcdef").await;
6354 let app = router(state);
6355 // Wrong secret.
6356 let resp = app
6357 .clone()
6358 .oneshot(
6359 Request::builder()
6360 .method("POST")
6361 .uri("/bot/claims")
6362 .header("x-bot-secret", "wrong")
6363 .body(Body::empty())
6364 .unwrap(),
6365 )
6366 .await
6367 .unwrap();
6368 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6369 // Missing secret.
6370 let resp = app
6371 .oneshot(
6372 Request::builder()
6373 .method("POST")
6374 .uri("/bot/claims")
6375 .body(Body::empty())
6376 .unwrap(),
6377 )
6378 .await
6379 .unwrap();
6380 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6381 }
6382
6383 #[tokio::test]
6384 async fn bot_claims_disabled_when_secret_unset() {
6385 // test_state configures NO bot secret → the endpoint is off (503).
6386 let state = test_state(&["did:plc:admin"]).await;
6387 let app = router(state);
6388 let resp = app
6389 .oneshot(
6390 Request::builder()
6391 .method("POST")
6392 .uri("/bot/claims")
6393 .header("x-bot-secret", "anything")
6394 .body(Body::empty())
6395 .unwrap(),
6396 )
6397 .await
6398 .unwrap();
6399 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6400 }
6401
6402 #[tokio::test]
6403 async fn bot_claims_refuses_at_capacity() {
6404 let state = bot_state("bot-secret-abcdef").await;
6405 // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6406 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6407 .await
6408 .unwrap();
6409 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6410 .await
6411 .unwrap();
6412 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6413 let app = router(state);
6414 let resp = app
6415 .oneshot(
6416 Request::builder()
6417 .method("POST")
6418 .uri("/bot/claims")
6419 .header("x-bot-secret", "bot-secret-abcdef")
6420 .body(Body::empty())
6421 .unwrap(),
6422 )
6423 .await
6424 .unwrap();
6425 assert_eq!(resp.status(), StatusCode::CONFLICT);
6426 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6427 .await
6428 .unwrap();
6429 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6430 }
6431
6432 #[tokio::test]
6433 async fn bot_claims_counts_outstanding_codes_against_cap() {
6434 let state = bot_state("bot-secret-abcdef").await;
6435 // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6436 store::mint_code(&state.db, "did:plc:admin", 3600)
6437 .await
6438 .unwrap();
6439 store::mint_code(&state.db, "did:plc:admin", 3600)
6440 .await
6441 .unwrap();
6442 let app = router(state);
6443 let resp = app
6444 .oneshot(
6445 Request::builder()
6446 .method("POST")
6447 .uri("/bot/claims")
6448 .header("x-bot-secret", "bot-secret-abcdef")
6449 .body(Body::empty())
6450 .unwrap(),
6451 )
6452 .await
6453 .unwrap();
6454 // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6455 assert_eq!(resp.status(), StatusCode::CONFLICT);
6456 }
6457
6458 /// POST /bot/claims with a JSON body carrying the follower DID.
6459 async fn post_bot_claim_for(
6460 app: &axum::Router,
6461 secret: &str,
6462 did: &str,
6463 ) -> (StatusCode, serde_json::Value) {
6464 let resp = app
6465 .clone()
6466 .oneshot(
6467 Request::builder()
6468 .method("POST")
6469 .uri("/bot/claims")
6470 .header("x-bot-secret", secret)
6471 .header("content-type", "application/json")
6472 .body(Body::from(format!(
6473 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6474 )))
6475 .unwrap(),
6476 )
6477 .await
6478 .unwrap();
6479 let status = resp.status();
6480 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6481 .await
6482 .unwrap();
6483 let json = if bytes.is_empty() {
6484 serde_json::Value::Null
6485 } else {
6486 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6487 };
6488 (status, json)
6489 }
6490
6491 #[tokio::test]
6492 async fn bot_claims_returns_already_seated_for_a_member() {
6493 // A DID that already holds beta access must get `already_seated` with NO
6494 // code/url — the bot posts nothing. This is the server-side backstop that
6495 // survives a bot-host state loss (it would otherwise re-mint + re-post).
6496 let state = bot_state("bot-secret-abcdef").await;
6497 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6498 .await
6499 .unwrap();
6500 let app = router(state.clone());
6501 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6502 assert_eq!(status, StatusCode::OK);
6503 assert_eq!(json["status"], "already_seated");
6504 assert_eq!(json["code"], "");
6505 assert_eq!(json["url"], "");
6506 // No new invite code was minted for the seated DID.
6507 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6508 .await
6509 .unwrap()
6510 .is_none());
6511 }
6512
6513 #[tokio::test]
6514 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6515 // Two mint requests for the SAME follower DID must return the SAME code
6516 // (the app is authoritative), never a second one — so a bot-host state loss
6517 // re-requesting cannot double-mint or double-post.
6518 let state = bot_state("bot-secret-abcdef").await;
6519 let app = router(state.clone());
6520
6521 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6522 assert_eq!(s1, StatusCode::OK);
6523 assert_eq!(j1["status"], "minted");
6524 let code1 = j1["code"].as_str().unwrap().to_string();
6525
6526 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6527 assert_eq!(s2, StatusCode::OK);
6528 assert_eq!(j2["status"], "existing");
6529 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6530 assert_eq!(j2["url"], j1["url"], "same url returned");
6531
6532 // Exactly ONE active code exists for that DID.
6533 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6534 }
6535
6536 #[tokio::test]
6537 async fn bot_claims_records_intended_did_at_mint() {
6538 // A fresh mint records the follower DID so the lookup finds it.
6539 let state = bot_state("bot-secret-abcdef").await;
6540 let app = router(state.clone());
6541 let (status, json) =
6542 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6543 assert_eq!(status, StatusCode::OK);
6544 let code = json["code"].as_str().unwrap();
6545 assert_eq!(
6546 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6547 .await
6548 .unwrap()
6549 .as_deref(),
6550 Some(code)
6551 );
6552 }
6553
6554 #[tokio::test]
6555 async fn bot_claims_concurrent_same_did_never_double_mints() {
6556 // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6557 // active code. The dedupe check (3b) and the mint are separate statements,
6558 // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6559 // then makes the loser's INSERT conflict, and the handler recovers by
6560 // returning the winner's code (status `existing`) rather than 500-ing.
6561 // Result: exactly ONE active code, and BOTH callers get a usable code.
6562 let state = bot_state("bot-secret-abcdef").await;
6563 let app = router(state.clone());
6564
6565 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6566 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6567 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6568
6569 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6570 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6571
6572 // Exactly one active code for the DID — the whole point of the fix.
6573 assert_eq!(
6574 store::count_active_codes(&state.db).await.unwrap(),
6575 1,
6576 "concurrent mints must not create two active codes"
6577 );
6578
6579 // Both callers received the SAME (single) code, and neither got a 500.
6580 let ca = ja["code"].as_str().unwrap_or("");
6581 let cb = jb["code"].as_str().unwrap_or("");
6582 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6583 assert_eq!(ca, cb, "both callers must get the one minted code");
6584 // One is `minted` (the winner), the other `minted` or `existing` depending
6585 // on interleaving — but never an error status.
6586 for st in [&ja["status"], &jb["status"]] {
6587 let s = st.as_str().unwrap_or("");
6588 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6589 }
6590 }
6591
6592 #[tokio::test]
6593 async fn bot_claims_rejects_malformed_json_body() {
6594 let state = bot_state("bot-secret-abcdef").await;
6595 let app = router(state);
6596 let resp = app
6597 .oneshot(
6598 Request::builder()
6599 .method("POST")
6600 .uri("/bot/claims")
6601 .header("x-bot-secret", "bot-secret-abcdef")
6602 .header("content-type", "application/json")
6603 .body(Body::from("{not json"))
6604 .unwrap(),
6605 )
6606 .await
6607 .unwrap();
6608 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6609 }
6610
6611 #[tokio::test]
6612 async fn favicon_ico_served_at_root() {
6613 // Browsers request bare /favicon.ico regardless of the <link rel="icon">
6614 // tags in <head>; the root route must serve the icon, not 404.
6615 let state = test_state(&[]).await;
6616 let app = router(state);
6617 let resp = app
6618 .oneshot(
6619 Request::builder()
6620 .uri("/favicon.ico")
6621 .body(Body::empty())
6622 .unwrap(),
6623 )
6624 .await
6625 .unwrap();
6626 assert_eq!(resp.status(), StatusCode::OK);
6627 let ct = resp
6628 .headers()
6629 .get(header::CONTENT_TYPE)
6630 .unwrap()
6631 .to_str()
6632 .unwrap();
6633 assert!(
6634 ct.contains("icon") || ct.starts_with("image/"),
6635 "content-type = {ct}"
6636 );
6637 }
6638
6639 #[tokio::test]
6640 async fn login_without_invite_redirects_to_beta_redeem() {
6641 // No allow-list seed, no invite cookie: starting OAuth must be refused.
6642 let state = test_state(&[]).await;
6643 let app = router(state);
6644 let resp = app
6645 .oneshot(
6646 Request::builder()
6647 .method("POST")
6648 .uri("/login")
6649 .header("content-type", "application/x-www-form-urlencoded")
6650 .body(Body::from("handle=alice.bsky.social"))
6651 .unwrap(),
6652 )
6653 .await
6654 .unwrap();
6655 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6656 assert_eq!(
6657 resp.headers().get(header::LOCATION).unwrap(),
6658 "/beta/redeem"
6659 );
6660 }
6661
6662 #[tokio::test]
6663 async fn login_with_valid_invite_cookie_starts_oauth() {
6664 let state = test_state(&[]).await;
6665 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6666 let cookie = cookie.split(';').next().unwrap().to_string();
6667 let app = router(state);
6668 let resp = app
6669 .oneshot(
6670 Request::builder()
6671 .method("POST")
6672 .uri("/login")
6673 .header("content-type", "application/x-www-form-urlencoded")
6674 .header(header::COOKIE, cookie)
6675 .body(Body::from("handle=alice.bsky.social"))
6676 .unwrap(),
6677 )
6678 .await
6679 .unwrap();
6680 // Redirects into the sidecar login (not to /beta/redeem).
6681 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6682 let loc = resp
6683 .headers()
6684 .get(header::LOCATION)
6685 .unwrap()
6686 .to_str()
6687 .unwrap();
6688 assert!(loc.contains("/login"), "loc = {loc}");
6689 assert_ne!(loc, "/beta/redeem");
6690 }
6691
6692 /// A resolver that always fails — proves the fast paths short-circuit BEFORE
6693 /// any network resolution and that a resolution failure fails closed.
6694 async fn resolver_never(_handle: String) -> Option<String> {
6695 None
6696 }
6697
6698 /// A resolver that maps every handle to `did`.
6699 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6700 move |_handle| std::future::ready(Some(did.to_string()))
6701 }
6702
6703 /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
6704 /// that already holds a seat (the seeded-admin first-login case) passes the
6705 /// gate — no session cookie, no invite code.
6706 #[tokio::test]
6707 async fn may_start_oauth_honors_seat_via_resolved_handle() {
6708 // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
6709 // no cookie on a fresh deploy.
6710 let state = test_state(&["did:plc:admin"]).await;
6711 let headers = HeaderMap::new();
6712 assert!(
6713 may_start_oauth_with(
6714 &state,
6715 &headers,
6716 "admin.example",
6717 resolver_to("did:plc:admin")
6718 )
6719 .await,
6720 "a handle resolving to a seated DID must pass the gate"
6721 );
6722 }
6723
6724 /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
6725 /// — the anti-abuse intent is preserved (resolution succeeds, seat check
6726 /// fails).
6727 #[tokio::test]
6728 async fn may_start_oauth_bounces_non_member_handle() {
6729 let state = test_state(&["did:plc:admin"]).await;
6730 let headers = HeaderMap::new();
6731 assert!(
6732 !may_start_oauth_with(
6733 &state,
6734 &headers,
6735 "rando.example",
6736 resolver_to("did:plc:rando")
6737 )
6738 .await,
6739 "a resolved DID with no seat must be bounced"
6740 );
6741 }
6742
6743 /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
6744 /// bounces gracefully — no panic, no handshake.
6745 #[tokio::test]
6746 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6747 let state = test_state(&["did:plc:admin"]).await;
6748 let headers = HeaderMap::new();
6749 assert!(
6750 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6751 "an unresolvable handle must fail closed"
6752 );
6753 }
6754
6755 /// The session-cookie fast path admits a seated member WITHOUT calling the
6756 /// resolver (proven by injecting `resolver_never`, which would otherwise
6757 /// bounce).
6758 #[tokio::test]
6759 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6760 let state = test_state(&[]).await;
6761 let did = "did:plc:member";
6762 store::grant_access(&state.db, did, Some("member.example"), "test", None)
6763 .await
6764 .unwrap();
6765 let cookie = session_cookie(&state, did, Some("member.example"));
6766 let mut headers = HeaderMap::new();
6767 headers.insert(header::COOKIE, cookie.parse().unwrap());
6768 assert!(
6769 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6770 "a seated session cookie must pass without resolution"
6771 );
6772 }
6773
6774 /// The invite-cookie fast path admits WITHOUT calling the resolver.
6775 #[tokio::test]
6776 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6777 let state = test_state(&[]).await;
6778 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6779 let cookie = cookie.split(';').next().unwrap().to_string();
6780 let mut headers = HeaderMap::new();
6781 headers.insert(header::COOKIE, cookie.parse().unwrap());
6782 assert!(
6783 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6784 "a valid invite cookie must pass without resolution"
6785 );
6786 }
6787
6788 #[tokio::test]
6789 async fn admin_mint_requires_admin_seed_did() {
6790 let state = test_state(&["did:plc:admin"]).await;
6791 // A non-admin (but beta'd) session is forbidden.
6792 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6793 .await
6794 .unwrap();
6795 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6796 // An admin session is allowed.
6797 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6798 let app = router(state);
6799
6800 let forbidden = app
6801 .clone()
6802 .oneshot(
6803 Request::builder()
6804 .method("POST")
6805 .uri("/admin/invites?n=2")
6806 .header(header::COOKIE, rando_cookie)
6807 .body(Body::empty())
6808 .unwrap(),
6809 )
6810 .await
6811 .unwrap();
6812 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6813
6814 let ok = app
6815 .oneshot(
6816 Request::builder()
6817 .method("POST")
6818 .uri("/admin/invites?n=2")
6819 .header(header::COOKIE, admin_cookie)
6820 .body(Body::empty())
6821 .unwrap(),
6822 )
6823 .await
6824 .unwrap();
6825 assert_eq!(ok.status(), StatusCode::OK);
6826 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6827 .await
6828 .unwrap();
6829 let body = String::from_utf8(bytes.to_vec()).unwrap();
6830 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6831 assert_eq!(minted.len(), 2);
6832 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6833 }
6834
6835 #[tokio::test]
6836 async fn admin_mint_unauthenticated_is_401() {
6837 let state = test_state(&["did:plc:admin"]).await;
6838 let app = router(state);
6839 let resp = app
6840 .oneshot(
6841 Request::builder()
6842 .method("POST")
6843 .uri("/admin/invites")
6844 .body(Body::empty())
6845 .unwrap(),
6846 )
6847 .await
6848 .unwrap();
6849 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6850 }
6851
6852 /// A state whose `/about` renders the adoption line, seeded with one
6853 /// observation.
6854 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6855 let db = store::init_url("sqlite::memory:").await.unwrap();
6856 store::record_network_stat(
6857 &db,
6858 &store::NetworkStat {
6859 key: store::ADOPTION_STAT_KEY.to_string(),
6860 source: "https://relay1.us-west.bsky.network".to_string(),
6861 value: repos,
6862 truncated,
6863 observed_at: "2026-08-13T04:05:06Z".to_string(),
6864 },
6865 )
6866 .await
6867 .unwrap();
6868 let config = Config {
6869 cookie_secret: "test-cookie-secret-000".to_string(),
6870 show_adoption: true,
6871 ..Config::default()
6872 };
6873 AppState::new(config, db).unwrap()
6874 }
6875
6876 async fn about_body(state: AppState) -> String {
6877 let resp = router(state)
6878 .oneshot(
6879 Request::builder()
6880 .uri("/about")
6881 .body(Body::empty())
6882 .unwrap(),
6883 )
6884 .await
6885 .unwrap();
6886 assert_eq!(resp.status(), StatusCode::OK);
6887 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
6888 .await
6889 .unwrap();
6890 String::from_utf8(bytes.to_vec()).unwrap()
6891 }
6892
6893 /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
6894 #[tokio::test]
6895 async fn about_omits_adoption_line_by_default() {
6896 let state = test_state(&[]).await;
6897 assert!(!state.config.show_adoption);
6898 let body = about_body(state).await;
6899 assert!(
6900 !body.contains("atproto network"),
6901 "the adoption line must not render by default"
6902 );
6903 }
6904
6905 #[tokio::test]
6906 async fn about_renders_adoption_line_when_enabled() {
6907 // **A distinctive count, and asserted IN ITS SENTENCE.**
6908 //
6909 // This used to seed 4 and assert `body.contains("4")`, which the
6910 // colophon's `width="44"` satisfies whatever the count is — so
6911 // hardcoding the rendered number passed. Both halves are needed: a
6912 // digit that does not occur incidentally, and the assertion tied to the
6913 // phrase it belongs to.
6914 let body = about_body(adoption_state(7_318, false).await).await;
6915 // The count and its phrase are on separate template lines, so compare
6916 // against a whitespace-collapsed copy rather than the raw HTML.
6917 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
6918 assert!(
6919 flat.contains("7318 accounts on the atproto network hold"),
6920 "the count did not render in its own sentence: {flat}",
6921 );
6922 assert!(
6923 body.contains("accounts on the atproto network hold"),
6924 "{body}"
6925 );
6926 assert!(
6927 body.contains("2026-08-13"),
6928 "the observation date must render"
6929 );
6930 assert!(
6931 body.contains("lower bound"),
6932 "the non-archival caveat must ride along with the number"
6933 );
6934 assert!(
6935 !body.contains("At least"),
6936 "an untruncated count is exact-ish"
6937 );
6938 }
6939
6940 /// A count of one must read as "1 account … holds", not "1 accounts … hold".
6941 #[tokio::test]
6942 async fn about_adoption_line_is_singular_at_one() {
6943 let body = about_body(adoption_state(1, false).await).await;
6944 assert!(
6945 body.contains("account on the atproto network holds"),
6946 "{body}"
6947 );
6948 }
6949
6950 /// A truncated observation is a floor, and must say so.
6951 #[tokio::test]
6952 async fn about_adoption_line_says_at_least_when_truncated() {
6953 let body = about_body(adoption_state(25_000, true).await).await;
6954 assert!(body.contains("At least"), "{body}");
6955 }
6956
6957 /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
6958 #[tokio::test]
6959 async fn about_omits_line_when_enabled_with_no_observation() {
6960 let db = store::init_url("sqlite::memory:").await.unwrap();
6961 let config = Config {
6962 cookie_secret: "test-cookie-secret-000".to_string(),
6963 show_adoption: true,
6964 ..Config::default()
6965 };
6966 let body = about_body(AppState::new(config, db).unwrap()).await;
6967 assert!(!body.contains("atproto network"));
6968 }
6969
6970 #[tokio::test]
6971 async fn cache_control_public_on_about_no_store_on_authed() {
6972 let state = test_state(&["did:plc:admin"]).await;
6973 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6974 let app = router(state);
6975
6976 // /about → public, cacheable.
6977 let about = app
6978 .clone()
6979 .oneshot(
6980 Request::builder()
6981 .uri("/about")
6982 .body(Body::empty())
6983 .unwrap(),
6984 )
6985 .await
6986 .unwrap();
6987 assert_eq!(
6988 about.headers().get(header::CACHE_CONTROL).unwrap(),
6989 "public, max-age=300"
6990 );
6991 // The security headers are still intact.
6992 // The VALUE, spelled out here rather than compared to the constant —
6993 // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
6994 // used to assert only that the header existed, which a policy of
6995 // `default-src *` satisfies.
6996 assert_eq!(
6997 about.headers()["content-security-policy"],
6998 EXPECTED_CSP,
6999 "the CSP is not the policy the router promises"
7000 );
7001 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7002
7003 // /privacy and /terms are static public pages → public, cacheable.
7004 for path in ["/privacy", "/terms"] {
7005 let resp = app
7006 .clone()
7007 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7008 .await
7009 .unwrap();
7010 assert_eq!(resp.status(), StatusCode::OK);
7011 assert_eq!(
7012 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7013 "public, max-age=300",
7014 "{path} should be publicly cacheable"
7015 );
7016 // Security headers apply to these pages too.
7017 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7018 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7019 }
7020
7021 // The bare /login landing → public, cacheable.
7022 let login = app
7023 .clone()
7024 .oneshot(
7025 Request::builder()
7026 .uri("/login")
7027 .body(Body::empty())
7028 .unwrap(),
7029 )
7030 .await
7031 .unwrap();
7032 assert_eq!(
7033 login.headers().get(header::CACHE_CONTROL).unwrap(),
7034 "public, max-age=300"
7035 );
7036
7037 // An authenticated page → no-store.
7038 let home = app
7039 .oneshot(
7040 Request::builder()
7041 .uri("/")
7042 .header(header::COOKIE, admin_cookie)
7043 .body(Body::empty())
7044 .unwrap(),
7045 )
7046 .await
7047 .unwrap();
7048 assert_eq!(
7049 home.headers().get(header::CACHE_CONTROL).unwrap(),
7050 "no-store"
7051 );
7052 }
7053
7054 #[tokio::test]
7055 async fn beta_redeem_page_renders() {
7056 let state = test_state(&[]).await;
7057 let app = router(state);
7058 let resp = app
7059 .oneshot(
7060 Request::builder()
7061 .uri("/beta/redeem")
7062 .body(Body::empty())
7063 .unwrap(),
7064 )
7065 .await
7066 .unwrap();
7067 assert_eq!(resp.status(), StatusCode::OK);
7068 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7069 .await
7070 .unwrap();
7071 let html = String::from_utf8(bytes.to_vec()).unwrap();
7072 assert!(html.contains("Invite code"));
7073 assert!(html.contains("/beta/redeem"));
7074 }
7075
7076 #[tokio::test]
7077 async fn rate_limit_returns_429_after_burst() {
7078 // Configure a trusted proxy header so the limiter keys on the forwarded
7079 // IP (the oneshot harness sets no ConnectInfo socket peer).
7080 let db = store::init_url("sqlite::memory:").await.unwrap();
7081 store::ensure_seed(&db, &[]).await.unwrap();
7082 let config = Config {
7083 cookie_secret: "test-cookie-secret-000".to_string(),
7084 beta_cap: 3,
7085 trusted_ip_header: Some("cf-connecting-ip".to_string()),
7086 ..Config::default()
7087 };
7088 let state = AppState::new(config, db).unwrap();
7089 let app = router(state);
7090 // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
7091 // handler itself returns 200 (re-render) on a bad code; the limiter is
7092 // what eventually yields 429.
7093 let mut saw_429 = false;
7094 for _ in 0..(RATE_BURST as usize + 5) {
7095 let resp = app
7096 .clone()
7097 .oneshot(
7098 Request::builder()
7099 .method("POST")
7100 .uri("/beta/redeem")
7101 .header("content-type", "application/x-www-form-urlencoded")
7102 .header("cf-connecting-ip", "203.0.113.200")
7103 .body(Body::from("code=FEATHER-NOPENOPE"))
7104 .unwrap(),
7105 )
7106 .await
7107 .unwrap();
7108 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7109 saw_429 = true;
7110 break;
7111 }
7112 }
7113 assert!(saw_429, "expected a 429 after exhausting the burst");
7114 }
7115
7116 /// **A forged `X-Forwarded-For` does not key the limiter** — the property
7117 /// the middleware's comment cites this test as proof of.
7118 ///
7119 /// The previous version rotated the forged header and asserted that no
7120 /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
7121 /// burst, so that assertion held whether the header was trusted or
7122 /// ignored — it passed in the vulnerable configuration too. And with no
7123 /// socket peer the limiter fails open, so nothing could have been keyed on
7124 /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
7125 /// a DIFFERENT forged header, and the last must be 429: they all landed in
7126 /// the peer's bucket. A limiter keying on the header mints a fresh bucket
7127 /// per request and never trips — which is exactly what the mutation does.
7128 #[tokio::test]
7129 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7130 let state = test_state(&[]).await;
7131 assert!(
7132 state.config.trusted_ip_header.is_none(),
7133 "no proxy header is trusted here"
7134 );
7135 let app = router(state);
7136 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7137 let mut saw_429 = false;
7138 for i in 0..(RATE_BURST as usize + 5) {
7139 let forged = format!("10.9.8.{}", i % 250);
7140 let resp = app
7141 .clone()
7142 .oneshot(
7143 Request::builder()
7144 .method("POST")
7145 .uri("/beta/redeem")
7146 .header("content-type", "application/x-www-form-urlencoded")
7147 .header("x-forwarded-for", forged)
7148 .extension(axum::extract::ConnectInfo(peer))
7149 .body(Body::from("code=FEATHER-NOPENOPE"))
7150 .unwrap(),
7151 )
7152 .await
7153 .unwrap();
7154 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7155 saw_429 = true;
7156 break;
7157 }
7158 }
7159 assert!(
7160 saw_429,
7161 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7162 );
7163 }
7164
7165 // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
7166
7167 /// **A private feed is refused BEFORE it is fetched.** The add path's
7168 /// privacy gate had no test at all — `private_feeds_are_classified_private_
7169 /// across_providers` says "the add + OPML paths both gate on this
7170 /// classifier" and nothing checked either. The gate exists so a
7171 /// token-bearing URL never reaches the network; the assertion that
7172 /// matters is the server's hit count: zero.
7173 #[tokio::test]
7174 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7175 let did = "did:plc:privateadder";
7176 let state = test_state_with_caps(did, 0, 0).await;
7177 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7178 let port: u16 = base
7179 .trim_end_matches('/')
7180 .rsplit(':')
7181 .next()
7182 .unwrap()
7183 .parse()
7184 .unwrap();
7185 crate::net::test_host_override(
7186 "private-add.test",
7187 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7188 );
7189 let cookie = session_cookie(&state, did, None);
7190 let resp = router(state.clone())
7191 .oneshot(
7192 Request::builder()
7193 .method("POST")
7194 .uri("/subscriptions")
7195 .header(header::COOKIE, cookie)
7196 .header("content-type", "application/x-www-form-urlencoded")
7197 .body(Body::from(format!(
7198 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7199 )))
7200 .unwrap(),
7201 )
7202 .await
7203 .unwrap();
7204 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7205 let loc = resp
7206 .headers()
7207 .get(header::LOCATION)
7208 .unwrap()
7209 .to_str()
7210 .unwrap();
7211 assert!(loc.contains("Private"), "not refused as private: {loc}");
7212 assert_eq!(
7213 hits.load(std::sync::atomic::Ordering::SeqCst),
7214 0,
7215 "the private feed was FETCHED before being refused"
7216 );
7217 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7218 }
7219
7220 /// **OPML import skips a private feed without storing or publishing it.**
7221 /// The import path does not fetch, so "never fetched" is not the signal
7222 /// here; "never stored, never written to the PDS" is. The batch write's
7223 /// bytes are captured and must not carry the URL.
7224 #[tokio::test]
7225 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7226 let did = "did:plc:renamer4";
7227 let (sidecar, bodies) = spawn_logging_sidecar().await;
7228 let state = test_state_with_sidecar(&[did], &sidecar).await;
7229 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7230 let opml = format!(
7231 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7232 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7233 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7234 </body></opml>"
7235 );
7236 let (ct, body) = opml_multipart(opml.as_bytes());
7237 let cookie = session_cookie(&state, did, None);
7238 let resp = router(state.clone())
7239 .oneshot(
7240 Request::builder()
7241 .method("POST")
7242 .uri("/opml")
7243 .header(header::COOKIE, cookie)
7244 .header("content-type", ct)
7245 .body(Body::from(body))
7246 .unwrap(),
7247 )
7248 .await
7249 .unwrap();
7250 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7251 let loc = resp
7252 .headers()
7253 .get(header::LOCATION)
7254 .unwrap()
7255 .to_str()
7256 .unwrap();
7257 assert!(
7258 loc.contains("skipped%20as%20private"),
7259 "not reported as skipped: {loc}"
7260 );
7261 assert!(store::get_feed_by_url(&state.db, tokened)
7262 .await
7263 .unwrap()
7264 .is_none());
7265 let sent = bodies.lock().unwrap().join("\n");
7266 assert!(
7267 sent.contains("public.example"),
7268 "the public feed was not written: {sent}"
7269 );
7270 assert!(
7271 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7272 "the secret was PUBLISHED to the PDS: {sent}"
7273 );
7274 }
7275
7276 /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
7277 /// tested; the GET form starts the same handshake and had no test, so
7278 /// deleting its gate left the suite green.
7279 #[tokio::test]
7280 async fn get_login_without_a_seat_is_refused() {
7281 let state = test_state(&[]).await;
7282 let resp = router(state)
7283 .oneshot(
7284 Request::builder()
7285 .method("GET")
7286 .uri("/login?handle=alice.bsky.social")
7287 .body(Body::empty())
7288 .unwrap(),
7289 )
7290 .await
7291 .unwrap();
7292 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7293 assert_eq!(
7294 resp.headers().get(header::LOCATION).unwrap(),
7295 "/beta/redeem"
7296 );
7297 }
7298
7299 /// A sidecar fake that answers every request `ok` and records the PATH of
7300 /// each in arrival order, plus every body — for asserting what was sent,
7301 /// and in what order.
7302 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7303 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7304 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7305 let addr = listener.local_addr().unwrap();
7306 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7307 let sink = log.clone();
7308 tokio::spawn(async move {
7309 loop {
7310 let Ok((mut sock, _)) = listener.accept().await else {
7311 break;
7312 };
7313 let mut raw: Vec<u8> = Vec::new();
7314 let mut chunk = [0u8; 4096];
7315 let text = loop {
7316 let Ok(n) = sock.read(&mut chunk).await else {
7317 break String::new();
7318 };
7319 if n == 0 {
7320 break String::from_utf8_lossy(&raw).to_string();
7321 }
7322 raw.extend_from_slice(&chunk[..n]);
7323 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7324 continue;
7325 };
7326 let (head, body) = raw.split_at(split + 4);
7327 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7328 let (k, v) = l.split_once(':')?;
7329 k.eq_ignore_ascii_case("content-length")
7330 .then(|| v.trim().parse::<usize>().ok())?
7331 });
7332 if want.is_none_or(|w| body.len() >= w) {
7333 break String::from_utf8_lossy(&raw).to_string();
7334 }
7335 };
7336 let path = text
7337 .lines()
7338 .next()
7339 .and_then(|l| l.split_whitespace().nth(1))
7340 .unwrap_or("")
7341 .to_string();
7342 let body_text = text
7343 .split_once("\r\n\r\n")
7344 .map(|(_, b)| b)
7345 .unwrap_or("")
7346 .to_string();
7347 sink.lock().unwrap().push(format!("{path} {body_text}"));
7348 let body = serde_json::json!({ "ok": true, "did": "did:plc:x", "revoked": true, "hadSession": true, "data": {"uri": "at://did:plc:x/c/r", "cid": "bafy"} }).to_string();
7349 let resp = format!(
7350 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7351 body.len(),
7352 body
7353 );
7354 let _ = sock.write_all(resp.as_bytes()).await;
7355 let _ = sock.flush().await;
7356 }
7357 });
7358 (format!("http://{addr}"), log)
7359 }
7360
7361 /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
7362 /// route.** The previous version of this test called
7363 /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
7364 /// flush attempt; its doc claimed deleting the call from the handler
7365 /// "drops that to zero", which was false — the handler was never run.
7366 /// Deleting the call left the suite green: #117 regressing in full, with
7367 /// the test named after it still passing. Now `POST /logout` is driven and
7368 /// the sidecar's log must show a repo write BEFORE the revoke.
7369 #[tokio::test]
7370 async fn signing_out_flushes_before_it_revokes_through_the_route() {
7371 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7372 let (sidecar, log) = spawn_logging_sidecar().await;
7373 let state = test_state_with_sidecar(&[did], &sidecar).await;
7374 crate::store::upsert_cursor(
7375 &state.db,
7376 &crate::store::ReadCursor {
7377 did: did.to_string(),
7378 feed_url: "https://example.com/feed.xml".into(),
7379 read_through: None,
7380 read_ids: "[\"1\"]".into(),
7381 unread_ids: "[]".into(),
7382 dirty: true,
7383 pds_created: false,
7384 updated_at: "2026-09-13T21:22:40Z".into(),
7385 },
7386 )
7387 .await
7388 .unwrap();
7389 let cookie = session_cookie(&state, did, None);
7390 let resp = router(state.clone())
7391 .oneshot(
7392 Request::builder()
7393 .method("POST")
7394 .uri("/logout")
7395 .header(header::COOKIE, cookie)
7396 .body(Body::empty())
7397 .unwrap(),
7398 )
7399 .await
7400 .unwrap();
7401 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7402
7403 let entries = log.lock().unwrap().clone();
7404 let flush = entries
7405 .iter()
7406 .position(|e| e.starts_with("/internal/repo "));
7407 let revoke = entries
7408 .iter()
7409 .position(|e| e.starts_with("/internal/revoke "));
7410 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7411 assert!(
7412 flush.is_some(),
7413 "sign-out did not attempt a flush before revoking: {entries:?}"
7414 );
7415 assert!(
7416 flush < revoke,
7417 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7418 );
7419 }
7420
7421 /// The policy, as a literal: the backstop the router calls "neutralises any
7422 /// XSS that slips past sanitization". `script-src 'self'` and no
7423 /// `'unsafe-inline'` on it are the two clauses that make it one.
7424 const EXPECTED_CSP: &str = "default-src 'self'; \
7425 script-src 'self'; \
7426 style-src 'self' 'unsafe-inline'; \
7427 img-src 'self' https: data:; \
7428 font-src 'self'; \
7429 connect-src 'self'; \
7430 form-action 'self'; \
7431 base-uri 'self'; \
7432 frame-ancestors 'none'; \
7433 object-src 'none'";
7434
7435 /// Build a `multipart/form-data` body carrying a single `file` field whose
7436 /// contents are `payload`, returning `(content_type, body_bytes)`.
7437 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7438 let boundary = "----featherreadertestboundary";
7439 let mut body = Vec::new();
7440 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7441 body.extend_from_slice(
7442 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7443 );
7444 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7445 body.extend_from_slice(payload);
7446 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7447 (format!("multipart/form-data; boundary={boundary}"), body)
7448 }
7449
7450 #[tokio::test]
7451 async fn opml_import_oversize_upload_returns_413() {
7452 let state = test_state(&["did:plc:admin"]).await;
7453 let cookie = session_cookie(&state, "did:plc:admin", None);
7454 let app = router(state);
7455
7456 // A payload comfortably above the route cap.
7457 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7458 let (content_type, body) = opml_multipart(&payload);
7459
7460 let resp = app
7461 .oneshot(
7462 Request::builder()
7463 .method("POST")
7464 .uri("/opml")
7465 .header("content-type", content_type)
7466 .header(header::COOKIE, cookie)
7467 .body(Body::from(body))
7468 .unwrap(),
7469 )
7470 .await
7471 .unwrap();
7472 assert_eq!(
7473 resp.status(),
7474 StatusCode::PAYLOAD_TOO_LARGE,
7475 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7476 );
7477 }
7478
7479 /// **The route's own cap is what refuses this, not the framework's.**
7480 ///
7481 /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
7482 /// the route's layer was a no-op — deleting it left every test green, and
7483 /// `opml_import_oversize_upload_returns_413` was really testing axum. The
7484 /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
7485 /// sits BETWEEN the two: over ours, under the framework's. Only the
7486 /// route's layer can refuse it — remove the layer and this payload is
7487 /// accepted, which is also what demonstrates the framework's default is
7488 /// the larger of the two.
7489 #[tokio::test]
7490 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7491 let state = test_state(&["did:plc:admin"]).await;
7492 let cookie = session_cookie(&state, "did:plc:admin", None);
7493 let app = router(state);
7494
7495 // Between the two ceilings: the framework would accept this.
7496 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7497 let (content_type, body) = opml_multipart(&payload);
7498
7499 let resp = app
7500 .oneshot(
7501 Request::builder()
7502 .method("POST")
7503 .uri("/opml")
7504 .header("content-type", content_type)
7505 .header(header::COOKIE, cookie)
7506 .body(Body::from(body))
7507 .unwrap(),
7508 )
7509 .await
7510 .unwrap();
7511 assert_eq!(
7512 resp.status(),
7513 StatusCode::PAYLOAD_TOO_LARGE,
7514 "a payload over the route's cap but under the framework's was accepted — \
7515 the route's own DefaultBodyLimit layer is not doing anything"
7516 );
7517 }
7518
7519 #[tokio::test]
7520 async fn opml_import_under_limit_upload_is_accepted() {
7521 let state = test_state(&["did:plc:admin"]).await;
7522 let cookie = session_cookie(&state, "did:plc:admin", None);
7523 let db = state.db.clone();
7524 let app = router(state);
7525
7526 // A small, valid OPML well under the cap: must be accepted (the handler
7527 // redirects to `/` or a flash), i.e. never 413.
7528 let opml = br#"<?xml version="1.0"?>
7529<opml version="2.0"><body>
7530 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7531</body></opml>"#;
7532 let (content_type, body) = opml_multipart(opml);
7533
7534 let resp = app
7535 .oneshot(
7536 Request::builder()
7537 .method("POST")
7538 .uri("/opml")
7539 .header("content-type", content_type)
7540 .header(header::COOKIE, cookie)
7541 .body(Body::from(body))
7542 .unwrap(),
7543 )
7544 .await
7545 .unwrap();
7546 // **Assert it was ACCEPTED, not merely that it was not a 413.**
7547 //
7548 // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
7549 // 500 satisfies — so making `import_opml` fail unconditionally left this
7550 // green. Three other OPML tests caught that mutation; the one whose name
7551 // promises to cover the under-cap case did not.
7552 assert_eq!(
7553 resp.status(),
7554 StatusCode::SEE_OTHER,
7555 "an under-cap OPML upload was not accepted (status {})",
7556 resp.status(),
7557 );
7558 // **303 alone is not acceptance.** `import_opml` redirects on several
7559 // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
7560 // by a cap — so an import that stored nothing satisfied the status check.
7561 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7562 .bind("https://example.com/feed.xml")
7563 .fetch_one(&db)
7564 .await
7565 .unwrap();
7566 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7567 let location = resp
7568 .headers()
7569 .get(header::LOCATION)
7570 .and_then(|v| v.to_str().ok())
7571 .unwrap_or_default()
7572 .to_string();
7573 assert!(
7574 !location.starts_with("/login"),
7575 "the import bounced to login instead of being accepted: {location}",
7576 );
7577 }
7578
7579 #[tokio::test]
7580 async fn opml_import_logged_out_redirects_to_login() {
7581 // Logged-out callers are redirected before the body is consumed; assert
7582 // the auth short-circuit rather than a body-cap rejection.
7583 let state = test_state(&["did:plc:admin"]).await;
7584 let app = router(state);
7585
7586 let opml = b"<opml version=\"2.0\"><body></body></opml>";
7587 let (content_type, body) = opml_multipart(opml);
7588
7589 let resp = app
7590 .oneshot(
7591 Request::builder()
7592 .method("POST")
7593 .uri("/opml")
7594 .header("content-type", content_type)
7595 .body(Body::from(body))
7596 .unwrap(),
7597 )
7598 .await
7599 .unwrap();
7600 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7601 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7602 }
7603
7604 // -- delete-my-data (POST /account/delete) --------------------------------
7605
7606 /// A one-shot mock sidecar: binds a loopback port, answers exactly one
7607 /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
7608 /// channel) the DID it was asked to revoke. Enough to prove the delete
7609 /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
7610 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7611 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7612 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7613 let addr = listener.local_addr().unwrap();
7614 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7615 tokio::spawn(async move {
7616 let (mut sock, _) = listener.accept().await.unwrap();
7617 let mut buf = vec![0u8; 4096];
7618 let n = sock.read(&mut buf).await.unwrap();
7619 let req = String::from_utf8_lossy(&buf[..n]).to_string();
7620 // Pull the DID out of the JSON body (last line of the request).
7621 let did = req
7622 .split("\r\n\r\n")
7623 .nth(1)
7624 .and_then(|body| {
7625 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7626 v.get("did")?.as_str().map(str::to_string)
7627 })
7628 .unwrap_or_default();
7629 let is_revoke = req.starts_with("POST /internal/revoke");
7630 let body = serde_json::json!({
7631 "ok": true, "did": did, "revoked": true, "hadSession": true
7632 })
7633 .to_string();
7634 let resp = format!(
7635 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7636 body.len(),
7637 body
7638 );
7639 sock.write_all(resp.as_bytes()).await.unwrap();
7640 sock.flush().await.unwrap();
7641 let _ = tx.send(if is_revoke { did } else { String::new() });
7642 });
7643 (format!("http://{addr}"), rx)
7644 }
7645
7646 /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
7647 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7648 let defaults = Config::default();
7649 test_state_with_sidecar_and(
7650 allowed,
7651 sidecar_url,
7652 defaults.standard_site,
7653 defaults.max_feeds_global,
7654 )
7655 .await
7656 }
7657
7658 /// [`test_state_with_sidecar`] with the standard.site flag and the global
7659 /// feeds ceiling chosen — the two settings the at:// paths branch on.
7660 async fn test_state_with_sidecar_and(
7661 allowed: &[&str],
7662 sidecar_url: &str,
7663 standard_site: bool,
7664 max_feeds_global: i64,
7665 ) -> AppState {
7666 let db = store::init_url("sqlite::memory:").await.unwrap();
7667 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7668 store::ensure_seed(&db, &dids).await.unwrap();
7669 let mut config = Config {
7670 allowed_dids: dids,
7671 cookie_secret: "test-cookie-secret-000".to_string(),
7672 beta_cap: 3,
7673 standard_site,
7674 max_feeds_global,
7675 ..Config::default()
7676 };
7677 config.sidecar.public_url = sidecar_url.to_string();
7678 config.sidecar.internal_url = sidecar_url.to_string();
7679 AppState::new(config, db).unwrap()
7680 }
7681
7682 /// A confirmed `POST /account/delete` purges the caller's local rows, calls
7683 /// the sidecar revoke for that DID, and clears the session cookie.
7684 #[tokio::test]
7685 async fn account_delete_purges_rows_and_triggers_revoke() {
7686 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
7687 let did = "did:plc:leaver";
7688 let state = test_state_with_sidecar(&[], &sidecar_url).await;
7689
7690 // Seed the DID with local rows across the per-DID tables.
7691 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
7692 .await
7693 .unwrap();
7694 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
7695 store::mint_code(&state.db, did, 3600).await.unwrap();
7696 assert!(store::has_beta_access(&state.db, did).await.unwrap());
7697
7698 let cookie = session_cookie(&state, did, Some("leaver.example"));
7699 let app = router(state.clone());
7700
7701 let resp = app
7702 .oneshot(
7703 Request::builder()
7704 .method("POST")
7705 .uri("/account/delete")
7706 .header(header::COOKIE, cookie)
7707 .header("content-type", "application/x-www-form-urlencoded")
7708 .body(Body::from("confirm=DELETE"))
7709 .unwrap(),
7710 )
7711 .await
7712 .unwrap();
7713
7714 // Signed out: redirect to /login with the cookie cleared.
7715 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7716 assert!(resp
7717 .headers()
7718 .get(header::LOCATION)
7719 .unwrap()
7720 .to_str()
7721 .unwrap()
7722 .starts_with("/login"));
7723 let set_cookie = resp
7724 .headers()
7725 .get(header::SET_COOKIE)
7726 .unwrap()
7727 .to_str()
7728 .unwrap();
7729 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
7730
7731 // The sidecar revoke was called for exactly this DID.
7732 //
7733 // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
7734 // that simply never called the sidecar — hung this test forever instead
7735 // of failing it: a wedged CI job rather than a red one, which is the
7736 // worse of the two signals because nobody reads it as a defect.
7737 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
7738 .await
7739 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
7740 .unwrap();
7741 assert_eq!(
7742 revoked_did, did,
7743 "sidecar revoke must fire for the caller DID"
7744 );
7745
7746 // Local rows are gone.
7747 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
7748 let codes: i64 =
7749 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
7750 .bind(did)
7751 .fetch_one(&state.db)
7752 .await
7753 .unwrap();
7754 assert_eq!(codes, 0);
7755 }
7756
7757 /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
7758 /// nothing and bounces back to /manage.
7759 #[tokio::test]
7760 async fn account_delete_without_confirm_is_a_noop() {
7761 let did = "did:plc:staying";
7762 let state = test_state(&[]).await;
7763 store::grant_access(&state.db, did, None, "test", None)
7764 .await
7765 .unwrap();
7766 let cookie = session_cookie(&state, did, None);
7767 let app = router(state.clone());
7768
7769 let resp = app
7770 .oneshot(
7771 Request::builder()
7772 .method("POST")
7773 .uri("/account/delete")
7774 .header(header::COOKIE, cookie)
7775 .header("content-type", "application/x-www-form-urlencoded")
7776 .body(Body::from("confirm=nope"))
7777 .unwrap(),
7778 )
7779 .await
7780 .unwrap();
7781
7782 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7783 assert!(resp
7784 .headers()
7785 .get(header::LOCATION)
7786 .unwrap()
7787 .to_str()
7788 .unwrap()
7789 .starts_with("/manage"));
7790 // Nothing deleted.
7791 assert!(store::has_beta_access(&state.db, did).await.unwrap());
7792 }
7793
7794 /// PDS-outage authorization: when the sidecar is unreachable (as it is in
7795 /// this harness — the default sidecar URL is not served), a DID must STILL
7796 /// be unable to read or mutate an entry in a feed it does not subscribe to.
7797 /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
7798 /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
7799 /// every cached feed.
7800 #[tokio::test]
7801 async fn pds_outage_does_not_widen_cross_did_access() {
7802 let did_a = "did:plc:aaaa";
7803 let state = test_state(&[]).await;
7804 store::grant_access(&state.db, did_a, None, "test", None)
7805 .await
7806 .unwrap();
7807
7808 // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
7809 // lives in feed_b — the one A must never touch during the outage.
7810 let feed_a = store::upsert_feed(
7811 &state.db,
7812 &store::NewFeed {
7813 url: "https://a.example/feed.xml".to_string(),
7814 title: Some("A".to_string()),
7815 ..Default::default()
7816 },
7817 )
7818 .await
7819 .unwrap();
7820 let feed_b = store::upsert_feed(
7821 &state.db,
7822 &store::NewFeed {
7823 url: "https://b.example/feed.xml".to_string(),
7824 title: Some("B".to_string()),
7825 ..Default::default()
7826 },
7827 )
7828 .await
7829 .unwrap();
7830 store::insert_entries(
7831 &state.db,
7832 feed_b,
7833 &[store::NewEntry {
7834 guid: "b-1".to_string(),
7835 url: Some("https://b.example/1".to_string()),
7836 title: Some("B one".to_string()),
7837 published: Some("2026-07-11T00:00:00Z".to_string()),
7838 content_html: Some("<p>secret B body</p>".to_string()),
7839 ..Default::default()
7840 }],
7841 0,
7842 )
7843 .await
7844 .unwrap();
7845 // A subscribes ONLY to feed_a.
7846 store::replace_sub_refs(&state.db, did_a, &[feed_a])
7847 .await
7848 .unwrap();
7849 // Read B's entry id via a transient sub_ref, then drop it so only the
7850 // shared cache holds B's entry (no DID subscribes to feed_b anymore).
7851 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
7852 .await
7853 .unwrap();
7854 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
7855 .await
7856 .unwrap()[0]
7857 .id;
7858 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
7859 .await
7860 .unwrap();
7861
7862 let cookie = session_cookie(&state, did_a, None);
7863 let app = router(state.clone());
7864
7865 // GET /entries/{b} as A → 404 even during the outage.
7866 let get_b = app
7867 .clone()
7868 .oneshot(
7869 Request::builder()
7870 .method("GET")
7871 .uri(format!("/entries/{b_entry_id}"))
7872 .header(header::COOKIE, cookie.clone())
7873 .body(Body::empty())
7874 .unwrap(),
7875 )
7876 .await
7877 .unwrap();
7878 assert_eq!(
7879 get_b.status(),
7880 StatusCode::NOT_FOUND,
7881 "A must not read B's entry during a PDS outage"
7882 );
7883
7884 // POST /entries/{b}/read as A → 404, and no entry_state row is written.
7885 let read_b = app
7886 .oneshot(
7887 Request::builder()
7888 .method("POST")
7889 .uri(format!("/entries/{b_entry_id}/read"))
7890 .header(header::COOKIE, cookie)
7891 .header("content-type", "application/x-www-form-urlencoded")
7892 .body(Body::from("read=true"))
7893 .unwrap(),
7894 )
7895 .await
7896 .unwrap();
7897 assert_eq!(
7898 read_b.status(),
7899 StatusCode::NOT_FOUND,
7900 "A must not mark B's entry read during a PDS outage"
7901 );
7902
7903 // The fallback must NOT have widened A's sub_ref to feed_b.
7904 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
7905 .bind(did_a)
7906 .fetch_all(&state.db)
7907 .await
7908 .unwrap();
7909 assert_eq!(
7910 a_feed_ids,
7911 vec![feed_a],
7912 "outage fallback must not add feeds A never subscribed to"
7913 );
7914 // And B's entry has zero read-state (A's attempt did not mutate).
7915 let es_count: i64 =
7916 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
7917 .bind(did_a)
7918 .bind(b_entry_id)
7919 .fetch_one(&state.db)
7920 .await
7921 .unwrap();
7922 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
7923 }
7924
7925 /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
7926 /// nothing. The other arm is counted separately.**
7927 ///
7928 /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
7929 /// error would make the metric noisy in exactly the case that is fine.
7930 ///
7931 /// But `revoke_everywhere` has TWO arms, and a review found that counting
7932 /// only the rust one let `oauth_revoke` report all-clear while every sidecar
7933 /// revocation failed. For anyone who logged in before the cutover the sidecar
7934 /// store is the only one holding tokens, so the rust arm correctly says
7935 /// NoSession and the metric said nothing was wrong. Both arms are now
7936 /// recorded, distinguished by the backend column — so this test pins the
7937 /// BACKEND as well as the outcome.
7938 #[tokio::test]
7939 async fn a_logout_with_no_session_counts_as_success() {
7940 let did = "did:plc:aaaa";
7941 let state = test_state(&[]).await;
7942 assert!(
7943 state.oauth.is_some(),
7944 "meaningless without an oauth runtime; the revoke arm would be skipped",
7945 );
7946
7947 revoke_everywhere(&state, did).await;
7948 let rows = state.metrics.snapshot();
7949 let find = |b: crate::metrics::Backend| {
7950 rows.iter()
7951 .find(|r| r.op == "oauth_revoke" && r.backend == b)
7952 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
7953 };
7954
7955 // Rust arm: nothing stored for this DID, so NoSession -> ok.
7956 let rust = find(crate::metrics::Backend::Rust);
7957 assert_eq!(
7958 rust.stats.err_count, 0,
7959 "NoSession was counted as a failure; logout is idempotent",
7960 );
7961 assert_eq!(rust.stats.ok_count, 1);
7962
7963 // Sidecar arm: unreachable in a test, so it must be recorded as an
7964 // ERROR under its own backend — not silently dropped, and not folded
7965 // into the rust row.
7966 let sidecar = find(crate::metrics::Backend::Sidecar);
7967 assert_eq!(
7968 sidecar.stats.err_count, 1,
7969 "a failed sidecar revoke was not counted",
7970 );
7971 }
7972
7973 /// **`Failed` must count as an error — the half the metric exists for.**
7974 ///
7975 /// A review found this unpinned: replacing the mapping with
7976 /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
7977 /// asserted the `NoSession -> ok` half, so the branch that actually means
7978 /// "the PDS still holds tokens we asked it to drop" was untested.
7979 ///
7980 /// Driven through the same handler, with a session present but the PDS
7981 /// unreachable, so `sign_out_discovering` returns `Failed`.
7982 #[tokio::test]
7983 async fn a_failed_rust_revoke_counts_as_an_error() {
7984 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7985 let state = test_state(&[]).await;
7986 let runtime = state.oauth.as_deref().expect("oauth runtime");
7987 crate::oauth::store::put_session(
7988 &state.db,
7989 &runtime.codec,
7990 &crate::oauth::store::OAuthSession {
7991 sub: did.into(),
7992 issuer: "https://auth.invalid".into(),
7993 aud: "https://pds.invalid".into(),
7994 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
7995 .to_jwk_json()
7996 .unwrap(),
7997 access_token: "at".into(),
7998 refresh_token: "rt".into(),
7999 token_type: "DPoP".into(),
8000 granted_scope: "atproto".into(),
8001 expires_at: Some(crate::store::now_unix() + 3600),
8002 },
8003 )
8004 .await
8005 .unwrap();
8006
8007 revoke_everywhere(&state, did).await;
8008
8009 let rows = state.metrics.snapshot();
8010 let rust = rows
8011 .iter()
8012 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
8013 .expect("no rust oauth_revoke row");
8014 assert_eq!(
8015 rust.stats.err_count, 1,
8016 "an unreachable PDS must count as a revocation failure",
8017 );
8018 assert_eq!(rust.stats.ok_count, 0);
8019 }
8020
8021 /// **The `href` defence is now carried by the TYPE, not by remembering.**
8022 ///
8023 /// `EntryRow.link` used to be a `String`, and the guard was "call
8024 /// `net::safe_link` before assigning it". Deleting that call left all 679
8025 /// tests passing — a live XSS defence with nothing protecting it.
8026 ///
8027 /// `SafeLink` has no `From<String>` and no public member, so the only way to
8028 /// get foreign input into an `href` is `external`, which does the check
8029 /// itself. This test pins that constructor; the *wiring* is now pinned by
8030 /// the compiler, which is the part a test could never hold down.
8031 ///
8032 /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
8033 /// so the template renders the row WITHOUT an anchor. Dropping the row
8034 /// instead would make the record unremovable, because the un-save button
8035 /// lives on it.
8036 #[test]
8037 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
8038 for hostile in [
8039 "javascript:alert(1)",
8040 "JavaScript:alert(1)",
8041 " javascript:alert(1)",
8042 "data:text/html;base64,PHNjcmlwdD4=",
8043 "vbscript:msgbox(1)",
8044 "file:///etc/passwd",
8045 // Protocol-relative: inherits the page's scheme, so it is an
8046 // off-site link wearing a same-site costume. Carried over from the
8047 // test this one replaces, which was its only unique input.
8048 "//evil.example/path",
8049 ] {
8050 let link = SafeLink::external(hostile);
8051 assert!(
8052 link.is_empty(),
8053 "{hostile:?} produced a non-empty href: {link}",
8054 );
8055 assert!(
8056 !link.to_string().to_ascii_lowercase().contains("script"),
8057 "{hostile:?} leaked into the rendered link",
8058 );
8059 }
8060
8061 // And the other direction: a check that rejects everything would satisfy
8062 // the loop above while breaking every real saved record.
8063 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8064 let link = SafeLink::external(good);
8065 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8066 assert_eq!(link.to_string(), good);
8067 }
8068 }
8069
8070 /// **The WIRING, not the helper — this is the one that catches the real
8071 /// mistake.**
8072 ///
8073 /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
8074 /// `SafeLink::external` *does*. It cannot pin that the saved-record path
8075 /// *calls* it, and a review proved that gap was live twice over: swapping
8076 /// `external` for the app-path constructor, and constructing the tuple
8077 /// directly, both restored the whole `javascript:` hole with every test
8078 /// green. The type now blocks both — `entry` takes an `i64`, and the field
8079 /// lives in another module — but the wiring deserves a test of its own
8080 /// rather than resting on the shape of a signature.
8081 ///
8082 /// Renders the actual row through the actual handler, from a record whose
8083 /// URL is hostile.
8084 #[tokio::test]
8085 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8086 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8087 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8088 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8089 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8090
8091 let resp = router(state)
8092 .oneshot(
8093 Request::builder()
8094 .uri("/?view=starred")
8095 .body(Body::empty())
8096 .unwrap(),
8097 )
8098 .await
8099 .unwrap();
8100 assert_eq!(resp.status(), StatusCode::OK);
8101 let body = String::from_utf8(
8102 axum::body::to_bytes(resp.into_body(), usize::MAX)
8103 .await
8104 .unwrap()
8105 .to_vec(),
8106 )
8107 .unwrap();
8108
8109 // Not in an href, and not as the title either — the title falls back to
8110 // the URL for links we DO render, so both paths must withhold it.
8111 assert!(
8112 !body.to_ascii_lowercase().contains("javascript:"),
8113 "the hostile scheme reached the rendered page",
8114 );
8115 // But the row must survive: the un-save button lives on it, so dropping
8116 // the row would make the record unremovable from here.
8117 assert!(
8118 body.contains("unusable link"),
8119 "the row was dropped instead of rendering without an anchor",
8120 );
8121 }
8122
8123 /// **The reader view's two `href`s, through the actual handler.**
8124 ///
8125 /// The sibling above covers the LIST row. `entry.html` has its own pair of
8126 /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
8127 /// taken straight off the `entries.url` column — a remote feed's `<link>`.
8128 ///
8129 /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
8130 /// this was never a live hole. But that guard is procedural and sits a long
8131 /// way from the `href`: it holds only as long as every future writer to
8132 /// `entries.url` remembers to go through `feed.rs`. This test does not
8133 /// depend on it — it writes the hostile URL into the column DIRECTLY, which
8134 /// is precisely the state the ingest check cannot speak for.
8135 ///
8136 /// **Both directions, deliberately.** A fix that renders no link at all
8137 /// satisfies every negative assertion here, and would break every real
8138 /// entry. The second half is what makes the first half mean something.
8139 #[tokio::test]
8140 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8141 let did = "did:plc:readerhref";
8142 let state = test_state(&[]).await;
8143 store::grant_access(&state.db, did, None, "test", None)
8144 .await
8145 .unwrap();
8146 let feed = store::upsert_feed(
8147 &state.db,
8148 &store::NewFeed {
8149 url: "https://href.example/feed.xml".to_string(),
8150 title: Some("Href".to_string()),
8151 ..Default::default()
8152 },
8153 )
8154 .await
8155 .unwrap();
8156 // Straight into the column, bypassing `feed.rs` — the whole point.
8157 store::insert_entries(
8158 &state.db,
8159 feed,
8160 &[
8161 store::NewEntry {
8162 guid: "hostile-1".to_string(),
8163 url: Some("javascript:alert(1)".to_string()),
8164 title: Some("Hostile entry".to_string()),
8165 published: Some("2026-07-11T00:00:00Z".to_string()),
8166 ..Default::default()
8167 },
8168 store::NewEntry {
8169 guid: "benign-1".to_string(),
8170 url: Some("https://href.example/post".to_string()),
8171 title: Some("Benign entry".to_string()),
8172 published: Some("2026-07-10T00:00:00Z".to_string()),
8173 ..Default::default()
8174 },
8175 ],
8176 0,
8177 )
8178 .await
8179 .unwrap();
8180 store::replace_sub_refs(&state.db, did, &[feed])
8181 .await
8182 .unwrap();
8183 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8184 let id_of = |guid: &str| {
8185 rows.iter()
8186 .find(|r| r.guid == guid)
8187 .unwrap_or_else(|| panic!("{guid} was not inserted"))
8188 .id
8189 };
8190
8191 let cookie = session_cookie(&state, did, None);
8192 let app = router(state.clone());
8193
8194 let render = |id: i64| {
8195 let app = app.clone();
8196 let cookie = cookie.clone();
8197 async move {
8198 let resp = app
8199 .oneshot(
8200 Request::builder()
8201 .method("GET")
8202 .uri(format!("/entries/{id}"))
8203 .header(header::COOKIE, cookie)
8204 .body(Body::empty())
8205 .unwrap(),
8206 )
8207 .await
8208 .unwrap();
8209 assert_eq!(resp.status(), StatusCode::OK);
8210 String::from_utf8(
8211 axum::body::to_bytes(resp.into_body(), usize::MAX)
8212 .await
8213 .unwrap()
8214 .to_vec(),
8215 )
8216 .unwrap()
8217 }
8218 };
8219
8220 let hostile = render(id_of("hostile-1")).await;
8221 // The reader page for THIS entry actually rendered. Without this the
8222 // three negatives below are satisfied by an empty body.
8223 assert!(
8224 hostile.contains("Hostile entry"),
8225 "the reader did not render the entry: {hostile}",
8226 );
8227 assert!(
8228 !hostile.to_ascii_lowercase().contains("javascript:"),
8229 "the hostile scheme reached the reader page: {hostile}",
8230 );
8231 // Not merely escaped — the template took its no-link branch. Both
8232 // `href`s are gated on the same `Option`, so this covers the byline
8233 // link and the action-bar button together.
8234 assert!(
8235 !hostile.contains("actionbar-open"),
8236 "the action bar rendered an open-original link for a refused URL: {hostile}",
8237 );
8238 assert!(
8239 !hostile.contains("Original \u{2197}"),
8240 "the byline rendered an original link for a refused URL: {hostile}",
8241 );
8242
8243 // The other direction: a legitimate entry still links out, so "render
8244 // nothing" cannot pass as a fix.
8245 let benign = render(id_of("benign-1")).await;
8246 assert!(
8247 benign.contains("Benign entry"),
8248 "the reader did not render the benign entry: {benign}",
8249 );
8250 // BOTH `href`s, counted. The negatives above fire on the action bar
8251 // first, so without this the byline needle `Original \u{2197}` is never
8252 // once observed failing — a misspelled needle would pass forever.
8253 assert_eq!(
8254 benign
8255 .matches(r#"href="https://href.example/post""#)
8256 .count(),
8257 2,
8258 "entry.html has two `href`s for the entry URL — the byline link and \
8259 the action-bar button — and this render produced a different \
8260 number: {benign}",
8261 );
8262 assert!(
8263 benign.contains("actionbar-open"),
8264 "a legitimate entry lost its open-original button: {benign}",
8265 );
8266 assert!(
8267 benign.contains("Original \u{2197}"),
8268 "a legitimate entry lost its byline link: {benign}",
8269 );
8270 }
8271
8272 /// **The outage fallback must not widen what the caller can READ — and the
8273 /// sibling test above can only see what it WRITES.**
8274 ///
8275 /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
8276 /// on `entry_state`: the fallback's side effects. But the fail-open it names
8277 /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
8278 /// leaks through the list it *hands back* — the sidebar and the reader render
8279 /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
8280 /// perfectly honest and every existing assertion stays green.
8281 ///
8282 /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
8283 /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
8284 /// exact historical bug the fallback's comment describes — left **all 663
8285 /// tests passing**. Cross-tenant isolation is the one property this project
8286 /// cannot regress quietly, and nothing observed it.
8287 ///
8288 /// So this asserts on the RETURN VALUE, which is the thing that reaches the
8289 /// user, and it deliberately does not look at `sub_ref` at all — that half is
8290 /// already covered above.
8291 #[tokio::test]
8292 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8293 let did_a = "did:plc:aaaa";
8294 let state = test_state(&[]).await;
8295 store::grant_access(&state.db, did_a, None, "test", None)
8296 .await
8297 .unwrap();
8298
8299 let feed_a = store::upsert_feed(
8300 &state.db,
8301 &store::NewFeed {
8302 url: "https://a.example/feed.xml".to_string(),
8303 title: Some("A".to_string()),
8304 ..Default::default()
8305 },
8306 )
8307 .await
8308 .unwrap();
8309 let _feed_b = store::upsert_feed(
8310 &state.db,
8311 &store::NewFeed {
8312 url: "https://b.example/feed.xml".to_string(),
8313 title: Some("B".to_string()),
8314 ..Default::default()
8315 },
8316 )
8317 .await
8318 .unwrap();
8319 // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
8320 // to nobody — exactly the row a whole-cache fallback would hand to A.
8321 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8322 .await
8323 .unwrap();
8324
8325 // No sidecar and no PDS are reachable from a test, so
8326 // `list_subscriptions_sorted` fails and this IS the outage path. Assert
8327 // that, rather than assuming it: if the repo ever starts succeeding here,
8328 // this test would silently stop exercising the fallback at all.
8329 assert!(
8330 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8331 "this test is only meaningful on the outage path; the repo answered",
8332 );
8333
8334 let resolved = resolve_subscriptions(&state, did_a).await;
8335
8336 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8337 assert_eq!(
8338 urls,
8339 vec!["https://a.example/feed.xml"],
8340 "the outage fallback must return the caller's OWN subscriptions only; \
8341 any other feed here is cross-tenant read access granted by an outage",
8342 );
8343 }
8344
8345 /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
8346 /// seeding `did` a beta seat + session-capable state.
8347 async fn test_state_with_caps(
8348 did: &str,
8349 max_subs_per_did: i64,
8350 max_feeds_global: i64,
8351 ) -> AppState {
8352 let db = store::init_url("sqlite::memory:").await.unwrap();
8353 let config = Config {
8354 cookie_secret: "test-cookie-secret-000".to_string(),
8355 beta_cap: 100,
8356 max_subs_per_did,
8357 max_feeds_global,
8358 ..Config::default()
8359 };
8360 store::grant_access(&db, did, None, "test", None)
8361 .await
8362 .unwrap();
8363 AppState::new(config, db).unwrap()
8364 }
8365
8366 /// An OPML document with `n` distinct public feeds.
8367 fn opml_with_feeds(n: usize) -> String {
8368 let mut outlines = String::new();
8369 for i in 0..n {
8370 outlines.push_str(&format!(
8371 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8372 ));
8373 }
8374 format!(
8375 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8376 )
8377 }
8378
8379 /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
8380 /// distinct new feeds than the shared cache can hold caches only up to the
8381 /// ceiling — the rest are trimmed. (Regression: the import loop previously
8382 /// bypassed `max_feeds_global` entirely.)
8383 #[tokio::test]
8384 async fn opml_import_enforces_global_feeds_ceiling() {
8385 let did = "did:plc:importer";
8386 // Cap the shared cache at 3 feeds; import 10 distinct new ones.
8387 let state = test_state_with_caps(did, 0, 3).await;
8388 let cookie = session_cookie(&state, did, None);
8389 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8390 let app = router(state.clone());
8391
8392 let resp = app
8393 .oneshot(
8394 Request::builder()
8395 .method("POST")
8396 .uri("/opml")
8397 .header(header::COOKIE, cookie)
8398 .header("content-type", ct)
8399 .body(Body::from(body))
8400 .unwrap(),
8401 )
8402 .await
8403 .unwrap();
8404 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8405
8406 let feeds = store::count_feeds(&state.db).await.unwrap();
8407 assert!(
8408 feeds <= 3,
8409 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8410 );
8411 }
8412
8413 /// **A malformed `at://` on the add path is "not a kind of feed we take",
8414 /// not "private/paid".** The first gate was the privacy classifier, whose
8415 /// at:// arm fails closed as `Private` for anything not a well-formed
8416 /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
8417 /// the private-feed flash and a "refused private/paid feed" log line. On
8418 /// main the same input reached `resolve_feed_url` and got "Couldn't find a
8419 /// feed". Storability is decided first for an at:// input, with its own
8420 /// message.
8421 #[tokio::test]
8422 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8423 let did = "did:plc:typoist";
8424 let state = test_state_with_caps(did, 0, 0).await;
8425 let cookie = session_cookie(&state, did, None);
8426 for input in [
8427 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8428 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8429 ] {
8430 let resp = router(state.clone())
8431 .oneshot(
8432 Request::builder()
8433 .method("POST")
8434 .uri("/subscriptions")
8435 .header(header::COOKIE, cookie.clone())
8436 .header("content-type", "application/x-www-form-urlencoded")
8437 .body(Body::from(format!("url={input}")))
8438 .unwrap(),
8439 )
8440 .await
8441 .unwrap();
8442 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8443 let loc = resp
8444 .headers()
8445 .get(header::LOCATION)
8446 .unwrap()
8447 .to_str()
8448 .unwrap();
8449 assert!(
8450 loc.contains("kind%20of%20feed"),
8451 "expected the unsupported-feed flash for {input}, got {loc}"
8452 );
8453 assert!(
8454 !loc.contains("Private"),
8455 "a storability refusal was reported as a privacy one for {input}: {loc}"
8456 );
8457 }
8458 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8459 }
8460
8461 /// **An OPML entry this instance cannot store is counted and reported, not
8462 /// silently dropped.** The storability `continue` incremented nothing,
8463 /// while the privacy branch beside it produced a user-visible label — so
8464 /// an OPML exported from a standard.site-enabled instance imported
8465 /// "successfully" with entries missing and no reason given. The reader is
8466 /// told how many, and why.
8467 #[tokio::test]
8468 async fn opml_import_reports_entries_this_instance_cannot_store() {
8469 let did = "did:plc:renamer4";
8470 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8471 let state = test_state_with_sidecar(&[did], &sidecar).await;
8472 assert!(!state.config.standard_site);
8473 let opml = format!(
8474 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8475 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8476 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8477 </body></opml>"
8478 );
8479 let (ct, body) = opml_multipart(opml.as_bytes());
8480 let cookie = session_cookie(&state, did, None);
8481 let resp = router(state.clone())
8482 .oneshot(
8483 Request::builder()
8484 .method("POST")
8485 .uri("/opml")
8486 .header(header::COOKIE, cookie)
8487 .header("content-type", ct)
8488 .body(Body::from(body))
8489 .unwrap(),
8490 )
8491 .await
8492 .unwrap();
8493 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8494 let loc = resp
8495 .headers()
8496 .get(header::LOCATION)
8497 .unwrap()
8498 .to_str()
8499 .unwrap();
8500 assert!(
8501 loc.contains("Imported%201%20feed"),
8502 "unexpected flash: {loc}"
8503 );
8504 assert!(
8505 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8506 "the dropped entry was not reported: {loc}"
8507 );
8508 // Reported by count only: the at-URI itself is not echoed back.
8509 assert!(
8510 !loc.contains("site.standard.publication"),
8511 "the URI was echoed: {loc}"
8512 );
8513 }
8514
8515 /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
8516 /// cap imports zero new feeds.
8517 #[tokio::test]
8518 async fn opml_import_enforces_per_did_cap() {
8519 let did = "did:plc:capped";
8520 // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
8521 let state = test_state_with_caps(did, 2, 0).await;
8522 let existing_a = store::upsert_feed(
8523 &state.db,
8524 &store::NewFeed {
8525 url: "https://have-a.example/feed.xml".to_string(),
8526 ..Default::default()
8527 },
8528 )
8529 .await
8530 .unwrap();
8531 let existing_b = store::upsert_feed(
8532 &state.db,
8533 &store::NewFeed {
8534 url: "https://have-b.example/feed.xml".to_string(),
8535 ..Default::default()
8536 },
8537 )
8538 .await
8539 .unwrap();
8540 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8541 .await
8542 .unwrap();
8543 let before = store::count_feeds(&state.db).await.unwrap();
8544
8545 let cookie = session_cookie(&state, did, None);
8546 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8547 let app = router(state.clone());
8548 let resp = app
8549 .oneshot(
8550 Request::builder()
8551 .method("POST")
8552 .uri("/opml")
8553 .header(header::COOKIE, cookie)
8554 .header("content-type", ct)
8555 .body(Body::from(body))
8556 .unwrap(),
8557 )
8558 .await
8559 .unwrap();
8560 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8561 // Headroom was 0 → no new feeds imported into the shared cache.
8562 let after = store::count_feeds(&state.db).await.unwrap();
8563 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8564 }
8565
8566 /// Single-add per-DID cap: a DID at its subscription cap is refused before
8567 /// any fetch, with the limit flash.
8568 #[tokio::test]
8569 async fn single_add_enforces_per_did_cap() {
8570 let did = "did:plc:subcapped";
8571 let state = test_state_with_caps(did, 1, 0).await;
8572 let f = store::upsert_feed(
8573 &state.db,
8574 &store::NewFeed {
8575 url: "https://have.example/feed.xml".to_string(),
8576 ..Default::default()
8577 },
8578 )
8579 .await
8580 .unwrap();
8581 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8582 let cookie = session_cookie(&state, did, None);
8583 let app = router(state.clone());
8584 let resp = app
8585 .oneshot(
8586 Request::builder()
8587 .method("POST")
8588 .uri("/subscriptions")
8589 .header(header::COOKIE, cookie)
8590 .header("content-type", "application/x-www-form-urlencoded")
8591 .body(Body::from("url=https://another.example/feed.xml"))
8592 .unwrap(),
8593 )
8594 .await
8595 .unwrap();
8596 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8597 let loc = resp
8598 .headers()
8599 .get(header::LOCATION)
8600 .unwrap()
8601 .to_str()
8602 .unwrap();
8603 assert!(
8604 loc.contains("Subscription%20limit%20reached"),
8605 "expected sub-limit flash, got {loc}"
8606 );
8607 }
8608
8609 /// `GET /` renders at most one page of rows and offers a way to the rest.
8610 ///
8611 /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
8612 /// `LIMIT`, article bodies included — and hand the lot to the template. With
8613 /// 250 entries that is the whole list in one response; with a real backlog on
8614 /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
8615 /// is capped, the heading still reports the true total, and page 2 is
8616 /// reachable and disjoint.
8617 #[tokio::test]
8618 async fn the_reader_index_pages_instead_of_rendering_everything() {
8619 let did = "did:plc:pager";
8620 let state = test_state(&[]).await;
8621 store::grant_access(&state.db, did, None, "test", None)
8622 .await
8623 .unwrap();
8624 let feed = store::upsert_feed(
8625 &state.db,
8626 &store::NewFeed {
8627 url: "https://pager.example/feed.xml".to_string(),
8628 title: Some("Pager".to_string()),
8629 ..Default::default()
8630 },
8631 )
8632 .await
8633 .unwrap();
8634 let total = 250_usize;
8635 let entries: Vec<store::NewEntry> = (0..total)
8636 .map(|i| store::NewEntry {
8637 guid: format!("p-{i:04}"),
8638 url: Some(format!("https://pager.example/{i}")),
8639 title: Some(format!("Article {i:04}")),
8640 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8641 content_html: Some("x".repeat(4_000)),
8642 ..Default::default()
8643 })
8644 .collect();
8645 store::insert_entries(&state.db, feed, &entries, 0)
8646 .await
8647 .unwrap();
8648 store::replace_sub_refs(&state.db, did, &[feed])
8649 .await
8650 .unwrap();
8651
8652 let cookie = session_cookie(&state, did, None);
8653 let app = router(state.clone());
8654 let get = |uri: &str| {
8655 let app = app.clone();
8656 let cookie = cookie.clone();
8657 let uri = uri.to_string();
8658 async move {
8659 let resp = app
8660 .oneshot(
8661 Request::builder()
8662 .uri(uri)
8663 .header(header::COOKIE, cookie)
8664 .body(Body::empty())
8665 .unwrap(),
8666 )
8667 .await
8668 .unwrap();
8669 assert_eq!(resp.status(), StatusCode::OK);
8670 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8671 .await
8672 .unwrap();
8673 String::from_utf8(bytes.to_vec()).unwrap()
8674 }
8675 };
8676
8677 let page1 = get("/").await;
8678 // One `<li class="entry…>` per rendered row. Counting "/entries/" would
8679 // over-count: each row carries several (the link plus the read/star
8680 // forms).
8681 let rows1 = page1.matches("<li class=\"entry").count();
8682 assert!(
8683 rows1 <= ENTRIES_PER_PAGE as usize,
8684 "page 1 rendered {rows1} entry links; the list is unbounded"
8685 );
8686 assert!(
8687 rows1 > 0,
8688 "page 1 rendered nothing at all: the page bound swallowed the list"
8689 );
8690 // The count is the TRUE total, not the page size — otherwise paging
8691 // would quietly relabel a 250-entry backlog as a 100-entry one.
8692 assert!(
8693 page1.contains("250 entries"),
8694 "heading must report the full total, not the page"
8695 );
8696 assert!(
8697 page1.contains("page=2"),
8698 "no way to reach the rest of the list: {}",
8699 &page1[..page1.len().min(400)]
8700 );
8701 // The body never belongs in a list response.
8702 assert!(
8703 !page1.contains(&"x".repeat(4_000)),
8704 "the list response carried an article body"
8705 );
8706
8707 let page2 = get("/?page=2").await;
8708 assert!(
8709 page2.matches("<li class=\"entry").count() > 0,
8710 "page 2 rendered no rows at all"
8711 );
8712 assert!(
8713 page2.contains("page=1") || page2.contains("Newer"),
8714 "page 2 offers no way back"
8715 );
8716 // Disjoint: an article on page 1 must not reappear on page 2.
8717 let first_title = (0..total)
8718 .map(|i| format!("Article {i:04}"))
8719 .find(|t| page1.contains(t))
8720 .expect("page 1 shows at least one titled article");
8721 assert!(
8722 !page2.contains(&first_title),
8723 "{first_title} appears on both pages"
8724 );
8725
8726 // A page past the end must not be a dead end. The empty state renders
8727 // instead of the pager, so an out-of-range page would leave a reader
8728 // with no link back — reachable by typing a number, and reachable
8729 // WITHOUT typing anything by paging to the end and then marking entries
8730 // read, which shrinks the list under the URL already in the address bar.
8731 let past_end = get("/?page=999").await;
8732 assert!(
8733 past_end.matches("<li class=\"entry").count() > 0,
8734 "an out-of-range page rendered nothing and offered no way back"
8735 );
8736 assert!(
8737 past_end.contains("page=2"),
8738 "the clamped page offers no pager"
8739 );
8740 }
8741
8742 /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
8743 /// out-of-band action-bar fragment with FRESHLY re-read state so a second
8744 /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
8745 /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
8746 /// view (no reader header) instead swaps the row. This guards the reader OOB
8747 /// toggle wiring, which had no test.
8748 #[tokio::test]
8749 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
8750 let did = "did:plc:reader";
8751 let state = test_state(&[]).await;
8752 store::grant_access(&state.db, did, None, "test", None)
8753 .await
8754 .unwrap();
8755 let feed = store::upsert_feed(
8756 &state.db,
8757 &store::NewFeed {
8758 url: "https://reader.example/feed.xml".to_string(),
8759 title: Some("Reader".to_string()),
8760 ..Default::default()
8761 },
8762 )
8763 .await
8764 .unwrap();
8765 store::insert_entries(
8766 &state.db,
8767 feed,
8768 &[store::NewEntry {
8769 guid: "r-1".to_string(),
8770 url: Some("https://reader.example/1".to_string()),
8771 title: Some("Article".to_string()),
8772 published: Some("2026-07-11T00:00:00Z".to_string()),
8773 content_html: Some("<p>body</p>".to_string()),
8774 ..Default::default()
8775 }],
8776 0,
8777 )
8778 .await
8779 .unwrap();
8780 store::replace_sub_refs(&state.db, did, &[feed])
8781 .await
8782 .unwrap();
8783 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8784
8785 let cookie = session_cookie(&state, did, None);
8786 let app = router(state.clone());
8787
8788 // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
8789 let resp = app
8790 .clone()
8791 .oneshot(
8792 Request::builder()
8793 .method("POST")
8794 .uri(format!("/entries/{entry_id}/read"))
8795 .header(header::COOKIE, cookie.clone())
8796 .header("HX-Request", "true")
8797 .header("X-FR-Reader", "1")
8798 .header("content-type", "application/x-www-form-urlencoded")
8799 .body(Body::from("read=true"))
8800 .unwrap(),
8801 )
8802 .await
8803 .unwrap();
8804 assert_eq!(resp.status(), StatusCode::OK);
8805 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8806 .await
8807 .unwrap();
8808 let html = String::from_utf8(bytes.to_vec()).unwrap();
8809 assert!(
8810 html.contains("hx-swap-oob=\"outerHTML\""),
8811 "reader response must be an OOB swap: {html}"
8812 );
8813 assert!(
8814 html.contains(r#"id="entry-actionbar""#),
8815 "reader response must be the action-bar fragment: {html}"
8816 );
8817 // Now READ: the read button reflects it (aria-pressed=true) and the
8818 // hidden value flips to `false` so the next tap marks it UNREAD.
8819 assert!(
8820 html.contains(r#"aria-pressed="true""#),
8821 "read button must show pressed after marking read: {html}"
8822 );
8823 assert!(
8824 html.contains(r#"name="read" value="false""#),
8825 "hidden read value must flip to false so a second tap reverses: {html}"
8826 );
8827
8828 // A second reader mark-read (submitting the flipped `read=false`) marks
8829 // it UNREAD again — the toggle reverses.
8830 let resp2 = app
8831 .oneshot(
8832 Request::builder()
8833 .method("POST")
8834 .uri(format!("/entries/{entry_id}/read"))
8835 .header(header::COOKIE, cookie)
8836 .header("HX-Request", "true")
8837 .header("X-FR-Reader", "1")
8838 .header("content-type", "application/x-www-form-urlencoded")
8839 .body(Body::from("read=false"))
8840 .unwrap(),
8841 )
8842 .await
8843 .unwrap();
8844 assert_eq!(resp2.status(), StatusCode::OK);
8845 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
8846 .await
8847 .unwrap();
8848 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
8849 assert!(
8850 html2.contains(r#"aria-pressed="false""#),
8851 "read button must show un-pressed after reversing: {html2}"
8852 );
8853 assert!(
8854 html2.contains(r#"name="read" value="true""#),
8855 "hidden read value must flip back to true: {html2}"
8856 );
8857 }
8858
8859 /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
8860 /// action-bar — the counterpart to the reader-OOB test above.
8861 #[tokio::test]
8862 async fn list_mark_read_returns_row_not_oob_actionbar() {
8863 let did = "did:plc:listv";
8864 let state = test_state(&[]).await;
8865 store::grant_access(&state.db, did, None, "test", None)
8866 .await
8867 .unwrap();
8868 let feed = store::upsert_feed(
8869 &state.db,
8870 &store::NewFeed {
8871 url: "https://list.example/feed.xml".to_string(),
8872 title: Some("List".to_string()),
8873 ..Default::default()
8874 },
8875 )
8876 .await
8877 .unwrap();
8878 store::insert_entries(
8879 &state.db,
8880 feed,
8881 &[store::NewEntry {
8882 guid: "l-1".to_string(),
8883 url: Some("https://list.example/1".to_string()),
8884 title: Some("Article".to_string()),
8885 published: Some("2026-07-11T00:00:00Z".to_string()),
8886 ..Default::default()
8887 }],
8888 0,
8889 )
8890 .await
8891 .unwrap();
8892 store::replace_sub_refs(&state.db, did, &[feed])
8893 .await
8894 .unwrap();
8895 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8896
8897 let cookie = session_cookie(&state, did, None);
8898 let app = router(state.clone());
8899
8900 let resp = app
8901 .oneshot(
8902 Request::builder()
8903 .method("POST")
8904 .uri(format!("/entries/{entry_id}/read"))
8905 .header(header::COOKIE, cookie)
8906 .header("HX-Request", "true")
8907 .header("content-type", "application/x-www-form-urlencoded")
8908 .body(Body::from("read=true"))
8909 .unwrap(),
8910 )
8911 .await
8912 .unwrap();
8913 assert_eq!(resp.status(), StatusCode::OK);
8914 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8915 .await
8916 .unwrap();
8917 let html = String::from_utf8(bytes.to_vec()).unwrap();
8918 assert!(
8919 !html.contains("hx-swap-oob"),
8920 "list-view response must NOT be an OOB swap: {html}"
8921 );
8922 // **And it must actually BE the row.** The assertion above is satisfied
8923 // by an empty body, or by any response that simply omits the attribute —
8924 // so on its own it pins half a property and the name promises the other
8925 // half.
8926 assert!(
8927 html.contains(&format!("/entries/{entry_id}")),
8928 "the response is not the row for this entry: {html}",
8929 );
8930 assert!(
8931 html.contains("Article"),
8932 "the row rendered without its title: {html}",
8933 );
8934 // **The row comes back carrying read state. That is all this proves.**
8935 //
8936 // It does NOT prove the state was persisted: the handler renders
8937 // `Some(read)` from the form value, so making `mark_read` roll back
8938 // instead of commit fails 11 store tests and leaves this one green.
8939 //
8940 // It does not prove the OVERRIDE either, which an earlier version of
8941 // this comment claimed. Verified: changing the call site to
8942 // `build_entry_row(pool, &did, id, None)` — deleting the override
8943 // wholesale — keeps the whole suite green, because `mark_read` has
8944 // already persisted the same value two lines earlier, so reading it back
8945 // from the database produces an identical row.
8946 //
8947 // Distinguishing the two needs a case where the override and the stored
8948 // state DISAGREE, which this handler never produces: it writes the value
8949 // it then renders. Left as a known gap rather than described as covered.
8950 assert!(
8951 html.contains("is-read"),
8952 "the row came back without the read state it was just given: {html}",
8953 );
8954 }
8955
8956 // -----------------------------------------------------------------------
8957 // Rename parity (POST /subscriptions/{rkey}/rename)
8958 // -----------------------------------------------------------------------
8959
8960 /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
8961 ///
8962 /// The add path gates the URL the user *typed*; the URL it *stores* is
8963 /// whatever `resolve_feed_url` returns, which for an HTML page is a
8964 /// publisher-controlled `<link rel="alternate">` href. Two layers stop
8965 /// that: `discover_feed` yields only http(s), and the add path re-checks
8966 /// storability on the resolved URL. This test pins the DISJUNCTION —
8967 /// each layer alone holds it, both removed fails it — driven through the
8968 /// real route against a real local server.
8969 ///
8970 /// **Why the fixture is `ftp://`, not `at://`.** This began as the
8971 /// at-URI bypass test from #164, and it was vacuous twice over. Handle
8972 /// form: once storage became DID-only the privacy classifier refused it
8973 /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
8974 /// — the colons in the DID), so `discover_feed` drops it before either
8975 /// layer exists. An at:// link cannot come out of autodiscovery under
8976 /// ANY mutation of the layers, so no test through this route can pin
8977 /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
8978 /// structure and pinned where it lives: `discover_skips_a_non_http_
8979 /// alternate` and the storability tests in `feed.rs`.
8980 #[tokio::test]
8981 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
8982 let did = "did:plc:autodiscovered";
8983 // Access granted, both caps disabled — the only gates left are the
8984 // two under test.
8985 let state = test_state_with_caps(did, 0, 0).await;
8986
8987 let page = r#"<!doctype html><html><head><title>Blog</title>
8988 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
8989 </head><body>hi</body></html>"#;
8990 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
8991 let port: u16 = base
8992 .trim_end_matches('/')
8993 .rsplit(':')
8994 .next()
8995 .unwrap()
8996 .parse()
8997 .unwrap();
8998 crate::net::test_host_override(
8999 "autodiscover-ftp.test",
9000 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9001 );
9002
9003 let cookie = session_cookie(&state, did, None);
9004 let resp = router(state.clone())
9005 .oneshot(
9006 Request::builder()
9007 .method("POST")
9008 .uri("/subscriptions")
9009 .header(header::COOKIE, cookie)
9010 .header("content-type", "application/x-www-form-urlencoded")
9011 .body(Body::from(format!(
9012 "url=http://autodiscover-ftp.test:{port}/"
9013 )))
9014 .unwrap(),
9015 )
9016 .await
9017 .unwrap();
9018 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9019 let loc = resp
9020 .headers()
9021 .get(header::LOCATION)
9022 .unwrap()
9023 .to_str()
9024 .unwrap();
9025 assert_ne!(loc, "/login", "the test never reached the add path");
9026 assert_ne!(loc, "/", "the subscribe succeeded");
9027
9028 assert_eq!(
9029 store::count_feeds(&state.db).await.unwrap(),
9030 0,
9031 "a non-http(s) URL from autodiscovery was stored"
9032 );
9033 assert_eq!(
9034 store::count_subscriptions_for_did(&state.db, did)
9035 .await
9036 .unwrap(),
9037 0
9038 );
9039 }
9040
9041 /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
9042 /// its global ceiling must be refused (capacity flash) and must NOT insert a
9043 /// new `feeds` row — parity with add_subscription's global-cap guard, so a
9044 /// rename loop can't inflate the shared cache past the cap.
9045 #[tokio::test]
9046 async fn rename_to_new_url_refused_at_global_feeds_cap() {
9047 let did = "did:plc:renamer4";
9048 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9049 // Global cap 1; pre-fill it with one feed so headroom is 0.
9050 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9051 store::upsert_feed(
9052 &state.db,
9053 &store::NewFeed {
9054 url: "https://existing.example/feed.xml".to_string(),
9055 ..Default::default()
9056 },
9057 )
9058 .await
9059 .unwrap();
9060 let before = store::count_feeds(&state.db).await.unwrap();
9061 assert_eq!(before, 1);
9062
9063 let cookie = session_cookie(&state, did, None);
9064 let resp = router(state.clone())
9065 .oneshot(
9066 Request::builder()
9067 .method("POST")
9068 .uri("/subscriptions/rk-keep/rename")
9069 .header(header::COOKIE, cookie)
9070 .header("content-type", "application/x-www-form-urlencoded")
9071 // A URL not in the cache → would be a NEW feeds row.
9072 .body(Body::from(
9073 "url=https://brand-new.example/feed.xml&title=Renamed",
9074 ))
9075 .unwrap(),
9076 )
9077 .await
9078 .unwrap();
9079 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9080 let loc = resp
9081 .headers()
9082 .get(header::LOCATION)
9083 .unwrap()
9084 .to_str()
9085 .unwrap();
9086 assert!(
9087 loc.contains("feed%20capacity"),
9088 "expected the feed-capacity flash, got {loc}"
9089 );
9090 // No new feeds row was inserted, and nothing reached the PDS.
9091 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9092 assert!(
9093 puts.lock().unwrap().is_empty(),
9094 "a refused repoint reached the PDS"
9095 );
9096 }
9097
9098 /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
9099 /// global cap (only new URLs are gated) — the other half of the guard.
9100 ///
9101 /// On the sidecar fake, so "allowed" means the put actually happened: the
9102 /// earlier harness had no sidecar, and this passed on a "could not reach
9103 /// your PDS" flash that merely was not the capacity one.
9104 #[tokio::test]
9105 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9106 let did = "did:plc:renamer4";
9107 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9108 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9109 store::upsert_feed(
9110 &state.db,
9111 &store::NewFeed {
9112 url: "https://existing.example/feed.xml".to_string(),
9113 ..Default::default()
9114 },
9115 )
9116 .await
9117 .unwrap();
9118 let before = store::count_feeds(&state.db).await.unwrap();
9119
9120 let cookie = session_cookie(&state, did, None);
9121 let resp = router(state.clone())
9122 .oneshot(
9123 Request::builder()
9124 .method("POST")
9125 .uri("/subscriptions/rk-keep/rename")
9126 .header(header::COOKIE, cookie)
9127 .header("content-type", "application/x-www-form-urlencoded")
9128 .body(Body::from(
9129 "url=https://existing.example/feed.xml&title=Retitled",
9130 ))
9131 .unwrap(),
9132 )
9133 .await
9134 .unwrap();
9135 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9136 let loc = resp
9137 .headers()
9138 .get(header::LOCATION)
9139 .unwrap()
9140 .to_str()
9141 .unwrap();
9142 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9143 assert_eq!(
9144 puts.lock().unwrap().len(),
9145 1,
9146 "the repoint did not reach the PDS"
9147 );
9148 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9149 }
9150
9151 /// A rename with a blank URL writes nothing anywhere.
9152 #[tokio::test]
9153 async fn rename_with_blank_url_writes_nothing() {
9154 let did = "did:plc:renamer3";
9155 let state = test_state_with_caps(did, 0, 0).await;
9156 let before = store::count_feeds(&state.db).await.unwrap();
9157 assert_eq!(before, 0);
9158
9159 let cookie = session_cookie(&state, did, None);
9160 let app = router(state.clone());
9161 let resp = app
9162 .oneshot(
9163 Request::builder()
9164 .method("POST")
9165 .uri("/subscriptions/rkey123/rename")
9166 .header(header::COOKIE, cookie)
9167 .header("content-type", "application/x-www-form-urlencoded")
9168 // Whitespace-only URL trims to empty.
9169 .body(Body::from("url=%20%20&title=Nope"))
9170 .unwrap(),
9171 )
9172 .await
9173 .unwrap();
9174 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9175 assert_eq!(
9176 resp.headers()
9177 .get(header::LOCATION)
9178 .unwrap()
9179 .to_str()
9180 .unwrap(),
9181 "/",
9182 );
9183 // Nothing was cached.
9184 assert_eq!(
9185 store::count_feeds(&state.db).await.unwrap(),
9186 0,
9187 "blank-URL rename wrote a junk feeds row"
9188 );
9189 }
9190
9191 /// A sidecar mock that serves ONE existing subscription record and captures
9192 /// every `put` body a rename produces.
9193 ///
9194 /// **Reads to `content-length` rather than taking one `read`.** A single
9195 /// read gets whatever one segment carried; if the head and body land
9196 /// separately the capture holds no record and every field assertion below
9197 /// passes for the wrong reason. Each captured body must also mention the
9198 /// collection, so an empty capture fails loudly instead of quietly.
9199 async fn spawn_rename_sidecar(
9200 existing: serde_json::Value,
9201 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9202 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9203 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9204 let addr = listener.local_addr().unwrap();
9205 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9206 let sink = puts.clone();
9207 tokio::spawn(async move {
9208 loop {
9209 let Ok((mut sock, _)) = listener.accept().await else {
9210 break;
9211 };
9212 let mut raw: Vec<u8> = Vec::new();
9213 let mut chunk = [0u8; 4096];
9214 let body_text = loop {
9215 let Ok(n) = sock.read(&mut chunk).await else {
9216 break String::new();
9217 };
9218 if n == 0 {
9219 break String::from_utf8_lossy(&raw).to_string();
9220 }
9221 raw.extend_from_slice(&chunk[..n]);
9222 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9223 continue;
9224 };
9225 let (head, body) = raw.split_at(split + 4);
9226 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9227 let (k, v) = l.split_once(':')?;
9228 k.eq_ignore_ascii_case("content-length")
9229 .then(|| v.trim().parse::<usize>().ok())?
9230 });
9231 if want.is_none_or(|want| body.len() >= want) {
9232 break String::from_utf8_lossy(body).to_string();
9233 }
9234 };
9235
9236 // `"action":"put"` is the rename write; anything else is the read.
9237 let is_put = body_text.contains("\"action\":\"put\"");
9238 let data = if is_put {
9239 sink.lock().unwrap().push(body_text.clone());
9240 serde_json::json!({
9241 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9242 "cid": "bafyreiafter"
9243 })
9244 } else {
9245 serde_json::json!({ "records": [existing.clone()] })
9246 };
9247 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9248 let resp = format!(
9249 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9250 body.len(),
9251 body
9252 );
9253 let _ = sock.write_all(resp.as_bytes()).await;
9254 let _ = sock.flush().await;
9255 }
9256 });
9257 (format!("http://{addr}"), puts)
9258 }
9259
9260 /// The existing record a rename must not destroy.
9261 fn seeded_subscription() -> serde_json::Value {
9262 serde_json::json!({
9263 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9264 "cid": "bafyreibefore",
9265 "value": {
9266 "$type": "community.lexicon.rss.subscription",
9267 "url": "https://example.com/feed.xml",
9268 "title": "Old title",
9269 "siteUrl": "https://example.com/blog",
9270 "fetchHint": "hourly",
9271 "private": false,
9272 "createdAt": "2024-03-01T00:00:00.000Z"
9273 }
9274 })
9275 }
9276
9277 /// An existing standard.site subscription, as the 19 in production are:
9278 /// written before this reader refused the scheme, still in the repo.
9279 fn seeded_at_uri_subscription() -> serde_json::Value {
9280 seeded_subscription_with_url(AT_URI_SUB)
9281 }
9282 /// An existing subscription record at `rk-keep` with the given URL.
9283 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9284 serde_json::json!({
9285 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9286 "cid": "bafyreibefore",
9287 "value": {
9288 "$type": "community.lexicon.rss.subscription",
9289 "url": url,
9290 "title": "Old title",
9291 "private": false,
9292 "createdAt": "2024-03-01T00:00:00.000Z"
9293 }
9294 })
9295 }
9296 const AT_URI_SUB: &str =
9297 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9298 const AT_URI_SUB_ENC: &str =
9299 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9300
9301 /// **Retitling an existing `at://` subscription must work with the flag off.**
9302 ///
9303 /// The storability guard was placed before the repo lookup, so it refused
9304 /// any rename whose URL is an at-URI — including a pure title or folder
9305 /// change on a record that already exists. On main that rename succeeded;
9306 /// the 19 production records would have become un-editable. The flag gates
9307 /// what may be STORED in the cache, not whether a reader may edit their own
9308 /// record: the PDS write goes through, the cache row is simply not created.
9309 #[tokio::test]
9310 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9311 let did = "did:plc:renamer5";
9312 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9313 let state = test_state_with_sidecar(&[did], &sidecar).await;
9314 assert!(
9315 !state.config.standard_site,
9316 "the flag must be off for this test"
9317 );
9318 let cookie = session_cookie(&state, did, None);
9319 let resp = router(state.clone())
9320 .oneshot(
9321 Request::builder()
9322 .method("POST")
9323 .uri("/subscriptions/rk-keep/rename")
9324 .header(header::COOKIE, cookie)
9325 .header("content-type", "application/x-www-form-urlencoded")
9326 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9327 .unwrap(),
9328 )
9329 .await
9330 .unwrap();
9331 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9332 let loc = resp
9333 .headers()
9334 .get(header::LOCATION)
9335 .unwrap()
9336 .to_str()
9337 .unwrap();
9338 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9339
9340 let bodies = puts.lock().unwrap().clone();
9341 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9342 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9343 assert_eq!(
9344 sent["record"]["title"], "New title",
9345 "the rename did not apply"
9346 );
9347 assert_eq!(
9348 sent["record"]["url"], AT_URI_SUB,
9349 "the rename changed the URL"
9350 );
9351
9352 // The flag still means what it says for the CACHE: no at:// row.
9353 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9354 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9355 }
9356
9357 /// **Repointing a subscription AT an `at://` URI is still refused with the
9358 /// flag off** — the half of the guard that has to survive the fix above.
9359 /// Nothing reaches the PDS and nothing reaches the cache.
9360 #[tokio::test]
9361 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9362 let did = "did:plc:renamer4";
9363 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9364 let state = test_state_with_sidecar(&[did], &sidecar).await;
9365 let cookie = session_cookie(&state, did, None);
9366 let resp = router(state.clone())
9367 .oneshot(
9368 Request::builder()
9369 .method("POST")
9370 .uri("/subscriptions/rk-keep/rename")
9371 .header(header::COOKIE, cookie)
9372 .header("content-type", "application/x-www-form-urlencoded")
9373 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9374 .unwrap(),
9375 )
9376 .await
9377 .unwrap();
9378 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9379 let loc = resp
9380 .headers()
9381 .get(header::LOCATION)
9382 .unwrap()
9383 .to_str()
9384 .unwrap();
9385 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9386 assert!(
9387 !loc.contains("Private"),
9388 "a storability refusal was reported as a privacy one: {loc}"
9389 );
9390 assert!(
9391 puts.lock().unwrap().is_empty(),
9392 "the repoint reached the PDS"
9393 );
9394 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9395 assert_eq!(cached, 0);
9396 }
9397
9398 /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
9399 /// redirect location.
9400 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9401 let cookie = session_cookie(state, did, None);
9402 let resp = router(state.clone())
9403 .oneshot(
9404 Request::builder()
9405 .method("POST")
9406 .uri("/subscriptions/rk-keep/rename")
9407 .header(header::COOKIE, cookie)
9408 .header("content-type", "application/x-www-form-urlencoded")
9409 .body(Body::from(format!("url={url_enc}&title=New+title")))
9410 .unwrap(),
9411 )
9412 .await
9413 .unwrap();
9414 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9415 resp.headers()
9416 .get(header::LOCATION)
9417 .unwrap()
9418 .to_str()
9419 .unwrap()
9420 .to_string()
9421 }
9422
9423 /// **The privacy gate has the same ordering bug the storable gate had.**
9424 ///
9425 /// Another client can write a subscription whose URL is an at-URI that is
9426 /// not a well-formed publication URI at all — a feed generator, say. On
9427 /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
9428 /// the classifier reads as `Public`). The narrowed at:// arm now fails
9429 /// closed as `Private` for it, and the gate ran before `url_changed` was
9430 /// known — so the record became un-editable, with a flash claiming it "was
9431 /// not saved or sent anywhere". Both gates now apply to a repoint only.
9432 #[tokio::test]
9433 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9434 let did = "did:plc:renamer5";
9435 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9436 let other_enc =
9437 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9438 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9439 let state = test_state_with_sidecar(&[did], &sidecar).await;
9440 let loc = retitle_unchanged(&state, did, other_enc).await;
9441 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9442 let bodies = puts.lock().unwrap().clone();
9443 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9444 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9445 assert_eq!(sent["record"]["title"], "New title");
9446 assert_eq!(sent["record"]["url"], other);
9447 }
9448
9449 /// **A repoint to a secret-bearing URL is still refused** — the half of
9450 /// the privacy gate that has to survive moving it behind `url_changed`.
9451 /// Found by mutation: with the gate deleted outright, nothing failed.
9452 #[tokio::test]
9453 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9454 let did = "did:plc:renamer4";
9455 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9456 let state = test_state_with_sidecar(&[did], &sidecar).await;
9457 let cookie = session_cookie(&state, did, None);
9458 let resp = router(state.clone())
9459 .oneshot(
9460 Request::builder()
9461 .method("POST")
9462 .uri("/subscriptions/rk-keep/rename")
9463 .header(header::COOKIE, cookie)
9464 .header("content-type", "application/x-www-form-urlencoded")
9465 .body(Body::from(
9466 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9467 ))
9468 .unwrap(),
9469 )
9470 .await
9471 .unwrap();
9472 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9473 let loc = resp
9474 .headers()
9475 .get(header::LOCATION)
9476 .unwrap()
9477 .to_str()
9478 .unwrap();
9479 assert!(
9480 loc.contains("Private"),
9481 "the private repoint was not refused: {loc}"
9482 );
9483 assert!(
9484 puts.lock().unwrap().is_empty(),
9485 "a secret-bearing URL reached the PDS"
9486 );
9487 // The repo's fixture token: opaque enough for the classifier, not a real
9488 // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
9489 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9490 assert!(store::get_feed_by_url(&state.db, leaked)
9491 .await
9492 .unwrap()
9493 .is_none());
9494 }
9495
9496 /// **A retitle of a never-cached at:// subscription is not "at feed
9497 /// capacity".** The global-ceiling check keyed on "URL not in the cache",
9498 /// and an at:// record is never cached with the flag off — so at capacity,
9499 /// a pure retitle was refused for a row the handler would not insert. The
9500 /// check now runs once `url_changed` is known and only for a repoint.
9501 #[tokio::test]
9502 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9503 let did = "did:plc:renamer5";
9504 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9505 // Ceiling 1, and one real feed already fills it.
9506 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9507 store::upsert_feed(
9508 &state.db,
9509 &store::NewFeed {
9510 url: "https://filler.example/feed.xml".to_string(),
9511 ..Default::default()
9512 },
9513 )
9514 .await
9515 .unwrap();
9516 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9517 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9518 assert_eq!(
9519 puts.lock().unwrap().len(),
9520 1,
9521 "the retitle did not reach the PDS"
9522 );
9523 assert_eq!(
9524 store::count_feeds(&state.db).await.unwrap(),
9525 1,
9526 "a row was inserted"
9527 );
9528 }
9529
9530 /// **With the flag ON, a well-formed at:// paste is still refused as
9531 /// unsupported** — not "Couldn't find a feed" plus a `warn!`. Nothing can
9532 /// fetch `at://` until the reader is wired, whatever the flag says, and the
9533 /// docs promise this answer "with the flag on or off". This is also the
9534 /// suite's first state with the flag on: every other site passes the flag
9535 /// through with `false`, where a literal `false` would be indistinguishable.
9536 #[tokio::test]
9537 async fn a_well_formed_at_uri_paste_is_refused_as_unsupported_with_the_flag_on() {
9538 let did = "did:plc:renamer5";
9539 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9540 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9541 let cookie = session_cookie(&state, did, None);
9542 let resp = router(state.clone())
9543 .oneshot(
9544 Request::builder()
9545 .method("POST")
9546 .uri("/subscriptions")
9547 .header(header::COOKIE, cookie)
9548 .header("content-type", "application/x-www-form-urlencoded")
9549 .body(Body::from(format!("url={AT_URI_SUB_ENC}")))
9550 .unwrap(),
9551 )
9552 .await
9553 .unwrap();
9554 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9555 let loc = resp
9556 .headers()
9557 .get(header::LOCATION)
9558 .unwrap()
9559 .to_str()
9560 .unwrap();
9561 assert!(
9562 loc.contains("kind%20of%20feed"),
9563 "expected the unsupported flash: {loc}"
9564 );
9565 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9566 }
9567
9568 /// **With the flag ON, an OPML at:// entry is stored.** The one storage
9569 /// path that is meant to work today, asserted with the flag actually on.
9570 #[tokio::test]
9571 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
9572 let did = "did:plc:renamer5";
9573 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9574 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9575 let opml = format!(
9576 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9577 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9578 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9579 </body></opml>"
9580 );
9581 let (ct, body) = opml_multipart(opml.as_bytes());
9582 let cookie = session_cookie(&state, did, None);
9583 let resp = router(state.clone())
9584 .oneshot(
9585 Request::builder()
9586 .method("POST")
9587 .uri("/opml")
9588 .header(header::COOKIE, cookie)
9589 .header("content-type", ct)
9590 .body(Body::from(body))
9591 .unwrap(),
9592 )
9593 .await
9594 .unwrap();
9595 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9596 let loc = resp
9597 .headers()
9598 .get(header::LOCATION)
9599 .unwrap()
9600 .to_str()
9601 .unwrap();
9602 assert!(
9603 loc.contains("Imported%202%20feeds"),
9604 "unexpected flash: {loc}"
9605 );
9606 assert!(
9607 !loc.contains("skipped"),
9608 "the at:// entry was skipped with the flag on: {loc}"
9609 );
9610 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
9611 assert!(
9612 stored.is_some(),
9613 "the at:// entry was not stored with the flag on"
9614 );
9615 }
9616
9617 /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
9618 /// gate behind `url_changed` was right for the PDS write — the record is
9619 /// the reader's — but the cache write was gated only on `storable`, which
9620 /// any http(s) URL is. So a retitle of a record another client wrote with
9621 /// a tokened feed URL inserted that URL into the shared `feeds` table,
9622 /// where the poller would fail it every cycle and print it on the admin
9623 /// page. main refused the whole rename; this keeps the record editable and
9624 /// the cache clean, as `resolve_subscriptions` already does for the same
9625 /// record.
9626 #[tokio::test]
9627 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
9628 let did = "did:plc:renamer5";
9629 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
9630 let tokened_enc =
9631 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
9632 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
9633 let state = test_state_with_sidecar(&[did], &sidecar).await;
9634 let loc = retitle_unchanged(&state, did, tokened_enc).await;
9635 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9636 assert_eq!(
9637 puts.lock().unwrap().len(),
9638 1,
9639 "the retitle did not reach the PDS"
9640 );
9641 assert!(
9642 store::get_feed_by_url(&state.db, tokened)
9643 .await
9644 .unwrap()
9645 .is_none(),
9646 "a secret-bearing URL was written to the shared cache by a retitle"
9647 );
9648 }
9649
9650 /// **On a repoint, storability is decided before privacy and capacity** —
9651 /// the same ordering the add path got. A malformed at:// target drew the
9652 /// private/paid flash, and at capacity a well-formed one drew "try again
9653 /// later" for a URL that can never be accepted with the flag off.
9654 #[tokio::test]
9655 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
9656 let did = "did:plc:renamer4";
9657 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9658 let state = test_state_with_sidecar(&[did], &sidecar).await;
9659 let cookie = session_cookie(&state, did, None);
9660 let resp = router(state.clone())
9661 .oneshot(
9662 Request::builder()
9663 .method("POST")
9664 .uri("/subscriptions/rk-keep/rename")
9665 .header(header::COOKIE, cookie)
9666 .header("content-type", "application/x-www-form-urlencoded")
9667 .body(Body::from(
9668 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
9669 ))
9670 .unwrap(),
9671 )
9672 .await
9673 .unwrap();
9674 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9675 let loc = resp
9676 .headers()
9677 .get(header::LOCATION)
9678 .unwrap()
9679 .to_str()
9680 .unwrap();
9681 assert!(
9682 loc.contains("kind%20of%20feed"),
9683 "expected the unsupported flash: {loc}"
9684 );
9685 assert!(
9686 !loc.contains("Private"),
9687 "a typo was reported as a paid feed: {loc}"
9688 );
9689 assert!(puts.lock().unwrap().is_empty());
9690 }
9691
9692 #[tokio::test]
9693 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
9694 let did = "did:plc:renamer4";
9695 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9696 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9697 store::upsert_feed(
9698 &state.db,
9699 &store::NewFeed {
9700 url: "https://filler.example/feed.xml".to_string(),
9701 ..Default::default()
9702 },
9703 )
9704 .await
9705 .unwrap();
9706 let cookie = session_cookie(&state, did, None);
9707 let resp = router(state.clone())
9708 .oneshot(
9709 Request::builder()
9710 .method("POST")
9711 .uri("/subscriptions/rk-keep/rename")
9712 .header(header::COOKIE, cookie)
9713 .header("content-type", "application/x-www-form-urlencoded")
9714 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9715 .unwrap(),
9716 )
9717 .await
9718 .unwrap();
9719 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9720 let loc = resp
9721 .headers()
9722 .get(header::LOCATION)
9723 .unwrap()
9724 .to_str()
9725 .unwrap();
9726 assert!(
9727 loc.contains("kind%20of%20feed"),
9728 "expected the unsupported flash: {loc}"
9729 );
9730 assert!(
9731 !loc.contains("capacity"),
9732 "an unacceptable URL was reported as a capacity problem: {loc}"
9733 );
9734 assert!(puts.lock().unwrap().is_empty());
9735 }
9736
9737 /// **`url_changed` compares like for like.** The form value is trimmed;
9738 /// the record's URL was compared raw, so a record another client wrote
9739 /// with a trailing space read as a repoint on every retitle and re-armed
9740 /// every gate — including the one that made an at:// record un-editable.
9741 #[tokio::test]
9742 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
9743 let did = "did:plc:renamer5";
9744 let padded = format!("{AT_URI_SUB} ");
9745 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
9746 let state = test_state_with_sidecar(&[did], &sidecar).await;
9747 // The manage row posts the record's URL verbatim, padding included.
9748 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
9749 assert_eq!(
9750 loc, "/",
9751 "the retitle was treated as a repoint and refused: {loc}"
9752 );
9753 let bodies = puts.lock().unwrap().clone();
9754 assert_eq!(bodies.len(), 1);
9755 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9756 assert_eq!(
9757 sent["record"]["url"], AT_URI_SUB,
9758 "the padding was not normalised away"
9759 );
9760 }
9761
9762 /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
9763 /// only, so the trailing upsert must not create a row for an unchanged URL
9764 /// that has none — with the flag on and the cache full, each retitle of a
9765 /// never-cached at:// record was a row past the cap. An existing row still
9766 /// gets its title kept in step.
9767 #[tokio::test]
9768 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
9769 let did = "did:plc:renamer5";
9770 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9771 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
9772 store::upsert_feed(
9773 &state.db,
9774 &store::NewFeed {
9775 url: "https://filler.example/feed.xml".to_string(),
9776 ..Default::default()
9777 },
9778 )
9779 .await
9780 .unwrap();
9781 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9782 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9783 assert_eq!(puts.lock().unwrap().len(), 1);
9784 assert_eq!(
9785 store::count_feeds(&state.db).await.unwrap(),
9786 1,
9787 "a retitle inserted a cache row past the ceiling"
9788 );
9789 }
9790
9791 /// **The add path's at:// pre-check is about the MESSAGE, so it is
9792 /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
9793 /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
9794 /// tripped the secret heuristic on the rkey — the private/paid flash the
9795 /// pre-check exists to avoid. Storage stays case-sensitive; this does not
9796 /// touch it.
9797 #[tokio::test]
9798 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
9799 let did = "did:plc:typoist";
9800 let state = test_state_with_caps(did, 0, 0).await;
9801 let cookie = session_cookie(&state, did, None);
9802 let resp = router(state.clone())
9803 .oneshot(
9804 Request::builder()
9805 .method("POST")
9806 .uri("/subscriptions")
9807 .header(header::COOKIE, cookie)
9808 .header("content-type", "application/x-www-form-urlencoded")
9809 .body(Body::from(
9810 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9811 ))
9812 .unwrap(),
9813 )
9814 .await
9815 .unwrap();
9816 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9817 let loc = resp
9818 .headers()
9819 .get(header::LOCATION)
9820 .unwrap()
9821 .to_str()
9822 .unwrap();
9823 assert!(
9824 loc.contains("kind%20of%20feed"),
9825 "expected the unsupported flash: {loc}"
9826 );
9827 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
9828 }
9829
9830 /// **A rename must not destroy the fields the form never carries.**
9831 ///
9832 /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
9833 /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
9834 /// every field absent from `templates/manage_row.html` (which posts only
9835 /// `url`, `title`, `folder`) was written back as its default:
9836 ///
9837 /// | field | before | after |
9838 /// |---|---|---|
9839 /// | `siteUrl` | whatever the feed advertised | gone |
9840 /// | `fetchHint` | as set | gone |
9841 /// | `private` | as set | gone |
9842 /// | `createdAt` | original subscribe time | reset to now |
9843 ///
9844 /// `createdAt` is the worst of the four: it is the sort key for "when did I
9845 /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
9846 /// tells the reader it moved.
9847 ///
9848 /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
9849 /// in the test — the record only becomes wrong on the way out, so checking
9850 /// the value we passed in would pass just as happily with the fix removed.
9851 #[tokio::test]
9852 async fn renaming_preserves_the_fields_the_form_never_carries() {
9853 let did = "did:plc:renamer4";
9854 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9855 let state = test_state_with_sidecar(&[did], &sidecar).await;
9856 let cookie = session_cookie(&state, did, None);
9857
9858 let resp = router(state.clone())
9859 .oneshot(
9860 Request::builder()
9861 .method("POST")
9862 .uri("/subscriptions/rk-keep/rename")
9863 .header(header::COOKIE, cookie)
9864 .header("content-type", "application/x-www-form-urlencoded")
9865 // Exactly what the manage row posts: url, title, folder.
9866 .body(Body::from(
9867 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
9868 ))
9869 .unwrap(),
9870 )
9871 .await
9872 .unwrap();
9873 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9874
9875 let bodies = puts.lock().unwrap().clone();
9876 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9877 let body = &bodies[0];
9878 // Anchors the negative assertions: an empty capture would satisfy them.
9879 assert!(
9880 body.contains("community.lexicon.rss.subscription"),
9881 "captured no usable put body: {body:?}"
9882 );
9883
9884 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
9885 let record = &sent["record"];
9886
9887 // What the form DID carry must be applied.
9888 assert_eq!(record["title"], "New title", "the rename did not apply");
9889 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
9890
9891 // What the form did NOT carry must survive.
9892 assert_eq!(
9893 record["createdAt"], "2024-03-01T00:00:00.000Z",
9894 "the rename reset createdAt — the reader's subscribe time is gone \
9895 from their own repo, and nothing told them"
9896 );
9897 assert_eq!(
9898 record["siteUrl"], "https://example.com/blog",
9899 "the rename erased siteUrl"
9900 );
9901 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
9902 assert_eq!(record["private"], false, "the rename erased private");
9903 }
9904
9905 /// **Repointing at a different feed drops that feed's properties, but not
9906 /// the subscription's.**
9907 ///
9908 /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
9909 /// so carrying them onto a different URL would leave a site link for the old
9910 /// feed hanging off the new one. `createdAt` and `private` are properties of
9911 /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
9912 /// subscribed, whatever the URL was later corrected to.
9913 #[tokio::test]
9914 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
9915 let did = "did:plc:renamer4";
9916 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9917 let state = test_state_with_sidecar(&[did], &sidecar).await;
9918 let cookie = session_cookie(&state, did, None);
9919
9920 let resp = router(state.clone())
9921 .oneshot(
9922 Request::builder()
9923 .method("POST")
9924 .uri("/subscriptions/rk-keep/rename")
9925 .header(header::COOKIE, cookie)
9926 .header("content-type", "application/x-www-form-urlencoded")
9927 // A DIFFERENT feed URL from the seeded record.
9928 .body(Body::from(
9929 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
9930 ))
9931 .unwrap(),
9932 )
9933 .await
9934 .unwrap();
9935 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9936
9937 let bodies = puts.lock().unwrap().clone();
9938 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9939 assert!(
9940 bodies[0].contains("community.lexicon.rss.subscription"),
9941 "captured no usable put body: {:?}",
9942 bodies[0]
9943 );
9944 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9945 let record = &sent["record"];
9946
9947 assert_eq!(record["url"], "https://other.example/feed.xml");
9948 // The old feed's properties are gone rather than misattributed.
9949 assert!(
9950 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
9951 "the old feed's site link followed the subscription to a new feed: {record}"
9952 );
9953 assert!(
9954 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
9955 "the old feed's fetch hint followed the subscription to a new feed: {record}"
9956 );
9957 // The subscription's own properties survive.
9958 assert_eq!(
9959 record["createdAt"], "2024-03-01T00:00:00.000Z",
9960 "a repoint is still not a new subscription; createdAt must not move"
9961 );
9962 assert_eq!(record["private"], false, "the repoint erased private");
9963 }
9964
9965 /// **A rename against an rkey that is not in the repo writes NOTHING.**
9966 ///
9967 /// `update_subscription` is a `putRecord`, which CREATES the record when the
9968 /// rkey does not exist — with whatever `createdAt` we hand it. So without
9969 /// this refusal a rename against a stale or wrong rkey manufactures a
9970 /// subscription dated today, which is the bug this whole change exists to
9971 /// fix, arriving by a different door.
9972 ///
9973 /// The guard was untested when first written: removing it left all 733 tests
9974 /// green. An untested guard against the exact defect being fixed is how the
9975 /// two previous rounds of this problem got through.
9976 #[tokio::test]
9977 async fn renaming_an_unknown_rkey_writes_nothing() {
9978 let did = "did:plc:renamer4";
9979 // The sidecar serves exactly one record, at rkey `rk-keep`.
9980 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9981 let state = test_state_with_sidecar(&[did], &sidecar).await;
9982 let cookie = session_cookie(&state, did, None);
9983
9984 let resp = router(state.clone())
9985 .oneshot(
9986 Request::builder()
9987 .method("POST")
9988 // ...and this is not it.
9989 .uri("/subscriptions/rk-does-not-exist/rename")
9990 .header(header::COOKIE, cookie)
9991 .header("content-type", "application/x-www-form-urlencoded")
9992 .body(Body::from(
9993 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
9994 ))
9995 .unwrap(),
9996 )
9997 .await
9998 .unwrap();
9999
10000 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10001 let loc = resp
10002 .headers()
10003 .get(header::LOCATION)
10004 .unwrap()
10005 .to_str()
10006 .unwrap();
10007 assert!(
10008 loc.contains("flash="),
10009 "an unknown rkey redirected as though the rename had worked: {loc}"
10010 );
10011 assert!(
10012 puts.lock().unwrap().is_empty(),
10013 "a rename against an unknown rkey wrote a record — putRecord would \
10014 CREATE it, dated today: {:?}",
10015 puts.lock().unwrap()
10016 );
10017 }
10018
10019 /// **A `site_url` the client actually sends is applied, not dropped.**
10020 ///
10021 /// `templates/manage_row.html` does not post this field, so it is tempting
10022 /// to read the arm that handles it as dead code. It is not:
10023 /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
10024 /// today. Discarding the value instead of applying it left all 733 tests
10025 /// green.
10026 ///
10027 /// The value is scheme-checked on the way out by the repo-boundary vet, so
10028 /// this is a coverage gap rather than an exposure — but an untested path
10029 /// that writes a URL into the reader's PDS should not stay untested.
10030 #[tokio::test]
10031 async fn a_client_supplied_site_url_reaches_the_record() {
10032 let did = "did:plc:renamer4";
10033 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10034 let state = test_state_with_sidecar(&[did], &sidecar).await;
10035 let cookie = session_cookie(&state, did, None);
10036
10037 let resp = router(state.clone())
10038 .oneshot(
10039 Request::builder()
10040 .method("POST")
10041 .uri("/subscriptions/rk-keep/rename")
10042 .header(header::COOKIE, cookie)
10043 .header("content-type", "application/x-www-form-urlencoded")
10044 // Same feed URL, but carrying a site_url the manage row
10045 // never sends.
10046 .body(Body::from(
10047 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10048 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10049 ))
10050 .unwrap(),
10051 )
10052 .await
10053 .unwrap();
10054 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10055
10056 let bodies = puts.lock().unwrap().clone();
10057 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10058 assert!(
10059 bodies[0].contains("community.lexicon.rss.subscription"),
10060 "captured no usable put body: {:?}",
10061 bodies[0]
10062 );
10063 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10064 assert_eq!(
10065 sent["record"]["siteUrl"], "https://typed.example/site",
10066 "the client's siteUrl was dropped; the seeded record's survived instead"
10067 );
10068 }
10069
10070 /// **A rename whose read fails writes NOTHING.**
10071 ///
10072 /// This is the property most easily lost when someone later touches this
10073 /// handler: falling back to `Subscription::new` on a read error looks like
10074 /// graceful degradation and is in fact the original bug, reinstated on
10075 /// exactly the path where it is hardest to notice. The reader must be told
10076 /// instead.
10077 #[tokio::test]
10078 async fn a_rename_whose_read_fails_writes_nothing() {
10079 let did = "did:plc:renamer5";
10080 // A port that accepts nothing: the read cannot succeed.
10081 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10082 let dead = format!("http://{}", listener.local_addr().unwrap());
10083 drop(listener);
10084
10085 let state = test_state_with_sidecar(&[did], &dead).await;
10086 let cookie = session_cookie(&state, did, None);
10087 let before = store::count_feeds(&state.db).await.unwrap();
10088
10089 let resp = router(state.clone())
10090 .oneshot(
10091 Request::builder()
10092 .method("POST")
10093 .uri("/subscriptions/rk-keep/rename")
10094 .header(header::COOKIE, cookie)
10095 .header("content-type", "application/x-www-form-urlencoded")
10096 .body(Body::from(
10097 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10098 ))
10099 .unwrap(),
10100 )
10101 .await
10102 .unwrap();
10103
10104 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10105 let loc = resp
10106 .headers()
10107 .get(header::LOCATION)
10108 .unwrap()
10109 .to_str()
10110 .unwrap();
10111 assert!(
10112 loc.contains("flash="),
10113 "a failed read redirected as though the rename had worked: {loc}"
10114 );
10115 assert_eq!(
10116 store::count_feeds(&state.db).await.unwrap(),
10117 before,
10118 "a rename that could not read the record still wrote to the cache"
10119 );
10120 }
10121
10122 /// Folder pre-selection regression: the manage rename row must mark the
10123 /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
10124 /// re-submits the current folder instead of silently un-foldering the feed.
10125 /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
10126 /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
10127 #[test]
10128 fn manage_rename_row_preselects_current_folder() {
10129 let nav = Nav {
10130 handle: "@reader.example".to_string(),
10131 avatar: "RE".to_string(),
10132 view: "unread".to_string(),
10133 scope_qs: String::new(),
10134 folders: Vec::new(),
10135 loose_feeds: Vec::new(),
10136 manage_active: true,
10137 };
10138 let folder_options = vec![
10139 FolderOption {
10140 uri: "at://did:plc:x/app.folder/work".to_string(),
10141 name: "Work".to_string(),
10142 },
10143 FolderOption {
10144 uri: "at://did:plc:x/app.folder/fun".to_string(),
10145 name: "Fun".to_string(),
10146 },
10147 ];
10148 // A foldered feed (in "Work") and a loose feed (no folder), each with a
10149 // non-empty rkey so the rename form renders.
10150 let foldered = FeedView {
10151 rkey: "sub-foldered".to_string(),
10152 url: "https://work.example/feed.xml".to_string(),
10153 title: "Work Feed".to_string(),
10154 unread: 0,
10155 selected: false,
10156 folder: Some("at://did:plc:x/app.folder/work".to_string()),
10157 };
10158 let loose = FeedView {
10159 rkey: "sub-loose".to_string(),
10160 url: "https://loose.example/feed.xml".to_string(),
10161 title: "Loose Feed".to_string(),
10162 unread: 0,
10163 selected: false,
10164 folder: None,
10165 };
10166 let tmpl = ManageTemplate {
10167 version: VERSION,
10168 repo_url: REPO_URL,
10169 kofi_url: KOFI_URL,
10170 flash: String::new(),
10171 nav,
10172 folder_options,
10173 folders: vec![FolderView {
10174 rkey: "folder-work".to_string(),
10175 uri: "at://did:plc:x/app.folder/work".to_string(),
10176 name: "Work".to_string(),
10177 feeds: vec![foldered],
10178 selected: false,
10179 }],
10180 loose_feeds: vec![loose],
10181 };
10182 let html = tmpl.render().unwrap();
10183
10184 // The foldered feed's "Work" option is pre-selected.
10185 assert!(
10186 html.contains(
10187 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10188 ),
10189 "foldered feed must pre-select its current folder: {html}"
10190 );
10191 // The loose feed's "No folder" option is pre-selected (appears for the
10192 // loose row, which has folder=None).
10193 assert!(
10194 html.contains(r#"<option value="" selected>No folder</option>"#),
10195 "loose feed must pre-select 'No folder': {html}"
10196 );
10197 }
10198
10199 /// **The public stats page carries no user data.**
10200 ///
10201 /// It is reachable by anyone, so the thing worth pinning is what it does
10202 /// NOT say: nothing about how many people use the instance, nothing about
10203 /// which feeds fail, nothing about who reads what.
10204 #[tokio::test]
10205 async fn the_public_stats_page_exposes_no_user_data() {
10206 let state = test_state(&[]).await;
10207 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10208 .await
10209 .unwrap();
10210
10211 let resp = router(state)
10212 .oneshot(
10213 Request::builder()
10214 .uri("/stats")
10215 .body(Body::empty())
10216 .unwrap(),
10217 )
10218 .await
10219 .unwrap();
10220 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10221
10222 let body = String::from_utf8(
10223 axum::body::to_bytes(resp.into_body(), usize::MAX)
10224 .await
10225 .unwrap()
10226 .to_vec(),
10227 )
10228 .unwrap();
10229
10230 // Structural checks, not word checks. The page's own prose says it
10231 // publishes no error rates, so searching for that PHRASE finds the
10232 // disclaimer rather than a leak — the first version of this test failed
10233 // on exactly that. What matters is whether identifiers or the
10234 // admin-only figures are present.
10235 assert!(
10236 !body.contains("did:"),
10237 "the public stats page leaked an identifier"
10238 );
10239 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10240 assert!(
10241 !body.contains(admin_only),
10242 "the public page is showing the admin metrics column {admin_only:?}"
10243 );
10244 }
10245 // And it does render the aggregate it exists for.
10246 assert!(body.contains("Feeds tracked"));
10247 assert!(body.contains("Waiting to be polled"));
10248 }
10249
10250 /// **The two states that stop feeds updating must be visible.**
10251 ///
10252 /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
10253 /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
10254 /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
10255 /// the backlog and makes the page read healthier. That inversion is what this
10256 /// test pins: a broken feed must raise a number, not lower one.
10257 #[tokio::test]
10258 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10259 let state = test_state(&[]).await;
10260 // Three feeds: one healthy, one flaky, one long dead.
10261 for (url, errors) in [
10262 ("https://ok.example/f.xml", 0),
10263 ("https://flaky.example/f.xml", 2),
10264 ("https://dead.example/f.xml", 9),
10265 ] {
10266 store::upsert_feed(
10267 &state.db,
10268 &store::NewFeed {
10269 url: url.to_string(),
10270 // Pushed forward, exactly as backoff does — so none of these
10271 // are counted as `overdue`.
10272 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10273 ..Default::default()
10274 },
10275 )
10276 .await
10277 .unwrap();
10278 for _ in 0..errors {
10279 store::bump_feed_errors(
10280 &state.db,
10281 url,
10282 feed::FailureKind::Fetch,
10283 "connection refused",
10284 )
10285 .await
10286 .unwrap();
10287 }
10288 }
10289
10290 let render_stats = |state: AppState| async move {
10291 let resp = router(state)
10292 .oneshot(
10293 Request::builder()
10294 .uri("/stats")
10295 .body(Body::empty())
10296 .unwrap(),
10297 )
10298 .await
10299 .unwrap();
10300 assert_eq!(resp.status(), StatusCode::OK);
10301 String::from_utf8(
10302 axum::body::to_bytes(resp.into_body(), usize::MAX)
10303 .await
10304 .unwrap()
10305 .to_vec(),
10306 )
10307 .unwrap()
10308 };
10309
10310 // **The fixture must actually be RUNNING, or this test measures nothing.**
10311 // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
10312 // checks that BEFORE the watermark — so without these two lines every
10313 // render below reports "off" and the watermark can never surface. The
10314 // assertions still passed, for reasons unrelated to what they name: see
10315 // the two comments below.
10316 state.runtime_health.set_schedulers_enabled(true);
10317 state
10318 .runtime_health
10319 .poll_tick_completed(crate::store::now_unix());
10320
10321 let body = render_stats(state.clone()).await;
10322 assert!(
10323 body.contains("Failing"),
10324 "backoff is still invisible on the public page"
10325 );
10326 // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
10327 // value rather than on surrounding whitespace, so re-indenting the
10328 // template cannot break this.
10329 assert!(
10330 body.contains("2, 1 badly"),
10331 "expected '2, 1 badly' in the failing row; got:\n{}",
10332 body.split("Failing")
10333 .nth(1)
10334 .unwrap_or("")
10335 .chars()
10336 .take(300)
10337 .collect::<String>()
10338 );
10339 // Not paused, and the backlog is genuinely empty — which is exactly the
10340 // reading that used to be indistinguishable from healthy.
10341 //
10342 // **Asserted by EXCLUDING the other states, not by matching "running".**
10343 // The `off` row reads "the poller is not running on this instance", which
10344 // contains "running" — so the bare substring passed while the page was
10345 // reporting the exact opposite of what this line claims to check.
10346 assert!(
10347 !body.contains("the poller is not running")
10348 && !body.contains("the cache is at its size limit")
10349 && !body.contains("has not completed a round"),
10350 "expected the running state; the page reported a stopped one",
10351 );
10352
10353 // Now trip the watermark. Nothing in the database changes; only the
10354 // recorded runtime state does — which is the whole reason it needed a
10355 // home outside the log stream.
10356 state.runtime_health.set_watermark(true);
10357 let paused = render_stats(state.clone()).await;
10358 // Matched on the paused row's OWN sentence. The bare word "paused" also
10359 // appeared in the page's explanatory prose, so this assertion passed
10360 // whether or not the row rendered — and trimming that prose is what
10361 // exposed it. This phrase exists only inside the `paused` branch.
10362 assert!(
10363 paused.contains("the cache is at its size limit"),
10364 "a watermark pause is still invisible on the public page"
10365 );
10366
10367 // Still no identifiers: these are counts, not feeds.
10368 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10369 assert!(
10370 !paused.contains(leak),
10371 "the public page leaked {leak:?} while reporting failures"
10372 );
10373 }
10374 }
10375
10376 /// **`/admin/metrics` is gated, and nothing checked that it was.**
10377 ///
10378 /// Deleting the `admin_seed_dids` check left the entire suite green. That
10379 /// was survivable while the page held only aggregate timings; it is not now,
10380 /// because this branch puts **per-feed URLs and remote error text** behind
10381 /// that gate. A guarantee nothing checks is a comment, and this one is now
10382 /// the only thing standing between a signed-in stranger and the operational
10383 /// picture the handler's own doc says is not public.
10384 ///
10385 /// All three doors: no session, a session that is not an admin, and the
10386 /// admin itself.
10387 #[tokio::test]
10388 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
10389 let admin = "did:plc:adminseed";
10390 // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
10391 // IS that list — deliberately, per its doc: "the same people I trust on
10392 // this instance". Production sets it to the bootstrap DID alone.
10393 //
10394 // A genuine non-admin is therefore someone holding a beta seat granted
10395 // by an invite, not by the allow-list. Seeding both would have made
10396 // both admins and quietly turned the 403 assertion below into a test of
10397 // nothing — which is exactly what the first draft of this did.
10398 let state = test_state(&[admin]).await;
10399 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
10400 .await
10401 .unwrap();
10402 let url = "https://broken.example/f.xml";
10403 store::upsert_feed(
10404 &state.db,
10405 &store::NewFeed {
10406 url: url.to_string(),
10407 ..Default::default()
10408 },
10409 )
10410 .await
10411 .unwrap();
10412 store::bump_feed_errors(
10413 &state.db,
10414 url,
10415 feed::FailureKind::Fetch,
10416 "SENTINEL_ADMIN_ONLY",
10417 )
10418 .await
10419 .unwrap();
10420
10421 let get = |state: AppState, cookie: Option<String>| async move {
10422 let mut req = Request::builder().uri("/admin/metrics");
10423 if let Some(c) = cookie {
10424 req = req.header(header::COOKIE, c);
10425 }
10426 let resp = router(state)
10427 .oneshot(req.body(Body::empty()).unwrap())
10428 .await
10429 .unwrap();
10430 let status = resp.status();
10431 let body = String::from_utf8(
10432 axum::body::to_bytes(resp.into_body(), usize::MAX)
10433 .await
10434 .unwrap()
10435 .to_vec(),
10436 )
10437 .unwrap();
10438 (status, body)
10439 };
10440
10441 // No session at all.
10442 let (status, body) = get(state.clone(), None).await;
10443 assert_eq!(status, StatusCode::UNAUTHORIZED);
10444 assert!(
10445 !body.contains("SENTINEL_ADMIN_ONLY"),
10446 "leaked to anonymous: {body}"
10447 );
10448
10449 // A real, signed-in user who is not an admin.
10450 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
10451 let (status, body) = get(state.clone(), Some(ordinary)).await;
10452 assert_eq!(
10453 status,
10454 StatusCode::FORBIDDEN,
10455 "a non-admin session was let in"
10456 );
10457 assert!(
10458 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
10459 "leaked to a non-admin: {body}",
10460 );
10461
10462 // The admin does get it — otherwise the two refusals above are
10463 // satisfied by the endpoint being broken for everyone.
10464 let admin_cookie = session_cookie(&state, admin, None);
10465 let (status, body) = get(state, Some(admin_cookie)).await;
10466 assert_eq!(status, StatusCode::OK);
10467 assert!(
10468 body.contains("SENTINEL_ADMIN_ONLY"),
10469 "admin cannot see it: {body}"
10470 );
10471 }
10472
10473 /// **The cause a public count cannot carry belongs on the admin page.**
10474 ///
10475 /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
10476 /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
10477 /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
10478 /// have separated "sixty dead publishers" from "one bug here", which is the
10479 /// case it was justified by.
10480 ///
10481 /// The answer is not a finer public vocabulary — `/stats` promises never
10482 /// which feed and never whose, and a bucket per error string would break
10483 /// that. It is to put the detail where per-feed data is already allowed.
10484 /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
10485 /// operational picture.
10486 ///
10487 /// Asserts both halves: the detail IS on the admin page, and is NOT on the
10488 /// public one.
10489 #[tokio::test]
10490 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
10491 let admin = "did:plc:adminseed";
10492 let state = test_state(&[admin]).await;
10493 let url = "https://broken.example/f.xml";
10494 store::upsert_feed(
10495 &state.db,
10496 &store::NewFeed {
10497 url: url.to_string(),
10498 ..Default::default()
10499 },
10500 )
10501 .await
10502 .unwrap();
10503 store::bump_feed_errors(
10504 &state.db,
10505 url,
10506 feed::FailureKind::Fetch,
10507 "SENTINEL_REDIRECT_NO_LOCATION",
10508 )
10509 .await
10510 .unwrap();
10511
10512 let cookie = session_cookie(&state, admin, None);
10513 let resp = router(state.clone())
10514 .oneshot(
10515 Request::builder()
10516 .uri("/admin/metrics")
10517 .header(header::COOKIE, cookie)
10518 .body(Body::empty())
10519 .unwrap(),
10520 )
10521 .await
10522 .unwrap();
10523 assert_eq!(resp.status(), StatusCode::OK);
10524 let admin_body = String::from_utf8(
10525 axum::body::to_bytes(resp.into_body(), usize::MAX)
10526 .await
10527 .unwrap()
10528 .to_vec(),
10529 )
10530 .unwrap();
10531 assert!(
10532 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
10533 "the admin page does not carry the failure detail: {admin_body}",
10534 );
10535 assert!(
10536 admin_body.contains("broken.example"),
10537 "the admin page does not name the failing feed: {admin_body}",
10538 );
10539
10540 // The public page still carries neither.
10541 let resp = router(state)
10542 .oneshot(
10543 Request::builder()
10544 .uri("/stats")
10545 .body(Body::empty())
10546 .unwrap(),
10547 )
10548 .await
10549 .unwrap();
10550 let public = String::from_utf8(
10551 axum::body::to_bytes(resp.into_body(), usize::MAX)
10552 .await
10553 .unwrap()
10554 .to_vec(),
10555 )
10556 .unwrap();
10557 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
10558 assert!(
10559 !public.contains(secret),
10560 "{secret:?} reached the PUBLIC stats page: {public}",
10561 );
10562 }
10563 }
10564
10565 /// **A direct poll must settle the error columns, like the scheduler does.**
10566 ///
10567 /// `add_subscription` polls through `feed::poll_feed` rather than the
10568 /// scheduler, and `poll_feed` writes validators and `last_polled` but never
10569 /// touches `consecutive_errors` — that is the scheduler's job, and this path
10570 /// is not the scheduler.
10571 ///
10572 /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
10573 /// its old count and its old cause: the public page went on reporting it
10574 /// under `Failing`, under `badly_broken`, and under a cause, for as long as
10575 /// the stale backoff horizon lasted — up to 24h — while the reader was
10576 /// demonstrably fetching it.
10577 #[tokio::test]
10578 async fn a_successful_direct_poll_clears_a_stale_failure() {
10579 let state = test_state(&[]).await;
10580 let url = "https://recovered.example/f.xml";
10581 store::upsert_feed(
10582 &state.db,
10583 &store::NewFeed {
10584 url: url.to_string(),
10585 ..Default::default()
10586 },
10587 )
10588 .await
10589 .unwrap();
10590 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
10591 .await
10592 .unwrap();
10593 // Park it on a stale backoff horizon, as a real failing feed would be.
10594 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
10595 .bind(url)
10596 .execute(&state.db)
10597 .await
10598 .unwrap();
10599
10600 // The publisher is fixed: a successful poll happens on this path.
10601 feed::settle_poll(
10602 &state.db,
10603 url,
10604 &feed::PollOutcome::NotModified,
10605 state.config.poll_interval,
10606 )
10607 .await;
10608
10609 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10610 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10611 )
10612 .bind(url)
10613 .fetch_one(&state.db)
10614 .await
10615 .unwrap();
10616 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
10617 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
10618 // **The half the first fix missed.** Clearing the count fixed the
10619 // REPORTING; the feed stayed parked until 2099. A working feed must be
10620 // rescheduled on its normal cadence, not left on the failure horizon.
10621 let next = row.2.expect("next_poll was cleared to NULL");
10622 // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
10623 // backoff. A mutation that reschedules successes with backoff_for(1)
10624 // (5 min) also moves it off 2099, so the interval is asserted.
10625 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10626 let delta = parsed
10627 .signed_duration_since(chrono::Utc::now())
10628 .num_seconds();
10629 let cadence = state.config.poll_interval.as_secs() as i64;
10630 assert!(
10631 (cadence - 60..=cadence + 60).contains(&delta),
10632 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
10633 );
10634 }
10635
10636 /// The mirror case: a first poll that FAILS must be visible at all.
10637 ///
10638 /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
10639 /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
10640 /// with a NULL cause — invisible to the page built to count exactly that.
10641 #[tokio::test]
10642 async fn a_failing_direct_poll_is_recorded() {
10643 let state = test_state(&[]).await;
10644 let url = "https://born-broken.example/f.xml";
10645 store::upsert_feed(
10646 &state.db,
10647 &store::NewFeed {
10648 url: url.to_string(),
10649 ..Default::default()
10650 },
10651 )
10652 .await
10653 .unwrap();
10654
10655 feed::settle_poll(
10656 &state.db,
10657 url,
10658 &feed::PollOutcome::Failed {
10659 backoff: std::time::Duration::from_secs(300),
10660 kind: feed::FailureKind::Parse,
10661 detail: "SENTINEL_BORN_BROKEN".to_string(),
10662 },
10663 state.config.poll_interval,
10664 )
10665 .await;
10666
10667 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10668 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10669 )
10670 .bind(url)
10671 .fetch_one(&state.db)
10672 .await
10673 .unwrap();
10674 assert_eq!(row.0, 1, "a failed first poll was not counted");
10675 assert_eq!(
10676 row.1.as_deref(),
10677 Some("parse"),
10678 "its cause was not recorded"
10679 );
10680 // And it is BACKED OFF on the schedule the scheduler would use — not
10681 // left with a NULL next_poll that `due_feeds` sorts first and re-polls
10682 // on the very next tick.
10683 let next = row.2.expect("a failed direct poll left next_poll NULL");
10684 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10685 let delta = parsed
10686 .signed_duration_since(chrono::Utc::now())
10687 .num_seconds();
10688 assert!(
10689 (240..=360).contains(&delta),
10690 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
10691 );
10692 }
10693
10694 /// **The breakdown must sum to the Failing figure above it.**
10695 ///
10696 /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
10697 /// `consecutive_errors > 0`. On a migrated database every row that was
10698 /// already failing has a NULL kind — correctly, it was never recorded — so
10699 /// the two do not reconcile and the page shows "70 failing" beside "3
10700 /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
10701 /// entirely while the prose still promises a breakdown.
10702 ///
10703 /// An explicit `unknown` bucket is the honest shape: the page says how many
10704 /// it cannot explain rather than omitting them.
10705 #[tokio::test]
10706 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
10707 let state = test_state(&[]).await;
10708 // Two legacy rows: failing, with no recorded cause.
10709 for url in [
10710 "https://legacy1.example/f.xml",
10711 "https://legacy2.example/f.xml",
10712 ] {
10713 store::upsert_feed(
10714 &state.db,
10715 &store::NewFeed {
10716 url: url.to_string(),
10717 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10718 ..Default::default()
10719 },
10720 )
10721 .await
10722 .unwrap();
10723 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
10724 .bind(url)
10725 .execute(&state.db)
10726 .await
10727 .unwrap();
10728 }
10729 // One row with a recorded cause.
10730 store::upsert_feed(
10731 &state.db,
10732 &store::NewFeed {
10733 url: "https://known.example/f.xml".to_string(),
10734 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10735 ..Default::default()
10736 },
10737 )
10738 .await
10739 .unwrap();
10740 store::bump_feed_errors(
10741 &state.db,
10742 "https://known.example/f.xml",
10743 feed::FailureKind::Status,
10744 "SENTINEL",
10745 )
10746 .await
10747 .unwrap();
10748
10749 let now = chrono::Utc::now();
10750 let health = store::poll_health(
10751 &state.db,
10752 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10753 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10754 )
10755 .await
10756 .unwrap();
10757 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
10758 assert_eq!(
10759 counted, health.in_backoff,
10760 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
10761 health.in_backoff, health.failure_kinds,
10762 );
10763 assert!(
10764 health
10765 .failure_kinds
10766 .iter()
10767 .any(|(k, n)| k == "unknown" && *n == 2),
10768 "no unknown bucket for the legacy rows: {:?}",
10769 health.failure_kinds,
10770 );
10771 }
10772
10773 /// **The breakdown is ordered by count, and the assertion can see it.**
10774 ///
10775 /// The first version of this asserted with three `contains` calls, which
10776 /// cannot observe order — deleting `ORDER BY` from the query passed.
10777 #[tokio::test]
10778 async fn the_failure_breakdown_is_ordered_by_count() {
10779 let state = test_state(&[]).await;
10780 for (url, kind, n) in [
10781 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
10782 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
10783 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
10784 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
10785 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
10786 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
10787 ] {
10788 store::upsert_feed(
10789 &state.db,
10790 &store::NewFeed {
10791 url: url.to_string(),
10792 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10793 ..Default::default()
10794 },
10795 )
10796 .await
10797 .unwrap();
10798 for _ in 0..n {
10799 store::bump_feed_errors(&state.db, url, kind, "d")
10800 .await
10801 .unwrap();
10802 }
10803 }
10804 let now = chrono::Utc::now();
10805 let health = store::poll_health(
10806 &state.db,
10807 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10808 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10809 )
10810 .await
10811 .unwrap();
10812 let labels: Vec<&str> = health
10813 .failure_kinds
10814 .iter()
10815 .map(|(k, _)| k.as_str())
10816 .collect();
10817 assert_eq!(
10818 labels,
10819 ["fetch", "status", "parse"],
10820 "not ordered by count, descending: {:?}",
10821 health.failure_kinds,
10822 );
10823 }
10824
10825 /// **Failing feeds are grouped by CAUSE, and still never named.**
10826 ///
10827 /// `badly_broken` could say that sixty feeds were failing and not whether
10828 /// that was sixty dead publishers or one bug here. It was the latter — #159,
10829 /// a `304 Not Modified` read as a malformed redirect — and the page could
10830 /// not say so, which is most of why it went unexamined.
10831 ///
10832 /// The second half of this test is the constraint that shapes the first:
10833 /// `/stats` is public and promises machines-not-people, *never which feed
10834 /// and never whose*. A histogram of causes keeps that promise; a list of
10835 /// failing URLs would break it, and is the obvious way to build this.
10836 #[tokio::test]
10837 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
10838 let state = test_state(&[]).await;
10839 for (url, kind, detail, errors) in [
10840 // Detail strings are distinctive SENTINELS, not plausible English.
10841 // A first pass used "not a feed", which the page's own explanation
10842 // of the `parse` kind contains verbatim — the privacy assertion
10843 // fired on static copy rather than on a leak. A sentinel cannot
10844 // collide with prose.
10845 (
10846 "https://a.example/f.xml",
10847 feed::FailureKind::Fetch,
10848 "SENTINEL_CONNREFUSED",
10849 3,
10850 ),
10851 (
10852 "https://b.example/f.xml",
10853 feed::FailureKind::Fetch,
10854 "SENTINEL_DNSFAIL",
10855 2,
10856 ),
10857 (
10858 "https://c.example/f.xml",
10859 feed::FailureKind::Status,
10860 "SENTINEL_404",
10861 1,
10862 ),
10863 (
10864 "https://d.example/f.xml",
10865 feed::FailureKind::Parse,
10866 "SENTINEL_UNPARSEABLE",
10867 1,
10868 ),
10869 ] {
10870 store::upsert_feed(
10871 &state.db,
10872 &store::NewFeed {
10873 url: url.to_string(),
10874 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10875 ..Default::default()
10876 },
10877 )
10878 .await
10879 .unwrap();
10880 for _ in 0..errors {
10881 store::bump_feed_errors(&state.db, url, kind, detail)
10882 .await
10883 .unwrap();
10884 }
10885 }
10886
10887 let resp = router(state.clone())
10888 .oneshot(
10889 Request::builder()
10890 .uri("/stats")
10891 .body(Body::empty())
10892 .unwrap(),
10893 )
10894 .await
10895 .unwrap();
10896 assert_eq!(resp.status(), StatusCode::OK);
10897 let body = String::from_utf8(
10898 axum::body::to_bytes(resp.into_body(), usize::MAX)
10899 .await
10900 .unwrap()
10901 .to_vec(),
10902 )
10903 .unwrap();
10904
10905 // Descending by count: two fetch, then one each, tie-broken by name.
10906 assert!(
10907 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
10908 "the cause histogram did not render: {body}",
10909 );
10910
10911 // **The privacy half.** No feed URL, host, or error detail reaches the
10912 // public page — only counts by kind.
10913 for secret in [
10914 "a.example",
10915 "b.example",
10916 "c.example",
10917 "d.example",
10918 "SENTINEL_CONNREFUSED",
10919 "SENTINEL_DNSFAIL",
10920 "SENTINEL_404",
10921 "SENTINEL_UNPARSEABLE",
10922 ] {
10923 assert!(
10924 !body.contains(secret),
10925 "{secret:?} reached the PUBLIC stats page: {body}",
10926 );
10927 }
10928 }
10929
10930 /// `/health` must prove the process can reach its database, and must report
10931 /// the loop state without letting it change the status code.
10932 #[tokio::test]
10933 async fn health_checks_the_database_and_reports_the_loops() {
10934 let state = test_state(&[]).await;
10935 let body_of = |state: AppState| async move {
10936 let resp = router(state)
10937 .oneshot(
10938 Request::builder()
10939 .uri("/health")
10940 .body(Body::empty())
10941 .unwrap(),
10942 )
10943 .await
10944 .unwrap();
10945 let status = resp.status();
10946 let body = String::from_utf8(
10947 axum::body::to_bytes(resp.into_body(), usize::MAX)
10948 .await
10949 .unwrap()
10950 .to_vec(),
10951 )
10952 .unwrap();
10953 (status, body)
10954 };
10955
10956 // The boot stamp is what `main` sets; the router alone does not, so this
10957 // starts "unknown" and the uptime branch below drives it explicitly.
10958 state
10959 .runtime_health
10960 .set_started_at(chrono::Utc::now().timestamp());
10961
10962 let (status, body) = body_of(state.clone()).await;
10963 assert_eq!(status, StatusCode::OK);
10964 assert!(
10965 body.contains("db: ok"),
10966 "health did not probe the DB: {body}"
10967 );
10968 assert!(
10969 body.contains("uptime:"),
10970 "no uptime — the first thing anyone asks about a container that may \
10971 be restarting: {body}"
10972 );
10973 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
10974 assert!(body.contains("polling-paused: no"), "{body}");
10975 assert!(body.contains("backend:"), "{body}");
10976 assert!(body.contains("oauth-runtime:"), "{body}");
10977
10978 // A watermark pause is REPORTED but must not fail the check. A failed
10979 // check DEREGISTERS this machine from the proxy — and it is the only
10980 // machine — so it would turn "feeds are behind" into "the site is down"
10981 // for as long as the disk stays full.
10982 state.runtime_health.set_watermark(true);
10983 state.runtime_health.set_schedulers_enabled(true);
10984 let (status, body) = body_of(state.clone()).await;
10985 assert_eq!(
10986 status,
10987 StatusCode::OK,
10988 "a watermark pause must not fail the liveness check: {body}"
10989 );
10990 assert!(body.contains("polling-paused: yes"), "{body}");
10991 // Schedulers on but no tick yet — and that must not read as "0s ago",
10992 // which is the healthiest possible answer to an unanswered question.
10993 assert!(
10994 body.contains("poller: not-yet-ticked"),
10995 "a never-ticked poller must say so: {body}"
10996 );
10997
10998 // A stale heartbeat is likewise reported, not fatal.
10999 let stale_after = health_tick_stale_secs(configured_poll_tick());
11000 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
11001 state.runtime_health.poll_tick_completed(long_ago);
11002 let (status, body) = body_of(state.clone()).await;
11003 assert_eq!(
11004 status,
11005 StatusCode::OK,
11006 "a stale poller must not 503: {body}"
11007 );
11008 assert!(body.contains("poller: stale"), "{body}");
11009
11010 // **A poller that has never ticked stops being benign.**
11011 //
11012 // In a crash loop with 30 s+ boot cycles the poller never reaches its
11013 // first tick, so `not-yet-ticked` was reported forever and the heartbeat
11014 // could not detect the one failure mode the startup delays were added
11015 // for. It is read against uptime now.
11016 state.runtime_health.poll_tick_completed(0); // reset to "never"
11017 state
11018 .runtime_health
11019 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
11020 let (status, body) = body_of(state.clone()).await;
11021 assert_eq!(status, StatusCode::OK);
11022 assert!(
11023 body.contains("poller: stale never-ticked"),
11024 "a poller that never ticked long after boot still reads as benign: {body}"
11025 );
11026
11027 // A closed pool is a real outage: nothing can be served, and a restart is
11028 // the correct response. THIS is what the status code is for.
11029 state.db.close().await;
11030 let (status, body) = body_of(state.clone()).await;
11031 assert_eq!(
11032 status,
11033 StatusCode::SERVICE_UNAVAILABLE,
11034 "an unreachable database must fail the check: {body}"
11035 );
11036 assert!(body.starts_with("FAIL"), "{body}");
11037 // Coarse, not the raw sqlx error: an unauthenticated caller learning
11038 // exactly which failure it hit is an attack-progress oracle, and this
11039 // endpoint is exempt from the origin lock.
11040 assert!(
11041 !body.contains("PoolClosed") && !body.contains("sqlx"),
11042 "health leaked the raw database error to an unauthenticated caller: {body}"
11043 );
11044 }
11045
11046 /// The staleness threshold must track the configured tick.
11047 ///
11048 /// Hardcoded at 15 minutes, an operator who raised
11049 /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
11050 /// in the body the deployment docs tell them to alert on.
11051 #[test]
11052 fn the_stale_threshold_follows_the_poll_tick() {
11053 // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
11054 // alerting that early would fire on any brief hiccup.
11055 assert_eq!(
11056 health_tick_stale_secs(Duration::from_secs(60)),
11057 HEALTH_TICK_STALE_FLOOR_SECS
11058 );
11059 // A slow tick raises it, so a legitimately-configured loop is never
11060 // permanently "stale".
11061 let slow = Duration::from_secs(30 * 60);
11062 assert!(
11063 health_tick_stale_secs(slow) > slow.as_secs() as i64,
11064 "a 30-minute tick must not be stale after one interval"
11065 );
11066 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11067 // And it cannot overflow into nonsense on an absurd value.
11068 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11069 }
11070
11071 /// `/stats` must distinguish "nothing is polling" from "polling is fine".
11072 ///
11073 /// `polling_paused` alone rendered "running" for three different states,
11074 /// including the two where nothing polls at all — on the page added to
11075 /// answer exactly that question.
11076 #[tokio::test]
11077 async fn stats_does_not_call_a_stopped_poller_running() {
11078 let state = test_state(&[]).await;
11079 let render = |state: AppState| async move {
11080 let resp = router(state)
11081 .oneshot(
11082 Request::builder()
11083 .uri("/stats")
11084 .body(Body::empty())
11085 .unwrap(),
11086 )
11087 .await
11088 .unwrap();
11089 assert_eq!(resp.status(), StatusCode::OK);
11090 String::from_utf8(
11091 axum::body::to_bytes(resp.into_body(), usize::MAX)
11092 .await
11093 .unwrap()
11094 .to_vec(),
11095 )
11096 .unwrap()
11097 };
11098
11099 // Schedulers never started: not "running".
11100 let body = render(state.clone()).await;
11101 assert!(
11102 body.contains("the poller is not running on this instance"),
11103 "a disabled poller renders as healthy"
11104 );
11105
11106 // Started, but no tick has finished yet.
11107 state.runtime_health.set_schedulers_enabled(true);
11108 let body = render(state.clone()).await;
11109 assert!(
11110 body.contains("no poll has finished since this instance booted"),
11111 "a poller that has not ticked renders as healthy"
11112 );
11113
11114 // Ticking: running.
11115 state
11116 .runtime_health
11117 .poll_tick_completed(chrono::Utc::now().timestamp());
11118 let body = render(state.clone()).await;
11119 assert!(
11120 body.contains("running"),
11121 "a healthy poller must read as running"
11122 );
11123
11124 // Paused at the watermark still wins over "running".
11125 state.runtime_health.set_watermark(true);
11126 let body = render(state.clone()).await;
11127 assert!(
11128 body.contains("the cache is at its size limit"),
11129 "a watermark pause is hidden once the poller is ticking"
11130 );
11131 }
11132
11133 /// **An UNMEASURED database must not fail the check.**
11134 ///
11135 /// `/health` is the one path exempt from the Cloudflare origin lock and
11136 /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
11137 /// drop WITHOUT recording a verdict — so a cancelled request (a client
11138 /// disconnect is enough) leaves the verdict at "none", and a concurrent
11139 /// caller reads it. Treating that as a failure turned an unauthenticated
11140 /// request into a lever on the only signal the platform acts on. The
11141 /// previous version of this code had the opposite bug and reported `ok` for
11142 /// a database nothing had read; "unknown" is neither.
11143 #[tokio::test]
11144 async fn health_reports_an_unmeasured_database_without_failing() {
11145 use crate::runtime_health::DbProbe;
11146 let state = test_state(&[]).await;
11147
11148 // Hold the probe claim, exactly as an in-flight request would, and never
11149 // record a verdict — the cancelled-request state.
11150 let held = state
11151 .runtime_health
11152 .begin_db_probe()
11153 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11154
11155 let resp = router(state.clone())
11156 .oneshot(
11157 Request::builder()
11158 .uri("/health")
11159 .body(Body::empty())
11160 .unwrap(),
11161 )
11162 .await
11163 .unwrap();
11164 let status = resp.status();
11165 let body = String::from_utf8(
11166 axum::body::to_bytes(resp.into_body(), usize::MAX)
11167 .await
11168 .unwrap()
11169 .to_vec(),
11170 )
11171 .unwrap();
11172 drop(held);
11173
11174 assert_eq!(
11175 status,
11176 StatusCode::OK,
11177 "an unmeasured database failed the check, which an unauthenticated \
11178 caller can cause on demand: {body}"
11179 );
11180 assert!(
11181 body.contains("db: unknown"),
11182 "the unmeasured state must still be REPORTED: {body}"
11183 );
11184 assert!(!body.starts_with("FAIL"), "{body}");
11185 // **And it must not read as `ok` either.** `fly.toml` tells operators to
11186 // alert on the BODY for everything the status code ignores, so a first
11187 // line identical to the healthy one makes a monitor keying on `^ok` read
11188 // green in exactly the state this enum exists to surface.
11189 assert!(
11190 !body.starts_with("ok"),
11191 "the unmeasured state is indistinguishable from healthy to a \
11192 body-matching monitor: {body}"
11193 );
11194 assert!(body.starts_with("unknown"), "{body}");
11195
11196 // **A BORROWED failure must 503 too.**
11197 //
11198 // This previously recorded `Failed` and then closed the pool — but
11199 // `record` consumes the guard and releases the claim, so the request won
11200 // it, ran a live probe against the closed pool, and failed on its own.
11201 // The 503 passed for the wrong reason and the borrow path — the whole
11202 // point of the three-state enum on the read side — had no coverage.
11203 //
11204 // Holding the claim forces the borrow, so the recorded verdict is what
11205 // gets reported.
11206 let held = state
11207 .runtime_health
11208 .begin_db_probe()
11209 .unwrap_or_else(|_| panic!("claim"));
11210 state
11211 .runtime_health
11212 .record_for_test(DbProbe::Failed("unavailable".to_string()));
11213 let resp = router(state.clone())
11214 .oneshot(
11215 Request::builder()
11216 .uri("/health")
11217 .body(Body::empty())
11218 .unwrap(),
11219 )
11220 .await
11221 .unwrap();
11222 let status = resp.status();
11223 let body = String::from_utf8(
11224 axum::body::to_bytes(resp.into_body(), usize::MAX)
11225 .await
11226 .unwrap()
11227 .to_vec(),
11228 )
11229 .unwrap();
11230 drop(held);
11231 assert_eq!(
11232 status,
11233 StatusCode::SERVICE_UNAVAILABLE,
11234 "a BORROWED failure verdict must fail the check, not just a freshly \
11235 measured one: {body}"
11236 );
11237 assert!(body.starts_with("FAIL"), "{body}");
11238
11239 state.db.close().await;
11240 let resp = router(state.clone())
11241 .oneshot(
11242 Request::builder()
11243 .uri("/health")
11244 .body(Body::empty())
11245 .unwrap(),
11246 )
11247 .await
11248 .unwrap();
11249 assert_eq!(
11250 resp.status(),
11251 StatusCode::SERVICE_UNAVAILABLE,
11252 "a measured database failure must still fail the check"
11253 );
11254 }
11255
11256 /// **A disconnected client must not be able to cancel the probe.**
11257 ///
11258 /// Axum drops the handler future when a caller goes away. With the probe
11259 /// inline that dropped it mid-flight and released the claim WITHOUT
11260 /// recording a verdict — which let an unauthenticated caller manufacture the
11261 /// no-verdict state on demand and freeze what every other caller, including
11262 /// Fly's own check, reads. The probe runs detached now, so the verdict is
11263 /// recorded whatever happens to the request that started it.
11264 #[tokio::test]
11265 async fn an_abandoned_request_still_records_its_probe() {
11266 use crate::runtime_health::DbProbe;
11267 let state = test_state(&[]).await;
11268 let rh = state.runtime_health.clone();
11269
11270 // Drive /health and abandon it immediately — the disconnect case.
11271 let app = router(state.clone());
11272 let fut = app.oneshot(
11273 Request::builder()
11274 .uri("/health")
11275 .body(Body::empty())
11276 .unwrap(),
11277 );
11278 let handle = tokio::spawn(fut);
11279 handle.abort();
11280 let _ = handle.await;
11281
11282 // The detached probe still completes and publishes a verdict, so the
11283 // claim is free and the next caller gets a MEASURED answer.
11284 for _ in 0..50 {
11285 if rh.begin_db_probe().is_ok() {
11286 break;
11287 }
11288 tokio::time::sleep(Duration::from_millis(20)).await;
11289 }
11290 let resp = router(state.clone())
11291 .oneshot(
11292 Request::builder()
11293 .uri("/health")
11294 .body(Body::empty())
11295 .unwrap(),
11296 )
11297 .await
11298 .unwrap();
11299 let body = String::from_utf8(
11300 axum::body::to_bytes(resp.into_body(), usize::MAX)
11301 .await
11302 .unwrap()
11303 .to_vec(),
11304 )
11305 .unwrap();
11306 assert!(
11307 body.contains("db: ok"),
11308 "after an abandoned request the next caller still reads an \
11309 unmeasured database — the probe was cancelled with it: {body}"
11310 );
11311 // Sanity: the type still distinguishes the three states.
11312 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11313 }
11314
11315 /// **The probe must read a real page.**
11316 ///
11317 /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
11318 /// it never touches a b-tree and returns success against a corrupted
11319 /// database. Asserted by asking SQLite what the statement actually compiles
11320 /// to, so it survives someone "simplifying" the query later.
11321 #[tokio::test]
11322 async fn the_health_probe_opens_a_real_table() {
11323 use sqlx::Row;
11324 let state = test_state(&[]).await;
11325 // `EXPLAIN` lists the VM program; the `opcode` column is the second.
11326 let opcodes = |sql: &'static str| {
11327 let db = state.db.clone();
11328 async move {
11329 sqlx::query(sql)
11330 .fetch_all(&db)
11331 .await
11332 .unwrap()
11333 .into_iter()
11334 .map(|r| r.get::<String, _>("opcode"))
11335 .collect::<Vec<String>>()
11336 }
11337 };
11338
11339 // The statement `health_db_probe` really runs — it is the sole path, so
11340 // there is no second string for the handler to use instead.
11341 let explain: &'static str =
11342 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11343 let probe = opcodes(explain).await;
11344 // And the probe itself works against a real schema.
11345 assert!(
11346 health_db_probe(&state.db).await.is_ok(),
11347 "the probe does not run against the real schema",
11348 );
11349 assert!(
11350 probe.iter().any(|op| op == "OpenRead"),
11351 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11352 );
11353 // And the bare form genuinely does not, which is the whole point.
11354 let bare = opcodes("EXPLAIN SELECT 1").await;
11355 assert!(
11356 !bare.iter().any(|op| op == "OpenRead"),
11357 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11358 );
11359 }
11360
11361 /// A fresh instance says "never", not "0" — which would read as "polled
11362 /// just now", the opposite of the truth.
11363 #[test]
11364 fn an_instance_that_has_never_polled_says_so() {
11365 assert_eq!(humanise_ago(None), "never");
11366 assert_eq!(humanise_ago(Some(0)), "0s ago");
11367 assert_eq!(humanise_ago(Some(59)), "59s ago");
11368 assert_eq!(humanise_ago(Some(60)), "1m ago");
11369 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11370 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11371 }
11372
11373 /// A sidecar mock that answers `/internal/repo` listRecords with one saved
11374 /// record, and anything else with an empty list. Serves repeatedly.
11375 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11376 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11377 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11378 let addr = listener.local_addr().unwrap();
11379 let (url, title) = (saved_url.to_string(), saved_title.to_string());
11380 tokio::spawn(async move {
11381 loop {
11382 let Ok((mut sock, _)) = listener.accept().await else {
11383 break;
11384 };
11385 let mut buf = vec![0u8; 8192];
11386 let Ok(n) = sock.read(&mut buf).await else {
11387 continue;
11388 };
11389 let req = String::from_utf8_lossy(&buf[..n]).to_string();
11390 let wants_saved = req.contains("community.lexicon.rss.saved");
11391 let records = if wants_saved {
11392 serde_json::json!([{
11393 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
11394 "cid": "bafy",
11395 "value": {
11396 "$type": "community.lexicon.rss.saved",
11397 "url": url,
11398 "title": title,
11399 "createdAt": "2026-01-01T00:00:00Z"
11400 }
11401 }])
11402 } else {
11403 serde_json::json!([])
11404 };
11405 let body = serde_json::json!({
11406 "ok": true, "data": { "records": records }
11407 })
11408 .to_string();
11409 let resp = format!(
11410 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11411 body.len(), body
11412 );
11413 let _ = sock.write_all(resp.as_bytes()).await;
11414 let _ = sock.flush().await;
11415 }
11416 });
11417 format!("http://{addr}")
11418 }
11419
11420 /// A sidecar mock serving `n` distinct saved records, none of them cached
11421 /// locally — the shape that exercises the uncached-row append.
11422 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
11423 let feed = subscribed_feed.to_string();
11424 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11425 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11426 let addr = listener.local_addr().unwrap();
11427 tokio::spawn(async move {
11428 loop {
11429 let Ok((mut sock, _)) = listener.accept().await else {
11430 break;
11431 };
11432 let mut buf = vec![0u8; 8192];
11433 let Ok(read) = sock.read(&mut buf).await else {
11434 continue;
11435 };
11436 let req = String::from_utf8_lossy(&buf[..read]).to_string();
11437 let records = if req.contains("community.lexicon.rss.saved") {
11438 serde_json::Value::Array(
11439 (0..n)
11440 .map(|i| {
11441 serde_json::json!({
11442 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
11443 "cid": "bafy",
11444 "value": {
11445 "$type": "community.lexicon.rss.saved",
11446 "url": format!("https://elsewhere.example/{i}"),
11447 "title": format!("Elsewhere {i}"),
11448 "createdAt": "2026-01-01T00:00:00Z"
11449 }
11450 })
11451 })
11452 .collect(),
11453 )
11454 } else if req.contains("community.lexicon.rss.subscription") {
11455 // Without this the handler's `sync_sub_refs` would REPLACE
11456 // sub_ref with an empty set on every render, and every
11457 // sub_ref-scoped read — including the cached starred list
11458 // this test is about — would come back empty.
11459 serde_json::json!([{
11460 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
11461 "cid": "bafy",
11462 "value": {
11463 "$type": "community.lexicon.rss.subscription",
11464 "url": feed,
11465 "createdAt": "2026-01-01T00:00:00Z"
11466 }
11467 }])
11468 } else {
11469 serde_json::json!([])
11470 };
11471 let body =
11472 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11473 let resp = format!(
11474 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11475 body.len(), body
11476 );
11477 let _ = sock.write_all(resp.as_bytes()).await;
11478 let _ = sock.flush().await;
11479 }
11480 });
11481 format!("http://{addr}")
11482 }
11483
11484 /// **The pager must not advertise a page the clamp cannot reach.**
11485 ///
11486 /// The page clamp is computed from the CACHED total; the uncached PDS rows
11487 /// are appended to the last page rather than paged. Inflating `total` with
11488 /// them made `page_count` and the "Older →" link point one page past the end:
11489 /// requesting it clamped straight back, re-rendered the same last page, and
11490 /// still offered the link. An infinite "next" that never advances.
11491 #[tokio::test]
11492 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
11493 let did = "did:plc:pagerloop";
11494 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
11495 let state = test_state_with_sidecar(&[], &sidecar).await;
11496 store::grant_access(&state.db, did, None, "test", None)
11497 .await
11498 .unwrap();
11499 let feed = store::upsert_feed(
11500 &state.db,
11501 &store::NewFeed {
11502 url: "https://loop.example/feed.xml".to_string(),
11503 title: Some("Loop".to_string()),
11504 ..Default::default()
11505 },
11506 )
11507 .await
11508 .unwrap();
11509 // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
11510 // and the old arithmetic reported a fourth page.
11511 let entries: Vec<store::NewEntry> = (0..250)
11512 .map(|i| store::NewEntry {
11513 guid: format!("s-{i:04}"),
11514 url: Some(format!("https://loop.example/{i}")),
11515 title: Some(format!("Starred {i:04}")),
11516 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
11517 ..Default::default()
11518 })
11519 .collect();
11520 store::insert_entries(&state.db, feed, &entries, 0)
11521 .await
11522 .unwrap();
11523 store::replace_sub_refs(&state.db, did, &[feed])
11524 .await
11525 .unwrap();
11526 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
11527 .await
11528 .unwrap()
11529 {
11530 store::mark_starred(&state.db, did, row.id, true)
11531 .await
11532 .unwrap();
11533 }
11534
11535 let cookie = session_cookie(&state, did, None);
11536 let app = router(state.clone());
11537 let get = |uri: &str| {
11538 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
11539 async move {
11540 let resp = app
11541 .oneshot(
11542 Request::builder()
11543 .uri(uri)
11544 .header(header::COOKIE, cookie)
11545 .body(Body::empty())
11546 .unwrap(),
11547 )
11548 .await
11549 .unwrap();
11550 assert_eq!(resp.status(), StatusCode::OK);
11551 String::from_utf8(
11552 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
11553 .await
11554 .unwrap()
11555 .to_vec(),
11556 )
11557 .unwrap()
11558 }
11559 };
11560
11561 // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
11562 // clamp must agree on that, and EVERY page it offers must have content —
11563 // the original bug advertised a fourth page that clamped back to the
11564 // third and re-rendered it, still offering the link.
11565 let p3 = get("/?view=starred&page=3").await;
11566 assert!(
11567 p3.contains("Page 3 of 4"),
11568 "the pager and the clamp disagree on the total: {}",
11569 p3.split("pager-pos")
11570 .nth(1)
11571 .unwrap_or("")
11572 .chars()
11573 .take(120)
11574 .collect::<String>()
11575 );
11576 // Page 3 is the boundary: the last 50 cached rows, then the first 50
11577 // uncached ones.
11578 assert!(
11579 p3.contains("Elsewhere 0"),
11580 "page 3 should start the uncached run"
11581 );
11582 assert_eq!(
11583 p3.matches("<li class=\"entry").count(),
11584 ENTRIES_PER_PAGE as usize,
11585 "the boundary page is not full"
11586 );
11587
11588 // **The heading, which the previous round broke by deleting this.**
11589 //
11590 // `total` includes the uncached records, so the parenthetical is a
11591 // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
11592 // The version that said "plus N" double counted once `total` started
11593 // including them, and N had become page-local in the same commit while
11594 // the template stayed put. It shipped because this assertion was deleted
11595 // rather than updated.
11596 {
11597 let body = &p3;
11598 assert!(
11599 body.contains("330 entries"),
11600 "the heading must count the whole sequence: {}",
11601 body.split("content-count")
11602 .nth(1)
11603 .unwrap_or("")
11604 .chars()
11605 .take(120)
11606 .collect::<String>()
11607 );
11608 assert!(
11609 body.contains("(80 saved elsewhere)"),
11610 "the heading must say how many of the total the cache cannot show, \
11611 as a whole-list figure and not a per-page one: {}",
11612 body.split("content-count")
11613 .nth(1)
11614 .unwrap_or("")
11615 .chars()
11616 .take(120)
11617 .collect::<String>()
11618 );
11619 assert!(
11620 !body.contains("plus 50") && !body.contains("plus 80"),
11621 "the heading is adding the uncached rows to a total that already \
11622 includes them"
11623 );
11624 }
11625
11626 let p4 = get("/?view=starred&page=4").await;
11627 assert!(
11628 p4.contains("Page 4 of 4"),
11629 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
11630 );
11631 assert_eq!(
11632 p4.matches("<li class=\"entry").count(),
11633 30,
11634 "page 4 should hold the remaining 30 uncached records"
11635 );
11636 assert!(
11637 p4.contains("Elsewhere 79"),
11638 "the LAST saved record is unreachable — it can only be removed from here"
11639 );
11640
11641 // No uncached record appears on two pages.
11642 assert!(
11643 !p4.contains("Elsewhere 0"),
11644 "an uncached record was rendered on more than one page"
11645 );
11646 // Page 1 is all cached — and still reports the same whole-list heading,
11647 // because the parenthetical describes the LIST, not the page.
11648 let first = get("/?view=starred").await;
11649 assert!(
11650 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
11651 "the heading changed between pages; it describes the list, not the page"
11652 );
11653 assert!(
11654 !first.contains("Elsewhere "),
11655 "uncached saved records leaked onto the first page"
11656 );
11657 }
11658
11659 /// **A saved record whose article is not cached here is still shown.**
11660 ///
11661 /// The starred view is built from local `entries`, so before this a record
11662 /// starred in ANOTHER atproto reader — the portability the shared lexicon
11663 /// exists for — was simply invisible. It now renders from the PDS record,
11664 /// visually distinct, linking straight out.
11665 #[tokio::test]
11666 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
11667 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
11668 let sidecar =
11669 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
11670 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
11671 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
11672
11673 let resp = router(state)
11674 .oneshot(
11675 Request::builder()
11676 .uri("/?view=starred")
11677 .body(Body::empty())
11678 .unwrap(),
11679 )
11680 .await
11681 .unwrap();
11682 assert_eq!(resp.status(), StatusCode::OK);
11683 let body = String::from_utf8(
11684 axum::body::to_bytes(resp.into_body(), usize::MAX)
11685 .await
11686 .unwrap()
11687 .to_vec(),
11688 )
11689 .unwrap();
11690
11691 assert!(
11692 body.contains("Starred elsewhere"),
11693 "the saved record was not rendered at all"
11694 );
11695 assert!(
11696 body.contains("entry-uncached"),
11697 "it was not marked as uncached, so it looks like a normal entry"
11698 );
11699 assert!(
11700 body.contains("https://elsewhere.example/article"),
11701 "the row must link straight to the article"
11702 );
11703 assert!(
11704 !body.contains("/entries/0/"),
11705 "an uncached row must not offer entry actions against a nonexistent id"
11706 );
11707 }
11708
11709 /// **A PDS `createdAt` must not be able to panic the starred view.**
11710 ///
11711 /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
11712 /// timestamp the feed parser produced; the saved-record path passes a bare
11713 /// string off a PDS record, written by whatever client the reader used. A
11714 /// multi-byte value panicked the handler, and with no catch-panic layer the
11715 /// view stayed down until the record was removed — from that same view.
11716 #[test]
11717 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
11718 for hostile in [
11719 "日本語日本語日本",
11720 "é",
11721 "",
11722 "2026",
11723 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
11724 ] {
11725 let out = display_date(Some(hostile));
11726 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
11727 }
11728 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
11729 assert_eq!(display_date(None), "");
11730 }
11731
11732 /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
11733 /// its neighbours are limited. It was added as a route and not added here.
11734 #[test]
11735 fn the_unsave_route_is_rate_limited() {
11736 use axum::http::Method;
11737 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
11738 // And the neighbours still are.
11739 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
11740 }
11741
11742 /// **The probe detects a broken database — asserted through `/health`
11743 /// itself, not through a string.**
11744 ///
11745 /// A named constant did not bind the handler: it stayed free to call
11746 /// `query_scalar` with a different literal, so degrading the real probe to
11747 /// `SELECT 1` shipped green twice over. This drops the table the probe reads
11748 /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
11749 /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
11750 #[tokio::test]
11751 async fn health_reports_a_broken_database() {
11752 let state = test_state(&[]).await;
11753 // Sanity: healthy first, so the assertion below is about the damage.
11754 assert!(
11755 health_db_probe(&state.db).await.is_ok(),
11756 "the fixture was not healthy to begin with",
11757 );
11758
11759 sqlx::query("DROP TABLE feeds")
11760 .execute(&state.db)
11761 .await
11762 .unwrap();
11763
11764 assert!(
11765 health_db_probe(&state.db).await.is_err(),
11766 "the probe reported success against a database missing the table it \
11767 claims to read; `SELECT 1` would do exactly this",
11768 );
11769
11770 let resp = router(state)
11771 .oneshot(
11772 Request::builder()
11773 .uri("/health")
11774 .body(Body::empty())
11775 .unwrap(),
11776 )
11777 .await
11778 .unwrap();
11779 let body = String::from_utf8(
11780 axum::body::to_bytes(resp.into_body(), usize::MAX)
11781 .await
11782 .unwrap()
11783 .to_vec(),
11784 )
11785 .unwrap();
11786 // The documented contract: the FIRST token is the state.
11787 assert!(
11788 body.starts_with("FAIL"),
11789 "/health did not report FAIL for a broken database: {body}",
11790 );
11791 assert!(
11792 !body.contains("db: ok"),
11793 "/health still called the database ok: {body}",
11794 );
11795 }
11796
11797 /// A sidecar mock for the OPML export: serves one subscription and one
11798 /// folder, except for the collection named in `fail_on`, which answers
11799 /// `500` — the shape a refused (short or unreadable) walk takes at this
11800 /// boundary.
11801 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
11802 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11803 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11804 let addr = listener.local_addr().unwrap();
11805 tokio::spawn(async move {
11806 loop {
11807 let Ok((mut sock, _)) = listener.accept().await else {
11808 break;
11809 };
11810 let mut buf = vec![0u8; 8192];
11811 let Ok(n) = sock.read(&mut buf).await else {
11812 continue;
11813 };
11814 let req = String::from_utf8_lossy(&buf[..n]).to_string();
11815 let wants = |c: &str| req.contains(c);
11816 if fail_on.is_some_and(wants) {
11817 let body = r#"{"ok":false,"error":"ShortList"}"#;
11818 let resp = format!(
11819 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11820 body.len(),
11821 body
11822 );
11823 let _ = sock.write_all(resp.as_bytes()).await;
11824 let _ = sock.flush().await;
11825 continue;
11826 }
11827 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
11828 serde_json::json!([{
11829 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
11830 "cid": "bafy",
11831 "value": {
11832 "$type": crate::lexicon::nsid::SUBSCRIPTION,
11833 "url": "https://kept.example/feed.xml",
11834 "title": "Kept",
11835 // Inside the folder, so the healthy export has to
11836 // carry BOTH walks' results: an exporter that lost
11837 // the folder list would flatten this outline out of
11838 // its group with nothing else changing.
11839 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
11840 "createdAt": "2026-01-01T00:00:00Z"
11841 }
11842 }])
11843 } else if wants(crate::lexicon::nsid::FOLDER) {
11844 serde_json::json!([{
11845 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
11846 "cid": "bafy",
11847 "value": {
11848 "$type": crate::lexicon::nsid::FOLDER,
11849 "name": "Kept folder",
11850 "createdAt": "2026-01-01T00:00:00Z"
11851 }
11852 }])
11853 } else {
11854 serde_json::json!([])
11855 };
11856 let body =
11857 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11858 let resp = format!(
11859 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11860 body.len(),
11861 body
11862 );
11863 let _ = sock.write_all(resp.as_bytes()).await;
11864 let _ = sock.flush().await;
11865 }
11866 });
11867 format!("http://{addr}")
11868 }
11869
11870 /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
11871 async fn export_opml_response(
11872 fail_on: Option<&'static str>,
11873 ) -> (StatusCode, HeaderMap, String) {
11874 let did = "did:plc:exporter";
11875 let sidecar = spawn_export_sidecar(fail_on).await;
11876 let state = test_state_with_sidecar(&[did], &sidecar).await;
11877 let cookie = session_cookie(&state, did, None);
11878 let resp = router(state)
11879 .oneshot(
11880 Request::builder()
11881 .uri("/opml/export")
11882 .header(header::COOKIE, cookie)
11883 .body(Body::empty())
11884 .unwrap(),
11885 )
11886 .await
11887 .unwrap();
11888 let status = resp.status();
11889 let headers = resp.headers().clone();
11890 let body = String::from_utf8_lossy(
11891 &axum::body::to_bytes(resp.into_body(), usize::MAX)
11892 .await
11893 .unwrap(),
11894 )
11895 .to_string();
11896 (status, headers, body)
11897 }
11898
11899 /// **An empty export is worse than no export, and this is the caller that
11900 /// used to produce one.**
11901 ///
11902 /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
11903 /// truncated walk refuses instead of returning a short list, that turned the
11904 /// refusal into `200 OK` carrying a zero-feed
11905 /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
11906 /// the moment a locked-out reader reached for one, and the changelog points
11907 /// them at this route as the recovery path.
11908 ///
11909 /// Asserts the three things a reader can actually observe: no success status,
11910 /// no download offered, and no OPML document in the body.
11911 #[tokio::test]
11912 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
11913 let (status, headers, body) =
11914 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
11915
11916 assert_ne!(
11917 status,
11918 StatusCode::OK,
11919 "a failed subscription walk answered 200: {body}",
11920 );
11921 assert!(
11922 !headers.contains_key(header::CONTENT_DISPOSITION),
11923 "a failed subscription walk still offered a download: {headers:?}",
11924 );
11925 assert!(
11926 !body.contains("<opml"),
11927 "a failed subscription walk still served an OPML document: {body}",
11928 );
11929 }
11930
11931 /// The folders half of the same hole. The two walks are separate calls, and
11932 /// fixing only the first leaves an export that silently loses every folder —
11933 /// a flat list that reimports as one, with no sign anything was lost.
11934 #[tokio::test]
11935 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
11936 let (status, headers, body) =
11937 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
11938
11939 assert_ne!(
11940 status,
11941 StatusCode::OK,
11942 "a failed folder walk answered 200: {body}",
11943 );
11944 assert!(
11945 !headers.contains_key(header::CONTENT_DISPOSITION),
11946 "a failed folder walk still offered a download: {headers:?}",
11947 );
11948 assert!(
11949 !body.contains("<opml"),
11950 "a failed folder walk still served an OPML document: {body}",
11951 );
11952 }
11953
11954 /// The other direction, without which "refuse everything" would pass both
11955 /// tests above: a healthy read still serves the file, with the feed in it.
11956 #[tokio::test]
11957 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
11958 let (status, headers, body) = export_opml_response(None).await;
11959
11960 assert_eq!(
11961 status,
11962 StatusCode::OK,
11963 "a healthy export did not answer 200"
11964 );
11965 assert_eq!(
11966 headers
11967 .get(header::CONTENT_DISPOSITION)
11968 .and_then(|v| v.to_str().ok()),
11969 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
11970 "a healthy export did not offer the download",
11971 );
11972 assert!(
11973 body.contains("https://kept.example/feed.xml"),
11974 "the exported OPML lost the subscription: {body}",
11975 );
11976 assert!(
11977 body.contains("Kept folder"),
11978 "the exported OPML lost the folder: {body}",
11979 );
11980 }
11981}