feather_reader/lib.rs
1//! **FeatherReader** — a minimalist, atproto-native RSS/Atom feed reader.
2//!
3//! Your feed subscriptions live in your own [atproto](https://atproto.com) PDS
4//! (via the open `community.lexicon.rss.*` community lexicon), so your reading
5//! list follows you across any compatible reader — you own your data, not the
6//! app. Minimalist by design.
7//!
8//! This crate ships as a single server binary (`featherreader`) plus this small
9//! library, which declares the module tree and the shared types the binary and
10//! its subsystems build on. The heavy lifting lives in sibling modules:
11//!
12//! - [`config`] — env-driven runtime configuration (`FEATHERREADER_*`).
13//! - [`lexicon`] — the `community.lexicon.rss.*` record schemas (subscription,
14//! folder, saved, readState) as serde types.
15//! - [`store`] — the per-DID SQLite cache + read-state working copy (sqlx,
16//! runtime queries).
17//! - [`feed`] — polite fetching (conditional GET, backoff), feed-rs parsing,
18//! and ammonia sanitization.
19//! - [`atproto`] — the atproto identity + PDS record layer (subscriptions,
20//! folders, saved, batched read-state sync), including the Node sidecar's
21//! client ([`atproto::SidecarClient`]).
22//! - [`oauth`] — the Rust-native atproto OAuth client (the `rust` repo
23//! backend, which production runs).
24//! - [`repo`] — the one dispatcher every `com.atproto.repo.*` call goes
25//! through, choosing the sidecar or the Rust client by
26//! `FEATHERREADER_REPO_BACKEND`.
27//! - [`standard_site`] — reading standard.site publications from their
28//! authors' repos.
29//! - [`network`] — read-only queries against the *public* atproto network (the
30//! relay adoption probe). A projection, never a source of truth, and never on
31//! a reader path.
32//! - [`web`] — the axum router + askama server-rendered views.
33//! - [`sanitized_html`] — the reader's article body, re-sanitized at render
34//! so the template never emits a stored string unescaped.
35//!
36//! **Status:** experimental / pre-1.0. See <https://feather-reader.com>.
37
38// The module tree; the layout owns the wiring between subsystems.
39pub mod atproto;
40pub mod config;
41pub mod feed;
42pub mod lexicon;
43pub mod metrics;
44pub mod net;
45pub mod network;
46pub mod oauth;
47pub mod readstate;
48pub mod repo;
49pub mod runtime_health;
50pub mod safe_link;
51pub mod sanitized_html;
52pub mod standard_site;
53pub mod store;
54pub mod vetted;
55pub mod web;
56
57use std::collections::HashMap;
58use std::sync::{Arc, RwLock};
59
60use atproto::SidecarClient;
61use config::Config;
62use store::Pool;
63
64/// One logged-in identity, resolved from the OAuth sidecar and keyed by DID.
65///
66/// The DID is the primary key for everything local; the handle is carried for
67/// display. This is what the signed session cookie resolves to.
68#[derive(Clone, Debug)]
69pub struct Session {
70 /// The account DID (the primary key for all per-user local state).
71 pub did: String,
72 /// The account handle at login time (display only).
73 pub handle: Option<String>,
74}
75
76/// In-memory session registry: **opaque random session-id → [`Session`]**.
77///
78/// The signed cookie carries a random, server-minted session id (`sid`), *not*
79/// the DID: the DID is never attacker-supplied, so a session cookie cannot be
80/// forged by resolving a victim's DID — an attacker would need both the server's
81/// HMAC secret *and* to guess a 256-bit random sid that only exists server-side.
82/// Sessions are therefore also **revocable** (drop the sid → the cookie is dead)
83/// and are cleared on restart (every client re-logs in; the durable OAuth
84/// session still lives in the sidecar's store).
85#[derive(Clone, Default)]
86pub struct SessionRegistry {
87 inner: Arc<RwLock<HashMap<String, Session>>>,
88}
89
90impl SessionRegistry {
91 /// A fresh, empty registry.
92 pub fn new() -> Self {
93 Self::default()
94 }
95
96 /// Create a new session for `session`, returning its freshly-minted random
97 /// session id (the value the signed cookie carries).
98 pub fn create(&self, session: Session) -> String {
99 let sid = new_session_id();
100 if let Ok(mut map) = self.inner.write() {
101 map.insert(sid.clone(), session);
102 }
103 sid
104 }
105
106 /// Look up a session by its opaque session id.
107 pub fn get(&self, sid: &str) -> Option<Session> {
108 self.inner.read().ok().and_then(|m| m.get(sid).cloned())
109 }
110
111 /// Drop a session by its session id (logout / revoke).
112 pub fn remove(&self, sid: &str) {
113 if let Ok(mut map) = self.inner.write() {
114 map.remove(sid);
115 }
116 }
117}
118
119/// Mint a fresh, unguessable session id: 32 random bytes (256 bits) as URL-safe
120/// hex. Sourced from the OS CSPRNG via `getrandom` (pulled in transitively);
121/// falls back to a time+address-seeded mix only if the OS RNG is unavailable,
122/// which never happens on the supported platforms.
123fn new_session_id() -> String {
124 let mut bytes = [0u8; 32];
125 if getrandom::fill(&mut bytes).is_err() {
126 // Extremely defensive fallback: mix a few entropy-ish sources. Not used
127 // on any supported platform (getrandom uses the OS CSPRNG).
128 use std::time::{SystemTime, UNIX_EPOCH};
129 let nanos = SystemTime::now()
130 .duration_since(UNIX_EPOCH)
131 .map(|d| d.as_nanos())
132 .unwrap_or(0);
133 let seed = nanos as u64 ^ (&bytes as *const _ as u64);
134 let mut x = seed | 1;
135 for b in bytes.iter_mut() {
136 // xorshift64 — only reached if the OS CSPRNG is unavailable.
137 x ^= x << 13;
138 x ^= x >> 7;
139 x ^= x << 17;
140 *b = (x & 0xff) as u8;
141 }
142 }
143 let mut s = String::with_capacity(64);
144 for b in bytes {
145 use std::fmt::Write;
146 let _ = write!(s, "{b:02x}");
147 }
148 s
149}
150
151/// Shared application state handed to every axum handler.
152///
153/// Holds the resolved [`Config`], the SQLite pool, a shared `reqwest::Client`
154/// (feed fetch + sidecar calls), the [`SidecarClient`] (the live atproto
155/// `com.atproto.repo.*` path), and the in-memory [`SessionRegistry`] (DID ↔
156/// handle, resolved via the sidecar's `/internal/session`). It is `Clone` (cheap
157/// — everything is behind `Arc`/handles) and is cloned into each request. It
158/// lives in the library so both [`web`] and the `featherreader` binary share it.
159#[derive(Clone)]
160pub struct AppState {
161 /// Immutable runtime configuration.
162 pub config: Arc<Config>,
163 /// The per-DID SQLite cache pool.
164 pub db: Pool,
165 /// Shared HTTP client (feed fetch + sidecar internal API).
166 pub http: reqwest::Client,
167 /// The atproto OAuth sidecar client — the repo-op path when
168 /// [`Config::repo_backend`] selects `Sidecar`. Production selects `Rust`.
169 pub sidecar: SidecarClient,
170 /// DID ↔ handle session registry (cookie-resolved identity).
171 pub sessions: SessionRegistry,
172 /// Repo-op latency for BOTH backends, for reading the two side by side
173 /// across a cutover flip.
174 pub metrics: Arc<metrics::RepoMetrics>,
175 /// The Rust OAuth client's runtime. `None` when it could not be built —
176 /// tolerated only while the sidecar is the selected backend, and refused at
177 /// startup otherwise.
178 pub oauth: Option<Arc<oauth::runtime::OauthRuntime>>,
179 /// What the background loops are doing right now — the poll heartbeat and
180 /// the watermark pause. Written by the scheduler, read by `/health` and
181 /// `/stats`. See [`runtime_health`] for why these two states needed a home
182 /// outside the log stream.
183 pub runtime_health: Arc<runtime_health::RuntimeHealth>,
184 /// Whether ingest is starved of sanitize permits (#226), read by
185 /// `/stats`: [`feed::SANITIZE_STARVATION`], which the poll path records
186 /// into; tests swap in their own.
187 pub sanitize_starvation: &'static feed::Starvation,
188}
189
190impl AppState {
191 /// Assemble the shared state from config + an initialized store pool.
192 ///
193 /// Builds the shared HTTP client and the [`SidecarClient`] from the config's
194 /// [`crate::config::SidecarConfig`], and starts with an empty session
195 /// registry. The binary's `main` calls this after opening the store.
196 pub fn new(config: Config, db: Pool) -> anyhow::Result<Self> {
197 let http = build_http_client()?;
198
199 // Built whatever the backend, so a bad OAuth config is caught on every
200 // deploy rather than at the moment the switch is thrown. With the
201 // sidecar selected a failure is only a warning; with the Rust backend
202 // selected it is fatal, because there would be nothing to serve with.
203 let oauth = match oauth::runtime::OauthRuntime::new(&config) {
204 Ok(runtime) => Some(Arc::new(runtime)),
205 Err(err) if config.repo_backend == metrics::Backend::Sidecar => {
206 tracing::warn!(
207 %err,
208 "the Rust OAuth runtime could not be built; the sidecar backend is \
209 unaffected, but FEATHERREADER_REPO_BACKEND=rust would refuse to start"
210 );
211 None
212 }
213 Err(err) => return Err(err.context(
214 "FEATHERREADER_REPO_BACKEND=rust, but the Rust OAuth runtime could not be built",
215 )),
216 };
217 let sidecar = SidecarClient::new(
218 http.clone(),
219 config.sidecar.public_url.clone(),
220 config.sidecar.internal_url.clone(),
221 config.sidecar.internal_secret.clone(),
222 );
223 Ok(Self {
224 config: Arc::new(config),
225 db,
226 http,
227 sidecar,
228 sessions: SessionRegistry::new(),
229 metrics: Arc::new(metrics::RepoMetrics::new()),
230 oauth,
231 runtime_health: Arc::new(runtime_health::RuntimeHealth::new()),
232 sanitize_starvation: &feed::SANITIZE_STARVATION,
233 })
234 }
235}
236
237/// The shared HTTP client [`AppState`] carries, also used by the binary's
238/// maintenance commands (`--revoke-all-sessions`) so they reach the network
239/// exactly as the serving app does.
240///
241/// `.no_proxy()` for the same reason as `net::build_pinned_client` and
242/// `feed::build_client`: ambient `HTTP_PROXY` would route this client's traffic
243/// through a proxy that resolves hostnames itself, out from under the SSRF
244/// guard's address checks.
245pub fn build_http_client() -> reqwest::Result<reqwest::Client> {
246 reqwest::Client::builder()
247 .user_agent(USER_AGENT)
248 .no_proxy()
249 .build()
250}
251
252/// Run `future` on a new multi-thread runtime — what `#[tokio::main]` builds —
253/// then shut that runtime down waiting **at most `shutdown`** for work still on
254/// its blocking pool, where dropping it would wait without limit. The server's
255/// `main` runs on this; see its `RUNTIME_SHUTDOWN_TIMEOUT` for why (#226: an
256/// abandoned ingest sanitize cannot be cancelled).
257pub fn block_on_then_shutdown<F: std::future::Future>(
258 future: F,
259 shutdown: std::time::Duration,
260) -> std::io::Result<F::Output> {
261 let runtime = tokio::runtime::Builder::new_multi_thread()
262 .enable_all()
263 .build()?;
264 let output = runtime.block_on(future);
265 runtime.shutdown_timeout(shutdown);
266 Ok(output)
267}
268
269/// The crate version — surfaced for the server's `--version` / health output.
270pub const VERSION: &str = env!("CARGO_PKG_VERSION");
271
272/// The `User-Agent` FeatherReader identifies itself with when fetching feeds.
273///
274/// Being a polite, identifiable client is a feed-hygiene requirement (§5 of the
275/// design): publishers ask readers to say who they are so they can be reached or
276/// rate-limited sanely rather than silently blocked.
277pub const USER_AGENT: &str = concat!(
278 "featherreader/",
279 env!("CARGO_PKG_VERSION"),
280 " (+https://feather-reader.com)"
281);
282
283#[cfg(test)]
284mod runtime_shutdown_tests {
285 use super::*;
286 use std::time::{Duration, Instant};
287
288 /// Blocking work still running when the future returns holds the shutdown
289 /// up for the bound and no longer — a plain drop of the runtime would wait
290 /// the full 10 s for it (vacuous-test hunt of #274: `main`'s
291 /// `shutdown_timeout` had no test).
292 #[test]
293 fn shutdown_waits_for_blocking_work_at_most_the_bound() {
294 let started = Instant::now();
295 let out = block_on_then_shutdown(
296 async {
297 drop(tokio::task::spawn_blocking(|| {
298 std::thread::sleep(Duration::from_secs(10))
299 }));
300 7
301 },
302 Duration::from_millis(100),
303 )
304 .unwrap();
305 let took = started.elapsed();
306 assert_eq!(out, 7);
307 assert!(took < Duration::from_secs(2), "shutdown took {took:?}");
308 }
309}