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);