Skip to main content

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//!
35//! ## Identity — a cookie-resolved atproto session
36//!
37//! Per-request identity comes from a **signed session cookie** (`fr_session`)
38//! keyed by the logged-in DID, set by [`oauth_callback`] and read by
39//! [`current_session`] / [`current_did`]. For local runs without the sidecar,
40//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
41//! All PDS writes route through the [`crate::atproto::SidecarClient`]; a live-PDS
42//! write needs a real OAuth session, but the full write path is built and unit-
43//! tested to the sidecar boundary.
44
45use std::collections::HashMap;
46use std::net::IpAddr;
47use std::sync::Mutex;
48use std::time::{Duration, Instant};
49
50use askama::Template;
51use axum::{
52    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
53    http::{header, HeaderMap, StatusCode},
54    middleware::{self, Next},
55    response::{Html, IntoResponse, Redirect, Response},
56    routing::{get, post},
57    Form, Router,
58};
59use serde::Deserialize;
60use std::net::SocketAddr;
61use tower_http::services::ServeDir;
62use tower_http::set_header::SetResponseHeaderLayer;
63use tower_http::trace::TraceLayer;
64use tracing::{info, warn};
65
66use crate::config::Config;
67use crate::lexicon::{self, Folder, Saved, Subscription};
68use crate::{feed, store, AppState, Session, VERSION};
69
70// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
71// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
72// here via an explicit path so the reader's OPML routes can use the canonical
73// `parse_opml` / `to_opml` without duplicating that logic.
74#[path = "opml.rs"]
75mod opml;
76
77/// The name of the signed session cookie.
78const SESSION_COOKIE: &str = "fr_session";
79
80/// The name of the short-lived signed **invite** cookie.
81///
82/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
83/// OAuth callback. It reserves *intent* to redeem a specific code before the
84/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
85/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
86/// signed with the same key as the session cookie.
87const INVITE_COOKIE: &str = "fr_invite";
88
89/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
90/// Short enough that a reserved-but-unclaimed seat frees quickly.
91const INVITE_TTL_SECS: i64 = 1800;
92
93/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
94/// sign-in pitch, and `/about`.
95const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
96
97/// The tip / support link (cloud plan public-experiment UI).
98const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
99
100/// The published crate on crates.io — surfaced on the signed-out landing page.
101const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
102
103/// The Content-Security-Policy applied to every response.
104///
105/// Tuned to keep the app fully working while neutralising injected script:
106/// * `default-src 'self'` — same-origin baseline.
107/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
108///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
109///   `javascript:` href (F4) cannot execute. (The design's templates carry no
110///   inline event handlers — every control is wired in `keyboard.js`.)
111/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
112///   inline styles htmx toggles for its request indicators.
113/// * `img-src 'self' https: data:` — feed content routinely embeds remote
114///   images; allow https + data URIs but not other schemes.
115/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
116///   down form posts, `<base>` hijacking, and clickjacking.
117/// * `object-src 'none'` — no plugins.
118const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
119     script-src 'self'; \
120     style-src 'self' 'unsafe-inline'; \
121     img-src 'self' https: data:; \
122     font-src 'self'; \
123     connect-src 'self'; \
124     form-action 'self'; \
125     base-uri 'self'; \
126     frame-ancestors 'none'; \
127     object-src 'none'";
128
129/// The resolved identity for the current request.
130///
131/// `did` is the primary key for all per-user local state; `handle` is display
132/// only; `sid` is the opaque server-side session id the cookie carried (needed
133/// so logout can revoke exactly this session). Sourced from the signed cookie
134/// (real login) or, if none, the configured dev DID fallback.
135#[derive(Clone, Debug)]
136struct CurrentUser {
137    did: String,
138    handle: Option<String>,
139    /// The opaque session id, if this identity came from a real cookie session
140    /// (absent for the dev-DID fallback, which has no server-side session row).
141    sid: Option<String>,
142}
143
144/// Resolve the current request's session from the signed cookie, falling back to
145/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
146///
147/// The cookie carries an opaque server-minted session id (not the DID). We
148/// verify its HMAC, look the id up in the registry, and — crucially —
149/// **re-check the DID against the closed-beta gate on every request**
150/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
151/// DID's beta seat takes effect immediately for already-issued cookies. (The
152/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
153/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
154async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
155    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
156        if let Some(session) = state.sessions.get(&sid) {
157            if store::has_beta_access(&state.db, &session.did)
158                .await
159                .unwrap_or(false)
160            {
161                return Some(CurrentUser {
162                    did: session.did,
163                    handle: session.handle,
164                    sid: Some(sid),
165                });
166            }
167            // DID no longer holds a beta seat: treat as logged out (and drop the
168            // stale server-side session so the dead cookie can't linger).
169            state.sessions.remove(&sid);
170        }
171    }
172    // No valid cookie: dev fallback only if explicitly configured *and* still
173    // inside the beta gate (seeded via ensure_seed / a redeemed code).
174    if let Some(did) = state.config.dev_did.clone() {
175        if store::has_beta_access(&state.db, &did)
176            .await
177            .unwrap_or(false)
178        {
179            return Some(CurrentUser {
180                did,
181                handle: None,
182                sid: None,
183            });
184        }
185    }
186    None
187}
188
189/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
190async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
191    current_session(state, headers).await.map(|u| u.did)
192}
193
194/// Build the application router over shared [`AppState`].
195///
196/// Wires the reader routes, the health check, and the `/static` asset mount
197/// (the stylesheet, vendored htmx, and the keyboard handler, served from
198/// `static/` via [`ServeDir`]). A [`TraceLayer`] gives per-request tracing.
199pub fn router(state: AppState) -> Router {
200    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
201    // and the write endpoints). One instance is cloned into the state closure of
202    // the `rate_limit` middleware.
203    let limiter = RateLimiter::shared();
204    // The trusted client-IP source for the limiter (a proxy header the operator
205    // controls, or the socket peer when unset). Bundled with the limiter so the
206    // middleware derives a spoof-resistant IP.
207    let rl_state = RateLimitState {
208        limiter,
209        trusted_header: state.config.trusted_ip_header.clone(),
210    };
211
212    Router::new()
213        .route("/health", get(health))
214        .route("/about", get(about))
215        .route("/manage", get(manage))
216        .route("/", get(index))
217        .route("/entries/{id}", get(entry_view))
218        .route("/entries/{id}/read", post(mark_read))
219        .route("/entries/{id}/star", post(toggle_star))
220        .route("/read-all", post(mark_all_read))
221        .route("/subscriptions", post(add_subscription))
222        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
223        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
224        .route("/folders", post(create_folder))
225        .route("/folders/{rkey}/rename", post(rename_folder))
226        .route("/folders/{rkey}/delete", post(delete_folder))
227        // OPML import takes untrusted uploads: cap the body so a huge upload
228        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
229        .route(
230            "/opml",
231            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
232        )
233        .route("/opml/export", get(export_opml))
234        .route("/login", get(login_form).post(login_submit))
235        .route(
236            "/beta/redeem",
237            get(beta_redeem_form).post(beta_redeem_submit),
238        )
239        .route("/admin/invites", post(admin_mint_invites))
240        .route("/account/delete", post(account_delete))
241        .route("/oauth/callback", get(oauth_callback))
242        .route("/logout", post(logout))
243        .nest_service("/static", ServeDir::new("static"))
244        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
245        // logged-out landing + static assets, `no-store` on anything that
246        // rendered a session's private view. Runs *inside* the security layers so
247        // the CSP/nosniff/frame headers are untouched.
248        .layer(middleware::from_fn(cache_control))
249        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
250        // as a middleware so it sees the matched path + the peer IP.
251        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
252        .layer(TraceLayer::new_for_http())
253        // Baseline security headers on *every* response (F4). The CSP is the
254        // backstop that neutralises any XSS that slips past sanitization; the
255        // others harden sniffing, framing, and referrer leakage.
256        .layer(static_header_layer(
257            "content-security-policy",
258            CONTENT_SECURITY_POLICY,
259        ))
260        .layer(static_header_layer("x-content-type-options", "nosniff"))
261        .layer(static_header_layer(
262            "referrer-policy",
263            "strict-origin-when-cross-origin",
264        ))
265        .layer(static_header_layer("x-frame-options", "DENY"))
266        .with_state(state)
267}
268
269/// Body-size ceiling for the OPML import upload (~2 MiB). Large enough for any
270/// realistic subscription list, small enough to make an OOM upload impossible.
271const OPML_BODY_LIMIT: usize = 2 * 1024 * 1024;
272
273/// A response-header layer that sets `name: value` on every response, overriding
274/// any existing header of that name. `name`/`value` must be valid static header
275/// tokens (they are, for our fixed security headers).
276fn static_header_layer(
277    name: &'static str,
278    value: &'static str,
279) -> SetResponseHeaderLayer<header::HeaderValue> {
280    SetResponseHeaderLayer::overriding(
281        header::HeaderName::from_static(name),
282        header::HeaderValue::from_static(value),
283    )
284}
285
286// ---------------------------------------------------------------------------
287// Per-IP rate limiting (token bucket, self-contained — no extra crate)
288// ---------------------------------------------------------------------------
289
290/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
291/// kick-off, the invite redeem, the mutating write endpoints, and mark-read/star
292/// /mark-all. Read-only navigation is intentionally *not* limited.
293fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
294    use axum::http::Method;
295    if method != Method::POST && !(method == Method::GET && path == "/login") {
296        return false;
297    }
298    match path {
299        "/login" | "/beta/redeem" | "/subscriptions" | "/opml" | "/read-all" | "/admin/invites"
300        | "/account/delete" | "/folders" => true,
301        // Every per-record subscription/folder mutation (delete/rename) and the
302        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
303        p => {
304            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
305                || p.starts_with("/subscriptions/")
306                || p.starts_with("/folders/")
307        }
308    }
309}
310
311/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
312/// client-IP header (if any). Cloned into every request; both fields are cheap.
313#[derive(Clone)]
314struct RateLimitState {
315    limiter: RateLimiter,
316    /// The lowercased proxy header the operator trusts for the client IP, or
317    /// `None` to trust only the socket peer. See [`client_ip`].
318    trusted_header: Option<String>,
319}
320
321/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
322/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
323/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
324/// dependency → no network fetch at build, deterministic offline CI).
325#[derive(Clone)]
326struct RateLimiter {
327    inner: std::sync::Arc<Mutex<HashMap<IpAddr, Bucket>>>,
328}
329
330/// One IP's token bucket: a fractional token count + the last-refill instant.
331struct Bucket {
332    tokens: f64,
333    last: Instant,
334}
335
336/// Burst capacity per IP — how many requests can arrive back-to-back.
337const RATE_BURST: f64 = 20.0;
338/// Steady-state refill rate (tokens/sec) once the burst is spent.
339const RATE_REFILL_PER_SEC: f64 = 1.0;
340/// Evict idle buckets older than this so the map can't grow unbounded.
341const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
342
343impl RateLimiter {
344    /// A fresh, shared limiter (cloned into the middleware state).
345    fn shared() -> Self {
346        Self {
347            inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
348        }
349    }
350
351    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
352    /// bucket is empty (→ 429).
353    fn check(&self, ip: IpAddr) -> bool {
354        let now = Instant::now();
355        let mut map = match self.inner.lock() {
356            Ok(m) => m,
357            // A poisoned lock shouldn't take the site down — fail open.
358            Err(p) => p.into_inner(),
359        };
360        // Opportunistic eviction of long-idle buckets (cheap, amortised).
361        map.retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
362
363        let bucket = map.entry(ip).or_insert(Bucket {
364            tokens: RATE_BURST,
365            last: now,
366        });
367        let elapsed = now.duration_since(bucket.last).as_secs_f64();
368        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
369        bucket.last = now;
370        if bucket.tokens >= 1.0 {
371            bucket.tokens -= 1.0;
372            true
373        } else {
374            false
375        }
376    }
377}
378
379/// The **trusted** client IP for a request.
380///
381/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
382/// is fully bypassable — the left-most value is attacker-supplied (any client
383/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
384/// bucket and the per-IP limit never bites. We therefore derive the IP only from
385/// a source the operator controls:
386///
387/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
388///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
389///   set by the proxy we run in front and overwrites any client-supplied copy.
390///   We take the LAST value if the header happens to be a comma list (the hop
391///   the trusted proxy appended), which is also the correct read for a
392///   right-most-`X-Forwarded-For` deployment where the operator points
393///   `trusted_header` at `x-forwarded-for`.
394/// * Otherwise we ignore all forwarding headers and use the socket peer
395///   (`ConnectInfo`) — correct for a direct bind with no proxy.
396///
397/// Returns `None` only when neither source yields a parseable IP (the limiter
398/// then fails open for that one request).
399fn client_ip(
400    headers: &HeaderMap,
401    conn: Option<&SocketAddr>,
402    trusted_header: Option<&str>,
403) -> Option<IpAddr> {
404    if let Some(name) = trusted_header {
405        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
406            // Right-most hop is the one the trusted proxy appended; earlier
407            // entries may be client-forged, so never trust the left-most.
408            if let Some(last) = raw.split(',').next_back() {
409                if let Ok(ip) = last.trim().parse::<IpAddr>() {
410                    return Some(ip);
411                }
412            }
413        }
414        // Trusted header absent/unparseable → fall through to the socket peer.
415    }
416    conn.map(|s| s.ip())
417}
418
419/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
420/// empty; every other request (and every non-guarded path) passes through. The
421/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
422/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
423async fn rate_limit(
424    State(rl): State<RateLimitState>,
425    req: axum::extract::Request,
426    next: Next,
427) -> Response {
428    let path = req.uri().path().to_string();
429    let method = req.method().clone();
430    if is_rate_limited_path(&path, &method) {
431        let conn = req
432            .extensions()
433            .get::<ConnectInfo<SocketAddr>>()
434            .map(|c| c.0);
435        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
436        // Deliberately fail OPEN when no client IP is derivable (no trusted
437        // header / no socket peer): there is no per-IP key to enforce, and a
438        // blanket 429 would self-DoS every guarded path (incl. /login). This is
439        // safe precisely because we never key on an attacker-forged XFF — see
440        // `rate_limit_ignores_spoofed_xff_rotation`.
441        if let Some(ip) = ip {
442            if !rl.limiter.check(ip) {
443                warn!(%ip, %path, "rate limit exceeded");
444                return (
445                    StatusCode::TOO_MANY_REQUESTS,
446                    [(header::RETRY_AFTER, "1")],
447                    "rate limit exceeded\n",
448                )
449                    .into_response();
450            }
451        }
452    }
453    next.run(req).await
454}
455
456// ---------------------------------------------------------------------------
457// Cache-Control (viral / CDN vs. private authenticated views)
458// ---------------------------------------------------------------------------
459
460/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
461/// logged-out surfaces (the `/login` landing without a handle, `/about`, and the
462/// `/static/*` assets) and `no-store` on the authenticated app pages, so a CDN /
463/// browser can hold the viral landing while never caching a signed-in user's
464/// private view. Never overrides a handler that already set Cache-Control.
465async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
466    let path = req.uri().path().to_string();
467    // The logged-out landing is only cacheable when it's the bare form — a
468    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
469    let is_login_landing = path == "/login"
470        && req.method() == axum::http::Method::GET
471        && !req.uri().query().unwrap_or("").contains("handle=");
472    let public = is_login_landing || path == "/about" || path.starts_with("/static/");
473
474    let mut resp = next.run(req).await;
475    if resp.headers().contains_key(header::CACHE_CONTROL) {
476        return resp;
477    }
478    let value = if public {
479        "public, max-age=300"
480    } else {
481        "no-store"
482    };
483    if let Ok(hv) = header::HeaderValue::from_str(value) {
484        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
485    }
486    resp
487}
488
489// ---------------------------------------------------------------------------
490// Health
491// ---------------------------------------------------------------------------
492
493/// `GET /health` — a cheap liveness probe returning `200 ok` + the crate version.
494async fn health() -> impl IntoResponse {
495    (StatusCode::OK, format!("ok featherreader/{VERSION}\n"))
496}
497
498/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
499/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link. A
500/// static render; readable whether or not a session exists.
501async fn about() -> Response {
502    render(&AboutTemplate {
503        version: VERSION,
504        repo_url: REPO_URL,
505        kofi_url: KOFI_URL,
506    })
507}
508
509// ---------------------------------------------------------------------------
510// View models
511// ---------------------------------------------------------------------------
512
513/// A feed as shown in the sidebar (title + its unread count + a stable scope key
514/// and the PDS subscription rkey for management actions).
515struct FeedView {
516    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
517    rkey: String,
518    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
519    url: String,
520    title: String,
521    unread: i64,
522    /// Whether this feed is the currently-selected scope.
523    selected: bool,
524    /// The feed's current folder `at://` URI (from its subscription record), or
525    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
526    /// rename row so an untouched folder dropdown does not silently un-folder the
527    /// feed on save.
528    folder: Option<String>,
529}
530
531/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
532struct FolderView {
533    /// PDS folder rkey — addresses the record for rename/delete.
534    rkey: String,
535    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
536    uri: String,
537    name: String,
538    feeds: Vec<FeedView>,
539    /// Whether this folder is the currently-selected scope.
540    selected: bool,
541}
542
543/// One entry as shown in the article list / after an htmx swap.
544struct EntryRow {
545    id: i64,
546    title: String,
547    feed_title: String,
548    published: String,
549    read: bool,
550    starred: bool,
551    /// The reader link href, already carrying the scope/view query so opening an
552    /// entry and paging back stays within the list it came from.
553    link: String,
554}
555
556/// A folder as an option in the "move feed to folder" select.
557struct FolderOption {
558    uri: String,
559    name: String,
560}
561
562/// The shared navigation "rail" model: the same DOM element is the
563/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
564/// manage) renders it from this one struct. Feed management lives on `/manage`,
565/// not here — the rail is navigation only.
566struct Nav {
567    /// `@handle` for the identity chip (falls back to the DID's tail).
568    handle: String,
569    /// Two-letter avatar initials for the identity chip.
570    avatar: String,
571    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
572    view: String,
573    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
574    /// empty for the unscoped "everything" views.
575    scope_qs: String,
576    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
577    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
578    folders: Vec<FolderView>,
579    loose_feeds: Vec<FeedView>,
580    /// Whether the "Manage feeds" rail tool is the current page.
581    manage_active: bool,
582}
583
584/// The reader index (`GET /`).
585#[derive(Template)]
586#[template(path = "index.html")]
587struct IndexTemplate {
588    version: &'static str,
589    repo_url: &'static str,
590    kofi_url: &'static str,
591    flash: String,
592    /// The shared rail (drawer + desktop sidebar) navigation model.
593    nav: Nav,
594    /// The article list for the selected scope + view.
595    entries: Vec<EntryRow>,
596    /// The list heading (the selected view/feed/folder name).
597    heading: String,
598    /// Whether a feed scope is active (enables per-feed mark-all-read).
599    feed_scope: Option<String>,
600}
601
602/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
603#[derive(Template)]
604#[template(path = "manage.html")]
605struct ManageTemplate {
606    version: &'static str,
607    repo_url: &'static str,
608    kofi_url: &'static str,
609    flash: String,
610    nav: Nav,
611    /// All folders as move-targets for the subscribe folder select.
612    folder_options: Vec<FolderOption>,
613    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
614    folders: Vec<FolderView>,
615    loose_feeds: Vec<FeedView>,
616}
617
618/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link.
619#[derive(Template)]
620#[template(path = "about.html")]
621struct AboutTemplate {
622    version: &'static str,
623    repo_url: &'static str,
624    kofi_url: &'static str,
625}
626
627/// The signed-out landing page (`GET /` with no session) — the public front
628/// door at feather-reader.com. A static render, no session required.
629#[derive(Template)]
630#[template(path = "landing.html")]
631struct LandingTemplate {
632    version: &'static str,
633    repo_url: &'static str,
634    crates_url: &'static str,
635    kofi_url: &'static str,
636}
637
638/// The single-entry reader view (`GET /entries/:id`).
639#[derive(Template)]
640#[template(path = "entry.html")]
641struct EntryTemplate {
642    version: &'static str,
643    repo_url: &'static str,
644    kofi_url: &'static str,
645    nav: Nav,
646    id: i64,
647    title: String,
648    feed_title: String,
649    author: Option<String>,
650    published: String,
651    url: Option<String>,
652    content_html: Option<String>,
653    read: bool,
654    starred: bool,
655    /// The query string to carry the reading context back to the list.
656    back_qs: String,
657    /// Prev/next entry ids within the current list, for keyboard/paging nav.
658    prev_id: Option<i64>,
659    next_id: Option<i64>,
660    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
661    oob: bool,
662}
663
664/// The htmx swap fragment for a single entry row (`entry_row.html`).
665#[derive(Template)]
666#[template(path = "entry_row.html")]
667struct EntryRowTemplate {
668    e: EntryRow,
669}
670
671/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
672/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
673/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
674/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
675#[derive(Template)]
676#[template(path = "entry_actionbar.html")]
677struct EntryActionBarTemplate {
678    id: i64,
679    read: bool,
680    starred: bool,
681    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
682    oob: bool,
683}
684
685/// The login stub (`GET /login`).
686#[derive(Template)]
687#[template(path = "login.html")]
688struct LoginTemplate {
689    repo_url: &'static str,
690    error: String,
691    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
692    /// distinct from `error`. Empty renders nothing.
693    flash: String,
694}
695
696/// The closed-beta invite-redeem page (`GET /beta/redeem`).
697#[derive(Template)]
698#[template(path = "beta_redeem.html")]
699struct BetaRedeemTemplate {
700    repo_url: &'static str,
701    error: String,
702    /// When true the seat cap is full: hide the form and show the "capacity
703    /// full — try self-hosting" message instead.
704    capacity_full: bool,
705}
706
707// ---------------------------------------------------------------------------
708// Rendering + error helpers
709// ---------------------------------------------------------------------------
710
711/// Render an askama template into an HTML response, mapping a render failure to
712/// a `500` rather than panicking (no `unwrap` in the request path).
713fn render<T: Template>(tmpl: &T) -> Response {
714    match tmpl.render() {
715        Ok(body) => Html(body).into_response(),
716        Err(err) => {
717            warn!(%err, "template render failed");
718            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
719        }
720    }
721}
722
723/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
724/// still return an `impl IntoResponse`. Renders as a `500` with a short message
725/// by default; a handler may override the status (e.g. `413` for an over-cap
726/// upload) via [`WebError::with_status`].
727struct WebError {
728    err: anyhow::Error,
729    status: StatusCode,
730}
731
732impl<E: Into<anyhow::Error>> From<E> for WebError {
733    fn from(err: E) -> Self {
734        WebError {
735            err: err.into(),
736            status: StatusCode::INTERNAL_SERVER_ERROR,
737        }
738    }
739}
740
741impl WebError {
742    /// Attach an explicit HTTP status to render instead of the default `500`.
743    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
744        WebError {
745            err: err.into(),
746            status,
747        }
748    }
749}
750
751impl IntoResponse for WebError {
752    fn into_response(self) -> Response {
753        warn!(error = %self.err, status = %self.status, "request failed");
754        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
755            "internal error"
756        } else {
757            self.status.canonical_reason().unwrap_or("error")
758        };
759        (self.status, body).into_response()
760    }
761}
762
763/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
764/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
765/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
766/// `400`. Either way this avoids collapsing the failure into a generic `500`.
767fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
768    let status = err.status();
769    WebError::with_status(err, status)
770}
771
772/// A short, human display of a feed/site title for the sidebar/list, falling
773/// back to the host of a URL and finally to the raw string.
774fn display_title(title: Option<&str>, url: &str) -> String {
775    if let Some(t) = title {
776        let t = t.trim();
777        if !t.is_empty() {
778            return t.to_string();
779        }
780    }
781    url::Url::parse(url)
782        .ok()
783        .and_then(|u| u.host_str().map(str::to_string))
784        .unwrap_or_else(|| url.to_string())
785}
786
787/// A display `@handle` for the identity chip: the stored handle if present,
788/// else the tail of the DID so the chip is never empty.
789fn display_handle(handle: Option<&str>, did: &str) -> String {
790    match handle {
791        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
792        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
793    }
794}
795
796/// Two-letter, lowercase avatar initials from a handle/DID.
797fn avatar_initials(handle: Option<&str>, did: &str) -> String {
798    let source = handle
799        .map(|h| h.trim().trim_start_matches('@'))
800        .filter(|h| !h.is_empty())
801        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
802    let letters: String = source
803        .chars()
804        .filter(|c| c.is_alphanumeric())
805        .take(2)
806        .collect::<String>()
807        .to_lowercase();
808    if letters.is_empty() {
809        "fr".to_string()
810    } else {
811        letters
812    }
813}
814
815/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
816/// low-noise display. Falls back to the raw string if it doesn't look like one.
817fn display_date(published: Option<&str>) -> String {
818    match published {
819        Some(p) if p.len() >= 10 => p[..10].to_string(),
820        Some(p) => p.to_string(),
821        None => String::new(),
822    }
823}
824
825/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
826/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
827/// a bare value, and this keeps the scope-preserving links honest.
828fn qenc(s: &str) -> String {
829    let mut out = String::with_capacity(s.len() * 3);
830    for b in s.bytes() {
831        match b {
832            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
833                out.push(b as char)
834            }
835            _ => out.push_str(&format!("%{b:02X}")),
836        }
837    }
838    out
839}
840
841// ---------------------------------------------------------------------------
842// Reader: index
843// ---------------------------------------------------------------------------
844
845/// Query for `GET /` — the scope + view selector.
846#[derive(Debug, Deserialize, Default)]
847struct IndexQuery {
848    /// Filter to a single feed by its canonical URL.
849    #[serde(default)]
850    feed: Option<String>,
851    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
852    #[serde(default)]
853    folder: Option<String>,
854    /// `unread` (default) | `all` | `starred`.
855    #[serde(default)]
856    view: Option<String>,
857    /// Optional flash message (e.g. after an action redirect).
858    #[serde(default)]
859    flash: Option<String>,
860}
861
862/// A subscription resolved against the local cache: the PDS record + its
863/// (possibly-missing) cached feed row.
864struct ResolvedSub {
865    rkey: String,
866    sub: Subscription,
867    feed: Option<store::Feed>,
868}
869
870/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
871/// local cache row so unread counts work, and return them resolved. Best-effort
872/// on the sidecar: a failure falls back to the local cache alone.
873async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
874    let pool = &state.db;
875    let subs = match state.sidecar.list_subscriptions_sorted(did).await {
876        Ok(s) => s,
877        Err(err) => {
878            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
879            // Fail CLOSED: the PDS is the source of truth for what this DID
880            // follows. When it is unreachable we must NOT widen the caller's
881            // authorization surface. Serve from the DID's OWN last-known
882            // `sub_ref` projection (its own feeds, possibly stale) and leave
883            // `sub_ref` untouched — never synthesize from every cached feed,
884            // which would grant cross-tenant read+mutate during any outage.
885            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_default();
886            return feeds
887                .into_iter()
888                .map(|f| ResolvedSub {
889                    rkey: String::new(),
890                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
891                    feed: Some(f),
892                })
893                .collect();
894        }
895    };
896
897    let mut out = Vec::with_capacity(subs.len());
898    for (rkey, sub) in subs {
899        let feed = match store::get_feed_by_url(pool, &sub.url).await {
900            Ok(Some(f)) => Some(f),
901            Ok(None) => {
902                // Upsert a cache row so the sidebar reflects the real follow-list.
903                let _ = store::upsert_feed(
904                    pool,
905                    &store::NewFeed {
906                        url: sub.url.clone(),
907                        title: sub.title.clone(),
908                        site_url: sub.site_url.clone(),
909                        ..Default::default()
910                    },
911                )
912                .await;
913                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
914            }
915            Err(err) => {
916                warn!(%err, url = %sub.url, "get_feed_by_url failed");
917                None
918            }
919        };
920        out.push(ResolvedSub { rkey, sub, feed });
921    }
922    // Mirror the caller's resolved subscription set into `sub_ref`, so every
923    // scoped entry/feed read + read/star mutation authorizes against exactly
924    // the feeds this DID follows right now. This is THE per-DID isolation hook.
925    sync_sub_refs(pool, did, &out).await;
926    out
927}
928
929/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
930/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
931/// fail closed / show fewer rows), never leaks another user's entries.
932async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
933    let feed_ids: Vec<i64> = subs
934        .iter()
935        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
936        .collect();
937    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
938        warn!(%err, %did, "failed to sync sub_ref projection");
939    }
940}
941
942/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
943/// records layer) and the article list for the selected scope + view.
944async fn index(
945    State(state): State<AppState>,
946    headers: HeaderMap,
947    Query(q): Query<IndexQuery>,
948) -> Result<Response, WebError> {
949    let user = match current_session(&state, &headers).await {
950        Some(u) => u,
951        // Signed out: serve the public landing page rather than bouncing to
952        // /login. /login remains the entry point for the actual OAuth sign-in.
953        None => {
954            return Ok(render(&LandingTemplate {
955                version: VERSION,
956                repo_url: REPO_URL,
957                crates_url: CRATES_URL,
958                kofi_url: KOFI_URL,
959            }))
960        }
961    };
962    let did = user.did.clone();
963    let pool = &state.db;
964
965    let subs = resolve_subscriptions(&state, &did).await;
966
967    // Per-DID working sets, once.
968    let unread = store::get_unread_for_did(pool, &did).await?;
969    let starred = store::get_starred_for_did(pool, &did).await?;
970    let starred_ids: std::collections::HashSet<i64> = starred.iter().map(|e| e.id).collect();
971
972    // View: unread (default) | all | starred.
973    let view = match q.view.as_deref() {
974        Some("all") => "all",
975        Some("starred") => "starred",
976        _ => "unread",
977    }
978    .to_string();
979
980    // Which feed URLs are in scope?
981    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
982
983    // Resolve feed_id → url once for row rendering + scope filtering.
984    let feed_url_by_id = |id: i64| -> Option<String> {
985        subs.iter()
986            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
987            .map(|s| s.sub.url.clone())
988    };
989    let feed_title_by_id = |id: i64| -> String {
990        subs.iter()
991            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
992            .map(|s| {
993                display_title(
994                    s.sub
995                        .title
996                        .as_deref()
997                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
998                    &s.sub.url,
999                )
1000            })
1001            .unwrap_or_default()
1002    };
1003
1004    let in_scope = |feed_id: i64| -> bool {
1005        match &scope_urls {
1006            None => true,
1007            Some(urls) => feed_url_by_id(feed_id)
1008                .map(|u| urls.contains(&u))
1009                .unwrap_or(false),
1010        }
1011    };
1012
1013    // The source list for the chosen view.
1014    let source = match view.as_str() {
1015        "all" => {
1016            // All entries across in-scope feeds, newest first.
1017            let mut all = Vec::new();
1018            for s in &subs {
1019                if let Some(f) = &s.feed {
1020                    if in_scope(f.id) {
1021                        let mut es = store::entries_for_feed(pool, &did, f.id)
1022                            .await
1023                            .unwrap_or_default();
1024                        all.append(&mut es);
1025                    }
1026                }
1027            }
1028            all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1029            all
1030        }
1031        "starred" => starred
1032            .iter()
1033            .filter(|e| in_scope(e.feed_id))
1034            .cloned()
1035            .collect(),
1036        _ => unread
1037            .iter()
1038            .filter(|e| in_scope(e.feed_id))
1039            .cloned()
1040            .collect(),
1041    };
1042
1043    // The scope/view suffix carried onto every entry link (built once).
1044    let entry_scope_qs = {
1045        let mut parts = Vec::new();
1046        if let Some(f) = q.feed.as_deref() {
1047            parts.push(format!("feed={}", qenc(f)));
1048        }
1049        if let Some(f) = q.folder.as_deref() {
1050            parts.push(format!("folder={}", qenc(f)));
1051        }
1052        if view != "unread" {
1053            parts.push(format!("view={}", qenc(&view)));
1054        }
1055        parts.join("&")
1056    };
1057    let entry_link = |id: i64| -> String {
1058        if entry_scope_qs.is_empty() {
1059            format!("/entries/{id}")
1060        } else {
1061            format!("/entries/{id}?{entry_scope_qs}")
1062        }
1063    };
1064
1065    let entries: Vec<EntryRow> = source
1066        .iter()
1067        .map(|e| EntryRow {
1068            id: e.id,
1069            title: e
1070                .title
1071                .clone()
1072                .filter(|t| !t.trim().is_empty())
1073                .unwrap_or_else(|| "(untitled)".to_string()),
1074            feed_title: feed_title_by_id(e.feed_id),
1075            published: display_date(e.published.as_deref()),
1076            read: view != "unread" && !unread.iter().any(|u| u.id == e.id),
1077            starred: starred_ids.contains(&e.id),
1078            link: entry_link(e.id),
1079        })
1080        .collect();
1081
1082    let selected_feed = q.feed.as_deref();
1083    let selected_folder = q.folder.as_deref();
1084
1085    // Build the shared sidebar (folders + loose feeds, with unread counts).
1086    let (folder_views, loose_feeds, _folder_options) =
1087        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
1088
1089    // Heading + scope query-string suffix.
1090    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
1091        let name = subs
1092            .iter()
1093            .find(|s| s.sub.url == feed_url)
1094            .map(|s| {
1095                display_title(
1096                    s.sub
1097                        .title
1098                        .as_deref()
1099                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1100                    &s.sub.url,
1101                )
1102            })
1103            .unwrap_or_else(|| display_title(None, feed_url));
1104        (name, format!("feed={}", qenc(feed_url)))
1105    } else if let Some(folder_uri) = selected_folder {
1106        let name = folder_views
1107            .iter()
1108            .find(|f| f.uri == folder_uri)
1109            .map(|f| f.name.clone())
1110            .unwrap_or_else(|| "Folder".to_string());
1111        (name, format!("folder={}", qenc(folder_uri)))
1112    } else {
1113        let h = match view.as_str() {
1114            "all" => "All",
1115            "starred" => "Starred",
1116            _ => "Unread",
1117        };
1118        (h.to_string(), String::new())
1119    };
1120
1121    let feed_scope = selected_feed.map(str::to_string);
1122    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
1123
1124    let tmpl = IndexTemplate {
1125        version: VERSION,
1126        repo_url: REPO_URL,
1127        kofi_url: KOFI_URL,
1128        flash: q.flash.unwrap_or_default(),
1129        nav,
1130        entries,
1131        heading,
1132        feed_scope,
1133    };
1134    Ok(render(&tmpl))
1135}
1136
1137/// Query for `GET /manage` — carries an optional flash after an action redirect.
1138#[derive(Debug, Deserialize, Default)]
1139struct ManageQuery {
1140    #[serde(default)]
1141    flash: Option<String>,
1142}
1143
1144/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
1145/// / your-feeds / OPML surfaces; the forms POST to the existing routes
1146/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
1147/// mutation logic of its own.
1148async fn manage(
1149    State(state): State<AppState>,
1150    headers: HeaderMap,
1151    Query(q): Query<ManageQuery>,
1152) -> Result<Response, WebError> {
1153    let user = match current_session(&state, &headers).await {
1154        Some(u) => u,
1155        None => return Ok(Redirect::to("/login").into_response()),
1156    };
1157    let did = user.did.clone();
1158
1159    let subs = resolve_subscriptions(&state, &did).await;
1160    let (folder_views, loose_feeds, folder_options) =
1161        build_sidebar(&state, &did, &subs, None, None).await;
1162
1163    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
1164    let nav = build_nav(
1165        &user,
1166        "unread",
1167        String::new(),
1168        folder_views.iter().map(clone_folder_view).collect(),
1169        loose_feeds.iter().map(clone_feed_view).collect(),
1170        true,
1171    );
1172
1173    let tmpl = ManageTemplate {
1174        version: VERSION,
1175        repo_url: REPO_URL,
1176        kofi_url: KOFI_URL,
1177        flash: q.flash.unwrap_or_default(),
1178        nav,
1179        folder_options,
1180        folders: folder_views,
1181        loose_feeds,
1182    };
1183    Ok(render(&tmpl))
1184}
1185
1186/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
1187/// (`Nav`) and the page body without an extra DB round-trip.
1188fn clone_feed_view(f: &FeedView) -> FeedView {
1189    FeedView {
1190        rkey: f.rkey.clone(),
1191        url: f.url.clone(),
1192        title: f.title.clone(),
1193        unread: f.unread,
1194        selected: f.selected,
1195        folder: f.folder.clone(),
1196    }
1197}
1198
1199fn clone_folder_view(f: &FolderView) -> FolderView {
1200    FolderView {
1201        rkey: f.rkey.clone(),
1202        uri: f.uri.clone(),
1203        name: f.name.clone(),
1204        feeds: f.feeds.iter().map(clone_feed_view).collect(),
1205        selected: f.selected,
1206    }
1207}
1208
1209/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
1210/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
1211/// unscoped "everything" view. A folder scope takes the feed scope when both are
1212/// somehow present (feed wins, matching the query precedence elsewhere).
1213fn scope_urls_for(
1214    subs: &[ResolvedSub],
1215    feed: Option<&str>,
1216    folder: Option<&str>,
1217) -> Option<Vec<String>> {
1218    if let Some(feed_url) = feed {
1219        Some(vec![feed_url.to_string()])
1220    } else {
1221        folder.map(|folder_uri| {
1222            subs.iter()
1223                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
1224                .map(|s| s.sub.url.clone())
1225                .collect()
1226        })
1227    }
1228}
1229
1230/// The `at://` URI for a folder record given the owner DID + rkey.
1231fn folder_uri(did: &str, rkey: &str) -> String {
1232    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
1233}
1234
1235/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
1236/// DID — the shared source for both the reader index and the rail on every
1237/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
1238async fn build_sidebar(
1239    state: &AppState,
1240    did: &str,
1241    subs: &[ResolvedSub],
1242    selected_feed: Option<&str>,
1243    selected_folder: Option<&str>,
1244) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
1245    let pool = &state.db;
1246    let unread = store::get_unread_for_did(pool, did)
1247        .await
1248        .unwrap_or_default();
1249    let folders = state
1250        .sidecar
1251        .list_folders_sorted(did)
1252        .await
1253        .unwrap_or_default();
1254
1255    let unread_count = |feed_id: Option<i64>| -> i64 {
1256        match feed_id {
1257            Some(id) => unread.iter().filter(|e| e.feed_id == id).count() as i64,
1258            None => 0,
1259        }
1260    };
1261    let mk_feed_view = |s: &ResolvedSub| FeedView {
1262        rkey: s.rkey.clone(),
1263        url: s.sub.url.clone(),
1264        title: display_title(
1265            s.sub
1266                .title
1267                .as_deref()
1268                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1269            &s.sub.url,
1270        ),
1271        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
1272        selected: selected_feed == Some(s.sub.url.as_str()),
1273        folder: s.sub.folder.clone(),
1274    };
1275
1276    let mut folder_views = Vec::with_capacity(folders.len());
1277    for (rkey, folder) in &folders {
1278        let uri = folder_uri(did, rkey);
1279        let feeds: Vec<FeedView> = subs
1280            .iter()
1281            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
1282            .map(mk_feed_view)
1283            .collect();
1284        folder_views.push(FolderView {
1285            rkey: rkey.clone(),
1286            uri: uri.clone(),
1287            name: folder.name.clone(),
1288            feeds,
1289            selected: selected_folder == Some(uri.as_str()),
1290        });
1291    }
1292
1293    let known_uris: std::collections::HashSet<String> =
1294        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
1295    let loose_feeds: Vec<FeedView> = subs
1296        .iter()
1297        .filter(|s| {
1298            s.sub
1299                .folder
1300                .as_deref()
1301                .map(|f| !known_uris.contains(f))
1302                .unwrap_or(true)
1303        })
1304        .map(mk_feed_view)
1305        .collect();
1306
1307    let folder_options: Vec<FolderOption> = folders
1308        .iter()
1309        .map(|(rkey, folder)| FolderOption {
1310            name: folder.name.clone(),
1311            uri: folder_uri(did, rkey),
1312        })
1313        .collect();
1314
1315    (folder_views, loose_feeds, folder_options)
1316}
1317
1318/// Assemble the shared rail [`Nav`] for a chrome page.
1319fn build_nav(
1320    user: &CurrentUser,
1321    view: &str,
1322    scope_qs: String,
1323    folders: Vec<FolderView>,
1324    loose_feeds: Vec<FeedView>,
1325    manage_active: bool,
1326) -> Nav {
1327    Nav {
1328        handle: display_handle(user.handle.as_deref(), &user.did),
1329        avatar: avatar_initials(user.handle.as_deref(), &user.did),
1330        view: view.to_string(),
1331        scope_qs,
1332        folders,
1333        loose_feeds,
1334        manage_active,
1335    }
1336}
1337
1338// ---------------------------------------------------------------------------
1339// Reader: single entry
1340// ---------------------------------------------------------------------------
1341
1342/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
1343/// prev/next and "back" stay within the list the reader came from.
1344#[derive(Debug, Deserialize, Default)]
1345struct EntryQuery {
1346    #[serde(default)]
1347    feed: Option<String>,
1348    #[serde(default)]
1349    folder: Option<String>,
1350    #[serde(default)]
1351    view: Option<String>,
1352}
1353
1354/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
1355/// within the current reading list.
1356async fn entry_view(
1357    State(state): State<AppState>,
1358    headers: HeaderMap,
1359    Path(id): Path<i64>,
1360    Query(q): Query<EntryQuery>,
1361) -> Result<Response, WebError> {
1362    let user = match current_session(&state, &headers).await {
1363        Some(u) => u,
1364        None => return Ok(Redirect::to("/login").into_response()),
1365    };
1366    let did = user.did.clone();
1367    let pool = &state.db;
1368
1369    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
1370    // the per-DID entry gate below authorizes against the caller's current PDS
1371    // subscription set (not another user's cached feeds).
1372    let subs = resolve_subscriptions(&state, &did).await;
1373
1374    let entry = match get_entry_by_id(pool, &did, id).await? {
1375        Some(e) => e,
1376        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1377    };
1378
1379    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
1380
1381    let read = entry_is_read(pool, &did, id).await?;
1382    let starred = entry_is_starred(pool, &did, id).await?;
1383
1384    // Reconstruct the current list to compute prev/next, so paging in the reader
1385    // matches what the list showed.
1386    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
1387
1388    let back_qs = scope_query(&q);
1389
1390    let (folder_views, loose_feeds, _) =
1391        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
1392    let nav_view = match q.view.as_deref() {
1393        Some("all") => "all",
1394        Some("starred") => "starred",
1395        _ => "unread",
1396    };
1397    let nav = build_nav(
1398        &user,
1399        nav_view,
1400        back_qs.clone(),
1401        folder_views,
1402        loose_feeds,
1403        false,
1404    );
1405
1406    let tmpl = EntryTemplate {
1407        version: VERSION,
1408        repo_url: REPO_URL,
1409        kofi_url: KOFI_URL,
1410        nav,
1411        id: entry.id,
1412        title: entry
1413            .title
1414            .clone()
1415            .filter(|t| !t.trim().is_empty())
1416            .unwrap_or_else(|| "(untitled)".to_string()),
1417        feed_title,
1418        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
1419        published: display_date(entry.published.as_deref()),
1420        url: entry.url.clone().filter(|u| !u.trim().is_empty()),
1421        content_html: entry.content_html.clone(),
1422        read,
1423        starred,
1424        back_qs,
1425        prev_id,
1426        next_id,
1427        oob: false,
1428    };
1429    Ok(render(&tmpl))
1430}
1431
1432/// Compute the prev/next entry ids around `current` within the reader's current
1433/// scope + view, so the reader view can offer keyboard/paging navigation.
1434async fn neighbors_in_scope(
1435    state: &AppState,
1436    did: &str,
1437    q: &EntryQuery,
1438    current: i64,
1439) -> (Option<i64>, Option<i64>) {
1440    let idx_q = IndexQuery {
1441        feed: q.feed.clone(),
1442        folder: q.folder.clone(),
1443        view: q.view.clone(),
1444        flash: None,
1445    };
1446    let ids = list_entry_ids(state, did, &idx_q).await;
1447    let pos = ids.iter().position(|&x| x == current);
1448    match pos {
1449        Some(p) => {
1450            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
1451            let next = ids.get(p + 1).copied();
1452            (prev, next)
1453        }
1454        None => (None, None),
1455    }
1456}
1457
1458/// The ordered entry ids for a scope + view — the same ordering `index` renders,
1459/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
1460async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
1461    let pool = &state.db;
1462    let subs = resolve_subscriptions(state, did).await;
1463
1464    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1465    let feed_url_by_id = |id: i64| -> Option<String> {
1466        subs.iter()
1467            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1468            .map(|s| s.sub.url.clone())
1469    };
1470    let in_scope = |feed_id: i64| -> bool {
1471        match &scope_urls {
1472            None => true,
1473            Some(urls) => feed_url_by_id(feed_id)
1474                .map(|u| urls.contains(&u))
1475                .unwrap_or(false),
1476        }
1477    };
1478
1479    let view = q.view.as_deref().unwrap_or("unread");
1480    let entries = match view {
1481        "all" => {
1482            let mut all = Vec::new();
1483            for s in &subs {
1484                if let Some(f) = &s.feed {
1485                    if in_scope(f.id) {
1486                        let mut es = store::entries_for_feed(pool, did, f.id)
1487                            .await
1488                            .unwrap_or_default();
1489                        all.append(&mut es);
1490                    }
1491                }
1492            }
1493            all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1494            all
1495        }
1496        "starred" => store::get_starred_for_did(pool, did)
1497            .await
1498            .unwrap_or_default()
1499            .into_iter()
1500            .filter(|e| in_scope(e.feed_id))
1501            .collect(),
1502        _ => store::get_unread_for_did(pool, did)
1503            .await
1504            .unwrap_or_default()
1505            .into_iter()
1506            .filter(|e| in_scope(e.feed_id))
1507            .collect(),
1508    };
1509    entries.into_iter().map(|e| e.id).collect()
1510}
1511
1512/// Build a `?…` query string that preserves the reading scope + view for links.
1513fn scope_query(q: &EntryQuery) -> String {
1514    let mut parts = Vec::new();
1515    if let Some(f) = q.feed.as_deref() {
1516        parts.push(format!("feed={}", qenc(f)));
1517    }
1518    if let Some(f) = q.folder.as_deref() {
1519        parts.push(format!("folder={}", qenc(f)));
1520    }
1521    if let Some(v) = q.view.as_deref() {
1522        if v != "unread" {
1523            parts.push(format!("view={}", qenc(v)));
1524        }
1525    }
1526    parts.join("&")
1527}
1528
1529// ---------------------------------------------------------------------------
1530// Mark read / unread
1531// ---------------------------------------------------------------------------
1532
1533/// Form body for `POST /entries/:id/read`.
1534#[derive(Debug, Deserialize)]
1535struct ReadForm {
1536    #[serde(default)]
1537    read: Option<String>,
1538}
1539
1540/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
1541async fn mark_read(
1542    State(state): State<AppState>,
1543    Path(id): Path<i64>,
1544    headers: HeaderMap,
1545    Form(form): Form<ReadForm>,
1546) -> Result<Response, WebError> {
1547    let did = match current_did(&state, &headers).await {
1548        Some(d) => d,
1549        None => return Ok(Redirect::to("/login").into_response()),
1550    };
1551    let pool = &state.db;
1552
1553    let read = matches!(
1554        form.read.as_deref(),
1555        Some("true") | Some("1") | Some("on") | None
1556    );
1557
1558    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
1559    // mutation: `mark_read` only writes when `did` subscribes to the entry's
1560    // feed. A non-subscriber gets a 404, never a mutation of someone else's
1561    // (or the shared cache's) state.
1562    resolve_subscriptions(&state, &did).await;
1563    if !store::mark_read(pool, &did, id, read).await? {
1564        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1565    }
1566
1567    if !is_htmx(&headers) {
1568        return Ok(Redirect::to("/").into_response());
1569    }
1570
1571    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
1572    // in the DOM), so its button's hidden value + aria-pressed update in place
1573    // and a second keypress can reverse the toggle. The list view swaps the row.
1574    if is_reader_request(&headers) {
1575        let starred = entry_is_starred(pool, &did, id).await?;
1576        return Ok(render(&EntryActionBarTemplate {
1577            id,
1578            read,
1579            starred,
1580            oob: true,
1581        }));
1582    }
1583
1584    let row = build_entry_row(pool, &did, id, Some(read)).await?;
1585    match row {
1586        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1587        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1588    }
1589}
1590
1591// ---------------------------------------------------------------------------
1592// Star / save
1593// ---------------------------------------------------------------------------
1594
1595/// Form body for `POST /entries/:id/star`.
1596#[derive(Debug, Deserialize)]
1597struct StarForm {
1598    #[serde(default)]
1599    starred: Option<String>,
1600}
1601
1602/// `POST /entries/:id/star` — star/unstar an entry.
1603///
1604/// Sets the local `starred` bit (fast working copy) and writes/removes a
1605/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
1606/// owning). The PDS write is best-effort — the local star still lands.
1607async fn toggle_star(
1608    State(state): State<AppState>,
1609    Path(id): Path<i64>,
1610    headers: HeaderMap,
1611    Form(form): Form<StarForm>,
1612) -> Result<Response, WebError> {
1613    let did = match current_did(&state, &headers).await {
1614        Some(d) => d,
1615        None => return Ok(Redirect::to("/login").into_response()),
1616    };
1617    let pool = &state.db;
1618
1619    let starred = matches!(
1620        form.starred.as_deref(),
1621        Some("true") | Some("1") | Some("on") | None
1622    );
1623
1624    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
1625    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
1626    // feed. A non-subscriber gets a 404, never a mutation.
1627    resolve_subscriptions(&state, &did).await;
1628    if !store::mark_starred(pool, &did, id, starred).await? {
1629        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1630    }
1631
1632    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
1633    // to the caller's subscriptions, so this only ever acts on the caller's feed.
1634    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
1635        let entry_url = entry.url.clone().unwrap_or_default();
1636        if !entry_url.is_empty() {
1637            if starred {
1638                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
1639                saved.title = entry.title.clone();
1640                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
1641                saved.entry_id = Some(entry.guid.clone());
1642                match state.sidecar.add_saved(&did, &saved).await {
1643                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
1644                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
1645                }
1646            } else {
1647                // Un-star: find and delete the matching saved record by URL.
1648                match state.sidecar.list_saved(&did).await {
1649                    Ok(records) => {
1650                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
1651                            if let Err(err) = state.sidecar.remove_saved(&did, rkey).await {
1652                                warn!(%err, %did, %rkey, "PDS saved delete failed");
1653                            }
1654                        }
1655                    }
1656                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
1657                }
1658            }
1659        }
1660    }
1661
1662    if !is_htmx(&headers) {
1663        return Ok(Redirect::to("/").into_response());
1664    }
1665
1666    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
1667    if is_reader_request(&headers) {
1668        let read = entry_is_read(pool, &did, id).await?;
1669        return Ok(render(&EntryActionBarTemplate {
1670            id,
1671            read,
1672            starred,
1673            oob: true,
1674        }));
1675    }
1676
1677    let row = build_entry_row(pool, &did, id, None).await?;
1678    match row {
1679        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1680        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1681    }
1682}
1683
1684/// The feed URL for a cached feed id, if the row exists.
1685async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
1686    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
1687        .bind(feed_id)
1688        .fetch_optional(pool)
1689        .await
1690        .ok()
1691        .flatten()
1692}
1693
1694// ---------------------------------------------------------------------------
1695// Mark-all-read
1696// ---------------------------------------------------------------------------
1697
1698/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
1699/// absent means mark everything read.
1700#[derive(Debug, Deserialize, Default)]
1701struct ReadAllQuery {
1702    #[serde(default)]
1703    feed: Option<String>,
1704}
1705
1706/// `POST /read-all` — mark every entry read for the current DID, optionally
1707/// scoped to one feed (mark-all-read per feed or globally).
1708async fn mark_all_read(
1709    State(state): State<AppState>,
1710    headers: HeaderMap,
1711    Query(q): Query<ReadAllQuery>,
1712) -> Result<Response, WebError> {
1713    let did = match current_did(&state, &headers).await {
1714        Some(d) => d,
1715        None => return Ok(Redirect::to("/login").into_response()),
1716    };
1717    let pool = &state.db;
1718
1719    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
1720    // only ever touch feeds this DID actually subscribes to.
1721    resolve_subscriptions(&state, &did).await;
1722
1723    if let Some(feed_url) = q.feed.as_deref() {
1724        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
1725            store::mark_feed_read(pool, &did, feed.id, true).await?;
1726        }
1727        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
1728    }
1729
1730    // Global: mark every subscribed feed read. Fan out over the DID's feeds
1731    // (bounded by the per-DID subscription cap) using the batched per-feed path,
1732    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
1733    // state, but O(feeds) statements instead of O(unread entries).
1734    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
1735        store::mark_feed_read(pool, &did, feed_id, true).await?;
1736    }
1737    Ok(Redirect::to("/").into_response())
1738}
1739
1740// ---------------------------------------------------------------------------
1741// Subscribe by URL
1742// ---------------------------------------------------------------------------
1743
1744/// Refusal message shown when a private/paid feed is submitted. FeatherReader
1745/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
1746/// only for now — a private feed's secret URL is never saved, fetched, or sent
1747/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
1748/// and the boot-smoke can assert on it.
1749const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
1750    FeatherReader stores your subscriptions in your public PDS, so it supports public \
1751    feeds for now — private-feed support arrives when atproto's private data \
1752    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
1753
1754/// Form body for `POST /subscriptions`.
1755#[derive(Debug, Deserialize)]
1756struct SubscribeForm {
1757    url: String,
1758    /// Optional folder `at://` URI to file the new feed under.
1759    #[serde(default)]
1760    folder: Option<String>,
1761}
1762
1763/// `POST /subscriptions` — subscribe by URL.
1764async fn add_subscription(
1765    State(state): State<AppState>,
1766    headers: HeaderMap,
1767    Form(form): Form<SubscribeForm>,
1768) -> Result<Response, WebError> {
1769    let did = match current_did(&state, &headers).await {
1770        Some(d) => d,
1771        None => return Ok(Redirect::to("/login").into_response()),
1772    };
1773    let pool = &state.db;
1774    let input = form.url.trim().to_string();
1775    if input.is_empty() {
1776        return Ok(Redirect::to("/").into_response());
1777    }
1778
1779    // Block private/paid feeds BEFORE any fetch/resolve so a secret-bearing URL is
1780    // never even requested. Public feeds only until atproto permissioned data
1781    // ships; there is no override and nothing is stored or written.
1782    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
1783        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
1784        return Ok(
1785            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1786        );
1787    }
1788
1789    // Per-DID subscription cap: bound one account's storage/poller footprint on
1790    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
1791    // can't even trigger an outbound request. `<= 0` disables the cap.
1792    let cap = state.config.max_subs_per_did;
1793    if cap > 0 {
1794        match store::count_subscriptions_for_did(pool, &did).await {
1795            Ok(n) if n >= cap => {
1796                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
1797                return Ok(Redirect::to(&format!(
1798                    "/?flash={}",
1799                    qenc(&format!(
1800                        "Subscription limit reached ({cap}). Remove a feed before adding another."
1801                    ))
1802                ))
1803                .into_response());
1804            }
1805            Ok(_) => {}
1806            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
1807        }
1808    }
1809
1810    let feed_url = match resolve_feed_url(&state.config, &input).await {
1811        Ok(u) => u,
1812        Err(err) => {
1813            warn!(%err, url = %input, "could not resolve a feed from the given URL");
1814            return Ok(Redirect::to(&format!(
1815                "/?flash={}",
1816                qenc("Couldn't find a feed at that URL")
1817            ))
1818            .into_response());
1819        }
1820    };
1821
1822    // Defensive: resolution may have discovered a feed URL that itself carries a
1823    // secret (e.g. a public site page linking a tokened feed). Re-check the
1824    // resolved URL and refuse before storing/writing anything.
1825    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1826        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
1827        return Ok(
1828            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1829        );
1830    }
1831
1832    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
1833    // cache is full (an existing/duplicate feed URL is always fine — it adds no
1834    // row). Bounds total cache size across all users on the box. `<= 0` disables.
1835    let feeds_cap = state.config.max_feeds_global;
1836    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
1837        match store::count_feeds(pool).await {
1838            Ok(n) if n >= feeds_cap => {
1839                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
1840                return Ok(Redirect::to(&format!(
1841                    "/?flash={}",
1842                    qenc(
1843                        "This instance is at its feed capacity right now. Please try again later."
1844                    )
1845                ))
1846                .into_response());
1847            }
1848            Ok(_) => {}
1849            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1850        }
1851    }
1852
1853    store::upsert_feed(
1854        pool,
1855        &store::NewFeed {
1856            url: feed_url.clone(),
1857            ..Default::default()
1858        },
1859    )
1860    .await?;
1861
1862    if let Ok(client) = feed::build_client() {
1863        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
1864            match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
1865            {
1866                Ok(outcome) => info!(feed = %feed_url, ?outcome, "polled new subscription"),
1867                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
1868            }
1869        }
1870    }
1871
1872    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
1873    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
1874        sub.title = feed_row.title.clone();
1875        sub.site_url = feed_row.site_url.clone();
1876    }
1877    sub.folder = form
1878        .folder
1879        .map(|f| f.trim().to_string())
1880        .filter(|f| !f.is_empty());
1881
1882    match state.sidecar.add_subscription(&did, &sub).await {
1883        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
1884        Err(err) => {
1885            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
1886        }
1887    }
1888
1889    Ok(Redirect::to("/").into_response())
1890}
1891
1892/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
1893async fn delete_subscription(
1894    State(state): State<AppState>,
1895    headers: HeaderMap,
1896    Path(rkey): Path<String>,
1897) -> Result<Response, WebError> {
1898    let did = match current_did(&state, &headers).await {
1899        Some(d) => d,
1900        None => return Ok(Redirect::to("/login").into_response()),
1901    };
1902    match state.sidecar.remove_subscription(&did, &rkey).await {
1903        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
1904        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
1905    }
1906    Ok(Redirect::to("/").into_response())
1907}
1908
1909/// Form body for `POST /subscriptions/:rkey/rename`.
1910#[derive(Debug, Deserialize)]
1911struct RenameSubForm {
1912    url: String,
1913    #[serde(default)]
1914    title: Option<String>,
1915    #[serde(default)]
1916    site_url: Option<String>,
1917    #[serde(default)]
1918    folder: Option<String>,
1919}
1920
1921/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
1922/// folder, rewriting the whole subscription record via `putRecord`.
1923async fn rename_subscription(
1924    State(state): State<AppState>,
1925    headers: HeaderMap,
1926    Path(rkey): Path<String>,
1927    Form(form): Form<RenameSubForm>,
1928) -> Result<Response, WebError> {
1929    let did = match current_did(&state, &headers).await {
1930        Some(d) => d,
1931        None => return Ok(Redirect::to("/login").into_response()),
1932    };
1933    let feed_url = form.url.trim().to_string();
1934
1935    // Reject an empty/blank resolved URL — a rename with no usable URL must not
1936    // write a junk row to the cache or a malformed subscription record to the
1937    // PDS (add_subscription refuses an empty input the same way).
1938    if feed_url.is_empty() {
1939        return Ok(Redirect::to("/").into_response());
1940    }
1941
1942    // Block private/paid feeds on rename too. `url` is attacker-controllable, and
1943    // rename both upserts it to the local cache AND rewrites the PDS subscription
1944    // record (a public `putRecord`), so without this guard a crafted rename could
1945    // land a secret-bearing URL in the public PDS — the exact leak the add and
1946    // OPML paths already prevent. Refuse before touching either store.
1947    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1948        info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
1949        return Ok(
1950            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1951        );
1952    }
1953
1954    // Global feeds ceiling parity with add_subscription: a rename can point at a
1955    // brand-new feed URL (not just retitle an existing one), which would insert a
1956    // NEW `feeds` row. Refuse that when the shared cache is at capacity (an
1957    // existing/duplicate URL adds no row and is always fine). `<= 0` disables.
1958    let feeds_cap = state.config.max_feeds_global;
1959    if feeds_cap > 0
1960        && store::get_feed_by_url(&state.db, &feed_url)
1961            .await?
1962            .is_none()
1963    {
1964        match store::count_feeds(&state.db).await {
1965            Ok(n) if n >= feeds_cap => {
1966                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
1967                return Ok(Redirect::to(&format!(
1968                    "/?flash={}",
1969                    qenc(
1970                        "This instance is at its feed capacity right now. Please try again later."
1971                    )
1972                ))
1973                .into_response());
1974            }
1975            Ok(_) => {}
1976            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1977        }
1978    }
1979
1980    let mut sub = Subscription::new(feed_url, now_rfc3339());
1981    sub.title = form
1982        .title
1983        .map(|t| t.trim().to_string())
1984        .filter(|t| !t.is_empty());
1985    sub.site_url = form
1986        .site_url
1987        .map(|t| t.trim().to_string())
1988        .filter(|t| !t.is_empty());
1989    sub.folder = form
1990        .folder
1991        .map(|f| f.trim().to_string())
1992        .filter(|f| !f.is_empty());
1993
1994    // Keep the local cache title in step for the loose-feed fallback path.
1995    let _ = store::upsert_feed(
1996        &state.db,
1997        &store::NewFeed {
1998            url: sub.url.clone(),
1999            title: sub.title.clone(),
2000            site_url: sub.site_url.clone(),
2001            ..Default::default()
2002        },
2003    )
2004    .await;
2005
2006    match state.sidecar.update_subscription(&did, &rkey, &sub).await {
2007        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription"),
2008        Err(err) => warn!(%err, %did, %rkey, "PDS subscription update failed"),
2009    }
2010    Ok(Redirect::to("/").into_response())
2011}
2012
2013// ---------------------------------------------------------------------------
2014// Folders
2015// ---------------------------------------------------------------------------
2016
2017/// Form body for `POST /folders`.
2018#[derive(Debug, Deserialize)]
2019struct FolderForm {
2020    name: String,
2021}
2022
2023/// `POST /folders` — create a folder record.
2024async fn create_folder(
2025    State(state): State<AppState>,
2026    headers: HeaderMap,
2027    Form(form): Form<FolderForm>,
2028) -> Result<Response, WebError> {
2029    let did = match current_did(&state, &headers).await {
2030        Some(d) => d,
2031        None => return Ok(Redirect::to("/login").into_response()),
2032    };
2033    let name = form.name.trim();
2034    if name.is_empty() {
2035        return Ok(Redirect::to("/").into_response());
2036    }
2037    let folder = Folder::new(name.to_string(), now_rfc3339());
2038    match state.sidecar.add_folder(&did, &folder).await {
2039        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
2040        Err(err) => warn!(%err, %did, "PDS folder create failed"),
2041    }
2042    Ok(Redirect::to("/").into_response())
2043}
2044
2045/// `POST /folders/:rkey/rename` — rename a folder record.
2046async fn rename_folder(
2047    State(state): State<AppState>,
2048    headers: HeaderMap,
2049    Path(rkey): Path<String>,
2050    Form(form): Form<FolderForm>,
2051) -> Result<Response, WebError> {
2052    let did = match current_did(&state, &headers).await {
2053        Some(d) => d,
2054        None => return Ok(Redirect::to("/login").into_response()),
2055    };
2056    let name = form.name.trim();
2057    if name.is_empty() {
2058        return Ok(Redirect::to("/").into_response());
2059    }
2060    let folder = Folder::new(name.to_string(), now_rfc3339());
2061    match state.sidecar.rename_folder(&did, &rkey, &folder).await {
2062        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
2063        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
2064    }
2065    Ok(Redirect::to("/").into_response())
2066}
2067
2068/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
2069/// simply become un-foldered).
2070async fn delete_folder(
2071    State(state): State<AppState>,
2072    headers: HeaderMap,
2073    Path(rkey): Path<String>,
2074) -> Result<Response, WebError> {
2075    let did = match current_did(&state, &headers).await {
2076        Some(d) => d,
2077        None => return Ok(Redirect::to("/login").into_response()),
2078    };
2079    match state.sidecar.remove_folder(&did, &rkey).await {
2080        Ok(()) => info!(%did, %rkey, "deleted folder record"),
2081        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
2082    }
2083    Ok(Redirect::to("/").into_response())
2084}
2085
2086/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
2087/// feed document we take it as-is; if it yields an HTML page we run
2088/// autodiscovery over its `<link rel="alternate">` tags.
2089async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
2090    let parsed =
2091        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
2092
2093    let client = feed::build_client()?;
2094    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
2095    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
2096    // loopback / private hosts.
2097    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
2098    let final_url = resp.url().clone();
2099    let content_type = resp
2100        .headers()
2101        .get(axum::http::header::CONTENT_TYPE)
2102        .and_then(|v| v.to_str().ok())
2103        .unwrap_or("")
2104        .to_ascii_lowercase();
2105    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
2106    // gzip strips it, and this response is reflected into the UI.
2107    let raw = crate::net::read_capped(resp).await?;
2108    let body = String::from_utf8_lossy(&raw).into_owned();
2109
2110    let looks_like_feed = content_type.contains("xml")
2111        || content_type.contains("rss")
2112        || content_type.contains("atom")
2113        || content_type.contains("application/feed+json")
2114        || {
2115            let head = body.trim_start();
2116            head.starts_with("<?xml")
2117                || head.starts_with("<rss")
2118                || head.starts_with("<feed")
2119                || head.contains("<rss")
2120                || head.contains("<feed")
2121        };
2122    if looks_like_feed {
2123        return Ok(final_url.to_string());
2124    }
2125
2126    match feed::discover_feed(&body, Some(&final_url)) {
2127        Some(u) => Ok(u.to_string()),
2128        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
2129    }
2130}
2131
2132// ---------------------------------------------------------------------------
2133// Login (atproto OAuth via the sidecar)
2134// ---------------------------------------------------------------------------
2135
2136/// Query for `GET /login`.
2137#[derive(Debug, Deserialize, Default)]
2138struct LoginQuery {
2139    #[serde(default)]
2140    handle: Option<String>,
2141    #[serde(default)]
2142    error: Option<String>,
2143    #[serde(default)]
2144    flash: Option<String>,
2145}
2146
2147/// `GET /login` — start the atproto OAuth flow, or render the handle form.
2148///
2149/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
2150/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
2151/// session cookie *or* the submitted handle resolving to a seated DID) or a
2152/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
2153/// form (no handle) always renders.
2154async fn login_form(
2155    State(state): State<AppState>,
2156    headers: HeaderMap,
2157    Query(q): Query<LoginQuery>,
2158) -> Response {
2159    if let Some(handle) = q
2160        .handle
2161        .map(|h| h.trim().to_string())
2162        .filter(|h| !h.is_empty())
2163    {
2164        if !may_start_oauth(&state, &headers, &handle).await {
2165            return Redirect::to("/beta/redeem").into_response();
2166        }
2167        return start_oauth(&state, &handle);
2168    }
2169    render(&LoginTemplate {
2170        repo_url: REPO_URL,
2171        error: q.error.unwrap_or_default(),
2172        flash: q.flash.unwrap_or_default(),
2173    })
2174}
2175
2176/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
2177/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
2178async fn login_submit(
2179    State(state): State<AppState>,
2180    headers: HeaderMap,
2181    Form(form): Form<LoginForm>,
2182) -> Response {
2183    let handle = form.handle.trim();
2184    if handle.is_empty() {
2185        return login_error("Enter your atproto handle.");
2186    }
2187    if !may_start_oauth(&state, &headers, handle).await {
2188        return Redirect::to("/beta/redeem").into_response();
2189    }
2190    start_oauth(&state, handle)
2191}
2192
2193/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
2194/// admits, in order of cost:
2195///
2196/// 1. an existing beta member's cookie session whose DID already holds a seat;
2197/// 2. a fresh visitor carrying a valid reserving invite cookie;
2198/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
2199///    already holds a seat — this honors the **seeded admin's first login** on a
2200///    fresh deploy (and any returning member who cleared cookies) without a
2201///    session cookie or an invite code.
2202///
2203/// The cookie/invite fast paths run FIRST and short-circuit, so the network
2204/// handle→DID resolution is only attempted when neither applies. It fails
2205/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
2206/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
2207/// This keeps the anti-abuse intent — a rando now pays a cheap handle
2208/// resolution instead of a burned sidecar handshake (and `/login` is already in
2209/// the rate-limited path set).
2210async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
2211    // The production resolver is the app's existing atproto handle→DID path,
2212    // routed through the SSRF guard. Resolution is injected so tests can exercise
2213    // the gate without a live network call (the guard forbids loopback mocks).
2214    may_start_oauth_with(state, headers, handle, |h| async move {
2215        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
2216            .await
2217            .ok()
2218    })
2219    .await
2220}
2221
2222/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
2223/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
2224/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
2225/// only called when neither admits — keeping the network round-trip off the hot
2226/// path and preserving the fail-closed contract on resolution failure.
2227async fn may_start_oauth_with<F, Fut>(
2228    state: &AppState,
2229    headers: &HeaderMap,
2230    handle: &str,
2231    resolve: F,
2232) -> bool
2233where
2234    F: FnOnce(String) -> Fut,
2235    Fut: std::future::Future<Output = Option<String>>,
2236{
2237    // 1. An already-beta'd session may re-auth freely.
2238    if let Some(did) = current_did(state, headers).await {
2239        if store::has_beta_access(&state.db, &did)
2240            .await
2241            .unwrap_or(false)
2242        {
2243            return true;
2244        }
2245    }
2246    // 2. A valid reserving invite cookie.
2247    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
2248        return true;
2249    }
2250    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
2251    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
2252    //    on any resolution error or unresolvable/malformed handle.
2253    match resolve(handle.to_string()).await {
2254        Some(did) => store::has_beta_access(&state.db, &did)
2255            .await
2256            .unwrap_or(false),
2257        None => {
2258            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
2259            false
2260        }
2261    }
2262}
2263
2264/// Redirect the browser to the sidecar's public `/login` for `handle`.
2265fn start_oauth(state: &AppState, handle: &str) -> Response {
2266    let url = state.sidecar.login_url(handle, None);
2267    info!(%handle, "redirecting to OAuth sidecar login");
2268    Redirect::to(&url).into_response()
2269}
2270
2271/// Form body for `POST /login`.
2272#[derive(Debug, Deserialize)]
2273struct LoginForm {
2274    handle: String,
2275}
2276
2277/// Query for `GET /oauth/callback`.
2278#[derive(Debug, Deserialize, Default)]
2279struct CallbackQuery {
2280    #[serde(default)]
2281    session_id: Option<String>,
2282    #[serde(default)]
2283    error: Option<String>,
2284    #[serde(default)]
2285    error_description: Option<String>,
2286}
2287
2288/// `GET /oauth/callback` — establish the cookie session.
2289///
2290/// **Invite gate:** the verified DID must hold beta access. If it already does
2291/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
2292/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
2293/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
2294async fn oauth_callback(
2295    State(state): State<AppState>,
2296    headers: HeaderMap,
2297    Query(q): Query<CallbackQuery>,
2298) -> Response {
2299    if let Some(err) = q.error {
2300        let desc = q.error_description.unwrap_or_default();
2301        warn!(error = %err, desc = %desc, "OAuth callback returned an error");
2302        return login_error(&format!("Login failed: {err}"));
2303    }
2304
2305    let session_id = match q.session_id {
2306        Some(s) if !s.is_empty() => s,
2307        _ => return login_error("Login failed: the callback carried no session."),
2308    };
2309
2310    let session = match state.sidecar.resolve_session(&session_id).await {
2311        Ok(Some(s)) => s,
2312        Ok(None) => {
2313            warn!("OAuth callback session_id did not resolve (expired/unknown)");
2314            return login_error("Login session expired — please try again.");
2315        }
2316        Err(err) => {
2317            warn!(%err, "failed to resolve OAuth session via the sidecar");
2318            return login_error("Login failed talking to the auth service.");
2319        }
2320    };
2321
2322    // Bind the verified DID to the invite gate. Returns a response only on the
2323    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
2324    let mut clear_invite = false;
2325    if !store::has_beta_access(&state.db, &session.did)
2326        .await
2327        .unwrap_or(false)
2328    {
2329        // Not yet a member: consume the reserved invite code, if any.
2330        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
2331            Some(c) => c,
2332            None => {
2333                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
2334                return Redirect::to("/beta/redeem").into_response();
2335            }
2336        };
2337        match store::redeem_code(
2338            &state.db,
2339            &code,
2340            &session.did,
2341            session.handle.as_deref(),
2342            state.config.beta_cap,
2343        )
2344        .await
2345        {
2346            Ok(Ok(())) => {
2347                clear_invite = true;
2348                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
2349            }
2350            Ok(Err(policy)) => {
2351                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
2352                let mut resp = redeem_bounce(&policy).into_response();
2353                // The reservation is spent/invalid — drop the stale invite cookie.
2354                clear_invite_cookie(&mut resp);
2355                return resp;
2356            }
2357            Err(err) => {
2358                warn!(%err, did = %session.did, "invite redeem infra error at callback");
2359                return login_error("Login failed while confirming your invite.");
2360            }
2361        }
2362    }
2363
2364    // Mint an opaque, random server-side session id and store the identity under
2365    // it; the cookie carries the (HMAC-signed) sid, never the DID.
2366    let sid = state.sessions.create(Session {
2367        did: session.did.clone(),
2368        handle: session.handle.clone(),
2369    });
2370    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
2371    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
2372
2373    let mut resp = Redirect::to("/").into_response();
2374    set_cookie(&mut resp, &cookie);
2375    if clear_invite {
2376        clear_invite_cookie(&mut resp);
2377    }
2378    resp
2379}
2380
2381/// `POST /logout` — end the session everywhere, not just in this browser.
2382///
2383/// Clearing the cookie only stops *this* device from presenting the session;
2384/// the sidecar still holds live OAuth tokens for the DID. So logout now also
2385/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
2386/// access tokens at the PDS and drops the sidecar's session rows. The local
2387/// registry entry is dropped and the cookie cleared regardless of whether the
2388/// revoke round-trip succeeds (best-effort — a network blip must not trap the
2389/// user in a half-logged-out state).
2390async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
2391    if let Some(user) = current_session(&state, &headers).await {
2392        // Only a real cookie session (`sid` present) has sidecar-held tokens to
2393        // revoke; the dev-DID fallback never handshook the sidecar.
2394        if let Some(sid) = user.sid {
2395            state.sessions.remove(&sid);
2396            match state.sidecar.revoke_session(&user.did).await {
2397                Ok(res) => {
2398                    info!(did = %user.did, revoked = res.revoked, "logout: sidecar session revoked");
2399                }
2400                Err(err) => {
2401                    warn!(did = %user.did, %err, "logout: sidecar revoke failed; clearing cookie anyway");
2402                }
2403            }
2404        }
2405    }
2406    let mut resp = Redirect::to("/login").into_response();
2407    set_cookie(
2408        &mut resp,
2409        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2410    );
2411    resp
2412}
2413
2414/// Form body for `POST /account/delete` — the confirm-gate. The user must type
2415/// `DELETE` into this field for the purge to run.
2416#[derive(Debug, Deserialize)]
2417struct DeleteAccountForm {
2418    #[serde(default)]
2419    confirm: String,
2420}
2421
2422/// The literal a user must type to confirm the destructive delete.
2423const DELETE_CONFIRM_PHRASE: &str = "DELETE";
2424
2425/// `POST /account/delete` (authed) — the "delete my data" endpoint.
2426///
2427/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
2428/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
2429///   1. purges **every** local row owned by the caller DID (`entry_state`,
2430///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
2431///      DID created) via [`store::purge_did_data`], then
2432///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
2433///      revoked at the PDS and the sidecar's session rows are dropped, then
2434///   3. drops the in-memory session and clears the cookie, signing the user out.
2435///
2436/// The subscription/folder/saved *records* in the user's own PDS are
2437/// intentionally left alone — they are the user's data on their own server; the
2438/// `/about` copy and this page's UI both say so, and export stays available.
2439async fn account_delete(
2440    State(state): State<AppState>,
2441    headers: HeaderMap,
2442    Form(form): Form<DeleteAccountForm>,
2443) -> Result<Response, WebError> {
2444    let user = match current_session(&state, &headers).await {
2445        Some(u) => u,
2446        None => return Ok(Redirect::to("/login").into_response()),
2447    };
2448    let did = user.did.clone();
2449
2450    // Confirm-gate: require the exact typed phrase before doing anything.
2451    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
2452        return Ok(Redirect::to(&format!(
2453            "/manage?flash={}",
2454            qenc("Type DELETE to confirm — nothing was deleted.")
2455        ))
2456        .into_response());
2457    }
2458
2459    // 1. Purge every local row this DID owns (single transaction).
2460    let counts = store::purge_did_data(&state.db, &did).await?;
2461    info!(
2462        %did,
2463        total = counts.total(),
2464        entry_state = counts.entry_state,
2465        read_cursor = counts.read_cursor,
2466        sub_ref = counts.sub_ref,
2467        beta_access = counts.beta_access,
2468        invite_codes = counts.invite_codes,
2469        "account/delete: local rows purged"
2470    );
2471
2472    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
2473    //    rows are already gone; a network blip must not block the sign-out).
2474    match state.sidecar.revoke_session(&did).await {
2475        Ok(res) => info!(%did, revoked = res.revoked, "account/delete: sidecar session revoked"),
2476        Err(err) => {
2477            warn!(%did, %err, "account/delete: sidecar revoke failed; local data already purged")
2478        }
2479    }
2480
2481    // 3. Drop the in-memory session and clear the cookie: sign the user out.
2482    if let Some(sid) = user.sid {
2483        state.sessions.remove(&sid);
2484    }
2485    let mut resp = Redirect::to(&format!(
2486        "/login?flash={}",
2487        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
2488    ))
2489    .into_response();
2490    set_cookie(
2491        &mut resp,
2492        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2493    );
2494    Ok(resp)
2495}
2496
2497/// Re-render the login form with an error banner.
2498fn login_error(msg: &str) -> Response {
2499    render(&LoginTemplate {
2500        repo_url: REPO_URL,
2501        error: msg.to_string(),
2502        flash: String::new(),
2503    })
2504}
2505
2506// ---------------------------------------------------------------------------
2507// Closed-beta invite gate (self-serve redeem + admin mint)
2508// ---------------------------------------------------------------------------
2509
2510/// Form body for `POST /beta/redeem`.
2511#[derive(Debug, Deserialize)]
2512struct RedeemForm {
2513    code: String,
2514}
2515
2516/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
2517/// already full we render the "capacity full" variant (no form).
2518async fn beta_redeem_form(State(state): State<AppState>) -> Response {
2519    let full = store::count_beta_access(&state.db)
2520        .await
2521        .map(|n| n >= state.config.beta_cap)
2522        .unwrap_or(false);
2523    render(&BetaRedeemTemplate {
2524        repo_url: REPO_URL,
2525        error: String::new(),
2526        capacity_full: full,
2527    })
2528}
2529
2530/// `POST /beta/redeem` — the **pre-handshake** reservation.
2531///
2532/// Validates the pasted code is *redeemable right now* (exists, active,
2533/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
2534/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
2535/// reserving intent to redeem this code, then sends the visitor to `/login`. The
2536/// OAuth callback later binds the verified DID and atomically consumes the code
2537/// (`store::redeem_code`). This ordering means a non-invited visitor can never
2538/// start OAuth (and burn a sidecar handshake).
2539async fn beta_redeem_submit(
2540    State(state): State<AppState>,
2541    Form(form): Form<RedeemForm>,
2542) -> Response {
2543    let code = form.code.trim().to_uppercase();
2544    if code.is_empty() {
2545        return render(&BetaRedeemTemplate {
2546            repo_url: REPO_URL,
2547            error: "Enter your invite code.".to_string(),
2548            capacity_full: false,
2549        });
2550    }
2551
2552    match preflight_code(&state, &code).await {
2553        Ok(()) => {
2554            let cookie = sign_invite(&code, &state.config.cookie_secret);
2555            let mut resp = Redirect::to("/login").into_response();
2556            set_cookie(&mut resp, &cookie);
2557            info!("invite code preflight OK; reserving intent + redirecting to /login");
2558            resp
2559        }
2560        Err(policy) => {
2561            warn!(?policy, "invite code preflight rejected");
2562            redeem_bounce(&policy)
2563        }
2564    }
2565}
2566
2567/// Read-only preflight of an invite code for the pre-handshake reservation:
2568/// verify it exists, is active, is not past `expires_at`, and that a seat is
2569/// free — mirroring the checks `store::redeem_code` will re-run atomically at
2570/// callback time. Does NOT consume the code or grant a seat. Returns the same
2571/// typed [`store::RedeemError`] variants so the two paths share one message map.
2572async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
2573    // Cap check first: a clear "capacity full" beats "code invalid" when both.
2574    let count = store::count_beta_access(&state.db).await.unwrap_or(0);
2575    if count >= state.config.beta_cap {
2576        return Err(store::RedeemError::CapacityFull);
2577    }
2578    // Look up the code's current status + expiry (read-only).
2579    let row = sqlx::query_as::<_, (String, i64)>(
2580        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
2581    )
2582    .bind(code)
2583    .fetch_optional(&state.db)
2584    .await
2585    .ok()
2586    .flatten();
2587    let (status, expires_at) = match row {
2588        Some(r) => r,
2589        None => return Err(store::RedeemError::NotFound),
2590    };
2591    let now = chrono::Utc::now().timestamp();
2592    match status.as_str() {
2593        "active" if expires_at >= now => Ok(()),
2594        "active" => Err(store::RedeemError::Expired),
2595        "expired" => Err(store::RedeemError::Expired),
2596        // "redeemed" or anything else non-active.
2597        _ => Err(store::RedeemError::AlreadyRedeemed),
2598    }
2599}
2600
2601/// Map a [`store::RedeemError`] to the invite page with the right message. Used
2602/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
2603fn redeem_bounce(policy: &store::RedeemError) -> Response {
2604    use store::RedeemError::*;
2605    let (msg, capacity_full) = match policy {
2606        NotFound => ("That invite code isn't valid.", false),
2607        Expired => ("That invite code has expired.", false),
2608        AlreadyRedeemed => ("That invite code has already been used.", false),
2609        CapacityFull => ("", true),
2610    };
2611    render(&BetaRedeemTemplate {
2612        repo_url: REPO_URL,
2613        error: msg.to_string(),
2614        capacity_full,
2615    })
2616}
2617
2618/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
2619#[derive(Debug, Deserialize, Default)]
2620struct MintQuery {
2621    #[serde(default)]
2622    n: Option<u32>,
2623}
2624
2625/// `POST /admin/invites?n=N` — mint N invite codes.
2626///
2627/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
2628/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
2629/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
2630async fn admin_mint_invites(
2631    State(state): State<AppState>,
2632    headers: HeaderMap,
2633    Query(q): Query<MintQuery>,
2634) -> Response {
2635    // Require a real, current session (not just a DID string) whose DID is an
2636    // admin-seed DID. `current_did` already re-checks the beta gate.
2637    let did = match current_did(&state, &headers).await {
2638        Some(d) => d,
2639        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
2640    };
2641    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
2642        warn!(%did, "admin mint denied: not an admin-seed DID");
2643        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
2644    }
2645
2646    let n = q.n.unwrap_or(1).clamp(1, 100);
2647    let mut codes = Vec::with_capacity(n as usize);
2648    for _ in 0..n {
2649        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
2650            Ok(code) => codes.push(code),
2651            Err(err) => {
2652                warn!(%err, %did, "admin mint_code failed");
2653                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2654            }
2655        }
2656    }
2657    info!(%did, count = codes.len(), "admin minted invite codes");
2658    let mut body = codes.join("\n");
2659    body.push('\n');
2660    (StatusCode::OK, body).into_response()
2661}
2662
2663// ---------------------------------------------------------------------------
2664// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
2665// ---------------------------------------------------------------------------
2666
2667/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
2668/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
2669/// itself (base64url) rather than an opaque sid, since the code IS the reserved
2670/// intent the callback consumes.
2671fn sign_invite(code: &str, secret: &str) -> String {
2672    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
2673}
2674
2675/// Verify + read the reserved invite code out of the request's invite cookie
2676/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
2677/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
2678/// authority on the code's live status.
2679fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
2680    cookie::verify_value(headers, INVITE_COOKIE, secret)
2681}
2682
2683/// Clear the invite cookie on a response (after a successful bind, or when the
2684/// reservation turned out to be stale).
2685fn clear_invite_cookie(resp: &mut Response) {
2686    set_cookie(
2687        resp,
2688        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2689    );
2690}
2691
2692// ---------------------------------------------------------------------------
2693// OPML import + export
2694// ---------------------------------------------------------------------------
2695
2696/// `POST /opml` — import subscriptions from an OPML document.
2697///
2698/// Accepts either a multipart file upload (field `file`) or a pasted textarea
2699/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
2700/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
2701/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
2702/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
2703/// they show immediately; polling is left to the background poller.
2704async fn import_opml(
2705    State(state): State<AppState>,
2706    headers: HeaderMap,
2707    mut multipart: Multipart,
2708) -> Result<Response, WebError> {
2709    let did = match current_did(&state, &headers).await {
2710        Some(d) => d,
2711        None => return Ok(Redirect::to("/login").into_response()),
2712    };
2713    let pool = &state.db;
2714
2715    // Collect the OPML text from whichever field carried it. Multipart errors
2716    // are mapped to their axum-native response so that an over-cap upload (the
2717    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
2718    // `413 Payload Too Large` rather than being swallowed by the blanket
2719    // `WebError` → `500` conversion.
2720    let mut opml_text = String::new();
2721    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
2722        let name = field.name().unwrap_or("").to_string();
2723        if name == "opml" || name == "file" {
2724            let bytes = field.bytes().await.map_err(multipart_response)?;
2725            if !bytes.is_empty() {
2726                opml_text = String::from_utf8_lossy(&bytes).into_owned();
2727                if name == "file" {
2728                    break;
2729                }
2730            }
2731        }
2732    }
2733
2734    let feeds = opml::parse_opml(&opml_text).unwrap_or_default();
2735    if feeds.is_empty() {
2736        info!(%did, "OPML import found no feeds");
2737        return Ok(
2738            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
2739                .into_response(),
2740        );
2741    }
2742
2743    // Create any named folders first, mapping folder name → at:// URI so
2744    // subscriptions can reference them.
2745    let now = now_rfc3339();
2746    let mut folder_uris: std::collections::HashMap<String, String> =
2747        std::collections::HashMap::new();
2748    // Reuse existing folders where the name already exists.
2749    if let Ok(existing) = state.sidecar.list_folders_sorted(&did).await {
2750        for (rkey, folder) in existing {
2751            folder_uris
2752                .entry(folder.name.clone())
2753                .or_insert_with(|| folder_uri(&did, &rkey));
2754        }
2755    }
2756    let mut wanted_folders: Vec<String> = feeds
2757        .iter()
2758        .filter_map(|f| f.folder.clone())
2759        .filter(|n| !n.is_empty())
2760        .collect();
2761    wanted_folders.sort();
2762    wanted_folders.dedup();
2763    for name in wanted_folders {
2764        if folder_uris.contains_key(&name) {
2765            continue;
2766        }
2767        let folder = Folder::new(name.clone(), now.clone());
2768        match state.sidecar.add_folder(&did, &folder).await {
2769            Ok(rkey) => {
2770                folder_uris.insert(name, folder_uri(&did, &rkey));
2771            }
2772            Err(err) => warn!(%err, %did, "OPML folder create failed"),
2773        }
2774    }
2775
2776    // Build one subscription record per PUBLIC feed + upsert the local cache row.
2777    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
2778    // and reported back to the user — the same public-feeds-only stance as the
2779    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
2780    // token onto the public network either.
2781    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
2782    // the remaining headroom (cap − existing) once; public feeds beyond it are
2783    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
2784    let sub_cap = state.config.max_subs_per_did;
2785    let mut headroom: Option<i64> = if sub_cap > 0 {
2786        let existing = store::count_subscriptions_for_did(pool, &did)
2787            .await
2788            .unwrap_or(0);
2789        Some((sub_cap - existing).max(0))
2790    } else {
2791        None
2792    };
2793    let mut trimmed_over_cap: usize = 0;
2794
2795    // Global feeds ceiling: an OPML import must not blow past the shared cache
2796    // ceiling any more than the single-add path may. Seed the remaining global
2797    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
2798    // not already cached) consumes it. Existing/duplicate URLs add no row and
2799    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
2800    // `<= 0` disables the ceiling.
2801    let feeds_cap = state.config.max_feeds_global;
2802    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
2803        let existing = store::count_feeds(pool).await.unwrap_or(0);
2804        Some((feeds_cap - existing).max(0))
2805    } else {
2806        None
2807    };
2808    let mut trimmed_over_global: usize = 0;
2809
2810    let mut subs = Vec::with_capacity(feeds.len());
2811    let mut skipped_private: Vec<String> = Vec::new();
2812    for f in &feeds {
2813        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
2814            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
2815            // Report by title where we have one, else the (public-safe) host.
2816            let label = f
2817                .title
2818                .clone()
2819                .filter(|t| !t.trim().is_empty())
2820                .unwrap_or_else(|| private_feed_label(&f.feed_url));
2821            skipped_private.push(label);
2822            continue;
2823        }
2824
2825        // Over-cap: stop importing once headroom is exhausted (count the rest so
2826        // we can tell the user how many were dropped).
2827        if let Some(h) = headroom.as_mut() {
2828            if *h <= 0 {
2829                trimmed_over_cap += 1;
2830                continue;
2831            }
2832        }
2833
2834        // Global ceiling: a brand-new feed URL consumes global headroom. Once
2835        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
2836        // free — they add no row). Checked before decrementing the per-DID
2837        // headroom so a dropped feed doesn't burn the caller's own quota.
2838        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
2839            Ok(existing) => existing.is_none(),
2840            // On a lookup error, treat as existing (don't consume global
2841            // headroom) but still allow the upsert to proceed.
2842            Err(err) => {
2843                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
2844                false
2845            }
2846        };
2847        if is_new {
2848            if let Some(g) = global_headroom.as_mut() {
2849                if *g <= 0 {
2850                    trimmed_over_global += 1;
2851                    continue;
2852                }
2853                *g -= 1;
2854            }
2855        }
2856
2857        // Passed both caps: consume the per-DID headroom now that the feed is
2858        // actually being imported.
2859        if let Some(h) = headroom.as_mut() {
2860            *h -= 1;
2861        }
2862
2863        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
2864        sub.title = f.title.clone();
2865        sub.site_url = f.site_url.clone();
2866        sub.folder = f
2867            .folder
2868            .as_ref()
2869            .and_then(|name| folder_uris.get(name).cloned());
2870        subs.push(sub);
2871        let _ = store::upsert_feed(
2872            pool,
2873            &store::NewFeed {
2874                url: f.feed_url.clone(),
2875                title: f.title.clone(),
2876                site_url: f.site_url.clone(),
2877                ..Default::default()
2878            },
2879        )
2880        .await;
2881    }
2882
2883    match state.sidecar.add_subscriptions_bulk(&did, &subs).await {
2884        Ok(rkeys) => {
2885            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)")
2886        }
2887        Err(err) => warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)"),
2888    }
2889
2890    // Report the import count, plus any private/paid feeds skipped as unsupported.
2891    let mut flash = format!("Imported {} feeds", subs.len());
2892    if trimmed_over_cap > 0 {
2893        flash.push_str(&format!(
2894            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
2895        ));
2896    }
2897    if trimmed_over_global > 0 {
2898        flash.push_str(&format!(
2899            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
2900        ));
2901    }
2902    if !skipped_private.is_empty() {
2903        flash.push_str(&format!(
2904            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
2905            skipped_private.len(),
2906            skipped_private.join(", ")
2907        ));
2908    }
2909    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
2910}
2911
2912/// A public-safe label for a skipped private feed when it has no title: just the
2913/// host, so we never echo the secret-bearing path/query back to the user.
2914fn private_feed_label(url: &str) -> String {
2915    url::Url::parse(url)
2916        .ok()
2917        .and_then(|u| u.host_str().map(str::to_string))
2918        .unwrap_or_else(|| "a private feed".to_string())
2919}
2920
2921/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
2922async fn export_opml(
2923    State(state): State<AppState>,
2924    headers: HeaderMap,
2925) -> Result<Response, WebError> {
2926    let did = match current_did(&state, &headers).await {
2927        Some(d) => d,
2928        None => return Ok(Redirect::to("/login").into_response()),
2929    };
2930
2931    let subs = state
2932        .sidecar
2933        .list_subscriptions_sorted(&did)
2934        .await
2935        .unwrap_or_default();
2936    let folders = state
2937        .sidecar
2938        .list_folders_sorted(&did)
2939        .await
2940        .unwrap_or_default();
2941    // The exporter matches a subscription's `folder` at-uri against the folder's
2942    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
2943    let folder_pairs: Vec<(String, Folder)> = folders
2944        .into_iter()
2945        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
2946        .collect();
2947
2948    let body = opml::to_opml(&subs, &folder_pairs);
2949    let mut resp = (StatusCode::OK, body).into_response();
2950    resp.headers_mut().insert(
2951        header::CONTENT_TYPE,
2952        "text/x-opml; charset=utf-8".parse().unwrap(),
2953    );
2954    resp.headers_mut().insert(
2955        header::CONTENT_DISPOSITION,
2956        "attachment; filename=\"featherreader-subscriptions.opml\""
2957            .parse()
2958            .unwrap(),
2959    );
2960    Ok(resp)
2961}
2962
2963// ---------------------------------------------------------------------------
2964// Signed session cookie (HMAC-SHA256, dependency-free)
2965// ---------------------------------------------------------------------------
2966
2967/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
2968fn set_cookie(resp: &mut Response, cookie: &str) {
2969    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
2970        resp.headers_mut()
2971            .append(axum::http::header::SET_COOKIE, value);
2972    }
2973}
2974
2975/// Whether the request came from htmx (the `HX-Request` header).
2976fn is_htmx(headers: &HeaderMap) -> bool {
2977    headers
2978        .get("HX-Request")
2979        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
2980}
2981
2982/// Whether a mark-read / star request originated from the single-entry READER
2983/// (as opposed to the list view). The reader's forms tag themselves with
2984/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
2985/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
2986/// isn't in the DOM), the list gets the row (`entry_row.html`).
2987fn is_reader_request(headers: &HeaderMap) -> bool {
2988    headers
2989        .get("X-FR-Reader")
2990        .is_some_and(|v| v.as_bytes() == b"1")
2991}
2992
2993/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
2994/// server-minted **session id** (never the DID — so the cookie can't be forged
2995/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
2996/// server-side session id).
2997mod cookie {
2998    use super::{HeaderMap, SESSION_COOKIE};
2999
3000    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
3001    pub fn sign_session(sid: &str, secret: &str) -> String {
3002        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
3003    }
3004
3005    /// Verify the request's session cookie and return the session id it carries.
3006    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
3007        verify_value(headers, SESSION_COOKIE, secret)
3008    }
3009
3010    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
3011    /// value`), so a signature minted for one cookie can't verify under another —
3012    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
3013    /// The NUL separator can't appear in a cookie name, so the encoding is
3014    /// unambiguous.
3015    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
3016        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
3017        msg.extend_from_slice(name.as_bytes());
3018        msg.push(0);
3019        msg.extend_from_slice(value.as_bytes());
3020        msg
3021    }
3022
3023    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
3024    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
3025    /// generic form behind both the session cookie and the short-lived invite
3026    /// cookie; domain-separating by name keeps a signature valid only for the
3027    /// cookie it was minted for.
3028    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
3029        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
3030        let b64 = b64url_encode(value.as_bytes());
3031        format!(
3032            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
3033        )
3034    }
3035
3036    /// Verify + read a value out of the named signed cookie (`None` on absent /
3037    /// tampered / forged / cross-cookie). The generic form behind both readers.
3038    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
3039        let raw = cookie_value(headers, name)?;
3040        let (b64, sig) = raw.split_once('.')?;
3041        let bytes = b64url_decode(b64)?;
3042        let value = String::from_utf8(bytes).ok()?;
3043        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
3044        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
3045            Some(value)
3046        } else {
3047            None
3048        }
3049    }
3050
3051    /// Pull one cookie value out of the `Cookie` request header.
3052    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
3053        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
3054        for part in header.split(';') {
3055            let part = part.trim();
3056            if let Some((k, v)) = part.split_once('=') {
3057                if k == name {
3058                    return Some(v.to_string());
3059                }
3060            }
3061        }
3062        None
3063    }
3064
3065    /// Constant-time byte comparison (avoid signature-timing leaks).
3066    fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
3067        if a.len() != b.len() {
3068            return false;
3069        }
3070        let mut diff = 0u8;
3071        for (x, y) in a.iter().zip(b.iter()) {
3072            diff |= x ^ y;
3073        }
3074        diff == 0
3075    }
3076
3077    // -- URL-safe base64 (no padding), std-only --------------------------------
3078
3079    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3080
3081    fn b64url_encode(input: &[u8]) -> String {
3082        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
3083        for chunk in input.chunks(3) {
3084            let b = [
3085                chunk[0],
3086                *chunk.get(1).unwrap_or(&0),
3087                *chunk.get(2).unwrap_or(&0),
3088            ];
3089            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
3090            out.push(B64[((n >> 18) & 63) as usize] as char);
3091            out.push(B64[((n >> 12) & 63) as usize] as char);
3092            if chunk.len() > 1 {
3093                out.push(B64[((n >> 6) & 63) as usize] as char);
3094            }
3095            if chunk.len() > 2 {
3096                out.push(B64[(n & 63) as usize] as char);
3097            }
3098        }
3099        out
3100    }
3101
3102    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
3103        fn val(c: u8) -> Option<u32> {
3104            match c {
3105                b'A'..=b'Z' => Some((c - b'A') as u32),
3106                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
3107                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
3108                b'-' => Some(62),
3109                b'_' => Some(63),
3110                _ => None,
3111            }
3112        }
3113        let bytes = input.as_bytes();
3114        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
3115        for chunk in bytes.chunks(4) {
3116            let mut n = 0u32;
3117            let mut valid = 0;
3118            for (i, &c) in chunk.iter().enumerate() {
3119                n |= val(c)? << (18 - 6 * i);
3120                valid += 1;
3121            }
3122            out.push((n >> 16) as u8);
3123            if valid > 2 {
3124                out.push((n >> 8) as u8);
3125            }
3126            if valid > 3 {
3127                out.push(n as u8);
3128            }
3129        }
3130        Some(out)
3131    }
3132
3133    // -- HMAC-SHA256, std-only -------------------------------------------------
3134
3135    /// HMAC-SHA256(key, msg) as lowercase hex.
3136    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
3137        const BLOCK: usize = 64;
3138        let mut k = [0u8; BLOCK];
3139        if key.len() > BLOCK {
3140            let d = sha256(key);
3141            k[..32].copy_from_slice(&d);
3142        } else {
3143            k[..key.len()].copy_from_slice(key);
3144        }
3145        let mut ipad = [0x36u8; BLOCK];
3146        let mut opad = [0x5cu8; BLOCK];
3147        for i in 0..BLOCK {
3148            ipad[i] ^= k[i];
3149            opad[i] ^= k[i];
3150        }
3151        let mut inner = Vec::with_capacity(BLOCK + msg.len());
3152        inner.extend_from_slice(&ipad);
3153        inner.extend_from_slice(msg);
3154        let inner_hash = sha256(&inner);
3155        let mut outer = Vec::with_capacity(BLOCK + 32);
3156        outer.extend_from_slice(&opad);
3157        outer.extend_from_slice(&inner_hash);
3158        let mac = sha256(&outer);
3159        let mut hex = String::with_capacity(64);
3160        for b in mac {
3161            hex.push_str(&format!("{b:02x}"));
3162        }
3163        hex
3164    }
3165
3166    /// SHA-256 (FIPS 180-4), std-only.
3167    fn sha256(data: &[u8]) -> [u8; 32] {
3168        const K: [u32; 64] = [
3169            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
3170            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
3171            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
3172            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
3173            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
3174            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
3175            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
3176            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
3177            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
3178            0xc67178f2,
3179        ];
3180        let mut h: [u32; 8] = [
3181            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
3182            0x5be0cd19,
3183        ];
3184
3185        let bit_len = (data.len() as u64) * 8;
3186        let mut msg = data.to_vec();
3187        msg.push(0x80);
3188        while msg.len() % 64 != 56 {
3189            msg.push(0);
3190        }
3191        msg.extend_from_slice(&bit_len.to_be_bytes());
3192
3193        for block in msg.chunks(64) {
3194            let mut w = [0u32; 64];
3195            for i in 0..16 {
3196                w[i] = u32::from_be_bytes([
3197                    block[i * 4],
3198                    block[i * 4 + 1],
3199                    block[i * 4 + 2],
3200                    block[i * 4 + 3],
3201                ]);
3202            }
3203            for i in 16..64 {
3204                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
3205                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
3206                w[i] = w[i - 16]
3207                    .wrapping_add(s0)
3208                    .wrapping_add(w[i - 7])
3209                    .wrapping_add(s1);
3210            }
3211            let mut a = h;
3212            for i in 0..64 {
3213                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
3214                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
3215                let t1 = a[7]
3216                    .wrapping_add(s1)
3217                    .wrapping_add(ch)
3218                    .wrapping_add(K[i])
3219                    .wrapping_add(w[i]);
3220                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
3221                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
3222                let t2 = s0.wrapping_add(maj);
3223                a[7] = a[6];
3224                a[6] = a[5];
3225                a[5] = a[4];
3226                a[4] = a[3].wrapping_add(t1);
3227                a[3] = a[2];
3228                a[2] = a[1];
3229                a[1] = a[0];
3230                a[0] = t1.wrapping_add(t2);
3231            }
3232            for i in 0..8 {
3233                h[i] = h[i].wrapping_add(a[i]);
3234            }
3235        }
3236
3237        let mut out = [0u8; 32];
3238        for (i, word) in h.iter().enumerate() {
3239            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
3240        }
3241        out
3242    }
3243
3244    #[cfg(test)]
3245    mod tests {
3246        use super::*;
3247
3248        #[test]
3249        fn sha256_known_vector() {
3250            let d = sha256(b"abc");
3251            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
3252            assert_eq!(
3253                hex,
3254                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
3255            );
3256        }
3257
3258        #[test]
3259        fn hmac_known_vector() {
3260            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
3261            assert_eq!(
3262                mac,
3263                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
3264            );
3265        }
3266
3267        #[test]
3268        fn sign_verify_round_trips() {
3269            let secret = "test-secret";
3270            let sid = "9f2c-opaque-session-id";
3271            let cookie = sign_session(sid, secret);
3272            let pair = cookie.split(';').next().unwrap().to_string();
3273            let mut headers = HeaderMap::new();
3274            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
3275            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
3276            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
3277            assert!(verify_session(&headers, "other-secret").is_none());
3278        }
3279
3280        #[test]
3281        fn forged_and_tampered_cookies_are_rejected() {
3282            let secret = "test-secret";
3283
3284            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
3285            //    the secret, so an arbitrary signature must not verify.
3286            let forged = format!(
3287                "{SESSION_COOKIE}={}.{}",
3288                b64url_encode(b"attacker-chosen-sid"),
3289                "deadbeef".repeat(8) // 64 hex chars, wrong sig
3290            );
3291            let mut headers = HeaderMap::new();
3292            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
3293            assert!(verify_session(&headers, secret).is_none());
3294
3295            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
3296            //    keeping the original signature — must not verify.
3297            let cookie = sign_session("real-sid", secret);
3298            let pair = cookie.split(';').next().unwrap();
3299            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
3300            let tampered = format!(
3301                "{SESSION_COOKIE}={}.{}",
3302                b64url_encode(b"different-sid"),
3303                sig
3304            );
3305            let mut headers2 = HeaderMap::new();
3306            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
3307            assert!(verify_session(&headers2, secret).is_none());
3308        }
3309
3310        #[test]
3311        fn b64url_round_trips() {
3312            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
3313                let enc = b64url_encode(s.as_bytes());
3314                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
3315            }
3316        }
3317    }
3318}
3319
3320// ---------------------------------------------------------------------------
3321// Small store helpers local to the web layer
3322// ---------------------------------------------------------------------------
3323
3324/// Fetch a single cached entry by id.
3325/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
3326///
3327/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
3328/// `did` does not subscribe to its feed. This is the per-DID read gate for the
3329/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
3330/// deduped by URL, but no DID can read another DID's cached article.
3331async fn get_entry_by_id(
3332    pool: &store::Pool,
3333    did: &str,
3334    id: i64,
3335) -> anyhow::Result<Option<store::Entry>> {
3336    let entry = sqlx::query_as::<_, store::Entry>(
3337        r#"
3338        SELECT e.* FROM entries e
3339        WHERE e.id = ?2
3340          AND EXISTS (
3341              SELECT 1 FROM sub_ref sr
3342              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3343          )
3344        "#,
3345    )
3346    .bind(did)
3347    .bind(id)
3348    .fetch_optional(pool)
3349    .await?;
3350    Ok(entry)
3351}
3352
3353/// Whether `entry_id` is marked read for `did` (absent state row = unread).
3354async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3355    let read: Option<bool> =
3356        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3357            .bind(did)
3358            .bind(entry_id)
3359            .fetch_optional(pool)
3360            .await?
3361            .flatten();
3362    Ok(read.unwrap_or(false))
3363}
3364
3365/// Whether `entry_id` is starred for `did` (absent state row = not starred).
3366async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3367    let starred: Option<bool> =
3368        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3369            .bind(did)
3370            .bind(entry_id)
3371            .fetch_optional(pool)
3372            .await?
3373            .flatten();
3374    Ok(starred.unwrap_or(false))
3375}
3376
3377/// Feed display title for one entry's feed id (via a single lookup).
3378async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
3379    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
3380        .bind(feed_id)
3381        .fetch_optional(pool)
3382        .await
3383    {
3384        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
3385        _ => String::new(),
3386    }
3387}
3388
3389/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
3390/// be forced (mark-read path) or looked up (`None` — star path).
3391async fn build_entry_row(
3392    pool: &store::Pool,
3393    did: &str,
3394    id: i64,
3395    read: Option<bool>,
3396) -> anyhow::Result<Option<EntryRow>> {
3397    let entry = match get_entry_by_id(pool, did, id).await? {
3398        Some(e) => e,
3399        None => return Ok(None),
3400    };
3401    let read = match read {
3402        Some(r) => r,
3403        None => entry_is_read(pool, did, id).await?,
3404    };
3405    let starred = entry_is_starred(pool, did, id).await?;
3406    Ok(Some(EntryRow {
3407        id: entry.id,
3408        title: entry
3409            .title
3410            .clone()
3411            .filter(|t| !t.trim().is_empty())
3412            .unwrap_or_else(|| "(untitled)".to_string()),
3413        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
3414        published: display_date(entry.published.as_deref()),
3415        read,
3416        starred,
3417        link: format!("/entries/{id}"),
3418    }))
3419}
3420
3421/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
3422fn now_rfc3339() -> String {
3423    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3424}
3425
3426#[cfg(test)]
3427mod tests {
3428    use super::*;
3429
3430    #[test]
3431    fn qenc_encodes_reserved() {
3432        assert_eq!(qenc("a b"), "a%20b");
3433        assert_eq!(
3434            qenc("https://example.com/feed.xml"),
3435            "https%3A%2F%2Fexample.com%2Ffeed.xml"
3436        );
3437        assert_eq!(
3438            qenc("at://did:plc:x/c/r"),
3439            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
3440        );
3441        // Unreserved chars pass through untouched.
3442        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
3443    }
3444
3445    #[test]
3446    fn folder_uri_shape() {
3447        assert_eq!(
3448            folder_uri("did:plc:abc", "3kfolder"),
3449            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
3450        );
3451    }
3452
3453    // -- public-feeds-only: private/paid feeds are refused --------------------
3454
3455    #[test]
3456    fn private_feeds_are_classified_private_across_providers() {
3457        // The add + OPML paths both gate on this classifier; assert it flags a
3458        // spread of paid providers (newsletters + private podcasts) and the
3459        // generic credential-in-URL shapes.
3460        for url in [
3461            "https://author.substack.com/feed/private/deadbeefcafe1234",
3462            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
3463            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
3464            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
3465            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
3466            "https://user:pass@example.com/feed",
3467        ] {
3468            assert!(
3469                feed::classify_feed_privacy(url).is_private(),
3470                "expected private: {url}"
3471            );
3472        }
3473    }
3474
3475    #[test]
3476    fn public_feeds_stay_public() {
3477        for url in [
3478            "https://author.substack.com/feed",
3479            "https://wordpress.example.com/feed/",
3480            "https://example.com/rss.xml",
3481            "https://example.org/atom.xml",
3482            // YouTube channel/playlist RSS is fully public — must not false-block.
3483            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
3484            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
3485        ] {
3486            assert!(
3487                !feed::classify_feed_privacy(url).is_private(),
3488                "expected public: {url}"
3489            );
3490        }
3491    }
3492
3493    #[test]
3494    fn private_feed_label_is_public_safe_host_only() {
3495        // The OPML skip report must never echo the secret path/query, only the host.
3496        let label =
3497            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
3498        assert_eq!(label, "author.substack.com");
3499        assert!(!label.contains("deadbeefcafe1234token"));
3500        assert!(!label.contains("/private/"));
3501        // An unparseable URL degrades to a generic label.
3502        assert_eq!(private_feed_label("not a url"), "a private feed");
3503    }
3504
3505    #[test]
3506    fn refusal_message_promises_nothing_stored() {
3507        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
3508        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
3509    }
3510
3511    #[test]
3512    fn scope_query_preserves_context() {
3513        let q = EntryQuery {
3514            feed: Some("https://example.com/feed.xml".to_string()),
3515            folder: None,
3516            view: Some("all".to_string()),
3517        };
3518        let s = scope_query(&q);
3519        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
3520        assert!(s.contains("view=all"));
3521
3522        // Default view is omitted.
3523        let q2 = EntryQuery {
3524            feed: None,
3525            folder: None,
3526            view: Some("unread".to_string()),
3527        };
3528        assert_eq!(scope_query(&q2), "");
3529    }
3530
3531    // -- closed-beta invite gate + rate-limit + cache-control ------------------
3532
3533    use axum::body::Body;
3534    use axum::http::Request;
3535    use tower::ServiceExt; // for `oneshot`
3536
3537    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
3538    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
3539    /// can forge matching cookies.
3540    async fn test_state(allowed: &[&str]) -> AppState {
3541        let db = store::init_url("sqlite::memory:").await.unwrap();
3542        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
3543        store::ensure_seed(&db, &dids).await.unwrap();
3544        let config = Config {
3545            allowed_dids: dids,
3546            cookie_secret: "test-cookie-secret-000".to_string(),
3547            beta_cap: 3,
3548            ..Config::default()
3549        };
3550        AppState::new(config, db).unwrap()
3551    }
3552
3553    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
3554    /// looked up in the registry, so create the session first).
3555    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
3556        let sid = state.sessions.create(Session {
3557            did: did.to_string(),
3558            handle: handle.map(str::to_string),
3559        });
3560        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
3561        sc.split(';').next().unwrap().to_string()
3562    }
3563
3564    #[test]
3565    fn rate_limited_paths_match_expected() {
3566        use axum::http::Method;
3567        assert!(is_rate_limited_path("/login", &Method::GET));
3568        assert!(is_rate_limited_path("/login", &Method::POST));
3569        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
3570        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
3571        assert!(is_rate_limited_path("/opml", &Method::POST));
3572        assert!(is_rate_limited_path("/read-all", &Method::POST));
3573        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
3574        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
3575        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
3576        // Read-only navigation is NOT limited.
3577        assert!(!is_rate_limited_path("/", &Method::GET));
3578        assert!(!is_rate_limited_path("/about", &Method::GET));
3579        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
3580        assert!(!is_rate_limited_path("/login", &Method::HEAD));
3581    }
3582
3583    #[test]
3584    fn rate_limiter_allows_burst_then_429s() {
3585        let rl = RateLimiter::shared();
3586        let ip: IpAddr = "203.0.113.7".parse().unwrap();
3587        // The full burst passes.
3588        for _ in 0..(RATE_BURST as usize) {
3589            assert!(rl.check(ip));
3590        }
3591        // The next one (no time elapsed → no refill) is rejected.
3592        assert!(!rl.check(ip));
3593        // A different IP has its own bucket.
3594        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
3595        assert!(rl.check(ip2));
3596    }
3597
3598    #[test]
3599    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
3600        // With NO trusted header configured, a client-supplied X-Forwarded-For
3601        // must be ignored entirely — the limiter keys on the real socket peer,
3602        // so an attacker can't mint a fresh bucket per forged XFF value.
3603        let mut h = HeaderMap::new();
3604        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
3605        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
3606        assert_eq!(
3607            client_ip(&h, Some(&sock), None),
3608            Some("203.0.113.55".parse().unwrap()),
3609            "spoofed XFF must not override the socket peer"
3610        );
3611    }
3612
3613    #[test]
3614    fn client_ip_uses_trusted_header_last_hop() {
3615        // With a trusted proxy header configured, the client IP comes from THAT
3616        // header (the proxy overwrites any client copy). On a comma list we take
3617        // the RIGHT-most hop — the one the trusted proxy appended — so a
3618        // client-forged left-most value is ignored.
3619        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
3620
3621        let mut h = HeaderMap::new();
3622        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
3623        assert_eq!(
3624            client_ip(&h, Some(&sock), Some("fly-client-ip")),
3625            Some("198.51.100.9".parse().unwrap())
3626        );
3627
3628        // Attacker prepends a forged hop; the trusted proxy appends the real one.
3629        let mut h2 = HeaderMap::new();
3630        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
3631        assert_eq!(
3632            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
3633            Some("198.51.100.9".parse().unwrap()),
3634            "must take the right-most (trusted) hop, not the forged left-most"
3635        );
3636
3637        // Trusted header absent → fall back to the socket peer.
3638        let h3 = HeaderMap::new();
3639        assert_eq!(
3640            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
3641            Some("10.0.0.1".parse().unwrap())
3642        );
3643    }
3644
3645    #[test]
3646    fn invite_cookie_round_trips_and_rejects_tamper() {
3647        let secret = "test-cookie-secret-000";
3648        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
3649        let pair = sc.split(';').next().unwrap();
3650        let mut h = HeaderMap::new();
3651        h.insert(header::COOKIE, pair.parse().unwrap());
3652        assert_eq!(
3653            invite_cookie_code(&h, secret).as_deref(),
3654            Some("FEATHER-ABCDWXYZ")
3655        );
3656        // Wrong secret → rejected.
3657        assert!(invite_cookie_code(&h, "other").is_none());
3658    }
3659
3660    #[tokio::test]
3661    async fn preflight_valid_expired_and_full() {
3662        let state = test_state(&["did:plc:admin"]).await;
3663        // A minted, active code preflights OK.
3664        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
3665            .await
3666            .unwrap();
3667        assert!(preflight_code(&state, &code).await.is_ok());
3668
3669        // A code whose expiry is in the past preflights as Expired. (mint_code
3670        // clamps negative ttl to 0, so back-date the row directly for a
3671        // deterministic past expiry.)
3672        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
3673            .await
3674            .unwrap();
3675        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
3676            .bind(chrono::Utc::now().timestamp() - 3600)
3677            .bind(&expired)
3678            .execute(&state.db)
3679            .await
3680            .unwrap();
3681        assert_eq!(
3682            preflight_code(&state, &expired).await,
3683            Err(store::RedeemError::Expired)
3684        );
3685
3686        // Unknown code → NotFound.
3687        assert_eq!(
3688            preflight_code(&state, "FEATHER-NOPENOPE").await,
3689            Err(store::RedeemError::NotFound)
3690        );
3691
3692        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
3693        // must report CapacityFull.
3694        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
3695            .await
3696            .unwrap();
3697        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
3698            .await
3699            .unwrap();
3700        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
3701        assert_eq!(
3702            preflight_code(&state, &code).await,
3703            Err(store::RedeemError::CapacityFull)
3704        );
3705    }
3706
3707    #[tokio::test]
3708    async fn login_without_invite_redirects_to_beta_redeem() {
3709        // No allow-list seed, no invite cookie: starting OAuth must be refused.
3710        let state = test_state(&[]).await;
3711        let app = router(state);
3712        let resp = app
3713            .oneshot(
3714                Request::builder()
3715                    .method("POST")
3716                    .uri("/login")
3717                    .header("content-type", "application/x-www-form-urlencoded")
3718                    .body(Body::from("handle=alice.bsky.social"))
3719                    .unwrap(),
3720            )
3721            .await
3722            .unwrap();
3723        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3724        assert_eq!(
3725            resp.headers().get(header::LOCATION).unwrap(),
3726            "/beta/redeem"
3727        );
3728    }
3729
3730    #[tokio::test]
3731    async fn login_with_valid_invite_cookie_starts_oauth() {
3732        let state = test_state(&[]).await;
3733        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
3734        let cookie = cookie.split(';').next().unwrap().to_string();
3735        let app = router(state);
3736        let resp = app
3737            .oneshot(
3738                Request::builder()
3739                    .method("POST")
3740                    .uri("/login")
3741                    .header("content-type", "application/x-www-form-urlencoded")
3742                    .header(header::COOKIE, cookie)
3743                    .body(Body::from("handle=alice.bsky.social"))
3744                    .unwrap(),
3745            )
3746            .await
3747            .unwrap();
3748        // Redirects into the sidecar login (not to /beta/redeem).
3749        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3750        let loc = resp
3751            .headers()
3752            .get(header::LOCATION)
3753            .unwrap()
3754            .to_str()
3755            .unwrap();
3756        assert!(loc.contains("/login"), "loc = {loc}");
3757        assert_ne!(loc, "/beta/redeem");
3758    }
3759
3760    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
3761    /// any network resolution and that a resolution failure fails closed.
3762    async fn resolver_never(_handle: String) -> Option<String> {
3763        None
3764    }
3765
3766    /// A resolver that maps every handle to `did`.
3767    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
3768        move |_handle| std::future::ready(Some(did.to_string()))
3769    }
3770
3771    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
3772    /// that already holds a seat (the seeded-admin first-login case) passes the
3773    /// gate — no session cookie, no invite code.
3774    #[tokio::test]
3775    async fn may_start_oauth_honors_seat_via_resolved_handle() {
3776        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
3777        // no cookie on a fresh deploy.
3778        let state = test_state(&["did:plc:admin"]).await;
3779        let headers = HeaderMap::new();
3780        assert!(
3781            may_start_oauth_with(
3782                &state,
3783                &headers,
3784                "admin.example",
3785                resolver_to("did:plc:admin")
3786            )
3787            .await,
3788            "a handle resolving to a seated DID must pass the gate"
3789        );
3790    }
3791
3792    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
3793    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
3794    /// fails).
3795    #[tokio::test]
3796    async fn may_start_oauth_bounces_non_member_handle() {
3797        let state = test_state(&["did:plc:admin"]).await;
3798        let headers = HeaderMap::new();
3799        assert!(
3800            !may_start_oauth_with(
3801                &state,
3802                &headers,
3803                "rando.example",
3804                resolver_to("did:plc:rando")
3805            )
3806            .await,
3807            "a resolved DID with no seat must be bounced"
3808        );
3809    }
3810
3811    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
3812    /// bounces gracefully — no panic, no handshake.
3813    #[tokio::test]
3814    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
3815        let state = test_state(&["did:plc:admin"]).await;
3816        let headers = HeaderMap::new();
3817        assert!(
3818            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
3819            "an unresolvable handle must fail closed"
3820        );
3821    }
3822
3823    /// The session-cookie fast path admits a seated member WITHOUT calling the
3824    /// resolver (proven by injecting `resolver_never`, which would otherwise
3825    /// bounce).
3826    #[tokio::test]
3827    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
3828        let state = test_state(&[]).await;
3829        let did = "did:plc:member";
3830        store::grant_access(&state.db, did, Some("member.example"), "test", None)
3831            .await
3832            .unwrap();
3833        let cookie = session_cookie(&state, did, Some("member.example"));
3834        let mut headers = HeaderMap::new();
3835        headers.insert(header::COOKIE, cookie.parse().unwrap());
3836        assert!(
3837            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
3838            "a seated session cookie must pass without resolution"
3839        );
3840    }
3841
3842    /// The invite-cookie fast path admits WITHOUT calling the resolver.
3843    #[tokio::test]
3844    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
3845        let state = test_state(&[]).await;
3846        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
3847        let cookie = cookie.split(';').next().unwrap().to_string();
3848        let mut headers = HeaderMap::new();
3849        headers.insert(header::COOKIE, cookie.parse().unwrap());
3850        assert!(
3851            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
3852            "a valid invite cookie must pass without resolution"
3853        );
3854    }
3855
3856    #[tokio::test]
3857    async fn admin_mint_requires_admin_seed_did() {
3858        let state = test_state(&["did:plc:admin"]).await;
3859        // A non-admin (but beta'd) session is forbidden.
3860        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
3861            .await
3862            .unwrap();
3863        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
3864        // An admin session is allowed.
3865        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3866        let app = router(state);
3867
3868        let forbidden = app
3869            .clone()
3870            .oneshot(
3871                Request::builder()
3872                    .method("POST")
3873                    .uri("/admin/invites?n=2")
3874                    .header(header::COOKIE, rando_cookie)
3875                    .body(Body::empty())
3876                    .unwrap(),
3877            )
3878            .await
3879            .unwrap();
3880        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
3881
3882        let ok = app
3883            .oneshot(
3884                Request::builder()
3885                    .method("POST")
3886                    .uri("/admin/invites?n=2")
3887                    .header(header::COOKIE, admin_cookie)
3888                    .body(Body::empty())
3889                    .unwrap(),
3890            )
3891            .await
3892            .unwrap();
3893        assert_eq!(ok.status(), StatusCode::OK);
3894        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
3895            .await
3896            .unwrap();
3897        let body = String::from_utf8(bytes.to_vec()).unwrap();
3898        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
3899        assert_eq!(minted.len(), 2);
3900        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
3901    }
3902
3903    #[tokio::test]
3904    async fn admin_mint_unauthenticated_is_401() {
3905        let state = test_state(&["did:plc:admin"]).await;
3906        let app = router(state);
3907        let resp = app
3908            .oneshot(
3909                Request::builder()
3910                    .method("POST")
3911                    .uri("/admin/invites")
3912                    .body(Body::empty())
3913                    .unwrap(),
3914            )
3915            .await
3916            .unwrap();
3917        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
3918    }
3919
3920    #[tokio::test]
3921    async fn cache_control_public_on_about_no_store_on_authed() {
3922        let state = test_state(&["did:plc:admin"]).await;
3923        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3924        let app = router(state);
3925
3926        // /about → public, cacheable.
3927        let about = app
3928            .clone()
3929            .oneshot(
3930                Request::builder()
3931                    .uri("/about")
3932                    .body(Body::empty())
3933                    .unwrap(),
3934            )
3935            .await
3936            .unwrap();
3937        assert_eq!(
3938            about.headers().get(header::CACHE_CONTROL).unwrap(),
3939            "public, max-age=300"
3940        );
3941        // The security headers are still intact.
3942        assert!(about.headers().contains_key("content-security-policy"));
3943        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
3944
3945        // The bare /login landing → public, cacheable.
3946        let login = app
3947            .clone()
3948            .oneshot(
3949                Request::builder()
3950                    .uri("/login")
3951                    .body(Body::empty())
3952                    .unwrap(),
3953            )
3954            .await
3955            .unwrap();
3956        assert_eq!(
3957            login.headers().get(header::CACHE_CONTROL).unwrap(),
3958            "public, max-age=300"
3959        );
3960
3961        // An authenticated page → no-store.
3962        let home = app
3963            .oneshot(
3964                Request::builder()
3965                    .uri("/")
3966                    .header(header::COOKIE, admin_cookie)
3967                    .body(Body::empty())
3968                    .unwrap(),
3969            )
3970            .await
3971            .unwrap();
3972        assert_eq!(
3973            home.headers().get(header::CACHE_CONTROL).unwrap(),
3974            "no-store"
3975        );
3976    }
3977
3978    #[tokio::test]
3979    async fn beta_redeem_page_renders() {
3980        let state = test_state(&[]).await;
3981        let app = router(state);
3982        let resp = app
3983            .oneshot(
3984                Request::builder()
3985                    .uri("/beta/redeem")
3986                    .body(Body::empty())
3987                    .unwrap(),
3988            )
3989            .await
3990            .unwrap();
3991        assert_eq!(resp.status(), StatusCode::OK);
3992        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
3993            .await
3994            .unwrap();
3995        let html = String::from_utf8(bytes.to_vec()).unwrap();
3996        assert!(html.contains("Invite code"));
3997        assert!(html.contains("/beta/redeem"));
3998    }
3999
4000    #[tokio::test]
4001    async fn rate_limit_returns_429_after_burst() {
4002        // Configure a trusted proxy header so the limiter keys on the forwarded
4003        // IP (the oneshot harness sets no ConnectInfo socket peer).
4004        let db = store::init_url("sqlite::memory:").await.unwrap();
4005        store::ensure_seed(&db, &[]).await.unwrap();
4006        let config = Config {
4007            cookie_secret: "test-cookie-secret-000".to_string(),
4008            beta_cap: 3,
4009            trusted_ip_header: Some("cf-connecting-ip".to_string()),
4010            ..Config::default()
4011        };
4012        let state = AppState::new(config, db).unwrap();
4013        let app = router(state);
4014        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
4015        // handler itself returns 200 (re-render) on a bad code; the limiter is
4016        // what eventually yields 429.
4017        let mut saw_429 = false;
4018        for _ in 0..(RATE_BURST as usize + 5) {
4019            let resp = app
4020                .clone()
4021                .oneshot(
4022                    Request::builder()
4023                        .method("POST")
4024                        .uri("/beta/redeem")
4025                        .header("content-type", "application/x-www-form-urlencoded")
4026                        .header("cf-connecting-ip", "203.0.113.200")
4027                        .body(Body::from("code=FEATHER-NOPENOPE"))
4028                        .unwrap(),
4029                )
4030                .await
4031                .unwrap();
4032            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
4033                saw_429 = true;
4034                break;
4035            }
4036        }
4037        assert!(saw_429, "expected a 429 after exhausting the burst");
4038    }
4039
4040    #[tokio::test]
4041    async fn rate_limit_ignores_spoofed_xff_rotation() {
4042        // WITHOUT a trusted header, rotating a forged X-Forwarded-For per request
4043        // must NOT mint a fresh bucket each time: the oneshot harness sets no
4044        // socket peer, so client_ip yields None and the limiter fails open —
4045        // crucially it never keys on the attacker-chosen XFF. We assert every
4046        // request is admitted (no 429), proving the forged header is not being
4047        // used as the bucket key (which would be the vulnerable behaviour only if
4048        // it *were* trusted; here the burst can't be exhausted per-IP because the
4049        // attacker can't address a single victim bucket via XFF).
4050        let state = test_state(&[]).await;
4051        let app = router(state);
4052        for i in 0..(RATE_BURST as usize + 5) {
4053            let forged = format!("10.9.8.{}", i % 250);
4054            let resp = app
4055                .clone()
4056                .oneshot(
4057                    Request::builder()
4058                        .method("POST")
4059                        .uri("/beta/redeem")
4060                        .header("content-type", "application/x-www-form-urlencoded")
4061                        .header("x-forwarded-for", forged)
4062                        .body(Body::from("code=FEATHER-NOPENOPE"))
4063                        .unwrap(),
4064                )
4065                .await
4066                .unwrap();
4067            assert_ne!(
4068                resp.status(),
4069                StatusCode::TOO_MANY_REQUESTS,
4070                "untrusted XFF must not be used as the rate-limit key"
4071            );
4072        }
4073    }
4074
4075    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
4076
4077    /// Build a `multipart/form-data` body carrying a single `file` field whose
4078    /// contents are `payload`, returning `(content_type, body_bytes)`.
4079    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
4080        let boundary = "----featherreadertestboundary";
4081        let mut body = Vec::new();
4082        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
4083        body.extend_from_slice(
4084            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
4085        );
4086        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
4087        body.extend_from_slice(payload);
4088        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
4089        (format!("multipart/form-data; boundary={boundary}"), body)
4090    }
4091
4092    #[tokio::test]
4093    async fn opml_import_oversize_upload_returns_413() {
4094        let state = test_state(&["did:plc:admin"]).await;
4095        let cookie = session_cookie(&state, "did:plc:admin", None);
4096        let app = router(state);
4097
4098        // A payload comfortably above the 2 MiB route cap.
4099        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
4100        let (content_type, body) = opml_multipart(&payload);
4101
4102        let resp = app
4103            .oneshot(
4104                Request::builder()
4105                    .method("POST")
4106                    .uri("/opml")
4107                    .header("content-type", content_type)
4108                    .header(header::COOKIE, cookie)
4109                    .body(Body::from(body))
4110                    .unwrap(),
4111            )
4112            .await
4113            .unwrap();
4114        assert_eq!(
4115            resp.status(),
4116            StatusCode::PAYLOAD_TOO_LARGE,
4117            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
4118        );
4119    }
4120
4121    #[tokio::test]
4122    async fn opml_import_under_limit_upload_is_not_413() {
4123        let state = test_state(&["did:plc:admin"]).await;
4124        let cookie = session_cookie(&state, "did:plc:admin", None);
4125        let app = router(state);
4126
4127        // A small, valid OPML well under the cap: must be accepted (the handler
4128        // redirects to `/` or a flash), i.e. never 413.
4129        let opml = br#"<?xml version="1.0"?>
4130<opml version="2.0"><body>
4131  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
4132</body></opml>"#;
4133        let (content_type, body) = opml_multipart(opml);
4134
4135        let resp = app
4136            .oneshot(
4137                Request::builder()
4138                    .method("POST")
4139                    .uri("/opml")
4140                    .header("content-type", content_type)
4141                    .header(header::COOKIE, cookie)
4142                    .body(Body::from(body))
4143                    .unwrap(),
4144            )
4145            .await
4146            .unwrap();
4147        assert_ne!(
4148            resp.status(),
4149            StatusCode::PAYLOAD_TOO_LARGE,
4150            "an under-cap OPML upload must not be rejected as too large"
4151        );
4152    }
4153
4154    #[tokio::test]
4155    async fn opml_import_logged_out_redirects_to_login() {
4156        // Logged-out callers are redirected before the body is consumed; assert
4157        // the auth short-circuit rather than a body-cap rejection.
4158        let state = test_state(&["did:plc:admin"]).await;
4159        let app = router(state);
4160
4161        let opml = b"<opml version=\"2.0\"><body></body></opml>";
4162        let (content_type, body) = opml_multipart(opml);
4163
4164        let resp = app
4165            .oneshot(
4166                Request::builder()
4167                    .method("POST")
4168                    .uri("/opml")
4169                    .header("content-type", content_type)
4170                    .body(Body::from(body))
4171                    .unwrap(),
4172            )
4173            .await
4174            .unwrap();
4175        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4176        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
4177    }
4178
4179    // -- delete-my-data (POST /account/delete) --------------------------------
4180
4181    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
4182    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
4183    /// channel) the DID it was asked to revoke. Enough to prove the delete
4184    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
4185    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
4186        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4187        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4188        let addr = listener.local_addr().unwrap();
4189        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
4190        tokio::spawn(async move {
4191            let (mut sock, _) = listener.accept().await.unwrap();
4192            let mut buf = vec![0u8; 4096];
4193            let n = sock.read(&mut buf).await.unwrap();
4194            let req = String::from_utf8_lossy(&buf[..n]).to_string();
4195            // Pull the DID out of the JSON body (last line of the request).
4196            let did = req
4197                .split("\r\n\r\n")
4198                .nth(1)
4199                .and_then(|body| {
4200                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
4201                    v.get("did")?.as_str().map(str::to_string)
4202                })
4203                .unwrap_or_default();
4204            let is_revoke = req.starts_with("POST /internal/revoke");
4205            let body = serde_json::json!({
4206                "ok": true, "did": did, "revoked": true, "hadSession": true
4207            })
4208            .to_string();
4209            let resp = format!(
4210                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
4211                body.len(),
4212                body
4213            );
4214            sock.write_all(resp.as_bytes()).await.unwrap();
4215            sock.flush().await.unwrap();
4216            let _ = tx.send(if is_revoke { did } else { String::new() });
4217        });
4218        (format!("http://{addr}"), rx)
4219    }
4220
4221    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
4222    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
4223        let db = store::init_url("sqlite::memory:").await.unwrap();
4224        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
4225        store::ensure_seed(&db, &dids).await.unwrap();
4226        let mut config = Config {
4227            allowed_dids: dids,
4228            cookie_secret: "test-cookie-secret-000".to_string(),
4229            beta_cap: 3,
4230            ..Config::default()
4231        };
4232        config.sidecar.public_url = sidecar_url.to_string();
4233        // The revoke/session/repo calls go over the internal URL; point it at the
4234        // same mock so `/internal/*` requests land there.
4235        config.sidecar.internal_url = sidecar_url.to_string();
4236        AppState::new(config, db).unwrap()
4237    }
4238
4239    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
4240    /// the sidecar revoke for that DID, and clears the session cookie.
4241    #[tokio::test]
4242    async fn account_delete_purges_rows_and_triggers_revoke() {
4243        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
4244        let did = "did:plc:leaver";
4245        let state = test_state_with_sidecar(&[], &sidecar_url).await;
4246
4247        // Seed the DID with local rows across the per-DID tables.
4248        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
4249            .await
4250            .unwrap();
4251        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
4252        store::mint_code(&state.db, did, 3600).await.unwrap();
4253        assert!(store::has_beta_access(&state.db, did).await.unwrap());
4254
4255        let cookie = session_cookie(&state, did, Some("leaver.example"));
4256        let app = router(state.clone());
4257
4258        let resp = app
4259            .oneshot(
4260                Request::builder()
4261                    .method("POST")
4262                    .uri("/account/delete")
4263                    .header(header::COOKIE, cookie)
4264                    .header("content-type", "application/x-www-form-urlencoded")
4265                    .body(Body::from("confirm=DELETE"))
4266                    .unwrap(),
4267            )
4268            .await
4269            .unwrap();
4270
4271        // Signed out: redirect to /login with the cookie cleared.
4272        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4273        assert!(resp
4274            .headers()
4275            .get(header::LOCATION)
4276            .unwrap()
4277            .to_str()
4278            .unwrap()
4279            .starts_with("/login"));
4280        let set_cookie = resp
4281            .headers()
4282            .get(header::SET_COOKIE)
4283            .unwrap()
4284            .to_str()
4285            .unwrap();
4286        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
4287
4288        // The sidecar revoke was called for exactly this DID.
4289        let revoked_did = revoke_rx.await.unwrap();
4290        assert_eq!(
4291            revoked_did, did,
4292            "sidecar revoke must fire for the caller DID"
4293        );
4294
4295        // Local rows are gone.
4296        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
4297        let codes: i64 =
4298            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
4299                .bind(did)
4300                .fetch_one(&state.db)
4301                .await
4302                .unwrap();
4303        assert_eq!(codes, 0);
4304    }
4305
4306    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
4307    /// nothing and bounces back to /manage.
4308    #[tokio::test]
4309    async fn account_delete_without_confirm_is_a_noop() {
4310        let did = "did:plc:staying";
4311        let state = test_state(&[]).await;
4312        store::grant_access(&state.db, did, None, "test", None)
4313            .await
4314            .unwrap();
4315        let cookie = session_cookie(&state, did, None);
4316        let app = router(state.clone());
4317
4318        let resp = app
4319            .oneshot(
4320                Request::builder()
4321                    .method("POST")
4322                    .uri("/account/delete")
4323                    .header(header::COOKIE, cookie)
4324                    .header("content-type", "application/x-www-form-urlencoded")
4325                    .body(Body::from("confirm=nope"))
4326                    .unwrap(),
4327            )
4328            .await
4329            .unwrap();
4330
4331        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4332        assert!(resp
4333            .headers()
4334            .get(header::LOCATION)
4335            .unwrap()
4336            .to_str()
4337            .unwrap()
4338            .starts_with("/manage"));
4339        // Nothing deleted.
4340        assert!(store::has_beta_access(&state.db, did).await.unwrap());
4341    }
4342
4343    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
4344    /// this harness — the default sidecar URL is not served), a DID must STILL
4345    /// be unable to read or mutate an entry in a feed it does not subscribe to.
4346    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
4347    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
4348    /// every cached feed.
4349    #[tokio::test]
4350    async fn pds_outage_does_not_widen_cross_did_access() {
4351        let did_a = "did:plc:aaaa";
4352        let state = test_state(&[]).await;
4353        store::grant_access(&state.db, did_a, None, "test", None)
4354            .await
4355            .unwrap();
4356
4357        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
4358        // lives in feed_b — the one A must never touch during the outage.
4359        let feed_a = store::upsert_feed(
4360            &state.db,
4361            &store::NewFeed {
4362                url: "https://a.example/feed.xml".to_string(),
4363                title: Some("A".to_string()),
4364                ..Default::default()
4365            },
4366        )
4367        .await
4368        .unwrap();
4369        let feed_b = store::upsert_feed(
4370            &state.db,
4371            &store::NewFeed {
4372                url: "https://b.example/feed.xml".to_string(),
4373                title: Some("B".to_string()),
4374                ..Default::default()
4375            },
4376        )
4377        .await
4378        .unwrap();
4379        store::insert_entries(
4380            &state.db,
4381            feed_b,
4382            &[store::NewEntry {
4383                guid: "b-1".to_string(),
4384                url: Some("https://b.example/1".to_string()),
4385                title: Some("B one".to_string()),
4386                published: Some("2026-07-11T00:00:00Z".to_string()),
4387                content_html: Some("<p>secret B body</p>".to_string()),
4388                ..Default::default()
4389            }],
4390            0,
4391        )
4392        .await
4393        .unwrap();
4394        // A subscribes ONLY to feed_a.
4395        store::replace_sub_refs(&state.db, did_a, &[feed_a])
4396            .await
4397            .unwrap();
4398        // Read B's entry id via a transient sub_ref, then drop it so only the
4399        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
4400        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
4401            .await
4402            .unwrap();
4403        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
4404            .await
4405            .unwrap()[0]
4406            .id;
4407        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
4408            .await
4409            .unwrap();
4410
4411        let cookie = session_cookie(&state, did_a, None);
4412        let app = router(state.clone());
4413
4414        // GET /entries/{b} as A → 404 even during the outage.
4415        let get_b = app
4416            .clone()
4417            .oneshot(
4418                Request::builder()
4419                    .method("GET")
4420                    .uri(format!("/entries/{b_entry_id}"))
4421                    .header(header::COOKIE, cookie.clone())
4422                    .body(Body::empty())
4423                    .unwrap(),
4424            )
4425            .await
4426            .unwrap();
4427        assert_eq!(
4428            get_b.status(),
4429            StatusCode::NOT_FOUND,
4430            "A must not read B's entry during a PDS outage"
4431        );
4432
4433        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
4434        let read_b = app
4435            .oneshot(
4436                Request::builder()
4437                    .method("POST")
4438                    .uri(format!("/entries/{b_entry_id}/read"))
4439                    .header(header::COOKIE, cookie)
4440                    .header("content-type", "application/x-www-form-urlencoded")
4441                    .body(Body::from("read=true"))
4442                    .unwrap(),
4443            )
4444            .await
4445            .unwrap();
4446        assert_eq!(
4447            read_b.status(),
4448            StatusCode::NOT_FOUND,
4449            "A must not mark B's entry read during a PDS outage"
4450        );
4451
4452        // The fallback must NOT have widened A's sub_ref to feed_b.
4453        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
4454            .bind(did_a)
4455            .fetch_all(&state.db)
4456            .await
4457            .unwrap();
4458        assert_eq!(
4459            a_feed_ids,
4460            vec![feed_a],
4461            "outage fallback must not add feeds A never subscribed to"
4462        );
4463        // And B's entry has zero read-state (A's attempt did not mutate).
4464        let es_count: i64 =
4465            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
4466                .bind(did_a)
4467                .bind(b_entry_id)
4468                .fetch_one(&state.db)
4469                .await
4470                .unwrap();
4471        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
4472    }
4473
4474    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
4475    /// seeding `did` a beta seat + session-capable state.
4476    async fn test_state_with_caps(
4477        did: &str,
4478        max_subs_per_did: i64,
4479        max_feeds_global: i64,
4480    ) -> AppState {
4481        let db = store::init_url("sqlite::memory:").await.unwrap();
4482        let config = Config {
4483            cookie_secret: "test-cookie-secret-000".to_string(),
4484            beta_cap: 100,
4485            max_subs_per_did,
4486            max_feeds_global,
4487            ..Config::default()
4488        };
4489        store::grant_access(&db, did, None, "test", None)
4490            .await
4491            .unwrap();
4492        AppState::new(config, db).unwrap()
4493    }
4494
4495    /// An OPML document with `n` distinct public feeds.
4496    fn opml_with_feeds(n: usize) -> String {
4497        let mut outlines = String::new();
4498        for i in 0..n {
4499            outlines.push_str(&format!(
4500                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
4501            ));
4502        }
4503        format!(
4504            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
4505        )
4506    }
4507
4508    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
4509    /// distinct new feeds than the shared cache can hold caches only up to the
4510    /// ceiling — the rest are trimmed. (Regression: the import loop previously
4511    /// bypassed `max_feeds_global` entirely.)
4512    #[tokio::test]
4513    async fn opml_import_enforces_global_feeds_ceiling() {
4514        let did = "did:plc:importer";
4515        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
4516        let state = test_state_with_caps(did, 0, 3).await;
4517        let cookie = session_cookie(&state, did, None);
4518        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4519        let app = router(state.clone());
4520
4521        let resp = app
4522            .oneshot(
4523                Request::builder()
4524                    .method("POST")
4525                    .uri("/opml")
4526                    .header(header::COOKIE, cookie)
4527                    .header("content-type", ct)
4528                    .body(Body::from(body))
4529                    .unwrap(),
4530            )
4531            .await
4532            .unwrap();
4533        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4534
4535        let feeds = store::count_feeds(&state.db).await.unwrap();
4536        assert!(
4537            feeds <= 3,
4538            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
4539        );
4540    }
4541
4542    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
4543    /// cap imports zero new feeds.
4544    #[tokio::test]
4545    async fn opml_import_enforces_per_did_cap() {
4546        let did = "did:plc:capped";
4547        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
4548        let state = test_state_with_caps(did, 2, 0).await;
4549        let existing_a = store::upsert_feed(
4550            &state.db,
4551            &store::NewFeed {
4552                url: "https://have-a.example/feed.xml".to_string(),
4553                ..Default::default()
4554            },
4555        )
4556        .await
4557        .unwrap();
4558        let existing_b = store::upsert_feed(
4559            &state.db,
4560            &store::NewFeed {
4561                url: "https://have-b.example/feed.xml".to_string(),
4562                ..Default::default()
4563            },
4564        )
4565        .await
4566        .unwrap();
4567        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
4568            .await
4569            .unwrap();
4570        let before = store::count_feeds(&state.db).await.unwrap();
4571
4572        let cookie = session_cookie(&state, did, None);
4573        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4574        let app = router(state.clone());
4575        let resp = app
4576            .oneshot(
4577                Request::builder()
4578                    .method("POST")
4579                    .uri("/opml")
4580                    .header(header::COOKIE, cookie)
4581                    .header("content-type", ct)
4582                    .body(Body::from(body))
4583                    .unwrap(),
4584            )
4585            .await
4586            .unwrap();
4587        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4588        // Headroom was 0 → no new feeds imported into the shared cache.
4589        let after = store::count_feeds(&state.db).await.unwrap();
4590        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
4591    }
4592
4593    /// Single-add per-DID cap: a DID at its subscription cap is refused before
4594    /// any fetch, with the limit flash.
4595    #[tokio::test]
4596    async fn single_add_enforces_per_did_cap() {
4597        let did = "did:plc:subcapped";
4598        let state = test_state_with_caps(did, 1, 0).await;
4599        let f = store::upsert_feed(
4600            &state.db,
4601            &store::NewFeed {
4602                url: "https://have.example/feed.xml".to_string(),
4603                ..Default::default()
4604            },
4605        )
4606        .await
4607        .unwrap();
4608        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
4609        let cookie = session_cookie(&state, did, None);
4610        let app = router(state.clone());
4611        let resp = app
4612            .oneshot(
4613                Request::builder()
4614                    .method("POST")
4615                    .uri("/subscriptions")
4616                    .header(header::COOKIE, cookie)
4617                    .header("content-type", "application/x-www-form-urlencoded")
4618                    .body(Body::from("url=https://another.example/feed.xml"))
4619                    .unwrap(),
4620            )
4621            .await
4622            .unwrap();
4623        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4624        let loc = resp
4625            .headers()
4626            .get(header::LOCATION)
4627            .unwrap()
4628            .to_str()
4629            .unwrap();
4630        assert!(
4631            loc.contains("Subscription%20limit%20reached"),
4632            "expected sub-limit flash, got {loc}"
4633        );
4634    }
4635
4636    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
4637    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
4638    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
4639    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
4640    /// view (no reader header) instead swaps the row. This guards the reader OOB
4641    /// toggle wiring, which had no test.
4642    #[tokio::test]
4643    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
4644        let did = "did:plc:reader";
4645        let state = test_state(&[]).await;
4646        store::grant_access(&state.db, did, None, "test", None)
4647            .await
4648            .unwrap();
4649        let feed = store::upsert_feed(
4650            &state.db,
4651            &store::NewFeed {
4652                url: "https://reader.example/feed.xml".to_string(),
4653                title: Some("Reader".to_string()),
4654                ..Default::default()
4655            },
4656        )
4657        .await
4658        .unwrap();
4659        store::insert_entries(
4660            &state.db,
4661            feed,
4662            &[store::NewEntry {
4663                guid: "r-1".to_string(),
4664                url: Some("https://reader.example/1".to_string()),
4665                title: Some("Article".to_string()),
4666                published: Some("2026-07-11T00:00:00Z".to_string()),
4667                content_html: Some("<p>body</p>".to_string()),
4668                ..Default::default()
4669            }],
4670            0,
4671        )
4672        .await
4673        .unwrap();
4674        store::replace_sub_refs(&state.db, did, &[feed])
4675            .await
4676            .unwrap();
4677        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4678
4679        let cookie = session_cookie(&state, did, None);
4680        let app = router(state.clone());
4681
4682        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
4683        let resp = app
4684            .clone()
4685            .oneshot(
4686                Request::builder()
4687                    .method("POST")
4688                    .uri(format!("/entries/{entry_id}/read"))
4689                    .header(header::COOKIE, cookie.clone())
4690                    .header("HX-Request", "true")
4691                    .header("X-FR-Reader", "1")
4692                    .header("content-type", "application/x-www-form-urlencoded")
4693                    .body(Body::from("read=true"))
4694                    .unwrap(),
4695            )
4696            .await
4697            .unwrap();
4698        assert_eq!(resp.status(), StatusCode::OK);
4699        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4700            .await
4701            .unwrap();
4702        let html = String::from_utf8(bytes.to_vec()).unwrap();
4703        assert!(
4704            html.contains("hx-swap-oob=\"outerHTML\""),
4705            "reader response must be an OOB swap: {html}"
4706        );
4707        assert!(
4708            html.contains(r#"id="entry-actionbar""#),
4709            "reader response must be the action-bar fragment: {html}"
4710        );
4711        // Now READ: the read button reflects it (aria-pressed=true) and the
4712        // hidden value flips to `false` so the next tap marks it UNREAD.
4713        assert!(
4714            html.contains(r#"aria-pressed="true""#),
4715            "read button must show pressed after marking read: {html}"
4716        );
4717        assert!(
4718            html.contains(r#"name="read" value="false""#),
4719            "hidden read value must flip to false so a second tap reverses: {html}"
4720        );
4721
4722        // A second reader mark-read (submitting the flipped `read=false`) marks
4723        // it UNREAD again — the toggle reverses.
4724        let resp2 = app
4725            .oneshot(
4726                Request::builder()
4727                    .method("POST")
4728                    .uri(format!("/entries/{entry_id}/read"))
4729                    .header(header::COOKIE, cookie)
4730                    .header("HX-Request", "true")
4731                    .header("X-FR-Reader", "1")
4732                    .header("content-type", "application/x-www-form-urlencoded")
4733                    .body(Body::from("read=false"))
4734                    .unwrap(),
4735            )
4736            .await
4737            .unwrap();
4738        assert_eq!(resp2.status(), StatusCode::OK);
4739        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
4740            .await
4741            .unwrap();
4742        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
4743        assert!(
4744            html2.contains(r#"aria-pressed="false""#),
4745            "read button must show un-pressed after reversing: {html2}"
4746        );
4747        assert!(
4748            html2.contains(r#"name="read" value="true""#),
4749            "hidden read value must flip back to true: {html2}"
4750        );
4751    }
4752
4753    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
4754    /// action-bar — the counterpart to the reader-OOB test above.
4755    #[tokio::test]
4756    async fn list_mark_read_returns_row_not_oob_actionbar() {
4757        let did = "did:plc:listv";
4758        let state = test_state(&[]).await;
4759        store::grant_access(&state.db, did, None, "test", None)
4760            .await
4761            .unwrap();
4762        let feed = store::upsert_feed(
4763            &state.db,
4764            &store::NewFeed {
4765                url: "https://list.example/feed.xml".to_string(),
4766                title: Some("List".to_string()),
4767                ..Default::default()
4768            },
4769        )
4770        .await
4771        .unwrap();
4772        store::insert_entries(
4773            &state.db,
4774            feed,
4775            &[store::NewEntry {
4776                guid: "l-1".to_string(),
4777                url: Some("https://list.example/1".to_string()),
4778                title: Some("Article".to_string()),
4779                published: Some("2026-07-11T00:00:00Z".to_string()),
4780                ..Default::default()
4781            }],
4782            0,
4783        )
4784        .await
4785        .unwrap();
4786        store::replace_sub_refs(&state.db, did, &[feed])
4787            .await
4788            .unwrap();
4789        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4790
4791        let cookie = session_cookie(&state, did, None);
4792        let app = router(state.clone());
4793
4794        let resp = app
4795            .oneshot(
4796                Request::builder()
4797                    .method("POST")
4798                    .uri(format!("/entries/{entry_id}/read"))
4799                    .header(header::COOKIE, cookie)
4800                    .header("HX-Request", "true")
4801                    .header("content-type", "application/x-www-form-urlencoded")
4802                    .body(Body::from("read=true"))
4803                    .unwrap(),
4804            )
4805            .await
4806            .unwrap();
4807        assert_eq!(resp.status(), StatusCode::OK);
4808        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4809            .await
4810            .unwrap();
4811        let html = String::from_utf8(bytes.to_vec()).unwrap();
4812        assert!(
4813            !html.contains("hx-swap-oob"),
4814            "list-view response must NOT be an OOB swap: {html}"
4815        );
4816    }
4817
4818    // -----------------------------------------------------------------------
4819    // Rename parity (POST /subscriptions/{rkey}/rename)
4820    // -----------------------------------------------------------------------
4821
4822    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
4823    /// its global ceiling must be refused (capacity flash) and must NOT insert a
4824    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
4825    /// rename loop can't inflate the shared cache past the cap.
4826    #[tokio::test]
4827    async fn rename_to_new_url_refused_at_global_feeds_cap() {
4828        let did = "did:plc:renamer";
4829        // Global cap 1; pre-fill it with one feed so headroom is 0.
4830        let state = test_state_with_caps(did, 0, 1).await;
4831        store::upsert_feed(
4832            &state.db,
4833            &store::NewFeed {
4834                url: "https://existing.example/feed.xml".to_string(),
4835                ..Default::default()
4836            },
4837        )
4838        .await
4839        .unwrap();
4840        let before = store::count_feeds(&state.db).await.unwrap();
4841        assert_eq!(before, 1);
4842
4843        let cookie = session_cookie(&state, did, None);
4844        let app = router(state.clone());
4845        let resp = app
4846            .oneshot(
4847                Request::builder()
4848                    .method("POST")
4849                    .uri("/subscriptions/rkey123/rename")
4850                    .header(header::COOKIE, cookie)
4851                    .header("content-type", "application/x-www-form-urlencoded")
4852                    // A URL not in the cache → would be a NEW feeds row.
4853                    .body(Body::from(
4854                        "url=https://brand-new.example/feed.xml&title=Renamed",
4855                    ))
4856                    .unwrap(),
4857            )
4858            .await
4859            .unwrap();
4860        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4861        let loc = resp
4862            .headers()
4863            .get(header::LOCATION)
4864            .unwrap()
4865            .to_str()
4866            .unwrap();
4867        assert!(
4868            loc.contains("feed%20capacity"),
4869            "expected the feed-capacity flash, got {loc}"
4870        );
4871        // No new feeds row was inserted.
4872        let after = store::count_feeds(&state.db).await.unwrap();
4873        assert_eq!(
4874            after, before,
4875            "rename inflated the shared cache past the cap"
4876        );
4877    }
4878
4879    /// A rename to an EXISTING URL adds no row, so it is allowed even at the
4880    /// global cap (only new URLs are gated) — the other half of the guard.
4881    #[tokio::test]
4882    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
4883        let did = "did:plc:renamer2";
4884        let state = test_state_with_caps(did, 0, 1).await;
4885        store::upsert_feed(
4886            &state.db,
4887            &store::NewFeed {
4888                url: "https://existing.example/feed.xml".to_string(),
4889                ..Default::default()
4890            },
4891        )
4892        .await
4893        .unwrap();
4894        let before = store::count_feeds(&state.db).await.unwrap();
4895
4896        let cookie = session_cookie(&state, did, None);
4897        let app = router(state.clone());
4898        let resp = app
4899            .oneshot(
4900                Request::builder()
4901                    .method("POST")
4902                    .uri("/subscriptions/rkey123/rename")
4903                    .header(header::COOKIE, cookie)
4904                    .header("content-type", "application/x-www-form-urlencoded")
4905                    .body(Body::from(
4906                        "url=https://existing.example/feed.xml&title=Retitled",
4907                    ))
4908                    .unwrap(),
4909            )
4910            .await
4911            .unwrap();
4912        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4913        let loc = resp
4914            .headers()
4915            .get(header::LOCATION)
4916            .unwrap()
4917            .to_str()
4918            .unwrap();
4919        assert_eq!(
4920            loc, "/",
4921            "retitle of an existing feed must succeed, got {loc}"
4922        );
4923        assert_eq!(
4924            store::count_feeds(&state.db).await.unwrap(),
4925            before,
4926            "retitle must not add a feeds row"
4927        );
4928    }
4929
4930    /// A rename with a blank/empty `url` must write NOTHING — no `feeds` row and
4931    /// no PDS update — and just redirect home. Guards the empty-URL early return.
4932    #[tokio::test]
4933    async fn rename_with_blank_url_writes_nothing() {
4934        let did = "did:plc:renamer3";
4935        let state = test_state_with_caps(did, 0, 0).await;
4936        let before = store::count_feeds(&state.db).await.unwrap();
4937        assert_eq!(before, 0);
4938
4939        let cookie = session_cookie(&state, did, None);
4940        let app = router(state.clone());
4941        let resp = app
4942            .oneshot(
4943                Request::builder()
4944                    .method("POST")
4945                    .uri("/subscriptions/rkey123/rename")
4946                    .header(header::COOKIE, cookie)
4947                    .header("content-type", "application/x-www-form-urlencoded")
4948                    // Whitespace-only URL trims to empty.
4949                    .body(Body::from("url=%20%20&title=Nope"))
4950                    .unwrap(),
4951            )
4952            .await
4953            .unwrap();
4954        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4955        assert_eq!(
4956            resp.headers()
4957                .get(header::LOCATION)
4958                .unwrap()
4959                .to_str()
4960                .unwrap(),
4961            "/",
4962        );
4963        // Nothing was cached.
4964        assert_eq!(
4965            store::count_feeds(&state.db).await.unwrap(),
4966            0,
4967            "blank-URL rename wrote a junk feeds row"
4968        );
4969    }
4970
4971    /// Folder pre-selection regression: the manage rename row must mark the
4972    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
4973    /// re-submits the current folder instead of silently un-foldering the feed.
4974    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
4975    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
4976    #[test]
4977    fn manage_rename_row_preselects_current_folder() {
4978        let nav = Nav {
4979            handle: "@reader.example".to_string(),
4980            avatar: "RE".to_string(),
4981            view: "unread".to_string(),
4982            scope_qs: String::new(),
4983            folders: Vec::new(),
4984            loose_feeds: Vec::new(),
4985            manage_active: true,
4986        };
4987        let folder_options = vec![
4988            FolderOption {
4989                uri: "at://did:plc:x/app.folder/work".to_string(),
4990                name: "Work".to_string(),
4991            },
4992            FolderOption {
4993                uri: "at://did:plc:x/app.folder/fun".to_string(),
4994                name: "Fun".to_string(),
4995            },
4996        ];
4997        // A foldered feed (in "Work") and a loose feed (no folder), each with a
4998        // non-empty rkey so the rename form renders.
4999        let foldered = FeedView {
5000            rkey: "sub-foldered".to_string(),
5001            url: "https://work.example/feed.xml".to_string(),
5002            title: "Work Feed".to_string(),
5003            unread: 0,
5004            selected: false,
5005            folder: Some("at://did:plc:x/app.folder/work".to_string()),
5006        };
5007        let loose = FeedView {
5008            rkey: "sub-loose".to_string(),
5009            url: "https://loose.example/feed.xml".to_string(),
5010            title: "Loose Feed".to_string(),
5011            unread: 0,
5012            selected: false,
5013            folder: None,
5014        };
5015        let tmpl = ManageTemplate {
5016            version: VERSION,
5017            repo_url: REPO_URL,
5018            kofi_url: KOFI_URL,
5019            flash: String::new(),
5020            nav,
5021            folder_options,
5022            folders: vec![FolderView {
5023                rkey: "folder-work".to_string(),
5024                uri: "at://did:plc:x/app.folder/work".to_string(),
5025                name: "Work".to_string(),
5026                feeds: vec![foldered],
5027                selected: false,
5028            }],
5029            loose_feeds: vec![loose],
5030        };
5031        let html = tmpl.render().unwrap();
5032
5033        // The foldered feed's "Work" option is pre-selected.
5034        assert!(
5035            html.contains(
5036                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
5037            ),
5038            "foldered feed must pre-select its current folder: {html}"
5039        );
5040        // The loose feed's "No folder" option is pre-selected (appears for the
5041        // loose row, which has folder=None).
5042        assert!(
5043            html.contains(r#"<option value="" selected>No folder</option>"#),
5044            "loose feed must pre-select 'No folder': {html}"
5045        );
5046    }
5047}