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 already holds beta access *or* presents a valid reserving invite
2151/// cookie — otherwise a non-invited visitor could burn a sidecar handshake.
2152/// Refusal redirects to `/beta/redeem`. The bare form (no handle) always renders.
2153async fn login_form(
2154    State(state): State<AppState>,
2155    headers: HeaderMap,
2156    Query(q): Query<LoginQuery>,
2157) -> Response {
2158    if let Some(handle) = q
2159        .handle
2160        .map(|h| h.trim().to_string())
2161        .filter(|h| !h.is_empty())
2162    {
2163        if !may_start_oauth(&state, &headers).await {
2164            return Redirect::to("/beta/redeem").into_response();
2165        }
2166        return start_oauth(&state, &handle);
2167    }
2168    render(&LoginTemplate {
2169        repo_url: REPO_URL,
2170        error: q.error.unwrap_or_default(),
2171        flash: q.flash.unwrap_or_default(),
2172    })
2173}
2174
2175/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
2176/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
2177async fn login_submit(
2178    State(state): State<AppState>,
2179    headers: HeaderMap,
2180    Form(form): Form<LoginForm>,
2181) -> Response {
2182    let handle = form.handle.trim();
2183    if handle.is_empty() {
2184        return login_error("Enter your atproto handle.");
2185    }
2186    if !may_start_oauth(&state, &headers).await {
2187        return Redirect::to("/beta/redeem").into_response();
2188    }
2189    start_oauth(&state, handle)
2190}
2191
2192/// Whether this visitor is allowed to *start* the OAuth handshake: either an
2193/// existing beta member (cookie session whose DID already holds a seat) or a
2194/// fresh visitor carrying a valid reserving invite cookie. This is the
2195/// pre-handshake guard that stops non-invited visitors from burning a sidecar
2196/// handshake.
2197async fn may_start_oauth(state: &AppState, headers: &HeaderMap) -> bool {
2198    // An already-beta'd session may re-auth freely.
2199    if let Some(did) = current_did(state, headers).await {
2200        if store::has_beta_access(&state.db, &did)
2201            .await
2202            .unwrap_or(false)
2203        {
2204            return true;
2205        }
2206    }
2207    // Otherwise require a valid reserving invite cookie.
2208    invite_cookie_code(headers, &state.config.cookie_secret).is_some()
2209}
2210
2211/// Redirect the browser to the sidecar's public `/login` for `handle`.
2212fn start_oauth(state: &AppState, handle: &str) -> Response {
2213    let url = state.sidecar.login_url(handle, None);
2214    info!(%handle, "redirecting to OAuth sidecar login");
2215    Redirect::to(&url).into_response()
2216}
2217
2218/// Form body for `POST /login`.
2219#[derive(Debug, Deserialize)]
2220struct LoginForm {
2221    handle: String,
2222}
2223
2224/// Query for `GET /oauth/callback`.
2225#[derive(Debug, Deserialize, Default)]
2226struct CallbackQuery {
2227    #[serde(default)]
2228    session_id: Option<String>,
2229    #[serde(default)]
2230    error: Option<String>,
2231    #[serde(default)]
2232    error_description: Option<String>,
2233}
2234
2235/// `GET /oauth/callback` — establish the cookie session.
2236///
2237/// **Invite gate:** the verified DID must hold beta access. If it already does
2238/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
2239/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
2240/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
2241async fn oauth_callback(
2242    State(state): State<AppState>,
2243    headers: HeaderMap,
2244    Query(q): Query<CallbackQuery>,
2245) -> Response {
2246    if let Some(err) = q.error {
2247        let desc = q.error_description.unwrap_or_default();
2248        warn!(error = %err, desc = %desc, "OAuth callback returned an error");
2249        return login_error(&format!("Login failed: {err}"));
2250    }
2251
2252    let session_id = match q.session_id {
2253        Some(s) if !s.is_empty() => s,
2254        _ => return login_error("Login failed: the callback carried no session."),
2255    };
2256
2257    let session = match state.sidecar.resolve_session(&session_id).await {
2258        Ok(Some(s)) => s,
2259        Ok(None) => {
2260            warn!("OAuth callback session_id did not resolve (expired/unknown)");
2261            return login_error("Login session expired — please try again.");
2262        }
2263        Err(err) => {
2264            warn!(%err, "failed to resolve OAuth session via the sidecar");
2265            return login_error("Login failed talking to the auth service.");
2266        }
2267    };
2268
2269    // Bind the verified DID to the invite gate. Returns a response only on the
2270    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
2271    let mut clear_invite = false;
2272    if !store::has_beta_access(&state.db, &session.did)
2273        .await
2274        .unwrap_or(false)
2275    {
2276        // Not yet a member: consume the reserved invite code, if any.
2277        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
2278            Some(c) => c,
2279            None => {
2280                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
2281                return Redirect::to("/beta/redeem").into_response();
2282            }
2283        };
2284        match store::redeem_code(
2285            &state.db,
2286            &code,
2287            &session.did,
2288            session.handle.as_deref(),
2289            state.config.beta_cap,
2290        )
2291        .await
2292        {
2293            Ok(Ok(())) => {
2294                clear_invite = true;
2295                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
2296            }
2297            Ok(Err(policy)) => {
2298                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
2299                let mut resp = redeem_bounce(&policy).into_response();
2300                // The reservation is spent/invalid — drop the stale invite cookie.
2301                clear_invite_cookie(&mut resp);
2302                return resp;
2303            }
2304            Err(err) => {
2305                warn!(%err, did = %session.did, "invite redeem infra error at callback");
2306                return login_error("Login failed while confirming your invite.");
2307            }
2308        }
2309    }
2310
2311    // Mint an opaque, random server-side session id and store the identity under
2312    // it; the cookie carries the (HMAC-signed) sid, never the DID.
2313    let sid = state.sessions.create(Session {
2314        did: session.did.clone(),
2315        handle: session.handle.clone(),
2316    });
2317    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
2318    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
2319
2320    let mut resp = Redirect::to("/").into_response();
2321    set_cookie(&mut resp, &cookie);
2322    if clear_invite {
2323        clear_invite_cookie(&mut resp);
2324    }
2325    resp
2326}
2327
2328/// `POST /logout` — end the session everywhere, not just in this browser.
2329///
2330/// Clearing the cookie only stops *this* device from presenting the session;
2331/// the sidecar still holds live OAuth tokens for the DID. So logout now also
2332/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
2333/// access tokens at the PDS and drops the sidecar's session rows. The local
2334/// registry entry is dropped and the cookie cleared regardless of whether the
2335/// revoke round-trip succeeds (best-effort — a network blip must not trap the
2336/// user in a half-logged-out state).
2337async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
2338    if let Some(user) = current_session(&state, &headers).await {
2339        // Only a real cookie session (`sid` present) has sidecar-held tokens to
2340        // revoke; the dev-DID fallback never handshook the sidecar.
2341        if let Some(sid) = user.sid {
2342            state.sessions.remove(&sid);
2343            match state.sidecar.revoke_session(&user.did).await {
2344                Ok(res) => {
2345                    info!(did = %user.did, revoked = res.revoked, "logout: sidecar session revoked");
2346                }
2347                Err(err) => {
2348                    warn!(did = %user.did, %err, "logout: sidecar revoke failed; clearing cookie anyway");
2349                }
2350            }
2351        }
2352    }
2353    let mut resp = Redirect::to("/login").into_response();
2354    set_cookie(
2355        &mut resp,
2356        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2357    );
2358    resp
2359}
2360
2361/// Form body for `POST /account/delete` — the confirm-gate. The user must type
2362/// `DELETE` into this field for the purge to run.
2363#[derive(Debug, Deserialize)]
2364struct DeleteAccountForm {
2365    #[serde(default)]
2366    confirm: String,
2367}
2368
2369/// The literal a user must type to confirm the destructive delete.
2370const DELETE_CONFIRM_PHRASE: &str = "DELETE";
2371
2372/// `POST /account/delete` (authed) — the "delete my data" endpoint.
2373///
2374/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
2375/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
2376///   1. purges **every** local row owned by the caller DID (`entry_state`,
2377///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
2378///      DID created) via [`store::purge_did_data`], then
2379///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
2380///      revoked at the PDS and the sidecar's session rows are dropped, then
2381///   3. drops the in-memory session and clears the cookie, signing the user out.
2382///
2383/// The subscription/folder/saved *records* in the user's own PDS are
2384/// intentionally left alone — they are the user's data on their own server; the
2385/// `/about` copy and this page's UI both say so, and export stays available.
2386async fn account_delete(
2387    State(state): State<AppState>,
2388    headers: HeaderMap,
2389    Form(form): Form<DeleteAccountForm>,
2390) -> Result<Response, WebError> {
2391    let user = match current_session(&state, &headers).await {
2392        Some(u) => u,
2393        None => return Ok(Redirect::to("/login").into_response()),
2394    };
2395    let did = user.did.clone();
2396
2397    // Confirm-gate: require the exact typed phrase before doing anything.
2398    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
2399        return Ok(Redirect::to(&format!(
2400            "/manage?flash={}",
2401            qenc("Type DELETE to confirm — nothing was deleted.")
2402        ))
2403        .into_response());
2404    }
2405
2406    // 1. Purge every local row this DID owns (single transaction).
2407    let counts = store::purge_did_data(&state.db, &did).await?;
2408    info!(
2409        %did,
2410        total = counts.total(),
2411        entry_state = counts.entry_state,
2412        read_cursor = counts.read_cursor,
2413        sub_ref = counts.sub_ref,
2414        beta_access = counts.beta_access,
2415        invite_codes = counts.invite_codes,
2416        "account/delete: local rows purged"
2417    );
2418
2419    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
2420    //    rows are already gone; a network blip must not block the sign-out).
2421    match state.sidecar.revoke_session(&did).await {
2422        Ok(res) => info!(%did, revoked = res.revoked, "account/delete: sidecar session revoked"),
2423        Err(err) => {
2424            warn!(%did, %err, "account/delete: sidecar revoke failed; local data already purged")
2425        }
2426    }
2427
2428    // 3. Drop the in-memory session and clear the cookie: sign the user out.
2429    if let Some(sid) = user.sid {
2430        state.sessions.remove(&sid);
2431    }
2432    let mut resp = Redirect::to(&format!(
2433        "/login?flash={}",
2434        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
2435    ))
2436    .into_response();
2437    set_cookie(
2438        &mut resp,
2439        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2440    );
2441    Ok(resp)
2442}
2443
2444/// Re-render the login form with an error banner.
2445fn login_error(msg: &str) -> Response {
2446    render(&LoginTemplate {
2447        repo_url: REPO_URL,
2448        error: msg.to_string(),
2449        flash: String::new(),
2450    })
2451}
2452
2453// ---------------------------------------------------------------------------
2454// Closed-beta invite gate (self-serve redeem + admin mint)
2455// ---------------------------------------------------------------------------
2456
2457/// Form body for `POST /beta/redeem`.
2458#[derive(Debug, Deserialize)]
2459struct RedeemForm {
2460    code: String,
2461}
2462
2463/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
2464/// already full we render the "capacity full" variant (no form).
2465async fn beta_redeem_form(State(state): State<AppState>) -> Response {
2466    let full = store::count_beta_access(&state.db)
2467        .await
2468        .map(|n| n >= state.config.beta_cap)
2469        .unwrap_or(false);
2470    render(&BetaRedeemTemplate {
2471        repo_url: REPO_URL,
2472        error: String::new(),
2473        capacity_full: full,
2474    })
2475}
2476
2477/// `POST /beta/redeem` — the **pre-handshake** reservation.
2478///
2479/// Validates the pasted code is *redeemable right now* (exists, active,
2480/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
2481/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
2482/// reserving intent to redeem this code, then sends the visitor to `/login`. The
2483/// OAuth callback later binds the verified DID and atomically consumes the code
2484/// (`store::redeem_code`). This ordering means a non-invited visitor can never
2485/// start OAuth (and burn a sidecar handshake).
2486async fn beta_redeem_submit(
2487    State(state): State<AppState>,
2488    Form(form): Form<RedeemForm>,
2489) -> Response {
2490    let code = form.code.trim().to_uppercase();
2491    if code.is_empty() {
2492        return render(&BetaRedeemTemplate {
2493            repo_url: REPO_URL,
2494            error: "Enter your invite code.".to_string(),
2495            capacity_full: false,
2496        });
2497    }
2498
2499    match preflight_code(&state, &code).await {
2500        Ok(()) => {
2501            let cookie = sign_invite(&code, &state.config.cookie_secret);
2502            let mut resp = Redirect::to("/login").into_response();
2503            set_cookie(&mut resp, &cookie);
2504            info!("invite code preflight OK; reserving intent + redirecting to /login");
2505            resp
2506        }
2507        Err(policy) => {
2508            warn!(?policy, "invite code preflight rejected");
2509            redeem_bounce(&policy)
2510        }
2511    }
2512}
2513
2514/// Read-only preflight of an invite code for the pre-handshake reservation:
2515/// verify it exists, is active, is not past `expires_at`, and that a seat is
2516/// free — mirroring the checks `store::redeem_code` will re-run atomically at
2517/// callback time. Does NOT consume the code or grant a seat. Returns the same
2518/// typed [`store::RedeemError`] variants so the two paths share one message map.
2519async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
2520    // Cap check first: a clear "capacity full" beats "code invalid" when both.
2521    let count = store::count_beta_access(&state.db).await.unwrap_or(0);
2522    if count >= state.config.beta_cap {
2523        return Err(store::RedeemError::CapacityFull);
2524    }
2525    // Look up the code's current status + expiry (read-only).
2526    let row = sqlx::query_as::<_, (String, i64)>(
2527        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
2528    )
2529    .bind(code)
2530    .fetch_optional(&state.db)
2531    .await
2532    .ok()
2533    .flatten();
2534    let (status, expires_at) = match row {
2535        Some(r) => r,
2536        None => return Err(store::RedeemError::NotFound),
2537    };
2538    let now = chrono::Utc::now().timestamp();
2539    match status.as_str() {
2540        "active" if expires_at >= now => Ok(()),
2541        "active" => Err(store::RedeemError::Expired),
2542        "expired" => Err(store::RedeemError::Expired),
2543        // "redeemed" or anything else non-active.
2544        _ => Err(store::RedeemError::AlreadyRedeemed),
2545    }
2546}
2547
2548/// Map a [`store::RedeemError`] to the invite page with the right message. Used
2549/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
2550fn redeem_bounce(policy: &store::RedeemError) -> Response {
2551    use store::RedeemError::*;
2552    let (msg, capacity_full) = match policy {
2553        NotFound => ("That invite code isn't valid.", false),
2554        Expired => ("That invite code has expired.", false),
2555        AlreadyRedeemed => ("That invite code has already been used.", false),
2556        CapacityFull => ("", true),
2557    };
2558    render(&BetaRedeemTemplate {
2559        repo_url: REPO_URL,
2560        error: msg.to_string(),
2561        capacity_full,
2562    })
2563}
2564
2565/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
2566#[derive(Debug, Deserialize, Default)]
2567struct MintQuery {
2568    #[serde(default)]
2569    n: Option<u32>,
2570}
2571
2572/// `POST /admin/invites?n=N` — mint N invite codes.
2573///
2574/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
2575/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
2576/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
2577async fn admin_mint_invites(
2578    State(state): State<AppState>,
2579    headers: HeaderMap,
2580    Query(q): Query<MintQuery>,
2581) -> Response {
2582    // Require a real, current session (not just a DID string) whose DID is an
2583    // admin-seed DID. `current_did` already re-checks the beta gate.
2584    let did = match current_did(&state, &headers).await {
2585        Some(d) => d,
2586        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
2587    };
2588    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
2589        warn!(%did, "admin mint denied: not an admin-seed DID");
2590        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
2591    }
2592
2593    let n = q.n.unwrap_or(1).clamp(1, 100);
2594    let mut codes = Vec::with_capacity(n as usize);
2595    for _ in 0..n {
2596        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
2597            Ok(code) => codes.push(code),
2598            Err(err) => {
2599                warn!(%err, %did, "admin mint_code failed");
2600                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2601            }
2602        }
2603    }
2604    info!(%did, count = codes.len(), "admin minted invite codes");
2605    let mut body = codes.join("\n");
2606    body.push('\n');
2607    (StatusCode::OK, body).into_response()
2608}
2609
2610// ---------------------------------------------------------------------------
2611// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
2612// ---------------------------------------------------------------------------
2613
2614/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
2615/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
2616/// itself (base64url) rather than an opaque sid, since the code IS the reserved
2617/// intent the callback consumes.
2618fn sign_invite(code: &str, secret: &str) -> String {
2619    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
2620}
2621
2622/// Verify + read the reserved invite code out of the request's invite cookie
2623/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
2624/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
2625/// authority on the code's live status.
2626fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
2627    cookie::verify_value(headers, INVITE_COOKIE, secret)
2628}
2629
2630/// Clear the invite cookie on a response (after a successful bind, or when the
2631/// reservation turned out to be stale).
2632fn clear_invite_cookie(resp: &mut Response) {
2633    set_cookie(
2634        resp,
2635        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2636    );
2637}
2638
2639// ---------------------------------------------------------------------------
2640// OPML import + export
2641// ---------------------------------------------------------------------------
2642
2643/// `POST /opml` — import subscriptions from an OPML document.
2644///
2645/// Accepts either a multipart file upload (field `file`) or a pasted textarea
2646/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
2647/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
2648/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
2649/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
2650/// they show immediately; polling is left to the background poller.
2651async fn import_opml(
2652    State(state): State<AppState>,
2653    headers: HeaderMap,
2654    mut multipart: Multipart,
2655) -> Result<Response, WebError> {
2656    let did = match current_did(&state, &headers).await {
2657        Some(d) => d,
2658        None => return Ok(Redirect::to("/login").into_response()),
2659    };
2660    let pool = &state.db;
2661
2662    // Collect the OPML text from whichever field carried it. Multipart errors
2663    // are mapped to their axum-native response so that an over-cap upload (the
2664    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
2665    // `413 Payload Too Large` rather than being swallowed by the blanket
2666    // `WebError` → `500` conversion.
2667    let mut opml_text = String::new();
2668    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
2669        let name = field.name().unwrap_or("").to_string();
2670        if name == "opml" || name == "file" {
2671            let bytes = field.bytes().await.map_err(multipart_response)?;
2672            if !bytes.is_empty() {
2673                opml_text = String::from_utf8_lossy(&bytes).into_owned();
2674                if name == "file" {
2675                    break;
2676                }
2677            }
2678        }
2679    }
2680
2681    let feeds = opml::parse_opml(&opml_text).unwrap_or_default();
2682    if feeds.is_empty() {
2683        info!(%did, "OPML import found no feeds");
2684        return Ok(
2685            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
2686                .into_response(),
2687        );
2688    }
2689
2690    // Create any named folders first, mapping folder name → at:// URI so
2691    // subscriptions can reference them.
2692    let now = now_rfc3339();
2693    let mut folder_uris: std::collections::HashMap<String, String> =
2694        std::collections::HashMap::new();
2695    // Reuse existing folders where the name already exists.
2696    if let Ok(existing) = state.sidecar.list_folders_sorted(&did).await {
2697        for (rkey, folder) in existing {
2698            folder_uris
2699                .entry(folder.name.clone())
2700                .or_insert_with(|| folder_uri(&did, &rkey));
2701        }
2702    }
2703    let mut wanted_folders: Vec<String> = feeds
2704        .iter()
2705        .filter_map(|f| f.folder.clone())
2706        .filter(|n| !n.is_empty())
2707        .collect();
2708    wanted_folders.sort();
2709    wanted_folders.dedup();
2710    for name in wanted_folders {
2711        if folder_uris.contains_key(&name) {
2712            continue;
2713        }
2714        let folder = Folder::new(name.clone(), now.clone());
2715        match state.sidecar.add_folder(&did, &folder).await {
2716            Ok(rkey) => {
2717                folder_uris.insert(name, folder_uri(&did, &rkey));
2718            }
2719            Err(err) => warn!(%err, %did, "OPML folder create failed"),
2720        }
2721    }
2722
2723    // Build one subscription record per PUBLIC feed + upsert the local cache row.
2724    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
2725    // and reported back to the user — the same public-feeds-only stance as the
2726    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
2727    // token onto the public network either.
2728    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
2729    // the remaining headroom (cap − existing) once; public feeds beyond it are
2730    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
2731    let sub_cap = state.config.max_subs_per_did;
2732    let mut headroom: Option<i64> = if sub_cap > 0 {
2733        let existing = store::count_subscriptions_for_did(pool, &did)
2734            .await
2735            .unwrap_or(0);
2736        Some((sub_cap - existing).max(0))
2737    } else {
2738        None
2739    };
2740    let mut trimmed_over_cap: usize = 0;
2741
2742    // Global feeds ceiling: an OPML import must not blow past the shared cache
2743    // ceiling any more than the single-add path may. Seed the remaining global
2744    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
2745    // not already cached) consumes it. Existing/duplicate URLs add no row and
2746    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
2747    // `<= 0` disables the ceiling.
2748    let feeds_cap = state.config.max_feeds_global;
2749    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
2750        let existing = store::count_feeds(pool).await.unwrap_or(0);
2751        Some((feeds_cap - existing).max(0))
2752    } else {
2753        None
2754    };
2755    let mut trimmed_over_global: usize = 0;
2756
2757    let mut subs = Vec::with_capacity(feeds.len());
2758    let mut skipped_private: Vec<String> = Vec::new();
2759    for f in &feeds {
2760        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
2761            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
2762            // Report by title where we have one, else the (public-safe) host.
2763            let label = f
2764                .title
2765                .clone()
2766                .filter(|t| !t.trim().is_empty())
2767                .unwrap_or_else(|| private_feed_label(&f.feed_url));
2768            skipped_private.push(label);
2769            continue;
2770        }
2771
2772        // Over-cap: stop importing once headroom is exhausted (count the rest so
2773        // we can tell the user how many were dropped).
2774        if let Some(h) = headroom.as_mut() {
2775            if *h <= 0 {
2776                trimmed_over_cap += 1;
2777                continue;
2778            }
2779        }
2780
2781        // Global ceiling: a brand-new feed URL consumes global headroom. Once
2782        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
2783        // free — they add no row). Checked before decrementing the per-DID
2784        // headroom so a dropped feed doesn't burn the caller's own quota.
2785        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
2786            Ok(existing) => existing.is_none(),
2787            // On a lookup error, treat as existing (don't consume global
2788            // headroom) but still allow the upsert to proceed.
2789            Err(err) => {
2790                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
2791                false
2792            }
2793        };
2794        if is_new {
2795            if let Some(g) = global_headroom.as_mut() {
2796                if *g <= 0 {
2797                    trimmed_over_global += 1;
2798                    continue;
2799                }
2800                *g -= 1;
2801            }
2802        }
2803
2804        // Passed both caps: consume the per-DID headroom now that the feed is
2805        // actually being imported.
2806        if let Some(h) = headroom.as_mut() {
2807            *h -= 1;
2808        }
2809
2810        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
2811        sub.title = f.title.clone();
2812        sub.site_url = f.site_url.clone();
2813        sub.folder = f
2814            .folder
2815            .as_ref()
2816            .and_then(|name| folder_uris.get(name).cloned());
2817        subs.push(sub);
2818        let _ = store::upsert_feed(
2819            pool,
2820            &store::NewFeed {
2821                url: f.feed_url.clone(),
2822                title: f.title.clone(),
2823                site_url: f.site_url.clone(),
2824                ..Default::default()
2825            },
2826        )
2827        .await;
2828    }
2829
2830    match state.sidecar.add_subscriptions_bulk(&did, &subs).await {
2831        Ok(rkeys) => {
2832            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)")
2833        }
2834        Err(err) => warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)"),
2835    }
2836
2837    // Report the import count, plus any private/paid feeds skipped as unsupported.
2838    let mut flash = format!("Imported {} feeds", subs.len());
2839    if trimmed_over_cap > 0 {
2840        flash.push_str(&format!(
2841            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
2842        ));
2843    }
2844    if trimmed_over_global > 0 {
2845        flash.push_str(&format!(
2846            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
2847        ));
2848    }
2849    if !skipped_private.is_empty() {
2850        flash.push_str(&format!(
2851            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
2852            skipped_private.len(),
2853            skipped_private.join(", ")
2854        ));
2855    }
2856    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
2857}
2858
2859/// A public-safe label for a skipped private feed when it has no title: just the
2860/// host, so we never echo the secret-bearing path/query back to the user.
2861fn private_feed_label(url: &str) -> String {
2862    url::Url::parse(url)
2863        .ok()
2864        .and_then(|u| u.host_str().map(str::to_string))
2865        .unwrap_or_else(|| "a private feed".to_string())
2866}
2867
2868/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
2869async fn export_opml(
2870    State(state): State<AppState>,
2871    headers: HeaderMap,
2872) -> Result<Response, WebError> {
2873    let did = match current_did(&state, &headers).await {
2874        Some(d) => d,
2875        None => return Ok(Redirect::to("/login").into_response()),
2876    };
2877
2878    let subs = state
2879        .sidecar
2880        .list_subscriptions_sorted(&did)
2881        .await
2882        .unwrap_or_default();
2883    let folders = state
2884        .sidecar
2885        .list_folders_sorted(&did)
2886        .await
2887        .unwrap_or_default();
2888    // The exporter matches a subscription's `folder` at-uri against the folder's
2889    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
2890    let folder_pairs: Vec<(String, Folder)> = folders
2891        .into_iter()
2892        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
2893        .collect();
2894
2895    let body = opml::to_opml(&subs, &folder_pairs);
2896    let mut resp = (StatusCode::OK, body).into_response();
2897    resp.headers_mut().insert(
2898        header::CONTENT_TYPE,
2899        "text/x-opml; charset=utf-8".parse().unwrap(),
2900    );
2901    resp.headers_mut().insert(
2902        header::CONTENT_DISPOSITION,
2903        "attachment; filename=\"featherreader-subscriptions.opml\""
2904            .parse()
2905            .unwrap(),
2906    );
2907    Ok(resp)
2908}
2909
2910// ---------------------------------------------------------------------------
2911// Signed session cookie (HMAC-SHA256, dependency-free)
2912// ---------------------------------------------------------------------------
2913
2914/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
2915fn set_cookie(resp: &mut Response, cookie: &str) {
2916    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
2917        resp.headers_mut()
2918            .append(axum::http::header::SET_COOKIE, value);
2919    }
2920}
2921
2922/// Whether the request came from htmx (the `HX-Request` header).
2923fn is_htmx(headers: &HeaderMap) -> bool {
2924    headers
2925        .get("HX-Request")
2926        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
2927}
2928
2929/// Whether a mark-read / star request originated from the single-entry READER
2930/// (as opposed to the list view). The reader's forms tag themselves with
2931/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
2932/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
2933/// isn't in the DOM), the list gets the row (`entry_row.html`).
2934fn is_reader_request(headers: &HeaderMap) -> bool {
2935    headers
2936        .get("X-FR-Reader")
2937        .is_some_and(|v| v.as_bytes() == b"1")
2938}
2939
2940/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
2941/// server-minted **session id** (never the DID — so the cookie can't be forged
2942/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
2943/// server-side session id).
2944mod cookie {
2945    use super::{HeaderMap, SESSION_COOKIE};
2946
2947    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
2948    pub fn sign_session(sid: &str, secret: &str) -> String {
2949        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
2950    }
2951
2952    /// Verify the request's session cookie and return the session id it carries.
2953    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
2954        verify_value(headers, SESSION_COOKIE, secret)
2955    }
2956
2957    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
2958    /// value`), so a signature minted for one cookie can't verify under another —
2959    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
2960    /// The NUL separator can't appear in a cookie name, so the encoding is
2961    /// unambiguous.
2962    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
2963        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
2964        msg.extend_from_slice(name.as_bytes());
2965        msg.push(0);
2966        msg.extend_from_slice(value.as_bytes());
2967        msg
2968    }
2969
2970    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
2971    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
2972    /// generic form behind both the session cookie and the short-lived invite
2973    /// cookie; domain-separating by name keeps a signature valid only for the
2974    /// cookie it was minted for.
2975    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
2976        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
2977        let b64 = b64url_encode(value.as_bytes());
2978        format!(
2979            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
2980        )
2981    }
2982
2983    /// Verify + read a value out of the named signed cookie (`None` on absent /
2984    /// tampered / forged / cross-cookie). The generic form behind both readers.
2985    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
2986        let raw = cookie_value(headers, name)?;
2987        let (b64, sig) = raw.split_once('.')?;
2988        let bytes = b64url_decode(b64)?;
2989        let value = String::from_utf8(bytes).ok()?;
2990        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
2991        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
2992            Some(value)
2993        } else {
2994            None
2995        }
2996    }
2997
2998    /// Pull one cookie value out of the `Cookie` request header.
2999    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
3000        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
3001        for part in header.split(';') {
3002            let part = part.trim();
3003            if let Some((k, v)) = part.split_once('=') {
3004                if k == name {
3005                    return Some(v.to_string());
3006                }
3007            }
3008        }
3009        None
3010    }
3011
3012    /// Constant-time byte comparison (avoid signature-timing leaks).
3013    fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
3014        if a.len() != b.len() {
3015            return false;
3016        }
3017        let mut diff = 0u8;
3018        for (x, y) in a.iter().zip(b.iter()) {
3019            diff |= x ^ y;
3020        }
3021        diff == 0
3022    }
3023
3024    // -- URL-safe base64 (no padding), std-only --------------------------------
3025
3026    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3027
3028    fn b64url_encode(input: &[u8]) -> String {
3029        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
3030        for chunk in input.chunks(3) {
3031            let b = [
3032                chunk[0],
3033                *chunk.get(1).unwrap_or(&0),
3034                *chunk.get(2).unwrap_or(&0),
3035            ];
3036            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
3037            out.push(B64[((n >> 18) & 63) as usize] as char);
3038            out.push(B64[((n >> 12) & 63) as usize] as char);
3039            if chunk.len() > 1 {
3040                out.push(B64[((n >> 6) & 63) as usize] as char);
3041            }
3042            if chunk.len() > 2 {
3043                out.push(B64[(n & 63) as usize] as char);
3044            }
3045        }
3046        out
3047    }
3048
3049    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
3050        fn val(c: u8) -> Option<u32> {
3051            match c {
3052                b'A'..=b'Z' => Some((c - b'A') as u32),
3053                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
3054                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
3055                b'-' => Some(62),
3056                b'_' => Some(63),
3057                _ => None,
3058            }
3059        }
3060        let bytes = input.as_bytes();
3061        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
3062        for chunk in bytes.chunks(4) {
3063            let mut n = 0u32;
3064            let mut valid = 0;
3065            for (i, &c) in chunk.iter().enumerate() {
3066                n |= val(c)? << (18 - 6 * i);
3067                valid += 1;
3068            }
3069            out.push((n >> 16) as u8);
3070            if valid > 2 {
3071                out.push((n >> 8) as u8);
3072            }
3073            if valid > 3 {
3074                out.push(n as u8);
3075            }
3076        }
3077        Some(out)
3078    }
3079
3080    // -- HMAC-SHA256, std-only -------------------------------------------------
3081
3082    /// HMAC-SHA256(key, msg) as lowercase hex.
3083    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
3084        const BLOCK: usize = 64;
3085        let mut k = [0u8; BLOCK];
3086        if key.len() > BLOCK {
3087            let d = sha256(key);
3088            k[..32].copy_from_slice(&d);
3089        } else {
3090            k[..key.len()].copy_from_slice(key);
3091        }
3092        let mut ipad = [0x36u8; BLOCK];
3093        let mut opad = [0x5cu8; BLOCK];
3094        for i in 0..BLOCK {
3095            ipad[i] ^= k[i];
3096            opad[i] ^= k[i];
3097        }
3098        let mut inner = Vec::with_capacity(BLOCK + msg.len());
3099        inner.extend_from_slice(&ipad);
3100        inner.extend_from_slice(msg);
3101        let inner_hash = sha256(&inner);
3102        let mut outer = Vec::with_capacity(BLOCK + 32);
3103        outer.extend_from_slice(&opad);
3104        outer.extend_from_slice(&inner_hash);
3105        let mac = sha256(&outer);
3106        let mut hex = String::with_capacity(64);
3107        for b in mac {
3108            hex.push_str(&format!("{b:02x}"));
3109        }
3110        hex
3111    }
3112
3113    /// SHA-256 (FIPS 180-4), std-only.
3114    fn sha256(data: &[u8]) -> [u8; 32] {
3115        const K: [u32; 64] = [
3116            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
3117            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
3118            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
3119            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
3120            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
3121            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
3122            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
3123            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
3124            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
3125            0xc67178f2,
3126        ];
3127        let mut h: [u32; 8] = [
3128            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
3129            0x5be0cd19,
3130        ];
3131
3132        let bit_len = (data.len() as u64) * 8;
3133        let mut msg = data.to_vec();
3134        msg.push(0x80);
3135        while msg.len() % 64 != 56 {
3136            msg.push(0);
3137        }
3138        msg.extend_from_slice(&bit_len.to_be_bytes());
3139
3140        for block in msg.chunks(64) {
3141            let mut w = [0u32; 64];
3142            for i in 0..16 {
3143                w[i] = u32::from_be_bytes([
3144                    block[i * 4],
3145                    block[i * 4 + 1],
3146                    block[i * 4 + 2],
3147                    block[i * 4 + 3],
3148                ]);
3149            }
3150            for i in 16..64 {
3151                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
3152                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
3153                w[i] = w[i - 16]
3154                    .wrapping_add(s0)
3155                    .wrapping_add(w[i - 7])
3156                    .wrapping_add(s1);
3157            }
3158            let mut a = h;
3159            for i in 0..64 {
3160                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
3161                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
3162                let t1 = a[7]
3163                    .wrapping_add(s1)
3164                    .wrapping_add(ch)
3165                    .wrapping_add(K[i])
3166                    .wrapping_add(w[i]);
3167                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
3168                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
3169                let t2 = s0.wrapping_add(maj);
3170                a[7] = a[6];
3171                a[6] = a[5];
3172                a[5] = a[4];
3173                a[4] = a[3].wrapping_add(t1);
3174                a[3] = a[2];
3175                a[2] = a[1];
3176                a[1] = a[0];
3177                a[0] = t1.wrapping_add(t2);
3178            }
3179            for i in 0..8 {
3180                h[i] = h[i].wrapping_add(a[i]);
3181            }
3182        }
3183
3184        let mut out = [0u8; 32];
3185        for (i, word) in h.iter().enumerate() {
3186            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
3187        }
3188        out
3189    }
3190
3191    #[cfg(test)]
3192    mod tests {
3193        use super::*;
3194
3195        #[test]
3196        fn sha256_known_vector() {
3197            let d = sha256(b"abc");
3198            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
3199            assert_eq!(
3200                hex,
3201                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
3202            );
3203        }
3204
3205        #[test]
3206        fn hmac_known_vector() {
3207            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
3208            assert_eq!(
3209                mac,
3210                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
3211            );
3212        }
3213
3214        #[test]
3215        fn sign_verify_round_trips() {
3216            let secret = "test-secret";
3217            let sid = "9f2c-opaque-session-id";
3218            let cookie = sign_session(sid, secret);
3219            let pair = cookie.split(';').next().unwrap().to_string();
3220            let mut headers = HeaderMap::new();
3221            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
3222            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
3223            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
3224            assert!(verify_session(&headers, "other-secret").is_none());
3225        }
3226
3227        #[test]
3228        fn forged_and_tampered_cookies_are_rejected() {
3229            let secret = "test-secret";
3230
3231            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
3232            //    the secret, so an arbitrary signature must not verify.
3233            let forged = format!(
3234                "{SESSION_COOKIE}={}.{}",
3235                b64url_encode(b"attacker-chosen-sid"),
3236                "deadbeef".repeat(8) // 64 hex chars, wrong sig
3237            );
3238            let mut headers = HeaderMap::new();
3239            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
3240            assert!(verify_session(&headers, secret).is_none());
3241
3242            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
3243            //    keeping the original signature — must not verify.
3244            let cookie = sign_session("real-sid", secret);
3245            let pair = cookie.split(';').next().unwrap();
3246            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
3247            let tampered = format!(
3248                "{SESSION_COOKIE}={}.{}",
3249                b64url_encode(b"different-sid"),
3250                sig
3251            );
3252            let mut headers2 = HeaderMap::new();
3253            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
3254            assert!(verify_session(&headers2, secret).is_none());
3255        }
3256
3257        #[test]
3258        fn b64url_round_trips() {
3259            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
3260                let enc = b64url_encode(s.as_bytes());
3261                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
3262            }
3263        }
3264    }
3265}
3266
3267// ---------------------------------------------------------------------------
3268// Small store helpers local to the web layer
3269// ---------------------------------------------------------------------------
3270
3271/// Fetch a single cached entry by id.
3272/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
3273///
3274/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
3275/// `did` does not subscribe to its feed. This is the per-DID read gate for the
3276/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
3277/// deduped by URL, but no DID can read another DID's cached article.
3278async fn get_entry_by_id(
3279    pool: &store::Pool,
3280    did: &str,
3281    id: i64,
3282) -> anyhow::Result<Option<store::Entry>> {
3283    let entry = sqlx::query_as::<_, store::Entry>(
3284        r#"
3285        SELECT e.* FROM entries e
3286        WHERE e.id = ?2
3287          AND EXISTS (
3288              SELECT 1 FROM sub_ref sr
3289              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3290          )
3291        "#,
3292    )
3293    .bind(did)
3294    .bind(id)
3295    .fetch_optional(pool)
3296    .await?;
3297    Ok(entry)
3298}
3299
3300/// Whether `entry_id` is marked read for `did` (absent state row = unread).
3301async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3302    let read: Option<bool> =
3303        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3304            .bind(did)
3305            .bind(entry_id)
3306            .fetch_optional(pool)
3307            .await?
3308            .flatten();
3309    Ok(read.unwrap_or(false))
3310}
3311
3312/// Whether `entry_id` is starred for `did` (absent state row = not starred).
3313async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3314    let starred: Option<bool> =
3315        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3316            .bind(did)
3317            .bind(entry_id)
3318            .fetch_optional(pool)
3319            .await?
3320            .flatten();
3321    Ok(starred.unwrap_or(false))
3322}
3323
3324/// Feed display title for one entry's feed id (via a single lookup).
3325async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
3326    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
3327        .bind(feed_id)
3328        .fetch_optional(pool)
3329        .await
3330    {
3331        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
3332        _ => String::new(),
3333    }
3334}
3335
3336/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
3337/// be forced (mark-read path) or looked up (`None` — star path).
3338async fn build_entry_row(
3339    pool: &store::Pool,
3340    did: &str,
3341    id: i64,
3342    read: Option<bool>,
3343) -> anyhow::Result<Option<EntryRow>> {
3344    let entry = match get_entry_by_id(pool, did, id).await? {
3345        Some(e) => e,
3346        None => return Ok(None),
3347    };
3348    let read = match read {
3349        Some(r) => r,
3350        None => entry_is_read(pool, did, id).await?,
3351    };
3352    let starred = entry_is_starred(pool, did, id).await?;
3353    Ok(Some(EntryRow {
3354        id: entry.id,
3355        title: entry
3356            .title
3357            .clone()
3358            .filter(|t| !t.trim().is_empty())
3359            .unwrap_or_else(|| "(untitled)".to_string()),
3360        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
3361        published: display_date(entry.published.as_deref()),
3362        read,
3363        starred,
3364        link: format!("/entries/{id}"),
3365    }))
3366}
3367
3368/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
3369fn now_rfc3339() -> String {
3370    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3371}
3372
3373#[cfg(test)]
3374mod tests {
3375    use super::*;
3376
3377    #[test]
3378    fn qenc_encodes_reserved() {
3379        assert_eq!(qenc("a b"), "a%20b");
3380        assert_eq!(
3381            qenc("https://example.com/feed.xml"),
3382            "https%3A%2F%2Fexample.com%2Ffeed.xml"
3383        );
3384        assert_eq!(
3385            qenc("at://did:plc:x/c/r"),
3386            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
3387        );
3388        // Unreserved chars pass through untouched.
3389        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
3390    }
3391
3392    #[test]
3393    fn folder_uri_shape() {
3394        assert_eq!(
3395            folder_uri("did:plc:abc", "3kfolder"),
3396            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
3397        );
3398    }
3399
3400    // -- public-feeds-only: private/paid feeds are refused --------------------
3401
3402    #[test]
3403    fn private_feeds_are_classified_private_across_providers() {
3404        // The add + OPML paths both gate on this classifier; assert it flags a
3405        // spread of paid providers (newsletters + private podcasts) and the
3406        // generic credential-in-URL shapes.
3407        for url in [
3408            "https://author.substack.com/feed/private/deadbeefcafe1234",
3409            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
3410            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
3411            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
3412            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
3413            "https://user:pass@example.com/feed",
3414        ] {
3415            assert!(
3416                feed::classify_feed_privacy(url).is_private(),
3417                "expected private: {url}"
3418            );
3419        }
3420    }
3421
3422    #[test]
3423    fn public_feeds_stay_public() {
3424        for url in [
3425            "https://author.substack.com/feed",
3426            "https://wordpress.example.com/feed/",
3427            "https://example.com/rss.xml",
3428            "https://example.org/atom.xml",
3429            // YouTube channel/playlist RSS is fully public — must not false-block.
3430            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
3431            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
3432        ] {
3433            assert!(
3434                !feed::classify_feed_privacy(url).is_private(),
3435                "expected public: {url}"
3436            );
3437        }
3438    }
3439
3440    #[test]
3441    fn private_feed_label_is_public_safe_host_only() {
3442        // The OPML skip report must never echo the secret path/query, only the host.
3443        let label =
3444            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
3445        assert_eq!(label, "author.substack.com");
3446        assert!(!label.contains("deadbeefcafe1234token"));
3447        assert!(!label.contains("/private/"));
3448        // An unparseable URL degrades to a generic label.
3449        assert_eq!(private_feed_label("not a url"), "a private feed");
3450    }
3451
3452    #[test]
3453    fn refusal_message_promises_nothing_stored() {
3454        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
3455        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
3456    }
3457
3458    #[test]
3459    fn scope_query_preserves_context() {
3460        let q = EntryQuery {
3461            feed: Some("https://example.com/feed.xml".to_string()),
3462            folder: None,
3463            view: Some("all".to_string()),
3464        };
3465        let s = scope_query(&q);
3466        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
3467        assert!(s.contains("view=all"));
3468
3469        // Default view is omitted.
3470        let q2 = EntryQuery {
3471            feed: None,
3472            folder: None,
3473            view: Some("unread".to_string()),
3474        };
3475        assert_eq!(scope_query(&q2), "");
3476    }
3477
3478    // -- closed-beta invite gate + rate-limit + cache-control ------------------
3479
3480    use axum::body::Body;
3481    use axum::http::Request;
3482    use tower::ServiceExt; // for `oneshot`
3483
3484    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
3485    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
3486    /// can forge matching cookies.
3487    async fn test_state(allowed: &[&str]) -> AppState {
3488        let db = store::init_url("sqlite::memory:").await.unwrap();
3489        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
3490        store::ensure_seed(&db, &dids).await.unwrap();
3491        let config = Config {
3492            allowed_dids: dids,
3493            cookie_secret: "test-cookie-secret-000".to_string(),
3494            beta_cap: 3,
3495            ..Config::default()
3496        };
3497        AppState::new(config, db).unwrap()
3498    }
3499
3500    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
3501    /// looked up in the registry, so create the session first).
3502    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
3503        let sid = state.sessions.create(Session {
3504            did: did.to_string(),
3505            handle: handle.map(str::to_string),
3506        });
3507        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
3508        sc.split(';').next().unwrap().to_string()
3509    }
3510
3511    #[test]
3512    fn rate_limited_paths_match_expected() {
3513        use axum::http::Method;
3514        assert!(is_rate_limited_path("/login", &Method::GET));
3515        assert!(is_rate_limited_path("/login", &Method::POST));
3516        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
3517        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
3518        assert!(is_rate_limited_path("/opml", &Method::POST));
3519        assert!(is_rate_limited_path("/read-all", &Method::POST));
3520        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
3521        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
3522        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
3523        // Read-only navigation is NOT limited.
3524        assert!(!is_rate_limited_path("/", &Method::GET));
3525        assert!(!is_rate_limited_path("/about", &Method::GET));
3526        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
3527        assert!(!is_rate_limited_path("/login", &Method::HEAD));
3528    }
3529
3530    #[test]
3531    fn rate_limiter_allows_burst_then_429s() {
3532        let rl = RateLimiter::shared();
3533        let ip: IpAddr = "203.0.113.7".parse().unwrap();
3534        // The full burst passes.
3535        for _ in 0..(RATE_BURST as usize) {
3536            assert!(rl.check(ip));
3537        }
3538        // The next one (no time elapsed → no refill) is rejected.
3539        assert!(!rl.check(ip));
3540        // A different IP has its own bucket.
3541        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
3542        assert!(rl.check(ip2));
3543    }
3544
3545    #[test]
3546    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
3547        // With NO trusted header configured, a client-supplied X-Forwarded-For
3548        // must be ignored entirely — the limiter keys on the real socket peer,
3549        // so an attacker can't mint a fresh bucket per forged XFF value.
3550        let mut h = HeaderMap::new();
3551        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
3552        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
3553        assert_eq!(
3554            client_ip(&h, Some(&sock), None),
3555            Some("203.0.113.55".parse().unwrap()),
3556            "spoofed XFF must not override the socket peer"
3557        );
3558    }
3559
3560    #[test]
3561    fn client_ip_uses_trusted_header_last_hop() {
3562        // With a trusted proxy header configured, the client IP comes from THAT
3563        // header (the proxy overwrites any client copy). On a comma list we take
3564        // the RIGHT-most hop — the one the trusted proxy appended — so a
3565        // client-forged left-most value is ignored.
3566        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
3567
3568        let mut h = HeaderMap::new();
3569        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
3570        assert_eq!(
3571            client_ip(&h, Some(&sock), Some("fly-client-ip")),
3572            Some("198.51.100.9".parse().unwrap())
3573        );
3574
3575        // Attacker prepends a forged hop; the trusted proxy appends the real one.
3576        let mut h2 = HeaderMap::new();
3577        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
3578        assert_eq!(
3579            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
3580            Some("198.51.100.9".parse().unwrap()),
3581            "must take the right-most (trusted) hop, not the forged left-most"
3582        );
3583
3584        // Trusted header absent → fall back to the socket peer.
3585        let h3 = HeaderMap::new();
3586        assert_eq!(
3587            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
3588            Some("10.0.0.1".parse().unwrap())
3589        );
3590    }
3591
3592    #[test]
3593    fn invite_cookie_round_trips_and_rejects_tamper() {
3594        let secret = "test-cookie-secret-000";
3595        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
3596        let pair = sc.split(';').next().unwrap();
3597        let mut h = HeaderMap::new();
3598        h.insert(header::COOKIE, pair.parse().unwrap());
3599        assert_eq!(
3600            invite_cookie_code(&h, secret).as_deref(),
3601            Some("FEATHER-ABCDWXYZ")
3602        );
3603        // Wrong secret → rejected.
3604        assert!(invite_cookie_code(&h, "other").is_none());
3605    }
3606
3607    #[tokio::test]
3608    async fn preflight_valid_expired_and_full() {
3609        let state = test_state(&["did:plc:admin"]).await;
3610        // A minted, active code preflights OK.
3611        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
3612            .await
3613            .unwrap();
3614        assert!(preflight_code(&state, &code).await.is_ok());
3615
3616        // A code whose expiry is in the past preflights as Expired. (mint_code
3617        // clamps negative ttl to 0, so back-date the row directly for a
3618        // deterministic past expiry.)
3619        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
3620            .await
3621            .unwrap();
3622        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
3623            .bind(chrono::Utc::now().timestamp() - 3600)
3624            .bind(&expired)
3625            .execute(&state.db)
3626            .await
3627            .unwrap();
3628        assert_eq!(
3629            preflight_code(&state, &expired).await,
3630            Err(store::RedeemError::Expired)
3631        );
3632
3633        // Unknown code → NotFound.
3634        assert_eq!(
3635            preflight_code(&state, "FEATHER-NOPENOPE").await,
3636            Err(store::RedeemError::NotFound)
3637        );
3638
3639        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
3640        // must report CapacityFull.
3641        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
3642            .await
3643            .unwrap();
3644        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
3645            .await
3646            .unwrap();
3647        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
3648        assert_eq!(
3649            preflight_code(&state, &code).await,
3650            Err(store::RedeemError::CapacityFull)
3651        );
3652    }
3653
3654    #[tokio::test]
3655    async fn login_without_invite_redirects_to_beta_redeem() {
3656        // No allow-list seed, no invite cookie: starting OAuth must be refused.
3657        let state = test_state(&[]).await;
3658        let app = router(state);
3659        let resp = app
3660            .oneshot(
3661                Request::builder()
3662                    .method("POST")
3663                    .uri("/login")
3664                    .header("content-type", "application/x-www-form-urlencoded")
3665                    .body(Body::from("handle=alice.bsky.social"))
3666                    .unwrap(),
3667            )
3668            .await
3669            .unwrap();
3670        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3671        assert_eq!(
3672            resp.headers().get(header::LOCATION).unwrap(),
3673            "/beta/redeem"
3674        );
3675    }
3676
3677    #[tokio::test]
3678    async fn login_with_valid_invite_cookie_starts_oauth() {
3679        let state = test_state(&[]).await;
3680        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
3681        let cookie = cookie.split(';').next().unwrap().to_string();
3682        let app = router(state);
3683        let resp = app
3684            .oneshot(
3685                Request::builder()
3686                    .method("POST")
3687                    .uri("/login")
3688                    .header("content-type", "application/x-www-form-urlencoded")
3689                    .header(header::COOKIE, cookie)
3690                    .body(Body::from("handle=alice.bsky.social"))
3691                    .unwrap(),
3692            )
3693            .await
3694            .unwrap();
3695        // Redirects into the sidecar login (not to /beta/redeem).
3696        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3697        let loc = resp
3698            .headers()
3699            .get(header::LOCATION)
3700            .unwrap()
3701            .to_str()
3702            .unwrap();
3703        assert!(loc.contains("/login"), "loc = {loc}");
3704        assert_ne!(loc, "/beta/redeem");
3705    }
3706
3707    #[tokio::test]
3708    async fn admin_mint_requires_admin_seed_did() {
3709        let state = test_state(&["did:plc:admin"]).await;
3710        // A non-admin (but beta'd) session is forbidden.
3711        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
3712            .await
3713            .unwrap();
3714        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
3715        // An admin session is allowed.
3716        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3717        let app = router(state);
3718
3719        let forbidden = app
3720            .clone()
3721            .oneshot(
3722                Request::builder()
3723                    .method("POST")
3724                    .uri("/admin/invites?n=2")
3725                    .header(header::COOKIE, rando_cookie)
3726                    .body(Body::empty())
3727                    .unwrap(),
3728            )
3729            .await
3730            .unwrap();
3731        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
3732
3733        let ok = app
3734            .oneshot(
3735                Request::builder()
3736                    .method("POST")
3737                    .uri("/admin/invites?n=2")
3738                    .header(header::COOKIE, admin_cookie)
3739                    .body(Body::empty())
3740                    .unwrap(),
3741            )
3742            .await
3743            .unwrap();
3744        assert_eq!(ok.status(), StatusCode::OK);
3745        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
3746            .await
3747            .unwrap();
3748        let body = String::from_utf8(bytes.to_vec()).unwrap();
3749        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
3750        assert_eq!(minted.len(), 2);
3751        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
3752    }
3753
3754    #[tokio::test]
3755    async fn admin_mint_unauthenticated_is_401() {
3756        let state = test_state(&["did:plc:admin"]).await;
3757        let app = router(state);
3758        let resp = app
3759            .oneshot(
3760                Request::builder()
3761                    .method("POST")
3762                    .uri("/admin/invites")
3763                    .body(Body::empty())
3764                    .unwrap(),
3765            )
3766            .await
3767            .unwrap();
3768        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
3769    }
3770
3771    #[tokio::test]
3772    async fn cache_control_public_on_about_no_store_on_authed() {
3773        let state = test_state(&["did:plc:admin"]).await;
3774        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3775        let app = router(state);
3776
3777        // /about → public, cacheable.
3778        let about = app
3779            .clone()
3780            .oneshot(
3781                Request::builder()
3782                    .uri("/about")
3783                    .body(Body::empty())
3784                    .unwrap(),
3785            )
3786            .await
3787            .unwrap();
3788        assert_eq!(
3789            about.headers().get(header::CACHE_CONTROL).unwrap(),
3790            "public, max-age=300"
3791        );
3792        // The security headers are still intact.
3793        assert!(about.headers().contains_key("content-security-policy"));
3794        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
3795
3796        // The bare /login landing → public, cacheable.
3797        let login = app
3798            .clone()
3799            .oneshot(
3800                Request::builder()
3801                    .uri("/login")
3802                    .body(Body::empty())
3803                    .unwrap(),
3804            )
3805            .await
3806            .unwrap();
3807        assert_eq!(
3808            login.headers().get(header::CACHE_CONTROL).unwrap(),
3809            "public, max-age=300"
3810        );
3811
3812        // An authenticated page → no-store.
3813        let home = app
3814            .oneshot(
3815                Request::builder()
3816                    .uri("/")
3817                    .header(header::COOKIE, admin_cookie)
3818                    .body(Body::empty())
3819                    .unwrap(),
3820            )
3821            .await
3822            .unwrap();
3823        assert_eq!(
3824            home.headers().get(header::CACHE_CONTROL).unwrap(),
3825            "no-store"
3826        );
3827    }
3828
3829    #[tokio::test]
3830    async fn beta_redeem_page_renders() {
3831        let state = test_state(&[]).await;
3832        let app = router(state);
3833        let resp = app
3834            .oneshot(
3835                Request::builder()
3836                    .uri("/beta/redeem")
3837                    .body(Body::empty())
3838                    .unwrap(),
3839            )
3840            .await
3841            .unwrap();
3842        assert_eq!(resp.status(), StatusCode::OK);
3843        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
3844            .await
3845            .unwrap();
3846        let html = String::from_utf8(bytes.to_vec()).unwrap();
3847        assert!(html.contains("Invite code"));
3848        assert!(html.contains("/beta/redeem"));
3849    }
3850
3851    #[tokio::test]
3852    async fn rate_limit_returns_429_after_burst() {
3853        // Configure a trusted proxy header so the limiter keys on the forwarded
3854        // IP (the oneshot harness sets no ConnectInfo socket peer).
3855        let db = store::init_url("sqlite::memory:").await.unwrap();
3856        store::ensure_seed(&db, &[]).await.unwrap();
3857        let config = Config {
3858            cookie_secret: "test-cookie-secret-000".to_string(),
3859            beta_cap: 3,
3860            trusted_ip_header: Some("cf-connecting-ip".to_string()),
3861            ..Config::default()
3862        };
3863        let state = AppState::new(config, db).unwrap();
3864        let app = router(state);
3865        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
3866        // handler itself returns 200 (re-render) on a bad code; the limiter is
3867        // what eventually yields 429.
3868        let mut saw_429 = false;
3869        for _ in 0..(RATE_BURST as usize + 5) {
3870            let resp = app
3871                .clone()
3872                .oneshot(
3873                    Request::builder()
3874                        .method("POST")
3875                        .uri("/beta/redeem")
3876                        .header("content-type", "application/x-www-form-urlencoded")
3877                        .header("cf-connecting-ip", "203.0.113.200")
3878                        .body(Body::from("code=FEATHER-NOPENOPE"))
3879                        .unwrap(),
3880                )
3881                .await
3882                .unwrap();
3883            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
3884                saw_429 = true;
3885                break;
3886            }
3887        }
3888        assert!(saw_429, "expected a 429 after exhausting the burst");
3889    }
3890
3891    #[tokio::test]
3892    async fn rate_limit_ignores_spoofed_xff_rotation() {
3893        // WITHOUT a trusted header, rotating a forged X-Forwarded-For per request
3894        // must NOT mint a fresh bucket each time: the oneshot harness sets no
3895        // socket peer, so client_ip yields None and the limiter fails open —
3896        // crucially it never keys on the attacker-chosen XFF. We assert every
3897        // request is admitted (no 429), proving the forged header is not being
3898        // used as the bucket key (which would be the vulnerable behaviour only if
3899        // it *were* trusted; here the burst can't be exhausted per-IP because the
3900        // attacker can't address a single victim bucket via XFF).
3901        let state = test_state(&[]).await;
3902        let app = router(state);
3903        for i in 0..(RATE_BURST as usize + 5) {
3904            let forged = format!("10.9.8.{}", i % 250);
3905            let resp = app
3906                .clone()
3907                .oneshot(
3908                    Request::builder()
3909                        .method("POST")
3910                        .uri("/beta/redeem")
3911                        .header("content-type", "application/x-www-form-urlencoded")
3912                        .header("x-forwarded-for", forged)
3913                        .body(Body::from("code=FEATHER-NOPENOPE"))
3914                        .unwrap(),
3915                )
3916                .await
3917                .unwrap();
3918            assert_ne!(
3919                resp.status(),
3920                StatusCode::TOO_MANY_REQUESTS,
3921                "untrusted XFF must not be used as the rate-limit key"
3922            );
3923        }
3924    }
3925
3926    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
3927
3928    /// Build a `multipart/form-data` body carrying a single `file` field whose
3929    /// contents are `payload`, returning `(content_type, body_bytes)`.
3930    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
3931        let boundary = "----featherreadertestboundary";
3932        let mut body = Vec::new();
3933        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
3934        body.extend_from_slice(
3935            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
3936        );
3937        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
3938        body.extend_from_slice(payload);
3939        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
3940        (format!("multipart/form-data; boundary={boundary}"), body)
3941    }
3942
3943    #[tokio::test]
3944    async fn opml_import_oversize_upload_returns_413() {
3945        let state = test_state(&["did:plc:admin"]).await;
3946        let cookie = session_cookie(&state, "did:plc:admin", None);
3947        let app = router(state);
3948
3949        // A payload comfortably above the 2 MiB route cap.
3950        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
3951        let (content_type, body) = opml_multipart(&payload);
3952
3953        let resp = app
3954            .oneshot(
3955                Request::builder()
3956                    .method("POST")
3957                    .uri("/opml")
3958                    .header("content-type", content_type)
3959                    .header(header::COOKIE, cookie)
3960                    .body(Body::from(body))
3961                    .unwrap(),
3962            )
3963            .await
3964            .unwrap();
3965        assert_eq!(
3966            resp.status(),
3967            StatusCode::PAYLOAD_TOO_LARGE,
3968            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
3969        );
3970    }
3971
3972    #[tokio::test]
3973    async fn opml_import_under_limit_upload_is_not_413() {
3974        let state = test_state(&["did:plc:admin"]).await;
3975        let cookie = session_cookie(&state, "did:plc:admin", None);
3976        let app = router(state);
3977
3978        // A small, valid OPML well under the cap: must be accepted (the handler
3979        // redirects to `/` or a flash), i.e. never 413.
3980        let opml = br#"<?xml version="1.0"?>
3981<opml version="2.0"><body>
3982  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
3983</body></opml>"#;
3984        let (content_type, body) = opml_multipart(opml);
3985
3986        let resp = app
3987            .oneshot(
3988                Request::builder()
3989                    .method("POST")
3990                    .uri("/opml")
3991                    .header("content-type", content_type)
3992                    .header(header::COOKIE, cookie)
3993                    .body(Body::from(body))
3994                    .unwrap(),
3995            )
3996            .await
3997            .unwrap();
3998        assert_ne!(
3999            resp.status(),
4000            StatusCode::PAYLOAD_TOO_LARGE,
4001            "an under-cap OPML upload must not be rejected as too large"
4002        );
4003    }
4004
4005    #[tokio::test]
4006    async fn opml_import_logged_out_redirects_to_login() {
4007        // Logged-out callers are redirected before the body is consumed; assert
4008        // the auth short-circuit rather than a body-cap rejection.
4009        let state = test_state(&["did:plc:admin"]).await;
4010        let app = router(state);
4011
4012        let opml = b"<opml version=\"2.0\"><body></body></opml>";
4013        let (content_type, body) = opml_multipart(opml);
4014
4015        let resp = app
4016            .oneshot(
4017                Request::builder()
4018                    .method("POST")
4019                    .uri("/opml")
4020                    .header("content-type", content_type)
4021                    .body(Body::from(body))
4022                    .unwrap(),
4023            )
4024            .await
4025            .unwrap();
4026        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4027        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
4028    }
4029
4030    // -- delete-my-data (POST /account/delete) --------------------------------
4031
4032    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
4033    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
4034    /// channel) the DID it was asked to revoke. Enough to prove the delete
4035    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
4036    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
4037        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4038        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4039        let addr = listener.local_addr().unwrap();
4040        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
4041        tokio::spawn(async move {
4042            let (mut sock, _) = listener.accept().await.unwrap();
4043            let mut buf = vec![0u8; 4096];
4044            let n = sock.read(&mut buf).await.unwrap();
4045            let req = String::from_utf8_lossy(&buf[..n]).to_string();
4046            // Pull the DID out of the JSON body (last line of the request).
4047            let did = req
4048                .split("\r\n\r\n")
4049                .nth(1)
4050                .and_then(|body| {
4051                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
4052                    v.get("did")?.as_str().map(str::to_string)
4053                })
4054                .unwrap_or_default();
4055            let is_revoke = req.starts_with("POST /internal/revoke");
4056            let body = serde_json::json!({
4057                "ok": true, "did": did, "revoked": true, "hadSession": true
4058            })
4059            .to_string();
4060            let resp = format!(
4061                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
4062                body.len(),
4063                body
4064            );
4065            sock.write_all(resp.as_bytes()).await.unwrap();
4066            sock.flush().await.unwrap();
4067            let _ = tx.send(if is_revoke { did } else { String::new() });
4068        });
4069        (format!("http://{addr}"), rx)
4070    }
4071
4072    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
4073    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
4074        let db = store::init_url("sqlite::memory:").await.unwrap();
4075        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
4076        store::ensure_seed(&db, &dids).await.unwrap();
4077        let mut config = Config {
4078            allowed_dids: dids,
4079            cookie_secret: "test-cookie-secret-000".to_string(),
4080            beta_cap: 3,
4081            ..Config::default()
4082        };
4083        config.sidecar.public_url = sidecar_url.to_string();
4084        // The revoke/session/repo calls go over the internal URL; point it at the
4085        // same mock so `/internal/*` requests land there.
4086        config.sidecar.internal_url = sidecar_url.to_string();
4087        AppState::new(config, db).unwrap()
4088    }
4089
4090    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
4091    /// the sidecar revoke for that DID, and clears the session cookie.
4092    #[tokio::test]
4093    async fn account_delete_purges_rows_and_triggers_revoke() {
4094        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
4095        let did = "did:plc:leaver";
4096        let state = test_state_with_sidecar(&[], &sidecar_url).await;
4097
4098        // Seed the DID with local rows across the per-DID tables.
4099        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
4100            .await
4101            .unwrap();
4102        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
4103        store::mint_code(&state.db, did, 3600).await.unwrap();
4104        assert!(store::has_beta_access(&state.db, did).await.unwrap());
4105
4106        let cookie = session_cookie(&state, did, Some("leaver.example"));
4107        let app = router(state.clone());
4108
4109        let resp = app
4110            .oneshot(
4111                Request::builder()
4112                    .method("POST")
4113                    .uri("/account/delete")
4114                    .header(header::COOKIE, cookie)
4115                    .header("content-type", "application/x-www-form-urlencoded")
4116                    .body(Body::from("confirm=DELETE"))
4117                    .unwrap(),
4118            )
4119            .await
4120            .unwrap();
4121
4122        // Signed out: redirect to /login with the cookie cleared.
4123        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4124        assert!(resp
4125            .headers()
4126            .get(header::LOCATION)
4127            .unwrap()
4128            .to_str()
4129            .unwrap()
4130            .starts_with("/login"));
4131        let set_cookie = resp
4132            .headers()
4133            .get(header::SET_COOKIE)
4134            .unwrap()
4135            .to_str()
4136            .unwrap();
4137        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
4138
4139        // The sidecar revoke was called for exactly this DID.
4140        let revoked_did = revoke_rx.await.unwrap();
4141        assert_eq!(
4142            revoked_did, did,
4143            "sidecar revoke must fire for the caller DID"
4144        );
4145
4146        // Local rows are gone.
4147        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
4148        let codes: i64 =
4149            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
4150                .bind(did)
4151                .fetch_one(&state.db)
4152                .await
4153                .unwrap();
4154        assert_eq!(codes, 0);
4155    }
4156
4157    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
4158    /// nothing and bounces back to /manage.
4159    #[tokio::test]
4160    async fn account_delete_without_confirm_is_a_noop() {
4161        let did = "did:plc:staying";
4162        let state = test_state(&[]).await;
4163        store::grant_access(&state.db, did, None, "test", None)
4164            .await
4165            .unwrap();
4166        let cookie = session_cookie(&state, did, None);
4167        let app = router(state.clone());
4168
4169        let resp = app
4170            .oneshot(
4171                Request::builder()
4172                    .method("POST")
4173                    .uri("/account/delete")
4174                    .header(header::COOKIE, cookie)
4175                    .header("content-type", "application/x-www-form-urlencoded")
4176                    .body(Body::from("confirm=nope"))
4177                    .unwrap(),
4178            )
4179            .await
4180            .unwrap();
4181
4182        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4183        assert!(resp
4184            .headers()
4185            .get(header::LOCATION)
4186            .unwrap()
4187            .to_str()
4188            .unwrap()
4189            .starts_with("/manage"));
4190        // Nothing deleted.
4191        assert!(store::has_beta_access(&state.db, did).await.unwrap());
4192    }
4193
4194    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
4195    /// this harness — the default sidecar URL is not served), a DID must STILL
4196    /// be unable to read or mutate an entry in a feed it does not subscribe to.
4197    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
4198    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
4199    /// every cached feed.
4200    #[tokio::test]
4201    async fn pds_outage_does_not_widen_cross_did_access() {
4202        let did_a = "did:plc:aaaa";
4203        let state = test_state(&[]).await;
4204        store::grant_access(&state.db, did_a, None, "test", None)
4205            .await
4206            .unwrap();
4207
4208        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
4209        // lives in feed_b — the one A must never touch during the outage.
4210        let feed_a = store::upsert_feed(
4211            &state.db,
4212            &store::NewFeed {
4213                url: "https://a.example/feed.xml".to_string(),
4214                title: Some("A".to_string()),
4215                ..Default::default()
4216            },
4217        )
4218        .await
4219        .unwrap();
4220        let feed_b = store::upsert_feed(
4221            &state.db,
4222            &store::NewFeed {
4223                url: "https://b.example/feed.xml".to_string(),
4224                title: Some("B".to_string()),
4225                ..Default::default()
4226            },
4227        )
4228        .await
4229        .unwrap();
4230        store::insert_entries(
4231            &state.db,
4232            feed_b,
4233            &[store::NewEntry {
4234                guid: "b-1".to_string(),
4235                url: Some("https://b.example/1".to_string()),
4236                title: Some("B one".to_string()),
4237                published: Some("2026-07-11T00:00:00Z".to_string()),
4238                content_html: Some("<p>secret B body</p>".to_string()),
4239                ..Default::default()
4240            }],
4241            0,
4242        )
4243        .await
4244        .unwrap();
4245        // A subscribes ONLY to feed_a.
4246        store::replace_sub_refs(&state.db, did_a, &[feed_a])
4247            .await
4248            .unwrap();
4249        // Read B's entry id via a transient sub_ref, then drop it so only the
4250        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
4251        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
4252            .await
4253            .unwrap();
4254        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
4255            .await
4256            .unwrap()[0]
4257            .id;
4258        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
4259            .await
4260            .unwrap();
4261
4262        let cookie = session_cookie(&state, did_a, None);
4263        let app = router(state.clone());
4264
4265        // GET /entries/{b} as A → 404 even during the outage.
4266        let get_b = app
4267            .clone()
4268            .oneshot(
4269                Request::builder()
4270                    .method("GET")
4271                    .uri(format!("/entries/{b_entry_id}"))
4272                    .header(header::COOKIE, cookie.clone())
4273                    .body(Body::empty())
4274                    .unwrap(),
4275            )
4276            .await
4277            .unwrap();
4278        assert_eq!(
4279            get_b.status(),
4280            StatusCode::NOT_FOUND,
4281            "A must not read B's entry during a PDS outage"
4282        );
4283
4284        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
4285        let read_b = app
4286            .oneshot(
4287                Request::builder()
4288                    .method("POST")
4289                    .uri(format!("/entries/{b_entry_id}/read"))
4290                    .header(header::COOKIE, cookie)
4291                    .header("content-type", "application/x-www-form-urlencoded")
4292                    .body(Body::from("read=true"))
4293                    .unwrap(),
4294            )
4295            .await
4296            .unwrap();
4297        assert_eq!(
4298            read_b.status(),
4299            StatusCode::NOT_FOUND,
4300            "A must not mark B's entry read during a PDS outage"
4301        );
4302
4303        // The fallback must NOT have widened A's sub_ref to feed_b.
4304        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
4305            .bind(did_a)
4306            .fetch_all(&state.db)
4307            .await
4308            .unwrap();
4309        assert_eq!(
4310            a_feed_ids,
4311            vec![feed_a],
4312            "outage fallback must not add feeds A never subscribed to"
4313        );
4314        // And B's entry has zero read-state (A's attempt did not mutate).
4315        let es_count: i64 =
4316            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
4317                .bind(did_a)
4318                .bind(b_entry_id)
4319                .fetch_one(&state.db)
4320                .await
4321                .unwrap();
4322        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
4323    }
4324
4325    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
4326    /// seeding `did` a beta seat + session-capable state.
4327    async fn test_state_with_caps(
4328        did: &str,
4329        max_subs_per_did: i64,
4330        max_feeds_global: i64,
4331    ) -> AppState {
4332        let db = store::init_url("sqlite::memory:").await.unwrap();
4333        let config = Config {
4334            cookie_secret: "test-cookie-secret-000".to_string(),
4335            beta_cap: 100,
4336            max_subs_per_did,
4337            max_feeds_global,
4338            ..Config::default()
4339        };
4340        store::grant_access(&db, did, None, "test", None)
4341            .await
4342            .unwrap();
4343        AppState::new(config, db).unwrap()
4344    }
4345
4346    /// An OPML document with `n` distinct public feeds.
4347    fn opml_with_feeds(n: usize) -> String {
4348        let mut outlines = String::new();
4349        for i in 0..n {
4350            outlines.push_str(&format!(
4351                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
4352            ));
4353        }
4354        format!(
4355            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
4356        )
4357    }
4358
4359    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
4360    /// distinct new feeds than the shared cache can hold caches only up to the
4361    /// ceiling — the rest are trimmed. (Regression: the import loop previously
4362    /// bypassed `max_feeds_global` entirely.)
4363    #[tokio::test]
4364    async fn opml_import_enforces_global_feeds_ceiling() {
4365        let did = "did:plc:importer";
4366        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
4367        let state = test_state_with_caps(did, 0, 3).await;
4368        let cookie = session_cookie(&state, did, None);
4369        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4370        let app = router(state.clone());
4371
4372        let resp = app
4373            .oneshot(
4374                Request::builder()
4375                    .method("POST")
4376                    .uri("/opml")
4377                    .header(header::COOKIE, cookie)
4378                    .header("content-type", ct)
4379                    .body(Body::from(body))
4380                    .unwrap(),
4381            )
4382            .await
4383            .unwrap();
4384        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4385
4386        let feeds = store::count_feeds(&state.db).await.unwrap();
4387        assert!(
4388            feeds <= 3,
4389            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
4390        );
4391    }
4392
4393    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
4394    /// cap imports zero new feeds.
4395    #[tokio::test]
4396    async fn opml_import_enforces_per_did_cap() {
4397        let did = "did:plc:capped";
4398        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
4399        let state = test_state_with_caps(did, 2, 0).await;
4400        let existing_a = store::upsert_feed(
4401            &state.db,
4402            &store::NewFeed {
4403                url: "https://have-a.example/feed.xml".to_string(),
4404                ..Default::default()
4405            },
4406        )
4407        .await
4408        .unwrap();
4409        let existing_b = store::upsert_feed(
4410            &state.db,
4411            &store::NewFeed {
4412                url: "https://have-b.example/feed.xml".to_string(),
4413                ..Default::default()
4414            },
4415        )
4416        .await
4417        .unwrap();
4418        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
4419            .await
4420            .unwrap();
4421        let before = store::count_feeds(&state.db).await.unwrap();
4422
4423        let cookie = session_cookie(&state, did, None);
4424        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4425        let app = router(state.clone());
4426        let resp = app
4427            .oneshot(
4428                Request::builder()
4429                    .method("POST")
4430                    .uri("/opml")
4431                    .header(header::COOKIE, cookie)
4432                    .header("content-type", ct)
4433                    .body(Body::from(body))
4434                    .unwrap(),
4435            )
4436            .await
4437            .unwrap();
4438        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4439        // Headroom was 0 → no new feeds imported into the shared cache.
4440        let after = store::count_feeds(&state.db).await.unwrap();
4441        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
4442    }
4443
4444    /// Single-add per-DID cap: a DID at its subscription cap is refused before
4445    /// any fetch, with the limit flash.
4446    #[tokio::test]
4447    async fn single_add_enforces_per_did_cap() {
4448        let did = "did:plc:subcapped";
4449        let state = test_state_with_caps(did, 1, 0).await;
4450        let f = store::upsert_feed(
4451            &state.db,
4452            &store::NewFeed {
4453                url: "https://have.example/feed.xml".to_string(),
4454                ..Default::default()
4455            },
4456        )
4457        .await
4458        .unwrap();
4459        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
4460        let cookie = session_cookie(&state, did, None);
4461        let app = router(state.clone());
4462        let resp = app
4463            .oneshot(
4464                Request::builder()
4465                    .method("POST")
4466                    .uri("/subscriptions")
4467                    .header(header::COOKIE, cookie)
4468                    .header("content-type", "application/x-www-form-urlencoded")
4469                    .body(Body::from("url=https://another.example/feed.xml"))
4470                    .unwrap(),
4471            )
4472            .await
4473            .unwrap();
4474        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4475        let loc = resp
4476            .headers()
4477            .get(header::LOCATION)
4478            .unwrap()
4479            .to_str()
4480            .unwrap();
4481        assert!(
4482            loc.contains("Subscription%20limit%20reached"),
4483            "expected sub-limit flash, got {loc}"
4484        );
4485    }
4486
4487    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
4488    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
4489    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
4490    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
4491    /// view (no reader header) instead swaps the row. This guards the reader OOB
4492    /// toggle wiring, which had no test.
4493    #[tokio::test]
4494    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
4495        let did = "did:plc:reader";
4496        let state = test_state(&[]).await;
4497        store::grant_access(&state.db, did, None, "test", None)
4498            .await
4499            .unwrap();
4500        let feed = store::upsert_feed(
4501            &state.db,
4502            &store::NewFeed {
4503                url: "https://reader.example/feed.xml".to_string(),
4504                title: Some("Reader".to_string()),
4505                ..Default::default()
4506            },
4507        )
4508        .await
4509        .unwrap();
4510        store::insert_entries(
4511            &state.db,
4512            feed,
4513            &[store::NewEntry {
4514                guid: "r-1".to_string(),
4515                url: Some("https://reader.example/1".to_string()),
4516                title: Some("Article".to_string()),
4517                published: Some("2026-07-11T00:00:00Z".to_string()),
4518                content_html: Some("<p>body</p>".to_string()),
4519                ..Default::default()
4520            }],
4521            0,
4522        )
4523        .await
4524        .unwrap();
4525        store::replace_sub_refs(&state.db, did, &[feed])
4526            .await
4527            .unwrap();
4528        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4529
4530        let cookie = session_cookie(&state, did, None);
4531        let app = router(state.clone());
4532
4533        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
4534        let resp = app
4535            .clone()
4536            .oneshot(
4537                Request::builder()
4538                    .method("POST")
4539                    .uri(format!("/entries/{entry_id}/read"))
4540                    .header(header::COOKIE, cookie.clone())
4541                    .header("HX-Request", "true")
4542                    .header("X-FR-Reader", "1")
4543                    .header("content-type", "application/x-www-form-urlencoded")
4544                    .body(Body::from("read=true"))
4545                    .unwrap(),
4546            )
4547            .await
4548            .unwrap();
4549        assert_eq!(resp.status(), StatusCode::OK);
4550        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4551            .await
4552            .unwrap();
4553        let html = String::from_utf8(bytes.to_vec()).unwrap();
4554        assert!(
4555            html.contains("hx-swap-oob=\"outerHTML\""),
4556            "reader response must be an OOB swap: {html}"
4557        );
4558        assert!(
4559            html.contains(r#"id="entry-actionbar""#),
4560            "reader response must be the action-bar fragment: {html}"
4561        );
4562        // Now READ: the read button reflects it (aria-pressed=true) and the
4563        // hidden value flips to `false` so the next tap marks it UNREAD.
4564        assert!(
4565            html.contains(r#"aria-pressed="true""#),
4566            "read button must show pressed after marking read: {html}"
4567        );
4568        assert!(
4569            html.contains(r#"name="read" value="false""#),
4570            "hidden read value must flip to false so a second tap reverses: {html}"
4571        );
4572
4573        // A second reader mark-read (submitting the flipped `read=false`) marks
4574        // it UNREAD again — the toggle reverses.
4575        let resp2 = app
4576            .oneshot(
4577                Request::builder()
4578                    .method("POST")
4579                    .uri(format!("/entries/{entry_id}/read"))
4580                    .header(header::COOKIE, cookie)
4581                    .header("HX-Request", "true")
4582                    .header("X-FR-Reader", "1")
4583                    .header("content-type", "application/x-www-form-urlencoded")
4584                    .body(Body::from("read=false"))
4585                    .unwrap(),
4586            )
4587            .await
4588            .unwrap();
4589        assert_eq!(resp2.status(), StatusCode::OK);
4590        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
4591            .await
4592            .unwrap();
4593        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
4594        assert!(
4595            html2.contains(r#"aria-pressed="false""#),
4596            "read button must show un-pressed after reversing: {html2}"
4597        );
4598        assert!(
4599            html2.contains(r#"name="read" value="true""#),
4600            "hidden read value must flip back to true: {html2}"
4601        );
4602    }
4603
4604    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
4605    /// action-bar — the counterpart to the reader-OOB test above.
4606    #[tokio::test]
4607    async fn list_mark_read_returns_row_not_oob_actionbar() {
4608        let did = "did:plc:listv";
4609        let state = test_state(&[]).await;
4610        store::grant_access(&state.db, did, None, "test", None)
4611            .await
4612            .unwrap();
4613        let feed = store::upsert_feed(
4614            &state.db,
4615            &store::NewFeed {
4616                url: "https://list.example/feed.xml".to_string(),
4617                title: Some("List".to_string()),
4618                ..Default::default()
4619            },
4620        )
4621        .await
4622        .unwrap();
4623        store::insert_entries(
4624            &state.db,
4625            feed,
4626            &[store::NewEntry {
4627                guid: "l-1".to_string(),
4628                url: Some("https://list.example/1".to_string()),
4629                title: Some("Article".to_string()),
4630                published: Some("2026-07-11T00:00:00Z".to_string()),
4631                ..Default::default()
4632            }],
4633            0,
4634        )
4635        .await
4636        .unwrap();
4637        store::replace_sub_refs(&state.db, did, &[feed])
4638            .await
4639            .unwrap();
4640        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4641
4642        let cookie = session_cookie(&state, did, None);
4643        let app = router(state.clone());
4644
4645        let resp = app
4646            .oneshot(
4647                Request::builder()
4648                    .method("POST")
4649                    .uri(format!("/entries/{entry_id}/read"))
4650                    .header(header::COOKIE, cookie)
4651                    .header("HX-Request", "true")
4652                    .header("content-type", "application/x-www-form-urlencoded")
4653                    .body(Body::from("read=true"))
4654                    .unwrap(),
4655            )
4656            .await
4657            .unwrap();
4658        assert_eq!(resp.status(), StatusCode::OK);
4659        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4660            .await
4661            .unwrap();
4662        let html = String::from_utf8(bytes.to_vec()).unwrap();
4663        assert!(
4664            !html.contains("hx-swap-oob"),
4665            "list-view response must NOT be an OOB swap: {html}"
4666        );
4667    }
4668
4669    // -----------------------------------------------------------------------
4670    // Rename parity (POST /subscriptions/{rkey}/rename)
4671    // -----------------------------------------------------------------------
4672
4673    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
4674    /// its global ceiling must be refused (capacity flash) and must NOT insert a
4675    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
4676    /// rename loop can't inflate the shared cache past the cap.
4677    #[tokio::test]
4678    async fn rename_to_new_url_refused_at_global_feeds_cap() {
4679        let did = "did:plc:renamer";
4680        // Global cap 1; pre-fill it with one feed so headroom is 0.
4681        let state = test_state_with_caps(did, 0, 1).await;
4682        store::upsert_feed(
4683            &state.db,
4684            &store::NewFeed {
4685                url: "https://existing.example/feed.xml".to_string(),
4686                ..Default::default()
4687            },
4688        )
4689        .await
4690        .unwrap();
4691        let before = store::count_feeds(&state.db).await.unwrap();
4692        assert_eq!(before, 1);
4693
4694        let cookie = session_cookie(&state, did, None);
4695        let app = router(state.clone());
4696        let resp = app
4697            .oneshot(
4698                Request::builder()
4699                    .method("POST")
4700                    .uri("/subscriptions/rkey123/rename")
4701                    .header(header::COOKIE, cookie)
4702                    .header("content-type", "application/x-www-form-urlencoded")
4703                    // A URL not in the cache → would be a NEW feeds row.
4704                    .body(Body::from(
4705                        "url=https://brand-new.example/feed.xml&title=Renamed",
4706                    ))
4707                    .unwrap(),
4708            )
4709            .await
4710            .unwrap();
4711        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4712        let loc = resp
4713            .headers()
4714            .get(header::LOCATION)
4715            .unwrap()
4716            .to_str()
4717            .unwrap();
4718        assert!(
4719            loc.contains("feed%20capacity"),
4720            "expected the feed-capacity flash, got {loc}"
4721        );
4722        // No new feeds row was inserted.
4723        let after = store::count_feeds(&state.db).await.unwrap();
4724        assert_eq!(
4725            after, before,
4726            "rename inflated the shared cache past the cap"
4727        );
4728    }
4729
4730    /// A rename to an EXISTING URL adds no row, so it is allowed even at the
4731    /// global cap (only new URLs are gated) — the other half of the guard.
4732    #[tokio::test]
4733    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
4734        let did = "did:plc:renamer2";
4735        let state = test_state_with_caps(did, 0, 1).await;
4736        store::upsert_feed(
4737            &state.db,
4738            &store::NewFeed {
4739                url: "https://existing.example/feed.xml".to_string(),
4740                ..Default::default()
4741            },
4742        )
4743        .await
4744        .unwrap();
4745        let before = store::count_feeds(&state.db).await.unwrap();
4746
4747        let cookie = session_cookie(&state, did, None);
4748        let app = router(state.clone());
4749        let resp = app
4750            .oneshot(
4751                Request::builder()
4752                    .method("POST")
4753                    .uri("/subscriptions/rkey123/rename")
4754                    .header(header::COOKIE, cookie)
4755                    .header("content-type", "application/x-www-form-urlencoded")
4756                    .body(Body::from(
4757                        "url=https://existing.example/feed.xml&title=Retitled",
4758                    ))
4759                    .unwrap(),
4760            )
4761            .await
4762            .unwrap();
4763        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4764        let loc = resp
4765            .headers()
4766            .get(header::LOCATION)
4767            .unwrap()
4768            .to_str()
4769            .unwrap();
4770        assert_eq!(
4771            loc, "/",
4772            "retitle of an existing feed must succeed, got {loc}"
4773        );
4774        assert_eq!(
4775            store::count_feeds(&state.db).await.unwrap(),
4776            before,
4777            "retitle must not add a feeds row"
4778        );
4779    }
4780
4781    /// A rename with a blank/empty `url` must write NOTHING — no `feeds` row and
4782    /// no PDS update — and just redirect home. Guards the empty-URL early return.
4783    #[tokio::test]
4784    async fn rename_with_blank_url_writes_nothing() {
4785        let did = "did:plc:renamer3";
4786        let state = test_state_with_caps(did, 0, 0).await;
4787        let before = store::count_feeds(&state.db).await.unwrap();
4788        assert_eq!(before, 0);
4789
4790        let cookie = session_cookie(&state, did, None);
4791        let app = router(state.clone());
4792        let resp = app
4793            .oneshot(
4794                Request::builder()
4795                    .method("POST")
4796                    .uri("/subscriptions/rkey123/rename")
4797                    .header(header::COOKIE, cookie)
4798                    .header("content-type", "application/x-www-form-urlencoded")
4799                    // Whitespace-only URL trims to empty.
4800                    .body(Body::from("url=%20%20&title=Nope"))
4801                    .unwrap(),
4802            )
4803            .await
4804            .unwrap();
4805        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4806        assert_eq!(
4807            resp.headers()
4808                .get(header::LOCATION)
4809                .unwrap()
4810                .to_str()
4811                .unwrap(),
4812            "/",
4813        );
4814        // Nothing was cached.
4815        assert_eq!(
4816            store::count_feeds(&state.db).await.unwrap(),
4817            0,
4818            "blank-URL rename wrote a junk feeds row"
4819        );
4820    }
4821
4822    /// Folder pre-selection regression: the manage rename row must mark the
4823    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
4824    /// re-submits the current folder instead of silently un-foldering the feed.
4825    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
4826    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
4827    #[test]
4828    fn manage_rename_row_preselects_current_folder() {
4829        let nav = Nav {
4830            handle: "@reader.example".to_string(),
4831            avatar: "RE".to_string(),
4832            view: "unread".to_string(),
4833            scope_qs: String::new(),
4834            folders: Vec::new(),
4835            loose_feeds: Vec::new(),
4836            manage_active: true,
4837        };
4838        let folder_options = vec![
4839            FolderOption {
4840                uri: "at://did:plc:x/app.folder/work".to_string(),
4841                name: "Work".to_string(),
4842            },
4843            FolderOption {
4844                uri: "at://did:plc:x/app.folder/fun".to_string(),
4845                name: "Fun".to_string(),
4846            },
4847        ];
4848        // A foldered feed (in "Work") and a loose feed (no folder), each with a
4849        // non-empty rkey so the rename form renders.
4850        let foldered = FeedView {
4851            rkey: "sub-foldered".to_string(),
4852            url: "https://work.example/feed.xml".to_string(),
4853            title: "Work Feed".to_string(),
4854            unread: 0,
4855            selected: false,
4856            folder: Some("at://did:plc:x/app.folder/work".to_string()),
4857        };
4858        let loose = FeedView {
4859            rkey: "sub-loose".to_string(),
4860            url: "https://loose.example/feed.xml".to_string(),
4861            title: "Loose Feed".to_string(),
4862            unread: 0,
4863            selected: false,
4864            folder: None,
4865        };
4866        let tmpl = ManageTemplate {
4867            version: VERSION,
4868            repo_url: REPO_URL,
4869            kofi_url: KOFI_URL,
4870            flash: String::new(),
4871            nav,
4872            folder_options,
4873            folders: vec![FolderView {
4874                rkey: "folder-work".to_string(),
4875                uri: "at://did:plc:x/app.folder/work".to_string(),
4876                name: "Work".to_string(),
4877                feeds: vec![foldered],
4878                selected: false,
4879            }],
4880            loose_feeds: vec![loose],
4881        };
4882        let html = tmpl.render().unwrap();
4883
4884        // The foldered feed's "Work" option is pre-selected.
4885        assert!(
4886            html.contains(
4887                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
4888            ),
4889            "foldered feed must pre-select its current folder: {html}"
4890        );
4891        // The loose feed's "No folder" option is pre-selected (appears for the
4892        // loose row, which has folder=None).
4893        assert!(
4894            html.contains(r#"<option value="" selected>No folder</option>"#),
4895            "loose feed must pre-select 'No folder': {html}"
4896        );
4897    }
4898}