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 20,000-record list ceiling) and are appended whole to the
1987 // last page, so `ENTRIES_PER_PAGE` does not constrain them at all. The
1988 // cap is generous — a reader with more saved-elsewhere records than this
1989 // is not the case being designed for — but a response has to have a size
1990 // an operator can reason about.
1991 let mut uncached_dropped = 0usize;
1992 match state.repo().list_saved_sorted(&did).await {
1993 Ok(saved) if identities_ok => {
1994 for (rkey, item) in saved {
1995 let known = cached_urls.contains(item.url.as_str())
1996 || item
1997 .entry_id
1998 .as_deref()
1999 .is_some_and(|g| cached_guids.contains(g));
2000 if known {
2001 continue;
2002 }
2003 // And the scope filter applies to these rows too. Without
2004 // it, `?feed=X` still listed saved records from every other
2005 // feed — the filter silently did nothing for them.
2006 if let Some(urls) = &scope_urls {
2007 match item.feed_url.as_deref() {
2008 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2009 // A saved record with no `feedUrl` cannot be placed
2010 // in any feed's scope, so it belongs only to the
2011 // unfiltered view.
2012 _ => continue,
2013 }
2014 }
2015 // **`safe_link` FIRST, and a failure no longer drops the row.**
2016 //
2017 // `item.url` is attacker-controlled — a saved record written
2018 // by any client — and it lands in an `href`. Askama escapes
2019 // HTML metacharacters but not SCHEMES, so `javascript:`
2020 // survives escaping intact. This project already built the
2021 // helper for exactly that, and `feed.rs` uses it on the
2022 // equivalent link; this path was simply not routed through it.
2023 //
2024 // The real defect was what a failure DID: it `continue`d, so
2025 // the row vanished entirely — no badge, no count, nothing —
2026 // and the only trace was a `debug!` below any realistic
2027 // filter. That makes the record unremovable FROM HERE, because
2028 // the un-save button lives on the row; the reader has to open
2029 // a different atproto client to get rid of it. A bad URL is a
2030 // reason to withhold the LINK, not the row.
2031 //
2032 // The check also moved ABOVE the poll nudge. That is ordering
2033 // hygiene rather than a fix: the nudge keys on `feed_url`, not
2034 // on the URL being rejected here, and is already gated on the
2035 // reader actually subscribing to that feed — so it was never
2036 // reachable by an unusable `item.url`. Deciding whether a
2037 // record is renderable before doing anything outbound on its
2038 // behalf is simply the order that stays correct if either of
2039 // those two facts later stops being true.
2040 let link = SafeLink::external(&item.url);
2041 if link.is_empty() {
2042 warn!(
2043 %did, %rkey,
2044 "a saved record has an unusable URL; rendering it without a link \
2045 so it can still be removed"
2046 );
2047 }
2048
2049 // Opportunistic re-fetch: if the reader still subscribes to
2050 // the feed, make it due now. If the article is still inside
2051 // the feed's window the poller caches it normally and this
2052 // row becomes a real entry on its own — no synthetic rows in
2053 // the shared cache, which every subscriber would otherwise
2054 // see as a content-less entry.
2055 // **Bound the WORK, not just the response.** This check sat
2056 // after the nudge and the `subs` scan below, so every render
2057 // still walked all ≤20,000 PDS records, ran a subs-length
2058 // string scan per record, and issued up to that many
2059 // `mark_feed_due` round-trips on a 5-connection pool — then
2060 // discarded everything past the cap. A cap that runs after
2061 // the expensive part is a cap on the output only.
2062 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2063 uncached_dropped += 1;
2064 continue;
2065 }
2066 if let Some(feed_url) = item.feed_url.as_deref() {
2067 if subs.iter().any(|s| s.sub.url == feed_url) {
2068 // Bounded to one nudge per feed per poll interval —
2069 // see `mark_feed_due`. Unbounded, a reload loop here
2070 // becomes outbound amplification.
2071 let stale_before = (chrono::Utc::now()
2072 - chrono::Duration::from_std(state.config.poll_interval)
2073 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2074 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2075 if let Err(err) =
2076 store::mark_feed_due(pool, feed_url, &stale_before).await
2077 {
2078 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2079 }
2080 }
2081 }
2082 uncached.push(EntryRow {
2083 id: 0,
2084 title: item
2085 .title
2086 .clone()
2087 .filter(|t| !t.trim().is_empty())
2088 // Falling back to the URL is fine for a link we are
2089 // willing to render, and wrong for one we are not:
2090 // it would put the exact string `safe_link` just
2091 // rejected into the page as the record's name. The
2092 // rkey is what the un-save button acts on, so it is
2093 // the honest identifier for a row that has nothing
2094 // else trustworthy to show.
2095 .unwrap_or_else(|| {
2096 if link.is_empty() {
2097 format!("Saved item {rkey}")
2098 } else {
2099 item.url.clone()
2100 }
2101 }),
2102 feed_title: item.feed_url.clone().unwrap_or_default(),
2103 published: display_date(Some(&item.created_at)),
2104 read: false,
2105 starred: true,
2106 // Empty = "render this row without an anchor". The
2107 // template branches on it, so the rejected URL never
2108 // reaches an `href` even as an escaped string.
2109 link,
2110 cached: false,
2111 rkey,
2112 });
2113 }
2114 }
2115 // Identity lookup was unusable — see the fail-closed note above.
2116 Ok(_) => {}
2117 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2118 }
2119 if uncached_dropped > 0 {
2120 warn!(
2121 %did,
2122 dropped = uncached_dropped,
2123 cap = MAX_UNCACHED_SAVED_ROWS,
2124 "more saved records than this instance will hold in one response; the \
2125 rest are not reachable from here"
2126 );
2127 }
2128 }
2129
2130 // **One sequence, two sources.** The cached rows come from SQL, the uncached
2131 // PDS records follow them, and the pager walks the concatenation.
2132 //
2133 // The first version appended the uncached rows to the last page only and
2134 // kept them out of `total`, which left everything past a cap invisible AND
2135 // unremovable — the un-save button lives on the row, and there is no other
2136 // surface in the app that lists these. That is the same "unremovable FROM
2137 // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2138 // forty lines later by a bound meant to protect memory.
2139 //
2140 // Paging the concatenation makes every record reachable and needs no cap on
2141 // what is RENDERED — one page is one page either way. The version before
2142 // that inflated `total` while clamping on the cached count, which advertised
2143 // a page the clamp could never reach; both numbers come from the same total
2144 // now, which is what makes that impossible rather than merely fixed.
2145 let total_cached =
2146 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2147 let uncached_len = uncached.len();
2148 let total = total_cached + uncached_len as i64;
2149 // Clamped to the range that exists. Past the end the list is empty, and the
2150 // empty state renders instead of the pager — which would strand a reader who
2151 // typed a page number, or who paged to the end and then marked entries read
2152 // out from under their own URL. Showing the last page is the answer to both.
2153 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2154 let offset = (page - 1) * ENTRIES_PER_PAGE;
2155 // Past the cached rows this returns nothing, which is exactly right: the
2156 // page is then made up entirely of uncached ones.
2157 let source = store::list_entries(
2158 pool,
2159 &did,
2160 list_view,
2161 scope_ids.as_deref(),
2162 ENTRIES_PER_PAGE,
2163 offset,
2164 )
2165 .await?;
2166 // **Both halves of the page are computed from the COUNT alone.**
2167 //
2168 // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2169 // queries, so they can disagree about how many cached rows exist. Any part of
2170 // the page composition that reads `source.len()` inherits that disagreement.
2171 //
2172 // `cached_allotment` is this page's cached share according to the snapshot,
2173 // and it is what the uncached `skip`/`take` are derived from — so consecutive
2174 // pages tile the uncached list exactly, whichever way the count drifted.
2175 // `source` is then truncated to it only to avoid rendering rows the next page
2176 // will also claim.
2177 //
2178 // The previous version took `skip` from the count but `take` from
2179 // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2180 // an un-star or a retention delete landing between the two queries — made
2181 // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2182 // putting twenty rows, each carrying the record-DELETING un-save button, on
2183 // two pages at once. The comment claimed that shape was impossible; it was
2184 // merely rarer.
2185 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2186 let cached_here = cached_allotment.min(source.len());
2187 // Only compose when there is something to compose WITH. `uncached` is empty
2188 // on every view but `starred`, and truncating there just drops trailing rows
2189 // that no page then shows — the poller inserting between the COUNT and the
2190 // SELECT was enough to trigger it.
2191 let source = if uncached_len == 0 {
2192 &source[..]
2193 } else {
2194 &source[..cached_here]
2195 };
2196 let uncached_page: Vec<EntryRow> = {
2197 let skip = (offset - total_cached).max(0) as usize;
2198 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2199 uncached.into_iter().skip(skip).take(take).collect()
2200 };
2201 // This page's slice, used only to append below. The heading needs the
2202 // WHOLE-list figure, which is the set's size before slicing.
2203 let uncached_total = uncached_len as i64;
2204
2205 // The scope/view suffix carried onto every entry link (built once).
2206 let entry_scope_qs = {
2207 let mut parts = Vec::new();
2208 if let Some(f) = q.feed.as_deref() {
2209 parts.push(format!("feed={}", qenc(f)));
2210 }
2211 if let Some(f) = q.folder.as_deref() {
2212 parts.push(format!("folder={}", qenc(f)));
2213 }
2214 if view != "unread" {
2215 parts.push(format!("view={}", qenc(&view)));
2216 }
2217 parts.join("&")
2218 };
2219 let entries: Vec<EntryRow> = source
2220 .iter()
2221 .map(|e| EntryRow {
2222 id: e.id,
2223 title: e
2224 .title
2225 .clone()
2226 .filter(|t| !t.trim().is_empty())
2227 .unwrap_or_else(|| "(untitled)".to_string()),
2228 feed_title: feed_title_by_id(e.feed_id),
2229 published: display_date(e.published.as_deref()),
2230 // Both bits ride along on the row's own `entry_state` join now. They
2231 // used to be membership tests against the full unread and starred
2232 // sets, which is why those two lists were fetched in their entirety
2233 // on every render even when the page showed a hundred rows.
2234 read: e.read,
2235 starred: e.starred,
2236 link: SafeLink::entry(e.id, &entry_scope_qs),
2237 cached: true,
2238 rkey: String::new(),
2239 })
2240 .collect();
2241
2242 // The uncached slice for this page follows the cached rows.
2243 let mut entries = entries;
2244 entries.extend(uncached_page);
2245 let entries = entries;
2246
2247 let selected_feed = q.feed.as_deref();
2248 let selected_folder = q.folder.as_deref();
2249
2250 // Build the shared sidebar (folders + loose feeds, with unread counts).
2251 let (folder_views, loose_feeds, _folder_options) =
2252 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2253
2254 // Heading + scope query-string suffix.
2255 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2256 let name = subs
2257 .iter()
2258 .find(|s| s.sub.url == feed_url)
2259 .map(|s| {
2260 display_title(
2261 s.sub
2262 .title
2263 .as_deref()
2264 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2265 &s.sub.url,
2266 )
2267 })
2268 .unwrap_or_else(|| display_title(None, feed_url));
2269 (name, format!("feed={}", qenc(feed_url)))
2270 } else if let Some(folder_uri) = selected_folder {
2271 let name = folder_views
2272 .iter()
2273 .find(|f| f.uri == folder_uri)
2274 .map(|f| f.name.clone())
2275 .unwrap_or_else(|| "Folder".to_string());
2276 (name, format!("folder={}", qenc(folder_uri)))
2277 } else {
2278 let h = match view.as_str() {
2279 "all" => "All",
2280 "starred" => "Starred",
2281 _ => "Unread",
2282 };
2283 (h.to_string(), String::new())
2284 };
2285
2286 let feed_scope = selected_feed.map(str::to_string);
2287 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2288
2289 // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2290 // page number is the only thing appended — which keeps a paged link
2291 // identical to an unpaged one in every other respect.
2292 let page_href = |n: i64| -> String {
2293 let mut parts = Vec::new();
2294 if !entry_scope_qs.is_empty() {
2295 parts.push(entry_scope_qs.clone());
2296 }
2297 if n > 1 {
2298 parts.push(format!("page={n}"));
2299 }
2300 if parts.is_empty() {
2301 "/".to_string()
2302 } else {
2303 format!("/?{}", parts.join("&"))
2304 }
2305 };
2306 let prev_href = (page > 1).then(|| page_href(page - 1));
2307 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2308
2309 let tmpl = IndexTemplate {
2310 version: VERSION,
2311 repo_url: REPO_URL,
2312 kofi_url: KOFI_URL,
2313 flash: q.flash.unwrap_or_default(),
2314 nav,
2315 entries,
2316 heading,
2317 feed_scope,
2318 total,
2319 // Whole-list figure, so it sits beside `total` without double counting.
2320 // The per-page slice is composed above and is not a heading number.
2321 uncached_total,
2322 page,
2323 page_count: page_count_for(total),
2324 prev_href,
2325 next_href,
2326 };
2327 Ok(render(&tmpl))
2328}
2329
2330/// Query for `GET /manage` — carries an optional flash after an action redirect.
2331#[derive(Debug, Deserialize, Default)]
2332struct ManageQuery {
2333 #[serde(default)]
2334 flash: Option<String>,
2335}
2336
2337/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2338/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2339/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2340/// mutation logic of its own.
2341async fn manage(
2342 State(state): State<AppState>,
2343 headers: HeaderMap,
2344 Query(q): Query<ManageQuery>,
2345) -> Result<Response, WebError> {
2346 let user = match current_session(&state, &headers).await {
2347 Some(u) => u,
2348 None => return Ok(Redirect::to("/login").into_response()),
2349 };
2350 let did = user.did.clone();
2351
2352 let subs = resolve_subscriptions(&state, &did).await;
2353 let (folder_views, loose_feeds, folder_options) =
2354 build_sidebar(&state, &did, &subs, None, None).await;
2355
2356 // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2357 let nav = build_nav(
2358 &user,
2359 "unread",
2360 String::new(),
2361 folder_views.iter().map(clone_folder_view).collect(),
2362 loose_feeds.iter().map(clone_feed_view).collect(),
2363 true,
2364 );
2365
2366 let tmpl = ManageTemplate {
2367 version: VERSION,
2368 repo_url: REPO_URL,
2369 kofi_url: KOFI_URL,
2370 flash: q.flash.unwrap_or_default(),
2371 nav,
2372 folder_options,
2373 folders: folder_views,
2374 loose_feeds,
2375 };
2376 Ok(render(&tmpl))
2377}
2378
2379/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2380/// (`Nav`) and the page body without an extra DB round-trip.
2381fn clone_feed_view(f: &FeedView) -> FeedView {
2382 FeedView {
2383 rkey: f.rkey.clone(),
2384 url: f.url.clone(),
2385 title: f.title.clone(),
2386 unread: f.unread,
2387 selected: f.selected,
2388 folder: f.folder.clone(),
2389 }
2390}
2391
2392fn clone_folder_view(f: &FolderView) -> FolderView {
2393 FolderView {
2394 rkey: f.rkey.clone(),
2395 uri: f.uri.clone(),
2396 name: f.name.clone(),
2397 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2398 selected: f.selected,
2399 }
2400}
2401
2402/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2403/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2404/// unscoped "everything" view. A folder scope takes the feed scope when both are
2405/// somehow present (feed wins, matching the query precedence elsewhere).
2406fn scope_urls_for(
2407 subs: &[ResolvedSub],
2408 feed: Option<&str>,
2409 folder: Option<&str>,
2410) -> Option<Vec<String>> {
2411 if let Some(feed_url) = feed {
2412 Some(vec![feed_url.to_string()])
2413 } else {
2414 folder.map(|folder_uri| {
2415 subs.iter()
2416 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2417 .map(|s| s.sub.url.clone())
2418 .collect()
2419 })
2420 }
2421}
2422
2423/// The `at://` URI for a folder record given the owner DID + rkey.
2424fn folder_uri(did: &str, rkey: &str) -> String {
2425 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2426}
2427
2428/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2429/// DID — the shared source for both the reader index and the rail on every
2430/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2431async fn build_sidebar(
2432 state: &AppState,
2433 did: &str,
2434 subs: &[ResolvedSub],
2435 selected_feed: Option<&str>,
2436 selected_folder: Option<&str>,
2437) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2438 let pool = &state.db;
2439 // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2440 // all — purely to `.filter().count()` them in Rust, on every page that
2441 // renders chrome, which made the sidebar the most frequently executed
2442 // instance of the unbounded-projection problem.
2443 let unread_counts = store::unread_counts_by_feed(pool, did)
2444 .await
2445 .unwrap_or_else(|err| {
2446 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2447 Default::default()
2448 });
2449 let folders = state
2450 .repo()
2451 .list_folders_sorted(did)
2452 .await
2453 .unwrap_or_default();
2454
2455 let unread_count = |feed_id: Option<i64>| -> i64 {
2456 feed_id
2457 .and_then(|id| unread_counts.get(&id).copied())
2458 .unwrap_or(0)
2459 };
2460 let mk_feed_view = |s: &ResolvedSub| FeedView {
2461 rkey: s.rkey.clone(),
2462 url: s.sub.url.clone(),
2463 title: display_title(
2464 s.sub
2465 .title
2466 .as_deref()
2467 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2468 &s.sub.url,
2469 ),
2470 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2471 selected: selected_feed == Some(s.sub.url.as_str()),
2472 folder: s.sub.folder.clone(),
2473 };
2474
2475 let mut folder_views = Vec::with_capacity(folders.len());
2476 for (rkey, folder) in &folders {
2477 let uri = folder_uri(did, rkey);
2478 let feeds: Vec<FeedView> = subs
2479 .iter()
2480 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2481 .map(mk_feed_view)
2482 .collect();
2483 folder_views.push(FolderView {
2484 rkey: rkey.clone(),
2485 uri: uri.clone(),
2486 name: folder.name.clone(),
2487 feeds,
2488 selected: selected_folder == Some(uri.as_str()),
2489 });
2490 }
2491
2492 let known_uris: std::collections::HashSet<String> =
2493 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2494 let loose_feeds: Vec<FeedView> = subs
2495 .iter()
2496 .filter(|s| {
2497 s.sub
2498 .folder
2499 .as_deref()
2500 .map(|f| !known_uris.contains(f))
2501 .unwrap_or(true)
2502 })
2503 .map(mk_feed_view)
2504 .collect();
2505
2506 let folder_options: Vec<FolderOption> = folders
2507 .iter()
2508 .map(|(rkey, folder)| FolderOption {
2509 name: folder.name.clone(),
2510 uri: folder_uri(did, rkey),
2511 })
2512 .collect();
2513
2514 (folder_views, loose_feeds, folder_options)
2515}
2516
2517/// Assemble the shared rail [`Nav`] for a chrome page.
2518fn build_nav(
2519 user: &CurrentUser,
2520 view: &str,
2521 scope_qs: String,
2522 folders: Vec<FolderView>,
2523 loose_feeds: Vec<FeedView>,
2524 manage_active: bool,
2525) -> Nav {
2526 Nav {
2527 handle: display_handle(user.handle.as_deref(), &user.did),
2528 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2529 view: view.to_string(),
2530 scope_qs,
2531 folders,
2532 loose_feeds,
2533 manage_active,
2534 }
2535}
2536
2537// ---------------------------------------------------------------------------
2538// Reader: single entry
2539// ---------------------------------------------------------------------------
2540
2541/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2542/// prev/next and "back" stay within the list the reader came from.
2543#[derive(Debug, Deserialize, Default)]
2544struct EntryQuery {
2545 #[serde(default)]
2546 feed: Option<String>,
2547 #[serde(default)]
2548 folder: Option<String>,
2549 #[serde(default)]
2550 view: Option<String>,
2551}
2552
2553/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2554/// within the current reading list.
2555async fn entry_view(
2556 State(state): State<AppState>,
2557 headers: HeaderMap,
2558 Path(id): Path<i64>,
2559 Query(q): Query<EntryQuery>,
2560) -> Result<Response, WebError> {
2561 let user = match current_session(&state, &headers).await {
2562 Some(u) => u,
2563 None => return Ok(Redirect::to("/login").into_response()),
2564 };
2565 let did = user.did.clone();
2566 let pool = &state.db;
2567
2568 // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2569 // the per-DID entry gate below authorizes against the caller's current PDS
2570 // subscription set (not another user's cached feeds).
2571 let subs = resolve_subscriptions(&state, &did).await;
2572
2573 let entry = match get_entry_by_id(pool, &did, id).await? {
2574 Some(e) => e,
2575 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2576 };
2577
2578 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2579
2580 let read = entry_is_read(pool, &did, id).await?;
2581 let starred = entry_is_starred(pool, &did, id).await?;
2582
2583 // Reconstruct the current list to compute prev/next, so paging in the reader
2584 // matches what the list showed.
2585 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2586
2587 let back_qs = scope_query(&q);
2588
2589 let (folder_views, loose_feeds, _) =
2590 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2591 let nav_view = match q.view.as_deref() {
2592 Some("all") => "all",
2593 Some("starred") => "starred",
2594 _ => "unread",
2595 };
2596 let nav = build_nav(
2597 &user,
2598 nav_view,
2599 back_qs.clone(),
2600 folder_views,
2601 loose_feeds,
2602 false,
2603 );
2604
2605 let tmpl = EntryTemplate {
2606 version: VERSION,
2607 repo_url: REPO_URL,
2608 kofi_url: KOFI_URL,
2609 nav,
2610 id: entry.id,
2611 title: entry
2612 .title
2613 .clone()
2614 .filter(|t| !t.trim().is_empty())
2615 .unwrap_or_else(|| "(untitled)".to_string()),
2616 feed_title,
2617 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2618 published: display_date(entry.published.as_deref()),
2619 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2620 content_html: entry.content_html.clone(),
2621 read,
2622 starred,
2623 back_qs,
2624 prev_id,
2625 next_id,
2626 oob: false,
2627 };
2628 Ok(render(&tmpl))
2629}
2630
2631/// Compute the prev/next entry ids around `current` within the reader's current
2632/// scope + view, so the reader view can offer keyboard/paging navigation.
2633async fn neighbors_in_scope(
2634 state: &AppState,
2635 did: &str,
2636 q: &EntryQuery,
2637 current: i64,
2638) -> (Option<i64>, Option<i64>) {
2639 let idx_q = IndexQuery {
2640 feed: q.feed.clone(),
2641 folder: q.folder.clone(),
2642 view: q.view.clone(),
2643 // Neighbours span the whole list, not the page the reader arrived from.
2644 page: None,
2645 flash: None,
2646 };
2647 let ids = list_entry_ids(state, did, &idx_q).await;
2648 let pos = ids.iter().position(|&x| x == current);
2649 match pos {
2650 Some(p) => {
2651 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2652 let next = ids.get(p + 1).copied();
2653 (prev, next)
2654 }
2655 None => (None, None),
2656 }
2657}
2658
2659/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2660/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2661async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2662 let pool = &state.db;
2663 let subs = resolve_subscriptions(state, did).await;
2664
2665 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2666
2667 // Ids only, and bounded. This used to fetch whole entries — bodies included
2668 // — for all three views and then throw everything but `id` away; the "all"
2669 // branch additionally ran one unbounded query PER FEED and sorted the union
2670 // in memory. Scope is now a feed-id restriction inside the query, so the
2671 // database does the filtering and the ordering exactly once.
2672 store::list_entry_ids(
2673 pool,
2674 did,
2675 list_view_of(q.view.as_deref()),
2676 scoped_feed_ids(&subs, &scope_urls).as_deref(),
2677 PREV_NEXT_MAX,
2678 )
2679 .await
2680 .unwrap_or_else(|err| {
2681 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2682 Vec::new()
2683 })
2684}
2685
2686/// Map the `?view=` query value onto the store's list view. Anything
2687/// unrecognised is the unread default, matching `index`.
2688fn list_view_of(view: Option<&str>) -> store::ListView {
2689 match view {
2690 Some("all") => store::ListView::All,
2691 Some("starred") => store::ListView::Starred,
2692 _ => store::ListView::Unread,
2693 }
2694}
2695
2696/// Translate a feed/folder scope into the feed ids to restrict a list query to.
2697///
2698/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
2699/// matched no local feed, which must return nothing rather than everything — so
2700/// the empty vec is deliberately preserved, not collapsed back into `None`.
2701fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2702 let urls = scope_urls.as_ref()?;
2703 Some(
2704 subs.iter()
2705 .filter(|s| urls.contains(&s.sub.url))
2706 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2707 .collect(),
2708 )
2709}
2710
2711/// Build a `?…` query string that preserves the reading scope + view for links.
2712fn scope_query(q: &EntryQuery) -> String {
2713 let mut parts = Vec::new();
2714 if let Some(f) = q.feed.as_deref() {
2715 parts.push(format!("feed={}", qenc(f)));
2716 }
2717 if let Some(f) = q.folder.as_deref() {
2718 parts.push(format!("folder={}", qenc(f)));
2719 }
2720 if let Some(v) = q.view.as_deref() {
2721 if v != "unread" {
2722 parts.push(format!("view={}", qenc(v)));
2723 }
2724 }
2725 parts.join("&")
2726}
2727
2728// ---------------------------------------------------------------------------
2729// Mark read / unread
2730// ---------------------------------------------------------------------------
2731
2732/// Form body for `POST /entries/:id/read`.
2733#[derive(Debug, Deserialize)]
2734struct ReadForm {
2735 #[serde(default)]
2736 read: Option<String>,
2737}
2738
2739/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
2740async fn mark_read(
2741 State(state): State<AppState>,
2742 Path(id): Path<i64>,
2743 headers: HeaderMap,
2744 Form(form): Form<ReadForm>,
2745) -> Result<Response, WebError> {
2746 let did = match current_did(&state, &headers).await {
2747 Some(d) => d,
2748 None => return Ok(Redirect::to("/login").into_response()),
2749 };
2750 let pool = &state.db;
2751
2752 let read = matches!(
2753 form.read.as_deref(),
2754 Some("true") | Some("1") | Some("on") | None
2755 );
2756
2757 // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2758 // mutation: `mark_read` only writes when `did` subscribes to the entry's
2759 // feed. A non-subscriber gets a 404, never a mutation of someone else's
2760 // (or the shared cache's) state.
2761 resolve_subscriptions(&state, &did).await;
2762 if !store::mark_read(pool, &did, id, read).await? {
2763 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2764 }
2765
2766 if !is_htmx(&headers) {
2767 return Ok(Redirect::to("/").into_response());
2768 }
2769
2770 // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
2771 // in the DOM), so its button's hidden value + aria-pressed update in place
2772 // and a second keypress can reverse the toggle. The list view swaps the row.
2773 if is_reader_request(&headers) {
2774 let starred = entry_is_starred(pool, &did, id).await?;
2775 return Ok(render(&EntryActionBarTemplate {
2776 id,
2777 read,
2778 starred,
2779 oob: true,
2780 }));
2781 }
2782
2783 let row = build_entry_row(pool, &did, id, Some(read)).await?;
2784 match row {
2785 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2786 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2787 }
2788}
2789
2790// ---------------------------------------------------------------------------
2791// Star / save
2792// ---------------------------------------------------------------------------
2793
2794/// Form body for `POST /entries/:id/star`.
2795#[derive(Debug, Deserialize)]
2796struct StarForm {
2797 #[serde(default)]
2798 starred: Option<String>,
2799}
2800
2801/// `POST /entries/:id/star` — star/unstar an entry.
2802///
2803/// Sets the local `starred` bit (fast working copy) and writes/removes a
2804/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
2805/// owning). The PDS write is best-effort — the local star still lands.
2806async fn toggle_star(
2807 State(state): State<AppState>,
2808 Path(id): Path<i64>,
2809 headers: HeaderMap,
2810 Form(form): Form<StarForm>,
2811) -> Result<Response, WebError> {
2812 let did = match current_did(&state, &headers).await {
2813 Some(d) => d,
2814 None => return Ok(Redirect::to("/login").into_response()),
2815 };
2816 let pool = &state.db;
2817
2818 let starred = matches!(
2819 form.starred.as_deref(),
2820 Some("true") | Some("1") | Some("on") | None
2821 );
2822
2823 // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2824 // mutation: `mark_starred` only writes when `did` subscribes to the entry's
2825 // feed. A non-subscriber gets a 404, never a mutation.
2826 resolve_subscriptions(&state, &did).await;
2827 if !store::mark_starred(pool, &did, id, starred).await? {
2828 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2829 }
2830
2831 // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
2832 // to the caller's subscriptions, so this only ever acts on the caller's feed.
2833 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2834 let entry_url = entry.url.clone().unwrap_or_default();
2835 if !entry_url.is_empty() {
2836 if starred {
2837 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2838 saved.title = entry.title.clone();
2839 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2840 saved.entry_id = Some(entry.guid.clone());
2841 match state.repo().add_saved(&did, &saved).await {
2842 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2843 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2844 }
2845 } else {
2846 // Un-star: find and delete the matching saved record by URL.
2847 match state.repo().list_saved(&did).await {
2848 Ok(records) => {
2849 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2850 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2851 warn!(%err, %did, %rkey, "PDS saved delete failed");
2852 }
2853 }
2854 }
2855 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2856 }
2857 }
2858 }
2859 }
2860
2861 if !is_htmx(&headers) {
2862 return Ok(Redirect::to("/").into_response());
2863 }
2864
2865 // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
2866 if is_reader_request(&headers) {
2867 let read = entry_is_read(pool, &did, id).await?;
2868 return Ok(render(&EntryActionBarTemplate {
2869 id,
2870 read,
2871 starred,
2872 oob: true,
2873 }));
2874 }
2875
2876 let row = build_entry_row(pool, &did, id, None).await?;
2877 match row {
2878 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2879 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2880 }
2881}
2882
2883/// The feed URL for a cached feed id, if the row exists.
2884async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2885 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2886 .bind(feed_id)
2887 .fetch_optional(pool)
2888 .await
2889 .ok()
2890 .flatten()
2891}
2892
2893// ---------------------------------------------------------------------------
2894// Mark-all-read
2895// ---------------------------------------------------------------------------
2896
2897/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
2898/// absent means mark everything read.
2899#[derive(Debug, Deserialize, Default)]
2900struct ReadAllQuery {
2901 #[serde(default)]
2902 feed: Option<String>,
2903}
2904
2905/// `POST /read-all` — mark every entry read for the current DID, optionally
2906/// scoped to one feed (mark-all-read per feed or globally).
2907async fn mark_all_read(
2908 State(state): State<AppState>,
2909 headers: HeaderMap,
2910 Query(q): Query<ReadAllQuery>,
2911) -> Result<Response, WebError> {
2912 let did = match current_did(&state, &headers).await {
2913 Some(d) => d,
2914 None => return Ok(Redirect::to("/login").into_response()),
2915 };
2916 let pool = &state.db;
2917
2918 // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
2919 // only ever touch feeds this DID actually subscribes to.
2920 resolve_subscriptions(&state, &did).await;
2921
2922 if let Some(feed_url) = q.feed.as_deref() {
2923 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2924 store::mark_feed_read(pool, &did, feed.id, true).await?;
2925 }
2926 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2927 }
2928
2929 // Global: mark every subscribed feed read. Fan out over the DID's feeds
2930 // (bounded by the per-DID subscription cap) using the batched per-feed path,
2931 // rather than one UPDATE round-trip per unread entry (unbounded) — same end
2932 // state, but O(feeds) statements instead of O(unread entries).
2933 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2934 store::mark_feed_read(pool, &did, feed_id, true).await?;
2935 }
2936 Ok(Redirect::to("/").into_response())
2937}
2938
2939// ---------------------------------------------------------------------------
2940// Subscribe by URL
2941// ---------------------------------------------------------------------------
2942
2943/// Flash for a URL this instance cannot store as a feed — not private, just
2944/// not a kind of feed it supports (an `at://` publication with
2945/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
2946/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
2947/// false promise for a record that may already exist in the user's PDS.
2948const UNSUPPORTED_FEED_URL_REFUSAL: &str =
2949 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
2950
2951/// Refusal message shown when a private/paid feed is submitted. FeatherReader
2952/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
2953/// only for now — a private feed's secret URL is never saved, fetched, or sent
2954/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
2955/// and the boot-smoke can assert on it.
2956const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
2957 FeatherReader stores your subscriptions in your public PDS, so it supports public \
2958 feeds for now — private-feed support arrives when atproto's private data \
2959 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
2960
2961/// Form body for `POST /subscriptions`.
2962#[derive(Debug, Deserialize)]
2963struct SubscribeForm {
2964 url: String,
2965 /// Optional folder `at://` URI to file the new feed under.
2966 #[serde(default)]
2967 folder: Option<String>,
2968}
2969
2970/// `POST /subscriptions` — subscribe by URL.
2971async fn add_subscription(
2972 State(state): State<AppState>,
2973 headers: HeaderMap,
2974 Form(form): Form<SubscribeForm>,
2975) -> Result<Response, WebError> {
2976 let did = match current_did(&state, &headers).await {
2977 Some(d) => d,
2978 None => return Ok(Redirect::to("/login").into_response()),
2979 };
2980 let pool = &state.db;
2981 let input = form.url.trim().to_string();
2982 if input.is_empty() {
2983 return Ok(Redirect::to("/").into_response());
2984 }
2985
2986 // An `at://` paste is refused here, whatever the flag says: this path must
2987 // FETCH what was pasted to find the feed in it, and nothing fetches
2988 // `at://` until the standard.site reader is wired. Letting a well-formed
2989 // one through produced "Couldn't find a feed" and a `warn!` for an
2990 // expected condition; letting a malformed one reach the privacy arm, which
2991 // fails closed as `Private`, told the reader a typo was a paid feed. Only
2992 // `at://` is pre-checked — an http(s) or scheme-less paste keeps its
2993 // "Couldn't find a feed" path below, which is the accurate answer there.
2994 // Case-insensitive, unlike the storage guards: `Url::parse` folds the
2995 // scheme, so `AT://…` would otherwise skip both this and the classifier's
2996 // at:// arm, parse as `at`, and draw the private/paid flash off the rkey.
2997 // This decides a MESSAGE; nothing about storage keys off it.
2998 if input
2999 .get(..crate::atproto::AT_URI_PREFIX.len())
3000 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX))
3001 {
3002 info!(url = %input, %did, "refused an at:// paste: the add path cannot fetch one (not stored)");
3003 return Ok(
3004 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3005 .into_response(),
3006 );
3007 }
3008
3009 // Block private/paid feeds BEFORE any fetch/resolve so a secret-bearing URL is
3010 // never even requested. Public feeds only until atproto permissioned data
3011 // ships; there is no override and nothing is stored or written.
3012 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
3013 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3014 return Ok(
3015 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3016 );
3017 }
3018
3019 // Per-DID subscription cap: bound one account's storage/poller footprint on
3020 // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3021 // can't even trigger an outbound request. `<= 0` disables the cap.
3022 let cap = state.config.max_subs_per_did;
3023 if cap > 0 {
3024 match store::count_subscriptions_for_did(pool, &did).await {
3025 Ok(n) if n >= cap => {
3026 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3027 return Ok(Redirect::to(&format!(
3028 "/?flash={}",
3029 qenc(&format!(
3030 "Subscription limit reached ({cap}). Remove a feed before adding another."
3031 ))
3032 ))
3033 .into_response());
3034 }
3035 Ok(_) => {}
3036 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3037 }
3038 }
3039
3040 let feed_url = match resolve_feed_url(&state.config, &input).await {
3041 Ok(u) => u,
3042 Err(err) => {
3043 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3044 return Ok(Redirect::to(&format!(
3045 "/?flash={}",
3046 qenc("Couldn't find a feed at that URL")
3047 ))
3048 .into_response());
3049 }
3050 };
3051
3052 // Defensive: resolution may have discovered a feed URL that itself carries a
3053 // secret (e.g. a public site page linking a tokened feed). Re-check the
3054 // resolved URL and refuse before storing/writing anything.
3055 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3056 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3057 return Ok(
3058 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3059 );
3060 }
3061
3062 // The URL about to be STORED is what must be storable — not the one the
3063 // user typed. Autodiscovery already yields only http(s), but this is the
3064 // path that writes the row and the PDS record, so the check lives here too:
3065 // the same gate the OPML and rename paths apply, on the same terms.
3066 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3067 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3068 return Ok(
3069 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3070 .into_response(),
3071 );
3072 }
3073
3074 // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3075 // cache is full (an existing/duplicate feed URL is always fine — it adds no
3076 // row). Bounds total cache size across all users on the box. `<= 0` disables.
3077 let feeds_cap = state.config.max_feeds_global;
3078 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3079 match store::count_feeds(pool).await {
3080 Ok(n) if n >= feeds_cap => {
3081 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3082 return Ok(Redirect::to(&format!(
3083 "/?flash={}",
3084 qenc(
3085 "This instance is at its feed capacity right now. Please try again later."
3086 )
3087 ))
3088 .into_response());
3089 }
3090 Ok(_) => {}
3091 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3092 }
3093 }
3094
3095 store::upsert_feed(
3096 pool,
3097 &store::NewFeed {
3098 url: feed_url.clone(),
3099 ..Default::default()
3100 },
3101 )
3102 .await?;
3103
3104 if let Ok(client) = feed::build_client() {
3105 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3106 match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
3107 {
3108 Ok(outcome) => {
3109 info!(feed = %feed_url, ?outcome, "polled new subscription");
3110 // **This path is not the scheduler, so it must settle the
3111 // error columns itself.** `poll_feed` writes validators and
3112 // `last_polled` and nothing else.
3113 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3114 }
3115 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3116 }
3117 }
3118 }
3119
3120 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3121 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3122 sub.title = feed_row.title.clone();
3123 sub.site_url = feed_row.site_url.clone();
3124 }
3125 sub.folder = form
3126 .folder
3127 .map(|f| f.trim().to_string())
3128 .filter(|f| !f.is_empty());
3129
3130 match state.repo().add_subscription(&did, &sub).await {
3131 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3132 Err(err) => {
3133 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3134 }
3135 }
3136
3137 Ok(Redirect::to("/").into_response())
3138}
3139
3140/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3141async fn delete_subscription(
3142 State(state): State<AppState>,
3143 headers: HeaderMap,
3144 Path(rkey): Path<String>,
3145) -> Result<Response, WebError> {
3146 let did = match current_did(&state, &headers).await {
3147 Some(d) => d,
3148 None => return Ok(Redirect::to("/login").into_response()),
3149 };
3150 match state.repo().remove_subscription(&did, &rkey).await {
3151 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3152 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3153 }
3154 Ok(Redirect::to("/").into_response())
3155}
3156
3157/// Form body for `POST /subscriptions/:rkey/rename`.
3158#[derive(Debug, Deserialize)]
3159struct RenameSubForm {
3160 url: String,
3161 #[serde(default)]
3162 title: Option<String>,
3163 #[serde(default)]
3164 site_url: Option<String>,
3165 #[serde(default)]
3166 folder: Option<String>,
3167}
3168
3169/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3170/// folder, rewriting the whole subscription record via `putRecord`.
3171async fn rename_subscription(
3172 State(state): State<AppState>,
3173 headers: HeaderMap,
3174 Path(rkey): Path<String>,
3175 Form(form): Form<RenameSubForm>,
3176) -> Result<Response, WebError> {
3177 let did = match current_did(&state, &headers).await {
3178 Some(d) => d,
3179 None => return Ok(Redirect::to("/login").into_response()),
3180 };
3181 let feed_url = form.url.trim().to_string();
3182
3183 // Reject an empty/blank resolved URL — a rename with no usable URL must not
3184 // write a junk row to the cache or a malformed subscription record to the
3185 // PDS (add_subscription refuses an empty input the same way).
3186 if feed_url.is_empty() {
3187 return Ok(Redirect::to("/").into_response());
3188 }
3189
3190 // **Read before write — `update_subscription` is a `putRecord`, and a
3191 // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3192 //
3193 // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3194 // and hand that over, so every field the form does not carry was written
3195 // back as its default. `templates/manage_row.html` posts `url`, `title` and
3196 // `folder` — and nothing else — so a rename silently destroyed four fields:
3197 // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3198 //
3199 // `createdAt` is the one that matters most: it is the reader's subscribe
3200 // time, it is the sort key for "when did I subscribe", it lives in THEIR
3201 // repo rather than our cache, and once overwritten it is gone with nothing
3202 // in the UI to say so.
3203 //
3204 // There is no single-record read on `Repo` (no `getRecord`), so this lists
3205 // and filters. That is one extra round trip on an action that is already
3206 // doing a PDS write, and it is bounded; a `get_subscription` would be
3207 // strictly better if this ever measures badly.
3208 //
3209 // **A failed read refuses the rename.** Falling back to the old
3210 // rebuild-from-scratch here would reinstate the data loss on exactly the
3211 // flaky path, which is the worst place to have it. The write below already
3212 // takes this stance — "a failure here means nothing was renamed or moved" —
3213 // and the read gets the same one.
3214 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3215 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3216 Err(err) => {
3217 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3218 return Ok(Redirect::to(&format!(
3219 "/?flash={}",
3220 qenc("Could not reach your PDS — nothing was renamed or moved.")
3221 ))
3222 .into_response());
3223 }
3224 };
3225 let Some(existing) = existing else {
3226 // The rkey is not in the reader's repo. Renaming a record that is not
3227 // there would CREATE one, which is not what "rename" means and would
3228 // give it a fresh `createdAt` — the bug this read exists to prevent.
3229 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3230 return Ok(Redirect::to(&format!(
3231 "/?flash={}",
3232 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3233 ))
3234 .into_response());
3235 };
3236
3237 // The subscription can be repointed at a different feed URL. **Every gate
3238 // on the URL applies to a repoint and only a repoint** — the three below
3239 // were each, at one time, run before this line on the URL as posted, and
3240 // each refused a pure retitle of a record that already existed:
3241 //
3242 // - privacy: the narrowed at:// arm fails closed as `Private` for an
3243 // at-URI that is not a publication (a feed generator another client
3244 // subscribed to), so the record became un-editable with a flash saying
3245 // it "was not saved or sent anywhere";
3246 // - the global feeds ceiling keyed on "URL not in the cache", and an
3247 // at:// record is never cached with the flag off, so at capacity a
3248 // retitle was refused for a row the handler would not insert;
3249 // - storability, the same way.
3250 //
3251 // An unchanged URL is already in the reader's repo; refusing to retitle
3252 // it protects nothing and takes their own record away from them.
3253 // Like for like: the form value is trimmed, and a record another client
3254 // wrote may carry padding — compared raw, every retitle of it was a repoint.
3255 let url_changed = existing.url.trim() != feed_url;
3256
3257 // **Storability, on the same terms as the add and OPML paths — for a
3258 // REPOINT, and FIRST.** A target this instance cannot store gets that
3259 // answer, not "private" (the at:// arm fails closed) or "at capacity"
3260 // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3261 // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3262 // here; a review found it by enumerating every writer of the table. The
3263 // first fix ran this check before the repo lookup, on the URL as posted —
3264 // which refused a pure retitle of a subscription that already IS an
3265 // at-URI, on every instance with the flag off. The flag gates what the
3266 // cache may store, not whether a reader may edit their own record: an
3267 // unchanged non-storable URL keeps its PDS write and simply gets no cache
3268 // row below.
3269 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3270 if url_changed && !storable {
3271 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3272 return Ok(
3273 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3274 .into_response(),
3275 );
3276 }
3277
3278 // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3279 // and rename both upserts it to the local cache AND rewrites the PDS
3280 // subscription record (a public `putRecord`), so without this guard a
3281 // crafted rename could land a secret-bearing URL in the public PDS — the
3282 // exact leak the add and OPML paths already prevent.
3283 if url_changed {
3284 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3285 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3286 return Ok(
3287 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3288 );
3289 }
3290 }
3291
3292 // Global feeds ceiling parity with add_subscription: a repoint to a
3293 // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3294 // shared cache is at capacity (an existing/duplicate URL adds no row and
3295 // is always fine). `<= 0` disables.
3296 let feeds_cap = state.config.max_feeds_global;
3297 if url_changed
3298 && feeds_cap > 0
3299 && store::get_feed_by_url(&state.db, &feed_url)
3300 .await?
3301 .is_none()
3302 {
3303 match store::count_feeds(&state.db).await {
3304 Ok(n) if n >= feeds_cap => {
3305 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3306 return Ok(Redirect::to(&format!(
3307 "/?flash={}",
3308 qenc(
3309 "This instance is at its feed capacity right now. Please try again later."
3310 )
3311 ))
3312 .into_response());
3313 }
3314 Ok(_) => {}
3315 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3316 }
3317 }
3318
3319 let mut sub = existing;
3320 sub.url = feed_url;
3321 sub.title = form
3322 .title
3323 .map(|t| t.trim().to_string())
3324 .filter(|t| !t.is_empty());
3325 sub.folder = form
3326 .folder
3327 .map(|f| f.trim().to_string())
3328 .filter(|f| !f.is_empty());
3329 // `createdAt` and `private` carry over untouched — neither is a property of
3330 // which feed URL the subscription points at.
3331 //
3332 // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3333 // repoint drops them rather than leaving a site link for the old feed
3334 // hanging off the new one. An explicit form value still wins if the form
3335 // ever starts carrying one.
3336 match form
3337 .site_url
3338 .map(|t| t.trim().to_string())
3339 .filter(|t| !t.is_empty())
3340 {
3341 Some(site) => sub.site_url = Some(site),
3342 None if url_changed => sub.site_url = None,
3343 None => {}
3344 }
3345 if url_changed {
3346 sub.fetch_hint = None;
3347 }
3348
3349 // Keep the local cache title in step for the loose-feed fallback path —
3350 // for a row this instance would have. Two cases write nothing:
3351 //
3352 // - not storable (an existing at-URI with the flag off): the record is the
3353 // reader's to edit, the cache row is not this instance's to create;
3354 // - an unchanged URL with no cache row: a retitle is never the write that
3355 // CREATES a row. That covers two findings at once — the ceiling is
3356 // checked on a repoint only, so a retitle must not insert past it; and
3357 // a secret-bearing URL another client subscribed to has no row (the
3358 // privacy gate above runs on a repoint only, and `resolve_subscriptions`
3359 // refuses to cache it), so it cannot enter the shared table here, be
3360 // polled, fail, and be printed on the admin page. A privacy re-check on
3361 // this write was the first draft; mutation showed it dead — the row
3362 // rule already refused every case it would have.
3363 let cache_write =
3364 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3365 if !cache_write {
3366 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3367 } else if let Err(err) = store::upsert_feed(
3368 &state.db,
3369 &store::NewFeed {
3370 url: sub.url.clone(),
3371 title: sub.title.clone(),
3372 site_url: sub.site_url.clone(),
3373 ..Default::default()
3374 },
3375 )
3376 .await
3377 {
3378 // Not fatal to the rename — the PDS record below is the source of truth
3379 // — but a missing `feeds` row means this subscription is never polled.
3380 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3381 }
3382
3383 // **The PDS write decides what the reader is told.**
3384 //
3385 // This used to `warn!` on failure and then redirect exactly as it does on
3386 // success, so a rename that did not happen was indistinguishable from one
3387 // that did — the reader saw their old title come back and had no reason to
3388 // think anything had gone wrong. The PDS record IS the subscription; a
3389 // failure here means nothing was renamed or moved.
3390 match state.repo().update_subscription(&did, &rkey, &sub).await {
3391 Ok(res) => {
3392 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3393 Ok(Redirect::to("/").into_response())
3394 }
3395 Err(err) => {
3396 warn!(%err, %did, %rkey, "PDS subscription update failed");
3397 Ok(Redirect::to(&format!(
3398 "/?flash={}",
3399 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3400 ))
3401 .into_response())
3402 }
3403 }
3404}
3405
3406// ---------------------------------------------------------------------------
3407// Folders
3408// ---------------------------------------------------------------------------
3409
3410/// Form body for `POST /folders`.
3411#[derive(Debug, Deserialize)]
3412struct FolderForm {
3413 name: String,
3414}
3415
3416/// `POST /folders` — create a folder record.
3417async fn create_folder(
3418 State(state): State<AppState>,
3419 headers: HeaderMap,
3420 Form(form): Form<FolderForm>,
3421) -> Result<Response, WebError> {
3422 let did = match current_did(&state, &headers).await {
3423 Some(d) => d,
3424 None => return Ok(Redirect::to("/login").into_response()),
3425 };
3426 let name = form.name.trim();
3427 if name.is_empty() {
3428 return Ok(Redirect::to("/").into_response());
3429 }
3430 let folder = Folder::new(name.to_string(), now_rfc3339());
3431 match state.repo().add_folder(&did, &folder).await {
3432 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3433 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3434 }
3435 Ok(Redirect::to("/").into_response())
3436}
3437
3438/// `POST /folders/:rkey/rename` — rename a folder record.
3439async fn rename_folder(
3440 State(state): State<AppState>,
3441 headers: HeaderMap,
3442 Path(rkey): Path<String>,
3443 Form(form): Form<FolderForm>,
3444) -> Result<Response, WebError> {
3445 let did = match current_did(&state, &headers).await {
3446 Some(d) => d,
3447 None => return Ok(Redirect::to("/login").into_response()),
3448 };
3449 let name = form.name.trim();
3450 if name.is_empty() {
3451 return Ok(Redirect::to("/").into_response());
3452 }
3453 let folder = Folder::new(name.to_string(), now_rfc3339());
3454 match state.repo().rename_folder(&did, &rkey, &folder).await {
3455 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3456 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3457 }
3458 Ok(Redirect::to("/").into_response())
3459}
3460
3461/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3462/// simply become un-foldered).
3463async fn delete_folder(
3464 State(state): State<AppState>,
3465 headers: HeaderMap,
3466 Path(rkey): Path<String>,
3467) -> Result<Response, WebError> {
3468 let did = match current_did(&state, &headers).await {
3469 Some(d) => d,
3470 None => return Ok(Redirect::to("/login").into_response()),
3471 };
3472 match state.repo().remove_folder(&did, &rkey).await {
3473 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3474 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3475 }
3476 Ok(Redirect::to("/").into_response())
3477}
3478
3479/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3480/// feed document we take it as-is; if it yields an HTML page we run
3481/// autodiscovery over its `<link rel="alternate">` tags.
3482async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3483 let parsed =
3484 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3485
3486 let client = feed::build_client()?;
3487 // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3488 // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3489 // loopback / private hosts.
3490 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3491 let final_url = resp.url().clone();
3492 let content_type = resp
3493 .headers()
3494 .get(axum::http::header::CONTENT_TYPE)
3495 .and_then(|v| v.to_str().ok())
3496 .unwrap_or("")
3497 .to_ascii_lowercase();
3498 // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3499 // gzip strips it, and this response is reflected into the UI.
3500 let raw = crate::net::read_capped(resp).await?;
3501 let body = String::from_utf8_lossy(&raw).into_owned();
3502
3503 let looks_like_feed = content_type.contains("xml")
3504 || content_type.contains("rss")
3505 || content_type.contains("atom")
3506 || content_type.contains("application/feed+json")
3507 || {
3508 let head = body.trim_start();
3509 head.starts_with("<?xml")
3510 || head.starts_with("<rss")
3511 || head.starts_with("<feed")
3512 || head.contains("<rss")
3513 || head.contains("<feed")
3514 };
3515 if looks_like_feed {
3516 return Ok(final_url.to_string());
3517 }
3518
3519 match feed::discover_feed(&body, Some(&final_url)) {
3520 Some(u) => Ok(u.to_string()),
3521 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3522 }
3523}
3524
3525// ---------------------------------------------------------------------------
3526// Login (atproto OAuth via the sidecar)
3527// ---------------------------------------------------------------------------
3528
3529/// Query for `GET /login`.
3530#[derive(Debug, Deserialize, Default)]
3531struct LoginQuery {
3532 #[serde(default)]
3533 handle: Option<String>,
3534 #[serde(default)]
3535 error: Option<String>,
3536 #[serde(default)]
3537 flash: Option<String>,
3538}
3539
3540/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3541///
3542/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3543/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3544/// session cookie *or* the submitted handle resolving to a seated DID) or a
3545/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3546/// form (no handle) always renders.
3547async fn login_form(
3548 State(state): State<AppState>,
3549 headers: HeaderMap,
3550 Query(q): Query<LoginQuery>,
3551) -> Response {
3552 if let Some(handle) = q
3553 .handle
3554 .map(|h| h.trim().to_string())
3555 .filter(|h| !h.is_empty())
3556 {
3557 if !may_start_oauth(&state, &headers, &handle).await {
3558 return Redirect::to("/beta/redeem").into_response();
3559 }
3560 return start_oauth(&state, &handle).await;
3561 }
3562 render(&LoginTemplate {
3563 repo_url: REPO_URL,
3564 error: q.error.unwrap_or_default(),
3565 flash: q.flash.unwrap_or_default(),
3566 })
3567}
3568
3569/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3570/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3571async fn login_submit(
3572 State(state): State<AppState>,
3573 headers: HeaderMap,
3574 Form(form): Form<LoginForm>,
3575) -> Response {
3576 let handle = form.handle.trim();
3577 if handle.is_empty() {
3578 return login_error("Enter your atproto handle.");
3579 }
3580 if !may_start_oauth(&state, &headers, handle).await {
3581 return Redirect::to("/beta/redeem").into_response();
3582 }
3583 start_oauth(&state, handle).await
3584}
3585
3586/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3587/// admits, in order of cost:
3588///
3589/// 1. an existing beta member's cookie session whose DID already holds a seat;
3590/// 2. a fresh visitor carrying a valid reserving invite cookie;
3591/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3592/// already holds a seat — this honors the **seeded admin's first login** on a
3593/// fresh deploy (and any returning member who cleared cookies) without a
3594/// session cookie or an invite code.
3595///
3596/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3597/// handle→DID resolution is only attempted when neither applies. It fails
3598/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3599/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3600/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3601/// resolution instead of a burned sidecar handshake (and `/login` is already in
3602/// the rate-limited path set).
3603async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3604 // The production resolver is the app's existing atproto handle→DID path,
3605 // routed through the SSRF guard. Resolution is injected so tests can exercise
3606 // the gate without a live network call (the guard forbids loopback mocks).
3607 may_start_oauth_with(state, headers, handle, |h| async move {
3608 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3609 .await
3610 .ok()
3611 })
3612 .await
3613}
3614
3615/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3616/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3617/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3618/// only called when neither admits — keeping the network round-trip off the hot
3619/// path and preserving the fail-closed contract on resolution failure.
3620async fn may_start_oauth_with<F, Fut>(
3621 state: &AppState,
3622 headers: &HeaderMap,
3623 handle: &str,
3624 resolve: F,
3625) -> bool
3626where
3627 F: FnOnce(String) -> Fut,
3628 Fut: std::future::Future<Output = Option<String>>,
3629{
3630 // 1. An already-beta'd session may re-auth freely.
3631 if let Some(did) = current_did(state, headers).await {
3632 if store::has_beta_access(&state.db, &did)
3633 .await
3634 .unwrap_or(false)
3635 {
3636 return true;
3637 }
3638 }
3639 // 2. A valid reserving invite cookie.
3640 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3641 return true;
3642 }
3643 // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
3644 // a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
3645 // on any resolution error or unresolvable/malformed handle.
3646 match resolve(handle.to_string()).await {
3647 Some(did) => store::has_beta_access(&state.db, &did)
3648 .await
3649 .unwrap_or(false),
3650 None => {
3651 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3652 false
3653 }
3654 }
3655}
3656
3657/// Begin the OAuth handshake for `handle`, on whichever backend is live.
3658///
3659/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
3660/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
3661/// carries `form-action 'self'`. Browsers have historically disagreed about
3662/// whether that directive applies to redirects following a form submission, and
3663/// if it did here, login would break in a browser while every test passed.
3664///
3665/// It does not, and the evidence is the SIDECAR path, which is live in
3666/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
3667/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
3668/// whole redirect chain would already be blocking that. One checking only the
3669/// form's action URL sees `/login` in both cases. The two arms differ only in
3670/// how many same-origin hops precede the cross-origin one, so any policy that
3671/// permits the sidecar flow permits this one.
3672///
3673/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
3674/// its own `/login` and its own callback, so starting a login is one redirect
3675/// and nothing is stored here. The Rust backend pushes the authorization
3676/// request itself, which means this app now holds the pending login — and must
3677/// set the browser-binding cookie that the callback will be checked against.
3678async fn start_oauth(state: &AppState, handle: &str) -> Response {
3679 match state.config.repo_backend {
3680 crate::metrics::Backend::Sidecar => {
3681 let url = state.sidecar.login_url(handle, None);
3682 info!(%handle, "redirecting to OAuth sidecar login");
3683 Redirect::to(&url).into_response()
3684 }
3685 crate::metrics::Backend::Rust => {
3686 let Some(runtime) = state.oauth.as_deref() else {
3687 warn!("the rust backend is live but its OAuth runtime is absent");
3688 return login_error("Login is not available right now.");
3689 };
3690 match crate::oauth::login::start(
3691 runtime,
3692 &state.http,
3693 &state.db,
3694 handle,
3695 crate::store::now_unix(),
3696 )
3697 .await
3698 {
3699 Ok(started) => {
3700 info!(%handle, "pushed authorization request; redirecting to the PDS");
3701 let mut resp = Redirect::to(&started.authorize_url).into_response();
3702 set_cookie(
3703 &mut resp,
3704 &cookie::sign_value(
3705 OAUTH_BINDING_COOKIE,
3706 &started.binding_token,
3707 &state.config.cookie_secret,
3708 OAUTH_BINDING_MAX_AGE_SECS,
3709 ),
3710 );
3711 resp
3712 }
3713 Err(err) => {
3714 // The handle the user typed is logged; the error is not shown
3715 // to them verbatim, since it can name internal hosts.
3716 warn!(%err, %handle, "could not start the OAuth login");
3717 login_error("Could not start login for that handle.")
3718 }
3719 }
3720 }
3721 }
3722}
3723
3724/// Clear the browser-binding cookie. Called on every terminal outcome of a
3725/// callback, successful or not: the pending row is consumed either way, so a
3726/// lingering cookie can only ever match a login that no longer exists.
3727fn clear_binding_cookie(resp: &mut Response) {
3728 set_cookie(
3729 resp,
3730 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3731 );
3732}
3733
3734/// Form body for `POST /login`.
3735#[derive(Debug, Deserialize)]
3736struct LoginForm {
3737 handle: String,
3738}
3739
3740/// Query for `GET /oauth/callback`.
3741///
3742/// Carries BOTH shapes, because the two backends deliver different things to
3743/// the same URL: the sidecar hands back a one-shot `session_id` it has already
3744/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
3745/// for this app to exchange itself. Which fields are populated is decided by
3746/// which backend started the login, not by which is live now — so a flip with a
3747/// login already in flight still lands in the right arm.
3748#[derive(Debug, Deserialize, Default)]
3749struct CallbackQuery {
3750 /// Sidecar backend: the handoff id.
3751 #[serde(default)]
3752 session_id: Option<String>,
3753 /// Rust backend: the authorization code and its envelope.
3754 #[serde(default)]
3755 code: Option<String>,
3756 #[serde(default)]
3757 state: Option<String>,
3758 #[serde(default)]
3759 iss: Option<String>,
3760 /// JARM, which is not supported — carried only so it can be refused
3761 /// explicitly rather than read as "no code".
3762 #[serde(default)]
3763 response: Option<String>,
3764 #[serde(default)]
3765 error: Option<String>,
3766 #[serde(default)]
3767 error_description: Option<String>,
3768}
3769
3770/// `GET /oauth/callback` — establish the cookie session.
3771///
3772/// **Invite gate:** the verified DID must hold beta access. If it already does
3773/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
3774/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
3775/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
3776async fn oauth_callback(
3777 State(state): State<AppState>,
3778 headers: HeaderMap,
3779 Query(q): Query<CallbackQuery>,
3780) -> Response {
3781 // An error response is handled by the SAME arm that would have handled a
3782 // success, not short-circuited here.
3783 //
3784 // Returning early looks obviously right and is wrong on the Rust path: it
3785 // skips `verify_callback`, which validates `iss` BEFORE reporting the error
3786 // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
3787 // error originates from the intended AS". It also leaves the pending row
3788 // unconsumed, so a `state` that has already produced a callback stays usable
3789 // until it expires.
3790 //
3791 // The sidecar arm has no such check to reach, so it is short-circuited
3792 // below, preserving exactly what it did before.
3793 // **The arm is chosen by what the SERVER knows, not by what the caller
3794 // sent.** A `session_id` in the query used to select the sidecar arm on its
3795 // own — so a caller could pick which code path ran, and the sidecar arm has
3796 // no browser-binding check at all. It also short-circuited the error path
3797 // below, skipping the `iss` validation.
3798 //
3799 // Requiring the Rust runtime to be absent, or a sidecar backend to be the
3800 // configured one, means the selection follows this deployment's own
3801 // configuration. A login started before a flip still completes, because the
3802 // Rust arm is reached whenever the Rust runtime exists and can match the
3803 // `state` against a pending row it actually wrote.
3804 // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
3805 // and `?error=…&error_description=…` on its own failure. Keying only on
3806 // `session_id` sent the failure shape down the Rust arm, which then failed
3807 // with "no `state`" and replaced the specific reason with a generic one —
3808 // and `error_description` is exactly what the sidecar Caddy routing matches
3809 // to send that request here in the first place.
3810 let sidecar_shape =
3811 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3812 let sidecar_handoff = sidecar_shape
3813 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3814 if let Some(err) = q.error.clone() {
3815 // **Neither the code nor the description is echoed as sent.**
3816 //
3817 // Both are server-controlled free text arriving on a public GET, so
3818 // anyone who can make a browser fetch this URL chooses them. The raw
3819 // `error` used to go into a `warn!` AND into the rendered login page,
3820 // and `error_description` — arbitrary text, newlines included — went
3821 // into the log verbatim: a log-injection surface on one side and
3822 // attacker-chosen copy in the product's own voice on the other.
3823 //
3824 // `oauth::flow` already decided this exact question for the Rust arm:
3825 // reduce the code to a known slug, drop the description entirely. That
3826 // reasoning is not specific to which arm handles the callback, and this
3827 // one simply never got the same treatment. The description's LENGTH is
3828 // kept, because "the server sent a 4 KB explanation" is occasionally
3829 // worth knowing and cannot be used to inject anything.
3830 let slug = crate::oauth::flow::known_error_slug(&err);
3831 warn!(
3832 error = slug,
3833 desc_len = q.error_description.as_deref().map_or(0, str::len),
3834 "OAuth callback returned an error"
3835 );
3836 if sidecar_handoff || state.oauth.is_none() {
3837 return login_error(&format!("Login failed: {slug}"));
3838 }
3839 // Fall through: the Rust arm consumes the pending row and validates
3840 // `iss` against it, and reports the failure afterwards.
3841 }
3842
3843 // Which arm runs is decided by WHAT ARRIVED, not by which backend is
3844 // currently selected: a login started before a flip must still complete.
3845 let session = if sidecar_handoff {
3846 let session_id = q.session_id.clone().unwrap_or_default();
3847 match state.sidecar.resolve_session(&session_id).await {
3848 Ok(Some(s)) => s,
3849 Ok(None) => {
3850 warn!("OAuth callback session_id did not resolve (expired/unknown)");
3851 return login_error("Login session expired — please try again.");
3852 }
3853 Err(err) => {
3854 warn!(%err, "failed to resolve OAuth session via the sidecar");
3855 return login_error("Login failed talking to the auth service.");
3856 }
3857 }
3858 } else {
3859 let Some(runtime) = state.oauth.as_deref() else {
3860 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3861 return login_error("Login failed: this login could not be completed.");
3862 };
3863 let params = crate::oauth::flow::CallbackParams {
3864 code: q.code.clone(),
3865 state: q.state.clone(),
3866 iss: q.iss.clone(),
3867 // Passed through, NOT dropped: `verify_callback` checks `iss`
3868 // against the pending row's issuer before it reports the error, and
3869 // it cannot do that for an error it never sees.
3870 error: q.error.clone(),
3871 error_description: q.error_description.clone(),
3872 response: q.response.clone(),
3873 };
3874 let binding =
3875 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
3876 match crate::oauth::login::complete(
3877 runtime,
3878 &state.http,
3879 &state.db,
3880 ¶ms,
3881 binding.as_deref(),
3882 crate::store::now_unix(),
3883 )
3884 .await
3885 {
3886 Ok(done) => crate::atproto::SidecarSession {
3887 did: done.did,
3888 handle: done.handle,
3889 },
3890 Err(err) => {
3891 // Never echoed to the browser: the message can name the issuer,
3892 // the PDS, and why a binding check failed.
3893 warn!(%err, "could not complete the OAuth callback");
3894 let mut resp = login_error("Login failed — please try again.");
3895 clear_binding_cookie(&mut resp);
3896 return resp;
3897 }
3898 }
3899 };
3900
3901 // Bind the verified DID to the invite gate. Returns a response only on the
3902 // (rare) failure paths; `Ok(())` means the DID now holds beta access.
3903 let mut clear_invite = false;
3904 if !store::has_beta_access(&state.db, &session.did)
3905 .await
3906 .unwrap_or(false)
3907 {
3908 // Not yet a member: consume the reserved invite code, if any.
3909 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
3910 Some(c) => c,
3911 None => {
3912 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
3913 return Redirect::to("/beta/redeem").into_response();
3914 }
3915 };
3916 match store::redeem_code(
3917 &state.db,
3918 &code,
3919 &session.did,
3920 session.handle.as_deref(),
3921 state.config.beta_cap,
3922 )
3923 .await
3924 {
3925 Ok(Ok(())) => {
3926 clear_invite = true;
3927 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
3928 }
3929 Ok(Err(policy)) => {
3930 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
3931 let mut resp = redeem_bounce(&policy).into_response();
3932 // The reservation is spent/invalid — drop the stale invite cookie.
3933 clear_invite_cookie(&mut resp);
3934 return resp;
3935 }
3936 Err(err) => {
3937 warn!(%err, did = %session.did, "invite redeem infra error at callback");
3938 return login_error("Login failed while confirming your invite.");
3939 }
3940 }
3941 }
3942
3943 // Mint an opaque, random server-side session id and store the identity under
3944 // it; the cookie carries the (HMAC-signed) sid, never the DID.
3945 let sid = state.sessions.create(Session {
3946 did: session.did.clone(),
3947 handle: session.handle.clone(),
3948 });
3949 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
3950 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
3951
3952 let mut resp = Redirect::to("/").into_response();
3953 set_cookie(&mut resp, &cookie);
3954 clear_binding_cookie(&mut resp);
3955 if clear_invite {
3956 clear_invite_cookie(&mut resp);
3957 }
3958 resp
3959}
3960
3961/// Revoke a DID's OAuth session on BOTH backends, best-effort.
3962///
3963/// Not "whichever backend is live": during a cutover a user's tokens can be in
3964/// either store — they logged in under one backend and are logging out under
3965/// the other. Revoking only the live one would leave a live refresh token
3966/// behind in the other, which is the exact failure sign-out exists to prevent,
3967/// and it would be invisible because the sign-out itself looks successful.
3968///
3969/// Both arms are best-effort. The caller has already decided to sign the user
3970/// out, and a network failure must not trap them in a half-logged-out state.
3971/// How long sign-out will wait for a final read-state flush before revoking
3972/// anyway.
3973///
3974/// Bounded because the flush talks to the user's PDS, and a user trying to leave
3975/// must never be held by a server that is not answering. Three seconds is long
3976/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
3977/// and short enough that a dead PDS is an inconvenience rather than a trap.
3978const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
3979
3980/// Flush whatever read-state is still dirty for `did`, then give up quietly.
3981///
3982/// **Called before revoking, because revoking first strands it (#117).**
3983/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
3984/// session cannot be sent by anyone — it parks until the user signs in again,
3985/// which may be never. Flushing first is what stops the common case from
3986/// becoming that.
3987///
3988/// Best-effort by construction: every failure path here falls through to the
3989/// revoke. A flush that times out or errors leaves the cursors dirty, which is
3990/// the parked state the flusher now handles deliberately rather than retrying
3991/// forever.
3992async fn flush_before_revoke(state: &AppState, did: &str) {
3993 match tokio::time::timeout(
3994 SIGN_OUT_FLUSH_BUDGET,
3995 crate::readstate::flush_did(state, did),
3996 )
3997 .await
3998 {
3999 Ok(Ok(())) => {}
4000 Ok(Err(err)) => {
4001 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4002 }
4003 Err(_) => warn!(
4004 %did,
4005 budget = ?SIGN_OUT_FLUSH_BUDGET,
4006 "sign-out: final read-state flush timed out; it will park until next sign-in"
4007 ),
4008 }
4009}
4010
4011async fn revoke_everywhere(state: &AppState, did: &str) {
4012 // **Counted under Backend::Sidecar, not left uncounted.** A review found
4013 // that recording only the rust arm let `oauth_revoke` report a clean success
4014 // while every sidecar revocation failed — and for anyone who logged in before
4015 // the cutover, the sidecar store is the ONLY one that held tokens, so the
4016 // rust arm correctly returns NoSession and the metric reads all-clear while
4017 // live refresh tokens sit at the PDS.
4018 //
4019 // Same op name, different backend: the backend column is what distinguishes
4020 // them, so "no revocation failures" means checking both rows, not one.
4021 let sidecar_started = std::time::Instant::now();
4022 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4023 Ok(res) => {
4024 info!(%did, revoked = res.revoked, "sidecar session revoked");
4025 true
4026 }
4027 Err(err) => {
4028 warn!(%did, %err, "sidecar revoke failed; continuing");
4029 false
4030 }
4031 };
4032 state.metrics.record(
4033 crate::metrics::Backend::Sidecar,
4034 "oauth_revoke",
4035 sidecar_started.elapsed().as_micros() as u64,
4036 sidecar_ok,
4037 );
4038
4039 if let Some(runtime) = state.oauth.as_deref() {
4040 let revoke_started = std::time::Instant::now();
4041 let outcome = crate::oauth::revoke::sign_out_discovering(
4042 runtime,
4043 &state.http,
4044 &state.db,
4045 did,
4046 crate::store::now_unix(),
4047 )
4048 .await;
4049 // **Counted, because a warn! nobody reads is not observability.** Until
4050 // this existed, a revocation failure left exactly one trace: a log line.
4051 // "No revocation failures this week" was therefore a statement about
4052 // nobody having looked, which is not the same claim.
4053 //
4054 // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4055 // there being nothing to revoke is the correct outcome, not a failure,
4056 // and counting it as an error would make the metric noisy in exactly
4057 // the case that is fine. Only `Failed` means the PDS still holds live
4058 // tokens we asked it to drop.
4059 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4060 state.metrics.record(
4061 crate::metrics::Backend::Rust,
4062 "oauth_revoke",
4063 revoke_started.elapsed().as_micros() as u64,
4064 revoke_ok,
4065 );
4066 match outcome {
4067 crate::oauth::revoke::Revocation::Revoked => {
4068 info!(%did, "rust OAuth session revoked at the PDS")
4069 }
4070 crate::oauth::revoke::Revocation::NoSession => {}
4071 crate::oauth::revoke::Revocation::Failed(reason) => {
4072 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4073 }
4074 }
4075 }
4076}
4077
4078/// `POST /logout` — end the session everywhere, not just in this browser.
4079///
4080/// Clearing the cookie only stops *this* device from presenting the session;
4081/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4082/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4083/// access tokens at the PDS and drops the sidecar's session rows. The local
4084/// registry entry is dropped and the cookie cleared regardless of whether the
4085/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4086/// user in a half-logged-out state).
4087async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4088 if let Some(user) = current_session(&state, &headers).await {
4089 // Only a real cookie session (`sid` present) has sidecar-held tokens to
4090 // revoke; the dev-DID fallback never handshook the sidecar.
4091 if let Some(sid) = user.sid {
4092 state.sessions.remove(&sid);
4093 // BEFORE the revoke: afterwards there is no session to send it with.
4094 flush_before_revoke(&state, &user.did).await;
4095 revoke_everywhere(&state, &user.did).await;
4096 }
4097 }
4098 let mut resp = Redirect::to("/login").into_response();
4099 set_cookie(
4100 &mut resp,
4101 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4102 );
4103 resp
4104}
4105
4106/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4107/// `DELETE` into this field for the purge to run.
4108#[derive(Debug, Deserialize)]
4109struct DeleteAccountForm {
4110 #[serde(default)]
4111 confirm: String,
4112}
4113
4114/// The literal a user must type to confirm the destructive delete.
4115const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4116
4117/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4118///
4119/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4120/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4121/// 1. purges **every** local row owned by the caller DID (`entry_state`,
4122/// `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4123/// DID created) via [`store::purge_did_data`], then
4124/// 2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4125/// revoked at the PDS and the sidecar's session rows are dropped, then
4126/// 3. drops the in-memory session and clears the cookie, signing the user out.
4127///
4128/// The subscription/folder/saved *records* in the user's own PDS are
4129/// intentionally left alone — they are the user's data on their own server; the
4130/// `/about` copy and this page's UI both say so, and export stays available.
4131async fn account_delete(
4132 State(state): State<AppState>,
4133 headers: HeaderMap,
4134 Form(form): Form<DeleteAccountForm>,
4135) -> Result<Response, WebError> {
4136 let user = match current_session(&state, &headers).await {
4137 Some(u) => u,
4138 None => return Ok(Redirect::to("/login").into_response()),
4139 };
4140 let did = user.did.clone();
4141
4142 // Confirm-gate: require the exact typed phrase before doing anything.
4143 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4144 return Ok(Redirect::to(&format!(
4145 "/manage?flash={}",
4146 qenc("Type DELETE to confirm — nothing was deleted.")
4147 ))
4148 .into_response());
4149 }
4150
4151 // 1. Purge every local row this DID owns (single transaction).
4152 let counts = store::purge_did_data(&state.db, &did).await?;
4153 info!(
4154 %did,
4155 total = counts.total(),
4156 entry_state = counts.entry_state,
4157 read_cursor = counts.read_cursor,
4158 sub_ref = counts.sub_ref,
4159 beta_access = counts.beta_access,
4160 invite_codes = counts.invite_codes,
4161 "account/delete: local rows purged"
4162 );
4163
4164 // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4165 // rows are already gone; a network blip must not block the sign-out).
4166 revoke_everywhere(&state, &did).await;
4167
4168 // 3. Drop the in-memory session and clear the cookie: sign the user out.
4169 if let Some(sid) = user.sid {
4170 state.sessions.remove(&sid);
4171 }
4172 let mut resp = Redirect::to(&format!(
4173 "/login?flash={}",
4174 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4175 ))
4176 .into_response();
4177 set_cookie(
4178 &mut resp,
4179 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4180 );
4181 Ok(resp)
4182}
4183
4184/// Re-render the login form with an error banner.
4185fn login_error(msg: &str) -> Response {
4186 render(&LoginTemplate {
4187 repo_url: REPO_URL,
4188 error: msg.to_string(),
4189 flash: String::new(),
4190 })
4191}
4192
4193// ---------------------------------------------------------------------------
4194// Closed-beta invite gate (self-serve redeem + admin mint)
4195// ---------------------------------------------------------------------------
4196
4197/// Form body for `POST /beta/redeem`.
4198#[derive(Debug, Deserialize)]
4199struct RedeemForm {
4200 code: String,
4201}
4202
4203/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4204/// already full we render the "capacity full" variant (no form).
4205async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4206 let full = store::count_beta_access(&state.db)
4207 .await
4208 .map(|n| n >= state.config.beta_cap)
4209 .unwrap_or(false);
4210 render(&BetaRedeemTemplate {
4211 repo_url: REPO_URL,
4212 error: String::new(),
4213 capacity_full: full,
4214 })
4215}
4216
4217/// `POST /beta/redeem` — the **pre-handshake** reservation.
4218///
4219/// Validates the pasted code is *redeemable right now* (exists, active,
4220/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4221/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4222/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4223/// OAuth callback later binds the verified DID and atomically consumes the code
4224/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4225/// start OAuth (and burn a sidecar handshake).
4226async fn beta_redeem_submit(
4227 State(state): State<AppState>,
4228 Form(form): Form<RedeemForm>,
4229) -> Response {
4230 let code = form.code.trim().to_uppercase();
4231 if code.is_empty() {
4232 return render(&BetaRedeemTemplate {
4233 repo_url: REPO_URL,
4234 error: "Enter your invite code.".to_string(),
4235 capacity_full: false,
4236 });
4237 }
4238
4239 match preflight_code(&state, &code).await {
4240 Ok(()) => {
4241 let cookie = sign_invite(&code, &state.config.cookie_secret);
4242 let mut resp = Redirect::to("/login").into_response();
4243 set_cookie(&mut resp, &cookie);
4244 info!("invite code preflight OK; reserving intent + redirecting to /login");
4245 resp
4246 }
4247 Err(policy) => {
4248 warn!(?policy, "invite code preflight rejected");
4249 redeem_bounce(&policy)
4250 }
4251 }
4252}
4253
4254/// Read-only preflight of an invite code for the pre-handshake reservation:
4255/// verify it exists, is active, is not past `expires_at`, and that a seat is
4256/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4257/// callback time. Does NOT consume the code or grant a seat. Returns the same
4258/// typed [`store::RedeemError`] variants so the two paths share one message map.
4259async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4260 // Cap check first: a clear "capacity full" beats "code invalid" when both.
4261 // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4262 // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4263 // still backstops the real cap inside its tx, so this is a consistency /
4264 // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4265 // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4266 // that might overrun the cap.
4267 let count = match store::count_beta_access(&state.db).await {
4268 Ok(n) => n,
4269 Err(err) => {
4270 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4271 return Err(store::RedeemError::CapacityFull);
4272 }
4273 };
4274 if count >= state.config.beta_cap {
4275 return Err(store::RedeemError::CapacityFull);
4276 }
4277 // Look up the code's current status + expiry (read-only).
4278 let row = sqlx::query_as::<_, (String, i64)>(
4279 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4280 )
4281 .bind(code)
4282 .fetch_optional(&state.db)
4283 .await
4284 .ok()
4285 .flatten();
4286 let (status, expires_at) = match row {
4287 Some(r) => r,
4288 None => return Err(store::RedeemError::NotFound),
4289 };
4290 let now = chrono::Utc::now().timestamp();
4291 match status.as_str() {
4292 "active" if expires_at >= now => Ok(()),
4293 "active" => Err(store::RedeemError::Expired),
4294 "expired" => Err(store::RedeemError::Expired),
4295 // "redeemed" or anything else non-active.
4296 _ => Err(store::RedeemError::AlreadyRedeemed),
4297 }
4298}
4299
4300/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4301/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4302fn redeem_bounce(policy: &store::RedeemError) -> Response {
4303 use store::RedeemError::*;
4304 let (msg, capacity_full) = match policy {
4305 NotFound => ("That invite code isn't valid.", false),
4306 Expired => ("That invite code has expired.", false),
4307 AlreadyRedeemed => ("That invite code has already been used.", false),
4308 CapacityFull => ("", true),
4309 };
4310 render(&BetaRedeemTemplate {
4311 repo_url: REPO_URL,
4312 error: msg.to_string(),
4313 capacity_full,
4314 })
4315}
4316
4317/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4318#[derive(Debug, Deserialize, Default)]
4319struct MintQuery {
4320 #[serde(default)]
4321 n: Option<u32>,
4322}
4323
4324/// `POST /admin/invites?n=N` — mint N invite codes.
4325///
4326/// `GET /oauth/client-metadata.json` — the client's published identity.
4327///
4328/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4329/// caches it against every existing grant, so it must keep answering at exactly
4330/// this path across the cutover — the sidecar serves the same document at the
4331/// same URL today, proxied by the edge.
4332///
4333/// Served whatever backend is live: a request that arrives here is from a PDS
4334/// resolving our identity, and it has no idea which of our two implementations
4335/// is currently answering repo calls.
4336async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4337 let Some(runtime) = state.oauth.as_deref() else {
4338 // The sidecar is serving this path in front of us, or nothing is.
4339 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4340 };
4341 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4342}
4343
4344/// `GET /oauth/jwks.json` — the client's public signing key.
4345///
4346/// Production only. The localhost dev client is a PUBLIC client: it registers no
4347/// key and signs no assertions, so publishing a JWKS there would advertise a
4348/// credential that is never used — and would make a dev deployment look like a
4349/// confidential client to anyone reading it.
4350async fn oauth_jwks(State(state): State<AppState>) -> Response {
4351 let Some(runtime) = state.oauth.as_deref() else {
4352 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4353 };
4354 match runtime.client_key.as_ref() {
4355 Some(key) => match key.jwks_document() {
4356 Ok(doc) => axum::Json(doc).into_response(),
4357 Err(err) => {
4358 warn!(%err, "could not render the client JWKS");
4359 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4360 }
4361 },
4362 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4363 }
4364}
4365
4366/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4367const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4368
4369/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4370///
4371/// Admin-gated on the same rule as the invite minter: the table names every
4372/// operation the reader performs and how often each fails, which is an
4373/// operational picture rather than public information.
4374///
4375/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4376/// is safe, and the comparison is two rows side by side.
4377async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4378 let did = match current_did(&state, &headers).await {
4379 Some(d) => d,
4380 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4381 };
4382 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4383 warn!(%did, "admin metrics denied: not an admin-seed DID");
4384 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4385 }
4386
4387 // Flush first, so the table includes this process's traffic up to now.
4388 // Then read the PERSISTED rows, which is the only place both backends can
4389 // appear at once -- a flip is a restart, and in-process memory only ever
4390 // holds the backend currently running.
4391 if let Err(err) =
4392 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4393 {
4394 warn!(%err, "could not flush repo timings before rendering");
4395 }
4396 let rows = match crate::metrics::persisted_rows(&state.db).await {
4397 Ok(rows) => rows,
4398 Err(err) => {
4399 warn!(%err, "could not read persisted repo timings");
4400 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4401 }
4402 };
4403
4404 // The live backend is named at the top: a table of two populated rows is
4405 // ambiguous about which one is currently serving users.
4406 // Parked read-state, alongside the timings. The flusher no longer logs
4407 // these every round (#117), so without a number here the state would be
4408 // silent — which is the failure the noisy loop at least did not have.
4409 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4410 Ok(n) => n.to_string(),
4411 Err(err) => {
4412 warn!(%err, "could not count parked read-state DIDs");
4413 "unknown".to_string()
4414 }
4415 };
4416 // **The half the public histogram cannot carry.** `/stats` reports counts by
4417 // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4418 // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4419 // cannot separate "the publishers are gone" from "we are broken". #159 was
4420 // the latter and took a production investigation to establish. Named feeds
4421 // and their error text belong here, behind ALLOWED_DIDS.
4422 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4423 Ok(f) => f,
4424 Err(err) => {
4425 warn!(%err, "could not list failing feeds");
4426 Vec::new()
4427 }
4428 };
4429 let mut failing_block = String::new();
4430 if !failing.is_empty() {
4431 failing_block.push_str("\nfailing feeds (worst first)\n");
4432 for f in &failing {
4433 failing_block.push_str(&format!(
4434 " {:>4}x {:<8} {}\n {}\n",
4435 f.consecutive_errors,
4436 f.kind.as_deref().unwrap_or("unknown"),
4437 f.url,
4438 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4439 ));
4440 }
4441 }
4442
4443 let body = format!(
4444 "live backend: {}\nparked read-state DIDs: {}\n\n{}{}",
4445 state.config.repo_backend.as_str(),
4446 parked,
4447 crate::metrics::render(&rows),
4448 failing_block,
4449 );
4450 (StatusCode::OK, body).into_response()
4451}
4452
4453/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4454/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4455/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4456async fn admin_mint_invites(
4457 State(state): State<AppState>,
4458 headers: HeaderMap,
4459 Query(q): Query<MintQuery>,
4460) -> Response {
4461 // Require a real, current session (not just a DID string) whose DID is an
4462 // admin-seed DID. `current_did` already re-checks the beta gate.
4463 let did = match current_did(&state, &headers).await {
4464 Some(d) => d,
4465 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4466 };
4467 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4468 warn!(%did, "admin mint denied: not an admin-seed DID");
4469 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4470 }
4471
4472 let n = q.n.unwrap_or(1).clamp(1, 100);
4473 let mut codes = Vec::with_capacity(n as usize);
4474 for _ in 0..n {
4475 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4476 Ok(code) => codes.push(code),
4477 Err(err) => {
4478 warn!(%err, %did, "admin mint_code failed");
4479 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4480 }
4481 }
4482 }
4483 info!(%did, count = codes.len(), "admin minted invite codes");
4484 let mut body = codes.join("\n");
4485 body.push('\n');
4486 (StatusCode::OK, body).into_response()
4487}
4488
4489// ---------------------------------------------------------------------------
4490// Bot claim link: /claim?t=<token> + POST /bot/claims (shared-secret mint)
4491// ---------------------------------------------------------------------------
4492
4493/// Query for `GET /claim`.
4494#[derive(Debug, Deserialize)]
4495struct ClaimQuery {
4496 /// The opaque claim token from the bot's public follow-back skeet.
4497 t: Option<String>,
4498}
4499
4500/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4501///
4502/// The follow→invite bot posts a public skeet mentioning a new follower with a
4503/// link here. The token wraps a pre-minted invite code (never the raw code — see
4504/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4505/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4506/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4507/// callback atomically consumes the code (`store::redeem_code`) — the same
4508/// machinery as a pasted code. On any failure it bounces to the invite page with
4509/// the matching message.
4510///
4511/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4512/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4513/// here rejects an already-used / expired / capacity-full code before reserving,
4514/// so a replayed link past the first successful claim is refused. The residual
4515/// window is the same as any pasted invite code: whoever completes OAuth *first*
4516/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4517/// blunts brute-force enumeration.
4518async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4519 let token = match q.t {
4520 Some(t) if !t.is_empty() => t,
4521 _ => {
4522 warn!("claim link with no token");
4523 return redeem_bounce(&store::RedeemError::NotFound);
4524 }
4525 };
4526
4527 // Unwrap the token → the invite code it reserves. A tampered/forged token
4528 // yields nothing → treat as an invalid code (don't leak whether it parsed).
4529 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4530 Some(c) => c,
4531 None => {
4532 warn!("claim token invalid (bad signature / malformed)");
4533 return redeem_bounce(&store::RedeemError::NotFound);
4534 }
4535 };
4536
4537 // Re-run the same preflight as the pasted-code path: exists, active,
4538 // unexpired, seat free. This is what makes a replayed link past first-claim
4539 // (or past cap) fail cleanly.
4540 match preflight_code(&state, &code).await {
4541 Ok(()) => {
4542 let cookie = sign_invite(&code, &state.config.cookie_secret);
4543 let mut resp = Redirect::to("/login").into_response();
4544 set_cookie(&mut resp, &cookie);
4545 info!("claim token preflight OK; reserving intent + redirecting to /login");
4546 resp
4547 }
4548 Err(policy) => {
4549 warn!(?policy, "claim token preflight rejected");
4550 redeem_bounce(&policy)
4551 }
4552 }
4553}
4554
4555/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4556///
4557/// Passing the follower DID makes the APP the authoritative deduper: the app can
4558/// short-circuit a DID that already holds a seat, and return the SAME code for a
4559/// DID that already has an outstanding claim — so a bot-host state loss cannot
4560/// re-mint or re-post per follower. Handle is advisory (logs only).
4561#[derive(Debug, Default, Deserialize)]
4562struct BotClaimRequest {
4563 /// The follower's DID (the idempotency key). Optional for backward-compat: an
4564 /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4565 #[serde(default)]
4566 did: Option<String>,
4567 /// The follower's handle (advisory; recorded for operator logs only).
4568 #[serde(default)]
4569 #[allow(dead_code)]
4570 handle: Option<String>,
4571}
4572
4573/// The JSON body `POST /bot/claims` returns on success.
4574#[derive(Debug, serde::Serialize)]
4575struct BotClaimResponse {
4576 /// Server-side dedupe outcome, so the bot knows whether to post:
4577 /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
4578 /// already had an outstanding claim; the SAME code/token/url is returned, so an
4579 /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
4580 /// beta access; code/token/url are empty and the bot should post NOTHING).
4581 status: &'static str,
4582 /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
4583 /// store. NEVER post this publicly; post the `url` instead. Empty when
4584 /// `already_seated`.
4585 code: String,
4586 /// The opaque claim token (the code wrapped + signed). Empty when
4587 /// `already_seated`.
4588 token: String,
4589 /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
4590 /// Empty when `already_seated`.
4591 url: String,
4592}
4593
4594/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
4595///
4596/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
4597/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
4598/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
4599/// (503), so a bare/dev instance never exposes an unauthenticated mint.
4600///
4601/// Server-side DID idempotency (the authoritative dedupe backstop): the request
4602/// body carries the follower `did`. The app — not the bot's local SQLite — is the
4603/// source of truth, so a bot-host state loss cannot re-mint or re-post per
4604/// follower:
4605/// * DID already holds beta access → `200 {status:"already_seated"}` (empty
4606/// code/url; the bot marks it handled and posts NOTHING);
4607/// * DID already has an outstanding active claim → `200 {status:"existing"}`
4608/// returning the SAME code/token/url (idempotent — never a second mint);
4609/// * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
4610///
4611/// Cap accounting: the bot must not promise more claims than seats remain, so
4612/// this refuses with `409 Conflict {"error":"full"}` when
4613/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
4614/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
4615/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
4616/// minting past the cap.
4617///
4618/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
4619/// default 14d — the admin browser flow's 30-min TTL would expire before the
4620/// follower taps an async-delivered link).
4621async fn bot_mint_claim(
4622 State(state): State<AppState>,
4623 headers: HeaderMap,
4624 body: axum::body::Bytes,
4625) -> Response {
4626 // 1. The endpoint is OFF unless a bot secret is configured.
4627 let bot_secret = match state.config.bot_secret.as_deref() {
4628 Some(s) => s,
4629 None => {
4630 warn!(
4631 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4632 );
4633 return (
4634 StatusCode::SERVICE_UNAVAILABLE,
4635 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4636 )
4637 .into_response();
4638 }
4639 };
4640
4641 // 2. Constant-time bearer check on the X-Bot-Secret header.
4642 let presented = headers
4643 .get("x-bot-secret")
4644 .and_then(|v| v.to_str().ok())
4645 .unwrap_or("");
4646 if !bot_secret_matches(presented, bot_secret) {
4647 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4648 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4649 }
4650
4651 // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
4652 // (legacy caller) parses to an all-None request; a malformed body is a 400.
4653 let req: BotClaimRequest = if body.is_empty() {
4654 BotClaimRequest::default()
4655 } else {
4656 match serde_json::from_slice(&body) {
4657 Ok(r) => r,
4658 Err(err) => {
4659 warn!(%err, "POST /bot/claims: bad JSON body");
4660 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4661 }
4662 }
4663 };
4664 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4665
4666 // 3. Server-side DID idempotency (only when a DID was supplied):
4667 if let Some(did) = follower_did {
4668 // 3a. Already seated → tell the bot to post nothing.
4669 match store::has_beta_access(&state.db, did).await {
4670 Ok(true) => {
4671 info!("bot mint: DID already holds beta access; already_seated");
4672 return bot_claim_json(BotClaimResponse {
4673 status: "already_seated",
4674 code: String::new(),
4675 token: String::new(),
4676 url: String::new(),
4677 });
4678 }
4679 Ok(false) => {}
4680 Err(err) => {
4681 // Fail closed: a DB error must not fall through to a fresh mint.
4682 warn!(%err, "bot mint: has_beta_access failed");
4683 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4684 }
4685 }
4686 // 3b. Outstanding active claim for this DID → return the SAME code (no
4687 // second mint). This is what survives a bot-host state loss.
4688 match store::find_active_code_for_did(&state.db, did).await {
4689 Ok(Some(code)) => {
4690 info!("bot mint: existing outstanding claim for DID; returning same code");
4691 let token = sign_claim_token(&code, &state.config.cookie_secret);
4692 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4693 return bot_claim_json(BotClaimResponse {
4694 status: "existing",
4695 code,
4696 token,
4697 url,
4698 });
4699 }
4700 Ok(None) => {}
4701 Err(err) => {
4702 warn!(%err, "bot mint: find_active_code_for_did failed");
4703 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4704 }
4705 }
4706 }
4707
4708 // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
4709 // FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
4710 let granted = match store::count_beta_access(&state.db).await {
4711 Ok(n) => n,
4712 Err(err) => {
4713 warn!(%err, "bot mint: count_beta_access failed; failing closed");
4714 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4715 }
4716 };
4717 let outstanding = match store::count_active_codes(&state.db).await {
4718 Ok(n) => n,
4719 Err(err) => {
4720 warn!(%err, "bot mint: count_active_codes failed; failing closed");
4721 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4722 }
4723 };
4724 if granted + outstanding >= state.config.beta_cap {
4725 info!(
4726 granted,
4727 outstanding,
4728 cap = state.config.beta_cap,
4729 "bot mint refused: at capacity"
4730 );
4731 return (
4732 StatusCode::CONFLICT,
4733 [(header::CONTENT_TYPE, "application/json")],
4734 "{\"error\":\"full\"}\n",
4735 )
4736 .into_response();
4737 }
4738
4739 // 5. Mint with the generous claim TTL, recording the follower DID (when given)
4740 // so a re-request for the same DID returns THIS code idempotently.
4741 let bot_did = state
4742 .config
4743 .admin_seed_dids()
4744 .first()
4745 .cloned()
4746 .unwrap_or_else(|| "did:bot:featherreader".to_string());
4747 let minted = match follower_did {
4748 Some(did) => {
4749 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4750 }
4751 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4752 };
4753 let code = match minted {
4754 Ok(c) => c,
4755 // S4: the dedupe check (3b) and this mint are separate statements, so two
4756 // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
4757 // The partial unique index `idx_invite_codes_intended_active` makes the
4758 // loser's INSERT fail (only one active row per intended DID), which
4759 // surfaces here as a conflict. Recover by returning the winner's existing
4760 // code (same shape as the 3b idempotent path) instead of a 500.
4761 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4762 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4763 Ok(Some(code)) => {
4764 info!("bot mint: lost the mint race; returning the concurrently-minted code");
4765 let token = sign_claim_token(&code, &state.config.cookie_secret);
4766 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4767 return bot_claim_json(BotClaimResponse {
4768 status: "existing",
4769 code,
4770 token,
4771 url,
4772 });
4773 }
4774 // The winner's row vanished between the conflict and this lookup
4775 // (redeemed/expired/purged in the gap) — nothing to hand back.
4776 // Fail closed rather than silently mint past the just-hit guard.
4777 Ok(None) => {
4778 warn!("bot mint: conflict but no active code found on recovery");
4779 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4780 }
4781 Err(err) => {
4782 warn!(%err, "bot mint: recovery lookup after conflict failed");
4783 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4784 }
4785 }
4786 }
4787 Err(err) => {
4788 warn!(%err, "bot mint_code failed");
4789 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4790 }
4791 };
4792 let token = sign_claim_token(&code, &state.config.cookie_secret);
4793 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4794 info!("bot minted a claim code + token");
4795
4796 bot_claim_json(BotClaimResponse {
4797 status: "minted",
4798 code,
4799 token,
4800 url,
4801 })
4802}
4803
4804/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
4805/// `500` if serialization somehow fails).
4806fn bot_claim_json(resp: BotClaimResponse) -> Response {
4807 match serde_json::to_string(&resp) {
4808 Ok(body) => (
4809 StatusCode::OK,
4810 [(header::CONTENT_TYPE, "application/json")],
4811 body,
4812 )
4813 .into_response(),
4814 Err(err) => {
4815 warn!(%err, "serializing bot claim response failed");
4816 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4817 }
4818 }
4819}
4820
4821/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
4822/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
4823/// by the HMAC checks so there is one comparator to audit; a length mismatch
4824/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
4825fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4826 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4827}
4828
4829// ---------------------------------------------------------------------------
4830// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
4831// ---------------------------------------------------------------------------
4832
4833/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
4834/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
4835/// itself (base64url) rather than an opaque sid, since the code IS the reserved
4836/// intent the callback consumes.
4837fn sign_invite(code: &str, secret: &str) -> String {
4838 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4839}
4840
4841/// Verify + read the reserved invite code out of the request's invite cookie
4842/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
4843/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
4844/// authority on the code's live status.
4845fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4846 cookie::verify_value(headers, INVITE_COOKIE, secret)
4847}
4848
4849/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
4850/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
4851/// cookie value and vice-versa.
4852const CLAIM_TOKEN_LABEL: &str = "claim-token";
4853
4854/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
4855/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
4856///
4857/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
4858/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
4859/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
4860/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
4861/// code won't verify), the wrapped code is single-use (redeem flips
4862/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
4863/// token one self-contained string needing no server-side token table; it does
4864/// NOT hide the code.
4865fn sign_claim_token(code: &str, secret: &str) -> String {
4866 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
4867}
4868
4869/// Verify a claim token and return the invite code it wraps (`None` on a tampered
4870/// / forged / malformed token). The code's live status (active/unexpired/seat
4871/// free) is re-checked by `preflight_code`; this only proves the token was minted
4872/// by this instance.
4873fn claim_token_code(token: &str, secret: &str) -> Option<String> {
4874 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
4875}
4876
4877/// Clear the invite cookie on a response (after a successful bind, or when the
4878/// reservation turned out to be stale).
4879fn clear_invite_cookie(resp: &mut Response) {
4880 set_cookie(
4881 resp,
4882 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4883 );
4884}
4885
4886// ---------------------------------------------------------------------------
4887// OPML import + export
4888// ---------------------------------------------------------------------------
4889
4890/// `POST /opml` — import subscriptions from an OPML document.
4891///
4892/// Accepts either a multipart file upload (field `file`) or a pasted textarea
4893/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
4894/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
4895/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
4896/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
4897/// they show immediately; polling is left to the background poller.
4898async fn import_opml(
4899 State(state): State<AppState>,
4900 headers: HeaderMap,
4901 mut multipart: Multipart,
4902) -> Result<Response, WebError> {
4903 let did = match current_did(&state, &headers).await {
4904 Some(d) => d,
4905 None => return Ok(Redirect::to("/login").into_response()),
4906 };
4907 let pool = &state.db;
4908
4909 // Collect the OPML text from whichever field carried it. Multipart errors
4910 // are mapped to their axum-native response so that an over-cap upload (the
4911 // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
4912 // `413 Payload Too Large` rather than being swallowed by the blanket
4913 // `WebError` → `500` conversion.
4914 let mut opml_text = String::new();
4915 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
4916 let name = field.name().unwrap_or("").to_string();
4917 if name == "opml" || name == "file" {
4918 let bytes = field.bytes().await.map_err(multipart_response)?;
4919 if !bytes.is_empty() {
4920 opml_text = String::from_utf8_lossy(&bytes).into_owned();
4921 if name == "file" {
4922 break;
4923 }
4924 }
4925 }
4926 }
4927
4928 // A parse FAILURE and an empty-but-valid file are different things, and
4929 // `unwrap_or_default` collapsed them: a malformed export was reported to the
4930 // reader as "No feeds found in that OPML", which sends them looking at their
4931 // old reader for feeds that are right there in the file.
4932 let feeds =
4933 match opml::parse_opml(&opml_text) {
4934 Ok(feeds) => feeds,
4935 Err(err) => {
4936 warn!(%err, %did, "OPML import could not parse the uploaded file");
4937 return Ok(Redirect::to(&format!(
4938 "/?flash={}",
4939 qenc("That file could not be read as OPML. Export it again from your other reader?")
4940 ))
4941 .into_response());
4942 }
4943 };
4944 if feeds.is_empty() {
4945 info!(%did, "OPML import found no feeds");
4946 return Ok(
4947 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
4948 .into_response(),
4949 );
4950 }
4951
4952 // Create any named folders first, mapping folder name → at:// URI so
4953 // subscriptions can reference them.
4954 let now = now_rfc3339();
4955 let mut folder_uris: std::collections::HashMap<String, String> =
4956 std::collections::HashMap::new();
4957 // Reuse existing folders where the name already exists.
4958 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
4959 for (rkey, folder) in existing {
4960 folder_uris
4961 .entry(folder.name.clone())
4962 .or_insert_with(|| folder_uri(&did, &rkey));
4963 }
4964 }
4965 let mut wanted_folders: Vec<String> = feeds
4966 .iter()
4967 .filter_map(|f| f.folder.clone())
4968 .filter(|n| !n.is_empty())
4969 .collect();
4970 wanted_folders.sort();
4971 wanted_folders.dedup();
4972 for name in wanted_folders {
4973 if folder_uris.contains_key(&name) {
4974 continue;
4975 }
4976 let folder = Folder::new(name.clone(), now.clone());
4977 match state.repo().add_folder(&did, &folder).await {
4978 Ok(rkey) => {
4979 folder_uris.insert(name, folder_uri(&did, &rkey));
4980 }
4981 Err(err) => warn!(%err, %did, "OPML folder create failed"),
4982 }
4983 }
4984
4985 // Build one subscription record per PUBLIC feed + upsert the local cache row.
4986 // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
4987 // and reported back to the user — the same public-feeds-only stance as the
4988 // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
4989 // token onto the public network either.
4990 // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
4991 // the remaining headroom (cap − existing) once; public feeds beyond it are
4992 // TRIMMED (not imported) and reported. `<= 0` disables the cap.
4993 let sub_cap = state.config.max_subs_per_did;
4994 let mut headroom: Option<i64> = if sub_cap > 0 {
4995 let existing = store::count_subscriptions_for_did(pool, &did)
4996 .await
4997 .unwrap_or(0);
4998 Some((sub_cap - existing).max(0))
4999 } else {
5000 None
5001 };
5002 let mut trimmed_over_cap: usize = 0;
5003
5004 // Global feeds ceiling: an OPML import must not blow past the shared cache
5005 // ceiling any more than the single-add path may. Seed the remaining global
5006 // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5007 // not already cached) consumes it. Existing/duplicate URLs add no row and
5008 // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5009 // `<= 0` disables the ceiling.
5010 let feeds_cap = state.config.max_feeds_global;
5011 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5012 let existing = store::count_feeds(pool).await.unwrap_or(0);
5013 Some((feeds_cap - existing).max(0))
5014 } else {
5015 None
5016 };
5017 let mut trimmed_over_global: usize = 0;
5018
5019 let mut subs = Vec::with_capacity(feeds.len());
5020 let mut skipped_private: Vec<String> = Vec::new();
5021 // Imported into the PDS but not cached locally, so not pollable until the
5022 // next import touches them. Counted rather than only logged — see below.
5023 let mut uncached: usize = 0;
5024 // Entries this instance cannot store at all (an `at://` publication with
5025 // the flag off, an unsupported scheme). Counted, because the `continue`
5026 // below used to increment nothing while the privacy branch beside it
5027 // produced a label — so an OPML from a standard.site-enabled instance
5028 // imported "successfully" with entries missing and no reason given.
5029 let mut skipped_unsupported: usize = 0;
5030 for f in &feeds {
5031 // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5032 // ever parsed it — the single-add path can't reach here because
5033 // `resolve_feed_url` must parse AND successfully fetch first. So
5034 // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5035 // cached, and published as records to the user's PUBLIC repo. Note that
5036 // `classify_feed_privacy` does not catch these: both parse cleanly, and
5037 // it returns `Public` for anything unparseable by design.
5038 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5039 info!(
5040 %did,
5041 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5042 );
5043 skipped_unsupported += 1;
5044 continue;
5045 }
5046 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5047 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5048 // Report by title where we have one, else the (public-safe) host.
5049 let label = f
5050 .title
5051 .clone()
5052 .filter(|t| !t.trim().is_empty())
5053 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5054 skipped_private.push(label);
5055 continue;
5056 }
5057
5058 // Over-cap: stop importing once headroom is exhausted (count the rest so
5059 // we can tell the user how many were dropped).
5060 if let Some(h) = headroom.as_mut() {
5061 if *h <= 0 {
5062 trimmed_over_cap += 1;
5063 continue;
5064 }
5065 }
5066
5067 // Global ceiling: a brand-new feed URL consumes global headroom. Once
5068 // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5069 // free — they add no row). Checked before decrementing the per-DID
5070 // headroom so a dropped feed doesn't burn the caller's own quota.
5071 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5072 Ok(existing) => existing.is_none(),
5073 // On a lookup error, treat as existing (don't consume global
5074 // headroom) but still allow the upsert to proceed.
5075 Err(err) => {
5076 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5077 false
5078 }
5079 };
5080 if is_new {
5081 if let Some(g) = global_headroom.as_mut() {
5082 if *g <= 0 {
5083 trimmed_over_global += 1;
5084 continue;
5085 }
5086 *g -= 1;
5087 }
5088 }
5089
5090 // Passed both caps: consume the per-DID headroom now that the feed is
5091 // actually being imported.
5092 if let Some(h) = headroom.as_mut() {
5093 *h -= 1;
5094 }
5095
5096 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5097 sub.title = f.title.clone();
5098 sub.site_url = f.site_url.clone();
5099 sub.folder = f
5100 .folder
5101 .as_ref()
5102 .and_then(|name| folder_uris.get(name).cloned());
5103 subs.push(sub);
5104 // Same support ticket as the single-add path: no `feeds` row means the
5105 // poller never selects this subscription, so the import looks like it
5106 // worked and the feed silently never updates. Counted as well as logged,
5107 // because one line per feed in a 200-feed import is not something anyone
5108 // reads — the count goes to the reader.
5109 if let Err(err) = store::upsert_feed(
5110 pool,
5111 &store::NewFeed {
5112 url: f.feed_url.clone(),
5113 title: f.title.clone(),
5114 site_url: f.site_url.clone(),
5115 ..Default::default()
5116 },
5117 )
5118 .await
5119 {
5120 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5121 it will not be polled");
5122 uncached += 1;
5123 }
5124 }
5125
5126 // **A failed PDS write is not an import.**
5127 //
5128 // The subscriptions live in the reader's repo; a local `feeds` row is just a
5129 // poller hint. This used to `warn!` and then report "Imported N feeds"
5130 // regardless, so a total failure read as a total success — and the reader
5131 // would only discover otherwise on their next visit, with an empty sidebar.
5132 let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5133 Ok(rkeys) => {
5134 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5135 true
5136 }
5137 Err(err) => {
5138 warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5139 false
5140 }
5141 };
5142 if !pds_written {
5143 return Ok(Redirect::to(&format!(
5144 "/?flash={}",
5145 qenc(
5146 "Could not save those subscriptions to your PDS, so nothing was imported. \
5147 Try again in a moment."
5148 )
5149 ))
5150 .into_response());
5151 }
5152
5153 // Report the import count, plus any private/paid feeds skipped as unsupported.
5154 let mut flash = format!("Imported {} feeds", subs.len());
5155 if uncached > 0 {
5156 flash.push_str(&format!(
5157 ". {uncached} of them could not be cached locally and may not update until the next import."
5158 ));
5159 }
5160 if trimmed_over_cap > 0 {
5161 flash.push_str(&format!(
5162 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5163 ));
5164 }
5165 if trimmed_over_global > 0 {
5166 flash.push_str(&format!(
5167 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5168 ));
5169 }
5170 if !skipped_private.is_empty() {
5171 flash.push_str(&format!(
5172 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5173 skipped_private.len(),
5174 skipped_private.join(", ")
5175 ));
5176 }
5177 if skipped_unsupported > 0 {
5178 // By count only — the URL is whatever the file said, and unlike the
5179 // private branch there is no public-safe label to give.
5180 flash.push_str(&format!(
5181 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5182 ));
5183 }
5184 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5185}
5186
5187/// A public-safe label for a skipped private feed when it has no title: just the
5188/// host, so we never echo the secret-bearing path/query back to the user.
5189fn private_feed_label(url: &str) -> String {
5190 url::Url::parse(url)
5191 .ok()
5192 .and_then(|u| u.host_str().map(str::to_string))
5193 .unwrap_or_else(|| "a private feed".to_string())
5194}
5195
5196/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5197async fn export_opml(
5198 State(state): State<AppState>,
5199 headers: HeaderMap,
5200) -> Result<Response, WebError> {
5201 let did = match current_did(&state, &headers).await {
5202 Some(d) => d,
5203 None => return Ok(Redirect::to("/login").into_response()),
5204 };
5205
5206 let subs = state
5207 .repo()
5208 .list_subscriptions_sorted(&did)
5209 .await
5210 .unwrap_or_default();
5211 let folders = state
5212 .repo()
5213 .list_folders_sorted(&did)
5214 .await
5215 .unwrap_or_default();
5216 // The exporter matches a subscription's `folder` at-uri against the folder's
5217 // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5218 let folder_pairs: Vec<(String, Folder)> = folders
5219 .into_iter()
5220 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5221 .collect();
5222
5223 let body = opml::to_opml(&subs, &folder_pairs);
5224 let mut resp = (StatusCode::OK, body).into_response();
5225 resp.headers_mut().insert(
5226 header::CONTENT_TYPE,
5227 "text/x-opml; charset=utf-8".parse().unwrap(),
5228 );
5229 resp.headers_mut().insert(
5230 header::CONTENT_DISPOSITION,
5231 "attachment; filename=\"featherreader-subscriptions.opml\""
5232 .parse()
5233 .unwrap(),
5234 );
5235 Ok(resp)
5236}
5237
5238// ---------------------------------------------------------------------------
5239// Signed session cookie (HMAC-SHA256, dependency-free)
5240// ---------------------------------------------------------------------------
5241
5242/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5243fn set_cookie(resp: &mut Response, cookie: &str) {
5244 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5245 resp.headers_mut()
5246 .append(axum::http::header::SET_COOKIE, value);
5247 }
5248}
5249
5250/// Whether the request came from htmx (the `HX-Request` header).
5251fn is_htmx(headers: &HeaderMap) -> bool {
5252 headers
5253 .get("HX-Request")
5254 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5255}
5256
5257/// Whether a mark-read / star request originated from the single-entry READER
5258/// (as opposed to the list view). The reader's forms tag themselves with
5259/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5260/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5261/// isn't in the DOM), the list gets the row (`entry_row.html`).
5262fn is_reader_request(headers: &HeaderMap) -> bool {
5263 headers
5264 .get("X-FR-Reader")
5265 .is_some_and(|v| v.as_bytes() == b"1")
5266}
5267
5268/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5269/// server-minted **session id** (never the DID — so the cookie can't be forged
5270/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5271/// server-side session id).
5272mod cookie {
5273 use super::{HeaderMap, SESSION_COOKIE};
5274
5275 /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5276 pub fn sign_session(sid: &str, secret: &str) -> String {
5277 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5278 }
5279
5280 /// Verify the request's session cookie and return the session id it carries.
5281 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5282 verify_value(headers, SESSION_COOKIE, secret)
5283 }
5284
5285 /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5286 /// value`), so a signature minted for one cookie can't verify under another —
5287 /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5288 /// The NUL separator can't appear in a cookie name, so the encoding is
5289 /// unambiguous.
5290 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5291 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5292 msg.extend_from_slice(name.as_bytes());
5293 msg.push(0);
5294 msg.extend_from_slice(value.as_bytes());
5295 msg
5296 }
5297
5298 /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5299 /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5300 /// generic form behind both the session cookie and the short-lived invite
5301 /// cookie; domain-separating by name keeps a signature valid only for the
5302 /// cookie it was minted for.
5303 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5304 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5305 let b64 = b64url_encode(value.as_bytes());
5306 format!(
5307 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5308 )
5309 }
5310
5311 /// Verify + read a value out of the named signed cookie (`None` on absent /
5312 /// tampered / forged / cross-cookie). The generic form behind both readers.
5313 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5314 let raw = cookie_value(headers, name)?;
5315 let (b64, sig) = raw.split_once('.')?;
5316 let bytes = b64url_decode(b64)?;
5317 let value = String::from_utf8(bytes).ok()?;
5318 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5319 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5320 Some(value)
5321 } else {
5322 None
5323 }
5324 }
5325
5326 /// Sign an arbitrary `value` into an opaque, URL-safe token string
5327 /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5328 /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5329 /// URL query param (the bot's claim link). `label` domain-separates it from
5330 /// the cookies so a token can't be replayed as a cookie value.
5331 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5332 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5333 let b64 = b64url_encode(value.as_bytes());
5334 format!("{b64}.{sig}")
5335 }
5336
5337 /// Verify a token minted by [`sign_token`] and return the wrapped value
5338 /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5339 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5340 let (b64, sig) = token.split_once('.')?;
5341 let bytes = b64url_decode(b64)?;
5342 let value = String::from_utf8(bytes).ok()?;
5343 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5344 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5345 Some(value)
5346 } else {
5347 None
5348 }
5349 }
5350
5351 /// Pull one cookie value out of the `Cookie` request header.
5352 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5353 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5354 for part in header.split(';') {
5355 let part = part.trim();
5356 if let Some((k, v)) = part.split_once('=') {
5357 if k == name {
5358 return Some(v.to_string());
5359 }
5360 }
5361 }
5362 None
5363 }
5364
5365 /// Constant-time byte comparison (avoid signature-timing leaks). Public
5366 /// within the module so the bot-secret bearer check reuses the exact same
5367 /// comparator as the cookie/token HMAC checks (one implementation to audit).
5368 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5369 if a.len() != b.len() {
5370 return false;
5371 }
5372 let mut diff = 0u8;
5373 for (x, y) in a.iter().zip(b.iter()) {
5374 diff |= x ^ y;
5375 }
5376 diff == 0
5377 }
5378
5379 // -- URL-safe base64 (no padding), std-only --------------------------------
5380
5381 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5382
5383 fn b64url_encode(input: &[u8]) -> String {
5384 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5385 for chunk in input.chunks(3) {
5386 let b = [
5387 chunk[0],
5388 *chunk.get(1).unwrap_or(&0),
5389 *chunk.get(2).unwrap_or(&0),
5390 ];
5391 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5392 out.push(B64[((n >> 18) & 63) as usize] as char);
5393 out.push(B64[((n >> 12) & 63) as usize] as char);
5394 if chunk.len() > 1 {
5395 out.push(B64[((n >> 6) & 63) as usize] as char);
5396 }
5397 if chunk.len() > 2 {
5398 out.push(B64[(n & 63) as usize] as char);
5399 }
5400 }
5401 out
5402 }
5403
5404 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5405 fn val(c: u8) -> Option<u32> {
5406 match c {
5407 b'A'..=b'Z' => Some((c - b'A') as u32),
5408 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5409 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5410 b'-' => Some(62),
5411 b'_' => Some(63),
5412 _ => None,
5413 }
5414 }
5415 let bytes = input.as_bytes();
5416 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5417 for chunk in bytes.chunks(4) {
5418 let mut n = 0u32;
5419 let mut valid = 0;
5420 for (i, &c) in chunk.iter().enumerate() {
5421 n |= val(c)? << (18 - 6 * i);
5422 valid += 1;
5423 }
5424 out.push((n >> 16) as u8);
5425 if valid > 2 {
5426 out.push((n >> 8) as u8);
5427 }
5428 if valid > 3 {
5429 out.push(n as u8);
5430 }
5431 }
5432 Some(out)
5433 }
5434
5435 // -- HMAC-SHA256, std-only -------------------------------------------------
5436
5437 /// HMAC-SHA256(key, msg) as lowercase hex.
5438 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5439 const BLOCK: usize = 64;
5440 let mut k = [0u8; BLOCK];
5441 if key.len() > BLOCK {
5442 let d = sha256(key);
5443 k[..32].copy_from_slice(&d);
5444 } else {
5445 k[..key.len()].copy_from_slice(key);
5446 }
5447 let mut ipad = [0x36u8; BLOCK];
5448 let mut opad = [0x5cu8; BLOCK];
5449 for i in 0..BLOCK {
5450 ipad[i] ^= k[i];
5451 opad[i] ^= k[i];
5452 }
5453 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5454 inner.extend_from_slice(&ipad);
5455 inner.extend_from_slice(msg);
5456 let inner_hash = sha256(&inner);
5457 let mut outer = Vec::with_capacity(BLOCK + 32);
5458 outer.extend_from_slice(&opad);
5459 outer.extend_from_slice(&inner_hash);
5460 let mac = sha256(&outer);
5461 let mut hex = String::with_capacity(64);
5462 for b in mac {
5463 hex.push_str(&format!("{b:02x}"));
5464 }
5465 hex
5466 }
5467
5468 /// SHA-256 (FIPS 180-4), std-only.
5469 fn sha256(data: &[u8]) -> [u8; 32] {
5470 const K: [u32; 64] = [
5471 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5472 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5473 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5474 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5475 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5476 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5477 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5478 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5479 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5480 0xc67178f2,
5481 ];
5482 let mut h: [u32; 8] = [
5483 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5484 0x5be0cd19,
5485 ];
5486
5487 let bit_len = (data.len() as u64) * 8;
5488 let mut msg = data.to_vec();
5489 msg.push(0x80);
5490 while msg.len() % 64 != 56 {
5491 msg.push(0);
5492 }
5493 msg.extend_from_slice(&bit_len.to_be_bytes());
5494
5495 for block in msg.chunks(64) {
5496 let mut w = [0u32; 64];
5497 for i in 0..16 {
5498 w[i] = u32::from_be_bytes([
5499 block[i * 4],
5500 block[i * 4 + 1],
5501 block[i * 4 + 2],
5502 block[i * 4 + 3],
5503 ]);
5504 }
5505 for i in 16..64 {
5506 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5507 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5508 w[i] = w[i - 16]
5509 .wrapping_add(s0)
5510 .wrapping_add(w[i - 7])
5511 .wrapping_add(s1);
5512 }
5513 let mut a = h;
5514 for i in 0..64 {
5515 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5516 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5517 let t1 = a[7]
5518 .wrapping_add(s1)
5519 .wrapping_add(ch)
5520 .wrapping_add(K[i])
5521 .wrapping_add(w[i]);
5522 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5523 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5524 let t2 = s0.wrapping_add(maj);
5525 a[7] = a[6];
5526 a[6] = a[5];
5527 a[5] = a[4];
5528 a[4] = a[3].wrapping_add(t1);
5529 a[3] = a[2];
5530 a[2] = a[1];
5531 a[1] = a[0];
5532 a[0] = t1.wrapping_add(t2);
5533 }
5534 for i in 0..8 {
5535 h[i] = h[i].wrapping_add(a[i]);
5536 }
5537 }
5538
5539 let mut out = [0u8; 32];
5540 for (i, word) in h.iter().enumerate() {
5541 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5542 }
5543 out
5544 }
5545
5546 #[cfg(test)]
5547 mod tests {
5548 use super::*;
5549
5550 #[test]
5551 fn sha256_known_vector() {
5552 let d = sha256(b"abc");
5553 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5554 assert_eq!(
5555 hex,
5556 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5557 );
5558 }
5559
5560 #[test]
5561 fn hmac_known_vector() {
5562 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5563 assert_eq!(
5564 mac,
5565 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5566 );
5567 }
5568
5569 #[test]
5570 fn sign_verify_round_trips() {
5571 let secret = "test-secret";
5572 let sid = "9f2c-opaque-session-id";
5573 let cookie = sign_session(sid, secret);
5574 let pair = cookie.split(';').next().unwrap().to_string();
5575 let mut headers = HeaderMap::new();
5576 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5577 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5578 // Wrong secret → rejected (an attacker without the HMAC key can't forge).
5579 assert!(verify_session(&headers, "other-secret").is_none());
5580 }
5581
5582 #[test]
5583 fn forged_and_tampered_cookies_are_rejected() {
5584 let secret = "test-secret";
5585
5586 // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
5587 // the secret, so an arbitrary signature must not verify.
5588 let forged = format!(
5589 "{SESSION_COOKIE}={}.{}",
5590 b64url_encode(b"attacker-chosen-sid"),
5591 "deadbeef".repeat(8) // 64 hex chars, wrong sig
5592 );
5593 let mut headers = HeaderMap::new();
5594 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5595 assert!(verify_session(&headers, secret).is_none());
5596
5597 // 2. A tampered cookie: take a VALID cookie and mutate the sid while
5598 // keeping the original signature — must not verify.
5599 let cookie = sign_session("real-sid", secret);
5600 let pair = cookie.split(';').next().unwrap();
5601 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5602 let tampered = format!(
5603 "{SESSION_COOKIE}={}.{}",
5604 b64url_encode(b"different-sid"),
5605 sig
5606 );
5607 let mut headers2 = HeaderMap::new();
5608 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5609 assert!(verify_session(&headers2, secret).is_none());
5610 }
5611
5612 #[test]
5613 fn b64url_round_trips() {
5614 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5615 let enc = b64url_encode(s.as_bytes());
5616 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5617 }
5618 }
5619 }
5620}
5621
5622// ---------------------------------------------------------------------------
5623// Small store helpers local to the web layer
5624// ---------------------------------------------------------------------------
5625
5626/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
5627///
5628/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
5629/// `did` does not subscribe to its feed. This is the per-DID read gate for the
5630/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
5631/// deduped by URL, but no DID can read another DID's cached article.
5632///
5633/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
5634/// that renders `content_html`, and it fetches exactly one row. The list views
5635/// go through [`store::list_entries`], which is both paged and body-free — see
5636/// [`store::EntryListRow`] for why they had to stop sharing this projection.
5637async fn get_entry_by_id(
5638 pool: &store::Pool,
5639 did: &str,
5640 id: i64,
5641) -> anyhow::Result<Option<store::Entry>> {
5642 let entry = sqlx::query_as::<_, store::Entry>(
5643 r#"
5644 SELECT e.* FROM entries e
5645 WHERE e.id = ?2
5646 AND EXISTS (
5647 SELECT 1 FROM sub_ref sr
5648 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5649 )
5650 "#,
5651 )
5652 .bind(did)
5653 .bind(id)
5654 .fetch_optional(pool)
5655 .await?;
5656 Ok(entry)
5657}
5658
5659/// Whether `entry_id` is marked read for `did` (absent state row = unread).
5660async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5661 let read: Option<bool> =
5662 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5663 .bind(did)
5664 .bind(entry_id)
5665 .fetch_optional(pool)
5666 .await?
5667 .flatten();
5668 Ok(read.unwrap_or(false))
5669}
5670
5671/// Whether `entry_id` is starred for `did` (absent state row = not starred).
5672async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5673 let starred: Option<bool> =
5674 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5675 .bind(did)
5676 .bind(entry_id)
5677 .fetch_optional(pool)
5678 .await?
5679 .flatten();
5680 Ok(starred.unwrap_or(false))
5681}
5682
5683/// Feed display title for one entry's feed id (via a single lookup).
5684async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5685 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5686 .bind(feed_id)
5687 .fetch_optional(pool)
5688 .await
5689 {
5690 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5691 _ => String::new(),
5692 }
5693}
5694
5695/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
5696/// be forced (mark-read path) or looked up (`None` — star path).
5697async fn build_entry_row(
5698 pool: &store::Pool,
5699 did: &str,
5700 id: i64,
5701 read: Option<bool>,
5702) -> anyhow::Result<Option<EntryRow>> {
5703 let entry = match get_entry_by_id(pool, did, id).await? {
5704 Some(e) => e,
5705 None => return Ok(None),
5706 };
5707 let read = match read {
5708 Some(r) => r,
5709 None => entry_is_read(pool, did, id).await?,
5710 };
5711 let starred = entry_is_starred(pool, did, id).await?;
5712 Ok(Some(EntryRow {
5713 id: entry.id,
5714 title: entry
5715 .title
5716 .clone()
5717 .filter(|t| !t.trim().is_empty())
5718 .unwrap_or_else(|| "(untitled)".to_string()),
5719 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5720 published: display_date(entry.published.as_deref()),
5721 read,
5722 starred,
5723 link: SafeLink::entry(id, ""),
5724 cached: true,
5725 rkey: String::new(),
5726 }))
5727}
5728
5729/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
5730fn now_rfc3339() -> String {
5731 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5732}
5733
5734#[cfg(test)]
5735mod tests {
5736 use super::*;
5737
5738 #[test]
5739 fn qenc_encodes_reserved() {
5740 assert_eq!(qenc("a b"), "a%20b");
5741 assert_eq!(
5742 qenc("https://example.com/feed.xml"),
5743 "https%3A%2F%2Fexample.com%2Ffeed.xml"
5744 );
5745 assert_eq!(
5746 qenc("at://did:plc:x/c/r"),
5747 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5748 );
5749 // Unreserved chars pass through untouched.
5750 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5751 }
5752
5753 #[test]
5754 fn folder_uri_shape() {
5755 assert_eq!(
5756 folder_uri("did:plc:abc", "3kfolder"),
5757 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5758 );
5759 }
5760
5761 // -- public-feeds-only: private/paid feeds are refused --------------------
5762
5763 #[test]
5764 fn private_feeds_are_classified_private_across_providers() {
5765 // The add + OPML paths both gate on this classifier; assert it flags a
5766 // spread of paid providers (newsletters + private podcasts) and the
5767 // generic credential-in-URL shapes.
5768 for url in [
5769 "https://author.substack.com/feed/private/deadbeefcafe1234",
5770 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5771 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5772 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5773 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5774 "https://user:pass@example.com/feed",
5775 ] {
5776 assert!(
5777 feed::classify_feed_privacy(url).is_private(),
5778 "expected private: {url}"
5779 );
5780 }
5781 }
5782
5783 #[test]
5784 fn public_feeds_stay_public() {
5785 for url in [
5786 "https://author.substack.com/feed",
5787 "https://wordpress.example.com/feed/",
5788 "https://example.com/rss.xml",
5789 "https://example.org/atom.xml",
5790 // YouTube channel/playlist RSS is fully public — must not false-block.
5791 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5792 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5793 ] {
5794 assert!(
5795 !feed::classify_feed_privacy(url).is_private(),
5796 "expected public: {url}"
5797 );
5798 }
5799 }
5800
5801 #[test]
5802 fn private_feed_label_is_public_safe_host_only() {
5803 // The OPML skip report must never echo the secret path/query, only the host.
5804 let label =
5805 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5806 assert_eq!(label, "author.substack.com");
5807 assert!(!label.contains("deadbeefcafe1234token"));
5808 assert!(!label.contains("/private/"));
5809 // An unparseable URL degrades to a generic label.
5810 assert_eq!(private_feed_label("not a url"), "a private feed");
5811 }
5812
5813 #[test]
5814 fn refusal_message_promises_nothing_stored() {
5815 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5816 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5817 }
5818
5819 #[test]
5820 fn scope_query_preserves_context() {
5821 let q = EntryQuery {
5822 feed: Some("https://example.com/feed.xml".to_string()),
5823 folder: None,
5824 view: Some("all".to_string()),
5825 };
5826 let s = scope_query(&q);
5827 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5828 assert!(s.contains("view=all"));
5829
5830 // Default view is omitted.
5831 let q2 = EntryQuery {
5832 feed: None,
5833 folder: None,
5834 view: Some("unread".to_string()),
5835 };
5836 assert_eq!(scope_query(&q2), "");
5837 }
5838
5839 // -- closed-beta invite gate + rate-limit + cache-control ------------------
5840
5841 use axum::body::Body;
5842 use axum::http::Request;
5843 use tower::ServiceExt; // for `oneshot`
5844
5845 /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
5846 /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
5847 /// can forge matching cookies.
5848 async fn test_state(allowed: &[&str]) -> AppState {
5849 let db = store::init_url("sqlite::memory:").await.unwrap();
5850 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5851 store::ensure_seed(&db, &dids).await.unwrap();
5852 let config = Config {
5853 allowed_dids: dids,
5854 cookie_secret: "test-cookie-secret-000".to_string(),
5855 beta_cap: 3,
5856 ..Config::default()
5857 };
5858 AppState::new(config, db).unwrap()
5859 }
5860
5861 /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
5862 /// looked up in the registry, so create the session first).
5863 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
5864 let sid = state.sessions.create(Session {
5865 did: did.to_string(),
5866 handle: handle.map(str::to_string),
5867 });
5868 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
5869 sc.split(';').next().unwrap().to_string()
5870 }
5871
5872 /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
5873 /// long time to accept distinct source IPs on two unauthenticated guarded
5874 /// routes.
5875 #[test]
5876 fn the_rate_limit_map_is_bounded() {
5877 let rl = RateLimiter::shared();
5878 let now = Instant::now();
5879 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5880 // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
5881 // ordering below is well-defined.
5882 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
5883 rl.check_at(ip, now + Duration::from_millis(i as u64));
5884 }
5885 let len = rl.inner.lock().unwrap().buckets.len();
5886 assert!(
5887 len <= MAX_RATE_BUCKETS,
5888 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
5889 );
5890 }
5891
5892 /// Eviction must not hand a throttled attacker a fresh burst.
5893 ///
5894 /// The bound is LRU, so the one bucket an attacker can never evict is their
5895 /// own — it is the most recently touched thing in the map. If this inverted,
5896 /// the size cap would become a rate-limit bypass: spray addresses until the
5897 /// map overflows, then resume.
5898 #[test]
5899 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
5900 let rl = RateLimiter::shared();
5901 let base = Instant::now();
5902 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
5903 // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
5904 // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
5905 // millisecond step made the whole flood take a second, and the refill —
5906 // working correctly — then looked exactly like an eviction bypass.
5907 let at = |n: u64| base + Duration::from_nanos(n);
5908
5909 // Spend the burst. `RATE_BURST` allowed, then refused.
5910 for i in 0..(RATE_BURST as u64) {
5911 assert!(rl.check_at(attacker, at(i)));
5912 }
5913 assert!(
5914 !rl.check_at(attacker, at(RATE_BURST as u64)),
5915 "burst was not exhausted; the rest of this test proves nothing"
5916 );
5917
5918 // Now overflow the map from other addresses, interleaving the attacker
5919 // so their bucket stays hot — the realistic shape of the attack.
5920 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5921 let t = at(100 + i as u64 * 2);
5922 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
5923 rl.check_at(ip, t);
5924 assert!(
5925 !rl.check_at(attacker, t),
5926 "the attacker got a token back after evictions at i={i}"
5927 );
5928 }
5929 }
5930
5931 /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
5932 /// of the whole map on every guarded request, on one shared core.
5933 #[test]
5934 fn the_idle_sweep_does_not_run_on_every_request() {
5935 let rl = RateLimiter::shared();
5936 let start = Instant::now();
5937 let a: IpAddr = "198.51.100.1".parse().unwrap();
5938 let b: IpAddr = "198.51.100.2".parse().unwrap();
5939
5940 rl.check_at(a, start);
5941 // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
5942 // the sweep interval has elapsed too, so this request does sweep it.
5943 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
5944 assert!(
5945 !rl.inner.lock().unwrap().buckets.contains_key(&a),
5946 "an idle bucket survived a sweep that was due"
5947 );
5948
5949 // A second request moments later must NOT re-sweep — `b` is still there,
5950 // and the recorded sweep time must not have moved.
5951 let before = rl.inner.lock().unwrap().last_sweep;
5952 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
5953 assert_eq!(
5954 rl.inner.lock().unwrap().last_sweep,
5955 before,
5956 "the sweep ran again within the interval"
5957 );
5958 }
5959
5960 #[test]
5961 fn rate_limited_paths_match_expected() {
5962 use axum::http::Method;
5963 assert!(is_rate_limited_path("/login", &Method::GET));
5964 assert!(is_rate_limited_path("/login", &Method::POST));
5965 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
5966 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
5967 assert!(is_rate_limited_path("/opml", &Method::POST));
5968 assert!(is_rate_limited_path("/read-all", &Method::POST));
5969 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
5970 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
5971 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
5972 // Read-only navigation is NOT limited.
5973 assert!(!is_rate_limited_path("/", &Method::GET));
5974 assert!(!is_rate_limited_path("/about", &Method::GET));
5975 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
5976 assert!(!is_rate_limited_path("/login", &Method::HEAD));
5977 }
5978
5979 #[test]
5980 fn rate_limiter_allows_burst_then_429s() {
5981 let rl = RateLimiter::shared();
5982 let ip: IpAddr = "203.0.113.7".parse().unwrap();
5983 // The full burst passes.
5984 for _ in 0..(RATE_BURST as usize) {
5985 assert!(rl.check(ip));
5986 }
5987 // The next one (no time elapsed → no refill) is rejected.
5988 assert!(!rl.check(ip));
5989 // A different IP has its own bucket.
5990 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
5991 assert!(rl.check(ip2));
5992 }
5993
5994 #[test]
5995 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
5996 // With NO trusted header configured, a client-supplied X-Forwarded-For
5997 // must be ignored entirely — the limiter keys on the real socket peer,
5998 // so an attacker can't mint a fresh bucket per forged XFF value.
5999 let mut h = HeaderMap::new();
6000 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6001 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6002 assert_eq!(
6003 client_ip(&h, Some(&sock), None),
6004 Some("203.0.113.55".parse().unwrap()),
6005 "spoofed XFF must not override the socket peer"
6006 );
6007 }
6008
6009 #[test]
6010 fn client_ip_uses_trusted_header_last_hop() {
6011 // With a trusted proxy header configured, the client IP comes from THAT
6012 // header (the proxy overwrites any client copy). On a comma list we take
6013 // the RIGHT-most hop — the one the trusted proxy appended — so a
6014 // client-forged left-most value is ignored.
6015 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6016
6017 let mut h = HeaderMap::new();
6018 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6019 assert_eq!(
6020 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6021 Some("198.51.100.9".parse().unwrap())
6022 );
6023
6024 // Attacker prepends a forged hop; the trusted proxy appends the real one.
6025 let mut h2 = HeaderMap::new();
6026 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6027 assert_eq!(
6028 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6029 Some("198.51.100.9".parse().unwrap()),
6030 "must take the right-most (trusted) hop, not the forged left-most"
6031 );
6032
6033 // Trusted header absent → fall back to the socket peer.
6034 let h3 = HeaderMap::new();
6035 assert_eq!(
6036 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6037 Some("10.0.0.1".parse().unwrap())
6038 );
6039 }
6040
6041 #[test]
6042 fn invite_cookie_round_trips_and_rejects_tamper() {
6043 let secret = "test-cookie-secret-000";
6044 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6045 let pair = sc.split(';').next().unwrap();
6046 let mut h = HeaderMap::new();
6047 h.insert(header::COOKIE, pair.parse().unwrap());
6048 assert_eq!(
6049 invite_cookie_code(&h, secret).as_deref(),
6050 Some("FEATHER-ABCDWXYZ")
6051 );
6052 // Wrong secret → rejected.
6053 assert!(invite_cookie_code(&h, "other").is_none());
6054 }
6055
6056 #[tokio::test]
6057 async fn preflight_valid_expired_and_full() {
6058 let state = test_state(&["did:plc:admin"]).await;
6059 // A minted, active code preflights OK.
6060 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6061 .await
6062 .unwrap();
6063 assert!(preflight_code(&state, &code).await.is_ok());
6064
6065 // A code whose expiry is in the past preflights as Expired. (mint_code
6066 // clamps negative ttl to 0, so back-date the row directly for a
6067 // deterministic past expiry.)
6068 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6069 .await
6070 .unwrap();
6071 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6072 .bind(chrono::Utc::now().timestamp() - 3600)
6073 .bind(&expired)
6074 .execute(&state.db)
6075 .await
6076 .unwrap();
6077 assert_eq!(
6078 preflight_code(&state, &expired).await,
6079 Err(store::RedeemError::Expired)
6080 );
6081
6082 // Unknown code → NotFound.
6083 assert_eq!(
6084 preflight_code(&state, "FEATHER-NOPENOPE").await,
6085 Err(store::RedeemError::NotFound)
6086 );
6087
6088 // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6089 // must report CapacityFull.
6090 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6091 .await
6092 .unwrap();
6093 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6094 .await
6095 .unwrap();
6096 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6097 assert_eq!(
6098 preflight_code(&state, &code).await,
6099 Err(store::RedeemError::CapacityFull)
6100 );
6101 }
6102
6103 // -- Bot claim link + shared-secret mint ---------------------------------
6104
6105 /// A test state with a configured bot secret (so `/bot/claims` is live).
6106 async fn bot_state(bot_secret: &str) -> AppState {
6107 let db = store::init_url("sqlite::memory:").await.unwrap();
6108 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6109 .await
6110 .unwrap();
6111 let config = Config {
6112 allowed_dids: vec!["did:plc:admin".to_string()],
6113 cookie_secret: "test-cookie-secret-000".to_string(),
6114 beta_cap: 3,
6115 bot_secret: Some(bot_secret.to_string()),
6116 public_url: "https://feather-reader.com".to_string(),
6117 ..Config::default()
6118 };
6119 AppState::new(config, db).unwrap()
6120 }
6121
6122 #[test]
6123 fn claim_token_round_trips_and_rejects_tamper() {
6124 let secret = "test-cookie-secret-000";
6125 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6126 // No cookie framing — a bare URL-safe token.
6127 assert!(!token.contains(';'));
6128 assert_eq!(
6129 claim_token_code(&token, secret).as_deref(),
6130 Some("FEATHER-ABCDWXYZ")
6131 );
6132 // Wrong secret → rejected.
6133 assert!(claim_token_code(&token, "other").is_none());
6134 // Tampered token → rejected.
6135 let mut bad = token.clone();
6136 bad.push('x');
6137 assert!(claim_token_code(&bad, secret).is_none());
6138 // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6139 // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6140 // recover it WITHOUT the secret). The security is single-use + HMAC
6141 // integrity + rate-limit, not secrecy of the code. Assert the code half is
6142 // publicly decodable (a plain base64url decode, no secret involved).
6143 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6144 assert_eq!(
6145 test_b64url_decode(b64).as_deref(),
6146 Some("FEATHER-ABCDWXYZ".as_bytes()),
6147 "the code half of the token is plain base64url, decodable by anyone"
6148 );
6149 }
6150
6151 /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6152 /// claim token's code half needs NO secret to recover (it is not confidential).
6153 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6154 fn val(c: u8) -> Option<u32> {
6155 match c {
6156 b'A'..=b'Z' => Some((c - b'A') as u32),
6157 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6158 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6159 b'-' => Some(62),
6160 b'_' => Some(63),
6161 _ => None,
6162 }
6163 }
6164 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6165 for chunk in input.as_bytes().chunks(4) {
6166 let mut n = 0u32;
6167 let mut bits = 0;
6168 for &c in chunk {
6169 n = (n << 6) | val(c)?;
6170 bits += 6;
6171 }
6172 let bytes = bits / 8;
6173 n <<= 24 - bits;
6174 for i in 0..bytes {
6175 out.push((n >> (16 - i * 8)) as u8);
6176 }
6177 }
6178 Some(out)
6179 }
6180
6181 #[tokio::test]
6182 async fn bot_mint_then_claim_grants_a_seat() {
6183 let state = bot_state("bot-secret-abcdef").await;
6184 let app = router(state.clone());
6185
6186 // 1. Mint a claim via the shared-secret endpoint.
6187 let resp = app
6188 .clone()
6189 .oneshot(
6190 Request::builder()
6191 .method("POST")
6192 .uri("/bot/claims")
6193 .header("x-bot-secret", "bot-secret-abcdef")
6194 .body(Body::empty())
6195 .unwrap(),
6196 )
6197 .await
6198 .unwrap();
6199 assert_eq!(resp.status(), StatusCode::OK);
6200 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6201 .await
6202 .unwrap();
6203 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6204 let token = json["token"].as_str().unwrap().to_string();
6205 let url = json["url"].as_str().unwrap();
6206 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6207 // The raw code is returned for the bot's records but not embedded in url.
6208 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6209 assert!(!url.contains("FEATHER-"));
6210
6211 // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6212 let resp = app
6213 .clone()
6214 .oneshot(
6215 Request::builder()
6216 .method("GET")
6217 .uri(format!("/claim?t={}", qenc(&token)))
6218 .body(Body::empty())
6219 .unwrap(),
6220 )
6221 .await
6222 .unwrap();
6223 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6224 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6225 let set_cookie = resp
6226 .headers()
6227 .get(header::SET_COOKIE)
6228 .unwrap()
6229 .to_str()
6230 .unwrap();
6231 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6232
6233 // 3. The reserved cookie carries the same code the token wrapped, and
6234 // redeeming it (the callback's machinery) grants a seat.
6235 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6236 let out = store::redeem_code(
6237 &state.db,
6238 &code,
6239 "did:plc:follower",
6240 None,
6241 state.config.beta_cap,
6242 )
6243 .await
6244 .unwrap();
6245 assert_eq!(out, Ok(()));
6246 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6247 .await
6248 .unwrap());
6249 }
6250
6251 #[tokio::test]
6252 async fn claim_with_invalid_token_bounces() {
6253 let state = bot_state("bot-secret-abcdef").await;
6254 let app = router(state);
6255 let resp = app
6256 .oneshot(
6257 Request::builder()
6258 .method("GET")
6259 .uri("/claim?t=not-a-real-token")
6260 .body(Body::empty())
6261 .unwrap(),
6262 )
6263 .await
6264 .unwrap();
6265 // Renders the invite page (200), NOT a redirect to /login.
6266 assert_eq!(resp.status(), StatusCode::OK);
6267 }
6268
6269 #[tokio::test]
6270 async fn claim_with_used_token_is_refused() {
6271 let state = bot_state("bot-secret-abcdef").await;
6272 // Mint a code + wrap it, then redeem it out from under the token.
6273 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6274 .await
6275 .unwrap();
6276 let token = sign_claim_token(&code, &state.config.cookie_secret);
6277 store::redeem_code(
6278 &state.db,
6279 &code,
6280 "did:plc:someone",
6281 None,
6282 state.config.beta_cap,
6283 )
6284 .await
6285 .unwrap()
6286 .unwrap();
6287 let app = router(state);
6288 let resp = app
6289 .oneshot(
6290 Request::builder()
6291 .method("GET")
6292 .uri(format!("/claim?t={}", qenc(&token)))
6293 .body(Body::empty())
6294 .unwrap(),
6295 )
6296 .await
6297 .unwrap();
6298 // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6299 assert_eq!(resp.status(), StatusCode::OK);
6300 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6301 }
6302
6303 #[tokio::test]
6304 async fn bot_claims_rejects_bad_and_missing_secret() {
6305 let state = bot_state("bot-secret-abcdef").await;
6306 let app = router(state);
6307 // Wrong secret.
6308 let resp = app
6309 .clone()
6310 .oneshot(
6311 Request::builder()
6312 .method("POST")
6313 .uri("/bot/claims")
6314 .header("x-bot-secret", "wrong")
6315 .body(Body::empty())
6316 .unwrap(),
6317 )
6318 .await
6319 .unwrap();
6320 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6321 // Missing secret.
6322 let resp = app
6323 .oneshot(
6324 Request::builder()
6325 .method("POST")
6326 .uri("/bot/claims")
6327 .body(Body::empty())
6328 .unwrap(),
6329 )
6330 .await
6331 .unwrap();
6332 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6333 }
6334
6335 #[tokio::test]
6336 async fn bot_claims_disabled_when_secret_unset() {
6337 // test_state configures NO bot secret → the endpoint is off (503).
6338 let state = test_state(&["did:plc:admin"]).await;
6339 let app = router(state);
6340 let resp = app
6341 .oneshot(
6342 Request::builder()
6343 .method("POST")
6344 .uri("/bot/claims")
6345 .header("x-bot-secret", "anything")
6346 .body(Body::empty())
6347 .unwrap(),
6348 )
6349 .await
6350 .unwrap();
6351 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6352 }
6353
6354 #[tokio::test]
6355 async fn bot_claims_refuses_at_capacity() {
6356 let state = bot_state("bot-secret-abcdef").await;
6357 // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6358 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6359 .await
6360 .unwrap();
6361 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6362 .await
6363 .unwrap();
6364 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6365 let app = router(state);
6366 let resp = app
6367 .oneshot(
6368 Request::builder()
6369 .method("POST")
6370 .uri("/bot/claims")
6371 .header("x-bot-secret", "bot-secret-abcdef")
6372 .body(Body::empty())
6373 .unwrap(),
6374 )
6375 .await
6376 .unwrap();
6377 assert_eq!(resp.status(), StatusCode::CONFLICT);
6378 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6379 .await
6380 .unwrap();
6381 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6382 }
6383
6384 #[tokio::test]
6385 async fn bot_claims_counts_outstanding_codes_against_cap() {
6386 let state = bot_state("bot-secret-abcdef").await;
6387 // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6388 store::mint_code(&state.db, "did:plc:admin", 3600)
6389 .await
6390 .unwrap();
6391 store::mint_code(&state.db, "did:plc:admin", 3600)
6392 .await
6393 .unwrap();
6394 let app = router(state);
6395 let resp = app
6396 .oneshot(
6397 Request::builder()
6398 .method("POST")
6399 .uri("/bot/claims")
6400 .header("x-bot-secret", "bot-secret-abcdef")
6401 .body(Body::empty())
6402 .unwrap(),
6403 )
6404 .await
6405 .unwrap();
6406 // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6407 assert_eq!(resp.status(), StatusCode::CONFLICT);
6408 }
6409
6410 /// POST /bot/claims with a JSON body carrying the follower DID.
6411 async fn post_bot_claim_for(
6412 app: &axum::Router,
6413 secret: &str,
6414 did: &str,
6415 ) -> (StatusCode, serde_json::Value) {
6416 let resp = app
6417 .clone()
6418 .oneshot(
6419 Request::builder()
6420 .method("POST")
6421 .uri("/bot/claims")
6422 .header("x-bot-secret", secret)
6423 .header("content-type", "application/json")
6424 .body(Body::from(format!(
6425 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6426 )))
6427 .unwrap(),
6428 )
6429 .await
6430 .unwrap();
6431 let status = resp.status();
6432 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6433 .await
6434 .unwrap();
6435 let json = if bytes.is_empty() {
6436 serde_json::Value::Null
6437 } else {
6438 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6439 };
6440 (status, json)
6441 }
6442
6443 #[tokio::test]
6444 async fn bot_claims_returns_already_seated_for_a_member() {
6445 // A DID that already holds beta access must get `already_seated` with NO
6446 // code/url — the bot posts nothing. This is the server-side backstop that
6447 // survives a bot-host state loss (it would otherwise re-mint + re-post).
6448 let state = bot_state("bot-secret-abcdef").await;
6449 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6450 .await
6451 .unwrap();
6452 let app = router(state.clone());
6453 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6454 assert_eq!(status, StatusCode::OK);
6455 assert_eq!(json["status"], "already_seated");
6456 assert_eq!(json["code"], "");
6457 assert_eq!(json["url"], "");
6458 // No new invite code was minted for the seated DID.
6459 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6460 .await
6461 .unwrap()
6462 .is_none());
6463 }
6464
6465 #[tokio::test]
6466 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6467 // Two mint requests for the SAME follower DID must return the SAME code
6468 // (the app is authoritative), never a second one — so a bot-host state loss
6469 // re-requesting cannot double-mint or double-post.
6470 let state = bot_state("bot-secret-abcdef").await;
6471 let app = router(state.clone());
6472
6473 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6474 assert_eq!(s1, StatusCode::OK);
6475 assert_eq!(j1["status"], "minted");
6476 let code1 = j1["code"].as_str().unwrap().to_string();
6477
6478 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6479 assert_eq!(s2, StatusCode::OK);
6480 assert_eq!(j2["status"], "existing");
6481 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6482 assert_eq!(j2["url"], j1["url"], "same url returned");
6483
6484 // Exactly ONE active code exists for that DID.
6485 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6486 }
6487
6488 #[tokio::test]
6489 async fn bot_claims_records_intended_did_at_mint() {
6490 // A fresh mint records the follower DID so the lookup finds it.
6491 let state = bot_state("bot-secret-abcdef").await;
6492 let app = router(state.clone());
6493 let (status, json) =
6494 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6495 assert_eq!(status, StatusCode::OK);
6496 let code = json["code"].as_str().unwrap();
6497 assert_eq!(
6498 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6499 .await
6500 .unwrap()
6501 .as_deref(),
6502 Some(code)
6503 );
6504 }
6505
6506 #[tokio::test]
6507 async fn bot_claims_concurrent_same_did_never_double_mints() {
6508 // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6509 // active code. The dedupe check (3b) and the mint are separate statements,
6510 // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6511 // then makes the loser's INSERT conflict, and the handler recovers by
6512 // returning the winner's code (status `existing`) rather than 500-ing.
6513 // Result: exactly ONE active code, and BOTH callers get a usable code.
6514 let state = bot_state("bot-secret-abcdef").await;
6515 let app = router(state.clone());
6516
6517 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6518 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6519 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6520
6521 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6522 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6523
6524 // Exactly one active code for the DID — the whole point of the fix.
6525 assert_eq!(
6526 store::count_active_codes(&state.db).await.unwrap(),
6527 1,
6528 "concurrent mints must not create two active codes"
6529 );
6530
6531 // Both callers received the SAME (single) code, and neither got a 500.
6532 let ca = ja["code"].as_str().unwrap_or("");
6533 let cb = jb["code"].as_str().unwrap_or("");
6534 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6535 assert_eq!(ca, cb, "both callers must get the one minted code");
6536 // One is `minted` (the winner), the other `minted` or `existing` depending
6537 // on interleaving — but never an error status.
6538 for st in [&ja["status"], &jb["status"]] {
6539 let s = st.as_str().unwrap_or("");
6540 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6541 }
6542 }
6543
6544 #[tokio::test]
6545 async fn bot_claims_rejects_malformed_json_body() {
6546 let state = bot_state("bot-secret-abcdef").await;
6547 let app = router(state);
6548 let resp = app
6549 .oneshot(
6550 Request::builder()
6551 .method("POST")
6552 .uri("/bot/claims")
6553 .header("x-bot-secret", "bot-secret-abcdef")
6554 .header("content-type", "application/json")
6555 .body(Body::from("{not json"))
6556 .unwrap(),
6557 )
6558 .await
6559 .unwrap();
6560 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6561 }
6562
6563 #[tokio::test]
6564 async fn favicon_ico_served_at_root() {
6565 // Browsers request bare /favicon.ico regardless of the <link rel="icon">
6566 // tags in <head>; the root route must serve the icon, not 404.
6567 let state = test_state(&[]).await;
6568 let app = router(state);
6569 let resp = app
6570 .oneshot(
6571 Request::builder()
6572 .uri("/favicon.ico")
6573 .body(Body::empty())
6574 .unwrap(),
6575 )
6576 .await
6577 .unwrap();
6578 assert_eq!(resp.status(), StatusCode::OK);
6579 let ct = resp
6580 .headers()
6581 .get(header::CONTENT_TYPE)
6582 .unwrap()
6583 .to_str()
6584 .unwrap();
6585 assert!(
6586 ct.contains("icon") || ct.starts_with("image/"),
6587 "content-type = {ct}"
6588 );
6589 }
6590
6591 #[tokio::test]
6592 async fn login_without_invite_redirects_to_beta_redeem() {
6593 // No allow-list seed, no invite cookie: starting OAuth must be refused.
6594 let state = test_state(&[]).await;
6595 let app = router(state);
6596 let resp = app
6597 .oneshot(
6598 Request::builder()
6599 .method("POST")
6600 .uri("/login")
6601 .header("content-type", "application/x-www-form-urlencoded")
6602 .body(Body::from("handle=alice.bsky.social"))
6603 .unwrap(),
6604 )
6605 .await
6606 .unwrap();
6607 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6608 assert_eq!(
6609 resp.headers().get(header::LOCATION).unwrap(),
6610 "/beta/redeem"
6611 );
6612 }
6613
6614 #[tokio::test]
6615 async fn login_with_valid_invite_cookie_starts_oauth() {
6616 let state = test_state(&[]).await;
6617 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6618 let cookie = cookie.split(';').next().unwrap().to_string();
6619 let app = router(state);
6620 let resp = app
6621 .oneshot(
6622 Request::builder()
6623 .method("POST")
6624 .uri("/login")
6625 .header("content-type", "application/x-www-form-urlencoded")
6626 .header(header::COOKIE, cookie)
6627 .body(Body::from("handle=alice.bsky.social"))
6628 .unwrap(),
6629 )
6630 .await
6631 .unwrap();
6632 // Redirects into the sidecar login (not to /beta/redeem).
6633 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6634 let loc = resp
6635 .headers()
6636 .get(header::LOCATION)
6637 .unwrap()
6638 .to_str()
6639 .unwrap();
6640 assert!(loc.contains("/login"), "loc = {loc}");
6641 assert_ne!(loc, "/beta/redeem");
6642 }
6643
6644 /// A resolver that always fails — proves the fast paths short-circuit BEFORE
6645 /// any network resolution and that a resolution failure fails closed.
6646 async fn resolver_never(_handle: String) -> Option<String> {
6647 None
6648 }
6649
6650 /// A resolver that maps every handle to `did`.
6651 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6652 move |_handle| std::future::ready(Some(did.to_string()))
6653 }
6654
6655 /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
6656 /// that already holds a seat (the seeded-admin first-login case) passes the
6657 /// gate — no session cookie, no invite code.
6658 #[tokio::test]
6659 async fn may_start_oauth_honors_seat_via_resolved_handle() {
6660 // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
6661 // no cookie on a fresh deploy.
6662 let state = test_state(&["did:plc:admin"]).await;
6663 let headers = HeaderMap::new();
6664 assert!(
6665 may_start_oauth_with(
6666 &state,
6667 &headers,
6668 "admin.example",
6669 resolver_to("did:plc:admin")
6670 )
6671 .await,
6672 "a handle resolving to a seated DID must pass the gate"
6673 );
6674 }
6675
6676 /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
6677 /// — the anti-abuse intent is preserved (resolution succeeds, seat check
6678 /// fails).
6679 #[tokio::test]
6680 async fn may_start_oauth_bounces_non_member_handle() {
6681 let state = test_state(&["did:plc:admin"]).await;
6682 let headers = HeaderMap::new();
6683 assert!(
6684 !may_start_oauth_with(
6685 &state,
6686 &headers,
6687 "rando.example",
6688 resolver_to("did:plc:rando")
6689 )
6690 .await,
6691 "a resolved DID with no seat must be bounced"
6692 );
6693 }
6694
6695 /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
6696 /// bounces gracefully — no panic, no handshake.
6697 #[tokio::test]
6698 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6699 let state = test_state(&["did:plc:admin"]).await;
6700 let headers = HeaderMap::new();
6701 assert!(
6702 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6703 "an unresolvable handle must fail closed"
6704 );
6705 }
6706
6707 /// The session-cookie fast path admits a seated member WITHOUT calling the
6708 /// resolver (proven by injecting `resolver_never`, which would otherwise
6709 /// bounce).
6710 #[tokio::test]
6711 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6712 let state = test_state(&[]).await;
6713 let did = "did:plc:member";
6714 store::grant_access(&state.db, did, Some("member.example"), "test", None)
6715 .await
6716 .unwrap();
6717 let cookie = session_cookie(&state, did, Some("member.example"));
6718 let mut headers = HeaderMap::new();
6719 headers.insert(header::COOKIE, cookie.parse().unwrap());
6720 assert!(
6721 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6722 "a seated session cookie must pass without resolution"
6723 );
6724 }
6725
6726 /// The invite-cookie fast path admits WITHOUT calling the resolver.
6727 #[tokio::test]
6728 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6729 let state = test_state(&[]).await;
6730 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6731 let cookie = cookie.split(';').next().unwrap().to_string();
6732 let mut headers = HeaderMap::new();
6733 headers.insert(header::COOKIE, cookie.parse().unwrap());
6734 assert!(
6735 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6736 "a valid invite cookie must pass without resolution"
6737 );
6738 }
6739
6740 #[tokio::test]
6741 async fn admin_mint_requires_admin_seed_did() {
6742 let state = test_state(&["did:plc:admin"]).await;
6743 // A non-admin (but beta'd) session is forbidden.
6744 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6745 .await
6746 .unwrap();
6747 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6748 // An admin session is allowed.
6749 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6750 let app = router(state);
6751
6752 let forbidden = app
6753 .clone()
6754 .oneshot(
6755 Request::builder()
6756 .method("POST")
6757 .uri("/admin/invites?n=2")
6758 .header(header::COOKIE, rando_cookie)
6759 .body(Body::empty())
6760 .unwrap(),
6761 )
6762 .await
6763 .unwrap();
6764 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6765
6766 let ok = app
6767 .oneshot(
6768 Request::builder()
6769 .method("POST")
6770 .uri("/admin/invites?n=2")
6771 .header(header::COOKIE, admin_cookie)
6772 .body(Body::empty())
6773 .unwrap(),
6774 )
6775 .await
6776 .unwrap();
6777 assert_eq!(ok.status(), StatusCode::OK);
6778 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6779 .await
6780 .unwrap();
6781 let body = String::from_utf8(bytes.to_vec()).unwrap();
6782 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6783 assert_eq!(minted.len(), 2);
6784 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6785 }
6786
6787 #[tokio::test]
6788 async fn admin_mint_unauthenticated_is_401() {
6789 let state = test_state(&["did:plc:admin"]).await;
6790 let app = router(state);
6791 let resp = app
6792 .oneshot(
6793 Request::builder()
6794 .method("POST")
6795 .uri("/admin/invites")
6796 .body(Body::empty())
6797 .unwrap(),
6798 )
6799 .await
6800 .unwrap();
6801 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6802 }
6803
6804 /// A state whose `/about` renders the adoption line, seeded with one
6805 /// observation.
6806 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6807 let db = store::init_url("sqlite::memory:").await.unwrap();
6808 store::record_network_stat(
6809 &db,
6810 &store::NetworkStat {
6811 key: store::ADOPTION_STAT_KEY.to_string(),
6812 source: "https://relay1.us-west.bsky.network".to_string(),
6813 value: repos,
6814 truncated,
6815 observed_at: "2026-08-13T04:05:06Z".to_string(),
6816 },
6817 )
6818 .await
6819 .unwrap();
6820 let config = Config {
6821 cookie_secret: "test-cookie-secret-000".to_string(),
6822 show_adoption: true,
6823 ..Config::default()
6824 };
6825 AppState::new(config, db).unwrap()
6826 }
6827
6828 async fn about_body(state: AppState) -> String {
6829 let resp = router(state)
6830 .oneshot(
6831 Request::builder()
6832 .uri("/about")
6833 .body(Body::empty())
6834 .unwrap(),
6835 )
6836 .await
6837 .unwrap();
6838 assert_eq!(resp.status(), StatusCode::OK);
6839 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
6840 .await
6841 .unwrap();
6842 String::from_utf8(bytes.to_vec()).unwrap()
6843 }
6844
6845 /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
6846 #[tokio::test]
6847 async fn about_omits_adoption_line_by_default() {
6848 let state = test_state(&[]).await;
6849 assert!(!state.config.show_adoption);
6850 let body = about_body(state).await;
6851 assert!(
6852 !body.contains("atproto network"),
6853 "the adoption line must not render by default"
6854 );
6855 }
6856
6857 #[tokio::test]
6858 async fn about_renders_adoption_line_when_enabled() {
6859 // **A distinctive count, and asserted IN ITS SENTENCE.**
6860 //
6861 // This used to seed 4 and assert `body.contains("4")`, which the
6862 // colophon's `width="44"` satisfies whatever the count is — so
6863 // hardcoding the rendered number passed. Both halves are needed: a
6864 // digit that does not occur incidentally, and the assertion tied to the
6865 // phrase it belongs to.
6866 let body = about_body(adoption_state(7_318, false).await).await;
6867 // The count and its phrase are on separate template lines, so compare
6868 // against a whitespace-collapsed copy rather than the raw HTML.
6869 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
6870 assert!(
6871 flat.contains("7318 accounts on the atproto network hold"),
6872 "the count did not render in its own sentence: {flat}",
6873 );
6874 assert!(
6875 body.contains("accounts on the atproto network hold"),
6876 "{body}"
6877 );
6878 assert!(
6879 body.contains("2026-08-13"),
6880 "the observation date must render"
6881 );
6882 assert!(
6883 body.contains("lower bound"),
6884 "the non-archival caveat must ride along with the number"
6885 );
6886 assert!(
6887 !body.contains("At least"),
6888 "an untruncated count is exact-ish"
6889 );
6890 }
6891
6892 /// A count of one must read as "1 account … holds", not "1 accounts … hold".
6893 #[tokio::test]
6894 async fn about_adoption_line_is_singular_at_one() {
6895 let body = about_body(adoption_state(1, false).await).await;
6896 assert!(
6897 body.contains("account on the atproto network holds"),
6898 "{body}"
6899 );
6900 }
6901
6902 /// A truncated observation is a floor, and must say so.
6903 #[tokio::test]
6904 async fn about_adoption_line_says_at_least_when_truncated() {
6905 let body = about_body(adoption_state(25_000, true).await).await;
6906 assert!(body.contains("At least"), "{body}");
6907 }
6908
6909 /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
6910 #[tokio::test]
6911 async fn about_omits_line_when_enabled_with_no_observation() {
6912 let db = store::init_url("sqlite::memory:").await.unwrap();
6913 let config = Config {
6914 cookie_secret: "test-cookie-secret-000".to_string(),
6915 show_adoption: true,
6916 ..Config::default()
6917 };
6918 let body = about_body(AppState::new(config, db).unwrap()).await;
6919 assert!(!body.contains("atproto network"));
6920 }
6921
6922 #[tokio::test]
6923 async fn cache_control_public_on_about_no_store_on_authed() {
6924 let state = test_state(&["did:plc:admin"]).await;
6925 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6926 let app = router(state);
6927
6928 // /about → public, cacheable.
6929 let about = app
6930 .clone()
6931 .oneshot(
6932 Request::builder()
6933 .uri("/about")
6934 .body(Body::empty())
6935 .unwrap(),
6936 )
6937 .await
6938 .unwrap();
6939 assert_eq!(
6940 about.headers().get(header::CACHE_CONTROL).unwrap(),
6941 "public, max-age=300"
6942 );
6943 // The security headers are still intact.
6944 // The VALUE, spelled out here rather than compared to the constant —
6945 // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
6946 // used to assert only that the header existed, which a policy of
6947 // `default-src *` satisfies.
6948 assert_eq!(
6949 about.headers()["content-security-policy"],
6950 EXPECTED_CSP,
6951 "the CSP is not the policy the router promises"
6952 );
6953 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
6954
6955 // /privacy and /terms are static public pages → public, cacheable.
6956 for path in ["/privacy", "/terms"] {
6957 let resp = app
6958 .clone()
6959 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
6960 .await
6961 .unwrap();
6962 assert_eq!(resp.status(), StatusCode::OK);
6963 assert_eq!(
6964 resp.headers().get(header::CACHE_CONTROL).unwrap(),
6965 "public, max-age=300",
6966 "{path} should be publicly cacheable"
6967 );
6968 // Security headers apply to these pages too.
6969 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
6970 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
6971 }
6972
6973 // The bare /login landing → public, cacheable.
6974 let login = app
6975 .clone()
6976 .oneshot(
6977 Request::builder()
6978 .uri("/login")
6979 .body(Body::empty())
6980 .unwrap(),
6981 )
6982 .await
6983 .unwrap();
6984 assert_eq!(
6985 login.headers().get(header::CACHE_CONTROL).unwrap(),
6986 "public, max-age=300"
6987 );
6988
6989 // An authenticated page → no-store.
6990 let home = app
6991 .oneshot(
6992 Request::builder()
6993 .uri("/")
6994 .header(header::COOKIE, admin_cookie)
6995 .body(Body::empty())
6996 .unwrap(),
6997 )
6998 .await
6999 .unwrap();
7000 assert_eq!(
7001 home.headers().get(header::CACHE_CONTROL).unwrap(),
7002 "no-store"
7003 );
7004 }
7005
7006 #[tokio::test]
7007 async fn beta_redeem_page_renders() {
7008 let state = test_state(&[]).await;
7009 let app = router(state);
7010 let resp = app
7011 .oneshot(
7012 Request::builder()
7013 .uri("/beta/redeem")
7014 .body(Body::empty())
7015 .unwrap(),
7016 )
7017 .await
7018 .unwrap();
7019 assert_eq!(resp.status(), StatusCode::OK);
7020 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7021 .await
7022 .unwrap();
7023 let html = String::from_utf8(bytes.to_vec()).unwrap();
7024 assert!(html.contains("Invite code"));
7025 assert!(html.contains("/beta/redeem"));
7026 }
7027
7028 #[tokio::test]
7029 async fn rate_limit_returns_429_after_burst() {
7030 // Configure a trusted proxy header so the limiter keys on the forwarded
7031 // IP (the oneshot harness sets no ConnectInfo socket peer).
7032 let db = store::init_url("sqlite::memory:").await.unwrap();
7033 store::ensure_seed(&db, &[]).await.unwrap();
7034 let config = Config {
7035 cookie_secret: "test-cookie-secret-000".to_string(),
7036 beta_cap: 3,
7037 trusted_ip_header: Some("cf-connecting-ip".to_string()),
7038 ..Config::default()
7039 };
7040 let state = AppState::new(config, db).unwrap();
7041 let app = router(state);
7042 // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
7043 // handler itself returns 200 (re-render) on a bad code; the limiter is
7044 // what eventually yields 429.
7045 let mut saw_429 = false;
7046 for _ in 0..(RATE_BURST as usize + 5) {
7047 let resp = app
7048 .clone()
7049 .oneshot(
7050 Request::builder()
7051 .method("POST")
7052 .uri("/beta/redeem")
7053 .header("content-type", "application/x-www-form-urlencoded")
7054 .header("cf-connecting-ip", "203.0.113.200")
7055 .body(Body::from("code=FEATHER-NOPENOPE"))
7056 .unwrap(),
7057 )
7058 .await
7059 .unwrap();
7060 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7061 saw_429 = true;
7062 break;
7063 }
7064 }
7065 assert!(saw_429, "expected a 429 after exhausting the burst");
7066 }
7067
7068 /// **A forged `X-Forwarded-For` does not key the limiter** — the property
7069 /// the middleware's comment cites this test as proof of.
7070 ///
7071 /// The previous version rotated the forged header and asserted that no
7072 /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
7073 /// burst, so that assertion held whether the header was trusted or
7074 /// ignored — it passed in the vulnerable configuration too. And with no
7075 /// socket peer the limiter fails open, so nothing could have been keyed on
7076 /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
7077 /// a DIFFERENT forged header, and the last must be 429: they all landed in
7078 /// the peer's bucket. A limiter keying on the header mints a fresh bucket
7079 /// per request and never trips — which is exactly what the mutation does.
7080 #[tokio::test]
7081 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7082 let state = test_state(&[]).await;
7083 assert!(
7084 state.config.trusted_ip_header.is_none(),
7085 "no proxy header is trusted here"
7086 );
7087 let app = router(state);
7088 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7089 let mut saw_429 = false;
7090 for i in 0..(RATE_BURST as usize + 5) {
7091 let forged = format!("10.9.8.{}", i % 250);
7092 let resp = app
7093 .clone()
7094 .oneshot(
7095 Request::builder()
7096 .method("POST")
7097 .uri("/beta/redeem")
7098 .header("content-type", "application/x-www-form-urlencoded")
7099 .header("x-forwarded-for", forged)
7100 .extension(axum::extract::ConnectInfo(peer))
7101 .body(Body::from("code=FEATHER-NOPENOPE"))
7102 .unwrap(),
7103 )
7104 .await
7105 .unwrap();
7106 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7107 saw_429 = true;
7108 break;
7109 }
7110 }
7111 assert!(
7112 saw_429,
7113 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7114 );
7115 }
7116
7117 // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
7118
7119 /// **A private feed is refused BEFORE it is fetched.** The add path's
7120 /// privacy gate had no test at all — `private_feeds_are_classified_private_
7121 /// across_providers` says "the add + OPML paths both gate on this
7122 /// classifier" and nothing checked either. The gate exists so a
7123 /// token-bearing URL never reaches the network; the assertion that
7124 /// matters is the server's hit count: zero.
7125 #[tokio::test]
7126 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7127 let did = "did:plc:privateadder";
7128 let state = test_state_with_caps(did, 0, 0).await;
7129 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7130 let port: u16 = base
7131 .trim_end_matches('/')
7132 .rsplit(':')
7133 .next()
7134 .unwrap()
7135 .parse()
7136 .unwrap();
7137 crate::net::test_host_override(
7138 "private-add.test",
7139 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7140 );
7141 let cookie = session_cookie(&state, did, None);
7142 let resp = router(state.clone())
7143 .oneshot(
7144 Request::builder()
7145 .method("POST")
7146 .uri("/subscriptions")
7147 .header(header::COOKIE, cookie)
7148 .header("content-type", "application/x-www-form-urlencoded")
7149 .body(Body::from(format!(
7150 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7151 )))
7152 .unwrap(),
7153 )
7154 .await
7155 .unwrap();
7156 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7157 let loc = resp
7158 .headers()
7159 .get(header::LOCATION)
7160 .unwrap()
7161 .to_str()
7162 .unwrap();
7163 assert!(loc.contains("Private"), "not refused as private: {loc}");
7164 assert_eq!(
7165 hits.load(std::sync::atomic::Ordering::SeqCst),
7166 0,
7167 "the private feed was FETCHED before being refused"
7168 );
7169 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7170 }
7171
7172 /// **OPML import skips a private feed without storing or publishing it.**
7173 /// The import path does not fetch, so "never fetched" is not the signal
7174 /// here; "never stored, never written to the PDS" is. The batch write's
7175 /// bytes are captured and must not carry the URL.
7176 #[tokio::test]
7177 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7178 let did = "did:plc:renamer4";
7179 let (sidecar, bodies) = spawn_logging_sidecar().await;
7180 let state = test_state_with_sidecar(&[did], &sidecar).await;
7181 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7182 let opml = format!(
7183 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7184 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7185 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7186 </body></opml>"
7187 );
7188 let (ct, body) = opml_multipart(opml.as_bytes());
7189 let cookie = session_cookie(&state, did, None);
7190 let resp = router(state.clone())
7191 .oneshot(
7192 Request::builder()
7193 .method("POST")
7194 .uri("/opml")
7195 .header(header::COOKIE, cookie)
7196 .header("content-type", ct)
7197 .body(Body::from(body))
7198 .unwrap(),
7199 )
7200 .await
7201 .unwrap();
7202 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7203 let loc = resp
7204 .headers()
7205 .get(header::LOCATION)
7206 .unwrap()
7207 .to_str()
7208 .unwrap();
7209 assert!(
7210 loc.contains("skipped%20as%20private"),
7211 "not reported as skipped: {loc}"
7212 );
7213 assert!(store::get_feed_by_url(&state.db, tokened)
7214 .await
7215 .unwrap()
7216 .is_none());
7217 let sent = bodies.lock().unwrap().join("\n");
7218 assert!(
7219 sent.contains("public.example"),
7220 "the public feed was not written: {sent}"
7221 );
7222 assert!(
7223 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7224 "the secret was PUBLISHED to the PDS: {sent}"
7225 );
7226 }
7227
7228 /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
7229 /// tested; the GET form starts the same handshake and had no test, so
7230 /// deleting its gate left the suite green.
7231 #[tokio::test]
7232 async fn get_login_without_a_seat_is_refused() {
7233 let state = test_state(&[]).await;
7234 let resp = router(state)
7235 .oneshot(
7236 Request::builder()
7237 .method("GET")
7238 .uri("/login?handle=alice.bsky.social")
7239 .body(Body::empty())
7240 .unwrap(),
7241 )
7242 .await
7243 .unwrap();
7244 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7245 assert_eq!(
7246 resp.headers().get(header::LOCATION).unwrap(),
7247 "/beta/redeem"
7248 );
7249 }
7250
7251 /// A sidecar fake that answers every request `ok` and records the PATH of
7252 /// each in arrival order, plus every body — for asserting what was sent,
7253 /// and in what order.
7254 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7255 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7256 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7257 let addr = listener.local_addr().unwrap();
7258 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7259 let sink = log.clone();
7260 tokio::spawn(async move {
7261 loop {
7262 let Ok((mut sock, _)) = listener.accept().await else {
7263 break;
7264 };
7265 let mut raw: Vec<u8> = Vec::new();
7266 let mut chunk = [0u8; 4096];
7267 let text = loop {
7268 let Ok(n) = sock.read(&mut chunk).await else {
7269 break String::new();
7270 };
7271 if n == 0 {
7272 break String::from_utf8_lossy(&raw).to_string();
7273 }
7274 raw.extend_from_slice(&chunk[..n]);
7275 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7276 continue;
7277 };
7278 let (head, body) = raw.split_at(split + 4);
7279 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7280 let (k, v) = l.split_once(':')?;
7281 k.eq_ignore_ascii_case("content-length")
7282 .then(|| v.trim().parse::<usize>().ok())?
7283 });
7284 if want.is_none_or(|w| body.len() >= w) {
7285 break String::from_utf8_lossy(&raw).to_string();
7286 }
7287 };
7288 let path = text
7289 .lines()
7290 .next()
7291 .and_then(|l| l.split_whitespace().nth(1))
7292 .unwrap_or("")
7293 .to_string();
7294 let body_text = text
7295 .split_once("\r\n\r\n")
7296 .map(|(_, b)| b)
7297 .unwrap_or("")
7298 .to_string();
7299 sink.lock().unwrap().push(format!("{path} {body_text}"));
7300 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();
7301 let resp = format!(
7302 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7303 body.len(),
7304 body
7305 );
7306 let _ = sock.write_all(resp.as_bytes()).await;
7307 let _ = sock.flush().await;
7308 }
7309 });
7310 (format!("http://{addr}"), log)
7311 }
7312
7313 /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
7314 /// route.** The previous version of this test called
7315 /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
7316 /// flush attempt; its doc claimed deleting the call from the handler
7317 /// "drops that to zero", which was false — the handler was never run.
7318 /// Deleting the call left the suite green: #117 regressing in full, with
7319 /// the test named after it still passing. Now `POST /logout` is driven and
7320 /// the sidecar's log must show a repo write BEFORE the revoke.
7321 #[tokio::test]
7322 async fn signing_out_flushes_before_it_revokes_through_the_route() {
7323 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7324 let (sidecar, log) = spawn_logging_sidecar().await;
7325 let state = test_state_with_sidecar(&[did], &sidecar).await;
7326 crate::store::upsert_cursor(
7327 &state.db,
7328 &crate::store::ReadCursor {
7329 did: did.to_string(),
7330 feed_url: "https://example.com/feed.xml".into(),
7331 read_through: None,
7332 read_ids: "[\"1\"]".into(),
7333 unread_ids: "[]".into(),
7334 dirty: true,
7335 pds_created: false,
7336 updated_at: "2026-09-13T21:22:40Z".into(),
7337 },
7338 )
7339 .await
7340 .unwrap();
7341 let cookie = session_cookie(&state, did, None);
7342 let resp = router(state.clone())
7343 .oneshot(
7344 Request::builder()
7345 .method("POST")
7346 .uri("/logout")
7347 .header(header::COOKIE, cookie)
7348 .body(Body::empty())
7349 .unwrap(),
7350 )
7351 .await
7352 .unwrap();
7353 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7354
7355 let entries = log.lock().unwrap().clone();
7356 let flush = entries
7357 .iter()
7358 .position(|e| e.starts_with("/internal/repo "));
7359 let revoke = entries
7360 .iter()
7361 .position(|e| e.starts_with("/internal/revoke "));
7362 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7363 assert!(
7364 flush.is_some(),
7365 "sign-out did not attempt a flush before revoking: {entries:?}"
7366 );
7367 assert!(
7368 flush < revoke,
7369 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7370 );
7371 }
7372
7373 /// The policy, as a literal: the backstop the router calls "neutralises any
7374 /// XSS that slips past sanitization". `script-src 'self'` and no
7375 /// `'unsafe-inline'` on it are the two clauses that make it one.
7376 const EXPECTED_CSP: &str = "default-src 'self'; \
7377 script-src 'self'; \
7378 style-src 'self' 'unsafe-inline'; \
7379 img-src 'self' https: data:; \
7380 font-src 'self'; \
7381 connect-src 'self'; \
7382 form-action 'self'; \
7383 base-uri 'self'; \
7384 frame-ancestors 'none'; \
7385 object-src 'none'";
7386
7387 /// Build a `multipart/form-data` body carrying a single `file` field whose
7388 /// contents are `payload`, returning `(content_type, body_bytes)`.
7389 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7390 let boundary = "----featherreadertestboundary";
7391 let mut body = Vec::new();
7392 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7393 body.extend_from_slice(
7394 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7395 );
7396 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7397 body.extend_from_slice(payload);
7398 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7399 (format!("multipart/form-data; boundary={boundary}"), body)
7400 }
7401
7402 #[tokio::test]
7403 async fn opml_import_oversize_upload_returns_413() {
7404 let state = test_state(&["did:plc:admin"]).await;
7405 let cookie = session_cookie(&state, "did:plc:admin", None);
7406 let app = router(state);
7407
7408 // A payload comfortably above the route cap.
7409 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7410 let (content_type, body) = opml_multipart(&payload);
7411
7412 let resp = app
7413 .oneshot(
7414 Request::builder()
7415 .method("POST")
7416 .uri("/opml")
7417 .header("content-type", content_type)
7418 .header(header::COOKIE, cookie)
7419 .body(Body::from(body))
7420 .unwrap(),
7421 )
7422 .await
7423 .unwrap();
7424 assert_eq!(
7425 resp.status(),
7426 StatusCode::PAYLOAD_TOO_LARGE,
7427 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7428 );
7429 }
7430
7431 /// **The route's own cap is what refuses this, not the framework's.**
7432 ///
7433 /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
7434 /// the route's layer was a no-op — deleting it left every test green, and
7435 /// `opml_import_oversize_upload_returns_413` was really testing axum. The
7436 /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
7437 /// sits BETWEEN the two: over ours, under the framework's. Only the
7438 /// route's layer can refuse it — remove the layer and this payload is
7439 /// accepted, which is also what demonstrates the framework's default is
7440 /// the larger of the two.
7441 #[tokio::test]
7442 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7443 let state = test_state(&["did:plc:admin"]).await;
7444 let cookie = session_cookie(&state, "did:plc:admin", None);
7445 let app = router(state);
7446
7447 // Between the two ceilings: the framework would accept this.
7448 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7449 let (content_type, body) = opml_multipart(&payload);
7450
7451 let resp = app
7452 .oneshot(
7453 Request::builder()
7454 .method("POST")
7455 .uri("/opml")
7456 .header("content-type", content_type)
7457 .header(header::COOKIE, cookie)
7458 .body(Body::from(body))
7459 .unwrap(),
7460 )
7461 .await
7462 .unwrap();
7463 assert_eq!(
7464 resp.status(),
7465 StatusCode::PAYLOAD_TOO_LARGE,
7466 "a payload over the route's cap but under the framework's was accepted — \
7467 the route's own DefaultBodyLimit layer is not doing anything"
7468 );
7469 }
7470
7471 #[tokio::test]
7472 async fn opml_import_under_limit_upload_is_accepted() {
7473 let state = test_state(&["did:plc:admin"]).await;
7474 let cookie = session_cookie(&state, "did:plc:admin", None);
7475 let db = state.db.clone();
7476 let app = router(state);
7477
7478 // A small, valid OPML well under the cap: must be accepted (the handler
7479 // redirects to `/` or a flash), i.e. never 413.
7480 let opml = br#"<?xml version="1.0"?>
7481<opml version="2.0"><body>
7482 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7483</body></opml>"#;
7484 let (content_type, body) = opml_multipart(opml);
7485
7486 let resp = app
7487 .oneshot(
7488 Request::builder()
7489 .method("POST")
7490 .uri("/opml")
7491 .header("content-type", content_type)
7492 .header(header::COOKIE, cookie)
7493 .body(Body::from(body))
7494 .unwrap(),
7495 )
7496 .await
7497 .unwrap();
7498 // **Assert it was ACCEPTED, not merely that it was not a 413.**
7499 //
7500 // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
7501 // 500 satisfies — so making `import_opml` fail unconditionally left this
7502 // green. Three other OPML tests caught that mutation; the one whose name
7503 // promises to cover the under-cap case did not.
7504 assert_eq!(
7505 resp.status(),
7506 StatusCode::SEE_OTHER,
7507 "an under-cap OPML upload was not accepted (status {})",
7508 resp.status(),
7509 );
7510 // **303 alone is not acceptance.** `import_opml` redirects on several
7511 // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
7512 // by a cap — so an import that stored nothing satisfied the status check.
7513 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7514 .bind("https://example.com/feed.xml")
7515 .fetch_one(&db)
7516 .await
7517 .unwrap();
7518 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7519 let location = resp
7520 .headers()
7521 .get(header::LOCATION)
7522 .and_then(|v| v.to_str().ok())
7523 .unwrap_or_default()
7524 .to_string();
7525 assert!(
7526 !location.starts_with("/login"),
7527 "the import bounced to login instead of being accepted: {location}",
7528 );
7529 }
7530
7531 #[tokio::test]
7532 async fn opml_import_logged_out_redirects_to_login() {
7533 // Logged-out callers are redirected before the body is consumed; assert
7534 // the auth short-circuit rather than a body-cap rejection.
7535 let state = test_state(&["did:plc:admin"]).await;
7536 let app = router(state);
7537
7538 let opml = b"<opml version=\"2.0\"><body></body></opml>";
7539 let (content_type, body) = opml_multipart(opml);
7540
7541 let resp = app
7542 .oneshot(
7543 Request::builder()
7544 .method("POST")
7545 .uri("/opml")
7546 .header("content-type", content_type)
7547 .body(Body::from(body))
7548 .unwrap(),
7549 )
7550 .await
7551 .unwrap();
7552 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7553 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7554 }
7555
7556 // -- delete-my-data (POST /account/delete) --------------------------------
7557
7558 /// A one-shot mock sidecar: binds a loopback port, answers exactly one
7559 /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
7560 /// channel) the DID it was asked to revoke. Enough to prove the delete
7561 /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
7562 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7563 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7564 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7565 let addr = listener.local_addr().unwrap();
7566 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7567 tokio::spawn(async move {
7568 let (mut sock, _) = listener.accept().await.unwrap();
7569 let mut buf = vec![0u8; 4096];
7570 let n = sock.read(&mut buf).await.unwrap();
7571 let req = String::from_utf8_lossy(&buf[..n]).to_string();
7572 // Pull the DID out of the JSON body (last line of the request).
7573 let did = req
7574 .split("\r\n\r\n")
7575 .nth(1)
7576 .and_then(|body| {
7577 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7578 v.get("did")?.as_str().map(str::to_string)
7579 })
7580 .unwrap_or_default();
7581 let is_revoke = req.starts_with("POST /internal/revoke");
7582 let body = serde_json::json!({
7583 "ok": true, "did": did, "revoked": true, "hadSession": true
7584 })
7585 .to_string();
7586 let resp = format!(
7587 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7588 body.len(),
7589 body
7590 );
7591 sock.write_all(resp.as_bytes()).await.unwrap();
7592 sock.flush().await.unwrap();
7593 let _ = tx.send(if is_revoke { did } else { String::new() });
7594 });
7595 (format!("http://{addr}"), rx)
7596 }
7597
7598 /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
7599 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7600 let defaults = Config::default();
7601 test_state_with_sidecar_and(
7602 allowed,
7603 sidecar_url,
7604 defaults.standard_site,
7605 defaults.max_feeds_global,
7606 )
7607 .await
7608 }
7609
7610 /// [`test_state_with_sidecar`] with the standard.site flag and the global
7611 /// feeds ceiling chosen — the two settings the at:// paths branch on.
7612 async fn test_state_with_sidecar_and(
7613 allowed: &[&str],
7614 sidecar_url: &str,
7615 standard_site: bool,
7616 max_feeds_global: i64,
7617 ) -> AppState {
7618 let db = store::init_url("sqlite::memory:").await.unwrap();
7619 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7620 store::ensure_seed(&db, &dids).await.unwrap();
7621 let mut config = Config {
7622 allowed_dids: dids,
7623 cookie_secret: "test-cookie-secret-000".to_string(),
7624 beta_cap: 3,
7625 standard_site,
7626 max_feeds_global,
7627 ..Config::default()
7628 };
7629 config.sidecar.public_url = sidecar_url.to_string();
7630 config.sidecar.internal_url = sidecar_url.to_string();
7631 AppState::new(config, db).unwrap()
7632 }
7633
7634 /// A confirmed `POST /account/delete` purges the caller's local rows, calls
7635 /// the sidecar revoke for that DID, and clears the session cookie.
7636 #[tokio::test]
7637 async fn account_delete_purges_rows_and_triggers_revoke() {
7638 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
7639 let did = "did:plc:leaver";
7640 let state = test_state_with_sidecar(&[], &sidecar_url).await;
7641
7642 // Seed the DID with local rows across the per-DID tables.
7643 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
7644 .await
7645 .unwrap();
7646 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
7647 store::mint_code(&state.db, did, 3600).await.unwrap();
7648 assert!(store::has_beta_access(&state.db, did).await.unwrap());
7649
7650 let cookie = session_cookie(&state, did, Some("leaver.example"));
7651 let app = router(state.clone());
7652
7653 let resp = app
7654 .oneshot(
7655 Request::builder()
7656 .method("POST")
7657 .uri("/account/delete")
7658 .header(header::COOKIE, cookie)
7659 .header("content-type", "application/x-www-form-urlencoded")
7660 .body(Body::from("confirm=DELETE"))
7661 .unwrap(),
7662 )
7663 .await
7664 .unwrap();
7665
7666 // Signed out: redirect to /login with the cookie cleared.
7667 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7668 assert!(resp
7669 .headers()
7670 .get(header::LOCATION)
7671 .unwrap()
7672 .to_str()
7673 .unwrap()
7674 .starts_with("/login"));
7675 let set_cookie = resp
7676 .headers()
7677 .get(header::SET_COOKIE)
7678 .unwrap()
7679 .to_str()
7680 .unwrap();
7681 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
7682
7683 // The sidecar revoke was called for exactly this DID.
7684 //
7685 // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
7686 // that simply never called the sidecar — hung this test forever instead
7687 // of failing it: a wedged CI job rather than a red one, which is the
7688 // worse of the two signals because nobody reads it as a defect.
7689 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
7690 .await
7691 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
7692 .unwrap();
7693 assert_eq!(
7694 revoked_did, did,
7695 "sidecar revoke must fire for the caller DID"
7696 );
7697
7698 // Local rows are gone.
7699 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
7700 let codes: i64 =
7701 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
7702 .bind(did)
7703 .fetch_one(&state.db)
7704 .await
7705 .unwrap();
7706 assert_eq!(codes, 0);
7707 }
7708
7709 /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
7710 /// nothing and bounces back to /manage.
7711 #[tokio::test]
7712 async fn account_delete_without_confirm_is_a_noop() {
7713 let did = "did:plc:staying";
7714 let state = test_state(&[]).await;
7715 store::grant_access(&state.db, did, None, "test", None)
7716 .await
7717 .unwrap();
7718 let cookie = session_cookie(&state, did, None);
7719 let app = router(state.clone());
7720
7721 let resp = app
7722 .oneshot(
7723 Request::builder()
7724 .method("POST")
7725 .uri("/account/delete")
7726 .header(header::COOKIE, cookie)
7727 .header("content-type", "application/x-www-form-urlencoded")
7728 .body(Body::from("confirm=nope"))
7729 .unwrap(),
7730 )
7731 .await
7732 .unwrap();
7733
7734 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7735 assert!(resp
7736 .headers()
7737 .get(header::LOCATION)
7738 .unwrap()
7739 .to_str()
7740 .unwrap()
7741 .starts_with("/manage"));
7742 // Nothing deleted.
7743 assert!(store::has_beta_access(&state.db, did).await.unwrap());
7744 }
7745
7746 /// PDS-outage authorization: when the sidecar is unreachable (as it is in
7747 /// this harness — the default sidecar URL is not served), a DID must STILL
7748 /// be unable to read or mutate an entry in a feed it does not subscribe to.
7749 /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
7750 /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
7751 /// every cached feed.
7752 #[tokio::test]
7753 async fn pds_outage_does_not_widen_cross_did_access() {
7754 let did_a = "did:plc:aaaa";
7755 let state = test_state(&[]).await;
7756 store::grant_access(&state.db, did_a, None, "test", None)
7757 .await
7758 .unwrap();
7759
7760 // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
7761 // lives in feed_b — the one A must never touch during the outage.
7762 let feed_a = store::upsert_feed(
7763 &state.db,
7764 &store::NewFeed {
7765 url: "https://a.example/feed.xml".to_string(),
7766 title: Some("A".to_string()),
7767 ..Default::default()
7768 },
7769 )
7770 .await
7771 .unwrap();
7772 let feed_b = store::upsert_feed(
7773 &state.db,
7774 &store::NewFeed {
7775 url: "https://b.example/feed.xml".to_string(),
7776 title: Some("B".to_string()),
7777 ..Default::default()
7778 },
7779 )
7780 .await
7781 .unwrap();
7782 store::insert_entries(
7783 &state.db,
7784 feed_b,
7785 &[store::NewEntry {
7786 guid: "b-1".to_string(),
7787 url: Some("https://b.example/1".to_string()),
7788 title: Some("B one".to_string()),
7789 published: Some("2026-07-11T00:00:00Z".to_string()),
7790 content_html: Some("<p>secret B body</p>".to_string()),
7791 ..Default::default()
7792 }],
7793 0,
7794 )
7795 .await
7796 .unwrap();
7797 // A subscribes ONLY to feed_a.
7798 store::replace_sub_refs(&state.db, did_a, &[feed_a])
7799 .await
7800 .unwrap();
7801 // Read B's entry id via a transient sub_ref, then drop it so only the
7802 // shared cache holds B's entry (no DID subscribes to feed_b anymore).
7803 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
7804 .await
7805 .unwrap();
7806 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
7807 .await
7808 .unwrap()[0]
7809 .id;
7810 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
7811 .await
7812 .unwrap();
7813
7814 let cookie = session_cookie(&state, did_a, None);
7815 let app = router(state.clone());
7816
7817 // GET /entries/{b} as A → 404 even during the outage.
7818 let get_b = app
7819 .clone()
7820 .oneshot(
7821 Request::builder()
7822 .method("GET")
7823 .uri(format!("/entries/{b_entry_id}"))
7824 .header(header::COOKIE, cookie.clone())
7825 .body(Body::empty())
7826 .unwrap(),
7827 )
7828 .await
7829 .unwrap();
7830 assert_eq!(
7831 get_b.status(),
7832 StatusCode::NOT_FOUND,
7833 "A must not read B's entry during a PDS outage"
7834 );
7835
7836 // POST /entries/{b}/read as A → 404, and no entry_state row is written.
7837 let read_b = app
7838 .oneshot(
7839 Request::builder()
7840 .method("POST")
7841 .uri(format!("/entries/{b_entry_id}/read"))
7842 .header(header::COOKIE, cookie)
7843 .header("content-type", "application/x-www-form-urlencoded")
7844 .body(Body::from("read=true"))
7845 .unwrap(),
7846 )
7847 .await
7848 .unwrap();
7849 assert_eq!(
7850 read_b.status(),
7851 StatusCode::NOT_FOUND,
7852 "A must not mark B's entry read during a PDS outage"
7853 );
7854
7855 // The fallback must NOT have widened A's sub_ref to feed_b.
7856 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
7857 .bind(did_a)
7858 .fetch_all(&state.db)
7859 .await
7860 .unwrap();
7861 assert_eq!(
7862 a_feed_ids,
7863 vec![feed_a],
7864 "outage fallback must not add feeds A never subscribed to"
7865 );
7866 // And B's entry has zero read-state (A's attempt did not mutate).
7867 let es_count: i64 =
7868 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
7869 .bind(did_a)
7870 .bind(b_entry_id)
7871 .fetch_one(&state.db)
7872 .await
7873 .unwrap();
7874 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
7875 }
7876
7877 /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
7878 /// nothing. The other arm is counted separately.**
7879 ///
7880 /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
7881 /// error would make the metric noisy in exactly the case that is fine.
7882 ///
7883 /// But `revoke_everywhere` has TWO arms, and a review found that counting
7884 /// only the rust one let `oauth_revoke` report all-clear while every sidecar
7885 /// revocation failed. For anyone who logged in before the cutover the sidecar
7886 /// store is the only one holding tokens, so the rust arm correctly says
7887 /// NoSession and the metric said nothing was wrong. Both arms are now
7888 /// recorded, distinguished by the backend column — so this test pins the
7889 /// BACKEND as well as the outcome.
7890 #[tokio::test]
7891 async fn a_logout_with_no_session_counts_as_success() {
7892 let did = "did:plc:aaaa";
7893 let state = test_state(&[]).await;
7894 assert!(
7895 state.oauth.is_some(),
7896 "meaningless without an oauth runtime; the revoke arm would be skipped",
7897 );
7898
7899 revoke_everywhere(&state, did).await;
7900 let rows = state.metrics.snapshot();
7901 let find = |b: crate::metrics::Backend| {
7902 rows.iter()
7903 .find(|r| r.op == "oauth_revoke" && r.backend == b)
7904 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
7905 };
7906
7907 // Rust arm: nothing stored for this DID, so NoSession -> ok.
7908 let rust = find(crate::metrics::Backend::Rust);
7909 assert_eq!(
7910 rust.stats.err_count, 0,
7911 "NoSession was counted as a failure; logout is idempotent",
7912 );
7913 assert_eq!(rust.stats.ok_count, 1);
7914
7915 // Sidecar arm: unreachable in a test, so it must be recorded as an
7916 // ERROR under its own backend — not silently dropped, and not folded
7917 // into the rust row.
7918 let sidecar = find(crate::metrics::Backend::Sidecar);
7919 assert_eq!(
7920 sidecar.stats.err_count, 1,
7921 "a failed sidecar revoke was not counted",
7922 );
7923 }
7924
7925 /// **`Failed` must count as an error — the half the metric exists for.**
7926 ///
7927 /// A review found this unpinned: replacing the mapping with
7928 /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
7929 /// asserted the `NoSession -> ok` half, so the branch that actually means
7930 /// "the PDS still holds tokens we asked it to drop" was untested.
7931 ///
7932 /// Driven through the same handler, with a session present but the PDS
7933 /// unreachable, so `sign_out_discovering` returns `Failed`.
7934 #[tokio::test]
7935 async fn a_failed_rust_revoke_counts_as_an_error() {
7936 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7937 let state = test_state(&[]).await;
7938 let runtime = state.oauth.as_deref().expect("oauth runtime");
7939 crate::oauth::store::put_session(
7940 &state.db,
7941 &runtime.codec,
7942 &crate::oauth::store::OAuthSession {
7943 sub: did.into(),
7944 issuer: "https://auth.invalid".into(),
7945 aud: "https://pds.invalid".into(),
7946 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
7947 .to_jwk_json()
7948 .unwrap(),
7949 access_token: "at".into(),
7950 refresh_token: "rt".into(),
7951 token_type: "DPoP".into(),
7952 granted_scope: "atproto".into(),
7953 expires_at: Some(crate::store::now_unix() + 3600),
7954 },
7955 )
7956 .await
7957 .unwrap();
7958
7959 revoke_everywhere(&state, did).await;
7960
7961 let rows = state.metrics.snapshot();
7962 let rust = rows
7963 .iter()
7964 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
7965 .expect("no rust oauth_revoke row");
7966 assert_eq!(
7967 rust.stats.err_count, 1,
7968 "an unreachable PDS must count as a revocation failure",
7969 );
7970 assert_eq!(rust.stats.ok_count, 0);
7971 }
7972
7973 /// **The `href` defence is now carried by the TYPE, not by remembering.**
7974 ///
7975 /// `EntryRow.link` used to be a `String`, and the guard was "call
7976 /// `net::safe_link` before assigning it". Deleting that call left all 679
7977 /// tests passing — a live XSS defence with nothing protecting it.
7978 ///
7979 /// `SafeLink` has no `From<String>` and no public member, so the only way to
7980 /// get foreign input into an `href` is `external`, which does the check
7981 /// itself. This test pins that constructor; the *wiring* is now pinned by
7982 /// the compiler, which is the part a test could never hold down.
7983 ///
7984 /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
7985 /// so the template renders the row WITHOUT an anchor. Dropping the row
7986 /// instead would make the record unremovable, because the un-save button
7987 /// lives on it.
7988 #[test]
7989 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
7990 for hostile in [
7991 "javascript:alert(1)",
7992 "JavaScript:alert(1)",
7993 " javascript:alert(1)",
7994 "data:text/html;base64,PHNjcmlwdD4=",
7995 "vbscript:msgbox(1)",
7996 "file:///etc/passwd",
7997 // Protocol-relative: inherits the page's scheme, so it is an
7998 // off-site link wearing a same-site costume. Carried over from the
7999 // test this one replaces, which was its only unique input.
8000 "//evil.example/path",
8001 ] {
8002 let link = SafeLink::external(hostile);
8003 assert!(
8004 link.is_empty(),
8005 "{hostile:?} produced a non-empty href: {link}",
8006 );
8007 assert!(
8008 !link.to_string().to_ascii_lowercase().contains("script"),
8009 "{hostile:?} leaked into the rendered link",
8010 );
8011 }
8012
8013 // And the other direction: a check that rejects everything would satisfy
8014 // the loop above while breaking every real saved record.
8015 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8016 let link = SafeLink::external(good);
8017 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8018 assert_eq!(link.to_string(), good);
8019 }
8020 }
8021
8022 /// **The WIRING, not the helper — this is the one that catches the real
8023 /// mistake.**
8024 ///
8025 /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
8026 /// `SafeLink::external` *does*. It cannot pin that the saved-record path
8027 /// *calls* it, and a review proved that gap was live twice over: swapping
8028 /// `external` for the app-path constructor, and constructing the tuple
8029 /// directly, both restored the whole `javascript:` hole with every test
8030 /// green. The type now blocks both — `entry` takes an `i64`, and the field
8031 /// lives in another module — but the wiring deserves a test of its own
8032 /// rather than resting on the shape of a signature.
8033 ///
8034 /// Renders the actual row through the actual handler, from a record whose
8035 /// URL is hostile.
8036 #[tokio::test]
8037 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8038 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8039 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8040 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8041 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8042
8043 let resp = router(state)
8044 .oneshot(
8045 Request::builder()
8046 .uri("/?view=starred")
8047 .body(Body::empty())
8048 .unwrap(),
8049 )
8050 .await
8051 .unwrap();
8052 assert_eq!(resp.status(), StatusCode::OK);
8053 let body = String::from_utf8(
8054 axum::body::to_bytes(resp.into_body(), usize::MAX)
8055 .await
8056 .unwrap()
8057 .to_vec(),
8058 )
8059 .unwrap();
8060
8061 // Not in an href, and not as the title either — the title falls back to
8062 // the URL for links we DO render, so both paths must withhold it.
8063 assert!(
8064 !body.to_ascii_lowercase().contains("javascript:"),
8065 "the hostile scheme reached the rendered page",
8066 );
8067 // But the row must survive: the un-save button lives on it, so dropping
8068 // the row would make the record unremovable from here.
8069 assert!(
8070 body.contains("unusable link"),
8071 "the row was dropped instead of rendering without an anchor",
8072 );
8073 }
8074
8075 /// **The reader view's two `href`s, through the actual handler.**
8076 ///
8077 /// The sibling above covers the LIST row. `entry.html` has its own pair of
8078 /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
8079 /// taken straight off the `entries.url` column — a remote feed's `<link>`.
8080 ///
8081 /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
8082 /// this was never a live hole. But that guard is procedural and sits a long
8083 /// way from the `href`: it holds only as long as every future writer to
8084 /// `entries.url` remembers to go through `feed.rs`. This test does not
8085 /// depend on it — it writes the hostile URL into the column DIRECTLY, which
8086 /// is precisely the state the ingest check cannot speak for.
8087 ///
8088 /// **Both directions, deliberately.** A fix that renders no link at all
8089 /// satisfies every negative assertion here, and would break every real
8090 /// entry. The second half is what makes the first half mean something.
8091 #[tokio::test]
8092 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8093 let did = "did:plc:readerhref";
8094 let state = test_state(&[]).await;
8095 store::grant_access(&state.db, did, None, "test", None)
8096 .await
8097 .unwrap();
8098 let feed = store::upsert_feed(
8099 &state.db,
8100 &store::NewFeed {
8101 url: "https://href.example/feed.xml".to_string(),
8102 title: Some("Href".to_string()),
8103 ..Default::default()
8104 },
8105 )
8106 .await
8107 .unwrap();
8108 // Straight into the column, bypassing `feed.rs` — the whole point.
8109 store::insert_entries(
8110 &state.db,
8111 feed,
8112 &[
8113 store::NewEntry {
8114 guid: "hostile-1".to_string(),
8115 url: Some("javascript:alert(1)".to_string()),
8116 title: Some("Hostile entry".to_string()),
8117 published: Some("2026-07-11T00:00:00Z".to_string()),
8118 ..Default::default()
8119 },
8120 store::NewEntry {
8121 guid: "benign-1".to_string(),
8122 url: Some("https://href.example/post".to_string()),
8123 title: Some("Benign entry".to_string()),
8124 published: Some("2026-07-10T00:00:00Z".to_string()),
8125 ..Default::default()
8126 },
8127 ],
8128 0,
8129 )
8130 .await
8131 .unwrap();
8132 store::replace_sub_refs(&state.db, did, &[feed])
8133 .await
8134 .unwrap();
8135 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8136 let id_of = |guid: &str| {
8137 rows.iter()
8138 .find(|r| r.guid == guid)
8139 .unwrap_or_else(|| panic!("{guid} was not inserted"))
8140 .id
8141 };
8142
8143 let cookie = session_cookie(&state, did, None);
8144 let app = router(state.clone());
8145
8146 let render = |id: i64| {
8147 let app = app.clone();
8148 let cookie = cookie.clone();
8149 async move {
8150 let resp = app
8151 .oneshot(
8152 Request::builder()
8153 .method("GET")
8154 .uri(format!("/entries/{id}"))
8155 .header(header::COOKIE, cookie)
8156 .body(Body::empty())
8157 .unwrap(),
8158 )
8159 .await
8160 .unwrap();
8161 assert_eq!(resp.status(), StatusCode::OK);
8162 String::from_utf8(
8163 axum::body::to_bytes(resp.into_body(), usize::MAX)
8164 .await
8165 .unwrap()
8166 .to_vec(),
8167 )
8168 .unwrap()
8169 }
8170 };
8171
8172 let hostile = render(id_of("hostile-1")).await;
8173 // The reader page for THIS entry actually rendered. Without this the
8174 // three negatives below are satisfied by an empty body.
8175 assert!(
8176 hostile.contains("Hostile entry"),
8177 "the reader did not render the entry: {hostile}",
8178 );
8179 assert!(
8180 !hostile.to_ascii_lowercase().contains("javascript:"),
8181 "the hostile scheme reached the reader page: {hostile}",
8182 );
8183 // Not merely escaped — the template took its no-link branch. Both
8184 // `href`s are gated on the same `Option`, so this covers the byline
8185 // link and the action-bar button together.
8186 assert!(
8187 !hostile.contains("actionbar-open"),
8188 "the action bar rendered an open-original link for a refused URL: {hostile}",
8189 );
8190 assert!(
8191 !hostile.contains("Original \u{2197}"),
8192 "the byline rendered an original link for a refused URL: {hostile}",
8193 );
8194
8195 // The other direction: a legitimate entry still links out, so "render
8196 // nothing" cannot pass as a fix.
8197 let benign = render(id_of("benign-1")).await;
8198 assert!(
8199 benign.contains("Benign entry"),
8200 "the reader did not render the benign entry: {benign}",
8201 );
8202 // BOTH `href`s, counted. The negatives above fire on the action bar
8203 // first, so without this the byline needle `Original \u{2197}` is never
8204 // once observed failing — a misspelled needle would pass forever.
8205 assert_eq!(
8206 benign
8207 .matches(r#"href="https://href.example/post""#)
8208 .count(),
8209 2,
8210 "entry.html has two `href`s for the entry URL — the byline link and \
8211 the action-bar button — and this render produced a different \
8212 number: {benign}",
8213 );
8214 assert!(
8215 benign.contains("actionbar-open"),
8216 "a legitimate entry lost its open-original button: {benign}",
8217 );
8218 assert!(
8219 benign.contains("Original \u{2197}"),
8220 "a legitimate entry lost its byline link: {benign}",
8221 );
8222 }
8223
8224 /// **The outage fallback must not widen what the caller can READ — and the
8225 /// sibling test above can only see what it WRITES.**
8226 ///
8227 /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
8228 /// on `entry_state`: the fallback's side effects. But the fail-open it names
8229 /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
8230 /// leaks through the list it *hands back* — the sidebar and the reader render
8231 /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
8232 /// perfectly honest and every existing assertion stays green.
8233 ///
8234 /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
8235 /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
8236 /// exact historical bug the fallback's comment describes — left **all 663
8237 /// tests passing**. Cross-tenant isolation is the one property this project
8238 /// cannot regress quietly, and nothing observed it.
8239 ///
8240 /// So this asserts on the RETURN VALUE, which is the thing that reaches the
8241 /// user, and it deliberately does not look at `sub_ref` at all — that half is
8242 /// already covered above.
8243 #[tokio::test]
8244 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8245 let did_a = "did:plc:aaaa";
8246 let state = test_state(&[]).await;
8247 store::grant_access(&state.db, did_a, None, "test", None)
8248 .await
8249 .unwrap();
8250
8251 let feed_a = store::upsert_feed(
8252 &state.db,
8253 &store::NewFeed {
8254 url: "https://a.example/feed.xml".to_string(),
8255 title: Some("A".to_string()),
8256 ..Default::default()
8257 },
8258 )
8259 .await
8260 .unwrap();
8261 let _feed_b = store::upsert_feed(
8262 &state.db,
8263 &store::NewFeed {
8264 url: "https://b.example/feed.xml".to_string(),
8265 title: Some("B".to_string()),
8266 ..Default::default()
8267 },
8268 )
8269 .await
8270 .unwrap();
8271 // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
8272 // to nobody — exactly the row a whole-cache fallback would hand to A.
8273 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8274 .await
8275 .unwrap();
8276
8277 // No sidecar and no PDS are reachable from a test, so
8278 // `list_subscriptions_sorted` fails and this IS the outage path. Assert
8279 // that, rather than assuming it: if the repo ever starts succeeding here,
8280 // this test would silently stop exercising the fallback at all.
8281 assert!(
8282 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8283 "this test is only meaningful on the outage path; the repo answered",
8284 );
8285
8286 let resolved = resolve_subscriptions(&state, did_a).await;
8287
8288 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8289 assert_eq!(
8290 urls,
8291 vec!["https://a.example/feed.xml"],
8292 "the outage fallback must return the caller's OWN subscriptions only; \
8293 any other feed here is cross-tenant read access granted by an outage",
8294 );
8295 }
8296
8297 /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
8298 /// seeding `did` a beta seat + session-capable state.
8299 async fn test_state_with_caps(
8300 did: &str,
8301 max_subs_per_did: i64,
8302 max_feeds_global: i64,
8303 ) -> AppState {
8304 let db = store::init_url("sqlite::memory:").await.unwrap();
8305 let config = Config {
8306 cookie_secret: "test-cookie-secret-000".to_string(),
8307 beta_cap: 100,
8308 max_subs_per_did,
8309 max_feeds_global,
8310 ..Config::default()
8311 };
8312 store::grant_access(&db, did, None, "test", None)
8313 .await
8314 .unwrap();
8315 AppState::new(config, db).unwrap()
8316 }
8317
8318 /// An OPML document with `n` distinct public feeds.
8319 fn opml_with_feeds(n: usize) -> String {
8320 let mut outlines = String::new();
8321 for i in 0..n {
8322 outlines.push_str(&format!(
8323 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8324 ));
8325 }
8326 format!(
8327 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8328 )
8329 }
8330
8331 /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
8332 /// distinct new feeds than the shared cache can hold caches only up to the
8333 /// ceiling — the rest are trimmed. (Regression: the import loop previously
8334 /// bypassed `max_feeds_global` entirely.)
8335 #[tokio::test]
8336 async fn opml_import_enforces_global_feeds_ceiling() {
8337 let did = "did:plc:importer";
8338 // Cap the shared cache at 3 feeds; import 10 distinct new ones.
8339 let state = test_state_with_caps(did, 0, 3).await;
8340 let cookie = session_cookie(&state, did, None);
8341 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8342 let app = router(state.clone());
8343
8344 let resp = app
8345 .oneshot(
8346 Request::builder()
8347 .method("POST")
8348 .uri("/opml")
8349 .header(header::COOKIE, cookie)
8350 .header("content-type", ct)
8351 .body(Body::from(body))
8352 .unwrap(),
8353 )
8354 .await
8355 .unwrap();
8356 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8357
8358 let feeds = store::count_feeds(&state.db).await.unwrap();
8359 assert!(
8360 feeds <= 3,
8361 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8362 );
8363 }
8364
8365 /// **A malformed `at://` on the add path is "not a kind of feed we take",
8366 /// not "private/paid".** The first gate was the privacy classifier, whose
8367 /// at:// arm fails closed as `Private` for anything not a well-formed
8368 /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
8369 /// the private-feed flash and a "refused private/paid feed" log line. On
8370 /// main the same input reached `resolve_feed_url` and got "Couldn't find a
8371 /// feed". Storability is decided first for an at:// input, with its own
8372 /// message.
8373 #[tokio::test]
8374 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8375 let did = "did:plc:typoist";
8376 let state = test_state_with_caps(did, 0, 0).await;
8377 let cookie = session_cookie(&state, did, None);
8378 for input in [
8379 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8380 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8381 ] {
8382 let resp = router(state.clone())
8383 .oneshot(
8384 Request::builder()
8385 .method("POST")
8386 .uri("/subscriptions")
8387 .header(header::COOKIE, cookie.clone())
8388 .header("content-type", "application/x-www-form-urlencoded")
8389 .body(Body::from(format!("url={input}")))
8390 .unwrap(),
8391 )
8392 .await
8393 .unwrap();
8394 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8395 let loc = resp
8396 .headers()
8397 .get(header::LOCATION)
8398 .unwrap()
8399 .to_str()
8400 .unwrap();
8401 assert!(
8402 loc.contains("kind%20of%20feed"),
8403 "expected the unsupported-feed flash for {input}, got {loc}"
8404 );
8405 assert!(
8406 !loc.contains("Private"),
8407 "a storability refusal was reported as a privacy one for {input}: {loc}"
8408 );
8409 }
8410 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8411 }
8412
8413 /// **An OPML entry this instance cannot store is counted and reported, not
8414 /// silently dropped.** The storability `continue` incremented nothing,
8415 /// while the privacy branch beside it produced a user-visible label — so
8416 /// an OPML exported from a standard.site-enabled instance imported
8417 /// "successfully" with entries missing and no reason given. The reader is
8418 /// told how many, and why.
8419 #[tokio::test]
8420 async fn opml_import_reports_entries_this_instance_cannot_store() {
8421 let did = "did:plc:renamer4";
8422 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8423 let state = test_state_with_sidecar(&[did], &sidecar).await;
8424 assert!(!state.config.standard_site);
8425 let opml = format!(
8426 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8427 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8428 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8429 </body></opml>"
8430 );
8431 let (ct, body) = opml_multipart(opml.as_bytes());
8432 let cookie = session_cookie(&state, did, None);
8433 let resp = router(state.clone())
8434 .oneshot(
8435 Request::builder()
8436 .method("POST")
8437 .uri("/opml")
8438 .header(header::COOKIE, cookie)
8439 .header("content-type", ct)
8440 .body(Body::from(body))
8441 .unwrap(),
8442 )
8443 .await
8444 .unwrap();
8445 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8446 let loc = resp
8447 .headers()
8448 .get(header::LOCATION)
8449 .unwrap()
8450 .to_str()
8451 .unwrap();
8452 assert!(
8453 loc.contains("Imported%201%20feed"),
8454 "unexpected flash: {loc}"
8455 );
8456 assert!(
8457 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8458 "the dropped entry was not reported: {loc}"
8459 );
8460 // Reported by count only: the at-URI itself is not echoed back.
8461 assert!(
8462 !loc.contains("site.standard.publication"),
8463 "the URI was echoed: {loc}"
8464 );
8465 }
8466
8467 /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
8468 /// cap imports zero new feeds.
8469 #[tokio::test]
8470 async fn opml_import_enforces_per_did_cap() {
8471 let did = "did:plc:capped";
8472 // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
8473 let state = test_state_with_caps(did, 2, 0).await;
8474 let existing_a = store::upsert_feed(
8475 &state.db,
8476 &store::NewFeed {
8477 url: "https://have-a.example/feed.xml".to_string(),
8478 ..Default::default()
8479 },
8480 )
8481 .await
8482 .unwrap();
8483 let existing_b = store::upsert_feed(
8484 &state.db,
8485 &store::NewFeed {
8486 url: "https://have-b.example/feed.xml".to_string(),
8487 ..Default::default()
8488 },
8489 )
8490 .await
8491 .unwrap();
8492 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8493 .await
8494 .unwrap();
8495 let before = store::count_feeds(&state.db).await.unwrap();
8496
8497 let cookie = session_cookie(&state, did, None);
8498 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8499 let app = router(state.clone());
8500 let resp = app
8501 .oneshot(
8502 Request::builder()
8503 .method("POST")
8504 .uri("/opml")
8505 .header(header::COOKIE, cookie)
8506 .header("content-type", ct)
8507 .body(Body::from(body))
8508 .unwrap(),
8509 )
8510 .await
8511 .unwrap();
8512 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8513 // Headroom was 0 → no new feeds imported into the shared cache.
8514 let after = store::count_feeds(&state.db).await.unwrap();
8515 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8516 }
8517
8518 /// Single-add per-DID cap: a DID at its subscription cap is refused before
8519 /// any fetch, with the limit flash.
8520 #[tokio::test]
8521 async fn single_add_enforces_per_did_cap() {
8522 let did = "did:plc:subcapped";
8523 let state = test_state_with_caps(did, 1, 0).await;
8524 let f = store::upsert_feed(
8525 &state.db,
8526 &store::NewFeed {
8527 url: "https://have.example/feed.xml".to_string(),
8528 ..Default::default()
8529 },
8530 )
8531 .await
8532 .unwrap();
8533 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8534 let cookie = session_cookie(&state, did, None);
8535 let app = router(state.clone());
8536 let resp = app
8537 .oneshot(
8538 Request::builder()
8539 .method("POST")
8540 .uri("/subscriptions")
8541 .header(header::COOKIE, cookie)
8542 .header("content-type", "application/x-www-form-urlencoded")
8543 .body(Body::from("url=https://another.example/feed.xml"))
8544 .unwrap(),
8545 )
8546 .await
8547 .unwrap();
8548 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8549 let loc = resp
8550 .headers()
8551 .get(header::LOCATION)
8552 .unwrap()
8553 .to_str()
8554 .unwrap();
8555 assert!(
8556 loc.contains("Subscription%20limit%20reached"),
8557 "expected sub-limit flash, got {loc}"
8558 );
8559 }
8560
8561 /// `GET /` renders at most one page of rows and offers a way to the rest.
8562 ///
8563 /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
8564 /// `LIMIT`, article bodies included — and hand the lot to the template. With
8565 /// 250 entries that is the whole list in one response; with a real backlog on
8566 /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
8567 /// is capped, the heading still reports the true total, and page 2 is
8568 /// reachable and disjoint.
8569 #[tokio::test]
8570 async fn the_reader_index_pages_instead_of_rendering_everything() {
8571 let did = "did:plc:pager";
8572 let state = test_state(&[]).await;
8573 store::grant_access(&state.db, did, None, "test", None)
8574 .await
8575 .unwrap();
8576 let feed = store::upsert_feed(
8577 &state.db,
8578 &store::NewFeed {
8579 url: "https://pager.example/feed.xml".to_string(),
8580 title: Some("Pager".to_string()),
8581 ..Default::default()
8582 },
8583 )
8584 .await
8585 .unwrap();
8586 let total = 250_usize;
8587 let entries: Vec<store::NewEntry> = (0..total)
8588 .map(|i| store::NewEntry {
8589 guid: format!("p-{i:04}"),
8590 url: Some(format!("https://pager.example/{i}")),
8591 title: Some(format!("Article {i:04}")),
8592 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8593 content_html: Some("x".repeat(4_000)),
8594 ..Default::default()
8595 })
8596 .collect();
8597 store::insert_entries(&state.db, feed, &entries, 0)
8598 .await
8599 .unwrap();
8600 store::replace_sub_refs(&state.db, did, &[feed])
8601 .await
8602 .unwrap();
8603
8604 let cookie = session_cookie(&state, did, None);
8605 let app = router(state.clone());
8606 let get = |uri: &str| {
8607 let app = app.clone();
8608 let cookie = cookie.clone();
8609 let uri = uri.to_string();
8610 async move {
8611 let resp = app
8612 .oneshot(
8613 Request::builder()
8614 .uri(uri)
8615 .header(header::COOKIE, cookie)
8616 .body(Body::empty())
8617 .unwrap(),
8618 )
8619 .await
8620 .unwrap();
8621 assert_eq!(resp.status(), StatusCode::OK);
8622 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8623 .await
8624 .unwrap();
8625 String::from_utf8(bytes.to_vec()).unwrap()
8626 }
8627 };
8628
8629 let page1 = get("/").await;
8630 // One `<li class="entry…>` per rendered row. Counting "/entries/" would
8631 // over-count: each row carries several (the link plus the read/star
8632 // forms).
8633 let rows1 = page1.matches("<li class=\"entry").count();
8634 assert!(
8635 rows1 <= ENTRIES_PER_PAGE as usize,
8636 "page 1 rendered {rows1} entry links; the list is unbounded"
8637 );
8638 assert!(
8639 rows1 > 0,
8640 "page 1 rendered nothing at all: the page bound swallowed the list"
8641 );
8642 // The count is the TRUE total, not the page size — otherwise paging
8643 // would quietly relabel a 250-entry backlog as a 100-entry one.
8644 assert!(
8645 page1.contains("250 entries"),
8646 "heading must report the full total, not the page"
8647 );
8648 assert!(
8649 page1.contains("page=2"),
8650 "no way to reach the rest of the list: {}",
8651 &page1[..page1.len().min(400)]
8652 );
8653 // The body never belongs in a list response.
8654 assert!(
8655 !page1.contains(&"x".repeat(4_000)),
8656 "the list response carried an article body"
8657 );
8658
8659 let page2 = get("/?page=2").await;
8660 assert!(
8661 page2.matches("<li class=\"entry").count() > 0,
8662 "page 2 rendered no rows at all"
8663 );
8664 assert!(
8665 page2.contains("page=1") || page2.contains("Newer"),
8666 "page 2 offers no way back"
8667 );
8668 // Disjoint: an article on page 1 must not reappear on page 2.
8669 let first_title = (0..total)
8670 .map(|i| format!("Article {i:04}"))
8671 .find(|t| page1.contains(t))
8672 .expect("page 1 shows at least one titled article");
8673 assert!(
8674 !page2.contains(&first_title),
8675 "{first_title} appears on both pages"
8676 );
8677
8678 // A page past the end must not be a dead end. The empty state renders
8679 // instead of the pager, so an out-of-range page would leave a reader
8680 // with no link back — reachable by typing a number, and reachable
8681 // WITHOUT typing anything by paging to the end and then marking entries
8682 // read, which shrinks the list under the URL already in the address bar.
8683 let past_end = get("/?page=999").await;
8684 assert!(
8685 past_end.matches("<li class=\"entry").count() > 0,
8686 "an out-of-range page rendered nothing and offered no way back"
8687 );
8688 assert!(
8689 past_end.contains("page=2"),
8690 "the clamped page offers no pager"
8691 );
8692 }
8693
8694 /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
8695 /// out-of-band action-bar fragment with FRESHLY re-read state so a second
8696 /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
8697 /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
8698 /// view (no reader header) instead swaps the row. This guards the reader OOB
8699 /// toggle wiring, which had no test.
8700 #[tokio::test]
8701 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
8702 let did = "did:plc:reader";
8703 let state = test_state(&[]).await;
8704 store::grant_access(&state.db, did, None, "test", None)
8705 .await
8706 .unwrap();
8707 let feed = store::upsert_feed(
8708 &state.db,
8709 &store::NewFeed {
8710 url: "https://reader.example/feed.xml".to_string(),
8711 title: Some("Reader".to_string()),
8712 ..Default::default()
8713 },
8714 )
8715 .await
8716 .unwrap();
8717 store::insert_entries(
8718 &state.db,
8719 feed,
8720 &[store::NewEntry {
8721 guid: "r-1".to_string(),
8722 url: Some("https://reader.example/1".to_string()),
8723 title: Some("Article".to_string()),
8724 published: Some("2026-07-11T00:00:00Z".to_string()),
8725 content_html: Some("<p>body</p>".to_string()),
8726 ..Default::default()
8727 }],
8728 0,
8729 )
8730 .await
8731 .unwrap();
8732 store::replace_sub_refs(&state.db, did, &[feed])
8733 .await
8734 .unwrap();
8735 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8736
8737 let cookie = session_cookie(&state, did, None);
8738 let app = router(state.clone());
8739
8740 // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
8741 let resp = app
8742 .clone()
8743 .oneshot(
8744 Request::builder()
8745 .method("POST")
8746 .uri(format!("/entries/{entry_id}/read"))
8747 .header(header::COOKIE, cookie.clone())
8748 .header("HX-Request", "true")
8749 .header("X-FR-Reader", "1")
8750 .header("content-type", "application/x-www-form-urlencoded")
8751 .body(Body::from("read=true"))
8752 .unwrap(),
8753 )
8754 .await
8755 .unwrap();
8756 assert_eq!(resp.status(), StatusCode::OK);
8757 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8758 .await
8759 .unwrap();
8760 let html = String::from_utf8(bytes.to_vec()).unwrap();
8761 assert!(
8762 html.contains("hx-swap-oob=\"outerHTML\""),
8763 "reader response must be an OOB swap: {html}"
8764 );
8765 assert!(
8766 html.contains(r#"id="entry-actionbar""#),
8767 "reader response must be the action-bar fragment: {html}"
8768 );
8769 // Now READ: the read button reflects it (aria-pressed=true) and the
8770 // hidden value flips to `false` so the next tap marks it UNREAD.
8771 assert!(
8772 html.contains(r#"aria-pressed="true""#),
8773 "read button must show pressed after marking read: {html}"
8774 );
8775 assert!(
8776 html.contains(r#"name="read" value="false""#),
8777 "hidden read value must flip to false so a second tap reverses: {html}"
8778 );
8779
8780 // A second reader mark-read (submitting the flipped `read=false`) marks
8781 // it UNREAD again — the toggle reverses.
8782 let resp2 = app
8783 .oneshot(
8784 Request::builder()
8785 .method("POST")
8786 .uri(format!("/entries/{entry_id}/read"))
8787 .header(header::COOKIE, cookie)
8788 .header("HX-Request", "true")
8789 .header("X-FR-Reader", "1")
8790 .header("content-type", "application/x-www-form-urlencoded")
8791 .body(Body::from("read=false"))
8792 .unwrap(),
8793 )
8794 .await
8795 .unwrap();
8796 assert_eq!(resp2.status(), StatusCode::OK);
8797 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
8798 .await
8799 .unwrap();
8800 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
8801 assert!(
8802 html2.contains(r#"aria-pressed="false""#),
8803 "read button must show un-pressed after reversing: {html2}"
8804 );
8805 assert!(
8806 html2.contains(r#"name="read" value="true""#),
8807 "hidden read value must flip back to true: {html2}"
8808 );
8809 }
8810
8811 /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
8812 /// action-bar — the counterpart to the reader-OOB test above.
8813 #[tokio::test]
8814 async fn list_mark_read_returns_row_not_oob_actionbar() {
8815 let did = "did:plc:listv";
8816 let state = test_state(&[]).await;
8817 store::grant_access(&state.db, did, None, "test", None)
8818 .await
8819 .unwrap();
8820 let feed = store::upsert_feed(
8821 &state.db,
8822 &store::NewFeed {
8823 url: "https://list.example/feed.xml".to_string(),
8824 title: Some("List".to_string()),
8825 ..Default::default()
8826 },
8827 )
8828 .await
8829 .unwrap();
8830 store::insert_entries(
8831 &state.db,
8832 feed,
8833 &[store::NewEntry {
8834 guid: "l-1".to_string(),
8835 url: Some("https://list.example/1".to_string()),
8836 title: Some("Article".to_string()),
8837 published: Some("2026-07-11T00:00:00Z".to_string()),
8838 ..Default::default()
8839 }],
8840 0,
8841 )
8842 .await
8843 .unwrap();
8844 store::replace_sub_refs(&state.db, did, &[feed])
8845 .await
8846 .unwrap();
8847 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8848
8849 let cookie = session_cookie(&state, did, None);
8850 let app = router(state.clone());
8851
8852 let resp = app
8853 .oneshot(
8854 Request::builder()
8855 .method("POST")
8856 .uri(format!("/entries/{entry_id}/read"))
8857 .header(header::COOKIE, cookie)
8858 .header("HX-Request", "true")
8859 .header("content-type", "application/x-www-form-urlencoded")
8860 .body(Body::from("read=true"))
8861 .unwrap(),
8862 )
8863 .await
8864 .unwrap();
8865 assert_eq!(resp.status(), StatusCode::OK);
8866 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8867 .await
8868 .unwrap();
8869 let html = String::from_utf8(bytes.to_vec()).unwrap();
8870 assert!(
8871 !html.contains("hx-swap-oob"),
8872 "list-view response must NOT be an OOB swap: {html}"
8873 );
8874 // **And it must actually BE the row.** The assertion above is satisfied
8875 // by an empty body, or by any response that simply omits the attribute —
8876 // so on its own it pins half a property and the name promises the other
8877 // half.
8878 assert!(
8879 html.contains(&format!("/entries/{entry_id}")),
8880 "the response is not the row for this entry: {html}",
8881 );
8882 assert!(
8883 html.contains("Article"),
8884 "the row rendered without its title: {html}",
8885 );
8886 // **The row comes back carrying read state. That is all this proves.**
8887 //
8888 // It does NOT prove the state was persisted: the handler renders
8889 // `Some(read)` from the form value, so making `mark_read` roll back
8890 // instead of commit fails 11 store tests and leaves this one green.
8891 //
8892 // It does not prove the OVERRIDE either, which an earlier version of
8893 // this comment claimed. Verified: changing the call site to
8894 // `build_entry_row(pool, &did, id, None)` — deleting the override
8895 // wholesale — keeps the whole suite green, because `mark_read` has
8896 // already persisted the same value two lines earlier, so reading it back
8897 // from the database produces an identical row.
8898 //
8899 // Distinguishing the two needs a case where the override and the stored
8900 // state DISAGREE, which this handler never produces: it writes the value
8901 // it then renders. Left as a known gap rather than described as covered.
8902 assert!(
8903 html.contains("is-read"),
8904 "the row came back without the read state it was just given: {html}",
8905 );
8906 }
8907
8908 // -----------------------------------------------------------------------
8909 // Rename parity (POST /subscriptions/{rkey}/rename)
8910 // -----------------------------------------------------------------------
8911
8912 /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
8913 ///
8914 /// The add path gates the URL the user *typed*; the URL it *stores* is
8915 /// whatever `resolve_feed_url` returns, which for an HTML page is a
8916 /// publisher-controlled `<link rel="alternate">` href. Two layers stop
8917 /// that: `discover_feed` yields only http(s), and the add path re-checks
8918 /// storability on the resolved URL. This test pins the DISJUNCTION —
8919 /// each layer alone holds it, both removed fails it — driven through the
8920 /// real route against a real local server.
8921 ///
8922 /// **Why the fixture is `ftp://`, not `at://`.** This began as the
8923 /// at-URI bypass test from #164, and it was vacuous twice over. Handle
8924 /// form: once storage became DID-only the privacy classifier refused it
8925 /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
8926 /// — the colons in the DID), so `discover_feed` drops it before either
8927 /// layer exists. An at:// link cannot come out of autodiscovery under
8928 /// ANY mutation of the layers, so no test through this route can pin
8929 /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
8930 /// structure and pinned where it lives: `discover_skips_a_non_http_
8931 /// alternate` and the storability tests in `feed.rs`.
8932 #[tokio::test]
8933 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
8934 let did = "did:plc:autodiscovered";
8935 // Access granted, both caps disabled — the only gates left are the
8936 // two under test.
8937 let state = test_state_with_caps(did, 0, 0).await;
8938
8939 let page = r#"<!doctype html><html><head><title>Blog</title>
8940 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
8941 </head><body>hi</body></html>"#;
8942 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
8943 let port: u16 = base
8944 .trim_end_matches('/')
8945 .rsplit(':')
8946 .next()
8947 .unwrap()
8948 .parse()
8949 .unwrap();
8950 crate::net::test_host_override(
8951 "autodiscover-ftp.test",
8952 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8953 );
8954
8955 let cookie = session_cookie(&state, did, None);
8956 let resp = router(state.clone())
8957 .oneshot(
8958 Request::builder()
8959 .method("POST")
8960 .uri("/subscriptions")
8961 .header(header::COOKIE, cookie)
8962 .header("content-type", "application/x-www-form-urlencoded")
8963 .body(Body::from(format!(
8964 "url=http://autodiscover-ftp.test:{port}/"
8965 )))
8966 .unwrap(),
8967 )
8968 .await
8969 .unwrap();
8970 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8971 let loc = resp
8972 .headers()
8973 .get(header::LOCATION)
8974 .unwrap()
8975 .to_str()
8976 .unwrap();
8977 assert_ne!(loc, "/login", "the test never reached the add path");
8978 assert_ne!(loc, "/", "the subscribe succeeded");
8979
8980 assert_eq!(
8981 store::count_feeds(&state.db).await.unwrap(),
8982 0,
8983 "a non-http(s) URL from autodiscovery was stored"
8984 );
8985 assert_eq!(
8986 store::count_subscriptions_for_did(&state.db, did)
8987 .await
8988 .unwrap(),
8989 0
8990 );
8991 }
8992
8993 /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
8994 /// its global ceiling must be refused (capacity flash) and must NOT insert a
8995 /// new `feeds` row — parity with add_subscription's global-cap guard, so a
8996 /// rename loop can't inflate the shared cache past the cap.
8997 #[tokio::test]
8998 async fn rename_to_new_url_refused_at_global_feeds_cap() {
8999 let did = "did:plc:renamer4";
9000 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9001 // Global cap 1; pre-fill it with one feed so headroom is 0.
9002 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9003 store::upsert_feed(
9004 &state.db,
9005 &store::NewFeed {
9006 url: "https://existing.example/feed.xml".to_string(),
9007 ..Default::default()
9008 },
9009 )
9010 .await
9011 .unwrap();
9012 let before = store::count_feeds(&state.db).await.unwrap();
9013 assert_eq!(before, 1);
9014
9015 let cookie = session_cookie(&state, did, None);
9016 let resp = router(state.clone())
9017 .oneshot(
9018 Request::builder()
9019 .method("POST")
9020 .uri("/subscriptions/rk-keep/rename")
9021 .header(header::COOKIE, cookie)
9022 .header("content-type", "application/x-www-form-urlencoded")
9023 // A URL not in the cache → would be a NEW feeds row.
9024 .body(Body::from(
9025 "url=https://brand-new.example/feed.xml&title=Renamed",
9026 ))
9027 .unwrap(),
9028 )
9029 .await
9030 .unwrap();
9031 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9032 let loc = resp
9033 .headers()
9034 .get(header::LOCATION)
9035 .unwrap()
9036 .to_str()
9037 .unwrap();
9038 assert!(
9039 loc.contains("feed%20capacity"),
9040 "expected the feed-capacity flash, got {loc}"
9041 );
9042 // No new feeds row was inserted, and nothing reached the PDS.
9043 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9044 assert!(
9045 puts.lock().unwrap().is_empty(),
9046 "a refused repoint reached the PDS"
9047 );
9048 }
9049
9050 /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
9051 /// global cap (only new URLs are gated) — the other half of the guard.
9052 ///
9053 /// On the sidecar fake, so "allowed" means the put actually happened: the
9054 /// earlier harness had no sidecar, and this passed on a "could not reach
9055 /// your PDS" flash that merely was not the capacity one.
9056 #[tokio::test]
9057 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9058 let did = "did:plc:renamer4";
9059 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9060 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9061 store::upsert_feed(
9062 &state.db,
9063 &store::NewFeed {
9064 url: "https://existing.example/feed.xml".to_string(),
9065 ..Default::default()
9066 },
9067 )
9068 .await
9069 .unwrap();
9070 let before = store::count_feeds(&state.db).await.unwrap();
9071
9072 let cookie = session_cookie(&state, did, None);
9073 let resp = router(state.clone())
9074 .oneshot(
9075 Request::builder()
9076 .method("POST")
9077 .uri("/subscriptions/rk-keep/rename")
9078 .header(header::COOKIE, cookie)
9079 .header("content-type", "application/x-www-form-urlencoded")
9080 .body(Body::from(
9081 "url=https://existing.example/feed.xml&title=Retitled",
9082 ))
9083 .unwrap(),
9084 )
9085 .await
9086 .unwrap();
9087 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9088 let loc = resp
9089 .headers()
9090 .get(header::LOCATION)
9091 .unwrap()
9092 .to_str()
9093 .unwrap();
9094 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9095 assert_eq!(
9096 puts.lock().unwrap().len(),
9097 1,
9098 "the repoint did not reach the PDS"
9099 );
9100 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9101 }
9102
9103 /// A rename with a blank URL writes nothing anywhere.
9104 #[tokio::test]
9105 async fn rename_with_blank_url_writes_nothing() {
9106 let did = "did:plc:renamer3";
9107 let state = test_state_with_caps(did, 0, 0).await;
9108 let before = store::count_feeds(&state.db).await.unwrap();
9109 assert_eq!(before, 0);
9110
9111 let cookie = session_cookie(&state, did, None);
9112 let app = router(state.clone());
9113 let resp = app
9114 .oneshot(
9115 Request::builder()
9116 .method("POST")
9117 .uri("/subscriptions/rkey123/rename")
9118 .header(header::COOKIE, cookie)
9119 .header("content-type", "application/x-www-form-urlencoded")
9120 // Whitespace-only URL trims to empty.
9121 .body(Body::from("url=%20%20&title=Nope"))
9122 .unwrap(),
9123 )
9124 .await
9125 .unwrap();
9126 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9127 assert_eq!(
9128 resp.headers()
9129 .get(header::LOCATION)
9130 .unwrap()
9131 .to_str()
9132 .unwrap(),
9133 "/",
9134 );
9135 // Nothing was cached.
9136 assert_eq!(
9137 store::count_feeds(&state.db).await.unwrap(),
9138 0,
9139 "blank-URL rename wrote a junk feeds row"
9140 );
9141 }
9142
9143 /// A sidecar mock that serves ONE existing subscription record and captures
9144 /// every `put` body a rename produces.
9145 ///
9146 /// **Reads to `content-length` rather than taking one `read`.** A single
9147 /// read gets whatever one segment carried; if the head and body land
9148 /// separately the capture holds no record and every field assertion below
9149 /// passes for the wrong reason. Each captured body must also mention the
9150 /// collection, so an empty capture fails loudly instead of quietly.
9151 async fn spawn_rename_sidecar(
9152 existing: serde_json::Value,
9153 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9154 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9155 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9156 let addr = listener.local_addr().unwrap();
9157 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9158 let sink = puts.clone();
9159 tokio::spawn(async move {
9160 loop {
9161 let Ok((mut sock, _)) = listener.accept().await else {
9162 break;
9163 };
9164 let mut raw: Vec<u8> = Vec::new();
9165 let mut chunk = [0u8; 4096];
9166 let body_text = loop {
9167 let Ok(n) = sock.read(&mut chunk).await else {
9168 break String::new();
9169 };
9170 if n == 0 {
9171 break String::from_utf8_lossy(&raw).to_string();
9172 }
9173 raw.extend_from_slice(&chunk[..n]);
9174 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9175 continue;
9176 };
9177 let (head, body) = raw.split_at(split + 4);
9178 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9179 let (k, v) = l.split_once(':')?;
9180 k.eq_ignore_ascii_case("content-length")
9181 .then(|| v.trim().parse::<usize>().ok())?
9182 });
9183 if want.is_none_or(|want| body.len() >= want) {
9184 break String::from_utf8_lossy(body).to_string();
9185 }
9186 };
9187
9188 // `"action":"put"` is the rename write; anything else is the read.
9189 let is_put = body_text.contains("\"action\":\"put\"");
9190 let data = if is_put {
9191 sink.lock().unwrap().push(body_text.clone());
9192 serde_json::json!({
9193 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9194 "cid": "bafyreiafter"
9195 })
9196 } else {
9197 serde_json::json!({ "records": [existing.clone()] })
9198 };
9199 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9200 let resp = format!(
9201 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9202 body.len(),
9203 body
9204 );
9205 let _ = sock.write_all(resp.as_bytes()).await;
9206 let _ = sock.flush().await;
9207 }
9208 });
9209 (format!("http://{addr}"), puts)
9210 }
9211
9212 /// The existing record a rename must not destroy.
9213 fn seeded_subscription() -> serde_json::Value {
9214 serde_json::json!({
9215 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9216 "cid": "bafyreibefore",
9217 "value": {
9218 "$type": "community.lexicon.rss.subscription",
9219 "url": "https://example.com/feed.xml",
9220 "title": "Old title",
9221 "siteUrl": "https://example.com/blog",
9222 "fetchHint": "hourly",
9223 "private": false,
9224 "createdAt": "2024-03-01T00:00:00.000Z"
9225 }
9226 })
9227 }
9228
9229 /// An existing standard.site subscription, as the 19 in production are:
9230 /// written before this reader refused the scheme, still in the repo.
9231 fn seeded_at_uri_subscription() -> serde_json::Value {
9232 seeded_subscription_with_url(AT_URI_SUB)
9233 }
9234 /// An existing subscription record at `rk-keep` with the given URL.
9235 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9236 serde_json::json!({
9237 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9238 "cid": "bafyreibefore",
9239 "value": {
9240 "$type": "community.lexicon.rss.subscription",
9241 "url": url,
9242 "title": "Old title",
9243 "private": false,
9244 "createdAt": "2024-03-01T00:00:00.000Z"
9245 }
9246 })
9247 }
9248 const AT_URI_SUB: &str =
9249 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9250 const AT_URI_SUB_ENC: &str =
9251 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9252
9253 /// **Retitling an existing `at://` subscription must work with the flag off.**
9254 ///
9255 /// The storability guard was placed before the repo lookup, so it refused
9256 /// any rename whose URL is an at-URI — including a pure title or folder
9257 /// change on a record that already exists. On main that rename succeeded;
9258 /// the 19 production records would have become un-editable. The flag gates
9259 /// what may be STORED in the cache, not whether a reader may edit their own
9260 /// record: the PDS write goes through, the cache row is simply not created.
9261 #[tokio::test]
9262 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9263 let did = "did:plc:renamer5";
9264 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9265 let state = test_state_with_sidecar(&[did], &sidecar).await;
9266 assert!(
9267 !state.config.standard_site,
9268 "the flag must be off for this test"
9269 );
9270 let cookie = session_cookie(&state, did, None);
9271 let resp = router(state.clone())
9272 .oneshot(
9273 Request::builder()
9274 .method("POST")
9275 .uri("/subscriptions/rk-keep/rename")
9276 .header(header::COOKIE, cookie)
9277 .header("content-type", "application/x-www-form-urlencoded")
9278 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9279 .unwrap(),
9280 )
9281 .await
9282 .unwrap();
9283 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9284 let loc = resp
9285 .headers()
9286 .get(header::LOCATION)
9287 .unwrap()
9288 .to_str()
9289 .unwrap();
9290 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9291
9292 let bodies = puts.lock().unwrap().clone();
9293 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9294 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9295 assert_eq!(
9296 sent["record"]["title"], "New title",
9297 "the rename did not apply"
9298 );
9299 assert_eq!(
9300 sent["record"]["url"], AT_URI_SUB,
9301 "the rename changed the URL"
9302 );
9303
9304 // The flag still means what it says for the CACHE: no at:// row.
9305 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9306 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9307 }
9308
9309 /// **Repointing a subscription AT an `at://` URI is still refused with the
9310 /// flag off** — the half of the guard that has to survive the fix above.
9311 /// Nothing reaches the PDS and nothing reaches the cache.
9312 #[tokio::test]
9313 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9314 let did = "did:plc:renamer4";
9315 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9316 let state = test_state_with_sidecar(&[did], &sidecar).await;
9317 let cookie = session_cookie(&state, did, None);
9318 let resp = router(state.clone())
9319 .oneshot(
9320 Request::builder()
9321 .method("POST")
9322 .uri("/subscriptions/rk-keep/rename")
9323 .header(header::COOKIE, cookie)
9324 .header("content-type", "application/x-www-form-urlencoded")
9325 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9326 .unwrap(),
9327 )
9328 .await
9329 .unwrap();
9330 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9331 let loc = resp
9332 .headers()
9333 .get(header::LOCATION)
9334 .unwrap()
9335 .to_str()
9336 .unwrap();
9337 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9338 assert!(
9339 !loc.contains("Private"),
9340 "a storability refusal was reported as a privacy one: {loc}"
9341 );
9342 assert!(
9343 puts.lock().unwrap().is_empty(),
9344 "the repoint reached the PDS"
9345 );
9346 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9347 assert_eq!(cached, 0);
9348 }
9349
9350 /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
9351 /// redirect location.
9352 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9353 let cookie = session_cookie(state, did, None);
9354 let resp = router(state.clone())
9355 .oneshot(
9356 Request::builder()
9357 .method("POST")
9358 .uri("/subscriptions/rk-keep/rename")
9359 .header(header::COOKIE, cookie)
9360 .header("content-type", "application/x-www-form-urlencoded")
9361 .body(Body::from(format!("url={url_enc}&title=New+title")))
9362 .unwrap(),
9363 )
9364 .await
9365 .unwrap();
9366 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9367 resp.headers()
9368 .get(header::LOCATION)
9369 .unwrap()
9370 .to_str()
9371 .unwrap()
9372 .to_string()
9373 }
9374
9375 /// **The privacy gate has the same ordering bug the storable gate had.**
9376 ///
9377 /// Another client can write a subscription whose URL is an at-URI that is
9378 /// not a well-formed publication URI at all — a feed generator, say. On
9379 /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
9380 /// the classifier reads as `Public`). The narrowed at:// arm now fails
9381 /// closed as `Private` for it, and the gate ran before `url_changed` was
9382 /// known — so the record became un-editable, with a flash claiming it "was
9383 /// not saved or sent anywhere". Both gates now apply to a repoint only.
9384 #[tokio::test]
9385 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9386 let did = "did:plc:renamer5";
9387 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9388 let other_enc =
9389 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9390 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9391 let state = test_state_with_sidecar(&[did], &sidecar).await;
9392 let loc = retitle_unchanged(&state, did, other_enc).await;
9393 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9394 let bodies = puts.lock().unwrap().clone();
9395 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9396 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9397 assert_eq!(sent["record"]["title"], "New title");
9398 assert_eq!(sent["record"]["url"], other);
9399 }
9400
9401 /// **A repoint to a secret-bearing URL is still refused** — the half of
9402 /// the privacy gate that has to survive moving it behind `url_changed`.
9403 /// Found by mutation: with the gate deleted outright, nothing failed.
9404 #[tokio::test]
9405 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9406 let did = "did:plc:renamer4";
9407 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9408 let state = test_state_with_sidecar(&[did], &sidecar).await;
9409 let cookie = session_cookie(&state, did, None);
9410 let resp = router(state.clone())
9411 .oneshot(
9412 Request::builder()
9413 .method("POST")
9414 .uri("/subscriptions/rk-keep/rename")
9415 .header(header::COOKIE, cookie)
9416 .header("content-type", "application/x-www-form-urlencoded")
9417 .body(Body::from(
9418 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9419 ))
9420 .unwrap(),
9421 )
9422 .await
9423 .unwrap();
9424 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9425 let loc = resp
9426 .headers()
9427 .get(header::LOCATION)
9428 .unwrap()
9429 .to_str()
9430 .unwrap();
9431 assert!(
9432 loc.contains("Private"),
9433 "the private repoint was not refused: {loc}"
9434 );
9435 assert!(
9436 puts.lock().unwrap().is_empty(),
9437 "a secret-bearing URL reached the PDS"
9438 );
9439 // The repo's fixture token: opaque enough for the classifier, not a real
9440 // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
9441 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9442 assert!(store::get_feed_by_url(&state.db, leaked)
9443 .await
9444 .unwrap()
9445 .is_none());
9446 }
9447
9448 /// **A retitle of a never-cached at:// subscription is not "at feed
9449 /// capacity".** The global-ceiling check keyed on "URL not in the cache",
9450 /// and an at:// record is never cached with the flag off — so at capacity,
9451 /// a pure retitle was refused for a row the handler would not insert. The
9452 /// check now runs once `url_changed` is known and only for a repoint.
9453 #[tokio::test]
9454 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9455 let did = "did:plc:renamer5";
9456 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9457 // Ceiling 1, and one real feed already fills it.
9458 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9459 store::upsert_feed(
9460 &state.db,
9461 &store::NewFeed {
9462 url: "https://filler.example/feed.xml".to_string(),
9463 ..Default::default()
9464 },
9465 )
9466 .await
9467 .unwrap();
9468 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9469 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9470 assert_eq!(
9471 puts.lock().unwrap().len(),
9472 1,
9473 "the retitle did not reach the PDS"
9474 );
9475 assert_eq!(
9476 store::count_feeds(&state.db).await.unwrap(),
9477 1,
9478 "a row was inserted"
9479 );
9480 }
9481
9482 /// **With the flag ON, a well-formed at:// paste is still refused as
9483 /// unsupported** — not "Couldn't find a feed" plus a `warn!`. Nothing can
9484 /// fetch `at://` until the reader is wired, whatever the flag says, and the
9485 /// docs promise this answer "with the flag on or off". This is also the
9486 /// suite's first state with the flag on: every other site passes the flag
9487 /// through with `false`, where a literal `false` would be indistinguishable.
9488 #[tokio::test]
9489 async fn a_well_formed_at_uri_paste_is_refused_as_unsupported_with_the_flag_on() {
9490 let did = "did:plc:renamer5";
9491 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9492 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9493 let cookie = session_cookie(&state, did, None);
9494 let resp = router(state.clone())
9495 .oneshot(
9496 Request::builder()
9497 .method("POST")
9498 .uri("/subscriptions")
9499 .header(header::COOKIE, cookie)
9500 .header("content-type", "application/x-www-form-urlencoded")
9501 .body(Body::from(format!("url={AT_URI_SUB_ENC}")))
9502 .unwrap(),
9503 )
9504 .await
9505 .unwrap();
9506 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9507 let loc = resp
9508 .headers()
9509 .get(header::LOCATION)
9510 .unwrap()
9511 .to_str()
9512 .unwrap();
9513 assert!(
9514 loc.contains("kind%20of%20feed"),
9515 "expected the unsupported flash: {loc}"
9516 );
9517 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9518 }
9519
9520 /// **With the flag ON, an OPML at:// entry is stored.** The one storage
9521 /// path that is meant to work today, asserted with the flag actually on.
9522 #[tokio::test]
9523 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
9524 let did = "did:plc:renamer5";
9525 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9526 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9527 let opml = format!(
9528 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9529 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9530 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9531 </body></opml>"
9532 );
9533 let (ct, body) = opml_multipart(opml.as_bytes());
9534 let cookie = session_cookie(&state, did, None);
9535 let resp = router(state.clone())
9536 .oneshot(
9537 Request::builder()
9538 .method("POST")
9539 .uri("/opml")
9540 .header(header::COOKIE, cookie)
9541 .header("content-type", ct)
9542 .body(Body::from(body))
9543 .unwrap(),
9544 )
9545 .await
9546 .unwrap();
9547 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9548 let loc = resp
9549 .headers()
9550 .get(header::LOCATION)
9551 .unwrap()
9552 .to_str()
9553 .unwrap();
9554 assert!(
9555 loc.contains("Imported%202%20feeds"),
9556 "unexpected flash: {loc}"
9557 );
9558 assert!(
9559 !loc.contains("skipped"),
9560 "the at:// entry was skipped with the flag on: {loc}"
9561 );
9562 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
9563 assert!(
9564 stored.is_some(),
9565 "the at:// entry was not stored with the flag on"
9566 );
9567 }
9568
9569 /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
9570 /// gate behind `url_changed` was right for the PDS write — the record is
9571 /// the reader's — but the cache write was gated only on `storable`, which
9572 /// any http(s) URL is. So a retitle of a record another client wrote with
9573 /// a tokened feed URL inserted that URL into the shared `feeds` table,
9574 /// where the poller would fail it every cycle and print it on the admin
9575 /// page. main refused the whole rename; this keeps the record editable and
9576 /// the cache clean, as `resolve_subscriptions` already does for the same
9577 /// record.
9578 #[tokio::test]
9579 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
9580 let did = "did:plc:renamer5";
9581 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
9582 let tokened_enc =
9583 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
9584 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
9585 let state = test_state_with_sidecar(&[did], &sidecar).await;
9586 let loc = retitle_unchanged(&state, did, tokened_enc).await;
9587 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9588 assert_eq!(
9589 puts.lock().unwrap().len(),
9590 1,
9591 "the retitle did not reach the PDS"
9592 );
9593 assert!(
9594 store::get_feed_by_url(&state.db, tokened)
9595 .await
9596 .unwrap()
9597 .is_none(),
9598 "a secret-bearing URL was written to the shared cache by a retitle"
9599 );
9600 }
9601
9602 /// **On a repoint, storability is decided before privacy and capacity** —
9603 /// the same ordering the add path got. A malformed at:// target drew the
9604 /// private/paid flash, and at capacity a well-formed one drew "try again
9605 /// later" for a URL that can never be accepted with the flag off.
9606 #[tokio::test]
9607 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
9608 let did = "did:plc:renamer4";
9609 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9610 let state = test_state_with_sidecar(&[did], &sidecar).await;
9611 let cookie = session_cookie(&state, did, None);
9612 let resp = router(state.clone())
9613 .oneshot(
9614 Request::builder()
9615 .method("POST")
9616 .uri("/subscriptions/rk-keep/rename")
9617 .header(header::COOKIE, cookie)
9618 .header("content-type", "application/x-www-form-urlencoded")
9619 .body(Body::from(
9620 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
9621 ))
9622 .unwrap(),
9623 )
9624 .await
9625 .unwrap();
9626 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9627 let loc = resp
9628 .headers()
9629 .get(header::LOCATION)
9630 .unwrap()
9631 .to_str()
9632 .unwrap();
9633 assert!(
9634 loc.contains("kind%20of%20feed"),
9635 "expected the unsupported flash: {loc}"
9636 );
9637 assert!(
9638 !loc.contains("Private"),
9639 "a typo was reported as a paid feed: {loc}"
9640 );
9641 assert!(puts.lock().unwrap().is_empty());
9642 }
9643
9644 #[tokio::test]
9645 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
9646 let did = "did:plc:renamer4";
9647 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9648 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9649 store::upsert_feed(
9650 &state.db,
9651 &store::NewFeed {
9652 url: "https://filler.example/feed.xml".to_string(),
9653 ..Default::default()
9654 },
9655 )
9656 .await
9657 .unwrap();
9658 let cookie = session_cookie(&state, did, None);
9659 let resp = router(state.clone())
9660 .oneshot(
9661 Request::builder()
9662 .method("POST")
9663 .uri("/subscriptions/rk-keep/rename")
9664 .header(header::COOKIE, cookie)
9665 .header("content-type", "application/x-www-form-urlencoded")
9666 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9667 .unwrap(),
9668 )
9669 .await
9670 .unwrap();
9671 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9672 let loc = resp
9673 .headers()
9674 .get(header::LOCATION)
9675 .unwrap()
9676 .to_str()
9677 .unwrap();
9678 assert!(
9679 loc.contains("kind%20of%20feed"),
9680 "expected the unsupported flash: {loc}"
9681 );
9682 assert!(
9683 !loc.contains("capacity"),
9684 "an unacceptable URL was reported as a capacity problem: {loc}"
9685 );
9686 assert!(puts.lock().unwrap().is_empty());
9687 }
9688
9689 /// **`url_changed` compares like for like.** The form value is trimmed;
9690 /// the record's URL was compared raw, so a record another client wrote
9691 /// with a trailing space read as a repoint on every retitle and re-armed
9692 /// every gate — including the one that made an at:// record un-editable.
9693 #[tokio::test]
9694 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
9695 let did = "did:plc:renamer5";
9696 let padded = format!("{AT_URI_SUB} ");
9697 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
9698 let state = test_state_with_sidecar(&[did], &sidecar).await;
9699 // The manage row posts the record's URL verbatim, padding included.
9700 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
9701 assert_eq!(
9702 loc, "/",
9703 "the retitle was treated as a repoint and refused: {loc}"
9704 );
9705 let bodies = puts.lock().unwrap().clone();
9706 assert_eq!(bodies.len(), 1);
9707 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9708 assert_eq!(
9709 sent["record"]["url"], AT_URI_SUB,
9710 "the padding was not normalised away"
9711 );
9712 }
9713
9714 /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
9715 /// only, so the trailing upsert must not create a row for an unchanged URL
9716 /// that has none — with the flag on and the cache full, each retitle of a
9717 /// never-cached at:// record was a row past the cap. An existing row still
9718 /// gets its title kept in step.
9719 #[tokio::test]
9720 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
9721 let did = "did:plc:renamer5";
9722 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9723 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
9724 store::upsert_feed(
9725 &state.db,
9726 &store::NewFeed {
9727 url: "https://filler.example/feed.xml".to_string(),
9728 ..Default::default()
9729 },
9730 )
9731 .await
9732 .unwrap();
9733 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9734 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9735 assert_eq!(puts.lock().unwrap().len(), 1);
9736 assert_eq!(
9737 store::count_feeds(&state.db).await.unwrap(),
9738 1,
9739 "a retitle inserted a cache row past the ceiling"
9740 );
9741 }
9742
9743 /// **The add path's at:// pre-check is about the MESSAGE, so it is
9744 /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
9745 /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
9746 /// tripped the secret heuristic on the rkey — the private/paid flash the
9747 /// pre-check exists to avoid. Storage stays case-sensitive; this does not
9748 /// touch it.
9749 #[tokio::test]
9750 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
9751 let did = "did:plc:typoist";
9752 let state = test_state_with_caps(did, 0, 0).await;
9753 let cookie = session_cookie(&state, did, None);
9754 let resp = router(state.clone())
9755 .oneshot(
9756 Request::builder()
9757 .method("POST")
9758 .uri("/subscriptions")
9759 .header(header::COOKIE, cookie)
9760 .header("content-type", "application/x-www-form-urlencoded")
9761 .body(Body::from(
9762 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9763 ))
9764 .unwrap(),
9765 )
9766 .await
9767 .unwrap();
9768 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9769 let loc = resp
9770 .headers()
9771 .get(header::LOCATION)
9772 .unwrap()
9773 .to_str()
9774 .unwrap();
9775 assert!(
9776 loc.contains("kind%20of%20feed"),
9777 "expected the unsupported flash: {loc}"
9778 );
9779 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
9780 }
9781
9782 /// **A rename must not destroy the fields the form never carries.**
9783 ///
9784 /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
9785 /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
9786 /// every field absent from `templates/manage_row.html` (which posts only
9787 /// `url`, `title`, `folder`) was written back as its default:
9788 ///
9789 /// | field | before | after |
9790 /// |---|---|---|
9791 /// | `siteUrl` | whatever the feed advertised | gone |
9792 /// | `fetchHint` | as set | gone |
9793 /// | `private` | as set | gone |
9794 /// | `createdAt` | original subscribe time | reset to now |
9795 ///
9796 /// `createdAt` is the worst of the four: it is the sort key for "when did I
9797 /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
9798 /// tells the reader it moved.
9799 ///
9800 /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
9801 /// in the test — the record only becomes wrong on the way out, so checking
9802 /// the value we passed in would pass just as happily with the fix removed.
9803 #[tokio::test]
9804 async fn renaming_preserves_the_fields_the_form_never_carries() {
9805 let did = "did:plc:renamer4";
9806 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9807 let state = test_state_with_sidecar(&[did], &sidecar).await;
9808 let cookie = session_cookie(&state, did, None);
9809
9810 let resp = router(state.clone())
9811 .oneshot(
9812 Request::builder()
9813 .method("POST")
9814 .uri("/subscriptions/rk-keep/rename")
9815 .header(header::COOKIE, cookie)
9816 .header("content-type", "application/x-www-form-urlencoded")
9817 // Exactly what the manage row posts: url, title, folder.
9818 .body(Body::from(
9819 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
9820 ))
9821 .unwrap(),
9822 )
9823 .await
9824 .unwrap();
9825 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9826
9827 let bodies = puts.lock().unwrap().clone();
9828 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9829 let body = &bodies[0];
9830 // Anchors the negative assertions: an empty capture would satisfy them.
9831 assert!(
9832 body.contains("community.lexicon.rss.subscription"),
9833 "captured no usable put body: {body:?}"
9834 );
9835
9836 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
9837 let record = &sent["record"];
9838
9839 // What the form DID carry must be applied.
9840 assert_eq!(record["title"], "New title", "the rename did not apply");
9841 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
9842
9843 // What the form did NOT carry must survive.
9844 assert_eq!(
9845 record["createdAt"], "2024-03-01T00:00:00.000Z",
9846 "the rename reset createdAt — the reader's subscribe time is gone \
9847 from their own repo, and nothing told them"
9848 );
9849 assert_eq!(
9850 record["siteUrl"], "https://example.com/blog",
9851 "the rename erased siteUrl"
9852 );
9853 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
9854 assert_eq!(record["private"], false, "the rename erased private");
9855 }
9856
9857 /// **Repointing at a different feed drops that feed's properties, but not
9858 /// the subscription's.**
9859 ///
9860 /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
9861 /// so carrying them onto a different URL would leave a site link for the old
9862 /// feed hanging off the new one. `createdAt` and `private` are properties of
9863 /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
9864 /// subscribed, whatever the URL was later corrected to.
9865 #[tokio::test]
9866 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
9867 let did = "did:plc:renamer4";
9868 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9869 let state = test_state_with_sidecar(&[did], &sidecar).await;
9870 let cookie = session_cookie(&state, did, None);
9871
9872 let resp = router(state.clone())
9873 .oneshot(
9874 Request::builder()
9875 .method("POST")
9876 .uri("/subscriptions/rk-keep/rename")
9877 .header(header::COOKIE, cookie)
9878 .header("content-type", "application/x-www-form-urlencoded")
9879 // A DIFFERENT feed URL from the seeded record.
9880 .body(Body::from(
9881 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
9882 ))
9883 .unwrap(),
9884 )
9885 .await
9886 .unwrap();
9887 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9888
9889 let bodies = puts.lock().unwrap().clone();
9890 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9891 assert!(
9892 bodies[0].contains("community.lexicon.rss.subscription"),
9893 "captured no usable put body: {:?}",
9894 bodies[0]
9895 );
9896 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9897 let record = &sent["record"];
9898
9899 assert_eq!(record["url"], "https://other.example/feed.xml");
9900 // The old feed's properties are gone rather than misattributed.
9901 assert!(
9902 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
9903 "the old feed's site link followed the subscription to a new feed: {record}"
9904 );
9905 assert!(
9906 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
9907 "the old feed's fetch hint followed the subscription to a new feed: {record}"
9908 );
9909 // The subscription's own properties survive.
9910 assert_eq!(
9911 record["createdAt"], "2024-03-01T00:00:00.000Z",
9912 "a repoint is still not a new subscription; createdAt must not move"
9913 );
9914 assert_eq!(record["private"], false, "the repoint erased private");
9915 }
9916
9917 /// **A rename against an rkey that is not in the repo writes NOTHING.**
9918 ///
9919 /// `update_subscription` is a `putRecord`, which CREATES the record when the
9920 /// rkey does not exist — with whatever `createdAt` we hand it. So without
9921 /// this refusal a rename against a stale or wrong rkey manufactures a
9922 /// subscription dated today, which is the bug this whole change exists to
9923 /// fix, arriving by a different door.
9924 ///
9925 /// The guard was untested when first written: removing it left all 733 tests
9926 /// green. An untested guard against the exact defect being fixed is how the
9927 /// two previous rounds of this problem got through.
9928 #[tokio::test]
9929 async fn renaming_an_unknown_rkey_writes_nothing() {
9930 let did = "did:plc:renamer4";
9931 // The sidecar serves exactly one record, at rkey `rk-keep`.
9932 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9933 let state = test_state_with_sidecar(&[did], &sidecar).await;
9934 let cookie = session_cookie(&state, did, None);
9935
9936 let resp = router(state.clone())
9937 .oneshot(
9938 Request::builder()
9939 .method("POST")
9940 // ...and this is not it.
9941 .uri("/subscriptions/rk-does-not-exist/rename")
9942 .header(header::COOKIE, cookie)
9943 .header("content-type", "application/x-www-form-urlencoded")
9944 .body(Body::from(
9945 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
9946 ))
9947 .unwrap(),
9948 )
9949 .await
9950 .unwrap();
9951
9952 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9953 let loc = resp
9954 .headers()
9955 .get(header::LOCATION)
9956 .unwrap()
9957 .to_str()
9958 .unwrap();
9959 assert!(
9960 loc.contains("flash="),
9961 "an unknown rkey redirected as though the rename had worked: {loc}"
9962 );
9963 assert!(
9964 puts.lock().unwrap().is_empty(),
9965 "a rename against an unknown rkey wrote a record — putRecord would \
9966 CREATE it, dated today: {:?}",
9967 puts.lock().unwrap()
9968 );
9969 }
9970
9971 /// **A `site_url` the client actually sends is applied, not dropped.**
9972 ///
9973 /// `templates/manage_row.html` does not post this field, so it is tempting
9974 /// to read the arm that handles it as dead code. It is not:
9975 /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
9976 /// today. Discarding the value instead of applying it left all 733 tests
9977 /// green.
9978 ///
9979 /// The value is scheme-checked on the way out by the repo-boundary vet, so
9980 /// this is a coverage gap rather than an exposure — but an untested path
9981 /// that writes a URL into the reader's PDS should not stay untested.
9982 #[tokio::test]
9983 async fn a_client_supplied_site_url_reaches_the_record() {
9984 let did = "did:plc:renamer4";
9985 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9986 let state = test_state_with_sidecar(&[did], &sidecar).await;
9987 let cookie = session_cookie(&state, did, None);
9988
9989 let resp = router(state.clone())
9990 .oneshot(
9991 Request::builder()
9992 .method("POST")
9993 .uri("/subscriptions/rk-keep/rename")
9994 .header(header::COOKIE, cookie)
9995 .header("content-type", "application/x-www-form-urlencoded")
9996 // Same feed URL, but carrying a site_url the manage row
9997 // never sends.
9998 .body(Body::from(
9999 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10000 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10001 ))
10002 .unwrap(),
10003 )
10004 .await
10005 .unwrap();
10006 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10007
10008 let bodies = puts.lock().unwrap().clone();
10009 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10010 assert!(
10011 bodies[0].contains("community.lexicon.rss.subscription"),
10012 "captured no usable put body: {:?}",
10013 bodies[0]
10014 );
10015 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10016 assert_eq!(
10017 sent["record"]["siteUrl"], "https://typed.example/site",
10018 "the client's siteUrl was dropped; the seeded record's survived instead"
10019 );
10020 }
10021
10022 /// **A rename whose read fails writes NOTHING.**
10023 ///
10024 /// This is the property most easily lost when someone later touches this
10025 /// handler: falling back to `Subscription::new` on a read error looks like
10026 /// graceful degradation and is in fact the original bug, reinstated on
10027 /// exactly the path where it is hardest to notice. The reader must be told
10028 /// instead.
10029 #[tokio::test]
10030 async fn a_rename_whose_read_fails_writes_nothing() {
10031 let did = "did:plc:renamer5";
10032 // A port that accepts nothing: the read cannot succeed.
10033 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10034 let dead = format!("http://{}", listener.local_addr().unwrap());
10035 drop(listener);
10036
10037 let state = test_state_with_sidecar(&[did], &dead).await;
10038 let cookie = session_cookie(&state, did, None);
10039 let before = store::count_feeds(&state.db).await.unwrap();
10040
10041 let resp = router(state.clone())
10042 .oneshot(
10043 Request::builder()
10044 .method("POST")
10045 .uri("/subscriptions/rk-keep/rename")
10046 .header(header::COOKIE, cookie)
10047 .header("content-type", "application/x-www-form-urlencoded")
10048 .body(Body::from(
10049 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10050 ))
10051 .unwrap(),
10052 )
10053 .await
10054 .unwrap();
10055
10056 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10057 let loc = resp
10058 .headers()
10059 .get(header::LOCATION)
10060 .unwrap()
10061 .to_str()
10062 .unwrap();
10063 assert!(
10064 loc.contains("flash="),
10065 "a failed read redirected as though the rename had worked: {loc}"
10066 );
10067 assert_eq!(
10068 store::count_feeds(&state.db).await.unwrap(),
10069 before,
10070 "a rename that could not read the record still wrote to the cache"
10071 );
10072 }
10073
10074 /// Folder pre-selection regression: the manage rename row must mark the
10075 /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
10076 /// re-submits the current folder instead of silently un-foldering the feed.
10077 /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
10078 /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
10079 #[test]
10080 fn manage_rename_row_preselects_current_folder() {
10081 let nav = Nav {
10082 handle: "@reader.example".to_string(),
10083 avatar: "RE".to_string(),
10084 view: "unread".to_string(),
10085 scope_qs: String::new(),
10086 folders: Vec::new(),
10087 loose_feeds: Vec::new(),
10088 manage_active: true,
10089 };
10090 let folder_options = vec![
10091 FolderOption {
10092 uri: "at://did:plc:x/app.folder/work".to_string(),
10093 name: "Work".to_string(),
10094 },
10095 FolderOption {
10096 uri: "at://did:plc:x/app.folder/fun".to_string(),
10097 name: "Fun".to_string(),
10098 },
10099 ];
10100 // A foldered feed (in "Work") and a loose feed (no folder), each with a
10101 // non-empty rkey so the rename form renders.
10102 let foldered = FeedView {
10103 rkey: "sub-foldered".to_string(),
10104 url: "https://work.example/feed.xml".to_string(),
10105 title: "Work Feed".to_string(),
10106 unread: 0,
10107 selected: false,
10108 folder: Some("at://did:plc:x/app.folder/work".to_string()),
10109 };
10110 let loose = FeedView {
10111 rkey: "sub-loose".to_string(),
10112 url: "https://loose.example/feed.xml".to_string(),
10113 title: "Loose Feed".to_string(),
10114 unread: 0,
10115 selected: false,
10116 folder: None,
10117 };
10118 let tmpl = ManageTemplate {
10119 version: VERSION,
10120 repo_url: REPO_URL,
10121 kofi_url: KOFI_URL,
10122 flash: String::new(),
10123 nav,
10124 folder_options,
10125 folders: vec![FolderView {
10126 rkey: "folder-work".to_string(),
10127 uri: "at://did:plc:x/app.folder/work".to_string(),
10128 name: "Work".to_string(),
10129 feeds: vec![foldered],
10130 selected: false,
10131 }],
10132 loose_feeds: vec![loose],
10133 };
10134 let html = tmpl.render().unwrap();
10135
10136 // The foldered feed's "Work" option is pre-selected.
10137 assert!(
10138 html.contains(
10139 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10140 ),
10141 "foldered feed must pre-select its current folder: {html}"
10142 );
10143 // The loose feed's "No folder" option is pre-selected (appears for the
10144 // loose row, which has folder=None).
10145 assert!(
10146 html.contains(r#"<option value="" selected>No folder</option>"#),
10147 "loose feed must pre-select 'No folder': {html}"
10148 );
10149 }
10150
10151 /// **The public stats page carries no user data.**
10152 ///
10153 /// It is reachable by anyone, so the thing worth pinning is what it does
10154 /// NOT say: nothing about how many people use the instance, nothing about
10155 /// which feeds fail, nothing about who reads what.
10156 #[tokio::test]
10157 async fn the_public_stats_page_exposes_no_user_data() {
10158 let state = test_state(&[]).await;
10159 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10160 .await
10161 .unwrap();
10162
10163 let resp = router(state)
10164 .oneshot(
10165 Request::builder()
10166 .uri("/stats")
10167 .body(Body::empty())
10168 .unwrap(),
10169 )
10170 .await
10171 .unwrap();
10172 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10173
10174 let body = String::from_utf8(
10175 axum::body::to_bytes(resp.into_body(), usize::MAX)
10176 .await
10177 .unwrap()
10178 .to_vec(),
10179 )
10180 .unwrap();
10181
10182 // Structural checks, not word checks. The page's own prose says it
10183 // publishes no error rates, so searching for that PHRASE finds the
10184 // disclaimer rather than a leak — the first version of this test failed
10185 // on exactly that. What matters is whether identifiers or the
10186 // admin-only figures are present.
10187 assert!(
10188 !body.contains("did:"),
10189 "the public stats page leaked an identifier"
10190 );
10191 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10192 assert!(
10193 !body.contains(admin_only),
10194 "the public page is showing the admin metrics column {admin_only:?}"
10195 );
10196 }
10197 // And it does render the aggregate it exists for.
10198 assert!(body.contains("Feeds tracked"));
10199 assert!(body.contains("Waiting to be polled"));
10200 }
10201
10202 /// **The two states that stop feeds updating must be visible.**
10203 ///
10204 /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
10205 /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
10206 /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
10207 /// the backlog and makes the page read healthier. That inversion is what this
10208 /// test pins: a broken feed must raise a number, not lower one.
10209 #[tokio::test]
10210 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10211 let state = test_state(&[]).await;
10212 // Three feeds: one healthy, one flaky, one long dead.
10213 for (url, errors) in [
10214 ("https://ok.example/f.xml", 0),
10215 ("https://flaky.example/f.xml", 2),
10216 ("https://dead.example/f.xml", 9),
10217 ] {
10218 store::upsert_feed(
10219 &state.db,
10220 &store::NewFeed {
10221 url: url.to_string(),
10222 // Pushed forward, exactly as backoff does — so none of these
10223 // are counted as `overdue`.
10224 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10225 ..Default::default()
10226 },
10227 )
10228 .await
10229 .unwrap();
10230 for _ in 0..errors {
10231 store::bump_feed_errors(
10232 &state.db,
10233 url,
10234 feed::FailureKind::Fetch,
10235 "connection refused",
10236 )
10237 .await
10238 .unwrap();
10239 }
10240 }
10241
10242 let render_stats = |state: AppState| async move {
10243 let resp = router(state)
10244 .oneshot(
10245 Request::builder()
10246 .uri("/stats")
10247 .body(Body::empty())
10248 .unwrap(),
10249 )
10250 .await
10251 .unwrap();
10252 assert_eq!(resp.status(), StatusCode::OK);
10253 String::from_utf8(
10254 axum::body::to_bytes(resp.into_body(), usize::MAX)
10255 .await
10256 .unwrap()
10257 .to_vec(),
10258 )
10259 .unwrap()
10260 };
10261
10262 // **The fixture must actually be RUNNING, or this test measures nothing.**
10263 // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
10264 // checks that BEFORE the watermark — so without these two lines every
10265 // render below reports "off" and the watermark can never surface. The
10266 // assertions still passed, for reasons unrelated to what they name: see
10267 // the two comments below.
10268 state.runtime_health.set_schedulers_enabled(true);
10269 state
10270 .runtime_health
10271 .poll_tick_completed(crate::store::now_unix());
10272
10273 let body = render_stats(state.clone()).await;
10274 assert!(
10275 body.contains("Failing"),
10276 "backoff is still invisible on the public page"
10277 );
10278 // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
10279 // value rather than on surrounding whitespace, so re-indenting the
10280 // template cannot break this.
10281 assert!(
10282 body.contains("2, 1 badly"),
10283 "expected '2, 1 badly' in the failing row; got:\n{}",
10284 body.split("Failing")
10285 .nth(1)
10286 .unwrap_or("")
10287 .chars()
10288 .take(300)
10289 .collect::<String>()
10290 );
10291 // Not paused, and the backlog is genuinely empty — which is exactly the
10292 // reading that used to be indistinguishable from healthy.
10293 //
10294 // **Asserted by EXCLUDING the other states, not by matching "running".**
10295 // The `off` row reads "the poller is not running on this instance", which
10296 // contains "running" — so the bare substring passed while the page was
10297 // reporting the exact opposite of what this line claims to check.
10298 assert!(
10299 !body.contains("the poller is not running")
10300 && !body.contains("the cache is at its size limit")
10301 && !body.contains("has not completed a round"),
10302 "expected the running state; the page reported a stopped one",
10303 );
10304
10305 // Now trip the watermark. Nothing in the database changes; only the
10306 // recorded runtime state does — which is the whole reason it needed a
10307 // home outside the log stream.
10308 state.runtime_health.set_watermark(true);
10309 let paused = render_stats(state.clone()).await;
10310 // Matched on the paused row's OWN sentence. The bare word "paused" also
10311 // appeared in the page's explanatory prose, so this assertion passed
10312 // whether or not the row rendered — and trimming that prose is what
10313 // exposed it. This phrase exists only inside the `paused` branch.
10314 assert!(
10315 paused.contains("the cache is at its size limit"),
10316 "a watermark pause is still invisible on the public page"
10317 );
10318
10319 // Still no identifiers: these are counts, not feeds.
10320 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10321 assert!(
10322 !paused.contains(leak),
10323 "the public page leaked {leak:?} while reporting failures"
10324 );
10325 }
10326 }
10327
10328 /// **`/admin/metrics` is gated, and nothing checked that it was.**
10329 ///
10330 /// Deleting the `admin_seed_dids` check left the entire suite green. That
10331 /// was survivable while the page held only aggregate timings; it is not now,
10332 /// because this branch puts **per-feed URLs and remote error text** behind
10333 /// that gate. A guarantee nothing checks is a comment, and this one is now
10334 /// the only thing standing between a signed-in stranger and the operational
10335 /// picture the handler's own doc says is not public.
10336 ///
10337 /// All three doors: no session, a session that is not an admin, and the
10338 /// admin itself.
10339 #[tokio::test]
10340 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
10341 let admin = "did:plc:adminseed";
10342 // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
10343 // IS that list — deliberately, per its doc: "the same people I trust on
10344 // this instance". Production sets it to the bootstrap DID alone.
10345 //
10346 // A genuine non-admin is therefore someone holding a beta seat granted
10347 // by an invite, not by the allow-list. Seeding both would have made
10348 // both admins and quietly turned the 403 assertion below into a test of
10349 // nothing — which is exactly what the first draft of this did.
10350 let state = test_state(&[admin]).await;
10351 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
10352 .await
10353 .unwrap();
10354 let url = "https://broken.example/f.xml";
10355 store::upsert_feed(
10356 &state.db,
10357 &store::NewFeed {
10358 url: url.to_string(),
10359 ..Default::default()
10360 },
10361 )
10362 .await
10363 .unwrap();
10364 store::bump_feed_errors(
10365 &state.db,
10366 url,
10367 feed::FailureKind::Fetch,
10368 "SENTINEL_ADMIN_ONLY",
10369 )
10370 .await
10371 .unwrap();
10372
10373 let get = |state: AppState, cookie: Option<String>| async move {
10374 let mut req = Request::builder().uri("/admin/metrics");
10375 if let Some(c) = cookie {
10376 req = req.header(header::COOKIE, c);
10377 }
10378 let resp = router(state)
10379 .oneshot(req.body(Body::empty()).unwrap())
10380 .await
10381 .unwrap();
10382 let status = resp.status();
10383 let body = String::from_utf8(
10384 axum::body::to_bytes(resp.into_body(), usize::MAX)
10385 .await
10386 .unwrap()
10387 .to_vec(),
10388 )
10389 .unwrap();
10390 (status, body)
10391 };
10392
10393 // No session at all.
10394 let (status, body) = get(state.clone(), None).await;
10395 assert_eq!(status, StatusCode::UNAUTHORIZED);
10396 assert!(
10397 !body.contains("SENTINEL_ADMIN_ONLY"),
10398 "leaked to anonymous: {body}"
10399 );
10400
10401 // A real, signed-in user who is not an admin.
10402 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
10403 let (status, body) = get(state.clone(), Some(ordinary)).await;
10404 assert_eq!(
10405 status,
10406 StatusCode::FORBIDDEN,
10407 "a non-admin session was let in"
10408 );
10409 assert!(
10410 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
10411 "leaked to a non-admin: {body}",
10412 );
10413
10414 // The admin does get it — otherwise the two refusals above are
10415 // satisfied by the endpoint being broken for everyone.
10416 let admin_cookie = session_cookie(&state, admin, None);
10417 let (status, body) = get(state, Some(admin_cookie)).await;
10418 assert_eq!(status, StatusCode::OK);
10419 assert!(
10420 body.contains("SENTINEL_ADMIN_ONLY"),
10421 "admin cannot see it: {body}"
10422 );
10423 }
10424
10425 /// **The cause a public count cannot carry belongs on the admin page.**
10426 ///
10427 /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
10428 /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
10429 /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
10430 /// have separated "sixty dead publishers" from "one bug here", which is the
10431 /// case it was justified by.
10432 ///
10433 /// The answer is not a finer public vocabulary — `/stats` promises never
10434 /// which feed and never whose, and a bucket per error string would break
10435 /// that. It is to put the detail where per-feed data is already allowed.
10436 /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
10437 /// operational picture.
10438 ///
10439 /// Asserts both halves: the detail IS on the admin page, and is NOT on the
10440 /// public one.
10441 #[tokio::test]
10442 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
10443 let admin = "did:plc:adminseed";
10444 let state = test_state(&[admin]).await;
10445 let url = "https://broken.example/f.xml";
10446 store::upsert_feed(
10447 &state.db,
10448 &store::NewFeed {
10449 url: url.to_string(),
10450 ..Default::default()
10451 },
10452 )
10453 .await
10454 .unwrap();
10455 store::bump_feed_errors(
10456 &state.db,
10457 url,
10458 feed::FailureKind::Fetch,
10459 "SENTINEL_REDIRECT_NO_LOCATION",
10460 )
10461 .await
10462 .unwrap();
10463
10464 let cookie = session_cookie(&state, admin, None);
10465 let resp = router(state.clone())
10466 .oneshot(
10467 Request::builder()
10468 .uri("/admin/metrics")
10469 .header(header::COOKIE, cookie)
10470 .body(Body::empty())
10471 .unwrap(),
10472 )
10473 .await
10474 .unwrap();
10475 assert_eq!(resp.status(), StatusCode::OK);
10476 let admin_body = String::from_utf8(
10477 axum::body::to_bytes(resp.into_body(), usize::MAX)
10478 .await
10479 .unwrap()
10480 .to_vec(),
10481 )
10482 .unwrap();
10483 assert!(
10484 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
10485 "the admin page does not carry the failure detail: {admin_body}",
10486 );
10487 assert!(
10488 admin_body.contains("broken.example"),
10489 "the admin page does not name the failing feed: {admin_body}",
10490 );
10491
10492 // The public page still carries neither.
10493 let resp = router(state)
10494 .oneshot(
10495 Request::builder()
10496 .uri("/stats")
10497 .body(Body::empty())
10498 .unwrap(),
10499 )
10500 .await
10501 .unwrap();
10502 let public = String::from_utf8(
10503 axum::body::to_bytes(resp.into_body(), usize::MAX)
10504 .await
10505 .unwrap()
10506 .to_vec(),
10507 )
10508 .unwrap();
10509 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
10510 assert!(
10511 !public.contains(secret),
10512 "{secret:?} reached the PUBLIC stats page: {public}",
10513 );
10514 }
10515 }
10516
10517 /// **A direct poll must settle the error columns, like the scheduler does.**
10518 ///
10519 /// `add_subscription` polls through `feed::poll_feed` rather than the
10520 /// scheduler, and `poll_feed` writes validators and `last_polled` but never
10521 /// touches `consecutive_errors` — that is the scheduler's job, and this path
10522 /// is not the scheduler.
10523 ///
10524 /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
10525 /// its old count and its old cause: the public page went on reporting it
10526 /// under `Failing`, under `badly_broken`, and under a cause, for as long as
10527 /// the stale backoff horizon lasted — up to 24h — while the reader was
10528 /// demonstrably fetching it.
10529 #[tokio::test]
10530 async fn a_successful_direct_poll_clears_a_stale_failure() {
10531 let state = test_state(&[]).await;
10532 let url = "https://recovered.example/f.xml";
10533 store::upsert_feed(
10534 &state.db,
10535 &store::NewFeed {
10536 url: url.to_string(),
10537 ..Default::default()
10538 },
10539 )
10540 .await
10541 .unwrap();
10542 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
10543 .await
10544 .unwrap();
10545 // Park it on a stale backoff horizon, as a real failing feed would be.
10546 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
10547 .bind(url)
10548 .execute(&state.db)
10549 .await
10550 .unwrap();
10551
10552 // The publisher is fixed: a successful poll happens on this path.
10553 feed::settle_poll(
10554 &state.db,
10555 url,
10556 &feed::PollOutcome::NotModified,
10557 state.config.poll_interval,
10558 )
10559 .await;
10560
10561 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10562 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10563 )
10564 .bind(url)
10565 .fetch_one(&state.db)
10566 .await
10567 .unwrap();
10568 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
10569 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
10570 // **The half the first fix missed.** Clearing the count fixed the
10571 // REPORTING; the feed stayed parked until 2099. A working feed must be
10572 // rescheduled on its normal cadence, not left on the failure horizon.
10573 let next = row.2.expect("next_poll was cleared to NULL");
10574 // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
10575 // backoff. A mutation that reschedules successes with backoff_for(1)
10576 // (5 min) also moves it off 2099, so the interval is asserted.
10577 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10578 let delta = parsed
10579 .signed_duration_since(chrono::Utc::now())
10580 .num_seconds();
10581 let cadence = state.config.poll_interval.as_secs() as i64;
10582 assert!(
10583 (cadence - 60..=cadence + 60).contains(&delta),
10584 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
10585 );
10586 }
10587
10588 /// The mirror case: a first poll that FAILS must be visible at all.
10589 ///
10590 /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
10591 /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
10592 /// with a NULL cause — invisible to the page built to count exactly that.
10593 #[tokio::test]
10594 async fn a_failing_direct_poll_is_recorded() {
10595 let state = test_state(&[]).await;
10596 let url = "https://born-broken.example/f.xml";
10597 store::upsert_feed(
10598 &state.db,
10599 &store::NewFeed {
10600 url: url.to_string(),
10601 ..Default::default()
10602 },
10603 )
10604 .await
10605 .unwrap();
10606
10607 feed::settle_poll(
10608 &state.db,
10609 url,
10610 &feed::PollOutcome::Failed {
10611 backoff: std::time::Duration::from_secs(300),
10612 kind: feed::FailureKind::Parse,
10613 detail: "SENTINEL_BORN_BROKEN".to_string(),
10614 },
10615 state.config.poll_interval,
10616 )
10617 .await;
10618
10619 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10620 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10621 )
10622 .bind(url)
10623 .fetch_one(&state.db)
10624 .await
10625 .unwrap();
10626 assert_eq!(row.0, 1, "a failed first poll was not counted");
10627 assert_eq!(
10628 row.1.as_deref(),
10629 Some("parse"),
10630 "its cause was not recorded"
10631 );
10632 // And it is BACKED OFF on the schedule the scheduler would use — not
10633 // left with a NULL next_poll that `due_feeds` sorts first and re-polls
10634 // on the very next tick.
10635 let next = row.2.expect("a failed direct poll left next_poll NULL");
10636 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10637 let delta = parsed
10638 .signed_duration_since(chrono::Utc::now())
10639 .num_seconds();
10640 assert!(
10641 (240..=360).contains(&delta),
10642 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
10643 );
10644 }
10645
10646 /// **The breakdown must sum to the Failing figure above it.**
10647 ///
10648 /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
10649 /// `consecutive_errors > 0`. On a migrated database every row that was
10650 /// already failing has a NULL kind — correctly, it was never recorded — so
10651 /// the two do not reconcile and the page shows "70 failing" beside "3
10652 /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
10653 /// entirely while the prose still promises a breakdown.
10654 ///
10655 /// An explicit `unknown` bucket is the honest shape: the page says how many
10656 /// it cannot explain rather than omitting them.
10657 #[tokio::test]
10658 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
10659 let state = test_state(&[]).await;
10660 // Two legacy rows: failing, with no recorded cause.
10661 for url in [
10662 "https://legacy1.example/f.xml",
10663 "https://legacy2.example/f.xml",
10664 ] {
10665 store::upsert_feed(
10666 &state.db,
10667 &store::NewFeed {
10668 url: url.to_string(),
10669 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10670 ..Default::default()
10671 },
10672 )
10673 .await
10674 .unwrap();
10675 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
10676 .bind(url)
10677 .execute(&state.db)
10678 .await
10679 .unwrap();
10680 }
10681 // One row with a recorded cause.
10682 store::upsert_feed(
10683 &state.db,
10684 &store::NewFeed {
10685 url: "https://known.example/f.xml".to_string(),
10686 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10687 ..Default::default()
10688 },
10689 )
10690 .await
10691 .unwrap();
10692 store::bump_feed_errors(
10693 &state.db,
10694 "https://known.example/f.xml",
10695 feed::FailureKind::Status,
10696 "SENTINEL",
10697 )
10698 .await
10699 .unwrap();
10700
10701 let now = chrono::Utc::now();
10702 let health = store::poll_health(
10703 &state.db,
10704 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10705 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10706 )
10707 .await
10708 .unwrap();
10709 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
10710 assert_eq!(
10711 counted, health.in_backoff,
10712 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
10713 health.in_backoff, health.failure_kinds,
10714 );
10715 assert!(
10716 health
10717 .failure_kinds
10718 .iter()
10719 .any(|(k, n)| k == "unknown" && *n == 2),
10720 "no unknown bucket for the legacy rows: {:?}",
10721 health.failure_kinds,
10722 );
10723 }
10724
10725 /// **The breakdown is ordered by count, and the assertion can see it.**
10726 ///
10727 /// The first version of this asserted with three `contains` calls, which
10728 /// cannot observe order — deleting `ORDER BY` from the query passed.
10729 #[tokio::test]
10730 async fn the_failure_breakdown_is_ordered_by_count() {
10731 let state = test_state(&[]).await;
10732 for (url, kind, n) in [
10733 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
10734 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
10735 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
10736 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
10737 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
10738 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
10739 ] {
10740 store::upsert_feed(
10741 &state.db,
10742 &store::NewFeed {
10743 url: url.to_string(),
10744 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10745 ..Default::default()
10746 },
10747 )
10748 .await
10749 .unwrap();
10750 for _ in 0..n {
10751 store::bump_feed_errors(&state.db, url, kind, "d")
10752 .await
10753 .unwrap();
10754 }
10755 }
10756 let now = chrono::Utc::now();
10757 let health = store::poll_health(
10758 &state.db,
10759 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10760 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10761 )
10762 .await
10763 .unwrap();
10764 let labels: Vec<&str> = health
10765 .failure_kinds
10766 .iter()
10767 .map(|(k, _)| k.as_str())
10768 .collect();
10769 assert_eq!(
10770 labels,
10771 ["fetch", "status", "parse"],
10772 "not ordered by count, descending: {:?}",
10773 health.failure_kinds,
10774 );
10775 }
10776
10777 /// **Failing feeds are grouped by CAUSE, and still never named.**
10778 ///
10779 /// `badly_broken` could say that sixty feeds were failing and not whether
10780 /// that was sixty dead publishers or one bug here. It was the latter — #159,
10781 /// a `304 Not Modified` read as a malformed redirect — and the page could
10782 /// not say so, which is most of why it went unexamined.
10783 ///
10784 /// The second half of this test is the constraint that shapes the first:
10785 /// `/stats` is public and promises machines-not-people, *never which feed
10786 /// and never whose*. A histogram of causes keeps that promise; a list of
10787 /// failing URLs would break it, and is the obvious way to build this.
10788 #[tokio::test]
10789 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
10790 let state = test_state(&[]).await;
10791 for (url, kind, detail, errors) in [
10792 // Detail strings are distinctive SENTINELS, not plausible English.
10793 // A first pass used "not a feed", which the page's own explanation
10794 // of the `parse` kind contains verbatim — the privacy assertion
10795 // fired on static copy rather than on a leak. A sentinel cannot
10796 // collide with prose.
10797 (
10798 "https://a.example/f.xml",
10799 feed::FailureKind::Fetch,
10800 "SENTINEL_CONNREFUSED",
10801 3,
10802 ),
10803 (
10804 "https://b.example/f.xml",
10805 feed::FailureKind::Fetch,
10806 "SENTINEL_DNSFAIL",
10807 2,
10808 ),
10809 (
10810 "https://c.example/f.xml",
10811 feed::FailureKind::Status,
10812 "SENTINEL_404",
10813 1,
10814 ),
10815 (
10816 "https://d.example/f.xml",
10817 feed::FailureKind::Parse,
10818 "SENTINEL_UNPARSEABLE",
10819 1,
10820 ),
10821 ] {
10822 store::upsert_feed(
10823 &state.db,
10824 &store::NewFeed {
10825 url: url.to_string(),
10826 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10827 ..Default::default()
10828 },
10829 )
10830 .await
10831 .unwrap();
10832 for _ in 0..errors {
10833 store::bump_feed_errors(&state.db, url, kind, detail)
10834 .await
10835 .unwrap();
10836 }
10837 }
10838
10839 let resp = router(state.clone())
10840 .oneshot(
10841 Request::builder()
10842 .uri("/stats")
10843 .body(Body::empty())
10844 .unwrap(),
10845 )
10846 .await
10847 .unwrap();
10848 assert_eq!(resp.status(), StatusCode::OK);
10849 let body = String::from_utf8(
10850 axum::body::to_bytes(resp.into_body(), usize::MAX)
10851 .await
10852 .unwrap()
10853 .to_vec(),
10854 )
10855 .unwrap();
10856
10857 // Descending by count: two fetch, then one each, tie-broken by name.
10858 assert!(
10859 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
10860 "the cause histogram did not render: {body}",
10861 );
10862
10863 // **The privacy half.** No feed URL, host, or error detail reaches the
10864 // public page — only counts by kind.
10865 for secret in [
10866 "a.example",
10867 "b.example",
10868 "c.example",
10869 "d.example",
10870 "SENTINEL_CONNREFUSED",
10871 "SENTINEL_DNSFAIL",
10872 "SENTINEL_404",
10873 "SENTINEL_UNPARSEABLE",
10874 ] {
10875 assert!(
10876 !body.contains(secret),
10877 "{secret:?} reached the PUBLIC stats page: {body}",
10878 );
10879 }
10880 }
10881
10882 /// `/health` must prove the process can reach its database, and must report
10883 /// the loop state without letting it change the status code.
10884 #[tokio::test]
10885 async fn health_checks_the_database_and_reports_the_loops() {
10886 let state = test_state(&[]).await;
10887 let body_of = |state: AppState| async move {
10888 let resp = router(state)
10889 .oneshot(
10890 Request::builder()
10891 .uri("/health")
10892 .body(Body::empty())
10893 .unwrap(),
10894 )
10895 .await
10896 .unwrap();
10897 let status = resp.status();
10898 let body = String::from_utf8(
10899 axum::body::to_bytes(resp.into_body(), usize::MAX)
10900 .await
10901 .unwrap()
10902 .to_vec(),
10903 )
10904 .unwrap();
10905 (status, body)
10906 };
10907
10908 // The boot stamp is what `main` sets; the router alone does not, so this
10909 // starts "unknown" and the uptime branch below drives it explicitly.
10910 state
10911 .runtime_health
10912 .set_started_at(chrono::Utc::now().timestamp());
10913
10914 let (status, body) = body_of(state.clone()).await;
10915 assert_eq!(status, StatusCode::OK);
10916 assert!(
10917 body.contains("db: ok"),
10918 "health did not probe the DB: {body}"
10919 );
10920 assert!(
10921 body.contains("uptime:"),
10922 "no uptime — the first thing anyone asks about a container that may \
10923 be restarting: {body}"
10924 );
10925 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
10926 assert!(body.contains("polling-paused: no"), "{body}");
10927 assert!(body.contains("backend:"), "{body}");
10928 assert!(body.contains("oauth-runtime:"), "{body}");
10929
10930 // A watermark pause is REPORTED but must not fail the check. A failed
10931 // check DEREGISTERS this machine from the proxy — and it is the only
10932 // machine — so it would turn "feeds are behind" into "the site is down"
10933 // for as long as the disk stays full.
10934 state.runtime_health.set_watermark(true);
10935 state.runtime_health.set_schedulers_enabled(true);
10936 let (status, body) = body_of(state.clone()).await;
10937 assert_eq!(
10938 status,
10939 StatusCode::OK,
10940 "a watermark pause must not fail the liveness check: {body}"
10941 );
10942 assert!(body.contains("polling-paused: yes"), "{body}");
10943 // Schedulers on but no tick yet — and that must not read as "0s ago",
10944 // which is the healthiest possible answer to an unanswered question.
10945 assert!(
10946 body.contains("poller: not-yet-ticked"),
10947 "a never-ticked poller must say so: {body}"
10948 );
10949
10950 // A stale heartbeat is likewise reported, not fatal.
10951 let stale_after = health_tick_stale_secs(configured_poll_tick());
10952 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
10953 state.runtime_health.poll_tick_completed(long_ago);
10954 let (status, body) = body_of(state.clone()).await;
10955 assert_eq!(
10956 status,
10957 StatusCode::OK,
10958 "a stale poller must not 503: {body}"
10959 );
10960 assert!(body.contains("poller: stale"), "{body}");
10961
10962 // **A poller that has never ticked stops being benign.**
10963 //
10964 // In a crash loop with 30 s+ boot cycles the poller never reaches its
10965 // first tick, so `not-yet-ticked` was reported forever and the heartbeat
10966 // could not detect the one failure mode the startup delays were added
10967 // for. It is read against uptime now.
10968 state.runtime_health.poll_tick_completed(0); // reset to "never"
10969 state
10970 .runtime_health
10971 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
10972 let (status, body) = body_of(state.clone()).await;
10973 assert_eq!(status, StatusCode::OK);
10974 assert!(
10975 body.contains("poller: stale never-ticked"),
10976 "a poller that never ticked long after boot still reads as benign: {body}"
10977 );
10978
10979 // A closed pool is a real outage: nothing can be served, and a restart is
10980 // the correct response. THIS is what the status code is for.
10981 state.db.close().await;
10982 let (status, body) = body_of(state.clone()).await;
10983 assert_eq!(
10984 status,
10985 StatusCode::SERVICE_UNAVAILABLE,
10986 "an unreachable database must fail the check: {body}"
10987 );
10988 assert!(body.starts_with("FAIL"), "{body}");
10989 // Coarse, not the raw sqlx error: an unauthenticated caller learning
10990 // exactly which failure it hit is an attack-progress oracle, and this
10991 // endpoint is exempt from the origin lock.
10992 assert!(
10993 !body.contains("PoolClosed") && !body.contains("sqlx"),
10994 "health leaked the raw database error to an unauthenticated caller: {body}"
10995 );
10996 }
10997
10998 /// The staleness threshold must track the configured tick.
10999 ///
11000 /// Hardcoded at 15 minutes, an operator who raised
11001 /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
11002 /// in the body the deployment docs tell them to alert on.
11003 #[test]
11004 fn the_stale_threshold_follows_the_poll_tick() {
11005 // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
11006 // alerting that early would fire on any brief hiccup.
11007 assert_eq!(
11008 health_tick_stale_secs(Duration::from_secs(60)),
11009 HEALTH_TICK_STALE_FLOOR_SECS
11010 );
11011 // A slow tick raises it, so a legitimately-configured loop is never
11012 // permanently "stale".
11013 let slow = Duration::from_secs(30 * 60);
11014 assert!(
11015 health_tick_stale_secs(slow) > slow.as_secs() as i64,
11016 "a 30-minute tick must not be stale after one interval"
11017 );
11018 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11019 // And it cannot overflow into nonsense on an absurd value.
11020 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11021 }
11022
11023 /// `/stats` must distinguish "nothing is polling" from "polling is fine".
11024 ///
11025 /// `polling_paused` alone rendered "running" for three different states,
11026 /// including the two where nothing polls at all — on the page added to
11027 /// answer exactly that question.
11028 #[tokio::test]
11029 async fn stats_does_not_call_a_stopped_poller_running() {
11030 let state = test_state(&[]).await;
11031 let render = |state: AppState| async move {
11032 let resp = router(state)
11033 .oneshot(
11034 Request::builder()
11035 .uri("/stats")
11036 .body(Body::empty())
11037 .unwrap(),
11038 )
11039 .await
11040 .unwrap();
11041 assert_eq!(resp.status(), StatusCode::OK);
11042 String::from_utf8(
11043 axum::body::to_bytes(resp.into_body(), usize::MAX)
11044 .await
11045 .unwrap()
11046 .to_vec(),
11047 )
11048 .unwrap()
11049 };
11050
11051 // Schedulers never started: not "running".
11052 let body = render(state.clone()).await;
11053 assert!(
11054 body.contains("the poller is not running on this instance"),
11055 "a disabled poller renders as healthy"
11056 );
11057
11058 // Started, but no tick has finished yet.
11059 state.runtime_health.set_schedulers_enabled(true);
11060 let body = render(state.clone()).await;
11061 assert!(
11062 body.contains("no poll has finished since this instance booted"),
11063 "a poller that has not ticked renders as healthy"
11064 );
11065
11066 // Ticking: running.
11067 state
11068 .runtime_health
11069 .poll_tick_completed(chrono::Utc::now().timestamp());
11070 let body = render(state.clone()).await;
11071 assert!(
11072 body.contains("running"),
11073 "a healthy poller must read as running"
11074 );
11075
11076 // Paused at the watermark still wins over "running".
11077 state.runtime_health.set_watermark(true);
11078 let body = render(state.clone()).await;
11079 assert!(
11080 body.contains("the cache is at its size limit"),
11081 "a watermark pause is hidden once the poller is ticking"
11082 );
11083 }
11084
11085 /// **An UNMEASURED database must not fail the check.**
11086 ///
11087 /// `/health` is the one path exempt from the Cloudflare origin lock and
11088 /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
11089 /// drop WITHOUT recording a verdict — so a cancelled request (a client
11090 /// disconnect is enough) leaves the verdict at "none", and a concurrent
11091 /// caller reads it. Treating that as a failure turned an unauthenticated
11092 /// request into a lever on the only signal the platform acts on. The
11093 /// previous version of this code had the opposite bug and reported `ok` for
11094 /// a database nothing had read; "unknown" is neither.
11095 #[tokio::test]
11096 async fn health_reports_an_unmeasured_database_without_failing() {
11097 use crate::runtime_health::DbProbe;
11098 let state = test_state(&[]).await;
11099
11100 // Hold the probe claim, exactly as an in-flight request would, and never
11101 // record a verdict — the cancelled-request state.
11102 let held = state
11103 .runtime_health
11104 .begin_db_probe()
11105 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11106
11107 let resp = router(state.clone())
11108 .oneshot(
11109 Request::builder()
11110 .uri("/health")
11111 .body(Body::empty())
11112 .unwrap(),
11113 )
11114 .await
11115 .unwrap();
11116 let status = resp.status();
11117 let body = String::from_utf8(
11118 axum::body::to_bytes(resp.into_body(), usize::MAX)
11119 .await
11120 .unwrap()
11121 .to_vec(),
11122 )
11123 .unwrap();
11124 drop(held);
11125
11126 assert_eq!(
11127 status,
11128 StatusCode::OK,
11129 "an unmeasured database failed the check, which an unauthenticated \
11130 caller can cause on demand: {body}"
11131 );
11132 assert!(
11133 body.contains("db: unknown"),
11134 "the unmeasured state must still be REPORTED: {body}"
11135 );
11136 assert!(!body.starts_with("FAIL"), "{body}");
11137 // **And it must not read as `ok` either.** `fly.toml` tells operators to
11138 // alert on the BODY for everything the status code ignores, so a first
11139 // line identical to the healthy one makes a monitor keying on `^ok` read
11140 // green in exactly the state this enum exists to surface.
11141 assert!(
11142 !body.starts_with("ok"),
11143 "the unmeasured state is indistinguishable from healthy to a \
11144 body-matching monitor: {body}"
11145 );
11146 assert!(body.starts_with("unknown"), "{body}");
11147
11148 // **A BORROWED failure must 503 too.**
11149 //
11150 // This previously recorded `Failed` and then closed the pool — but
11151 // `record` consumes the guard and releases the claim, so the request won
11152 // it, ran a live probe against the closed pool, and failed on its own.
11153 // The 503 passed for the wrong reason and the borrow path — the whole
11154 // point of the three-state enum on the read side — had no coverage.
11155 //
11156 // Holding the claim forces the borrow, so the recorded verdict is what
11157 // gets reported.
11158 let held = state
11159 .runtime_health
11160 .begin_db_probe()
11161 .unwrap_or_else(|_| panic!("claim"));
11162 state
11163 .runtime_health
11164 .record_for_test(DbProbe::Failed("unavailable".to_string()));
11165 let resp = router(state.clone())
11166 .oneshot(
11167 Request::builder()
11168 .uri("/health")
11169 .body(Body::empty())
11170 .unwrap(),
11171 )
11172 .await
11173 .unwrap();
11174 let status = resp.status();
11175 let body = String::from_utf8(
11176 axum::body::to_bytes(resp.into_body(), usize::MAX)
11177 .await
11178 .unwrap()
11179 .to_vec(),
11180 )
11181 .unwrap();
11182 drop(held);
11183 assert_eq!(
11184 status,
11185 StatusCode::SERVICE_UNAVAILABLE,
11186 "a BORROWED failure verdict must fail the check, not just a freshly \
11187 measured one: {body}"
11188 );
11189 assert!(body.starts_with("FAIL"), "{body}");
11190
11191 state.db.close().await;
11192 let resp = router(state.clone())
11193 .oneshot(
11194 Request::builder()
11195 .uri("/health")
11196 .body(Body::empty())
11197 .unwrap(),
11198 )
11199 .await
11200 .unwrap();
11201 assert_eq!(
11202 resp.status(),
11203 StatusCode::SERVICE_UNAVAILABLE,
11204 "a measured database failure must still fail the check"
11205 );
11206 }
11207
11208 /// **A disconnected client must not be able to cancel the probe.**
11209 ///
11210 /// Axum drops the handler future when a caller goes away. With the probe
11211 /// inline that dropped it mid-flight and released the claim WITHOUT
11212 /// recording a verdict — which let an unauthenticated caller manufacture the
11213 /// no-verdict state on demand and freeze what every other caller, including
11214 /// Fly's own check, reads. The probe runs detached now, so the verdict is
11215 /// recorded whatever happens to the request that started it.
11216 #[tokio::test]
11217 async fn an_abandoned_request_still_records_its_probe() {
11218 use crate::runtime_health::DbProbe;
11219 let state = test_state(&[]).await;
11220 let rh = state.runtime_health.clone();
11221
11222 // Drive /health and abandon it immediately — the disconnect case.
11223 let app = router(state.clone());
11224 let fut = app.oneshot(
11225 Request::builder()
11226 .uri("/health")
11227 .body(Body::empty())
11228 .unwrap(),
11229 );
11230 let handle = tokio::spawn(fut);
11231 handle.abort();
11232 let _ = handle.await;
11233
11234 // The detached probe still completes and publishes a verdict, so the
11235 // claim is free and the next caller gets a MEASURED answer.
11236 for _ in 0..50 {
11237 if rh.begin_db_probe().is_ok() {
11238 break;
11239 }
11240 tokio::time::sleep(Duration::from_millis(20)).await;
11241 }
11242 let resp = router(state.clone())
11243 .oneshot(
11244 Request::builder()
11245 .uri("/health")
11246 .body(Body::empty())
11247 .unwrap(),
11248 )
11249 .await
11250 .unwrap();
11251 let body = String::from_utf8(
11252 axum::body::to_bytes(resp.into_body(), usize::MAX)
11253 .await
11254 .unwrap()
11255 .to_vec(),
11256 )
11257 .unwrap();
11258 assert!(
11259 body.contains("db: ok"),
11260 "after an abandoned request the next caller still reads an \
11261 unmeasured database — the probe was cancelled with it: {body}"
11262 );
11263 // Sanity: the type still distinguishes the three states.
11264 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11265 }
11266
11267 /// **The probe must read a real page.**
11268 ///
11269 /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
11270 /// it never touches a b-tree and returns success against a corrupted
11271 /// database. Asserted by asking SQLite what the statement actually compiles
11272 /// to, so it survives someone "simplifying" the query later.
11273 #[tokio::test]
11274 async fn the_health_probe_opens_a_real_table() {
11275 use sqlx::Row;
11276 let state = test_state(&[]).await;
11277 // `EXPLAIN` lists the VM program; the `opcode` column is the second.
11278 let opcodes = |sql: &'static str| {
11279 let db = state.db.clone();
11280 async move {
11281 sqlx::query(sql)
11282 .fetch_all(&db)
11283 .await
11284 .unwrap()
11285 .into_iter()
11286 .map(|r| r.get::<String, _>("opcode"))
11287 .collect::<Vec<String>>()
11288 }
11289 };
11290
11291 // The statement `health_db_probe` really runs — it is the sole path, so
11292 // there is no second string for the handler to use instead.
11293 let explain: &'static str =
11294 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11295 let probe = opcodes(explain).await;
11296 // And the probe itself works against a real schema.
11297 assert!(
11298 health_db_probe(&state.db).await.is_ok(),
11299 "the probe does not run against the real schema",
11300 );
11301 assert!(
11302 probe.iter().any(|op| op == "OpenRead"),
11303 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11304 );
11305 // And the bare form genuinely does not, which is the whole point.
11306 let bare = opcodes("EXPLAIN SELECT 1").await;
11307 assert!(
11308 !bare.iter().any(|op| op == "OpenRead"),
11309 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11310 );
11311 }
11312
11313 /// A fresh instance says "never", not "0" — which would read as "polled
11314 /// just now", the opposite of the truth.
11315 #[test]
11316 fn an_instance_that_has_never_polled_says_so() {
11317 assert_eq!(humanise_ago(None), "never");
11318 assert_eq!(humanise_ago(Some(0)), "0s ago");
11319 assert_eq!(humanise_ago(Some(59)), "59s ago");
11320 assert_eq!(humanise_ago(Some(60)), "1m ago");
11321 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11322 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11323 }
11324
11325 /// A sidecar mock that answers `/internal/repo` listRecords with one saved
11326 /// record, and anything else with an empty list. Serves repeatedly.
11327 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11328 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11329 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11330 let addr = listener.local_addr().unwrap();
11331 let (url, title) = (saved_url.to_string(), saved_title.to_string());
11332 tokio::spawn(async move {
11333 loop {
11334 let Ok((mut sock, _)) = listener.accept().await else {
11335 break;
11336 };
11337 let mut buf = vec![0u8; 8192];
11338 let Ok(n) = sock.read(&mut buf).await else {
11339 continue;
11340 };
11341 let req = String::from_utf8_lossy(&buf[..n]).to_string();
11342 let wants_saved = req.contains("community.lexicon.rss.saved");
11343 let records = if wants_saved {
11344 serde_json::json!([{
11345 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
11346 "cid": "bafy",
11347 "value": {
11348 "$type": "community.lexicon.rss.saved",
11349 "url": url,
11350 "title": title,
11351 "createdAt": "2026-01-01T00:00:00Z"
11352 }
11353 }])
11354 } else {
11355 serde_json::json!([])
11356 };
11357 let body = serde_json::json!({
11358 "ok": true, "data": { "records": records }
11359 })
11360 .to_string();
11361 let resp = format!(
11362 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11363 body.len(), body
11364 );
11365 let _ = sock.write_all(resp.as_bytes()).await;
11366 let _ = sock.flush().await;
11367 }
11368 });
11369 format!("http://{addr}")
11370 }
11371
11372 /// A sidecar mock serving `n` distinct saved records, none of them cached
11373 /// locally — the shape that exercises the uncached-row append.
11374 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
11375 let feed = subscribed_feed.to_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 tokio::spawn(async move {
11380 loop {
11381 let Ok((mut sock, _)) = listener.accept().await else {
11382 break;
11383 };
11384 let mut buf = vec![0u8; 8192];
11385 let Ok(read) = sock.read(&mut buf).await else {
11386 continue;
11387 };
11388 let req = String::from_utf8_lossy(&buf[..read]).to_string();
11389 let records = if req.contains("community.lexicon.rss.saved") {
11390 serde_json::Value::Array(
11391 (0..n)
11392 .map(|i| {
11393 serde_json::json!({
11394 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
11395 "cid": "bafy",
11396 "value": {
11397 "$type": "community.lexicon.rss.saved",
11398 "url": format!("https://elsewhere.example/{i}"),
11399 "title": format!("Elsewhere {i}"),
11400 "createdAt": "2026-01-01T00:00:00Z"
11401 }
11402 })
11403 })
11404 .collect(),
11405 )
11406 } else if req.contains("community.lexicon.rss.subscription") {
11407 // Without this the handler's `sync_sub_refs` would REPLACE
11408 // sub_ref with an empty set on every render, and every
11409 // sub_ref-scoped read — including the cached starred list
11410 // this test is about — would come back empty.
11411 serde_json::json!([{
11412 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
11413 "cid": "bafy",
11414 "value": {
11415 "$type": "community.lexicon.rss.subscription",
11416 "url": feed,
11417 "createdAt": "2026-01-01T00:00:00Z"
11418 }
11419 }])
11420 } else {
11421 serde_json::json!([])
11422 };
11423 let body =
11424 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11425 let resp = format!(
11426 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11427 body.len(), body
11428 );
11429 let _ = sock.write_all(resp.as_bytes()).await;
11430 let _ = sock.flush().await;
11431 }
11432 });
11433 format!("http://{addr}")
11434 }
11435
11436 /// **The pager must not advertise a page the clamp cannot reach.**
11437 ///
11438 /// The page clamp is computed from the CACHED total; the uncached PDS rows
11439 /// are appended to the last page rather than paged. Inflating `total` with
11440 /// them made `page_count` and the "Older →" link point one page past the end:
11441 /// requesting it clamped straight back, re-rendered the same last page, and
11442 /// still offered the link. An infinite "next" that never advances.
11443 #[tokio::test]
11444 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
11445 let did = "did:plc:pagerloop";
11446 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
11447 let state = test_state_with_sidecar(&[], &sidecar).await;
11448 store::grant_access(&state.db, did, None, "test", None)
11449 .await
11450 .unwrap();
11451 let feed = store::upsert_feed(
11452 &state.db,
11453 &store::NewFeed {
11454 url: "https://loop.example/feed.xml".to_string(),
11455 title: Some("Loop".to_string()),
11456 ..Default::default()
11457 },
11458 )
11459 .await
11460 .unwrap();
11461 // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
11462 // and the old arithmetic reported a fourth page.
11463 let entries: Vec<store::NewEntry> = (0..250)
11464 .map(|i| store::NewEntry {
11465 guid: format!("s-{i:04}"),
11466 url: Some(format!("https://loop.example/{i}")),
11467 title: Some(format!("Starred {i:04}")),
11468 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
11469 ..Default::default()
11470 })
11471 .collect();
11472 store::insert_entries(&state.db, feed, &entries, 0)
11473 .await
11474 .unwrap();
11475 store::replace_sub_refs(&state.db, did, &[feed])
11476 .await
11477 .unwrap();
11478 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
11479 .await
11480 .unwrap()
11481 {
11482 store::mark_starred(&state.db, did, row.id, true)
11483 .await
11484 .unwrap();
11485 }
11486
11487 let cookie = session_cookie(&state, did, None);
11488 let app = router(state.clone());
11489 let get = |uri: &str| {
11490 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
11491 async move {
11492 let resp = app
11493 .oneshot(
11494 Request::builder()
11495 .uri(uri)
11496 .header(header::COOKIE, cookie)
11497 .body(Body::empty())
11498 .unwrap(),
11499 )
11500 .await
11501 .unwrap();
11502 assert_eq!(resp.status(), StatusCode::OK);
11503 String::from_utf8(
11504 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
11505 .await
11506 .unwrap()
11507 .to_vec(),
11508 )
11509 .unwrap()
11510 }
11511 };
11512
11513 // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
11514 // clamp must agree on that, and EVERY page it offers must have content —
11515 // the original bug advertised a fourth page that clamped back to the
11516 // third and re-rendered it, still offering the link.
11517 let p3 = get("/?view=starred&page=3").await;
11518 assert!(
11519 p3.contains("Page 3 of 4"),
11520 "the pager and the clamp disagree on the total: {}",
11521 p3.split("pager-pos")
11522 .nth(1)
11523 .unwrap_or("")
11524 .chars()
11525 .take(120)
11526 .collect::<String>()
11527 );
11528 // Page 3 is the boundary: the last 50 cached rows, then the first 50
11529 // uncached ones.
11530 assert!(
11531 p3.contains("Elsewhere 0"),
11532 "page 3 should start the uncached run"
11533 );
11534 assert_eq!(
11535 p3.matches("<li class=\"entry").count(),
11536 ENTRIES_PER_PAGE as usize,
11537 "the boundary page is not full"
11538 );
11539
11540 // **The heading, which the previous round broke by deleting this.**
11541 //
11542 // `total` includes the uncached records, so the parenthetical is a
11543 // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
11544 // The version that said "plus N" double counted once `total` started
11545 // including them, and N had become page-local in the same commit while
11546 // the template stayed put. It shipped because this assertion was deleted
11547 // rather than updated.
11548 {
11549 let body = &p3;
11550 assert!(
11551 body.contains("330 entries"),
11552 "the heading must count the whole sequence: {}",
11553 body.split("content-count")
11554 .nth(1)
11555 .unwrap_or("")
11556 .chars()
11557 .take(120)
11558 .collect::<String>()
11559 );
11560 assert!(
11561 body.contains("(80 saved elsewhere)"),
11562 "the heading must say how many of the total the cache cannot show, \
11563 as a whole-list figure and not a per-page one: {}",
11564 body.split("content-count")
11565 .nth(1)
11566 .unwrap_or("")
11567 .chars()
11568 .take(120)
11569 .collect::<String>()
11570 );
11571 assert!(
11572 !body.contains("plus 50") && !body.contains("plus 80"),
11573 "the heading is adding the uncached rows to a total that already \
11574 includes them"
11575 );
11576 }
11577
11578 let p4 = get("/?view=starred&page=4").await;
11579 assert!(
11580 p4.contains("Page 4 of 4"),
11581 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
11582 );
11583 assert_eq!(
11584 p4.matches("<li class=\"entry").count(),
11585 30,
11586 "page 4 should hold the remaining 30 uncached records"
11587 );
11588 assert!(
11589 p4.contains("Elsewhere 79"),
11590 "the LAST saved record is unreachable — it can only be removed from here"
11591 );
11592
11593 // No uncached record appears on two pages.
11594 assert!(
11595 !p4.contains("Elsewhere 0"),
11596 "an uncached record was rendered on more than one page"
11597 );
11598 // Page 1 is all cached — and still reports the same whole-list heading,
11599 // because the parenthetical describes the LIST, not the page.
11600 let first = get("/?view=starred").await;
11601 assert!(
11602 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
11603 "the heading changed between pages; it describes the list, not the page"
11604 );
11605 assert!(
11606 !first.contains("Elsewhere "),
11607 "uncached saved records leaked onto the first page"
11608 );
11609 }
11610
11611 /// **A saved record whose article is not cached here is still shown.**
11612 ///
11613 /// The starred view is built from local `entries`, so before this a record
11614 /// starred in ANOTHER atproto reader — the portability the shared lexicon
11615 /// exists for — was simply invisible. It now renders from the PDS record,
11616 /// visually distinct, linking straight out.
11617 #[tokio::test]
11618 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
11619 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
11620 let sidecar =
11621 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
11622 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
11623 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
11624
11625 let resp = router(state)
11626 .oneshot(
11627 Request::builder()
11628 .uri("/?view=starred")
11629 .body(Body::empty())
11630 .unwrap(),
11631 )
11632 .await
11633 .unwrap();
11634 assert_eq!(resp.status(), StatusCode::OK);
11635 let body = String::from_utf8(
11636 axum::body::to_bytes(resp.into_body(), usize::MAX)
11637 .await
11638 .unwrap()
11639 .to_vec(),
11640 )
11641 .unwrap();
11642
11643 assert!(
11644 body.contains("Starred elsewhere"),
11645 "the saved record was not rendered at all"
11646 );
11647 assert!(
11648 body.contains("entry-uncached"),
11649 "it was not marked as uncached, so it looks like a normal entry"
11650 );
11651 assert!(
11652 body.contains("https://elsewhere.example/article"),
11653 "the row must link straight to the article"
11654 );
11655 assert!(
11656 !body.contains("/entries/0/"),
11657 "an uncached row must not offer entry actions against a nonexistent id"
11658 );
11659 }
11660
11661 /// **A PDS `createdAt` must not be able to panic the starred view.**
11662 ///
11663 /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
11664 /// timestamp the feed parser produced; the saved-record path passes a bare
11665 /// string off a PDS record, written by whatever client the reader used. A
11666 /// multi-byte value panicked the handler, and with no catch-panic layer the
11667 /// view stayed down until the record was removed — from that same view.
11668 #[test]
11669 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
11670 for hostile in [
11671 "日本語日本語日本",
11672 "é",
11673 "",
11674 "2026",
11675 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
11676 ] {
11677 let out = display_date(Some(hostile));
11678 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
11679 }
11680 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
11681 assert_eq!(display_date(None), "");
11682 }
11683
11684 /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
11685 /// its neighbours are limited. It was added as a route and not added here.
11686 #[test]
11687 fn the_unsave_route_is_rate_limited() {
11688 use axum::http::Method;
11689 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
11690 // And the neighbours still are.
11691 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
11692 }
11693
11694 /// **The probe detects a broken database — asserted through `/health`
11695 /// itself, not through a string.**
11696 ///
11697 /// A named constant did not bind the handler: it stayed free to call
11698 /// `query_scalar` with a different literal, so degrading the real probe to
11699 /// `SELECT 1` shipped green twice over. This drops the table the probe reads
11700 /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
11701 /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
11702 #[tokio::test]
11703 async fn health_reports_a_broken_database() {
11704 let state = test_state(&[]).await;
11705 // Sanity: healthy first, so the assertion below is about the damage.
11706 assert!(
11707 health_db_probe(&state.db).await.is_ok(),
11708 "the fixture was not healthy to begin with",
11709 );
11710
11711 sqlx::query("DROP TABLE feeds")
11712 .execute(&state.db)
11713 .await
11714 .unwrap();
11715
11716 assert!(
11717 health_db_probe(&state.db).await.is_err(),
11718 "the probe reported success against a database missing the table it \
11719 claims to read; `SELECT 1` would do exactly this",
11720 );
11721
11722 let resp = router(state)
11723 .oneshot(
11724 Request::builder()
11725 .uri("/health")
11726 .body(Body::empty())
11727 .unwrap(),
11728 )
11729 .await
11730 .unwrap();
11731 let body = String::from_utf8(
11732 axum::body::to_bytes(resp.into_body(), usize::MAX)
11733 .await
11734 .unwrap()
11735 .to_vec(),
11736 )
11737 .unwrap();
11738 // The documented contract: the FIRST token is the state.
11739 assert!(
11740 body.starts_with("FAIL"),
11741 "/health did not report FAIL for a broken database: {body}",
11742 );
11743 assert!(
11744 !body.contains("db: ok"),
11745 "/health still called the database ok: {body}",
11746 );
11747 }
11748}