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//! - [`web`]     — the axum router + askama server-rendered views.
23//!
24//! **Status:** experimental / pre-1.0. See <https://feather-reader.com>.
25
26// The module tree; the layout owns the wiring between subsystems.
27pub mod atproto;
28pub mod config;
29pub mod feed;
30pub mod lexicon;
31pub mod net;
32pub mod store;
33pub mod web;
34
35use std::collections::HashMap;
36use std::sync::{Arc, RwLock};
37
38use atproto::SidecarClient;
39use config::Config;
40use store::Pool;
41
42/// One logged-in identity, resolved from the OAuth sidecar and keyed by DID.
43///
44/// The DID is the primary key for everything local; the handle is carried for
45/// display. This is what the signed session cookie resolves to.
46#[derive(Clone, Debug)]
47pub struct Session {
48    /// The account DID (the primary key for all per-user local state).
49    pub did: String,
50    /// The account handle at login time (display only).
51    pub handle: Option<String>,
52}
53
54/// In-memory session registry: **opaque random session-id → [`Session`]**.
55///
56/// The signed cookie carries a random, server-minted session id (`sid`), *not*
57/// the DID: the DID is never attacker-supplied, so a session cookie cannot be
58/// forged by resolving a victim's DID — an attacker would need both the server's
59/// HMAC secret *and* to guess a 256-bit random sid that only exists server-side.
60/// Sessions are therefore also **revocable** (drop the sid → the cookie is dead)
61/// and are cleared on restart (every client re-logs in; the durable OAuth
62/// session still lives in the sidecar's store).
63#[derive(Clone, Default)]
64pub struct SessionRegistry {
65    inner: Arc<RwLock<HashMap<String, Session>>>,
66}
67
68impl SessionRegistry {
69    /// A fresh, empty registry.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Create a new session for `session`, returning its freshly-minted random
75    /// session id (the value the signed cookie carries).
76    pub fn create(&self, session: Session) -> String {
77        let sid = new_session_id();
78        if let Ok(mut map) = self.inner.write() {
79            map.insert(sid.clone(), session);
80        }
81        sid
82    }
83
84    /// Look up a session by its opaque session id.
85    pub fn get(&self, sid: &str) -> Option<Session> {
86        self.inner.read().ok().and_then(|m| m.get(sid).cloned())
87    }
88
89    /// Drop a session by its session id (logout / revoke).
90    pub fn remove(&self, sid: &str) {
91        if let Ok(mut map) = self.inner.write() {
92            map.remove(sid);
93        }
94    }
95}
96
97/// Mint a fresh, unguessable session id: 32 random bytes (256 bits) as URL-safe
98/// hex. Sourced from the OS CSPRNG via `getrandom` (pulled in transitively);
99/// falls back to a time+address-seeded mix only if the OS RNG is unavailable,
100/// which never happens on the supported platforms.
101fn new_session_id() -> String {
102    let mut bytes = [0u8; 32];
103    if getrandom::fill(&mut bytes).is_err() {
104        // Extremely defensive fallback: mix a few entropy-ish sources. Not used
105        // on any supported platform (getrandom uses the OS CSPRNG).
106        use std::time::{SystemTime, UNIX_EPOCH};
107        let nanos = SystemTime::now()
108            .duration_since(UNIX_EPOCH)
109            .map(|d| d.as_nanos())
110            .unwrap_or(0);
111        let seed = nanos as u64 ^ (&bytes as *const _ as u64);
112        let mut x = seed | 1;
113        for b in bytes.iter_mut() {
114            // xorshift64 — only reached if the OS CSPRNG is unavailable.
115            x ^= x << 13;
116            x ^= x >> 7;
117            x ^= x << 17;
118            *b = (x & 0xff) as u8;
119        }
120    }
121    let mut s = String::with_capacity(64);
122    for b in bytes {
123        use std::fmt::Write;
124        let _ = write!(s, "{b:02x}");
125    }
126    s
127}
128
129/// Shared application state handed to every axum handler.
130///
131/// Holds the resolved [`Config`], the SQLite pool, a shared [`reqwest::Client`]
132/// (feed fetch + sidecar calls), the [`SidecarClient`] (the live atproto
133/// `com.atproto.repo.*` path), and the in-memory [`SessionRegistry`] (DID ↔
134/// handle, resolved via the sidecar's `/internal/session`). It is `Clone` (cheap
135/// — everything is behind `Arc`/handles) and is cloned into each request. It
136/// lives in the library so both [`web`] and the `featherreader` binary share it.
137#[derive(Clone)]
138pub struct AppState {
139    /// Immutable runtime configuration.
140    pub config: Arc<Config>,
141    /// The per-DID SQLite cache pool.
142    pub db: Pool,
143    /// Shared HTTP client (feed fetch + sidecar internal API).
144    pub http: reqwest::Client,
145    /// The atproto OAuth sidecar client — the live repo-op path.
146    pub sidecar: SidecarClient,
147    /// DID ↔ handle session registry (cookie-resolved identity).
148    pub sessions: SessionRegistry,
149}
150
151impl AppState {
152    /// Assemble the shared state from config + an initialized store pool.
153    ///
154    /// Builds the shared HTTP client and the [`SidecarClient`] from the config's
155    /// [`crate::config::SidecarConfig`], and starts with an empty session
156    /// registry. The binary's `main` calls this after opening the store.
157    pub fn new(config: Config, db: Pool) -> anyhow::Result<Self> {
158        let http = reqwest::Client::builder().user_agent(USER_AGENT).build()?;
159        let sidecar = SidecarClient::new(
160            http.clone(),
161            config.sidecar.public_url.clone(),
162            config.sidecar.internal_url.clone(),
163            config.sidecar.internal_secret.clone(),
164        );
165        Ok(Self {
166            config: Arc::new(config),
167            db,
168            http,
169            sidecar,
170            sessions: SessionRegistry::new(),
171        })
172    }
173}
174
175/// The crate version — surfaced for the server's `--version` / health output.
176pub const VERSION: &str = env!("CARGO_PKG_VERSION");
177
178/// The `User-Agent` FeatherReader identifies itself with when fetching feeds.
179///
180/// Being a polite, identifiable client is a feed-hygiene requirement (§5 of the
181/// design): publishers ask readers to say who they are so they can be reached or
182/// rate-limited sanely rather than silently blocked.
183pub const USER_AGENT: &str = concat!(
184    "featherreader/",
185    env!("CARGO_PKG_VERSION"),
186    " (+https://feather-reader.com)"
187);