Skip to main content

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). Live repo writes go through the
21//!   OAuth confidential-client sidecar ([`atproto::SidecarClient`]).
22//! - [`network`] — read-only queries against the *public* atproto network (the
23//!   relay adoption probe). A projection, never a source of truth, and never on
24//!   a reader path.
25//! - [`web`]     — the axum router + askama server-rendered views.
26//!
27//! **Status:** experimental / pre-1.0. See <https://feather-reader.com>.
28
29// The module tree; the layout owns the wiring between subsystems.
30pub mod atproto;
31pub mod config;
32pub mod feed;
33pub mod lexicon;
34pub mod metrics;
35pub mod net;
36pub mod network;
37pub mod oauth;
38pub mod readstate;
39pub mod repo;
40pub mod runtime_health;
41pub mod safe_link;
42pub mod standard_site;
43pub mod store;
44pub mod vetted;
45pub mod web;
46
47use std::collections::HashMap;
48use std::sync::{Arc, RwLock};
49
50use atproto::SidecarClient;
51use config::Config;
52use store::Pool;
53
54/// One logged-in identity, resolved from the OAuth sidecar and keyed by DID.
55///
56/// The DID is the primary key for everything local; the handle is carried for
57/// display. This is what the signed session cookie resolves to.
58#[derive(Clone, Debug)]
59pub struct Session {
60    /// The account DID (the primary key for all per-user local state).
61    pub did: String,
62    /// The account handle at login time (display only).
63    pub handle: Option<String>,
64}
65
66/// In-memory session registry: **opaque random session-id → [`Session`]**.
67///
68/// The signed cookie carries a random, server-minted session id (`sid`), *not*
69/// the DID: the DID is never attacker-supplied, so a session cookie cannot be
70/// forged by resolving a victim's DID — an attacker would need both the server's
71/// HMAC secret *and* to guess a 256-bit random sid that only exists server-side.
72/// Sessions are therefore also **revocable** (drop the sid → the cookie is dead)
73/// and are cleared on restart (every client re-logs in; the durable OAuth
74/// session still lives in the sidecar's store).
75#[derive(Clone, Default)]
76pub struct SessionRegistry {
77    inner: Arc<RwLock<HashMap<String, Session>>>,
78}
79
80impl SessionRegistry {
81    /// A fresh, empty registry.
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Create a new session for `session`, returning its freshly-minted random
87    /// session id (the value the signed cookie carries).
88    pub fn create(&self, session: Session) -> String {
89        let sid = new_session_id();
90        if let Ok(mut map) = self.inner.write() {
91            map.insert(sid.clone(), session);
92        }
93        sid
94    }
95
96    /// Look up a session by its opaque session id.
97    pub fn get(&self, sid: &str) -> Option<Session> {
98        self.inner.read().ok().and_then(|m| m.get(sid).cloned())
99    }
100
101    /// Drop a session by its session id (logout / revoke).
102    pub fn remove(&self, sid: &str) {
103        if let Ok(mut map) = self.inner.write() {
104            map.remove(sid);
105        }
106    }
107}
108
109/// Mint a fresh, unguessable session id: 32 random bytes (256 bits) as URL-safe
110/// hex. Sourced from the OS CSPRNG via `getrandom` (pulled in transitively);
111/// falls back to a time+address-seeded mix only if the OS RNG is unavailable,
112/// which never happens on the supported platforms.
113fn new_session_id() -> String {
114    let mut bytes = [0u8; 32];
115    if getrandom::fill(&mut bytes).is_err() {
116        // Extremely defensive fallback: mix a few entropy-ish sources. Not used
117        // on any supported platform (getrandom uses the OS CSPRNG).
118        use std::time::{SystemTime, UNIX_EPOCH};
119        let nanos = SystemTime::now()
120            .duration_since(UNIX_EPOCH)
121            .map(|d| d.as_nanos())
122            .unwrap_or(0);
123        let seed = nanos as u64 ^ (&bytes as *const _ as u64);
124        let mut x = seed | 1;
125        for b in bytes.iter_mut() {
126            // xorshift64 — only reached if the OS CSPRNG is unavailable.
127            x ^= x << 13;
128            x ^= x >> 7;
129            x ^= x << 17;
130            *b = (x & 0xff) as u8;
131        }
132    }
133    let mut s = String::with_capacity(64);
134    for b in bytes {
135        use std::fmt::Write;
136        let _ = write!(s, "{b:02x}");
137    }
138    s
139}
140
141/// Shared application state handed to every axum handler.
142///
143/// Holds the resolved [`Config`], the SQLite pool, a shared [`reqwest::Client`]
144/// (feed fetch + sidecar calls), the [`SidecarClient`] (the live atproto
145/// `com.atproto.repo.*` path), and the in-memory [`SessionRegistry`] (DID ↔
146/// handle, resolved via the sidecar's `/internal/session`). It is `Clone` (cheap
147/// — everything is behind `Arc`/handles) and is cloned into each request. It
148/// lives in the library so both [`web`] and the `featherreader` binary share it.
149#[derive(Clone)]
150pub struct AppState {
151    /// Immutable runtime configuration.
152    pub config: Arc<Config>,
153    /// The per-DID SQLite cache pool.
154    pub db: Pool,
155    /// Shared HTTP client (feed fetch + sidecar internal API).
156    pub http: reqwest::Client,
157    /// The atproto OAuth sidecar client — the repo-op path when
158    /// [`Config::repo_backend`] selects `Sidecar`. Production selects `Rust`.
159    pub sidecar: SidecarClient,
160    /// DID ↔ handle session registry (cookie-resolved identity).
161    pub sessions: SessionRegistry,
162    /// Repo-op latency for BOTH backends, for reading the two side by side
163    /// across a cutover flip.
164    pub metrics: Arc<metrics::RepoMetrics>,
165    /// The Rust OAuth client's runtime. `None` when it could not be built —
166    /// tolerated only while the sidecar is the selected backend, and refused at
167    /// startup otherwise.
168    pub oauth: Option<Arc<oauth::runtime::OauthRuntime>>,
169    /// What the background loops are doing right now — the poll heartbeat and
170    /// the watermark pause. Written by the scheduler, read by `/health` and
171    /// `/stats`. See [`runtime_health`] for why these two states needed a home
172    /// outside the log stream.
173    pub runtime_health: Arc<runtime_health::RuntimeHealth>,
174}
175
176impl AppState {
177    /// Assemble the shared state from config + an initialized store pool.
178    ///
179    /// Builds the shared HTTP client and the [`SidecarClient`] from the config's
180    /// [`crate::config::SidecarConfig`], and starts with an empty session
181    /// registry. The binary's `main` calls this after opening the store.
182    pub fn new(config: Config, db: Pool) -> anyhow::Result<Self> {
183        // `.no_proxy()` for the same reason as `net::build_pinned_client` and
184        // `feed::build_client`: ambient `HTTP_PROXY` would route this client's
185        // traffic through a proxy that resolves hostnames itself, out from
186        // under the SSRF guard's address checks.
187        let http = reqwest::Client::builder()
188            .user_agent(USER_AGENT)
189            .no_proxy()
190            .build()?;
191
192        // Built whatever the backend, so a bad OAuth config is caught on every
193        // deploy rather than at the moment the switch is thrown. With the
194        // sidecar selected a failure is only a warning; with the Rust backend
195        // selected it is fatal, because there would be nothing to serve with.
196        let oauth = match oauth::runtime::OauthRuntime::new(&config) {
197            Ok(runtime) => Some(Arc::new(runtime)),
198            Err(err) if config.repo_backend == metrics::Backend::Sidecar => {
199                tracing::warn!(
200                    %err,
201                    "the Rust OAuth runtime could not be built; the sidecar backend is \
202                     unaffected, but FEATHERREADER_REPO_BACKEND=rust would refuse to start"
203                );
204                None
205            }
206            Err(err) => return Err(err.context(
207                "FEATHERREADER_REPO_BACKEND=rust, but the Rust OAuth runtime could not be built",
208            )),
209        };
210        let sidecar = SidecarClient::new(
211            http.clone(),
212            config.sidecar.public_url.clone(),
213            config.sidecar.internal_url.clone(),
214            config.sidecar.internal_secret.clone(),
215        );
216        Ok(Self {
217            config: Arc::new(config),
218            db,
219            http,
220            sidecar,
221            sessions: SessionRegistry::new(),
222            metrics: Arc::new(metrics::RepoMetrics::new()),
223            oauth,
224            runtime_health: Arc::new(runtime_health::RuntimeHealth::new()),
225        })
226    }
227}
228
229/// The crate version — surfaced for the server's `--version` / health output.
230pub const VERSION: &str = env!("CARGO_PKG_VERSION");
231
232/// The `User-Agent` FeatherReader identifies itself with when fetching feeds.
233///
234/// Being a polite, identifiable client is a feed-hygiene requirement (§5 of the
235/// design): publishers ask readers to say who they are so they can be reached or
236/// rate-limited sanely rather than silently blocked.
237pub const USER_AGENT: &str = concat!(
238    "featherreader/",
239    env!("CARGO_PKG_VERSION"),
240    " (+https://feather-reader.com)"
241);