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