use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use askama::Template;
use axum::{
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
http::{header, HeaderMap, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
Form, Router,
};
use serde::Deserialize;
use std::net::SocketAddr;
use tower_http::services::ServeDir;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use tracing::{info, warn};
use crate::config::Config;
use crate::lexicon::{self, Folder, Saved, Subscription};
use crate::{feed, store, AppState, Session, VERSION};
#[path = "opml.rs"]
mod opml;
const SESSION_COOKIE: &str = "fr_session";
const INVITE_COOKIE: &str = "fr_invite";
const INVITE_TTL_SECS: i64 = 1800;
const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' https: data:; \
font-src 'self'; \
connect-src 'self'; \
form-action 'self'; \
base-uri 'self'; \
frame-ancestors 'none'; \
object-src 'none'";
#[derive(Clone, Debug)]
struct CurrentUser {
did: String,
handle: Option<String>,
sid: Option<String>,
}
async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
if let Some(session) = state.sessions.get(&sid) {
if store::has_beta_access(&state.db, &session.did)
.await
.unwrap_or(false)
{
return Some(CurrentUser {
did: session.did,
handle: session.handle,
sid: Some(sid),
});
}
state.sessions.remove(&sid);
}
}
if let Some(did) = state.config.dev_did.clone() {
if store::has_beta_access(&state.db, &did)
.await
.unwrap_or(false)
{
return Some(CurrentUser {
did,
handle: None,
sid: None,
});
}
}
None
}
async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
current_session(state, headers).await.map(|u| u.did)
}
pub fn router(state: AppState) -> Router {
let limiter = RateLimiter::shared();
let rl_state = RateLimitState {
limiter,
trusted_header: state.config.trusted_ip_header.clone(),
};
Router::new()
.route("/health", get(health))
.route("/about", get(about))
.route("/manage", get(manage))
.route("/", get(index))
.route("/entries/{id}", get(entry_view))
.route("/entries/{id}/read", post(mark_read))
.route("/entries/{id}/star", post(toggle_star))
.route("/read-all", post(mark_all_read))
.route("/subscriptions", post(add_subscription))
.route("/subscriptions/{rkey}/delete", post(delete_subscription))
.route("/subscriptions/{rkey}/rename", post(rename_subscription))
.route("/folders", post(create_folder))
.route("/folders/{rkey}/rename", post(rename_folder))
.route("/folders/{rkey}/delete", post(delete_folder))
.route(
"/opml",
post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
)
.route("/opml/export", get(export_opml))
.route("/login", get(login_form).post(login_submit))
.route(
"/beta/redeem",
get(beta_redeem_form).post(beta_redeem_submit),
)
.route("/admin/invites", post(admin_mint_invites))
.route("/account/delete", post(account_delete))
.route("/oauth/callback", get(oauth_callback))
.route("/logout", post(logout))
.nest_service("/static", ServeDir::new("static"))
.layer(middleware::from_fn(cache_control))
.layer(middleware::from_fn_with_state(rl_state, rate_limit))
.layer(TraceLayer::new_for_http())
.layer(static_header_layer(
"content-security-policy",
CONTENT_SECURITY_POLICY,
))
.layer(static_header_layer("x-content-type-options", "nosniff"))
.layer(static_header_layer(
"referrer-policy",
"strict-origin-when-cross-origin",
))
.layer(static_header_layer("x-frame-options", "DENY"))
.with_state(state)
}
const OPML_BODY_LIMIT: usize = 2 * 1024 * 1024;
fn static_header_layer(
name: &'static str,
value: &'static str,
) -> SetResponseHeaderLayer<header::HeaderValue> {
SetResponseHeaderLayer::overriding(
header::HeaderName::from_static(name),
header::HeaderValue::from_static(value),
)
}
fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
use axum::http::Method;
if method != Method::POST && !(method == Method::GET && path == "/login") {
return false;
}
match path {
"/login" | "/beta/redeem" | "/subscriptions" | "/opml" | "/read-all" | "/admin/invites"
| "/account/delete" | "/folders" => true,
p => {
(p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
|| p.starts_with("/subscriptions/")
|| p.starts_with("/folders/")
}
}
}
#[derive(Clone)]
struct RateLimitState {
limiter: RateLimiter,
trusted_header: Option<String>,
}
#[derive(Clone)]
struct RateLimiter {
inner: std::sync::Arc<Mutex<HashMap<IpAddr, Bucket>>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
const RATE_BURST: f64 = 20.0;
const RATE_REFILL_PER_SEC: f64 = 1.0;
const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
impl RateLimiter {
fn shared() -> Self {
Self {
inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
}
}
fn check(&self, ip: IpAddr) -> bool {
let now = Instant::now();
let mut map = match self.inner.lock() {
Ok(m) => m,
Err(p) => p.into_inner(),
};
map.retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
let bucket = map.entry(ip).or_insert(Bucket {
tokens: RATE_BURST,
last: now,
});
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
true
} else {
false
}
}
}
fn client_ip(
headers: &HeaderMap,
conn: Option<&SocketAddr>,
trusted_header: Option<&str>,
) -> Option<IpAddr> {
if let Some(name) = trusted_header {
if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
if let Some(last) = raw.split(',').next_back() {
if let Ok(ip) = last.trim().parse::<IpAddr>() {
return Some(ip);
}
}
}
}
conn.map(|s| s.ip())
}
async fn rate_limit(
State(rl): State<RateLimitState>,
req: axum::extract::Request,
next: Next,
) -> Response {
let path = req.uri().path().to_string();
let method = req.method().clone();
if is_rate_limited_path(&path, &method) {
let conn = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0);
let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
if let Some(ip) = ip {
if !rl.limiter.check(ip) {
warn!(%ip, %path, "rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, "1")],
"rate limit exceeded\n",
)
.into_response();
}
}
}
next.run(req).await
}
async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
let path = req.uri().path().to_string();
let is_login_landing = path == "/login"
&& req.method() == axum::http::Method::GET
&& !req.uri().query().unwrap_or("").contains("handle=");
let public = is_login_landing || path == "/about" || path.starts_with("/static/");
let mut resp = next.run(req).await;
if resp.headers().contains_key(header::CACHE_CONTROL) {
return resp;
}
let value = if public {
"public, max-age=300"
} else {
"no-store"
};
if let Ok(hv) = header::HeaderValue::from_str(value) {
resp.headers_mut().insert(header::CACHE_CONTROL, hv);
}
resp
}
async fn health() -> impl IntoResponse {
(StatusCode::OK, format!("ok featherreader/{VERSION}\n"))
}
async fn about() -> Response {
render(&AboutTemplate {
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
})
}
struct FeedView {
rkey: String,
url: String,
title: String,
unread: i64,
selected: bool,
folder: Option<String>,
}
struct FolderView {
rkey: String,
uri: String,
name: String,
feeds: Vec<FeedView>,
selected: bool,
}
struct EntryRow {
id: i64,
title: String,
feed_title: String,
published: String,
read: bool,
starred: bool,
link: String,
}
struct FolderOption {
uri: String,
name: String,
}
struct Nav {
handle: String,
avatar: String,
view: String,
scope_qs: String,
folders: Vec<FolderView>,
loose_feeds: Vec<FeedView>,
manage_active: bool,
}
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
flash: String,
nav: Nav,
entries: Vec<EntryRow>,
heading: String,
feed_scope: Option<String>,
}
#[derive(Template)]
#[template(path = "manage.html")]
struct ManageTemplate {
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
flash: String,
nav: Nav,
folder_options: Vec<FolderOption>,
folders: Vec<FolderView>,
loose_feeds: Vec<FeedView>,
}
#[derive(Template)]
#[template(path = "about.html")]
struct AboutTemplate {
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
}
#[derive(Template)]
#[template(path = "landing.html")]
struct LandingTemplate {
version: &'static str,
repo_url: &'static str,
crates_url: &'static str,
kofi_url: &'static str,
}
#[derive(Template)]
#[template(path = "entry.html")]
struct EntryTemplate {
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
nav: Nav,
id: i64,
title: String,
feed_title: String,
author: Option<String>,
published: String,
url: Option<String>,
content_html: Option<String>,
read: bool,
starred: bool,
back_qs: String,
prev_id: Option<i64>,
next_id: Option<i64>,
oob: bool,
}
#[derive(Template)]
#[template(path = "entry_row.html")]
struct EntryRowTemplate {
e: EntryRow,
}
#[derive(Template)]
#[template(path = "entry_actionbar.html")]
struct EntryActionBarTemplate {
id: i64,
read: bool,
starred: bool,
oob: bool,
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
repo_url: &'static str,
error: String,
flash: String,
}
#[derive(Template)]
#[template(path = "beta_redeem.html")]
struct BetaRedeemTemplate {
repo_url: &'static str,
error: String,
capacity_full: bool,
}
fn render<T: Template>(tmpl: &T) -> Response {
match tmpl.render() {
Ok(body) => Html(body).into_response(),
Err(err) => {
warn!(%err, "template render failed");
(StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
}
}
}
struct WebError {
err: anyhow::Error,
status: StatusCode,
}
impl<E: Into<anyhow::Error>> From<E> for WebError {
fn from(err: E) -> Self {
WebError {
err: err.into(),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl WebError {
fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
WebError {
err: err.into(),
status,
}
}
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
warn!(error = %self.err, status = %self.status, "request failed");
let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
"internal error"
} else {
self.status.canonical_reason().unwrap_or("error")
};
(self.status, body).into_response()
}
}
fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
let status = err.status();
WebError::with_status(err, status)
}
fn display_title(title: Option<&str>, url: &str) -> String {
if let Some(t) = title {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
}
}
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| url.to_string())
}
fn display_handle(handle: Option<&str>, did: &str) -> String {
match handle {
Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
_ => did.rsplit(':').next().unwrap_or(did).to_string(),
}
}
fn avatar_initials(handle: Option<&str>, did: &str) -> String {
let source = handle
.map(|h| h.trim().trim_start_matches('@'))
.filter(|h| !h.is_empty())
.unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
let letters: String = source
.chars()
.filter(|c| c.is_alphanumeric())
.take(2)
.collect::<String>()
.to_lowercase();
if letters.is_empty() {
"fr".to_string()
} else {
letters
}
}
fn display_date(published: Option<&str>) -> String {
match published {
Some(p) if p.len() >= 10 => p[..10].to_string(),
Some(p) => p.to_string(),
None => String::new(),
}
}
fn qenc(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[derive(Debug, Deserialize, Default)]
struct IndexQuery {
#[serde(default)]
feed: Option<String>,
#[serde(default)]
folder: Option<String>,
#[serde(default)]
view: Option<String>,
#[serde(default)]
flash: Option<String>,
}
struct ResolvedSub {
rkey: String,
sub: Subscription,
feed: Option<store::Feed>,
}
async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
let pool = &state.db;
let subs = match state.sidecar.list_subscriptions_sorted(did).await {
Ok(s) => s,
Err(err) => {
warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
let feeds = store::feeds_for_did(pool, did).await.unwrap_or_default();
return feeds
.into_iter()
.map(|f| ResolvedSub {
rkey: String::new(),
sub: Subscription::new(f.url.clone(), now_rfc3339()),
feed: Some(f),
})
.collect();
}
};
let mut out = Vec::with_capacity(subs.len());
for (rkey, sub) in subs {
let feed = match store::get_feed_by_url(pool, &sub.url).await {
Ok(Some(f)) => Some(f),
Ok(None) => {
let _ = store::upsert_feed(
pool,
&store::NewFeed {
url: sub.url.clone(),
title: sub.title.clone(),
site_url: sub.site_url.clone(),
..Default::default()
},
)
.await;
store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
}
Err(err) => {
warn!(%err, url = %sub.url, "get_feed_by_url failed");
None
}
};
out.push(ResolvedSub { rkey, sub, feed });
}
sync_sub_refs(pool, did, &out).await;
out
}
async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
let feed_ids: Vec<i64> = subs
.iter()
.filter_map(|s| s.feed.as_ref().map(|f| f.id))
.collect();
if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
warn!(%err, %did, "failed to sync sub_ref projection");
}
}
async fn index(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<IndexQuery>,
) -> Result<Response, WebError> {
let user = match current_session(&state, &headers).await {
Some(u) => u,
None => {
return Ok(render(&LandingTemplate {
version: VERSION,
repo_url: REPO_URL,
crates_url: CRATES_URL,
kofi_url: KOFI_URL,
}))
}
};
let did = user.did.clone();
let pool = &state.db;
let subs = resolve_subscriptions(&state, &did).await;
let unread = store::get_unread_for_did(pool, &did).await?;
let starred = store::get_starred_for_did(pool, &did).await?;
let starred_ids: std::collections::HashSet<i64> = starred.iter().map(|e| e.id).collect();
let view = match q.view.as_deref() {
Some("all") => "all",
Some("starred") => "starred",
_ => "unread",
}
.to_string();
let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
let feed_url_by_id = |id: i64| -> Option<String> {
subs.iter()
.find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
.map(|s| s.sub.url.clone())
};
let feed_title_by_id = |id: i64| -> String {
subs.iter()
.find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
.map(|s| {
display_title(
s.sub
.title
.as_deref()
.or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
&s.sub.url,
)
})
.unwrap_or_default()
};
let in_scope = |feed_id: i64| -> bool {
match &scope_urls {
None => true,
Some(urls) => feed_url_by_id(feed_id)
.map(|u| urls.contains(&u))
.unwrap_or(false),
}
};
let source = match view.as_str() {
"all" => {
let mut all = Vec::new();
for s in &subs {
if let Some(f) = &s.feed {
if in_scope(f.id) {
let mut es = store::entries_for_feed(pool, &did, f.id)
.await
.unwrap_or_default();
all.append(&mut es);
}
}
}
all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
all
}
"starred" => starred
.iter()
.filter(|e| in_scope(e.feed_id))
.cloned()
.collect(),
_ => unread
.iter()
.filter(|e| in_scope(e.feed_id))
.cloned()
.collect(),
};
let entry_scope_qs = {
let mut parts = Vec::new();
if let Some(f) = q.feed.as_deref() {
parts.push(format!("feed={}", qenc(f)));
}
if let Some(f) = q.folder.as_deref() {
parts.push(format!("folder={}", qenc(f)));
}
if view != "unread" {
parts.push(format!("view={}", qenc(&view)));
}
parts.join("&")
};
let entry_link = |id: i64| -> String {
if entry_scope_qs.is_empty() {
format!("/entries/{id}")
} else {
format!("/entries/{id}?{entry_scope_qs}")
}
};
let entries: Vec<EntryRow> = source
.iter()
.map(|e| EntryRow {
id: e.id,
title: e
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "(untitled)".to_string()),
feed_title: feed_title_by_id(e.feed_id),
published: display_date(e.published.as_deref()),
read: view != "unread" && !unread.iter().any(|u| u.id == e.id),
starred: starred_ids.contains(&e.id),
link: entry_link(e.id),
})
.collect();
let selected_feed = q.feed.as_deref();
let selected_folder = q.folder.as_deref();
let (folder_views, loose_feeds, _folder_options) =
build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
let name = subs
.iter()
.find(|s| s.sub.url == feed_url)
.map(|s| {
display_title(
s.sub
.title
.as_deref()
.or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
&s.sub.url,
)
})
.unwrap_or_else(|| display_title(None, feed_url));
(name, format!("feed={}", qenc(feed_url)))
} else if let Some(folder_uri) = selected_folder {
let name = folder_views
.iter()
.find(|f| f.uri == folder_uri)
.map(|f| f.name.clone())
.unwrap_or_else(|| "Folder".to_string());
(name, format!("folder={}", qenc(folder_uri)))
} else {
let h = match view.as_str() {
"all" => "All",
"starred" => "Starred",
_ => "Unread",
};
(h.to_string(), String::new())
};
let feed_scope = selected_feed.map(str::to_string);
let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
let tmpl = IndexTemplate {
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
flash: q.flash.unwrap_or_default(),
nav,
entries,
heading,
feed_scope,
};
Ok(render(&tmpl))
}
#[derive(Debug, Deserialize, Default)]
struct ManageQuery {
#[serde(default)]
flash: Option<String>,
}
async fn manage(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ManageQuery>,
) -> Result<Response, WebError> {
let user = match current_session(&state, &headers).await {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
};
let did = user.did.clone();
let subs = resolve_subscriptions(&state, &did).await;
let (folder_views, loose_feeds, folder_options) =
build_sidebar(&state, &did, &subs, None, None).await;
let nav = build_nav(
&user,
"unread",
String::new(),
folder_views.iter().map(clone_folder_view).collect(),
loose_feeds.iter().map(clone_feed_view).collect(),
true,
);
let tmpl = ManageTemplate {
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
flash: q.flash.unwrap_or_default(),
nav,
folder_options,
folders: folder_views,
loose_feeds,
};
Ok(render(&tmpl))
}
fn clone_feed_view(f: &FeedView) -> FeedView {
FeedView {
rkey: f.rkey.clone(),
url: f.url.clone(),
title: f.title.clone(),
unread: f.unread,
selected: f.selected,
folder: f.folder.clone(),
}
}
fn clone_folder_view(f: &FolderView) -> FolderView {
FolderView {
rkey: f.rkey.clone(),
uri: f.uri.clone(),
name: f.name.clone(),
feeds: f.feeds.iter().map(clone_feed_view).collect(),
selected: f.selected,
}
}
fn scope_urls_for(
subs: &[ResolvedSub],
feed: Option<&str>,
folder: Option<&str>,
) -> Option<Vec<String>> {
if let Some(feed_url) = feed {
Some(vec![feed_url.to_string()])
} else {
folder.map(|folder_uri| {
subs.iter()
.filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
.map(|s| s.sub.url.clone())
.collect()
})
}
}
fn folder_uri(did: &str, rkey: &str) -> String {
format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
}
async fn build_sidebar(
state: &AppState,
did: &str,
subs: &[ResolvedSub],
selected_feed: Option<&str>,
selected_folder: Option<&str>,
) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
let pool = &state.db;
let unread = store::get_unread_for_did(pool, did)
.await
.unwrap_or_default();
let folders = state
.sidecar
.list_folders_sorted(did)
.await
.unwrap_or_default();
let unread_count = |feed_id: Option<i64>| -> i64 {
match feed_id {
Some(id) => unread.iter().filter(|e| e.feed_id == id).count() as i64,
None => 0,
}
};
let mk_feed_view = |s: &ResolvedSub| FeedView {
rkey: s.rkey.clone(),
url: s.sub.url.clone(),
title: display_title(
s.sub
.title
.as_deref()
.or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
&s.sub.url,
),
unread: unread_count(s.feed.as_ref().map(|f| f.id)),
selected: selected_feed == Some(s.sub.url.as_str()),
folder: s.sub.folder.clone(),
};
let mut folder_views = Vec::with_capacity(folders.len());
for (rkey, folder) in &folders {
let uri = folder_uri(did, rkey);
let feeds: Vec<FeedView> = subs
.iter()
.filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
.map(mk_feed_view)
.collect();
folder_views.push(FolderView {
rkey: rkey.clone(),
uri: uri.clone(),
name: folder.name.clone(),
feeds,
selected: selected_folder == Some(uri.as_str()),
});
}
let known_uris: std::collections::HashSet<String> =
folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
let loose_feeds: Vec<FeedView> = subs
.iter()
.filter(|s| {
s.sub
.folder
.as_deref()
.map(|f| !known_uris.contains(f))
.unwrap_or(true)
})
.map(mk_feed_view)
.collect();
let folder_options: Vec<FolderOption> = folders
.iter()
.map(|(rkey, folder)| FolderOption {
name: folder.name.clone(),
uri: folder_uri(did, rkey),
})
.collect();
(folder_views, loose_feeds, folder_options)
}
fn build_nav(
user: &CurrentUser,
view: &str,
scope_qs: String,
folders: Vec<FolderView>,
loose_feeds: Vec<FeedView>,
manage_active: bool,
) -> Nav {
Nav {
handle: display_handle(user.handle.as_deref(), &user.did),
avatar: avatar_initials(user.handle.as_deref(), &user.did),
view: view.to_string(),
scope_qs,
folders,
loose_feeds,
manage_active,
}
}
#[derive(Debug, Deserialize, Default)]
struct EntryQuery {
#[serde(default)]
feed: Option<String>,
#[serde(default)]
folder: Option<String>,
#[serde(default)]
view: Option<String>,
}
async fn entry_view(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<EntryQuery>,
) -> Result<Response, WebError> {
let user = match current_session(&state, &headers).await {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
};
let did = user.did.clone();
let pool = &state.db;
let subs = resolve_subscriptions(&state, &did).await;
let entry = match get_entry_by_id(pool, &did, id).await? {
Some(e) => e,
None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
};
let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
let read = entry_is_read(pool, &did, id).await?;
let starred = entry_is_starred(pool, &did, id).await?;
let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
let back_qs = scope_query(&q);
let (folder_views, loose_feeds, _) =
build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
let nav_view = match q.view.as_deref() {
Some("all") => "all",
Some("starred") => "starred",
_ => "unread",
};
let nav = build_nav(
&user,
nav_view,
back_qs.clone(),
folder_views,
loose_feeds,
false,
);
let tmpl = EntryTemplate {
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
nav,
id: entry.id,
title: entry
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "(untitled)".to_string()),
feed_title,
author: entry.author.clone().filter(|a| !a.trim().is_empty()),
published: display_date(entry.published.as_deref()),
url: entry.url.clone().filter(|u| !u.trim().is_empty()),
content_html: entry.content_html.clone(),
read,
starred,
back_qs,
prev_id,
next_id,
oob: false,
};
Ok(render(&tmpl))
}
async fn neighbors_in_scope(
state: &AppState,
did: &str,
q: &EntryQuery,
current: i64,
) -> (Option<i64>, Option<i64>) {
let idx_q = IndexQuery {
feed: q.feed.clone(),
folder: q.folder.clone(),
view: q.view.clone(),
flash: None,
};
let ids = list_entry_ids(state, did, &idx_q).await;
let pos = ids.iter().position(|&x| x == current);
match pos {
Some(p) => {
let prev = if p > 0 { Some(ids[p - 1]) } else { None };
let next = ids.get(p + 1).copied();
(prev, next)
}
None => (None, None),
}
}
async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
let pool = &state.db;
let subs = resolve_subscriptions(state, did).await;
let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
let feed_url_by_id = |id: i64| -> Option<String> {
subs.iter()
.find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
.map(|s| s.sub.url.clone())
};
let in_scope = |feed_id: i64| -> bool {
match &scope_urls {
None => true,
Some(urls) => feed_url_by_id(feed_id)
.map(|u| urls.contains(&u))
.unwrap_or(false),
}
};
let view = q.view.as_deref().unwrap_or("unread");
let entries = match view {
"all" => {
let mut all = Vec::new();
for s in &subs {
if let Some(f) = &s.feed {
if in_scope(f.id) {
let mut es = store::entries_for_feed(pool, did, f.id)
.await
.unwrap_or_default();
all.append(&mut es);
}
}
}
all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
all
}
"starred" => store::get_starred_for_did(pool, did)
.await
.unwrap_or_default()
.into_iter()
.filter(|e| in_scope(e.feed_id))
.collect(),
_ => store::get_unread_for_did(pool, did)
.await
.unwrap_or_default()
.into_iter()
.filter(|e| in_scope(e.feed_id))
.collect(),
};
entries.into_iter().map(|e| e.id).collect()
}
fn scope_query(q: &EntryQuery) -> String {
let mut parts = Vec::new();
if let Some(f) = q.feed.as_deref() {
parts.push(format!("feed={}", qenc(f)));
}
if let Some(f) = q.folder.as_deref() {
parts.push(format!("folder={}", qenc(f)));
}
if let Some(v) = q.view.as_deref() {
if v != "unread" {
parts.push(format!("view={}", qenc(v)));
}
}
parts.join("&")
}
#[derive(Debug, Deserialize)]
struct ReadForm {
#[serde(default)]
read: Option<String>,
}
async fn mark_read(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Form(form): Form<ReadForm>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let pool = &state.db;
let read = matches!(
form.read.as_deref(),
Some("true") | Some("1") | Some("on") | None
);
resolve_subscriptions(&state, &did).await;
if !store::mark_read(pool, &did, id, read).await? {
return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
}
if !is_htmx(&headers) {
return Ok(Redirect::to("/").into_response());
}
if is_reader_request(&headers) {
let starred = entry_is_starred(pool, &did, id).await?;
return Ok(render(&EntryActionBarTemplate {
id,
read,
starred,
oob: true,
}));
}
let row = build_entry_row(pool, &did, id, Some(read)).await?;
match row {
Some(r) => Ok(render(&EntryRowTemplate { e: r })),
None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
}
}
#[derive(Debug, Deserialize)]
struct StarForm {
#[serde(default)]
starred: Option<String>,
}
async fn toggle_star(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Form(form): Form<StarForm>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let pool = &state.db;
let starred = matches!(
form.starred.as_deref(),
Some("true") | Some("1") | Some("on") | None
);
resolve_subscriptions(&state, &did).await;
if !store::mark_starred(pool, &did, id, starred).await? {
return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
}
if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
let entry_url = entry.url.clone().unwrap_or_default();
if !entry_url.is_empty() {
if starred {
let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
saved.title = entry.title.clone();
saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
saved.entry_id = Some(entry.guid.clone());
match state.sidecar.add_saved(&did, &saved).await {
Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
}
} else {
match state.sidecar.list_saved(&did).await {
Ok(records) => {
for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
if let Err(err) = state.sidecar.remove_saved(&did, rkey).await {
warn!(%err, %did, %rkey, "PDS saved delete failed");
}
}
}
Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
}
}
}
}
if !is_htmx(&headers) {
return Ok(Redirect::to("/").into_response());
}
if is_reader_request(&headers) {
let read = entry_is_read(pool, &did, id).await?;
return Ok(render(&EntryActionBarTemplate {
id,
read,
starred,
oob: true,
}));
}
let row = build_entry_row(pool, &did, id, None).await?;
match row {
Some(r) => Ok(render(&EntryRowTemplate { e: r })),
None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
}
}
async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
.bind(feed_id)
.fetch_optional(pool)
.await
.ok()
.flatten()
}
#[derive(Debug, Deserialize, Default)]
struct ReadAllQuery {
#[serde(default)]
feed: Option<String>,
}
async fn mark_all_read(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ReadAllQuery>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let pool = &state.db;
resolve_subscriptions(&state, &did).await;
if let Some(feed_url) = q.feed.as_deref() {
if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
store::mark_feed_read(pool, &did, feed.id, true).await?;
}
return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
}
for feed_id in store::subscribed_feed_ids(pool, &did).await? {
store::mark_feed_read(pool, &did, feed_id, true).await?;
}
Ok(Redirect::to("/").into_response())
}
const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
FeatherReader stores your subscriptions in your public PDS, so it supports public \
feeds for now — private-feed support arrives when atproto's private data \
(permissioned records) ships. Your feed URL was not saved or sent anywhere.";
#[derive(Debug, Deserialize)]
struct SubscribeForm {
url: String,
#[serde(default)]
folder: Option<String>,
}
async fn add_subscription(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<SubscribeForm>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let pool = &state.db;
let input = form.url.trim().to_string();
if input.is_empty() {
return Ok(Redirect::to("/").into_response());
}
if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
return Ok(
Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
);
}
let cap = state.config.max_subs_per_did;
if cap > 0 {
match store::count_subscriptions_for_did(pool, &did).await {
Ok(n) if n >= cap => {
info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
return Ok(Redirect::to(&format!(
"/?flash={}",
qenc(&format!(
"Subscription limit reached ({cap}). Remove a feed before adding another."
))
))
.into_response());
}
Ok(_) => {}
Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
}
}
let feed_url = match resolve_feed_url(&state.config, &input).await {
Ok(u) => u,
Err(err) => {
warn!(%err, url = %input, "could not resolve a feed from the given URL");
return Ok(Redirect::to(&format!(
"/?flash={}",
qenc("Couldn't find a feed at that URL")
))
.into_response());
}
};
if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
return Ok(
Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
);
}
let feeds_cap = state.config.max_feeds_global;
if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
match store::count_feeds(pool).await {
Ok(n) if n >= feeds_cap => {
warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
return Ok(Redirect::to(&format!(
"/?flash={}",
qenc(
"This instance is at its feed capacity right now. Please try again later."
)
))
.into_response());
}
Ok(_) => {}
Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
}
}
store::upsert_feed(
pool,
&store::NewFeed {
url: feed_url.clone(),
..Default::default()
},
)
.await?;
if let Ok(client) = feed::build_client() {
if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
{
Ok(outcome) => info!(feed = %feed_url, ?outcome, "polled new subscription"),
Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
}
}
}
let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
sub.title = feed_row.title.clone();
sub.site_url = feed_row.site_url.clone();
}
sub.folder = form
.folder
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty());
match state.sidecar.add_subscription(&did, &sub).await {
Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
Err(err) => {
warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
}
}
Ok(Redirect::to("/").into_response())
}
async fn delete_subscription(
State(state): State<AppState>,
headers: HeaderMap,
Path(rkey): Path<String>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
match state.sidecar.remove_subscription(&did, &rkey).await {
Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
}
Ok(Redirect::to("/").into_response())
}
#[derive(Debug, Deserialize)]
struct RenameSubForm {
url: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
site_url: Option<String>,
#[serde(default)]
folder: Option<String>,
}
async fn rename_subscription(
State(state): State<AppState>,
headers: HeaderMap,
Path(rkey): Path<String>,
Form(form): Form<RenameSubForm>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let feed_url = form.url.trim().to_string();
if feed_url.is_empty() {
return Ok(Redirect::to("/").into_response());
}
if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
return Ok(
Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
);
}
let feeds_cap = state.config.max_feeds_global;
if feeds_cap > 0
&& store::get_feed_by_url(&state.db, &feed_url)
.await?
.is_none()
{
match store::count_feeds(&state.db).await {
Ok(n) if n >= feeds_cap => {
warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
return Ok(Redirect::to(&format!(
"/?flash={}",
qenc(
"This instance is at its feed capacity right now. Please try again later."
)
))
.into_response());
}
Ok(_) => {}
Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
}
}
let mut sub = Subscription::new(feed_url, now_rfc3339());
sub.title = form
.title
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
sub.site_url = form
.site_url
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
sub.folder = form
.folder
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty());
let _ = store::upsert_feed(
&state.db,
&store::NewFeed {
url: sub.url.clone(),
title: sub.title.clone(),
site_url: sub.site_url.clone(),
..Default::default()
},
)
.await;
match state.sidecar.update_subscription(&did, &rkey, &sub).await {
Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription"),
Err(err) => warn!(%err, %did, %rkey, "PDS subscription update failed"),
}
Ok(Redirect::to("/").into_response())
}
#[derive(Debug, Deserialize)]
struct FolderForm {
name: String,
}
async fn create_folder(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<FolderForm>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let name = form.name.trim();
if name.is_empty() {
return Ok(Redirect::to("/").into_response());
}
let folder = Folder::new(name.to_string(), now_rfc3339());
match state.sidecar.add_folder(&did, &folder).await {
Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
Err(err) => warn!(%err, %did, "PDS folder create failed"),
}
Ok(Redirect::to("/").into_response())
}
async fn rename_folder(
State(state): State<AppState>,
headers: HeaderMap,
Path(rkey): Path<String>,
Form(form): Form<FolderForm>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let name = form.name.trim();
if name.is_empty() {
return Ok(Redirect::to("/").into_response());
}
let folder = Folder::new(name.to_string(), now_rfc3339());
match state.sidecar.rename_folder(&did, &rkey, &folder).await {
Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
}
Ok(Redirect::to("/").into_response())
}
async fn delete_folder(
State(state): State<AppState>,
headers: HeaderMap,
Path(rkey): Path<String>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
match state.sidecar.remove_folder(&did, &rkey).await {
Ok(()) => info!(%did, %rkey, "deleted folder record"),
Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
}
Ok(Redirect::to("/").into_response())
}
async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
let parsed =
url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
let client = feed::build_client()?;
let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
let final_url = resp.url().clone();
let content_type = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
let raw = crate::net::read_capped(resp).await?;
let body = String::from_utf8_lossy(&raw).into_owned();
let looks_like_feed = content_type.contains("xml")
|| content_type.contains("rss")
|| content_type.contains("atom")
|| content_type.contains("application/feed+json")
|| {
let head = body.trim_start();
head.starts_with("<?xml")
|| head.starts_with("<rss")
|| head.starts_with("<feed")
|| head.contains("<rss")
|| head.contains("<feed")
};
if looks_like_feed {
return Ok(final_url.to_string());
}
match feed::discover_feed(&body, Some(&final_url)) {
Some(u) => Ok(u.to_string()),
None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
}
}
#[derive(Debug, Deserialize, Default)]
struct LoginQuery {
#[serde(default)]
handle: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
flash: Option<String>,
}
async fn login_form(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<LoginQuery>,
) -> Response {
if let Some(handle) = q
.handle
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
{
if !may_start_oauth(&state, &headers, &handle).await {
return Redirect::to("/beta/redeem").into_response();
}
return start_oauth(&state, &handle);
}
render(&LoginTemplate {
repo_url: REPO_URL,
error: q.error.unwrap_or_default(),
flash: q.flash.unwrap_or_default(),
})
}
async fn login_submit(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<LoginForm>,
) -> Response {
let handle = form.handle.trim();
if handle.is_empty() {
return login_error("Enter your atproto handle.");
}
if !may_start_oauth(&state, &headers, handle).await {
return Redirect::to("/beta/redeem").into_response();
}
start_oauth(&state, handle)
}
async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
may_start_oauth_with(state, headers, handle, |h| async move {
crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
.await
.ok()
})
.await
}
async fn may_start_oauth_with<F, Fut>(
state: &AppState,
headers: &HeaderMap,
handle: &str,
resolve: F,
) -> bool
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
if let Some(did) = current_did(state, headers).await {
if store::has_beta_access(&state.db, &did)
.await
.unwrap_or(false)
{
return true;
}
}
if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
return true;
}
match resolve(handle.to_string()).await {
Some(did) => store::has_beta_access(&state.db, &did)
.await
.unwrap_or(false),
None => {
warn!(%handle, "handle resolution failed in pre-handshake beta gate");
false
}
}
}
fn start_oauth(state: &AppState, handle: &str) -> Response {
let url = state.sidecar.login_url(handle, None);
info!(%handle, "redirecting to OAuth sidecar login");
Redirect::to(&url).into_response()
}
#[derive(Debug, Deserialize)]
struct LoginForm {
handle: String,
}
#[derive(Debug, Deserialize, Default)]
struct CallbackQuery {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
async fn oauth_callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<CallbackQuery>,
) -> Response {
if let Some(err) = q.error {
let desc = q.error_description.unwrap_or_default();
warn!(error = %err, desc = %desc, "OAuth callback returned an error");
return login_error(&format!("Login failed: {err}"));
}
let session_id = match q.session_id {
Some(s) if !s.is_empty() => s,
_ => return login_error("Login failed: the callback carried no session."),
};
let session = match state.sidecar.resolve_session(&session_id).await {
Ok(Some(s)) => s,
Ok(None) => {
warn!("OAuth callback session_id did not resolve (expired/unknown)");
return login_error("Login session expired — please try again.");
}
Err(err) => {
warn!(%err, "failed to resolve OAuth session via the sidecar");
return login_error("Login failed talking to the auth service.");
}
};
let mut clear_invite = false;
if !store::has_beta_access(&state.db, &session.did)
.await
.unwrap_or(false)
{
let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
Some(c) => c,
None => {
warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
return Redirect::to("/beta/redeem").into_response();
}
};
match store::redeem_code(
&state.db,
&code,
&session.did,
session.handle.as_deref(),
state.config.beta_cap,
)
.await
{
Ok(Ok(())) => {
clear_invite = true;
info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
}
Ok(Err(policy)) => {
warn!(did = %session.did, ?policy, "invite redeem failed at callback");
let mut resp = redeem_bounce(&policy).into_response();
clear_invite_cookie(&mut resp);
return resp;
}
Err(err) => {
warn!(%err, did = %session.did, "invite redeem infra error at callback");
return login_error("Login failed while confirming your invite.");
}
}
}
let sid = state.sessions.create(Session {
did: session.did.clone(),
handle: session.handle.clone(),
});
let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
let mut resp = Redirect::to("/").into_response();
set_cookie(&mut resp, &cookie);
if clear_invite {
clear_invite_cookie(&mut resp);
}
resp
}
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(user) = current_session(&state, &headers).await {
if let Some(sid) = user.sid {
state.sessions.remove(&sid);
match state.sidecar.revoke_session(&user.did).await {
Ok(res) => {
info!(did = %user.did, revoked = res.revoked, "logout: sidecar session revoked");
}
Err(err) => {
warn!(did = %user.did, %err, "logout: sidecar revoke failed; clearing cookie anyway");
}
}
}
}
let mut resp = Redirect::to("/login").into_response();
set_cookie(
&mut resp,
&format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
);
resp
}
#[derive(Debug, Deserialize)]
struct DeleteAccountForm {
#[serde(default)]
confirm: String,
}
const DELETE_CONFIRM_PHRASE: &str = "DELETE";
async fn account_delete(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<DeleteAccountForm>,
) -> Result<Response, WebError> {
let user = match current_session(&state, &headers).await {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
};
let did = user.did.clone();
if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
return Ok(Redirect::to(&format!(
"/manage?flash={}",
qenc("Type DELETE to confirm — nothing was deleted.")
))
.into_response());
}
let counts = store::purge_did_data(&state.db, &did).await?;
info!(
%did,
total = counts.total(),
entry_state = counts.entry_state,
read_cursor = counts.read_cursor,
sub_ref = counts.sub_ref,
beta_access = counts.beta_access,
invite_codes = counts.invite_codes,
"account/delete: local rows purged"
);
match state.sidecar.revoke_session(&did).await {
Ok(res) => info!(%did, revoked = res.revoked, "account/delete: sidecar session revoked"),
Err(err) => {
warn!(%did, %err, "account/delete: sidecar revoke failed; local data already purged")
}
}
if let Some(sid) = user.sid {
state.sessions.remove(&sid);
}
let mut resp = Redirect::to(&format!(
"/login?flash={}",
qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
))
.into_response();
set_cookie(
&mut resp,
&format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
);
Ok(resp)
}
fn login_error(msg: &str) -> Response {
render(&LoginTemplate {
repo_url: REPO_URL,
error: msg.to_string(),
flash: String::new(),
})
}
#[derive(Debug, Deserialize)]
struct RedeemForm {
code: String,
}
async fn beta_redeem_form(State(state): State<AppState>) -> Response {
let full = store::count_beta_access(&state.db)
.await
.map(|n| n >= state.config.beta_cap)
.unwrap_or(false);
render(&BetaRedeemTemplate {
repo_url: REPO_URL,
error: String::new(),
capacity_full: full,
})
}
async fn beta_redeem_submit(
State(state): State<AppState>,
Form(form): Form<RedeemForm>,
) -> Response {
let code = form.code.trim().to_uppercase();
if code.is_empty() {
return render(&BetaRedeemTemplate {
repo_url: REPO_URL,
error: "Enter your invite code.".to_string(),
capacity_full: false,
});
}
match preflight_code(&state, &code).await {
Ok(()) => {
let cookie = sign_invite(&code, &state.config.cookie_secret);
let mut resp = Redirect::to("/login").into_response();
set_cookie(&mut resp, &cookie);
info!("invite code preflight OK; reserving intent + redirecting to /login");
resp
}
Err(policy) => {
warn!(?policy, "invite code preflight rejected");
redeem_bounce(&policy)
}
}
}
async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
let count = store::count_beta_access(&state.db).await.unwrap_or(0);
if count >= state.config.beta_cap {
return Err(store::RedeemError::CapacityFull);
}
let row = sqlx::query_as::<_, (String, i64)>(
"SELECT status, expires_at FROM invite_codes WHERE code = ?1",
)
.bind(code)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let (status, expires_at) = match row {
Some(r) => r,
None => return Err(store::RedeemError::NotFound),
};
let now = chrono::Utc::now().timestamp();
match status.as_str() {
"active" if expires_at >= now => Ok(()),
"active" => Err(store::RedeemError::Expired),
"expired" => Err(store::RedeemError::Expired),
_ => Err(store::RedeemError::AlreadyRedeemed),
}
}
fn redeem_bounce(policy: &store::RedeemError) -> Response {
use store::RedeemError::*;
let (msg, capacity_full) = match policy {
NotFound => ("That invite code isn't valid.", false),
Expired => ("That invite code has expired.", false),
AlreadyRedeemed => ("That invite code has already been used.", false),
CapacityFull => ("", true),
};
render(&BetaRedeemTemplate {
repo_url: REPO_URL,
error: msg.to_string(),
capacity_full,
})
}
#[derive(Debug, Deserialize, Default)]
struct MintQuery {
#[serde(default)]
n: Option<u32>,
}
async fn admin_mint_invites(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<MintQuery>,
) -> Response {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
};
if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
warn!(%did, "admin mint denied: not an admin-seed DID");
return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
}
let n = q.n.unwrap_or(1).clamp(1, 100);
let mut codes = Vec::with_capacity(n as usize);
for _ in 0..n {
match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
Ok(code) => codes.push(code),
Err(err) => {
warn!(%err, %did, "admin mint_code failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
}
}
}
info!(%did, count = codes.len(), "admin minted invite codes");
let mut body = codes.join("\n");
body.push('\n');
(StatusCode::OK, body).into_response()
}
fn sign_invite(code: &str, secret: &str) -> String {
cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
}
fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
cookie::verify_value(headers, INVITE_COOKIE, secret)
}
fn clear_invite_cookie(resp: &mut Response) {
set_cookie(
resp,
&format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
);
}
async fn import_opml(
State(state): State<AppState>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let pool = &state.db;
let mut opml_text = String::new();
while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
let name = field.name().unwrap_or("").to_string();
if name == "opml" || name == "file" {
let bytes = field.bytes().await.map_err(multipart_response)?;
if !bytes.is_empty() {
opml_text = String::from_utf8_lossy(&bytes).into_owned();
if name == "file" {
break;
}
}
}
}
let feeds = opml::parse_opml(&opml_text).unwrap_or_default();
if feeds.is_empty() {
info!(%did, "OPML import found no feeds");
return Ok(
Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
.into_response(),
);
}
let now = now_rfc3339();
let mut folder_uris: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
if let Ok(existing) = state.sidecar.list_folders_sorted(&did).await {
for (rkey, folder) in existing {
folder_uris
.entry(folder.name.clone())
.or_insert_with(|| folder_uri(&did, &rkey));
}
}
let mut wanted_folders: Vec<String> = feeds
.iter()
.filter_map(|f| f.folder.clone())
.filter(|n| !n.is_empty())
.collect();
wanted_folders.sort();
wanted_folders.dedup();
for name in wanted_folders {
if folder_uris.contains_key(&name) {
continue;
}
let folder = Folder::new(name.clone(), now.clone());
match state.sidecar.add_folder(&did, &folder).await {
Ok(rkey) => {
folder_uris.insert(name, folder_uri(&did, &rkey));
}
Err(err) => warn!(%err, %did, "OPML folder create failed"),
}
}
let sub_cap = state.config.max_subs_per_did;
let mut headroom: Option<i64> = if sub_cap > 0 {
let existing = store::count_subscriptions_for_did(pool, &did)
.await
.unwrap_or(0);
Some((sub_cap - existing).max(0))
} else {
None
};
let mut trimmed_over_cap: usize = 0;
let feeds_cap = state.config.max_feeds_global;
let mut global_headroom: Option<i64> = if feeds_cap > 0 {
let existing = store::count_feeds(pool).await.unwrap_or(0);
Some((feeds_cap - existing).max(0))
} else {
None
};
let mut trimmed_over_global: usize = 0;
let mut subs = Vec::with_capacity(feeds.len());
let mut skipped_private: Vec<String> = Vec::new();
for f in &feeds {
if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
let label = f
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| private_feed_label(&f.feed_url));
skipped_private.push(label);
continue;
}
if let Some(h) = headroom.as_mut() {
if *h <= 0 {
trimmed_over_cap += 1;
continue;
}
}
let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
Ok(existing) => existing.is_none(),
Err(err) => {
warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
false
}
};
if is_new {
if let Some(g) = global_headroom.as_mut() {
if *g <= 0 {
trimmed_over_global += 1;
continue;
}
*g -= 1;
}
}
if let Some(h) = headroom.as_mut() {
*h -= 1;
}
let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
sub.title = f.title.clone();
sub.site_url = f.site_url.clone();
sub.folder = f
.folder
.as_ref()
.and_then(|name| folder_uris.get(name).cloned());
subs.push(sub);
let _ = store::upsert_feed(
pool,
&store::NewFeed {
url: f.feed_url.clone(),
title: f.title.clone(),
site_url: f.site_url.clone(),
..Default::default()
},
)
.await;
}
match state.sidecar.add_subscriptions_bulk(&did, &subs).await {
Ok(rkeys) => {
info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)")
}
Err(err) => warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)"),
}
let mut flash = format!("Imported {} feeds", subs.len());
if trimmed_over_cap > 0 {
flash.push_str(&format!(
". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
));
}
if trimmed_over_global > 0 {
flash.push_str(&format!(
". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
));
}
if !skipped_private.is_empty() {
flash.push_str(&format!(
". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
skipped_private.len(),
skipped_private.join(", ")
));
}
Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
}
fn private_feed_label(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| "a private feed".to_string())
}
async fn export_opml(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
let subs = state
.sidecar
.list_subscriptions_sorted(&did)
.await
.unwrap_or_default();
let folders = state
.sidecar
.list_folders_sorted(&did)
.await
.unwrap_or_default();
let folder_pairs: Vec<(String, Folder)> = folders
.into_iter()
.map(|(rkey, f)| (folder_uri(&did, &rkey), f))
.collect();
let body = opml::to_opml(&subs, &folder_pairs);
let mut resp = (StatusCode::OK, body).into_response();
resp.headers_mut().insert(
header::CONTENT_TYPE,
"text/x-opml; charset=utf-8".parse().unwrap(),
);
resp.headers_mut().insert(
header::CONTENT_DISPOSITION,
"attachment; filename=\"featherreader-subscriptions.opml\""
.parse()
.unwrap(),
);
Ok(resp)
}
fn set_cookie(resp: &mut Response, cookie: &str) {
if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
resp.headers_mut()
.append(axum::http::header::SET_COOKIE, value);
}
}
fn is_htmx(headers: &HeaderMap) -> bool {
headers
.get("HX-Request")
.is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
}
fn is_reader_request(headers: &HeaderMap) -> bool {
headers
.get("X-FR-Reader")
.is_some_and(|v| v.as_bytes() == b"1")
}
mod cookie {
use super::{HeaderMap, SESSION_COOKIE};
pub fn sign_session(sid: &str, secret: &str) -> String {
sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
}
pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
verify_value(headers, SESSION_COOKIE, secret)
}
fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
msg.extend_from_slice(name.as_bytes());
msg.push(0);
msg.extend_from_slice(value.as_bytes());
msg
}
pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
let b64 = b64url_encode(value.as_bytes());
format!(
"{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
)
}
pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
let raw = cookie_value(headers, name)?;
let (b64, sig) = raw.split_once('.')?;
let bytes = b64url_decode(b64)?;
let value = String::from_utf8(bytes).ok()?;
let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
Some(value)
} else {
None
}
}
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
for part in header.split(';') {
let part = part.trim();
if let Some((k, v)) = part.split_once('=') {
if k == name {
return Some(v.to_string());
}
}
}
None
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
fn b64url_encode(input: &[u8]) -> String {
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
out.push(B64[((n >> 18) & 63) as usize] as char);
out.push(B64[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(B64[((n >> 6) & 63) as usize] as char);
}
if chunk.len() > 2 {
out.push(B64[(n & 63) as usize] as char);
}
}
out
}
fn b64url_decode(input: &str) -> Option<Vec<u8>> {
fn val(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some((c - b'A') as u32),
b'a'..=b'z' => Some((c - b'a' + 26) as u32),
b'0'..=b'9' => Some((c - b'0' + 52) as u32),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
for chunk in bytes.chunks(4) {
let mut n = 0u32;
let mut valid = 0;
for (i, &c) in chunk.iter().enumerate() {
n |= val(c)? << (18 - 6 * i);
valid += 1;
}
out.push((n >> 16) as u8);
if valid > 2 {
out.push((n >> 8) as u8);
}
if valid > 3 {
out.push(n as u8);
}
}
Some(out)
}
fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
const BLOCK: usize = 64;
let mut k = [0u8; BLOCK];
if key.len() > BLOCK {
let d = sha256(key);
k[..32].copy_from_slice(&d);
} else {
k[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= k[i];
opad[i] ^= k[i];
}
let mut inner = Vec::with_capacity(BLOCK + msg.len());
inner.extend_from_slice(&ipad);
inner.extend_from_slice(msg);
let inner_hash = sha256(&inner);
let mut outer = Vec::with_capacity(BLOCK + 32);
outer.extend_from_slice(&opad);
outer.extend_from_slice(&inner_hash);
let mac = sha256(&outer);
let mut hex = String::with_capacity(64);
for b in mac {
hex.push_str(&format!("{b:02x}"));
}
hex
}
fn sha256(data: &[u8]) -> [u8; 32] {
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
0xc67178f2,
];
let mut h: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
0x5be0cd19,
];
let bit_len = (data.len() as u64) * 8;
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for block in msg.chunks(64) {
let mut w = [0u32; 64];
for i in 0..16 {
w[i] = u32::from_be_bytes([
block[i * 4],
block[i * 4 + 1],
block[i * 4 + 2],
block[i * 4 + 3],
]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16]
.wrapping_add(s0)
.wrapping_add(w[i - 7])
.wrapping_add(s1);
}
let mut a = h;
for i in 0..64 {
let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
let t1 = a[7]
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(K[i])
.wrapping_add(w[i]);
let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
let t2 = s0.wrapping_add(maj);
a[7] = a[6];
a[6] = a[5];
a[5] = a[4];
a[4] = a[3].wrapping_add(t1);
a[3] = a[2];
a[2] = a[1];
a[1] = a[0];
a[0] = t1.wrapping_add(t2);
}
for i in 0..8 {
h[i] = h[i].wrapping_add(a[i]);
}
}
let mut out = [0u8; 32];
for (i, word) in h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_known_vector() {
let d = sha256(b"abc");
let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn hmac_known_vector() {
let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
assert_eq!(
mac,
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
#[test]
fn sign_verify_round_trips() {
let secret = "test-secret";
let sid = "9f2c-opaque-session-id";
let cookie = sign_session(sid, secret);
let pair = cookie.split(';').next().unwrap().to_string();
let mut headers = HeaderMap::new();
headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
assert!(verify_session(&headers, "other-secret").is_none());
}
#[test]
fn forged_and_tampered_cookies_are_rejected() {
let secret = "test-secret";
let forged = format!(
"{SESSION_COOKIE}={}.{}",
b64url_encode(b"attacker-chosen-sid"),
"deadbeef".repeat(8) );
let mut headers = HeaderMap::new();
headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
assert!(verify_session(&headers, secret).is_none());
let cookie = sign_session("real-sid", secret);
let pair = cookie.split(';').next().unwrap();
let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
let tampered = format!(
"{SESSION_COOKIE}={}.{}",
b64url_encode(b"different-sid"),
sig
);
let mut headers2 = HeaderMap::new();
headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
assert!(verify_session(&headers2, secret).is_none());
}
#[test]
fn b64url_round_trips() {
for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
let enc = b64url_encode(s.as_bytes());
assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
}
}
}
}
async fn get_entry_by_id(
pool: &store::Pool,
did: &str,
id: i64,
) -> anyhow::Result<Option<store::Entry>> {
let entry = sqlx::query_as::<_, store::Entry>(
r#"
SELECT e.* FROM entries e
WHERE e.id = ?2
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
"#,
)
.bind(did)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(entry)
}
async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
let read: Option<bool> =
sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
.bind(did)
.bind(entry_id)
.fetch_optional(pool)
.await?
.flatten();
Ok(read.unwrap_or(false))
}
async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
let starred: Option<bool> =
sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
.bind(did)
.bind(entry_id)
.fetch_optional(pool)
.await?
.flatten();
Ok(starred.unwrap_or(false))
}
async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
.bind(feed_id)
.fetch_optional(pool)
.await
{
Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
_ => String::new(),
}
}
async fn build_entry_row(
pool: &store::Pool,
did: &str,
id: i64,
read: Option<bool>,
) -> anyhow::Result<Option<EntryRow>> {
let entry = match get_entry_by_id(pool, did, id).await? {
Some(e) => e,
None => return Ok(None),
};
let read = match read {
Some(r) => r,
None => entry_is_read(pool, did, id).await?,
};
let starred = entry_is_starred(pool, did, id).await?;
Ok(Some(EntryRow {
id: entry.id,
title: entry
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "(untitled)".to_string()),
feed_title: feed_title_by_entry(pool, entry.feed_id).await,
published: display_date(entry.published.as_deref()),
read,
starred,
link: format!("/entries/{id}"),
}))
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qenc_encodes_reserved() {
assert_eq!(qenc("a b"), "a%20b");
assert_eq!(
qenc("https://example.com/feed.xml"),
"https%3A%2F%2Fexample.com%2Ffeed.xml"
);
assert_eq!(
qenc("at://did:plc:x/c/r"),
"at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
);
assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
}
#[test]
fn folder_uri_shape() {
assert_eq!(
folder_uri("did:plc:abc", "3kfolder"),
"at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
);
}
#[test]
fn private_feeds_are_classified_private_across_providers() {
for url in [
"https://author.substack.com/feed/private/deadbeefcafe1234",
"https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
"https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
"https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
"https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
"https://user:pass@example.com/feed",
] {
assert!(
feed::classify_feed_privacy(url).is_private(),
"expected private: {url}"
);
}
}
#[test]
fn public_feeds_stay_public() {
for url in [
"https://author.substack.com/feed",
"https://wordpress.example.com/feed/",
"https://example.com/rss.xml",
"https://example.org/atom.xml",
"https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
"https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
] {
assert!(
!feed::classify_feed_privacy(url).is_private(),
"expected public: {url}"
);
}
}
#[test]
fn private_feed_label_is_public_safe_host_only() {
let label =
private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
assert_eq!(label, "author.substack.com");
assert!(!label.contains("deadbeefcafe1234token"));
assert!(!label.contains("/private/"));
assert_eq!(private_feed_label("not a url"), "a private feed");
}
#[test]
fn refusal_message_promises_nothing_stored() {
assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
}
#[test]
fn scope_query_preserves_context() {
let q = EntryQuery {
feed: Some("https://example.com/feed.xml".to_string()),
folder: None,
view: Some("all".to_string()),
};
let s = scope_query(&q);
assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
assert!(s.contains("view=all"));
let q2 = EntryQuery {
feed: None,
folder: None,
view: Some("unread".to_string()),
};
assert_eq!(scope_query(&q2), "");
}
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
async fn test_state(allowed: &[&str]) -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
store::ensure_seed(&db, &dids).await.unwrap();
let config = Config {
allowed_dids: dids,
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 3,
..Config::default()
};
AppState::new(config, db).unwrap()
}
fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
let sid = state.sessions.create(Session {
did: did.to_string(),
handle: handle.map(str::to_string),
});
let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
sc.split(';').next().unwrap().to_string()
}
#[test]
fn rate_limited_paths_match_expected() {
use axum::http::Method;
assert!(is_rate_limited_path("/login", &Method::GET));
assert!(is_rate_limited_path("/login", &Method::POST));
assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
assert!(is_rate_limited_path("/subscriptions", &Method::POST));
assert!(is_rate_limited_path("/opml", &Method::POST));
assert!(is_rate_limited_path("/read-all", &Method::POST));
assert!(is_rate_limited_path("/admin/invites", &Method::POST));
assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
assert!(!is_rate_limited_path("/", &Method::GET));
assert!(!is_rate_limited_path("/about", &Method::GET));
assert!(!is_rate_limited_path("/entries/42", &Method::GET));
assert!(!is_rate_limited_path("/login", &Method::HEAD));
}
#[test]
fn rate_limiter_allows_burst_then_429s() {
let rl = RateLimiter::shared();
let ip: IpAddr = "203.0.113.7".parse().unwrap();
for _ in 0..(RATE_BURST as usize) {
assert!(rl.check(ip));
}
assert!(!rl.check(ip));
let ip2: IpAddr = "203.0.113.8".parse().unwrap();
assert!(rl.check(ip2));
}
#[test]
fn client_ip_ignores_spoofed_xff_without_trusted_header() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
assert_eq!(
client_ip(&h, Some(&sock), None),
Some("203.0.113.55".parse().unwrap()),
"spoofed XFF must not override the socket peer"
);
}
#[test]
fn client_ip_uses_trusted_header_last_hop() {
let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
let mut h = HeaderMap::new();
h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
assert_eq!(
client_ip(&h, Some(&sock), Some("fly-client-ip")),
Some("198.51.100.9".parse().unwrap())
);
let mut h2 = HeaderMap::new();
h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
assert_eq!(
client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
Some("198.51.100.9".parse().unwrap()),
"must take the right-most (trusted) hop, not the forged left-most"
);
let h3 = HeaderMap::new();
assert_eq!(
client_ip(&h3, Some(&sock), Some("fly-client-ip")),
Some("10.0.0.1".parse().unwrap())
);
}
#[test]
fn invite_cookie_round_trips_and_rejects_tamper() {
let secret = "test-cookie-secret-000";
let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
let pair = sc.split(';').next().unwrap();
let mut h = HeaderMap::new();
h.insert(header::COOKIE, pair.parse().unwrap());
assert_eq!(
invite_cookie_code(&h, secret).as_deref(),
Some("FEATHER-ABCDWXYZ")
);
assert!(invite_cookie_code(&h, "other").is_none());
}
#[tokio::test]
async fn preflight_valid_expired_and_full() {
let state = test_state(&["did:plc:admin"]).await;
let code = store::mint_code(&state.db, "did:plc:admin", 3600)
.await
.unwrap();
assert!(preflight_code(&state, &code).await.is_ok());
let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
.await
.unwrap();
sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
.bind(chrono::Utc::now().timestamp() - 3600)
.bind(&expired)
.execute(&state.db)
.await
.unwrap();
assert_eq!(
preflight_code(&state, &expired).await,
Err(store::RedeemError::Expired)
);
assert_eq!(
preflight_code(&state, "FEATHER-NOPENOPE").await,
Err(store::RedeemError::NotFound)
);
store::grant_access(&state.db, "did:plc:b", None, "admin", None)
.await
.unwrap();
store::grant_access(&state.db, "did:plc:c", None, "admin", None)
.await
.unwrap();
assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
assert_eq!(
preflight_code(&state, &code).await,
Err(store::RedeemError::CapacityFull)
);
}
#[tokio::test]
async fn login_without_invite_redirects_to_beta_redeem() {
let state = test_state(&[]).await;
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("handle=alice.bsky.social"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"/beta/redeem"
);
}
#[tokio::test]
async fn login_with_valid_invite_cookie_starts_oauth() {
let state = test_state(&[]).await;
let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
let cookie = cookie.split(';').next().unwrap().to_string();
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.body(Body::from("handle=alice.bsky.social"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(loc.contains("/login"), "loc = {loc}");
assert_ne!(loc, "/beta/redeem");
}
async fn resolver_never(_handle: String) -> Option<String> {
None
}
fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
move |_handle| std::future::ready(Some(did.to_string()))
}
#[tokio::test]
async fn may_start_oauth_honors_seat_via_resolved_handle() {
let state = test_state(&["did:plc:admin"]).await;
let headers = HeaderMap::new();
assert!(
may_start_oauth_with(
&state,
&headers,
"admin.example",
resolver_to("did:plc:admin")
)
.await,
"a handle resolving to a seated DID must pass the gate"
);
}
#[tokio::test]
async fn may_start_oauth_bounces_non_member_handle() {
let state = test_state(&["did:plc:admin"]).await;
let headers = HeaderMap::new();
assert!(
!may_start_oauth_with(
&state,
&headers,
"rando.example",
resolver_to("did:plc:rando")
)
.await,
"a resolved DID with no seat must be bounced"
);
}
#[tokio::test]
async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
let state = test_state(&["did:plc:admin"]).await;
let headers = HeaderMap::new();
assert!(
!may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
"an unresolvable handle must fail closed"
);
}
#[tokio::test]
async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
let state = test_state(&[]).await;
let did = "did:plc:member";
store::grant_access(&state.db, did, Some("member.example"), "test", None)
.await
.unwrap();
let cookie = session_cookie(&state, did, Some("member.example"));
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, cookie.parse().unwrap());
assert!(
may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
"a seated session cookie must pass without resolution"
);
}
#[tokio::test]
async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
let state = test_state(&[]).await;
let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
let cookie = cookie.split(';').next().unwrap().to_string();
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, cookie.parse().unwrap());
assert!(
may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
"a valid invite cookie must pass without resolution"
);
}
#[tokio::test]
async fn admin_mint_requires_admin_seed_did() {
let state = test_state(&["did:plc:admin"]).await;
store::grant_access(&state.db, "did:plc:rando", None, "test", None)
.await
.unwrap();
let rando_cookie = session_cookie(&state, "did:plc:rando", None);
let admin_cookie = session_cookie(&state, "did:plc:admin", None);
let app = router(state);
let forbidden = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/invites?n=2")
.header(header::COOKIE, rando_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
let ok = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/invites?n=2")
.header(header::COOKIE, admin_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(minted.len(), 2);
assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
}
#[tokio::test]
async fn admin_mint_unauthenticated_is_401() {
let state = test_state(&["did:plc:admin"]).await;
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/invites")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn cache_control_public_on_about_no_store_on_authed() {
let state = test_state(&["did:plc:admin"]).await;
let admin_cookie = session_cookie(&state, "did:plc:admin", None);
let app = router(state);
let about = app
.clone()
.oneshot(
Request::builder()
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
about.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
assert!(about.headers().contains_key("content-security-policy"));
assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
let login = app
.clone()
.oneshot(
Request::builder()
.uri("/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
login.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
let home = app
.oneshot(
Request::builder()
.uri("/")
.header(header::COOKIE, admin_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
home.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
}
#[tokio::test]
async fn beta_redeem_page_renders() {
let state = test_state(&[]).await;
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/beta/redeem")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
.await
.unwrap();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("Invite code"));
assert!(html.contains("/beta/redeem"));
}
#[tokio::test]
async fn rate_limit_returns_429_after_burst() {
let db = store::init_url("sqlite::memory:").await.unwrap();
store::ensure_seed(&db, &[]).await.unwrap();
let config = Config {
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 3,
trusted_ip_header: Some("cf-connecting-ip".to_string()),
..Config::default()
};
let state = AppState::new(config, db).unwrap();
let app = router(state);
let mut saw_429 = false;
for _ in 0..(RATE_BURST as usize + 5) {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/beta/redeem")
.header("content-type", "application/x-www-form-urlencoded")
.header("cf-connecting-ip", "203.0.113.200")
.body(Body::from("code=FEATHER-NOPENOPE"))
.unwrap(),
)
.await
.unwrap();
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
saw_429 = true;
break;
}
}
assert!(saw_429, "expected a 429 after exhausting the burst");
}
#[tokio::test]
async fn rate_limit_ignores_spoofed_xff_rotation() {
let state = test_state(&[]).await;
let app = router(state);
for i in 0..(RATE_BURST as usize + 5) {
let forged = format!("10.9.8.{}", i % 250);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/beta/redeem")
.header("content-type", "application/x-www-form-urlencoded")
.header("x-forwarded-for", forged)
.body(Body::from("code=FEATHER-NOPENOPE"))
.unwrap(),
)
.await
.unwrap();
assert_ne!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"untrusted XFF must not be used as the rate-limit key"
);
}
}
fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
let boundary = "----featherreadertestboundary";
let mut body = Vec::new();
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
);
body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
body.extend_from_slice(payload);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
(format!("multipart/form-data; boundary={boundary}"), body)
}
#[tokio::test]
async fn opml_import_oversize_upload_returns_413() {
let state = test_state(&["did:plc:admin"]).await;
let cookie = session_cookie(&state, "did:plc:admin", None);
let app = router(state);
let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
let (content_type, body) = opml_multipart(&payload);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/opml")
.header("content-type", content_type)
.header(header::COOKIE, cookie)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"an over-cap OPML upload must be rejected with 413, not collapsed to 500"
);
}
#[tokio::test]
async fn opml_import_under_limit_upload_is_not_413() {
let state = test_state(&["did:plc:admin"]).await;
let cookie = session_cookie(&state, "did:plc:admin", None);
let app = router(state);
let opml = br#"<?xml version="1.0"?>
<opml version="2.0"><body>
<outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
</body></opml>"#;
let (content_type, body) = opml_multipart(opml);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/opml")
.header("content-type", content_type)
.header(header::COOKIE, cookie)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_ne!(
resp.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"an under-cap OPML upload must not be rejected as too large"
);
}
#[tokio::test]
async fn opml_import_logged_out_redirects_to_login() {
let state = test_state(&["did:plc:admin"]).await;
let app = router(state);
let opml = b"<opml version=\"2.0\"><body></body></opml>";
let (content_type, body) = opml_multipart(opml);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/opml")
.header("content-type", content_type)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
}
async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let did = req
.split("\r\n\r\n")
.nth(1)
.and_then(|body| {
let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
v.get("did")?.as_str().map(str::to_string)
})
.unwrap_or_default();
let is_revoke = req.starts_with("POST /internal/revoke");
let body = serde_json::json!({
"ok": true, "did": did, "revoked": true, "hadSession": true
})
.to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
sock.write_all(resp.as_bytes()).await.unwrap();
sock.flush().await.unwrap();
let _ = tx.send(if is_revoke { did } else { String::new() });
});
(format!("http://{addr}"), rx)
}
async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
store::ensure_seed(&db, &dids).await.unwrap();
let mut config = Config {
allowed_dids: dids,
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 3,
..Config::default()
};
config.sidecar.public_url = sidecar_url.to_string();
config.sidecar.internal_url = sidecar_url.to_string();
AppState::new(config, db).unwrap()
}
#[tokio::test]
async fn account_delete_purges_rows_and_triggers_revoke() {
let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
let did = "did:plc:leaver";
let state = test_state_with_sidecar(&[], &sidecar_url).await;
store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
store::mint_code(&state.db, did, 3600).await.unwrap();
assert!(store::has_beta_access(&state.db, did).await.unwrap());
let cookie = session_cookie(&state, did, Some("leaver.example"));
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/account/delete")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("confirm=DELETE"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.starts_with("/login"));
let set_cookie = resp
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
let revoked_did = revoke_rx.await.unwrap();
assert_eq!(
revoked_did, did,
"sidecar revoke must fire for the caller DID"
);
assert!(!store::has_beta_access(&state.db, did).await.unwrap());
let codes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
.bind(did)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(codes, 0);
}
#[tokio::test]
async fn account_delete_without_confirm_is_a_noop() {
let did = "did:plc:staying";
let state = test_state(&[]).await;
store::grant_access(&state.db, did, None, "test", None)
.await
.unwrap();
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/account/delete")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("confirm=nope"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.starts_with("/manage"));
assert!(store::has_beta_access(&state.db, did).await.unwrap());
}
#[tokio::test]
async fn pds_outage_does_not_widen_cross_did_access() {
let did_a = "did:plc:aaaa";
let state = test_state(&[]).await;
store::grant_access(&state.db, did_a, None, "test", None)
.await
.unwrap();
let feed_a = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://a.example/feed.xml".to_string(),
title: Some("A".to_string()),
..Default::default()
},
)
.await
.unwrap();
let feed_b = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://b.example/feed.xml".to_string(),
title: Some("B".to_string()),
..Default::default()
},
)
.await
.unwrap();
store::insert_entries(
&state.db,
feed_b,
&[store::NewEntry {
guid: "b-1".to_string(),
url: Some("https://b.example/1".to_string()),
title: Some("B one".to_string()),
published: Some("2026-07-11T00:00:00Z".to_string()),
content_html: Some("<p>secret B body</p>".to_string()),
..Default::default()
}],
0,
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did_a, &[feed_a])
.await
.unwrap();
store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
.await
.unwrap();
let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
.await
.unwrap()[0]
.id;
store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
.await
.unwrap();
let cookie = session_cookie(&state, did_a, None);
let app = router(state.clone());
let get_b = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/entries/{b_entry_id}"))
.header(header::COOKIE, cookie.clone())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
get_b.status(),
StatusCode::NOT_FOUND,
"A must not read B's entry during a PDS outage"
);
let read_b = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/entries/{b_entry_id}/read"))
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("read=true"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
read_b.status(),
StatusCode::NOT_FOUND,
"A must not mark B's entry read during a PDS outage"
);
let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
.bind(did_a)
.fetch_all(&state.db)
.await
.unwrap();
assert_eq!(
a_feed_ids,
vec![feed_a],
"outage fallback must not add feeds A never subscribed to"
);
let es_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
.bind(did_a)
.bind(b_entry_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
}
async fn test_state_with_caps(
did: &str,
max_subs_per_did: i64,
max_feeds_global: i64,
) -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
let config = Config {
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 100,
max_subs_per_did,
max_feeds_global,
..Config::default()
};
store::grant_access(&db, did, None, "test", None)
.await
.unwrap();
AppState::new(config, db).unwrap()
}
fn opml_with_feeds(n: usize) -> String {
let mut outlines = String::new();
for i in 0..n {
outlines.push_str(&format!(
"<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
));
}
format!(
"<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
)
}
#[tokio::test]
async fn opml_import_enforces_global_feeds_ceiling() {
let did = "did:plc:importer";
let state = test_state_with_caps(did, 0, 3).await;
let cookie = session_cookie(&state, did, None);
let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/opml")
.header(header::COOKIE, cookie)
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let feeds = store::count_feeds(&state.db).await.unwrap();
assert!(
feeds <= 3,
"OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
);
}
#[tokio::test]
async fn opml_import_enforces_per_did_cap() {
let did = "did:plc:capped";
let state = test_state_with_caps(did, 2, 0).await;
let existing_a = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://have-a.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
let existing_b = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://have-b.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
.await
.unwrap();
let before = store::count_feeds(&state.db).await.unwrap();
let cookie = session_cookie(&state, did, None);
let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/opml")
.header(header::COOKIE, cookie)
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let after = store::count_feeds(&state.db).await.unwrap();
assert_eq!(after, before, "over-cap DID imported new feeds anyway");
}
#[tokio::test]
async fn single_add_enforces_per_did_cap() {
let did = "did:plc:subcapped";
let state = test_state_with_caps(did, 1, 0).await;
let f = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://have.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("url=https://another.example/feed.xml"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("Subscription%20limit%20reached"),
"expected sub-limit flash, got {loc}"
);
}
#[tokio::test]
async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
let did = "did:plc:reader";
let state = test_state(&[]).await;
store::grant_access(&state.db, did, None, "test", None)
.await
.unwrap();
let feed = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://reader.example/feed.xml".to_string(),
title: Some("Reader".to_string()),
..Default::default()
},
)
.await
.unwrap();
store::insert_entries(
&state.db,
feed,
&[store::NewEntry {
guid: "r-1".to_string(),
url: Some("https://reader.example/1".to_string()),
title: Some("Article".to_string()),
published: Some("2026-07-11T00:00:00Z".to_string()),
content_html: Some("<p>body</p>".to_string()),
..Default::default()
}],
0,
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed])
.await
.unwrap();
let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/entries/{entry_id}/read"))
.header(header::COOKIE, cookie.clone())
.header("HX-Request", "true")
.header("X-FR-Reader", "1")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("read=true"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
html.contains("hx-swap-oob=\"outerHTML\""),
"reader response must be an OOB swap: {html}"
);
assert!(
html.contains(r#"id="entry-actionbar""#),
"reader response must be the action-bar fragment: {html}"
);
assert!(
html.contains(r#"aria-pressed="true""#),
"read button must show pressed after marking read: {html}"
);
assert!(
html.contains(r#"name="read" value="false""#),
"hidden read value must flip to false so a second tap reverses: {html}"
);
let resp2 = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/entries/{entry_id}/read"))
.header(header::COOKIE, cookie)
.header("HX-Request", "true")
.header("X-FR-Reader", "1")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("read=false"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp2.status(), StatusCode::OK);
let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
.await
.unwrap();
let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
assert!(
html2.contains(r#"aria-pressed="false""#),
"read button must show un-pressed after reversing: {html2}"
);
assert!(
html2.contains(r#"name="read" value="true""#),
"hidden read value must flip back to true: {html2}"
);
}
#[tokio::test]
async fn list_mark_read_returns_row_not_oob_actionbar() {
let did = "did:plc:listv";
let state = test_state(&[]).await;
store::grant_access(&state.db, did, None, "test", None)
.await
.unwrap();
let feed = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://list.example/feed.xml".to_string(),
title: Some("List".to_string()),
..Default::default()
},
)
.await
.unwrap();
store::insert_entries(
&state.db,
feed,
&[store::NewEntry {
guid: "l-1".to_string(),
url: Some("https://list.example/1".to_string()),
title: Some("Article".to_string()),
published: Some("2026-07-11T00:00:00Z".to_string()),
..Default::default()
}],
0,
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed])
.await
.unwrap();
let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/entries/{entry_id}/read"))
.header(header::COOKIE, cookie)
.header("HX-Request", "true")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("read=true"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
!html.contains("hx-swap-oob"),
"list-view response must NOT be an OOB swap: {html}"
);
}
#[tokio::test]
async fn rename_to_new_url_refused_at_global_feeds_cap() {
let did = "did:plc:renamer";
let state = test_state_with_caps(did, 0, 1).await;
store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://existing.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
let before = store::count_feeds(&state.db).await.unwrap();
assert_eq!(before, 1);
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rkey123/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https://brand-new.example/feed.xml&title=Renamed",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("feed%20capacity"),
"expected the feed-capacity flash, got {loc}"
);
let after = store::count_feeds(&state.db).await.unwrap();
assert_eq!(
after, before,
"rename inflated the shared cache past the cap"
);
}
#[tokio::test]
async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
let did = "did:plc:renamer2";
let state = test_state_with_caps(did, 0, 1).await;
store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://existing.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
let before = store::count_feeds(&state.db).await.unwrap();
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rkey123/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https://existing.example/feed.xml&title=Retitled",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert_eq!(
loc, "/",
"retitle of an existing feed must succeed, got {loc}"
);
assert_eq!(
store::count_feeds(&state.db).await.unwrap(),
before,
"retitle must not add a feeds row"
);
}
#[tokio::test]
async fn rename_with_blank_url_writes_nothing() {
let did = "did:plc:renamer3";
let state = test_state_with_caps(did, 0, 0).await;
let before = store::count_feeds(&state.db).await.unwrap();
assert_eq!(before, 0);
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rkey123/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("url=%20%20&title=Nope"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/",
);
assert_eq!(
store::count_feeds(&state.db).await.unwrap(),
0,
"blank-URL rename wrote a junk feeds row"
);
}
#[test]
fn manage_rename_row_preselects_current_folder() {
let nav = Nav {
handle: "@reader.example".to_string(),
avatar: "RE".to_string(),
view: "unread".to_string(),
scope_qs: String::new(),
folders: Vec::new(),
loose_feeds: Vec::new(),
manage_active: true,
};
let folder_options = vec![
FolderOption {
uri: "at://did:plc:x/app.folder/work".to_string(),
name: "Work".to_string(),
},
FolderOption {
uri: "at://did:plc:x/app.folder/fun".to_string(),
name: "Fun".to_string(),
},
];
let foldered = FeedView {
rkey: "sub-foldered".to_string(),
url: "https://work.example/feed.xml".to_string(),
title: "Work Feed".to_string(),
unread: 0,
selected: false,
folder: Some("at://did:plc:x/app.folder/work".to_string()),
};
let loose = FeedView {
rkey: "sub-loose".to_string(),
url: "https://loose.example/feed.xml".to_string(),
title: "Loose Feed".to_string(),
unread: 0,
selected: false,
folder: None,
};
let tmpl = ManageTemplate {
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
flash: String::new(),
nav,
folder_options,
folders: vec![FolderView {
rkey: "folder-work".to_string(),
uri: "at://did:plc:x/app.folder/work".to_string(),
name: "Work".to_string(),
feeds: vec![foldered],
selected: false,
}],
loose_feeds: vec![loose],
};
let html = tmpl.render().unwrap();
assert!(
html.contains(
r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
),
"foldered feed must pre-select its current folder: {html}"
);
assert!(
html.contains(r#"<option value="" selected>No folder</option>"#),
"loose feed must pre-select 'No folder': {html}"
);
}
}