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, ServeFile};
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::safe_link::SafeLink;
use crate::sanitized_html::{BodyRender, BodyRenderer};
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 OAUTH_BINDING_COOKIE: &str = "fr_oauth";
const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
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("/standard-site", get(standard_site))
.route("/stats", get(stats))
.route("/privacy", get(privacy))
.route("/terms", get(terms))
.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("/saved/{rkey}/delete", post(unsave_record))
.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("/claim", get(claim))
.route("/bot/claims", post(bot_mint_claim))
.route("/admin/invites", post(admin_mint_invites))
.route("/admin/metrics", get(admin_metrics))
.route("/oauth/client-metadata.json", get(oauth_client_metadata))
.route("/oauth/jwks.json", get(oauth_jwks))
.route("/account/delete", post(account_delete))
.route("/oauth/callback", get(oauth_callback))
.route("/logout", post(logout))
.nest_service("/static", ServeDir::new("static"))
.route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
.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 = 1024 * 1024;
#[cfg(test)]
const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
#[cfg(test)]
const _: () = assert!(
OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
"OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
);
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" || path == "/claim" || path == "/oauth/callback"))
{
return false;
}
match path {
"/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
| "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
| "/folders" => true,
p => {
(p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
|| p.starts_with("/saved/")
|| 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<RateLimiterState>>,
}
struct RateLimiterState {
buckets: HashMap<IpAddr, Bucket>,
last_sweep: Instant,
}
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);
const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
const MAX_RATE_BUCKETS: usize = 10_000;
const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
impl RateLimiter {
fn shared() -> Self {
Self {
inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
buckets: HashMap::new(),
last_sweep: Instant::now(),
})),
}
}
fn check(&self, ip: IpAddr) -> bool {
self.check_at(ip, Instant::now())
}
fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
let mut state = match self.inner.lock() {
Ok(m) => m,
Err(p) => p.into_inner(),
};
if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
state
.buckets
.retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
state.last_sweep = now;
}
if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
let mut by_age: Vec<(IpAddr, Instant)> =
state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
by_age.sort_unstable_by_key(|(_, last)| *last);
for (victim, _) in by_age
.into_iter()
.take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
{
state.buckets.remove(&victim);
}
warn!(
buckets = state.buckets.len(),
"rate-limit bucket cap reached; evicted the least recently seen clients"
);
}
let bucket = state.buckets.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 == "/standard-site"
|| path == "/privacy"
|| path == "/terms"
|| 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_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
.fetch_optional(pool)
.await
}
const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
fn health_tick_stale_secs(tick: Duration) -> i64 {
let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
}
fn configured_poll_tick() -> Duration {
std::env::var("FEATHERREADER_POLL_TICK_SECS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|s| *s > 0)
.map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
}
const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
async fn health(State(state): State<AppState>) -> Response {
let now = chrono::Utc::now().timestamp();
let rh = &state.runtime_health;
use crate::runtime_health::DbProbe;
let db = match rh.begin_db_probe() {
Err(borrowed) => borrowed,
Ok(probe) => {
let pool = state.db.clone();
let task = tokio::spawn(async move {
let verdict =
match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
Ok(Ok(_)) => DbProbe::Ok,
Ok(Err(err)) => {
warn!(%err, "health: database probe failed");
DbProbe::Failed("unavailable".to_string())
}
Err(_) => {
warn!(
timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
"health: database probe timed out (pool exhausted?)"
);
DbProbe::Failed("timeout".to_string())
}
};
probe.record(verdict.clone());
verdict
});
task.await.unwrap_or(DbProbe::Unknown)
}
};
let uptime = rh.uptime_secs(now);
let poller = if !rh.schedulers_enabled() {
"disabled".to_string()
} else {
match rh.secs_since_poll_tick(now) {
None => match uptime {
Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
format!("stale never-ticked {up}s")
}
_ => "not-yet-ticked".to_string(),
},
Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
format!("stale {secs}s")
}
Some(secs) => format!("ok {secs}s"),
}
};
let mut body = String::new();
let status = match &db {
DbProbe::Ok => {
body.push_str(&format!("ok featherreader/{VERSION}\n"));
body.push_str("db: ok\n");
StatusCode::OK
}
DbProbe::Unknown => {
body.push_str(&format!("unknown featherreader/{VERSION}\n"));
body.push_str("db: unknown (no probe has completed yet)\n");
StatusCode::OK
}
DbProbe::Failed(why) => {
body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
body.push_str(&format!("db: {why}\n"));
StatusCode::SERVICE_UNAVAILABLE
}
};
body.push_str(&format!(
"uptime: {}\n",
match uptime {
Some(secs) => format!("{secs}s"),
None => "unknown".to_string(),
}
));
body.push_str(&format!("poller: {poller}\n"));
body.push_str(&format!(
"polling-paused: {}\n",
if rh.watermark_paused() { "yes" } else { "no" }
));
body.push_str(&format!(
"backend: {}\n",
state.config.repo_backend.as_str()
));
body.push_str(&format!(
"oauth-runtime: {}\n",
if state.oauth.is_some() {
"built"
} else {
"absent"
}
));
let mut resp = (status, body).into_response();
if let Ok(hv) = header::HeaderValue::from_str("no-store") {
resp.headers_mut().insert(header::CACHE_CONTROL, hv);
}
resp
}
async fn about(State(state): State<AppState>) -> Response {
let adoption = if state.config.show_adoption {
adoption_line(&state).await
} else {
None
};
render(&AboutTemplate {
card: Card::public(
&state.config,
"/about",
"About — FeatherReader",
"What FeatherReader is and isn't: an open-source, atproto-native reader for \
RSS feeds and standard.site publications, run as an experiment, free to \
self-host under the AGPL.",
),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
adoption,
standard_site: state.config.standard_site,
})
}
async fn standard_site(State(state): State<AppState>) -> Response {
render(&StandardSiteTemplate {
card: Card::public(
&state.config,
"/standard-site",
"standard.site — FeatherReader",
"Read standard.site publications beside your RSS feeds: articles \
published as atproto records, followed with the same portable \
subscription record.",
),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
standard_site: state.config.standard_site,
releases: RELEASES,
})
}
async fn unsave_record(
State(state): State<AppState>,
headers: HeaderMap,
Path(rkey): Path<String>,
) -> Response {
let Some(did) = current_did(&state, &headers).await else {
return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
};
let identity = match state.repo().list_saved(&did).await {
Ok(records) => records
.into_iter()
.find(|(k, _)| *k == rkey)
.map(|(_, rec)| (rec.url, rec.entry_id)),
Err(err) => {
warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
a local star for the same article may survive");
None
}
};
match state.repo().remove_saved(&did, &rkey).await {
Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
Err(err) => {
warn!(%err, %did, %rkey, "could not remove the saved record");
return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
}
}
if let Some((url, guid)) = identity {
match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
Ok(0) => {}
Ok(n) => {
info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
}
Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
}
}
if is_htmx(&headers) {
return (StatusCode::OK, "").into_response();
}
Redirect::to("/?view=starred").into_response()
}
fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
if !rh.schedulers_enabled() {
return "off";
}
match rh.secs_since_poll_tick(now_unix) {
None => {
match rh.uptime_secs(now_unix) {
Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
_ => "starting",
}
}
Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
_ if rh.watermark_paused() => "paused",
_ => "running",
}
}
async fn stats(State(state): State<AppState>) -> Response {
let now = chrono::Utc::now();
let health = match store::poll_health(
&state.db,
&now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
&(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
)
.await
{
Ok(health) => health,
Err(err) => {
warn!(%err, "could not compute poll health");
return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
}
};
let (deferred, starved_since) = state.sanitize_starvation.snapshot();
let polled_pct = if health.feeds_tracked == 0 {
100
} else {
health.polled_last_hour * 100 / health.feeds_tracked
};
render(&StatsTemplate {
card: Card::public(
&state.config,
"/stats",
"Stats — FeatherReader",
"Is this instance's poller keeping up? Aggregate feed-polling health — \
counts only; no feed and no reader is named.",
),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
feeds_tracked: health.feeds_tracked,
polled_last_hour: health.polled_last_hour,
polled_pct,
overdue: health.overdue,
last_poll: humanise_ago(health.last_poll_secs_ago),
oldest_poll: if health.never_polled > 0 {
"never".to_string()
} else {
humanise_ago(health.oldest_poll_secs_ago)
},
never_polled: health.never_polled,
poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
in_backoff: health.in_backoff,
badly_broken: health.badly_broken,
failure_kinds: health.failure_kinds,
fetching: fetching_state(&state.runtime_health, now.timestamp()),
deferred_no_permit: deferred,
starved_since: starved_since.map(|since| humanise_ago(Some(now.timestamp() - since))),
})
}
fn humanise_ago(secs: Option<i64>) -> String {
let Some(secs) = secs else {
return "never".to_string();
};
match secs {
s if s < 60 => format!("{s}s ago"),
s if s < 3600 => format!("{}m ago", s / 60),
s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
}
}
async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
repos: stat.value,
truncated: stat.truncated,
observed_on: stat
.observed_at
.split('T')
.next()
.unwrap_or_default()
.to_string(),
}),
Ok(_) => None,
Err(err) => {
warn!(%err, "about: adoption stat read failed; omitting the line");
None
}
}
}
async fn privacy(State(state): State<AppState>) -> Response {
render(&PrivacyTemplate {
card: Card::public(
&state.config,
"/privacy",
"Privacy — FeatherReader",
"No account and no tracking: your subscriptions and reading state live in \
your own PDS. What this server caches, for how long, and how the session \
token is handled.",
),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
})
}
async fn terms(State(state): State<AppState>) -> Response {
render(&TermsTemplate {
card: Card::public(
&state.config,
"/terms",
"Terms — FeatherReader",
"The terms of use: an experimental service offered as-is with no warranty, \
what acceptable use means here, and the AGPL self-host note.",
),
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: SafeLink,
cached: bool,
rkey: 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,
}
pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
const SITE_TITLE: &str = "FeatherReader — read, quietly";
const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
standard.site publications. Your subscriptions live in your own PDS — no signup, no \
password, no tracking.";
const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
#[derive(Debug, Clone)]
pub(crate) struct Card {
pub title: String,
pub description: String,
pub url: String,
pub image: String,
pub private: bool,
}
impl Card {
fn public(
config: &Config,
path: &str,
title: impl Into<String>,
description: impl Into<String>,
) -> Self {
let origin = config.public_url.trim_end_matches('/');
Card {
title: title.into(),
description: description.into(),
url: format!("{origin}{path}"),
image: format!("{origin}{SHARE_IMAGE_PATH}"),
private: false,
}
}
fn site(config: &Config) -> Self {
Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
}
fn private(config: &Config) -> Self {
Card {
private: true,
..Card::site(config)
}
}
}
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
flash: String,
alert: String,
nav: Nav,
entries: Vec<EntryRow>,
heading: String,
feed_scope: Option<String>,
total: i64,
uncached_total: i64,
page: i64,
page_count: i64,
prev_href: Option<String>,
next_href: Option<String>,
}
#[derive(Template)]
#[template(path = "manage.html")]
struct ManageTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
flash: String,
alert: String,
nav: Nav,
folder_options: Vec<FolderOption>,
folders: Vec<FolderView>,
loose_feeds: Vec<FeedView>,
standard_site: bool,
}
struct AdoptionLine {
repos: i64,
truncated: bool,
observed_on: String,
}
#[derive(Template)]
#[template(path = "about.html")]
struct AboutTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
adoption: Option<AdoptionLine>,
standard_site: bool,
}
#[derive(Template)]
#[template(path = "standard_site.html")]
struct StandardSiteTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
standard_site: bool,
releases: &'static [Release],
}
pub(crate) struct Release {
pub(crate) version: &'static str,
pub(crate) date: &'static str,
pub(crate) summary: &'static str,
}
impl Release {
pub(crate) fn url(&self) -> String {
format!("{REPO_URL}/releases/tag/v{}", self.version)
}
pub(crate) fn changelog_url(&self) -> String {
format!(
"{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
self.version.replace('.', ""),
self.date
)
}
}
pub(crate) const RELEASES: &[Release] = &[
Release {
version: "0.4.7",
date: "2026-10-07",
summary: "A feed can no longer stall the reader with an article that is \
slow to clean up, and every stored article is cleaned again \
as it is shown.",
},
Release {
version: "0.4.6",
date: "2026-10-06",
summary: "Renaming a subscription or a folder no longer overwrites \
what another app changed at the same moment, and a folder \
rename keeps everything but the name.",
},
Release {
version: "0.4.5",
date: "2026-10-06",
summary: "An operator teardown now signs every user out at their own \
server before deleting anything, and the session-writing \
code is hardened against the races that work exposed.",
},
Release {
version: "0.4.4",
date: "2026-10-05",
summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
links unchanged and real RSS bylines, and the address guard \
refuses the reserved ranges it missed.",
},
Release {
version: "0.4.3",
date: "2026-10-05",
summary: "Two write-path fixes for any PDS: large OPML imports and \
read-state syncs are sent in calls the PDS accepts, and a \
read-state sync that disagreed with the PDS recovers instead \
of failing every round.",
},
Release {
version: "0.4.2",
date: "2026-10-04",
summary: "A public standard.site feature page with this list of recent \
releases, and link cards: a posted feather-reader.com link \
now unfurls with a description and an image.",
},
Release {
version: "0.4.1",
date: "2026-10-04",
summary: "The public pages explain standard.site publications, and the \
subscribe form can submit the DID form of a publication URI, \
which browsers refused in 0.4.0.",
},
Release {
version: "0.4.0",
date: "2026-10-03",
summary: "standard.site support: publications are read from their \
authors' atproto repos as subscriptions, beside RSS, on their \
own polling loop. Every stored field from a feed or a \
publication now has a size bound.",
},
];
#[derive(Template)]
#[template(path = "stats.html")]
struct StatsTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
feeds_tracked: i64,
polled_last_hour: i64,
polled_pct: i64,
overdue: i64,
last_poll: String,
oldest_poll: String,
never_polled: i64,
poll_interval_mins: i64,
in_backoff: i64,
badly_broken: i64,
failure_kinds: Vec<(String, i64)>,
fetching: &'static str,
deferred_no_permit: u64,
starved_since: Option<String>,
}
#[derive(Template)]
#[template(path = "privacy.html")]
struct PrivacyTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
}
#[derive(Template)]
#[template(path = "terms.html")]
struct TermsTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
kofi_url: &'static str,
}
#[derive(Template)]
#[template(path = "landing.html")]
struct LandingTemplate {
card: Card,
version: &'static str,
repo_url: &'static str,
crates_url: &'static str,
kofi_url: &'static str,
standard_site: bool,
releases: &'static [Release],
}
#[derive(Template)]
#[template(path = "entry.html")]
struct EntryTemplate {
card: Card,
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<SafeLink>,
content_html: Option<BodyRender>,
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 {
card: Card,
repo_url: &'static str,
error: String,
flash: String,
}
#[derive(Template)]
#[template(path = "beta_redeem.html")]
struct BetaRedeemTemplate {
card: Card,
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) => p.chars().take(10).collect(),
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)]
page: Option<u32>,
#[serde(default)]
flash: Option<String>,
}
const ENTRIES_PER_PAGE: i64 = 100;
fn page_count_for(total: i64) -> i64 {
((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
}
const PREV_NEXT_MAX: i64 = 5_000;
const STARRED_IDENTITY_MAX: i64 = 20_000;
const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
struct ResolvedSub {
rkey: String,
sub: Subscription,
feed: Option<store::Feed>,
}
async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
resolve_subscriptions_noting(state, did).await.0
}
fn subscriptions_alert(err: &anyhow::Error) -> String {
match err.downcast_ref::<crate::atproto::MalformedRecords>() {
Some(m) => format!(
"{} record(s) in your subscription list could not be read, so it was not \
refreshed. Showing your last-known subscriptions; nothing was removed.",
m.count
),
None => "Your subscription list could not be read from your PDS just now. \
Showing your last-known subscriptions."
.to_string(),
}
}
async fn resolve_subscriptions_noting(
state: &AppState,
did: &str,
) -> (Vec<ResolvedSub>, Option<String>) {
let pool = &state.db;
let subs = match state.repo().list_subscriptions_sorted(did).await {
Ok(s) => s,
Err(err) => {
let alert = subscriptions_alert(&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_else(|err| {
warn!(%err, %did, "the PDS is unreachable AND the local subscription \
projection could not be read; rendering an EMPTY \
feed list, which is not the same as having none");
Vec::new()
});
let cached = feeds
.into_iter()
.map(|f| ResolvedSub {
rkey: String::new(),
sub: Subscription::new(f.url.clone(), now_rfc3339()),
feed: Some(f),
})
.collect();
return (cached, Some(alert));
}
};
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) => {
if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
|| feed::classify_feed_privacy(&sub.url).is_private()
{
warn!(
%did,
"skipping cache row for a subscription URL that is private or not http(s)"
);
out.push(ResolvedSub {
rkey,
sub,
feed: None,
});
continue;
}
if let Err(err) = store::upsert_feed(
pool,
&store::NewFeed {
url: sub.url.clone(),
title: sub.title.clone(),
site_url: sub.site_url.clone(),
..Default::default()
},
)
.await
{
warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
it will not be polled");
}
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, None)
}
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 {
card: Card::site(&state.config),
version: VERSION,
repo_url: REPO_URL,
crates_url: CRATES_URL,
kofi_url: KOFI_URL,
standard_site: state.config.standard_site,
releases: RELEASES,
}))
}
};
let did = user.did.clone();
let pool = &state.db;
let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
let view = match q.view.as_deref() {
Some("all") => "all",
Some("starred") => "starred",
_ => "unread",
}
.to_string();
let list_view = list_view_of(q.view.as_deref());
let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
let scope_ids = scoped_feed_ids(&subs, &scope_urls);
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 mut uncached: Vec<EntryRow> = Vec::new();
if view == "starred" {
let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
Ok(store::StarredIdentities::All(rows)) => Some(rows),
Ok(store::StarredIdentities::Truncated) => {
warn!(
%did,
cap = STARRED_IDENTITY_MAX,
"cached-starred set exceeded its cap; suppressing uncached saved rows \
rather than rendering record-deleting buttons for cached articles"
);
None
}
Err(err) => {
warn!(%err, %did, "cached-starred identity lookup failed; \
suppressing uncached saved rows this render");
None
}
};
let identities_ok = identities.is_some();
let identities = identities.unwrap_or_default();
let cached_urls: std::collections::HashSet<&str> = identities
.iter()
.filter_map(|(url, _)| url.as_deref())
.collect();
let cached_guids: std::collections::HashSet<&str> =
identities.iter().map(|(_, guid)| guid.as_str()).collect();
let mut uncached_dropped = 0usize;
match state.repo().list_saved_sorted(&did).await {
Ok(saved) if identities_ok => {
for (rkey, item) in saved {
let known = cached_urls.contains(item.url.as_str())
|| item
.entry_id
.as_deref()
.is_some_and(|g| cached_guids.contains(g));
if known {
continue;
}
if let Some(urls) = &scope_urls {
match item.feed_url.as_deref() {
Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
_ => continue,
}
}
let link = SafeLink::external(&item.url);
if link.is_empty() {
warn!(
%did, %rkey,
"a saved record has an unusable URL; rendering it without a link \
so it can still be removed"
);
}
if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
uncached_dropped += 1;
continue;
}
if let Some(feed_url) = item.feed_url.as_deref() {
if subs.iter().any(|s| s.sub.url == feed_url) {
let stale_before = (chrono::Utc::now()
- chrono::Duration::from_std(state.config.poll_interval)
.unwrap_or_else(|_| chrono::Duration::hours(1)))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
if let Err(err) =
store::mark_feed_due(pool, feed_url, &stale_before).await
{
tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
}
}
}
uncached.push(EntryRow {
id: 0,
title: item
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| {
if link.is_empty() {
format!("Saved item {rkey}")
} else {
item.url.clone()
}
}),
feed_title: item.feed_url.clone().unwrap_or_default(),
published: display_date(Some(&item.created_at)),
read: false,
starred: true,
link,
cached: false,
rkey,
});
}
}
Ok(_) => {}
Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
}
if uncached_dropped > 0 {
warn!(
%did,
dropped = uncached_dropped,
cap = MAX_UNCACHED_SAVED_ROWS,
"more saved records than this instance will hold in one response; the \
rest are not reachable from here"
);
}
}
let total_cached =
store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
let uncached_len = uncached.len();
let total = total_cached + uncached_len as i64;
let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
let offset = (page - 1) * ENTRIES_PER_PAGE;
let source = store::list_entries(
pool,
&did,
list_view,
scope_ids.as_deref(),
ENTRIES_PER_PAGE,
offset,
)
.await?;
let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
let cached_here = cached_allotment.min(source.len());
let source = if uncached_len == 0 {
&source[..]
} else {
&source[..cached_here]
};
let uncached_page: Vec<EntryRow> = {
let skip = (offset - total_cached).max(0) as usize;
let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
uncached.into_iter().skip(skip).take(take).collect()
};
let uncached_total = uncached_len as i64;
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 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: e.read,
starred: e.starred,
link: SafeLink::entry(e.id, &entry_scope_qs),
cached: true,
rkey: String::new(),
})
.collect();
let mut entries = entries;
entries.extend(uncached_page);
let entries = entries;
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 page_href = |n: i64| -> String {
let mut parts = Vec::new();
if !entry_scope_qs.is_empty() {
parts.push(entry_scope_qs.clone());
}
if n > 1 {
parts.push(format!("page={n}"));
}
if parts.is_empty() {
"/".to_string()
} else {
format!("/?{}", parts.join("&"))
}
};
let prev_href = (page > 1).then(|| page_href(page - 1));
let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
let tmpl = IndexTemplate {
card: Card::private(&state.config),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
flash: q.flash.unwrap_or_default(),
alert: alert.unwrap_or_default(),
nav,
entries,
heading,
feed_scope,
total,
uncached_total,
page,
page_count: page_count_for(total),
prev_href,
next_href,
};
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, alert) = resolve_subscriptions_noting(&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 {
card: Card::private(&state.config),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
flash: q.flash.unwrap_or_default(),
alert: alert.unwrap_or_default(),
nav,
folder_options,
folders: folder_views,
loose_feeds,
standard_site: state.config.standard_site,
};
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_counts = store::unread_counts_by_feed(pool, did)
.await
.unwrap_or_else(|err| {
warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
Default::default()
});
let folders = state
.repo()
.list_folders_sorted(did)
.await
.unwrap_or_default();
let unread_count = |feed_id: Option<i64>| -> i64 {
feed_id
.and_then(|id| unread_counts.get(&id).copied())
.unwrap_or(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 content_html = match entry.content_html.clone() {
Some(raw) => Some(BodyRenderer::shared().render(raw).await?),
None => None,
};
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 {
card: Card::private(&state.config),
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.as_deref().and_then(SafeLink::external_opt),
content_html,
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(),
page: None,
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());
store::list_entry_ids(
pool,
did,
list_view_of(q.view.as_deref()),
scoped_feed_ids(&subs, &scope_urls).as_deref(),
PREV_NEXT_MAX,
)
.await
.unwrap_or_else(|err| {
warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
Vec::new()
})
}
fn list_view_of(view: Option<&str>) -> store::ListView {
match view {
Some("all") => store::ListView::All,
Some("starred") => store::ListView::Starred,
_ => store::ListView::Unread,
}
}
fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
let urls = scope_urls.as_ref()?;
Some(
subs.iter()
.filter(|s| urls.contains(&s.sub.url))
.filter_map(|s| s.feed.as_ref().map(|f| f.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.repo().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.repo().list_saved(&did).await {
Ok(records) => {
for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
if let Err(err) = state.repo().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 UNSUPPORTED_FEED_URL_REFUSAL: &str =
"That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
const EXPORT_INCOMPLETE_REFUSAL: &str =
"Could not read your subscriptions in full, so nothing was exported. Your \
feeds are unchanged — try again, and if it keeps failing the list may be \
larger than this reader can page through.";
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 publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
let canonical = format!(
"{}{}",
crate::atproto::AT_URI_PREFIX,
&input[crate::atproto::AT_URI_PREFIX.len()..]
);
let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
return Err(unsupported());
}
let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
uri.authority.clone()
} else {
let handle =
crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
.await
.map_err(|err| {
warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
format!("Couldn't resolve the handle {} to an account.", uri.authority)
})?
};
let url = format!(
"{}{did}/{}/{}",
crate::atproto::AT_URI_PREFIX,
uri.collection,
uri.rkey
);
if !feed::is_storable_feed_url(&url, true) {
return Err(unsupported());
}
Ok(url)
}
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());
}
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 is_at_uri = input
.get(..crate::atproto::AT_URI_PREFIX.len())
.is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
let publication_url = if is_at_uri {
if !state.config.standard_site {
info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
return Ok(
Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
.into_response(),
);
}
match publication_url_from_paste(&state, &input).await {
Ok(url) => Some(url),
Err(flash) => {
info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
}
}
} else {
None
};
if let feed::FeedPrivacy::Private(reason) =
feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&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 resolved = match publication_url {
Some(url) => Ok(url),
None => resolve_feed_url(&state.config, &input).await,
};
let feed_url = match resolved {
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(),
);
}
if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
return Ok(
Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_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_by_kind(pool, &client, &state.config, &feed_row).await {
Ok(outcome) => {
info!(feed = %feed_url, ?outcome, "polled new subscription");
feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
}
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.repo().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.repo().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>,
#[serde(default)]
seen_url: Option<String>,
#[serde(default)]
seen_title: Option<String>,
#[serde(default)]
seen_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());
}
let mut base: Option<Subscription> = None;
for attempt in 1..=RENAME_ATTEMPTS {
match rename_subscription_once(&state, &did, &rkey, &form, &mut base).await? {
RenameAttempt::Done(resp) => return Ok(resp),
RenameAttempt::Raced => {
info!(%did, %rkey, attempt, "subscription changed between read and write; re-reading");
}
}
}
warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused rename: the subscription kept changing elsewhere");
Ok(rename_conflict_response())
}
fn rename_conflict_response() -> Response {
Redirect::to(&format!(
"/?flash={}",
qenc(
"This subscription was changed elsewhere while you were editing it — \
nothing was renamed or moved. Reload and try again."
)
))
.into_response()
}
fn form_value(v: Option<&str>) -> Option<String> {
v.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
}
#[derive(Debug, PartialEq, Eq)]
struct MergedRename {
sub: Subscription,
repoint: bool,
already_saved: bool,
}
#[derive(Debug, PartialEq, Eq)]
struct RenameConflict(&'static str);
fn merge_rename(
form: &RenameSubForm,
base: &Subscription,
fresh: Subscription,
) -> Result<MergedRename, RenameConflict> {
let mut sub = fresh;
let mut edited = 0;
let mut to_write = 0;
let posted_url = form.url.trim();
let seen_url = form.seen_url.as_deref().unwrap_or(&base.url).trim();
let mut repoint = false;
if posted_url != seen_url {
edited += 1;
if sub.url.trim() == posted_url {
} else if sub.url.trim() != base.url.trim() {
return Err(RenameConflict("url"));
} else {
to_write += 1;
repoint = true;
}
}
sub.url = if repoint { posted_url } else { sub.url.trim() }.to_string();
let posted_title = form_value(form.title.as_deref());
let seen_title = match form.seen_title.as_deref() {
Some(seen) => form_value(Some(seen)),
None => base.title.clone(),
};
if posted_title != seen_title {
edited += 1;
if sub.title == posted_title {
} else if sub.title != base.title {
return Err(RenameConflict("title"));
} else {
to_write += 1;
sub.title = posted_title;
}
}
if form.folder.is_some() || form.seen_folder.is_some() {
let posted_folder = form_value(form.folder.as_deref());
let seen_folder = match form.seen_folder.as_deref() {
Some(seen) => form_value(Some(seen)),
None => base.folder.clone(),
};
if posted_folder != seen_folder {
edited += 1;
if sub.folder == posted_folder {
} else if sub.folder != base.folder {
return Err(RenameConflict("folder"));
} else {
to_write += 1;
sub.folder = posted_folder;
}
}
}
match form_value(form.site_url.as_deref()) {
Some(site) if Some(&site) != base.site_url.as_ref() => {
edited += 1;
if sub.site_url.as_ref() == Some(&site) {
} else if sub.site_url != base.site_url {
return Err(RenameConflict("siteUrl"));
} else {
to_write += 1;
sub.site_url = Some(site);
}
}
Some(_) => {}
None if repoint => sub.site_url = None,
None => {}
}
if repoint {
sub.fetch_hint = None;
}
Ok(MergedRename {
sub,
repoint,
already_saved: edited > 0 && to_write == 0,
})
}
const RENAME_ATTEMPTS: u32 = 2;
enum RenameAttempt {
Done(Response),
Raced,
}
async fn rename_subscription_once(
state: &AppState,
did: &str,
rkey: &str,
form: &RenameSubForm,
base: &mut Option<Subscription>,
) -> Result<RenameAttempt, WebError> {
use RenameAttempt::Done;
let found = match state.repo().list_subscriptions_with_cids(did).await {
Ok(subs) => subs
.into_iter()
.find(|(k, _, _)| k == rkey)
.map(|(_, cid, s)| (cid, s)),
Err(err) => {
warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
return Ok(Done(
Redirect::to(&format!(
"/?flash={}",
qenc("Could not reach your PDS — nothing was renamed or moved.")
))
.into_response(),
));
}
};
let Some((read_cid, fresh)) = found else {
warn!(%did, %rkey, "refused rename: no such subscription in the repo");
return Ok(Done(
Redirect::to(&format!(
"/?flash={}",
qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
))
.into_response(),
));
};
let base = base.get_or_insert_with(|| fresh.clone());
let MergedRename {
sub,
repoint: url_changed,
already_saved,
} = match merge_rename(form, base, fresh) {
Ok(merged) => merged,
Err(RenameConflict(field)) => {
warn!(%did, %rkey, field, "refused rename: the reader and another client both changed the same field");
return Ok(Done(rename_conflict_response()));
}
};
if already_saved {
info!(%did, %rkey, "rename already in the record; nothing to write");
return Ok(Done(Redirect::to("/").into_response()));
}
let feed_url = sub.url.clone();
let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
if url_changed && !storable {
info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
return Ok(Done(
Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
.into_response(),
));
}
if url_changed {
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(Done(
Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
));
}
}
let feeds_cap = state.config.max_feeds_global;
if url_changed
&& 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(Done(
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"),
}
}
if read_cid.is_none() {
warn!(%did, %rkey, "the PDS listed this subscription without a CID; renaming without a compare-and-swap");
}
let res = match state
.repo()
.update_subscription(did, rkey, &sub, read_cid.as_deref())
.await
{
Ok(res) => res,
Err(err) if crate::atproto::is_invalid_swap(&err) => return Ok(RenameAttempt::Raced),
Err(err) => {
warn!(%err, %did, %rkey, "PDS subscription update failed");
return Ok(Done(
Redirect::to(&format!(
"/?flash={}",
qenc("Could not save that change to your PDS — nothing was renamed or moved.")
))
.into_response(),
));
}
};
info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
let cache_write = storable
&& (url_changed
|| match store::get_feed_by_url(&state.db, &sub.url).await {
Ok(row) => row.is_some(),
Err(err) => {
warn!(%err, %did, url = %sub.url, "could not look up the cached feed row after a rename");
false
}
});
if !cache_write {
info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
} else if let Err(err) = store::upsert_feed(
&state.db,
&store::NewFeed {
url: sub.url.clone(),
title: sub.title.clone(),
site_url: sub.site_url.clone(),
..Default::default()
},
)
.await
{
warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
}
Ok(Done(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.repo().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())
}
#[derive(Debug, Deserialize)]
struct RenameFolderForm {
name: String,
#[serde(default)]
seen_name: Option<String>,
}
async fn rename_folder(
State(state): State<AppState>,
headers: HeaderMap,
Path(rkey): Path<String>,
Form(form): Form<RenameFolderForm>,
) -> Result<Response, WebError> {
let did = match current_did(&state, &headers).await {
Some(d) => d,
None => return Ok(Redirect::to("/login").into_response()),
};
if form.name.trim().is_empty() {
return Ok(Redirect::to("/").into_response());
}
let mut base: Option<Folder> = None;
for attempt in 1..=RENAME_ATTEMPTS {
match rename_folder_once(&state, &did, &rkey, &form, &mut base).await {
RenameAttempt::Done(resp) => return Ok(resp),
RenameAttempt::Raced => {
info!(%did, %rkey, attempt, "folder changed between read and write; re-reading");
}
}
}
warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused folder rename: the folder kept changing elsewhere");
Ok(folder_flash(FOLDER_RENAME_CONFLICT))
}
const FOLDER_RENAME_CONFLICT: &str = "This folder was changed elsewhere while you were renaming \
it — it was not renamed. Reload and try again.";
fn folder_flash(message: &str) -> Response {
Redirect::to(&format!("/?flash={}", qenc(message))).into_response()
}
#[derive(Debug, PartialEq, Eq)]
enum FolderMerge {
Write(Folder),
AlreadySaved,
Unchanged,
}
fn merge_folder_rename(
posted: &str,
seen: Option<&str>,
base: &Folder,
fresh: Folder,
) -> Result<FolderMerge, RenameConflict> {
let posted = posted.trim();
let ancestor = seen.unwrap_or(&base.name).trim();
if posted == ancestor {
return Ok(FolderMerge::Unchanged);
}
let current = fresh.name.trim();
if current == posted {
return Ok(FolderMerge::AlreadySaved);
}
if current != ancestor {
return Err(RenameConflict("name"));
}
let mut folder = fresh;
folder.name = posted.to_string();
Ok(FolderMerge::Write(folder))
}
async fn rename_folder_once(
state: &AppState,
did: &str,
rkey: &str,
form: &RenameFolderForm,
base: &mut Option<Folder>,
) -> RenameAttempt {
use RenameAttempt::Done;
let found = match state.repo().list_folders_with_cids(did).await {
Ok(folders) => folders
.into_iter()
.find(|(k, _, _)| k == rkey)
.map(|(_, cid, f)| (cid, f)),
Err(err) => {
warn!(%err, %did, %rkey, "could not read the folder before renaming it");
return Done(folder_flash(
"Could not reach your PDS — the folder was not renamed.",
));
}
};
let Some((read_cid, fresh)) = found else {
warn!(%did, %rkey, "refused folder rename: no such folder in the repo");
return Done(folder_flash(
"That folder no longer exists — it may have been deleted elsewhere. \
Nothing was renamed.",
));
};
let base = base.get_or_insert_with(|| fresh.clone());
let folder = match merge_folder_rename(&form.name, form.seen_name.as_deref(), base, fresh) {
Ok(FolderMerge::Write(folder)) => folder,
Ok(FolderMerge::AlreadySaved) => {
info!(%did, %rkey, "folder already has this name; nothing to write");
return Done(Redirect::to("/").into_response());
}
Ok(FolderMerge::Unchanged) => return Done(Redirect::to("/").into_response()),
Err(RenameConflict(field)) => {
warn!(%did, %rkey, field, "refused folder rename: the reader and another client both renamed it");
return Done(folder_flash(FOLDER_RENAME_CONFLICT));
}
};
if read_cid.is_none() {
warn!(%did, %rkey, "the PDS listed this folder without a CID; renaming without a compare-and-swap");
}
match state
.repo()
.rename_folder(did, rkey, &folder, read_cid.as_deref())
.await
{
Ok(res) => {
info!(%did, %rkey, uri = %res.uri, "renamed folder");
Done(Redirect::to("/").into_response())
}
Err(err) if crate::atproto::is_invalid_swap(&err) => RenameAttempt::Raced,
Err(err) => {
warn!(%err, %did, %rkey, "PDS folder rename failed");
Done(folder_flash(
"Could not save that change to your PDS — the folder was not renamed.",
))
}
}
}
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.repo().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).await;
}
render(&LoginTemplate {
card: login_card(&state.config),
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(&state, "Enter your atproto handle.");
}
if !may_start_oauth(&state, &headers, handle).await {
return Redirect::to("/beta/redeem").into_response();
}
start_oauth(&state, handle).await
}
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
}
}
}
async fn start_oauth(state: &AppState, handle: &str) -> Response {
match state.config.repo_backend {
crate::metrics::Backend::Sidecar => {
let url = state.sidecar.login_url(handle, None);
info!(%handle, "redirecting to OAuth sidecar login");
Redirect::to(&url).into_response()
}
crate::metrics::Backend::Rust => {
let Some(runtime) = state.oauth.as_deref() else {
warn!("the rust backend is live but its OAuth runtime is absent");
return login_error(state, "Login is not available right now.");
};
match crate::oauth::login::start(
runtime,
&state.http,
&state.db,
handle,
crate::store::now_unix(),
)
.await
{
Ok(started) => {
info!(%handle, "pushed authorization request; redirecting to the PDS");
let mut resp = Redirect::to(&started.authorize_url).into_response();
set_cookie(
&mut resp,
&cookie::sign_value(
OAUTH_BINDING_COOKIE,
&started.binding_token,
&state.config.cookie_secret,
OAUTH_BINDING_MAX_AGE_SECS,
),
);
resp
}
Err(err) => {
warn!(%err, %handle, "could not start the OAuth login");
login_error(state, "Could not start login for that handle.")
}
}
}
}
}
fn clear_binding_cookie(resp: &mut Response) {
set_cookie(
resp,
&format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
);
}
#[derive(Debug, Deserialize)]
struct LoginForm {
handle: String,
}
#[derive(Debug, Deserialize, Default)]
struct CallbackQuery {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
code: Option<String>,
#[serde(default)]
state: Option<String>,
#[serde(default)]
iss: Option<String>,
#[serde(default)]
response: 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 {
let sidecar_shape =
q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
let sidecar_handoff = sidecar_shape
&& (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
if let Some(err) = q.error.clone() {
let slug = crate::oauth::flow::known_error_slug(&err);
warn!(
error = slug,
desc_len = q.error_description.as_deref().map_or(0, str::len),
"OAuth callback returned an error"
);
if sidecar_handoff || state.oauth.is_none() {
return login_error(&state, &format!("Login failed: {slug}"));
}
}
let session = if sidecar_handoff {
let session_id = q.session_id.clone().unwrap_or_default();
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(&state, "Login session expired — please try again.");
}
Err(err) => {
warn!(%err, "failed to resolve OAuth session via the sidecar");
return login_error(&state, "Login failed talking to the auth service.");
}
}
} else {
let Some(runtime) = state.oauth.as_deref() else {
warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
return login_error(&state, "Login failed: this login could not be completed.");
};
let params = crate::oauth::flow::CallbackParams {
code: q.code.clone(),
state: q.state.clone(),
iss: q.iss.clone(),
error: q.error.clone(),
error_description: q.error_description.clone(),
response: q.response.clone(),
};
let binding =
cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
match crate::oauth::login::complete(
runtime,
&state.http,
&state.db,
¶ms,
binding.as_deref(),
crate::store::now_unix(),
)
.await
{
Ok(done) => crate::atproto::SidecarSession {
did: done.did,
handle: done.handle,
},
Err(err) => {
warn!(%err, "could not complete the OAuth callback");
let mut resp = login_error(&state, "Login failed — please try again.");
clear_binding_cookie(&mut resp);
return resp;
}
}
};
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(&state, &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(&state, "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);
clear_binding_cookie(&mut resp);
if clear_invite {
clear_invite_cookie(&mut resp);
}
resp
}
const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
async fn flush_before_revoke(state: &AppState, did: &str) {
match tokio::time::timeout(
SIGN_OUT_FLUSH_BUDGET,
crate::readstate::flush_did(state, did),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
}
Err(_) => warn!(
%did,
budget = ?SIGN_OUT_FLUSH_BUDGET,
"sign-out: final read-state flush timed out; it will park until next sign-in"
),
}
}
async fn revoke_everywhere(state: &AppState, did: &str) {
let sidecar_started = std::time::Instant::now();
let sidecar_ok = match state.sidecar.revoke_session(did).await {
Ok(res) => {
info!(%did, revoked = res.revoked, "sidecar session revoked");
true
}
Err(err) => {
warn!(%did, %err, "sidecar revoke failed; continuing");
false
}
};
state.metrics.record(
crate::metrics::Backend::Sidecar,
"oauth_revoke",
sidecar_started.elapsed().as_micros() as u64,
sidecar_ok,
);
if let Some(runtime) = state.oauth.as_deref() {
let revoke_started = std::time::Instant::now();
let outcome = crate::oauth::revoke::sign_out_discovering(
runtime,
&state.http,
&state.db,
did,
crate::store::now_unix(),
)
.await;
let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
state.metrics.record(
crate::metrics::Backend::Rust,
"oauth_revoke",
revoke_started.elapsed().as_micros() as u64,
revoke_ok,
);
match outcome {
crate::oauth::revoke::Revocation::Revoked => {
info!(%did, "rust OAuth session revoked at the PDS")
}
crate::oauth::revoke::Revocation::NoSession => {}
crate::oauth::revoke::Revocation::Failed(reason) => {
warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
}
}
}
}
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);
flush_before_revoke(&state, &user.did).await;
revoke_everywhere(&state, &user.did).await;
}
}
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"
);
revoke_everywhere(&state, &did).await;
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_card(config: &Config) -> Card {
Card::public(
config,
"/login",
"Sign in — FeatherReader",
"Sign in to FeatherReader with your atproto handle. You approve access on \
your own server — no signup, no password.",
)
}
fn login_error(state: &AppState, msg: &str) -> Response {
render(&LoginTemplate {
card: login_card(&state.config),
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 {
card: redeem_card(&state.config),
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 {
card: redeem_card(&state.config),
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(&state, &policy)
}
}
}
async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
let count = match store::count_beta_access(&state.db).await {
Ok(n) => n,
Err(err) => {
warn!(%err, "preflight_code: count_beta_access failed; failing closed");
return Err(store::RedeemError::CapacityFull);
}
};
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(state: &AppState, 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 {
card: redeem_card(&state.config),
repo_url: REPO_URL,
error: msg.to_string(),
capacity_full,
})
}
fn redeem_card(config: &Config) -> Card {
Card::public(
config,
"/beta/redeem",
"Redeem an invite — FeatherReader",
"Redeem a closed-beta invite code for this FeatherReader instance, then sign \
in with your atproto handle.",
)
}
#[derive(Debug, Deserialize, Default)]
struct MintQuery {
#[serde(default)]
n: Option<u32>,
}
async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
let Some(runtime) = state.oauth.as_deref() else {
return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
};
axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
}
async fn oauth_jwks(State(state): State<AppState>) -> Response {
let Some(runtime) = state.oauth.as_deref() else {
return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
};
match runtime.client_key.as_ref() {
Some(key) => match key.jwks_document() {
Ok(doc) => axum::Json(doc).into_response(),
Err(err) => {
warn!(%err, "could not render the client JWKS");
(StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
}
},
None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
}
}
const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> 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 metrics denied: not an admin-seed DID");
return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
}
if let Err(err) =
crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
{
warn!(%err, "could not flush repo timings before rendering");
}
let rows = match crate::metrics::persisted_rows(&state.db).await {
Ok(rows) => rows,
Err(err) => {
warn!(%err, "could not read persisted repo timings");
return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
}
};
let parked = match crate::store::parked_readstate_dids(&state.db).await {
Ok(n) => n.to_string(),
Err(err) => {
warn!(%err, "could not count parked read-state DIDs");
"unknown".to_string()
}
};
let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
Ok(f) => f,
Err(err) => {
warn!(%err, "could not list failing feeds");
Vec::new()
}
};
let mut failing_block = String::new();
if !failing.is_empty() {
failing_block.push_str("\nfailing feeds (worst first)\n");
for f in &failing {
failing_block.push_str(&format!(
" {:>4}x {:<8} {}\n {}\n",
f.consecutive_errors,
f.kind.as_deref().unwrap_or("unknown"),
f.url,
f.detail.as_deref().unwrap_or("(no detail recorded)"),
));
}
}
let unpollable = match crate::store::unpollable_feeds(&state.db).await {
Ok(n) => n,
Err(err) => {
warn!(%err, "could not count unpollable feeds");
-1
}
};
let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
let body = format!(
"live backend: {}\nparked read-state DIDs: {}\n\
feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
state.config.repo_backend.as_str(),
parked,
cached,
state.config.max_feeds_global,
unpollable,
crate::metrics::render(&rows),
failing_block,
);
(StatusCode::OK, body).into_response()
}
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()
}
#[derive(Debug, Deserialize)]
struct ClaimQuery {
t: Option<String>,
}
async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
let token = match q.t {
Some(t) if !t.is_empty() => t,
_ => {
warn!("claim link with no token");
return redeem_bounce(&state, &store::RedeemError::NotFound);
}
};
let code = match claim_token_code(&token, &state.config.cookie_secret) {
Some(c) => c,
None => {
warn!("claim token invalid (bad signature / malformed)");
return redeem_bounce(&state, &store::RedeemError::NotFound);
}
};
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!("claim token preflight OK; reserving intent + redirecting to /login");
resp
}
Err(policy) => {
warn!(?policy, "claim token preflight rejected");
redeem_bounce(&state, &policy)
}
}
}
#[derive(Debug, Default, Deserialize)]
struct BotClaimRequest {
#[serde(default)]
did: Option<String>,
#[serde(default)]
#[allow(dead_code)]
handle: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct BotClaimResponse {
status: &'static str,
code: String,
token: String,
url: String,
}
async fn bot_mint_claim(
State(state): State<AppState>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let bot_secret = match state.config.bot_secret.as_deref() {
Some(s) => s,
None => {
warn!(
"POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
);
return (
StatusCode::SERVICE_UNAVAILABLE,
"bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
)
.into_response();
}
};
let presented = headers
.get("x-bot-secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !bot_secret_matches(presented, bot_secret) {
warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
}
let req: BotClaimRequest = if body.is_empty() {
BotClaimRequest::default()
} else {
match serde_json::from_slice(&body) {
Ok(r) => r,
Err(err) => {
warn!(%err, "POST /bot/claims: bad JSON body");
return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
}
}
};
let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
if let Some(did) = follower_did {
match store::has_beta_access(&state.db, did).await {
Ok(true) => {
info!("bot mint: DID already holds beta access; already_seated");
return bot_claim_json(BotClaimResponse {
status: "already_seated",
code: String::new(),
token: String::new(),
url: String::new(),
});
}
Ok(false) => {}
Err(err) => {
warn!(%err, "bot mint: has_beta_access failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
}
}
match store::find_active_code_for_did(&state.db, did).await {
Ok(Some(code)) => {
info!("bot mint: existing outstanding claim for DID; returning same code");
let token = sign_claim_token(&code, &state.config.cookie_secret);
let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
return bot_claim_json(BotClaimResponse {
status: "existing",
code,
token,
url,
});
}
Ok(None) => {}
Err(err) => {
warn!(%err, "bot mint: find_active_code_for_did failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
}
}
}
let granted = match store::count_beta_access(&state.db).await {
Ok(n) => n,
Err(err) => {
warn!(%err, "bot mint: count_beta_access failed; failing closed");
return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
}
};
let outstanding = match store::count_active_codes(&state.db).await {
Ok(n) => n,
Err(err) => {
warn!(%err, "bot mint: count_active_codes failed; failing closed");
return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
}
};
if granted + outstanding >= state.config.beta_cap {
info!(
granted,
outstanding,
cap = state.config.beta_cap,
"bot mint refused: at capacity"
);
return (
StatusCode::CONFLICT,
[(header::CONTENT_TYPE, "application/json")],
"{\"error\":\"full\"}\n",
)
.into_response();
}
let bot_did = state
.config
.admin_seed_dids()
.first()
.cloned()
.unwrap_or_else(|| "did:bot:featherreader".to_string());
let minted = match follower_did {
Some(did) => {
store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
}
None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
};
let code = match minted {
Ok(c) => c,
Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
Ok(Some(code)) => {
info!("bot mint: lost the mint race; returning the concurrently-minted code");
let token = sign_claim_token(&code, &state.config.cookie_secret);
let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
return bot_claim_json(BotClaimResponse {
status: "existing",
code,
token,
url,
});
}
Ok(None) => {
warn!("bot mint: conflict but no active code found on recovery");
return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
}
Err(err) => {
warn!(%err, "bot mint: recovery lookup after conflict failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
}
}
}
Err(err) => {
warn!(%err, "bot mint_code failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
}
};
let token = sign_claim_token(&code, &state.config.cookie_secret);
let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
info!("bot minted a claim code + token");
bot_claim_json(BotClaimResponse {
status: "minted",
code,
token,
url,
})
}
fn bot_claim_json(resp: BotClaimResponse) -> Response {
match serde_json::to_string(&resp) {
Ok(body) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
body,
)
.into_response(),
Err(err) => {
warn!(%err, "serializing bot claim response failed");
(StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
}
}
}
fn bot_secret_matches(presented: &str, expected: &str) -> bool {
cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
}
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)
}
const CLAIM_TOKEN_LABEL: &str = "claim-token";
fn sign_claim_token(code: &str, secret: &str) -> String {
cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
}
fn claim_token_code(token: &str, secret: &str) -> Option<String> {
cookie::verify_token(CLAIM_TOKEN_LABEL, token, 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 =
match opml::parse_opml(&opml_text) {
Ok(feeds) => feeds,
Err(err) => {
warn!(%err, %did, "OPML import could not parse the uploaded file");
return Ok(Redirect::to(&format!(
"/?flash={}",
qenc("That file could not be read as OPML. Export it again from your other reader?")
))
.into_response());
}
};
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.repo().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.repo().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();
let mut uncached: usize = 0;
let mut skipped_unsupported: usize = 0;
for f in &feeds {
if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
info!(
%did,
"skipped an OPML entry whose xmlUrl is not a storable feed URL"
);
skipped_unsupported += 1;
continue;
}
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);
if let Err(err) = store::upsert_feed(
pool,
&store::NewFeed {
url: f.feed_url.clone(),
title: f.title.clone(),
site_url: f.site_url.clone(),
..Default::default()
},
)
.await
{
warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
it will not be polled");
uncached += 1;
}
}
let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
Ok(rkeys) => {
info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
rkeys.len()
}
Err(err) => {
let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
landed
}
};
if landed == 0 && !subs.is_empty() {
return Ok(Redirect::to(&format!(
"/?flash={}",
qenc(
"Could not save those subscriptions to your PDS, so nothing was imported. \
Try again in a moment."
)
))
.into_response());
}
let mut flash = if landed < subs.len() {
format!(
"Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
other {} may not have been saved. Importing the same file again would add the first \
{landed} a second time",
subs.len(),
subs.len() - landed
)
} else {
format!("Imported {} feeds", subs.len())
};
if uncached > 0 {
flash.push_str(&format!(
". {uncached} of them could not be cached locally and may not update until the next import."
));
}
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(", ")
));
}
if skipped_unsupported > 0 {
flash.push_str(&format!(
". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
));
}
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 = match state.repo().list_subscriptions_sorted(&did).await {
Ok(subs) => subs,
Err(err) => {
tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
return Ok(Redirect::to(&format!(
"/manage?flash={}",
qenc(EXPORT_INCOMPLETE_REFUSAL)
))
.into_response());
}
};
let folders = match state.repo().list_folders_sorted(&did).await {
Ok(folders) => folders,
Err(err) => {
tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
return Ok(Redirect::to(&format!(
"/manage?flash={}",
qenc(EXPORT_INCOMPLETE_REFUSAL)
))
.into_response());
}
};
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
}
}
pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
let b64 = b64url_encode(value.as_bytes());
format!("{b64}.{sig}")
}
pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
let (b64, sig) = token.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(label, &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
}
pub 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: SafeLink::entry(id, ""),
cached: true,
rkey: String::new(),
}))
}
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 the_rate_limit_map_is_bounded() {
let rl = RateLimiter::shared();
let now = Instant::now();
for i in 0..(MAX_RATE_BUCKETS + 2_000) {
let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
rl.check_at(ip, now + Duration::from_millis(i as u64));
}
let len = rl.inner.lock().unwrap().buckets.len();
assert!(
len <= MAX_RATE_BUCKETS,
"the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
);
}
#[test]
fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
let rl = RateLimiter::shared();
let base = Instant::now();
let attacker: IpAddr = "203.0.113.7".parse().unwrap();
let at = |n: u64| base + Duration::from_nanos(n);
for i in 0..(RATE_BURST as u64) {
assert!(rl.check_at(attacker, at(i)));
}
assert!(
!rl.check_at(attacker, at(RATE_BURST as u64)),
"burst was not exhausted; the rest of this test proves nothing"
);
for i in 0..(MAX_RATE_BUCKETS + 2_000) {
let t = at(100 + i as u64 * 2);
let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
rl.check_at(ip, t);
assert!(
!rl.check_at(attacker, t),
"the attacker got a token back after evictions at i={i}"
);
}
}
#[test]
fn the_idle_sweep_does_not_run_on_every_request() {
let rl = RateLimiter::shared();
let start = Instant::now();
let a: IpAddr = "198.51.100.1".parse().unwrap();
let b: IpAddr = "198.51.100.2".parse().unwrap();
rl.check_at(a, start);
rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
assert!(
!rl.inner.lock().unwrap().buckets.contains_key(&a),
"an idle bucket survived a sweep that was due"
);
let before = rl.inner.lock().unwrap().last_sweep;
rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
assert_eq!(
rl.inner.lock().unwrap().last_sweep,
before,
"the sweep ran again within the interval"
);
}
#[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)
);
}
async fn bot_state(bot_secret: &str) -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
store::ensure_seed(&db, &["did:plc:admin".to_string()])
.await
.unwrap();
let config = Config {
allowed_dids: vec!["did:plc:admin".to_string()],
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 3,
bot_secret: Some(bot_secret.to_string()),
public_url: "https://feather-reader.com".to_string(),
..Config::default()
};
AppState::new(config, db).unwrap()
}
#[test]
fn claim_token_round_trips_and_rejects_tamper() {
let secret = "test-cookie-secret-000";
let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
assert!(!token.contains(';'));
assert_eq!(
claim_token_code(&token, secret).as_deref(),
Some("FEATHER-ABCDWXYZ")
);
assert!(claim_token_code(&token, "other").is_none());
let mut bad = token.clone();
bad.push('x');
assert!(claim_token_code(&bad, secret).is_none());
let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
assert_eq!(
test_b64url_decode(b64).as_deref(),
Some("FEATHER-ABCDWXYZ".as_bytes()),
"the code half of the token is plain base64url, decodable by anyone"
);
}
fn test_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 mut out = Vec::with_capacity(input.len() / 4 * 3);
for chunk in input.as_bytes().chunks(4) {
let mut n = 0u32;
let mut bits = 0;
for &c in chunk {
n = (n << 6) | val(c)?;
bits += 6;
}
let bytes = bits / 8;
n <<= 24 - bits;
for i in 0..bytes {
out.push((n >> (16 - i * 8)) as u8);
}
}
Some(out)
}
#[tokio::test]
async fn bot_mint_then_claim_grants_a_seat() {
let state = bot_state("bot-secret-abcdef").await;
let app = router(state.clone());
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.header("x-bot-secret", "bot-secret-abcdef")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let token = json["token"].as_str().unwrap().to_string();
let url = json["url"].as_str().unwrap();
assert!(url.starts_with("https://feather-reader.com/claim?t="));
assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
assert!(!url.contains("FEATHER-"));
let resp = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/claim?t={}", qenc(&token)))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
let set_cookie = resp
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
let out = store::redeem_code(
&state.db,
&code,
"did:plc:follower",
None,
state.config.beta_cap,
)
.await
.unwrap();
assert_eq!(out, Ok(()));
assert!(store::has_beta_access(&state.db, "did:plc:follower")
.await
.unwrap());
}
#[tokio::test]
async fn claim_with_invalid_token_bounces() {
let state = bot_state("bot-secret-abcdef").await;
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/claim?t=not-a-real-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn claim_with_used_token_is_refused() {
let state = bot_state("bot-secret-abcdef").await;
let code = store::mint_code(&state.db, "did:plc:admin", 3600)
.await
.unwrap();
let token = sign_claim_token(&code, &state.config.cookie_secret);
store::redeem_code(
&state.db,
&code,
"did:plc:someone",
None,
state.config.beta_cap,
)
.await
.unwrap()
.unwrap();
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/claim?t={}", qenc(&token)))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::SET_COOKIE).is_none());
}
#[tokio::test]
async fn bot_claims_rejects_bad_and_missing_secret() {
let state = bot_state("bot-secret-abcdef").await;
let app = router(state);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.header("x-bot-secret", "wrong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn bot_claims_disabled_when_secret_unset() {
let state = test_state(&["did:plc:admin"]).await;
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.header("x-bot-secret", "anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn bot_claims_refuses_at_capacity() {
let state = bot_state("bot-secret-abcdef").await;
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);
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.header("x-bot-secret", "bot-secret-abcdef")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("full"));
}
#[tokio::test]
async fn bot_claims_counts_outstanding_codes_against_cap() {
let state = bot_state("bot-secret-abcdef").await;
store::mint_code(&state.db, "did:plc:admin", 3600)
.await
.unwrap();
store::mint_code(&state.db, "did:plc:admin", 3600)
.await
.unwrap();
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.header("x-bot-secret", "bot-secret-abcdef")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
async fn post_bot_claim_for(
app: &axum::Router,
secret: &str,
did: &str,
) -> (StatusCode, serde_json::Value) {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.header("x-bot-secret", secret)
.header("content-type", "application/json")
.body(Body::from(format!(
"{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
)))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json = if bytes.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
};
(status, json)
}
#[tokio::test]
async fn bot_claims_returns_already_seated_for_a_member() {
let state = bot_state("bot-secret-abcdef").await;
store::grant_access(&state.db, "did:plc:member", None, "admin", None)
.await
.unwrap();
let app = router(state.clone());
let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["status"], "already_seated");
assert_eq!(json["code"], "");
assert_eq!(json["url"], "");
assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn bot_claims_is_idempotent_per_did_returns_same_code() {
let state = bot_state("bot-secret-abcdef").await;
let app = router(state.clone());
let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
assert_eq!(s1, StatusCode::OK);
assert_eq!(j1["status"], "minted");
let code1 = j1["code"].as_str().unwrap().to_string();
let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
assert_eq!(s2, StatusCode::OK);
assert_eq!(j2["status"], "existing");
assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
assert_eq!(j2["url"], j1["url"], "same url returned");
assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
}
#[tokio::test]
async fn bot_claims_records_intended_did_at_mint() {
let state = bot_state("bot-secret-abcdef").await;
let app = router(state.clone());
let (status, json) =
post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
assert_eq!(status, StatusCode::OK);
let code = json["code"].as_str().unwrap();
assert_eq!(
store::find_active_code_for_did(&state.db, "did:plc:follower2")
.await
.unwrap()
.as_deref(),
Some(code)
);
}
#[tokio::test]
async fn bot_claims_concurrent_same_did_never_double_mints() {
let state = bot_state("bot-secret-abcdef").await;
let app = router(state.clone());
let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
assert_eq!(
store::count_active_codes(&state.db).await.unwrap(),
1,
"concurrent mints must not create two active codes"
);
let ca = ja["code"].as_str().unwrap_or("");
let cb = jb["code"].as_str().unwrap_or("");
assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
assert_eq!(ca, cb, "both callers must get the one minted code");
for st in [&ja["status"], &jb["status"]] {
let s = st.as_str().unwrap_or("");
assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
}
}
#[tokio::test]
async fn bot_claims_rejects_malformed_json_body() {
let state = bot_state("bot-secret-abcdef").await;
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/bot/claims")
.header("x-bot-secret", "bot-secret-abcdef")
.header("content-type", "application/json")
.body(Body::from("{not json"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn favicon_ico_served_at_root() {
let state = test_state(&[]).await;
let app = router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/favicon.ico")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("icon") || ct.starts_with("image/"),
"content-type = {ct}"
);
}
#[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);
}
async fn adoption_state(repos: i64, truncated: bool) -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
store::record_network_stat(
&db,
&store::NetworkStat {
key: store::ADOPTION_STAT_KEY.to_string(),
source: "https://relay1.us-west.bsky.network".to_string(),
value: repos,
truncated,
observed_at: "2026-08-13T04:05:06Z".to_string(),
},
)
.await
.unwrap();
let config = Config {
cookie_secret: "test-cookie-secret-000".to_string(),
show_adoption: true,
..Config::default()
};
AppState::new(config, db).unwrap()
}
async fn about_body(state: AppState) -> String {
let resp = router(state)
.oneshot(
Request::builder()
.uri("/about")
.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();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn about_omits_adoption_line_by_default() {
let state = test_state(&[]).await;
assert!(!state.config.show_adoption);
let body = about_body(state).await;
assert!(
!body.contains("atproto network"),
"the adoption line must not render by default"
);
}
#[tokio::test]
async fn about_renders_adoption_line_when_enabled() {
let body = about_body(adoption_state(7_318, false).await).await;
let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
flat.contains("7318 accounts on the atproto network hold"),
"the count did not render in its own sentence: {flat}",
);
assert!(
body.contains("accounts on the atproto network hold"),
"{body}"
);
assert!(
body.contains("2026-08-13"),
"the observation date must render"
);
assert!(
body.contains("lower bound"),
"the non-archival caveat must ride along with the number"
);
assert!(
!body.contains("At least"),
"an untruncated count is exact-ish"
);
}
#[tokio::test]
async fn about_adoption_line_is_singular_at_one() {
let body = about_body(adoption_state(1, false).await).await;
assert!(
body.contains("account on the atproto network holds"),
"{body}"
);
}
#[tokio::test]
async fn about_adoption_line_says_at_least_when_truncated() {
let body = about_body(adoption_state(25_000, true).await).await;
assert!(body.contains("At least"), "{body}");
}
#[tokio::test]
async fn about_omits_line_when_enabled_with_no_observation() {
let db = store::init_url("sqlite::memory:").await.unwrap();
let config = Config {
cookie_secret: "test-cookie-secret-000".to_string(),
show_adoption: true,
..Config::default()
};
let body = about_body(AppState::new(config, db).unwrap()).await;
assert!(!body.contains("atproto network"));
}
async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
let config = Config {
allowed_dids: vec![did.to_string()],
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 3,
standard_site,
..Config::default()
};
AppState::new(config, db).unwrap()
}
async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
let cookie = session_cookie(&state, did, Some("reader.example"));
let resp = router(state)
.oneshot(
Request::builder()
.uri(path)
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "{path}");
let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn public_body(state: AppState, path: &str) -> String {
let resp = router(state)
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "{path}");
let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn feed_url_input(body: &str) -> &str {
let start = body
.find("id=\"feed-url\"")
.and_then(|i| body[..i].rfind("<input"))
.expect("the subscribe form's URL input renders");
let end = body[start..].find('>').expect("the input tag closes") + start + 1;
&body[start..end]
}
#[tokio::test]
async fn manage_hints_at_publications_when_the_flag_is_on() {
let did = "did:plc:reader";
let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
assert!(
body.contains("at://did:plc:…/site.standard.publication/…"),
"the DID form must be shown: {body}"
);
assert!(
body.contains("at://alice.example.com/site.standard.publication/…"),
"the handle form must be shown: {body}"
);
}
#[tokio::test]
async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
let did = "did:plc:reader";
let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
let input = feed_url_input(&body);
assert!(
input.contains("type=\"text\""),
"the input must be type=text so a DID-form at:// URI can be submitted: {input}"
);
assert!(
input.contains("inputmode=\"url\""),
"the URL keyboard is still wanted: {input}"
);
}
#[tokio::test]
async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
let did = "did:plc:reader";
let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
let input = feed_url_input(&body);
assert!(
input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
"the text input must keep a scheme check: {input}"
);
}
#[tokio::test]
async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
let did = "did:plc:reader";
let state = standard_site_state(false, did).await;
assert!(!state.config.standard_site);
let page = signed_in_body(state, "/manage", did).await;
let body = &page[page.find("</head>").expect("a <head>")..];
assert!(
!body.contains("site.standard.publication"),
"a refused form must not be advertised: {body}"
);
let above_footer = body
.split("<footer")
.next()
.expect("split yields at least one piece");
assert!(
above_footer.contains("id=\"feed-url\""),
"the form must be above the footer: {body}"
);
assert!(
!above_footer.contains("standard.site"),
"a refused form must not be advertised: {body}"
);
assert!(
feed_url_input(body).contains("type=\"url\""),
"with the flag off the input is unchanged"
);
}
#[tokio::test]
async fn landing_describes_publications_and_how_to_subscribe_when_on() {
let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
assert!(body.contains("standard.site"), "{body}");
assert!(
body.contains("at://did:plc:…/site.standard.publication/…"),
"the landing page must show the DID form: {body}"
);
assert!(
body.contains("at://alice.example.com/site.standard.publication/…"),
"the landing page must show the handle form: {body}"
);
}
#[tokio::test]
async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
assert!(body.contains("standard.site"), "{body}");
assert!(
!body.contains("at://did:plc:…/site.standard.publication/…"),
"no paste instructions with the flag off: {body}"
);
assert!(
!body.contains("at://alice.example.com/site.standard.publication/…"),
"no paste instructions with the flag off: {body}"
);
assert!(
body.contains("isn't accepting new publication subscriptions"),
"the page must say the form is closed here: {body}"
);
}
#[tokio::test]
async fn about_describes_publications_and_how_to_subscribe_when_on() {
let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
assert!(body.contains("site.standard.publication"), "{body}");
assert!(body.contains("site.standard.document"), "{body}");
assert!(
body.contains("at://did:plc:…/site.standard.publication/…"),
"{body}"
);
assert!(
body.contains("at://alice.example.com/site.standard.publication/…"),
"{body}"
);
}
#[tokio::test]
async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
assert!(body.contains("site.standard.publication"), "{body}");
assert!(
!body.contains("at://did:plc:…/site.standard.publication/…"),
"no paste instructions with the flag off: {body}"
);
assert!(
!body.contains("at://alice.example.com/site.standard.publication/…"),
"no paste instructions with the flag off: {body}"
);
assert!(
body.contains("isn't accepting new publication subscriptions"),
"{body}"
);
}
#[tokio::test]
async fn standard_site_page_renders_signed_out() {
let body = public_body(test_state(&[]).await, "/standard-site").await;
assert!(body.contains("site.standard.publication"), "{body}");
assert!(body.contains("site.standard.document"), "{body}");
assert!(
body.contains("<title>standard.site — FeatherReader</title>"),
"{body}"
);
}
#[tokio::test]
async fn standard_site_page_tells_how_to_subscribe_when_on() {
let body = public_body(
standard_site_state(true, "did:plc:x").await,
"/standard-site",
)
.await;
assert!(
body.contains("at://did:plc:…/site.standard.publication/…"),
"the DID form must be shown: {body}"
);
assert!(
body.contains("at://alice.example.com/site.standard.publication/…"),
"the handle form must be shown: {body}"
);
assert!(
body.contains("resolved to its DID"),
"the handle resolution must be stated: {body}"
);
assert!(
!body.contains("isn't accepting new publication subscriptions"),
"{body}"
);
}
#[tokio::test]
async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
let state = standard_site_state(false, "did:plc:x").await;
assert!(!state.config.standard_site);
let body = public_body(state, "/standard-site").await;
assert!(body.contains("site.standard.publication"), "{body}");
assert!(
!body.contains("at://did:plc:…/site.standard.publication/…"),
"no paste instructions with the flag off: {body}"
);
assert!(
!body.contains("at://alice.example.com/site.standard.publication/…"),
"no paste instructions with the flag off: {body}"
);
assert!(
body.contains("isn't accepting new publication subscriptions"),
"the page must say the form is closed here: {body}"
);
assert!(
body.contains("already follows are still read"),
"stored publications are polled whatever the flag says: {body}"
);
}
#[tokio::test]
async fn releases_callout_links_the_release_pages() {
for path in ["/standard-site", "/"] {
let body = public_body(test_state(&[]).await, path).await;
for tag in ["v0.4.1", "v0.4.0"] {
let href = format!(
"href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
);
assert!(body.contains(&href), "{path} must link {tag}: {body}");
}
assert!(
body.contains(
"https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
),
"{path} must link the changelog: {body}"
);
}
}
#[tokio::test]
async fn landing_about_and_footer_link_the_standard_site_page() {
for path in ["/", "/about", "/privacy"] {
let body = public_body(test_state(&[]).await, path).await;
assert!(
body.contains("href=\"/standard-site\""),
"{path} must link the feature page: {body}"
);
}
}
#[test]
fn releases_are_newest_first_and_link_the_tag_and_changelog() {
assert!(!RELEASES.is_empty());
let parse = |v: &str| -> Vec<u32> {
v.split('.')
.map(|p| p.parse::<u32>().expect("a numeric version part"))
.collect()
};
for pair in RELEASES.windows(2) {
assert!(
parse(pair[0].version) > parse(pair[1].version),
"{} must come before {}",
pair[0].version,
pair[1].version
);
}
for r in RELEASES {
assert_eq!(parse(r.version).len(), 3, "{}", r.version);
assert!(
chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
"{} is not YYYY-MM-DD",
r.date
);
assert!(!r.summary.trim().is_empty());
assert!(!r.summary.contains('<'), "the summary is plain text");
assert_eq!(
r.url(),
format!(
"https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
r.version
)
);
}
let latest = &RELEASES[0];
assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
assert_eq!(
latest.changelog_url(),
"https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#047--2026-10-07"
);
}
#[tokio::test]
async fn standard_site_page_is_publicly_cacheable() {
let resp = router(test_state(&[]).await)
.oneshot(
Request::builder()
.uri("/standard-site")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
}
#[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_eq!(
about.headers()["content-security-policy"],
EXPECTED_CSP,
"the CSP is not the policy the router promises"
);
assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
for path in ["/privacy", "/terms"] {
let resp = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300",
"{path} should be publicly cacheable"
);
assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
assert_eq!(resp.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"
);
}
fn head(body: &str) -> &str {
let end = body.find("</head>").expect("a <head>");
&body[..end]
}
fn meta(head: &str, attr: &str) -> Option<String> {
let tag_start = head.find(attr)?;
let rest = &head[tag_start..];
let tag_end = rest.find('>')?;
let tag = &rest[..tag_end];
let content = tag.find("content=\"")? + "content=\"".len();
let close = tag[content..].find('"')?;
Some(tag[content..content + close].to_string())
}
async fn production_origin_state() -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
store::ensure_seed(&db, &["did:plc:admin".to_string()])
.await
.unwrap();
let config = Config {
allowed_dids: vec!["did:plc:admin".to_string()],
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 3,
public_url: "https://feather-reader.com".to_string(),
..Config::default()
};
AppState::new(config, db).unwrap()
}
#[tokio::test]
async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
let landing = public_body(production_origin_state().await, "/").await;
let about = public_body(production_origin_state().await, "/about").await;
let (lh, ah) = (head(&landing), head(&about));
assert_eq!(
meta(lh, "property=\"og:title\"").as_deref(),
Some("FeatherReader — read, quietly"),
"{lh}"
);
assert_eq!(
meta(ah, "property=\"og:title\"").as_deref(),
Some("About — FeatherReader"),
"{ah}"
);
for (h, path) in [(lh, "/"), (ah, "/about")] {
let url = format!("https://feather-reader.com{path}");
assert_eq!(
meta(h, "property=\"og:url\"").as_deref(),
Some(url.as_str())
);
assert!(
h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
"{path} must carry a canonical link: {h}"
);
let image = meta(h, "property=\"og:image\"").unwrap_or_default();
assert!(
image.starts_with("https://feather-reader.com/static/"),
"{path}: og:image must be absolute on the public origin, got {image:?}"
);
assert_eq!(
meta(h, "name=\"twitter:card\"").as_deref(),
Some("summary_large_image")
);
assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
assert_eq!(
meta(h, "property=\"og:site_name\"").as_deref(),
Some("FeatherReader")
);
let description = meta(h, "property=\"og:description\"").unwrap_or_default();
assert!(!description.is_empty(), "{path}: og:description is empty");
assert_eq!(
meta(h, "name=\"description\"").as_deref(),
Some(description.as_str()),
"{path}: the meta description and og:description must agree"
);
}
assert_ne!(
meta(lh, "property=\"og:description\""),
meta(ah, "property=\"og:description\""),
"the landing page and /about must not share a description"
);
}
#[tokio::test]
async fn card_urls_follow_the_configured_public_url() {
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(),
public_url: "https://reader.example.org".to_string(),
..Config::default()
};
let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
let h = head(&body);
assert_eq!(
meta(h, "property=\"og:url\"").as_deref(),
Some("https://reader.example.org/privacy")
);
assert_eq!(
meta(h, "property=\"og:image\"").as_deref(),
Some("https://reader.example.org/static/social-card.png")
);
}
#[tokio::test]
async fn public_pages_each_carry_their_own_description() {
let paths = [
"/",
"/about",
"/privacy",
"/terms",
"/stats",
"/standard-site",
"/login",
"/beta/redeem",
];
let mut seen = std::collections::HashSet::new();
for path in paths {
let body = public_body(production_origin_state().await, path).await;
let h = head(&body);
let description = meta(h, "name=\"description\"").unwrap_or_default();
assert!(!description.is_empty(), "{path} has no description: {h}");
assert!(
seen.insert(description.clone()),
"{path} repeats another page's description: {description:?}"
);
assert_eq!(
meta(h, "property=\"og:url\"").as_deref(),
Some(format!("https://feather-reader.com{path}").as_str()),
"{path}"
);
assert!(
!h.contains("name=\"robots\""),
"{path} is public and must not be noindex: {h}"
);
}
}
#[tokio::test]
async fn share_image_is_served_as_a_png_of_the_advertised_size() {
let landing = public_body(production_origin_state().await, "/").await;
let h = head(&landing);
let image = meta(h, "property=\"og:image\"").unwrap();
let path = image.strip_prefix("https://feather-reader.com").unwrap();
let width: u32 = meta(h, "property=\"og:image:width\"")
.unwrap()
.parse()
.unwrap();
let height: u32 = meta(h, "property=\"og:image:height\"")
.unwrap()
.parse()
.unwrap();
assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
assert_eq!(
meta(h, "property=\"og:image:type\"").as_deref(),
Some("image/png")
);
assert!(
!meta(h, "property=\"og:image:alt\"")
.unwrap_or_default()
.is_empty(),
"the image needs alt text"
);
let resp = router(production_origin_state().await)
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "{path}");
assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
.await
.expect("the image is under 1 MB");
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
assert_eq!(
(be(16), be(20)),
(width, height),
"the PNG's own dimensions must match the tags"
);
}
#[tokio::test]
async fn private_pages_keep_user_data_out_of_the_card() {
for path in ["/", "/manage"] {
let state = production_origin_state().await;
let body = signed_in_body(state, path, "did:plc:admin").await;
let h = head(&body);
assert!(
h.contains("<meta name=\"robots\" content=\"noindex\""),
"{path}: a private view must be noindex: {h}"
);
assert_eq!(
meta(h, "property=\"og:title\"").as_deref(),
Some("FeatherReader — read, quietly"),
"{path}: the card of a private view is the site's generic one"
);
assert_eq!(
meta(h, "property=\"og:url\"").as_deref(),
Some("https://feather-reader.com/"),
"{path}: og:url of a private view is the front door, not the private path"
);
for private in ["reader.example", "did:plc:admin"] {
assert!(
!h.contains(private),
"{path}: {private:?} must not reach <head>: {h}"
);
}
}
}
#[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 a_forged_forwarded_for_header_does_not_key_the_limiter() {
let state = test_state(&[]).await;
assert!(
state.config.trusted_ip_header.is_none(),
"no proxy header is trusted here"
);
let app = router(state);
let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
let mut saw_429 = false;
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)
.extension(axum::extract::ConnectInfo(peer))
.body(Body::from("code=FEATHER-NOPENOPE"))
.unwrap(),
)
.await
.unwrap();
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
saw_429 = true;
break;
}
}
assert!(
saw_429,
"rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
);
}
#[tokio::test]
async fn subscribing_to_a_private_feed_never_reaches_the_network() {
let did = "did:plc:privateadder";
let state = test_state_with_caps(did, 0, 0).await;
let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"private-add.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!(
"url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(loc.contains("Private"), "not refused as private: {loc}");
assert_eq!(
hits.load(std::sync::atomic::Ordering::SeqCst),
0,
"the private feed was FETCHED before being refused"
);
assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
}
#[tokio::test]
async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
let did = "did:plc:renamer4";
let (sidecar, bodies) = spawn_logging_sidecar().await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
let opml = format!(
"<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
<outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
<outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
</body></opml>"
);
let (ct, body) = opml_multipart(opml.as_bytes());
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.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 loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("skipped%20as%20private"),
"not reported as skipped: {loc}"
);
assert!(store::get_feed_by_url(&state.db, tokened)
.await
.unwrap()
.is_none());
let sent = bodies.lock().unwrap().join("\n");
assert!(
sent.contains("public.example"),
"the public feed was not written: {sent}"
);
assert!(
!sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
"the secret was PUBLISHED to the PDS: {sent}"
);
}
#[tokio::test]
async fn get_login_without_a_seat_is_refused() {
let state = test_state(&[]).await;
let resp = router(state)
.oneshot(
Request::builder()
.method("GET")
.uri("/login?handle=alice.bsky.social")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"/beta/redeem"
);
}
async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = log.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut raw: Vec<u8> = Vec::new();
let mut chunk = [0u8; 4096];
let text = loop {
let Ok(n) = sock.read(&mut chunk).await else {
break String::new();
};
if n == 0 {
break String::from_utf8_lossy(&raw).to_string();
}
raw.extend_from_slice(&chunk[..n]);
let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
continue;
};
let (head, body) = raw.split_at(split + 4);
let want = String::from_utf8_lossy(head).lines().find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse::<usize>().ok())?
});
if want.is_none_or(|w| body.len() >= w) {
break String::from_utf8_lossy(&raw).to_string();
}
};
let path = text
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("")
.to_string();
let body_text = text
.split_once("\r\n\r\n")
.map(|(_, b)| b)
.unwrap_or("")
.to_string();
sink.lock().unwrap().push(format!("{path} {body_text}"));
let body = serde_json::json!({ "ok": true, "did": "did:plc:x", "revoked": true, "hadSession": true, "data": {"uri": "at://did:plc:x/c/r", "cid": "bafy"} }).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
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
}
});
(format!("http://{addr}"), log)
}
#[tokio::test]
async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
use crate::readstate::tests as rs;
for backend in [
crate::metrics::Backend::Sidecar,
crate::metrics::Backend::Rust,
] {
let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
let state = rs::state_on(backend, &fake).await;
for i in 0..250 {
rs::mark_read(&state, i, "1").await;
}
fake.lock().unwrap().drop_call = Some(2);
flush_before_revoke(&state, rs::DID).await;
let order = rs::send_order(250);
let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
for &i in landed {
let c = rs::cursor(&state, i).await;
assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
}
for &i in rest {
let c = rs::cursor(&state, i).await;
assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
}
assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
}
}
#[tokio::test]
async fn signing_out_flushes_before_it_revokes_through_the_route() {
let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
let (sidecar, log) = spawn_logging_sidecar().await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
crate::store::upsert_cursor(
&state.db,
&crate::store::ReadCursor {
did: did.to_string(),
feed_url: "https://example.com/feed.xml".into(),
read_through: None,
read_ids: "[\"1\"]".into(),
unread_ids: "[]".into(),
dirty: true,
pds_created: false,
updated_at: "2026-09-13T21:22:40Z".into(),
},
)
.await
.unwrap();
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/logout")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let entries = log.lock().unwrap().clone();
let flush = entries
.iter()
.position(|e| e.starts_with("/internal/repo "));
let revoke = entries
.iter()
.position(|e| e.starts_with("/internal/revoke "));
assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
assert!(
flush.is_some(),
"sign-out did not attempt a flush before revoking: {entries:?}"
);
assert!(
flush < revoke,
"the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
);
}
const EXPECTED_CSP: &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'";
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_over_the_route_cap_is_refused_below_the_framework_default() {
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 + AXUM_DEFAULT_BODY_LIMIT) / 2];
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,
"a payload over the route's cap but under the framework's was accepted — \
the route's own DefaultBodyLimit layer is not doing anything"
);
}
#[tokio::test]
async fn opml_import_under_limit_upload_is_accepted() {
let state = test_state(&["did:plc:admin"]).await;
let cookie = session_cookie(&state, "did:plc:admin", None);
let db = state.db.clone();
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_eq!(
resp.status(),
StatusCode::SEE_OTHER,
"an under-cap OPML upload was not accepted (status {})",
resp.status(),
);
let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
.bind("https://example.com/feed.xml")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(stored, 1, "the upload was redirected but imported nothing");
let location = resp
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
!location.starts_with("/login"),
"the import bounced to login instead of being accepted: {location}",
);
}
#[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 defaults = Config::default();
test_state_with_sidecar_and(
allowed,
sidecar_url,
defaults.standard_site,
defaults.max_feeds_global,
)
.await
}
async fn test_state_with_sidecar_and(
allowed: &[&str],
sidecar_url: &str,
standard_site: bool,
max_feeds_global: i64,
) -> 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,
standard_site,
max_feeds_global,
..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 = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
.await
.expect("the sidecar revoke never fired; revoke_everywhere did not call it")
.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");
}
#[tokio::test]
async fn a_logout_with_no_session_counts_as_success() {
let did = "did:plc:aaaa";
let state = test_state(&[]).await;
assert!(
state.oauth.is_some(),
"meaningless without an oauth runtime; the revoke arm would be skipped",
);
revoke_everywhere(&state, did).await;
let rows = state.metrics.snapshot();
let find = |b: crate::metrics::Backend| {
rows.iter()
.find(|r| r.op == "oauth_revoke" && r.backend == b)
.unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
};
let rust = find(crate::metrics::Backend::Rust);
assert_eq!(
rust.stats.err_count, 0,
"NoSession was counted as a failure; logout is idempotent",
);
assert_eq!(rust.stats.ok_count, 1);
let sidecar = find(crate::metrics::Backend::Sidecar);
assert_eq!(
sidecar.stats.err_count, 1,
"a failed sidecar revoke was not counted",
);
}
#[tokio::test]
async fn a_failed_rust_revoke_counts_as_an_error() {
let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
let state = test_state(&[]).await;
let runtime = state.oauth.as_deref().expect("oauth runtime");
crate::oauth::store::put_session(
&state.db,
&runtime.codec,
&crate::oauth::store::OAuthSession {
sub: did.into(),
issuer: "https://auth.invalid".into(),
aud: "https://pds.invalid".into(),
dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
.to_jwk_json()
.unwrap(),
access_token: "at".into(),
refresh_token: "rt".into(),
token_type: "DPoP".into(),
granted_scope: "atproto".into(),
expires_at: Some(crate::store::now_unix() + 3600),
},
)
.await
.unwrap();
revoke_everywhere(&state, did).await;
let rows = state.metrics.snapshot();
let rust = rows
.iter()
.find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
.expect("no rust oauth_revoke row");
assert_eq!(
rust.stats.err_count, 1,
"an unreachable PDS must count as a revocation failure",
);
assert_eq!(rust.stats.ok_count, 0);
}
#[test]
fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
for hostile in [
"javascript:alert(1)",
"JavaScript:alert(1)",
" javascript:alert(1)",
"data:text/html;base64,PHNjcmlwdD4=",
"vbscript:msgbox(1)",
"file:///etc/passwd",
"//evil.example/path",
] {
let link = SafeLink::external(hostile);
assert!(
link.is_empty(),
"{hostile:?} produced a non-empty href: {link}",
);
assert!(
!link.to_string().to_ascii_lowercase().contains("script"),
"{hostile:?} leaked into the rendered link",
);
}
for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
let link = SafeLink::external(good);
assert!(!link.is_empty(), "{good:?} was wrongly rejected");
assert_eq!(link.to_string(), good);
}
}
#[tokio::test]
async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
let mut state = test_state_with_sidecar(&[did], &sidecar).await;
std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
let resp = router(state)
.oneshot(
Request::builder()
.uri("/?view=starred")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
!body.to_ascii_lowercase().contains("javascript:"),
"the hostile scheme reached the rendered page",
);
assert!(
body.contains("unusable link"),
"the row was dropped instead of rendering without an anchor",
);
}
#[tokio::test]
async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
let did = "did:plc:readerhref";
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://href.example/feed.xml".to_string(),
title: Some("Href".to_string()),
..Default::default()
},
)
.await
.unwrap();
store::insert_entries(
&state.db,
feed,
&[
store::NewEntry {
guid: "hostile-1".to_string(),
url: Some("javascript:alert(1)".to_string()),
title: Some("Hostile entry".to_string()),
published: Some("2026-07-11T00:00:00Z".to_string()),
..Default::default()
},
store::NewEntry {
guid: "benign-1".to_string(),
url: Some("https://href.example/post".to_string()),
title: Some("Benign entry".to_string()),
published: Some("2026-07-10T00:00:00Z".to_string()),
..Default::default()
},
],
0,
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed])
.await
.unwrap();
let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
let id_of = |guid: &str| {
rows.iter()
.find(|r| r.guid == guid)
.unwrap_or_else(|| panic!("{guid} was not inserted"))
.id
};
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let render = |id: i64| {
let app = app.clone();
let cookie = cookie.clone();
async move {
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/entries/{id}"))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap()
}
};
let hostile = render(id_of("hostile-1")).await;
assert!(
hostile.contains("Hostile entry"),
"the reader did not render the entry: {hostile}",
);
assert!(
!hostile.to_ascii_lowercase().contains("javascript:"),
"the hostile scheme reached the reader page: {hostile}",
);
assert!(
!hostile.contains("actionbar-open"),
"the action bar rendered an open-original link for a refused URL: {hostile}",
);
assert!(
!hostile.contains("Original \u{2197}"),
"the byline rendered an original link for a refused URL: {hostile}",
);
let benign = render(id_of("benign-1")).await;
assert!(
benign.contains("Benign entry"),
"the reader did not render the benign entry: {benign}",
);
assert_eq!(
benign
.matches(r#"href="https://href.example/post""#)
.count(),
2,
"entry.html has two `href`s for the entry URL — the byline link and \
the action-bar button — and this render produced a different \
number: {benign}",
);
assert!(
benign.contains("actionbar-open"),
"a legitimate entry lost its open-original button: {benign}",
);
assert!(
benign.contains("Original \u{2197}"),
"a legitimate entry lost its byline link: {benign}",
);
}
#[tokio::test]
async fn a_hostile_stored_body_renders_inert_on_the_reader_page() {
let did = "did:plc:readerbody";
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://body.example/feed.xml".to_string(),
title: Some("Body".to_string()),
..Default::default()
},
)
.await
.unwrap();
let hostile_body = concat!(
"<p>kept <b>bold</b></p>",
r#"<img src="x" onerror="alert(2)">"#,
r#"<a href="javascript:alert(3)">click</a>"#,
r#"<p onclick="alert(4)" style="color:red">tail</p>"#,
"<script>alert(1)</script>",
r#"<iframe src="https://evil.example/"></iframe>"#,
);
let clean_body = concat!(
"<h2>Heading</h2>",
r#"<p>Text with <a href="https://body.example/x" rel="noopener noreferrer">a link</a>, "#,
"<em>emphasis</em> & an entity, <angle> brackets.</p>",
"<pre><code>if a < b && c { }</code></pre>",
r#"<ul><li>one</li><li>two</li></ul><img src="https://body.example/i.png" alt="i">"#,
);
store::insert_entries(
&state.db,
feed,
&[
store::NewEntry {
guid: "hostile-body".to_string(),
title: Some("Hostile body".to_string()),
published: Some("2026-07-11T00:00:00Z".to_string()),
content_html: Some(hostile_body.to_string()),
..Default::default()
},
store::NewEntry {
guid: "clean-body".to_string(),
title: Some("Clean body".to_string()),
published: Some("2026-07-10T00:00:00Z".to_string()),
content_html: Some(clean_body.to_string()),
..Default::default()
},
store::NewEntry {
guid: "oversize-body".to_string(),
title: Some("Oversize body".to_string()),
published: Some("2026-07-09T00:00:00Z".to_string()),
content_html: Some(format!(
"<p>{}OVERSIZE</p>",
"a".repeat(crate::sanitized_html::MAX_RENDER_HTML_BYTES)
)),
..Default::default()
},
],
0,
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed])
.await
.unwrap();
let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
let id_of = |guid: &str| {
rows.iter()
.find(|r| r.guid == guid)
.unwrap_or_else(|| panic!("{guid} was not inserted"))
.id
};
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let prose = |id: i64| {
let app = app.clone();
let cookie = cookie.clone();
async move {
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/entries/{id}"))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let page = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
let start = page
.find(r#"<div class="prose">"#)
.unwrap_or_else(|| panic!("no prose block: {page}"));
let end = page[start..]
.find("</article>")
.map(|e| start + e)
.unwrap_or_else(|| panic!("no </article>: {page}"));
page[start..end].to_string()
}
};
let hostile = prose(id_of("hostile-body")).await;
let lower = hostile.to_ascii_lowercase();
for needle in [
"<script",
"alert(1)",
"onerror",
"javascript:",
"<iframe",
"onclick",
] {
assert!(
!lower.contains(needle),
"`{needle}` from a stored body reached the reader page: {hostile}",
);
}
assert!(
hostile.contains("<p>kept <b>bold</b></p>"),
"the benign markup did not render as markup: {hostile}",
);
assert!(
hostile.contains(r#"<img src="x">"#)
&& hostile.contains(r#"<a rel="noopener noreferrer">click</a>"#),
"the sanitizer's output did not reach the page: {hostile}",
);
let clean = prose(id_of("clean-body")).await;
assert!(
clean.contains(clean_body),
"an already-clean stored body did not render byte-identically: {clean}",
);
assert!(
!clean.contains("body-too-large")
&& !clean.contains("body-unavailable")
&& !clean.contains("body-too-slow"),
"a whole, ordinary body was shown as refused: {clean}",
);
let over = prose(id_of("oversize-body")).await;
assert!(
!over.contains("OVERSIZE") && !over.contains(&"a".repeat(64)),
"an over-size stored body was rendered: {}…",
&over[..over.len().min(300)],
);
assert!(
over.contains("body-too-large"),
"an over-size body did not say so: {over}",
);
}
#[test]
fn the_reader_template_renders_each_body_outcome() {
let config = Config::default();
let user = CurrentUser {
did: "did:plc:bodyoutcomes".to_string(),
handle: None,
sid: None,
};
let page = |content_html: Option<BodyRender>| {
EntryTemplate {
card: Card::private(&config),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
nav: build_nav(&user, "unread", String::new(), vec![], vec![], false),
id: 1,
title: "T".to_string(),
feed_title: "F".to_string(),
author: None,
published: String::new(),
url: SafeLink::external_opt("https://orig.example/a"),
content_html,
read: false,
starred: false,
back_qs: String::new(),
prev_id: None,
next_id: None,
oob: false,
}
.render()
.unwrap()
};
let html = page(Some(BodyRender::Html(
crate::sanitized_html::SanitizedHtml::clean("<p>body <b>here</b></p>"),
)));
assert!(html.contains("<p>body <b>here</b></p>"), "{html}");
assert!(
!html.contains("body-too-large")
&& !html.contains("body-unavailable")
&& !html.contains("body-too-slow")
);
let too_large = page(Some(BodyRender::TooLarge));
assert!(too_large.contains("body-too-large"), "{too_large}");
assert!(too_large.contains("too large to display"), "{too_large}");
assert!(!too_large.contains("body-unavailable"));
let unavailable = page(Some(BodyRender::Unavailable));
assert!(unavailable.contains("body-unavailable"), "{unavailable}");
assert!(
unavailable.contains("temporarily unavailable"),
"{unavailable}"
);
assert!(!unavailable.contains("body-too-large"));
let too_slow = page(Some(BodyRender::TooSlow));
assert!(too_slow.contains("body-too-slow"), "{too_slow}");
assert!(too_slow.contains("too complex to display"), "{too_slow}");
assert!(
!too_slow.contains("body-too-large") && !too_slow.contains("body-unavailable"),
"{too_slow}"
);
let none = page(None);
assert!(none.contains("has no stored content"), "{none}");
}
#[tokio::test]
async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
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::replace_sub_refs(&state.db, did_a, &[feed_a])
.await
.unwrap();
assert!(
state.repo().list_subscriptions_sorted(did_a).await.is_err(),
"this test is only meaningful on the outage path; the repo answered",
);
let resolved = resolve_subscriptions(&state, did_a).await;
let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
assert_eq!(
urls,
vec!["https://a.example/feed.xml"],
"the outage fallback must return the caller's OWN subscriptions only; \
any other feed here is cross-tenant read access granted by an 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"
);
}
async fn import_against_strict_pds(
did: &str,
n: usize,
fail_call: Option<usize>,
) -> (String, crate::atproto::tests::ApplyWritesLog) {
let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
let resp = router(state)
.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 loc = resp.headers()[header::LOCATION].to_str().unwrap();
let flash = url::Url::parse(&format!("http://x{loc}"))
.unwrap()
.query_pairs()
.find(|(k, _)| k == "flash")
.map(|(_, v)| v.into_owned())
.unwrap_or_default();
(flash, log)
}
#[tokio::test]
async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
assert_eq!(flash, "Imported 450 feeds", "{flash}");
assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
}
#[tokio::test]
async fn opml_import_that_part_lands_reports_what_landed() {
let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
assert!(
flash.contains("200 of 450"),
"the landed count is not reported: {flash}"
);
assert!(
!flash.contains("nothing was imported"),
"200 feeds landed and the reader was told none did: {flash}"
);
}
#[tokio::test]
async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
assert!(flash.contains("nothing was imported"), "{flash}");
}
#[tokio::test]
async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
let did = "did:plc:typoist";
let state = test_state_with_caps(did, 0, 0).await;
let cookie = session_cookie(&state, did, None);
for input in [
"at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
"at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
] {
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions")
.header(header::COOKIE, cookie.clone())
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("url={input}")))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("kind%20of%20feed"),
"expected the unsupported-feed flash for {input}, got {loc}"
);
assert!(
!loc.contains("Private"),
"a storability refusal was reported as a privacy one for {input}: {loc}"
);
}
assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
}
#[tokio::test]
async fn opml_import_reports_entries_this_instance_cannot_store() {
let did = "did:plc:renamer4";
let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
assert!(!state.config.standard_site);
let opml = format!(
"<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
<outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
<outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
</body></opml>"
);
let (ct, body) = opml_multipart(opml.as_bytes());
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.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 loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("Imported%201%20feed"),
"unexpected flash: {loc}"
);
assert!(
loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
"the dropped entry was not reported: {loc}"
);
assert!(
!loc.contains("site.standard.publication"),
"the URI was echoed: {loc}"
);
}
#[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 the_reader_index_pages_instead_of_rendering_everything() {
let did = "did:plc:pager";
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://pager.example/feed.xml".to_string(),
title: Some("Pager".to_string()),
..Default::default()
},
)
.await
.unwrap();
let total = 250_usize;
let entries: Vec<store::NewEntry> = (0..total)
.map(|i| store::NewEntry {
guid: format!("p-{i:04}"),
url: Some(format!("https://pager.example/{i}")),
title: Some(format!("Article {i:04}")),
published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
content_html: Some("x".repeat(4_000)),
..Default::default()
})
.collect();
store::insert_entries(&state.db, feed, &entries, 0)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed])
.await
.unwrap();
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let get = |uri: &str| {
let app = app.clone();
let cookie = cookie.clone();
let uri = uri.to_string();
async move {
let resp = app
.oneshot(
Request::builder()
.uri(uri)
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
};
let page1 = get("/").await;
let rows1 = page1.matches("<li class=\"entry").count();
assert!(
rows1 <= ENTRIES_PER_PAGE as usize,
"page 1 rendered {rows1} entry links; the list is unbounded"
);
assert!(
rows1 > 0,
"page 1 rendered nothing at all: the page bound swallowed the list"
);
assert!(
page1.contains("250 entries"),
"heading must report the full total, not the page"
);
assert!(
page1.contains("page=2"),
"no way to reach the rest of the list: {}",
&page1[..page1.len().min(400)]
);
assert!(
!page1.contains(&"x".repeat(4_000)),
"the list response carried an article body"
);
let page2 = get("/?page=2").await;
assert!(
page2.matches("<li class=\"entry").count() > 0,
"page 2 rendered no rows at all"
);
assert!(
page2.contains("page=1") || page2.contains("Newer"),
"page 2 offers no way back"
);
let first_title = (0..total)
.map(|i| format!("Article {i:04}"))
.find(|t| page1.contains(t))
.expect("page 1 shows at least one titled article");
assert!(
!page2.contains(&first_title),
"{first_title} appears on both pages"
);
let past_end = get("/?page=999").await;
assert!(
past_end.matches("<li class=\"entry").count() > 0,
"an out-of-range page rendered nothing and offered no way back"
);
assert!(
past_end.contains("page=2"),
"the clamped page offers no pager"
);
}
#[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}"
);
assert!(
html.contains(&format!("/entries/{entry_id}")),
"the response is not the row for this entry: {html}",
);
assert!(
html.contains("Article"),
"the row rendered without its title: {html}",
);
assert!(
html.contains("is-read"),
"the row came back without the read state it was just given: {html}",
);
}
#[tokio::test]
async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
let did = "did:plc:autodiscovered";
let state = test_state_with_caps(did, 0, 0).await;
let page = r#"<!doctype html><html><head><title>Blog</title>
<link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
</head><body>hi</body></html>"#;
let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"autodiscover-ftp.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!(
"url=http://autodiscover-ftp.test:{port}/"
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert_ne!(loc, "/login", "the test never reached the add path");
assert_ne!(loc, "/", "the subscribe succeeded");
assert_eq!(
store::count_feeds(&state.db).await.unwrap(),
0,
"a non-http(s) URL from autodiscovery was stored"
);
assert_eq!(
store::count_subscriptions_for_did(&state.db, did)
.await
.unwrap(),
0
);
}
#[tokio::test]
async fn rename_to_new_url_refused_at_global_feeds_cap() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar_and(&[did], &sidecar, false, 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 resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/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}"
);
assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
assert!(
puts.lock().unwrap().is_empty(),
"a refused repoint reached the PDS"
);
}
#[tokio::test]
async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar_and(&[did], &sidecar, false, 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 resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/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, "/", "the repoint to a cached URL was refused: {loc}");
assert_eq!(
puts.lock().unwrap().len(),
1,
"the repoint did not reach the PDS"
);
assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
}
#[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"
);
}
async fn spawn_rename_sidecar(
existing: serde_json::Value,
) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = puts.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut raw: Vec<u8> = Vec::new();
let mut chunk = [0u8; 4096];
let body_text = loop {
let Ok(n) = sock.read(&mut chunk).await else {
break String::new();
};
if n == 0 {
break String::from_utf8_lossy(&raw).to_string();
}
raw.extend_from_slice(&chunk[..n]);
let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
continue;
};
let (head, body) = raw.split_at(split + 4);
let want = String::from_utf8_lossy(head).lines().find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse::<usize>().ok())?
});
if want.is_none_or(|want| body.len() >= want) {
break String::from_utf8_lossy(body).to_string();
}
};
let is_put = body_text.contains("\"action\":\"put\"");
let data = if is_put {
sink.lock().unwrap().push(body_text.clone());
serde_json::json!({
"uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
"cid": "bafyreiafter"
})
} else {
serde_json::json!({ "records": [existing.clone()] })
};
let body = serde_json::json!({ "ok": true, "data": data }).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
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
}
});
(format!("http://{addr}"), puts)
}
fn seeded_subscription() -> serde_json::Value {
serde_json::json!({
"uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
"cid": "bafyreibefore",
"value": {
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"title": "Old title",
"siteUrl": "https://example.com/blog",
"fetchHint": "hourly",
"private": false,
"createdAt": "2024-03-01T00:00:00.000Z"
}
})
}
fn seeded_at_uri_subscription() -> serde_json::Value {
seeded_subscription_with_url(AT_URI_SUB)
}
fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
serde_json::json!({
"uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
"cid": "bafyreibefore",
"value": {
"$type": "community.lexicon.rss.subscription",
"url": url,
"title": "Old title",
"private": false,
"createdAt": "2024-03-01T00:00:00.000Z"
}
})
}
const AT_URI_SUB: &str =
"at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
const AT_URI_SUB_ENC: &str =
"at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
#[tokio::test]
async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
let did = "did:plc:renamer5";
let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
assert!(
!state.config.standard_site,
"the flag must be off for this test"
);
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert_eq!(loc, "/", "the retitle was refused: {loc}");
let bodies = puts.lock().unwrap().clone();
assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
assert_eq!(
sent["record"]["title"], "New title",
"the rename did not apply"
);
assert_eq!(
sent["record"]["url"], AT_URI_SUB,
"the rename changed the URL"
);
let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
}
#[tokio::test]
async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
assert!(
!loc.contains("Private"),
"a storability refusal was reported as a privacy one: {loc}"
);
assert!(
puts.lock().unwrap().is_empty(),
"the repoint reached the PDS"
);
let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
assert_eq!(cached, 0);
}
async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
let cookie = session_cookie(state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("url={url_enc}&title=New+title")))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string()
}
#[tokio::test]
async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
let did = "did:plc:renamer5";
let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
let other_enc =
"at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let loc = retitle_unchanged(&state, did, other_enc).await;
assert_eq!(loc, "/", "the retitle was refused: {loc}");
let bodies = puts.lock().unwrap().clone();
assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
assert_eq!(sent["record"]["title"], "New title");
assert_eq!(sent["record"]["url"], other);
}
#[tokio::test]
async fn repointing_a_subscription_at_a_private_feed_is_refused() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("Private"),
"the private repoint was not refused: {loc}"
);
assert!(
puts.lock().unwrap().is_empty(),
"a secret-bearing URL reached the PDS"
);
let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
assert!(store::get_feed_by_url(&state.db, leaked)
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
let did = "did:plc:renamer5";
let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://filler.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
assert_eq!(loc, "/", "the retitle was refused: {loc}");
assert_eq!(
puts.lock().unwrap().len(),
1,
"the retitle did not reach the PDS"
);
assert_eq!(
store::count_feeds(&state.db).await.unwrap(),
1,
"a row was inserted"
);
}
async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
let cookie = session_cookie(state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("url={url_enc}")))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string()
}
async fn serve_resolver(did: &str) -> String {
let base = crate::net::tests::serve_body(
serde_json::json!({ "did": did }).to_string().into_bytes(),
)
.await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
let host = format!("resolver-{port}.test");
crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
format!("http://{host}:{port}")
}
fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
let mut config = (*state.config).clone();
f(&mut config);
state.config = std::sync::Arc::new(config);
state
}
#[tokio::test]
async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
let did = "did:plc:renamer5";
let (sidecar, log) = spawn_logging_sidecar().await;
let state = with_config(
test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
|c| {
c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
},
);
let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
assert_eq!(loc, "/", "the paste was refused: {loc}");
let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
.await
.unwrap()
.expect("no feed row");
assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
let sent = log.lock().unwrap().join("\n");
assert!(
sent.contains(AT_URI_SUB),
"the subscription was not written to the PDS: {sent}"
);
}
#[tokio::test]
async fn a0_subscribing_from_the_form_delivers_entries() {
let did = "did:plc:renamer5";
let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
let site = AT_URI_SUB;
let (plc, _) = crate::standard_site::tests::serve_repo(
author,
vec![
(
lexicon::nsid::STANDARD_PUBLICATION,
"3lab2c4d5e6f7g8h",
serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
),
(
lexicon::nsid::STANDARD_DOCUMENT,
"3l2a0frmaaa2a",
serde_json::json!({ "title": "From the form", "path": "/f",
"publishedAt": "2026-07-11T00:00:00Z", "site": site }),
),
],
)
.await;
let (sidecar, _log) = spawn_logging_sidecar().await;
let state = with_config(
test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
|c| {
c.oauth.plc_directory = plc;
},
);
assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
let row = store::get_feed_by_url(&state.db, site)
.await
.unwrap()
.unwrap();
let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
.bind(row.id)
.fetch_all(&state.db)
.await
.unwrap();
assert_eq!(
titles,
vec!["From the form".to_string()],
"the first poll stored nothing"
);
assert_eq!(row.title.as_deref(), Some("A0 Journal"));
}
#[tokio::test]
async fn a_handle_form_paste_is_stored_by_its_did() {
let did = "did:plc:renamer5";
let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
let (sidecar, _log) = spawn_logging_sidecar().await;
let resolver = serve_resolver(author).await;
let state = with_config(
test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
|c| {
c.resolver_base = resolver;
c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
},
);
let loc = subscribe(
&state,
did,
"at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
)
.await;
assert_eq!(loc, "/", "the paste was refused: {loc}");
assert!(
store::get_feed_by_url(&state.db, AT_URI_SUB)
.await
.unwrap()
.is_some(),
"not stored by its DID"
);
assert_eq!(
store::count_feeds(&state.db).await.unwrap(),
1,
"the handle form was stored too"
);
}
async fn serve_counting_resolver(
did: &str,
) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
let (base, hits) = crate::net::tests::serve_body_counted(
serde_json::json!({ "did": did }).to_string().into_bytes(),
)
.await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
let host = format!("counting-resolver-{port}.test");
crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
(format!("http://{host}:{port}"), hits)
}
#[tokio::test]
async fn an_over_cap_handle_paste_makes_no_outbound_request() {
let did = "did:plc:renamer5";
let (sidecar, _log) = spawn_logging_sidecar().await;
let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
let state = with_config(
test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
|c| {
c.resolver_base = resolver;
c.max_subs_per_did = 1;
},
);
let feed_id = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://already.example/feed.xml".into(),
..Default::default()
},
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed_id])
.await
.unwrap();
let loc = subscribe(
&state,
did,
"at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
)
.await;
assert!(
loc.contains("Subscription%20limit"),
"expected the cap flash: {loc}"
);
assert_eq!(
hits.load(std::sync::atomic::Ordering::SeqCst),
0,
"an over-cap paste resolved a handle"
);
}
#[tokio::test]
async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
let did = "did:plc:renamer5";
let (sidecar, _log) = spawn_logging_sidecar().await;
let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
let state = with_config(
test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
|c| {
c.resolver_base = resolver;
},
);
for authority in [
"did%3Aplc%3ATOOSHORT",
"did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
"bad%0Ahandle.example",
] {
let loc = subscribe(
&state,
did,
&format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
)
.await;
assert!(
loc.contains("kind%20of%20feed"),
"{authority}: expected the unsupported flash: {loc}"
);
}
assert_eq!(
hits.load(std::sync::atomic::Ordering::SeqCst),
0,
"a malformed authority reached the resolver"
);
assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
}
#[tokio::test]
async fn an_unresolvable_handle_paste_is_refused() {
let did = "did:plc:renamer5";
let (sidecar, _log) = spawn_logging_sidecar().await;
let state = with_config(
test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
|c| {
c.resolver_base = "http://resolver.nowhere.invalid".into();
},
);
let loc = subscribe(
&state,
did,
"at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
)
.await;
assert!(
loc.contains("resolve%20the%20handle"),
"expected the unresolvable-handle flash: {loc}"
);
assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
}
#[tokio::test]
async fn a_non_publication_at_uri_paste_is_refused() {
let did = "did:plc:renamer5";
let (sidecar, _log) = spawn_logging_sidecar().await;
let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
let loc = subscribe(
&state,
did,
"at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
)
.await;
assert!(
loc.contains("kind%20of%20feed"),
"expected the unsupported flash: {loc}"
);
assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
}
#[tokio::test]
async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
let did = "did:plc:renamer5";
let (sidecar, _log) = spawn_logging_sidecar().await;
let state = with_config(
test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
|c| {
c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
},
);
let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
assert_eq!(loc, "/", "the paste was refused: {loc}");
assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
let did = "did:plc:renamer5";
let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
let opml = format!(
"<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
<outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
<outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
</body></opml>"
);
let (ct, body) = opml_multipart(opml.as_bytes());
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.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 loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("Imported%202%20feeds"),
"unexpected flash: {loc}"
);
assert!(
!loc.contains("skipped"),
"the at:// entry was skipped with the flag on: {loc}"
);
let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
assert!(
stored.is_some(),
"the at:// entry was not stored with the flag on"
);
}
#[tokio::test]
async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
let did = "did:plc:renamer5";
let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
let tokened_enc =
"https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let loc = retitle_unchanged(&state, did, tokened_enc).await;
assert_eq!(loc, "/", "the retitle was refused: {loc}");
assert_eq!(
puts.lock().unwrap().len(),
1,
"the retitle did not reach the PDS"
);
assert!(
store::get_feed_by_url(&state.db, tokened)
.await
.unwrap()
.is_none(),
"a secret-bearing URL was written to the shared cache by a retitle"
);
}
#[tokio::test]
async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("kind%20of%20feed"),
"expected the unsupported flash: {loc}"
);
assert!(
!loc.contains("Private"),
"a typo was reported as a paid feed: {loc}"
);
assert!(puts.lock().unwrap().is_empty());
}
#[tokio::test]
async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://filler.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("kind%20of%20feed"),
"expected the unsupported flash: {loc}"
);
assert!(
!loc.contains("capacity"),
"an unacceptable URL was reported as a capacity problem: {loc}"
);
assert!(puts.lock().unwrap().is_empty());
}
#[tokio::test]
async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
let did = "did:plc:renamer5";
let padded = format!("{AT_URI_SUB} ");
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
assert_eq!(
loc, "/",
"the retitle was treated as a repoint and refused: {loc}"
);
let bodies = puts.lock().unwrap().clone();
assert_eq!(bodies.len(), 1);
let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
assert_eq!(
sent["record"]["url"], AT_URI_SUB,
"the padding was not normalised away"
);
}
#[tokio::test]
async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
let did = "did:plc:renamer5";
let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://filler.example/feed.xml".to_string(),
..Default::default()
},
)
.await
.unwrap();
let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
assert_eq!(loc, "/", "the retitle was refused: {loc}");
assert_eq!(puts.lock().unwrap().len(), 1);
assert_eq!(
store::count_feeds(&state.db).await.unwrap(),
1,
"a retitle inserted a cache row past the ceiling"
);
}
#[tokio::test]
async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
let did = "did:plc:typoist";
let state = test_state_with_caps(did, 0, 0).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("kind%20of%20feed"),
"expected the unsupported flash: {loc}"
);
assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
}
#[derive(Default)]
struct SwapRepo {
value: serde_json::Value,
version: u32,
puts: Vec<serde_json::Value>,
concurrent: Option<serde_json::Value>,
refuse_every_swap: bool,
fail_puts: Option<(u16, &'static str)>,
collection: Option<&'static str>,
missing: bool,
fail_list: bool,
}
impl SwapRepo {
fn cid(&self) -> String {
format!("bafyreiversion{}", self.version)
}
fn nsid(&self) -> &'static str {
self.collection
.unwrap_or(crate::lexicon::nsid::SUBSCRIPTION)
}
fn page(&self) -> serde_json::Value {
if self.missing {
return serde_json::json!({ "records": [] });
}
serde_json::json!({ "records": [{
"uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
"cid": self.cid(),
"value": self.value,
}] })
}
fn put(&mut self, body: &serde_json::Value) -> Result<serde_json::Value, (u16, String)> {
self.puts.push(body.clone());
if let Some(theirs) = self.concurrent.take() {
self.value = theirs;
self.version += 1;
}
if let Some((status, error)) = self.fail_puts {
return Err((status, error.to_string()));
}
if let Some(swap) = body.get("swapRecord").and_then(|v| v.as_str()) {
if self.refuse_every_swap || swap != self.cid() {
return Err((400, "InvalidSwap".to_string()));
}
}
self.value = body["record"].clone();
self.version += 1;
Ok(serde_json::json!({
"uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
"cid": self.cid(),
}))
}
}
const RACE_DID: &str = "did:plc:racer149";
async fn serve_swap_repo(repo: std::sync::Arc<std::sync::Mutex<SwapRepo>>) -> (String, String) {
use axum::response::IntoResponse as _;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let host = format!("pds-{}.race.test", addr.port());
crate::net::test_host_override(&host, addr);
let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
let repo = std::sync::Arc::clone(&repo);
async move {
let (parts, body) = req.into_parts();
let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let body: serde_json::Value =
serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
let reply = |status: u16, body: serde_json::Value| {
(StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
};
let mut repo = repo.lock().unwrap();
match (parts.uri.path(), body["action"].as_str()) {
("/internal/repo", Some("list")) if repo.fail_list => reply(
502,
serde_json::json!({
"ok": false, "error": "UpstreamFailure", "message": "down", "status": 502,
}),
),
("/internal/repo", Some("list")) => {
reply(200, serde_json::json!({ "ok": true, "data": repo.page() }))
}
("/internal/repo", Some("put")) => match repo.put(&body) {
Ok(data) => reply(200, serde_json::json!({ "ok": true, "data": data })),
Err((status, error)) => reply(
status,
serde_json::json!({
"ok": false, "error": error, "message": "refused", "status": status,
}),
),
},
("/xrpc/com.atproto.repo.listRecords", _) if repo.fail_list => reply(
502,
serde_json::json!({ "error": "UpstreamFailure", "message": "down" }),
),
("/xrpc/com.atproto.repo.listRecords", _) => reply(200, repo.page()),
("/xrpc/com.atproto.repo.putRecord", _) => match repo.put(&body) {
Ok(data) => reply(200, data),
Err((status, error)) => reply(
status,
serde_json::json!({ "error": error, "message": "refused" }),
),
},
other => panic!("unexpected request {other:?}"),
}
}
});
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(
format!("http://{addr}"),
format!("http://{host}:{}", addr.port()),
)
}
async fn race_state(
backend: crate::metrics::Backend,
repo: &std::sync::Arc<std::sync::Mutex<SwapRepo>>,
) -> AppState {
let (sidecar, aud) = serve_swap_repo(std::sync::Arc::clone(repo)).await;
let db = store::init_url("sqlite::memory:").await.unwrap();
store::ensure_seed(&db, &[RACE_DID.to_string()])
.await
.unwrap();
let mut config = Config {
allowed_dids: vec![RACE_DID.to_string()],
cookie_secret: "test-cookie-secret-000".to_string(),
beta_cap: 3,
repo_backend: backend,
oauth: crate::config::OauthConfig {
key_path: std::env::temp_dir().join(format!(
"fr-race-oauth-key-{}-{:p}.json",
std::process::id(),
&db as *const _
)),
encryption_key: Some("a".repeat(43)),
..crate::config::OauthConfig::default()
},
..Config::default()
};
config.sidecar.public_url = sidecar.clone();
config.sidecar.internal_url = sidecar;
let state = AppState::new(config, db).unwrap();
if backend == crate::metrics::Backend::Rust {
let runtime = state.oauth.as_deref().expect("oauth runtime");
crate::oauth::store::put_session(
&state.db,
&runtime.codec,
&crate::oauth::store::OAuthSession {
sub: RACE_DID.into(),
issuer: "https://auth.invalid".into(),
aud,
dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
.to_jwk_json()
.unwrap(),
access_token: "at".into(),
refresh_token: "rt".into(),
token_type: "DPoP".into(),
granted_scope: "atproto".into(),
expires_at: Some(store::now_unix() + 3600),
},
)
.await
.unwrap();
}
state
}
fn race_seed() -> serde_json::Value {
seeded_subscription()["value"].clone()
}
async fn post_race_rename(state: &AppState) -> String {
post_race_rename_body(
state,
"url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
)
.await
}
async fn post_race_rename_body(state: &AppState, body: &str) -> String {
let cookie = session_cookie(state, RACE_DID, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string()
}
const RACE_BACKENDS: [crate::metrics::Backend; 2] = [
crate::metrics::Backend::Sidecar,
crate::metrics::Backend::Rust,
];
#[tokio::test]
async fn a_rename_that_loses_a_race_keeps_the_concurrent_edit_and_lands() {
for backend in RACE_BACKENDS {
let mut theirs = race_seed();
theirs["siteUrl"] = serde_json::json!("https://elsewhere.example/blog");
theirs["fetchHint"] = serde_json::json!("daily");
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
concurrent: Some(theirs),
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename(&state).await;
let repo = repo.lock().unwrap();
assert_eq!(
loc, "/",
"{backend:?}: a rename that converged was not reported as done"
);
assert_eq!(
repo.puts.len(),
2,
"{backend:?}: expected the refused put and one retry: {:?}",
repo.puts
);
assert_eq!(
repo.puts[0]["swapRecord"], "bafyreiversion0",
"{backend:?}: the first put did not name the CID it read: {}",
repo.puts[0]
);
assert_eq!(
repo.puts[1]["swapRecord"], "bafyreiversion1",
"{backend:?}: the retry did not name the RE-READ CID: {}",
repo.puts[1]
);
let landed = &repo.value;
assert_eq!(landed["title"], "New title", "{backend:?}: {landed}");
assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
assert_eq!(
landed["siteUrl"], "https://elsewhere.example/blog",
"{backend:?}: the concurrent edit was lost: {landed}"
);
assert_eq!(
landed["fetchHint"], "daily",
"{backend:?}: the concurrent edit was lost: {landed}"
);
assert_eq!(
landed["createdAt"], "2024-03-01T00:00:00.000Z",
"{backend:?}: {landed}"
);
}
}
#[tokio::test]
async fn a_rename_refused_on_every_swap_reports_the_conflict() {
for backend in RACE_BACKENDS {
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
refuse_every_swap: true,
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename(&state).await;
let repo = repo.lock().unwrap();
assert_ne!(loc, "/", "{backend:?}: a refused rename reported success");
assert!(
loc.contains("changed%20elsewhere"),
"{backend:?}: expected the conflict flash, got {loc}"
);
assert!(
(1..=2).contains(&repo.puts.len()),
"{backend:?}: expected at most two put attempts, got {}",
repo.puts.len()
);
assert_eq!(
repo.value,
race_seed(),
"{backend:?}: the record changed though every put was refused"
);
}
}
#[tokio::test]
async fn a_rename_refused_for_another_reason_is_not_retried() {
for backend in RACE_BACKENDS {
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
fail_puts: Some((400, "InvalidRequest")),
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename(&state).await;
let repo = repo.lock().unwrap();
assert_eq!(
repo.puts.len(),
1,
"{backend:?}: a non-swap refusal was retried"
);
assert!(
loc.contains("Could%20not%20save"),
"{backend:?}: expected the save-failed flash, got {loc}"
);
assert!(
!loc.contains("changed%20elsewhere"),
"{backend:?}: a non-swap refusal was reported as a conflict: {loc}"
);
}
}
const SEEN_SEED: &str = "seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
&seen_title=Old+title&seen_folder=";
#[tokio::test]
async fn a_retry_keeps_a_concurrent_repoint_the_reader_did_not_make() {
for backend in RACE_BACKENDS {
for with_seen in [true, false] {
let mut theirs = race_seed();
theirs["url"] = serde_json::json!("https://moved.example/feed.xml");
theirs["siteUrl"] = serde_json::json!("https://moved.example/");
theirs["fetchHint"] = serde_json::json!("daily");
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
concurrent: Some(theirs),
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let mut body =
"url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title".to_string();
if with_seen {
body.push_str(
"&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml&seen_title=Old+title",
);
}
let loc = post_race_rename_body(&state, &body).await;
let repo = repo.lock().unwrap();
let ctx = format!("{backend:?} seen={with_seen}");
assert_eq!(loc, "/", "{ctx}: the rename did not land: {loc}");
assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
let landed = &repo.value;
assert_eq!(landed["title"], "New title", "{ctx}: {landed}");
assert_eq!(
landed["url"], "https://moved.example/feed.xml",
"{ctx}: the retry repointed the record back to the stale URL: {landed}"
);
assert_eq!(
landed["siteUrl"], "https://moved.example/",
"{ctx}: {landed}"
);
assert_eq!(landed["fetchHint"], "daily", "{ctx}: {landed}");
}
}
}
#[tokio::test]
async fn a_retry_keeps_a_concurrent_retitle_when_the_reader_only_moved_it() {
for backend in RACE_BACKENDS {
let mut theirs = race_seed();
theirs["title"] = serde_json::json!("Their title");
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
concurrent: Some(theirs),
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename_body(
&state,
&format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Old+title&folder=Tech&{SEEN_SEED}"),
)
.await;
let repo = repo.lock().unwrap();
assert_eq!(loc, "/", "{backend:?}: {loc}");
let landed = &repo.value;
assert_eq!(
landed["title"], "Their title",
"{backend:?}: the reader's untouched title overwrote the other client's: {landed}"
);
assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
}
}
#[tokio::test]
async fn both_retitling_is_a_conflict_that_writes_nothing() {
for backend in RACE_BACKENDS {
let mut theirs = race_seed();
theirs["title"] = serde_json::json!("Their title");
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
concurrent: Some(theirs.clone()),
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename_body(
&state,
&format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
)
.await;
let repo = repo.lock().unwrap();
assert!(
loc.contains("changed%20elsewhere"),
"{backend:?}: expected the conflict flash, got {loc}"
);
assert_eq!(
repo.puts.len(),
1,
"{backend:?}: a conflicting retry was written: {:?}",
repo.puts
);
assert_eq!(
repo.value, theirs,
"{backend:?}: their title was overwritten"
);
}
}
#[tokio::test]
async fn a_repoint_before_the_first_read_is_kept_when_the_reader_only_retitled() {
for backend in RACE_BACKENDS {
let mut moved = race_seed();
moved["url"] = serde_json::json!("https://moved.example/feed.xml");
moved["siteUrl"] = serde_json::json!("https://moved.example/");
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: moved,
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename_body(
&state,
&format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
)
.await;
let repo = repo.lock().unwrap();
assert_eq!(loc, "/", "{backend:?}: {loc}");
assert_eq!(repo.puts.len(), 1, "{backend:?}");
let landed = &repo.value;
assert_eq!(
landed["url"], "https://moved.example/feed.xml",
"{backend:?}: the stale hidden url repointed the record: {landed}"
);
assert_eq!(landed["siteUrl"], "https://moved.example/", "{backend:?}");
assert_eq!(landed["title"], "New title", "{backend:?}");
}
}
#[tokio::test]
async fn a_rename_that_did_not_land_leaves_the_cache_alone() {
const NEW_URL: &str = "https://other.example/feed.xml";
const OLD_URL: &str = "https://example.com/feed.xml";
for (refuse_every_swap, fail_puts, lands) in [
(true, None, false),
(false, Some((400, "InvalidRequest")), false),
(false, Some((502, "UpstreamFailure")), false),
(false, None, true),
] {
for backend in RACE_BACKENDS {
let ctx = format!("{backend:?} refuse={refuse_every_swap} fail={fail_puts:?}");
for repoint in [false, true] {
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
refuse_every_swap,
fail_puts,
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
store::upsert_feed(
&state.db,
&store::NewFeed {
url: OLD_URL.to_string(),
title: Some("Cached title".to_string()),
..Default::default()
},
)
.await
.unwrap();
let url = if repoint { NEW_URL } else { OLD_URL };
let body = format!("url={}&title=New+title&{SEEN_SEED}", qenc(url));
let loc = post_race_rename_body(&state, &body).await;
let new_row = store::get_feed_by_url(&state.db, NEW_URL).await.unwrap();
let old_row = store::get_feed_by_url(&state.db, OLD_URL)
.await
.unwrap()
.expect("the old row");
let ctx = format!("{ctx} repoint={repoint} -> {loc}");
if lands {
assert_eq!(loc, "/", "{ctx}");
if repoint {
assert!(
new_row.is_some(),
"{ctx}: a landed repoint got no cache row"
);
} else {
assert_eq!(old_row.title.as_deref(), Some("New title"), "{ctx}");
}
} else {
assert_ne!(loc, "/", "{ctx}");
assert!(
new_row.is_none(),
"{ctx}: a repoint that did not land left a feeds row for its URL"
);
assert_eq!(
old_row.title.as_deref(),
Some("Cached title"),
"{ctx}: a rename that did not land changed the cached title"
);
}
}
}
}
}
#[tokio::test]
async fn a_rename_from_a_page_without_a_folder_select_keeps_the_folder() {
for backend in RACE_BACKENDS {
for raced in [false, true] {
let mut seed = race_seed();
seed["folder"] = serde_json::json!("at://did:plc:racer149/folder/kept");
let concurrent = raced.then(|| {
let mut theirs = seed.clone();
theirs["folder"] = serde_json::json!("at://did:plc:racer149/folder/theirs");
theirs
});
let want_folder = concurrent
.as_ref()
.map_or(seed["folder"].clone(), |t| t["folder"].clone());
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: seed,
concurrent,
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename_body(
&state,
"url=https%3A%2F%2Fexample.com%2Ffeed.xml\
&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
&seen_title=Old+title&title=New+title",
)
.await;
let repo = repo.lock().unwrap();
let ctx = format!("{backend:?} raced={raced}");
assert_eq!(loc, "/", "{ctx}: {loc}");
assert_eq!(repo.value["title"], "New title", "{ctx}");
assert_eq!(
repo.value["folder"], want_folder,
"{ctx}: a page that never showed a folder changed it: {}",
repo.value
);
}
}
}
#[tokio::test]
async fn a_double_submitted_rename_reports_success_and_writes_once() {
for backend in RACE_BACKENDS {
let mut first = race_seed();
first["title"] = serde_json::json!("New title");
first["folder"] = serde_json::json!("Tech");
let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
value: race_seed(),
concurrent: Some(first.clone()),
..SwapRepo::default()
}));
let state = race_state(backend, &repo).await;
let loc = post_race_rename_body(
&state,
&format!(
"url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech&{SEEN_SEED}"
),
)
.await;
let repo = repo.lock().unwrap();
assert_eq!(
loc, "/",
"{backend:?}: a save that landed was reported as a conflict: {loc}"
);
assert_eq!(
repo.puts.len(),
1,
"{backend:?}: only the refused put; the re-read has nothing left to write: {:?}",
repo.puts
);
assert_eq!(repo.value, first, "{backend:?}");
}
}
fn folder_seed() -> serde_json::Value {
serde_json::json!({
"$type": crate::lexicon::nsid::FOLDER,
"name": "Old name",
"position": 3,
"createdAt": "2024-01-01T00:00:00.000Z",
"color": "#abc",
})
}
fn folder_repo(value: serde_json::Value) -> SwapRepo {
SwapRepo {
value,
collection: Some(crate::lexicon::nsid::FOLDER),
..SwapRepo::default()
}
}
async fn post_folder_rename(state: &AppState, body: &str) -> String {
let cookie = session_cookie(state, RACE_DID, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/folders/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string()
}
fn renamed(mut value: serde_json::Value, name: &str) -> serde_json::Value {
value["name"] = serde_json::json!(name);
value
}
#[tokio::test]
async fn renaming_a_folder_changes_only_its_name() {
for backend in RACE_BACKENDS {
let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(folder_seed())));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=New+name").await;
let repo = repo.lock().unwrap();
assert_eq!(
loc, "/",
"{backend:?}: a landed rename was not reported as done"
);
assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
assert_eq!(
repo.puts[0]["record"],
renamed(folder_seed(), "New name"),
"{backend:?}: the put did not keep the record whole: {}",
repo.puts[0]
);
assert_eq!(
repo.puts[0]["collection"],
crate::lexicon::nsid::FOLDER,
"{backend:?}"
);
assert_eq!(
repo.puts[0]["swapRecord"], "bafyreiversion0",
"{backend:?}: the put did not name the CID it read: {}",
repo.puts[0]
);
}
}
#[tokio::test]
async fn a_folder_rename_that_loses_a_race_keeps_the_concurrent_edit() {
for backend in RACE_BACKENDS {
for with_seen in [true, false] {
let mut theirs = folder_seed();
theirs["position"] = serde_json::json!(7);
theirs["icon"] = serde_json::json!("star");
let mut fake = folder_repo(folder_seed());
fake.concurrent = Some(theirs.clone());
let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
let state = race_state(backend, &repo).await;
let body = if with_seen {
"name=New+name&seen_name=Old+name"
} else {
"name=New+name"
};
let loc = post_folder_rename(&state, body).await;
let repo = repo.lock().unwrap();
let ctx = format!("{backend:?} with_seen={with_seen}");
assert_eq!(
loc, "/",
"{ctx}: a converged rename was not reported as done"
);
assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
assert_eq!(repo.puts[0]["swapRecord"], "bafyreiversion0", "{ctx}");
assert_eq!(
repo.puts[1]["swapRecord"], "bafyreiversion1",
"{ctx}: the retry did not name the RE-READ CID"
);
assert_eq!(
repo.value,
renamed(theirs, "New name"),
"{ctx}: the concurrent edit was lost"
);
}
}
}
#[tokio::test]
async fn both_renaming_a_folder_differently_is_a_conflict() {
for backend in RACE_BACKENDS {
for body in ["name=New+name&seen_name=Old+name", "name=New+name"] {
let theirs = renamed(folder_seed(), "Their name");
let mut fake = folder_repo(folder_seed());
fake.concurrent = Some(theirs.clone());
let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, body).await;
let repo = repo.lock().unwrap();
assert!(
loc.contains("changed%20elsewhere"),
"{backend:?} {body}: expected the conflict flash, got {loc}"
);
assert_eq!(
repo.puts.len(),
1,
"{backend:?} {body}: only the refused put: {:?}",
repo.puts
);
assert_eq!(
repo.value, theirs,
"{backend:?} {body}: the other client's name was overwritten"
);
}
}
}
#[tokio::test]
async fn both_renaming_a_folder_the_same_is_success_without_a_write() {
for backend in RACE_BACKENDS {
let theirs = renamed(folder_seed(), "New name");
let mut fake = folder_repo(folder_seed());
fake.concurrent = Some(theirs.clone());
let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
let repo = repo.lock().unwrap();
assert_eq!(
loc, "/",
"{backend:?}: agreement reported as a failure: {loc}"
);
assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
assert_eq!(repo.value, theirs, "{backend:?}");
}
}
#[tokio::test]
async fn a_folder_rename_refused_on_every_swap_reports_the_conflict() {
for backend in RACE_BACKENDS {
let mut fake = folder_repo(folder_seed());
fake.refuse_every_swap = true;
let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=New+name").await;
let repo = repo.lock().unwrap();
assert!(
loc.contains("changed%20elsewhere"),
"{backend:?}: expected the conflict flash, got {loc}"
);
assert_eq!(repo.puts.len(), 2, "{backend:?}: one try and one retry");
assert_eq!(repo.value, folder_seed(), "{backend:?}");
}
}
#[tokio::test]
async fn a_failed_folder_rename_shows_an_error() {
for backend in RACE_BACKENDS {
let mut fake = folder_repo(folder_seed());
fake.fail_puts = Some((502, "UpstreamFailure"));
let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=New+name").await;
let repo = repo.lock().unwrap();
assert_ne!(loc, "/", "{backend:?}: a failed rename reported success");
assert!(
loc.contains("Could%20not%20save"),
"{backend:?}: expected the save-failed flash, got {loc}"
);
assert!(!loc.contains("changed%20elsewhere"), "{backend:?}: {loc}");
assert_eq!(
repo.puts.len(),
1,
"{backend:?}: a non-swap failure was retried"
);
}
}
#[tokio::test]
async fn renaming_a_folder_that_no_longer_exists_writes_nothing() {
for backend in RACE_BACKENDS {
let mut fake = folder_repo(folder_seed());
fake.missing = true;
let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=New+name").await;
let repo = repo.lock().unwrap();
assert!(
loc.contains("no%20longer%20exists"),
"{backend:?}: expected the missing-folder flash, got {loc}"
);
assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
}
}
#[tokio::test]
async fn a_folder_rename_whose_read_fails_writes_nothing() {
for backend in RACE_BACKENDS {
let mut fake = folder_repo(folder_seed());
fake.fail_list = true;
let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=New+name").await;
let repo = repo.lock().unwrap();
assert!(
loc.contains("Could%20not%20reach"),
"{backend:?}: expected the read-failed flash, got {loc}"
);
assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
}
}
#[tokio::test]
async fn a_rename_elsewhere_after_page_load_is_a_conflict_with_seen_name() {
for backend in RACE_BACKENDS {
let theirs = renamed(folder_seed(), "Their name");
let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
let repo = repo.lock().unwrap();
assert!(
loc.contains("changed%20elsewhere"),
"{backend:?}: expected the conflict flash, got {loc}"
);
assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
assert_eq!(repo.value, theirs, "{backend:?}");
}
}
#[tokio::test]
async fn an_unchanged_folder_name_writes_nothing() {
for backend in RACE_BACKENDS {
let theirs = renamed(folder_seed(), "Their name");
let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
let state = race_state(backend, &repo).await;
let loc = post_folder_rename(&state, "name=Old+name&seen_name=Old+name").await;
let repo = repo.lock().unwrap();
assert_eq!(loc, "/", "{backend:?}");
assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
assert_eq!(repo.value, theirs, "{backend:?}");
}
}
#[test]
fn merge_folder_rename_is_a_three_way_merge_on_the_name() {
let base = Folder::new("Old", "2024-01-01T00:00:00.000Z");
let with = |name: &str| {
let mut f = base.clone();
f.name = name.to_string();
f.position = Some(3);
f.extra
.insert("color".to_string(), serde_json::json!("#abc"));
f
};
assert_eq!(
merge_folder_rename("Old", Some("Old"), &base, with("Other")),
Ok(FolderMerge::Unchanged)
);
assert_eq!(
merge_folder_rename("New", Some("Old"), &base, with("Old")),
Ok(FolderMerge::Write(with("New")))
);
assert_eq!(
merge_folder_rename("New", Some("Old"), &base, with("New")),
Ok(FolderMerge::AlreadySaved)
);
assert_eq!(
merge_folder_rename("New", Some("Old"), &base, with("Other")),
Err(RenameConflict("name"))
);
assert_eq!(
merge_folder_rename("New", None, &with("Other"), with("Other")),
Ok(FolderMerge::Write(with("New")))
);
assert_eq!(
merge_folder_rename("New", None, &base, with("Other")),
Err(RenameConflict("name"))
);
assert_eq!(
merge_folder_rename(" Old ", Some("Old "), &base, with("Other")),
Ok(FolderMerge::Unchanged)
);
assert_eq!(
merge_folder_rename("New ", Some("Old"), &base, with(" Old ")),
Ok(FolderMerge::Write(with("New")))
);
}
#[test]
fn the_same_change_on_both_sides_is_not_a_conflict() {
let base = merge_base();
let url = "https://a.example/feed.xml";
let new_url = "https://c.example/feed.xml";
let mut fresh = base.clone();
fresh.title = Some("New".to_string());
let merged = merge_rename(&merge_form(url, "New", Some("at://f/old")), &base, fresh)
.expect("same title is no conflict");
assert!(merged.already_saved, "nothing left to write");
let mut fresh = base.clone();
fresh.folder = Some("at://f/new".to_string());
let merged = merge_rename(&merge_form(url, "Mine", Some("at://f/new")), &base, fresh)
.expect("same folder is no conflict");
assert!(!merged.already_saved, "the title is still to write");
assert_eq!(merged.sub.title.as_deref(), Some("Mine"));
assert_eq!(merged.sub.folder.as_deref(), Some("at://f/new"));
let mut fresh = base.clone();
fresh.url = new_url.to_string();
fresh.site_url = Some("https://c.example/".to_string());
let merged = merge_rename(
&merge_form(new_url, "Old", Some("at://f/old")),
&base,
fresh,
)
.expect("same url is no conflict");
assert!(merged.already_saved);
assert!(!merged.repoint);
assert_eq!(merged.sub.site_url.as_deref(), Some("https://c.example/"));
let mut fresh = base.clone();
fresh.site_url = Some("https://same.example/".to_string());
let mut form = merge_form(url, "Old", Some("at://f/old"));
form.site_url = Some("https://same.example/".to_string());
let merged = merge_rename(&form, &base, fresh).expect("same siteUrl is no conflict");
assert!(merged.already_saved);
let merged = merge_rename(
&merge_form(url, "Old", Some("at://f/old")),
&base,
base.clone(),
)
.unwrap();
assert!(!merged.already_saved);
}
fn merge_form(url: &str, title: &str, folder: Option<&str>) -> RenameSubForm {
RenameSubForm {
url: url.to_string(),
title: Some(title.to_string()),
site_url: None,
folder: folder.map(str::to_string),
seen_url: None,
seen_title: None,
seen_folder: None,
}
}
fn merge_base() -> Subscription {
let mut s = Subscription::new("https://a.example/feed.xml", "2024-03-01T00:00:00.000Z");
s.title = Some("Old".to_string());
s.folder = Some("at://f/old".to_string());
s.site_url = Some("https://a.example/".to_string());
s
}
#[test]
fn merge_rename_is_a_three_way_merge_per_field() {
let base = merge_base();
let url = "https://a.example/feed.xml";
let mut theirs = base.clone();
theirs.folder = Some("at://f/theirs".to_string());
assert_eq!(
merge_rename(
&merge_form(url, "Old", Some("at://f/mine")),
&base,
theirs.clone()
),
Err(RenameConflict("folder"))
);
let merged = merge_rename(
&merge_form(url, "Old", Some("at://f/mine")),
&base,
base.clone(),
)
.unwrap();
assert_eq!(merged.sub.folder.as_deref(), Some("at://f/mine"));
assert!(!merged.repoint);
let merged =
merge_rename(&merge_form(url, "Old", Some("at://f/old")), &base, theirs).unwrap();
assert_eq!(merged.sub.folder.as_deref(), Some("at://f/theirs"));
let mut moved = base.clone();
moved.url = "https://b.example/feed.xml".to_string();
assert_eq!(
merge_rename(
&merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
&base,
moved
),
Err(RenameConflict("url"))
);
let mut resited = base.clone();
resited.site_url = Some("https://theirs.example/".to_string());
let mut form = merge_form(url, "Old", Some("at://f/old"));
form.site_url = Some("https://mine.example/".to_string());
assert_eq!(
merge_rename(&form, &base, resited),
Err(RenameConflict("siteUrl"))
);
let merged = merge_rename(
&merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
&base,
base.clone(),
)
.unwrap();
assert!(merged.repoint);
assert_eq!(merged.sub.url, "https://c.example/feed.xml");
assert_eq!(merged.sub.site_url, None);
let mut untitled = base.clone();
untitled.title = None;
let mut form = merge_form(url, "A display fallback", Some("at://f/old"));
form.seen_title = Some("A display fallback".to_string());
let merged = merge_rename(&form, &untitled, untitled.clone()).unwrap();
assert_eq!(
merged.sub.title, None,
"an untouched display title was written"
);
}
#[tokio::test]
async fn renaming_preserves_the_fields_the_form_never_carries() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let bodies = puts.lock().unwrap().clone();
assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
let body = &bodies[0];
assert!(
body.contains("community.lexicon.rss.subscription"),
"captured no usable put body: {body:?}"
);
let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
let record = &sent["record"];
assert_eq!(record["title"], "New title", "the rename did not apply");
assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
assert_eq!(
record["createdAt"], "2024-03-01T00:00:00.000Z",
"the rename reset createdAt — the reader's subscribe time is gone \
from their own repo, and nothing told them"
);
assert_eq!(
record["siteUrl"], "https://example.com/blog",
"the rename erased siteUrl"
);
assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
assert_eq!(record["private"], false, "the rename erased private");
}
#[tokio::test]
async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let bodies = puts.lock().unwrap().clone();
assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
assert!(
bodies[0].contains("community.lexicon.rss.subscription"),
"captured no usable put body: {:?}",
bodies[0]
);
let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
let record = &sent["record"];
assert_eq!(record["url"], "https://other.example/feed.xml");
assert!(
record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
"the old feed's site link followed the subscription to a new feed: {record}"
);
assert!(
record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
"the old feed's fetch hint followed the subscription to a new feed: {record}"
);
assert_eq!(
record["createdAt"], "2024-03-01T00:00:00.000Z",
"a repoint is still not a new subscription; createdAt must not move"
);
assert_eq!(record["private"], false, "the repoint erased private");
}
#[tokio::test]
async fn renaming_an_unknown_rkey_writes_nothing() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-does-not-exist/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("flash="),
"an unknown rkey redirected as though the rename had worked: {loc}"
);
assert!(
puts.lock().unwrap().is_empty(),
"a rename against an unknown rkey wrote a record — putRecord would \
CREATE it, dated today: {:?}",
puts.lock().unwrap()
);
}
#[tokio::test]
async fn a_client_supplied_site_url_reaches_the_record() {
let did = "did:plc:renamer4";
let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
&site_url=https%3A%2F%2Ftyped.example%2Fsite",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let bodies = puts.lock().unwrap().clone();
assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
assert!(
bodies[0].contains("community.lexicon.rss.subscription"),
"captured no usable put body: {:?}",
bodies[0]
);
let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
assert_eq!(
sent["record"]["siteUrl"], "https://typed.example/site",
"the client's siteUrl was dropped; the seeded record's survived instead"
);
}
#[tokio::test]
async fn a_rename_whose_read_fails_writes_nothing() {
let did = "did:plc:renamer5";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let dead = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let state = test_state_with_sidecar(&[did], &dead).await;
let cookie = session_cookie(&state, did, None);
let before = store::count_feeds(&state.db).await.unwrap();
let resp = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/subscriptions/rk-keep/rename")
.header(header::COOKIE, cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert!(
loc.contains("flash="),
"a failed read redirected as though the rename had worked: {loc}"
);
assert_eq!(
store::count_feeds(&state.db).await.unwrap(),
before,
"a rename that could not read the record still wrote to the cache"
);
}
#[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 {
card: Card::private(&Config::default()),
version: VERSION,
repo_url: REPO_URL,
kofi_url: KOFI_URL,
flash: String::new(),
alert: 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],
standard_site: false,
};
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}"
);
for want in [
r#"<input type="hidden" name="seen_url" value="https://work.example/feed.xml" />"#,
r#"<input type="hidden" name="seen_title" value="Work Feed" />"#,
r#"<input type="hidden" name="seen_folder" value="at://did:plc:x/app.folder/work" />"#,
r#"<input type="hidden" name="seen_folder" value="" />"#,
r#"<input type="hidden" name="seen_name" value="Work" />"#,
] {
assert!(html.contains(want), "missing {want}: {html}");
}
}
#[tokio::test]
async fn the_public_stats_page_exposes_no_user_data() {
let state = test_state(&[]).await;
store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
.await
.unwrap();
let resp = router(state)
.oneshot(
Request::builder()
.uri("/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
!body.contains("did:"),
"the public stats page leaked an identifier"
);
for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
assert!(
!body.contains(admin_only),
"the public page is showing the admin metrics column {admin_only:?}"
);
}
assert!(body.contains("Feeds tracked"));
assert!(body.contains("Waiting to be polled"));
}
#[tokio::test]
async fn stats_distinguishes_backoff_from_a_watermark_pause() {
let state = test_state(&[]).await;
for (url, errors) in [
("https://ok.example/f.xml", 0),
("https://flaky.example/f.xml", 2),
("https://dead.example/f.xml", 9),
] {
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
next_poll: Some("2099-01-01T00:00:00Z".to_string()),
..Default::default()
},
)
.await
.unwrap();
for _ in 0..errors {
store::bump_feed_errors(
&state.db,
url,
feed::FailureKind::Fetch,
"connection refused",
)
.await
.unwrap();
}
}
let render_stats = |state: AppState| async move {
let resp = router(state)
.oneshot(
Request::builder()
.uri("/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap()
};
state.runtime_health.set_schedulers_enabled(true);
state
.runtime_health
.poll_tick_completed(crate::store::now_unix());
let body = render_stats(state.clone()).await;
assert!(
body.contains("Failing"),
"backoff is still invisible on the public page"
);
assert!(
body.contains("2, 1 badly"),
"expected '2, 1 badly' in the failing row; got:\n{}",
body.split("Failing")
.nth(1)
.unwrap_or("")
.chars()
.take(300)
.collect::<String>()
);
assert!(
!body.contains("the poller is not running")
&& !body.contains("the cache is at its size limit")
&& !body.contains("has not completed a round"),
"expected the running state; the page reported a stopped one",
);
state.runtime_health.set_watermark(true);
let paused = render_stats(state.clone()).await;
assert!(
paused.contains("the cache is at its size limit"),
"a watermark pause is still invisible on the public page"
);
for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
assert!(
!paused.contains(leak),
"the public page leaked {leak:?} while reporting failures"
);
}
}
#[tokio::test]
async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
let admin = "did:plc:adminseed";
let state = test_state(&[admin]).await;
store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
.await
.unwrap();
let url = "https://broken.example/f.xml";
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
..Default::default()
},
)
.await
.unwrap();
store::bump_feed_errors(
&state.db,
url,
feed::FailureKind::Fetch,
"SENTINEL_ADMIN_ONLY",
)
.await
.unwrap();
let get = |state: AppState, cookie: Option<String>| async move {
let mut req = Request::builder().uri("/admin/metrics");
if let Some(c) = cookie {
req = req.header(header::COOKIE, c);
}
let resp = router(state)
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
(status, body)
};
let (status, body) = get(state.clone(), None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(
!body.contains("SENTINEL_ADMIN_ONLY"),
"leaked to anonymous: {body}"
);
let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
let (status, body) = get(state.clone(), Some(ordinary)).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a non-admin session was let in"
);
assert!(
!body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
"leaked to a non-admin: {body}",
);
let admin_cookie = session_cookie(&state, admin, None);
let (status, body) = get(state, Some(admin_cookie)).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("SENTINEL_ADMIN_ONLY"),
"admin cannot see it: {body}"
);
}
#[tokio::test]
async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
let admin = "did:plc:adminseed";
let state = test_state(&[admin]).await;
let url = "https://broken.example/f.xml";
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
..Default::default()
},
)
.await
.unwrap();
store::bump_feed_errors(
&state.db,
url,
feed::FailureKind::Fetch,
"SENTINEL_REDIRECT_NO_LOCATION",
)
.await
.unwrap();
let cookie = session_cookie(&state, admin, None);
let resp = router(state.clone())
.oneshot(
Request::builder()
.uri("/admin/metrics")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let admin_body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
"the admin page does not carry the failure detail: {admin_body}",
);
assert!(
admin_body.contains("broken.example"),
"the admin page does not name the failing feed: {admin_body}",
);
let resp = router(state)
.oneshot(
Request::builder()
.uri("/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let public = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
assert!(
!public.contains(secret),
"{secret:?} reached the PUBLIC stats page: {public}",
);
}
}
#[tokio::test]
async fn a_successful_direct_poll_clears_a_stale_failure() {
let state = test_state(&[]).await;
let url = "https://recovered.example/f.xml";
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
..Default::default()
},
)
.await
.unwrap();
store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
.await
.unwrap();
sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
.bind(url)
.execute(&state.db)
.await
.unwrap();
feed::settle_poll(
&state.db,
url,
&feed::PollOutcome::NotModified,
state.config.poll_interval,
)
.await;
let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
"SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
)
.bind(url)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(row.0, 0, "a successful direct poll left the error streak");
assert_eq!(row.1, None, "a successful direct poll left a stale cause");
let next = row.2.expect("next_poll was cleared to NULL");
let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
let delta = parsed
.signed_duration_since(chrono::Utc::now())
.num_seconds();
let cadence = state.config.poll_interval.as_secs() as i64;
assert!(
(cadence - 60..=cadence + 60).contains(&delta),
"expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
);
}
#[tokio::test]
async fn a_failing_direct_poll_is_recorded() {
let state = test_state(&[]).await;
let url = "https://born-broken.example/f.xml";
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
..Default::default()
},
)
.await
.unwrap();
feed::settle_poll(
&state.db,
url,
&feed::PollOutcome::Failed {
backoff: std::time::Duration::from_secs(300),
kind: feed::FailureKind::Parse,
detail: "SENTINEL_BORN_BROKEN".to_string(),
},
state.config.poll_interval,
)
.await;
let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
"SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
)
.bind(url)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(row.0, 1, "a failed first poll was not counted");
assert_eq!(
row.1.as_deref(),
Some("parse"),
"its cause was not recorded"
);
let next = row.2.expect("a failed direct poll left next_poll NULL");
let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
let delta = parsed
.signed_duration_since(chrono::Utc::now())
.num_seconds();
assert!(
(240..=360).contains(&delta),
"expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
);
}
#[tokio::test]
async fn the_failure_breakdown_accounts_for_every_failing_feed() {
let state = test_state(&[]).await;
for url in [
"https://legacy1.example/f.xml",
"https://legacy2.example/f.xml",
] {
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
next_poll: Some("2099-01-01T00:00:00Z".to_string()),
..Default::default()
},
)
.await
.unwrap();
sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
.bind(url)
.execute(&state.db)
.await
.unwrap();
}
store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://known.example/f.xml".to_string(),
next_poll: Some("2099-01-01T00:00:00Z".to_string()),
..Default::default()
},
)
.await
.unwrap();
store::bump_feed_errors(
&state.db,
"https://known.example/f.xml",
feed::FailureKind::Status,
"SENTINEL",
)
.await
.unwrap();
let now = chrono::Utc::now();
let health = store::poll_health(
&state.db,
&now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
&(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
)
.await
.unwrap();
let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
assert_eq!(
counted, health.in_backoff,
"the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
health.in_backoff, health.failure_kinds,
);
assert!(
health
.failure_kinds
.iter()
.any(|(k, n)| k == "unknown" && *n == 2),
"no unknown bucket for the legacy rows: {:?}",
health.failure_kinds,
);
}
#[tokio::test]
async fn the_failure_breakdown_is_ordered_by_count() {
let state = test_state(&[]).await;
for (url, kind, n) in [
("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
("https://s1.example/f.xml", feed::FailureKind::Status, 1),
("https://s2.example/f.xml", feed::FailureKind::Status, 1),
] {
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
next_poll: Some("2099-01-01T00:00:00Z".to_string()),
..Default::default()
},
)
.await
.unwrap();
for _ in 0..n {
store::bump_feed_errors(&state.db, url, kind, "d")
.await
.unwrap();
}
}
let now = chrono::Utc::now();
let health = store::poll_health(
&state.db,
&now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
&(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
)
.await
.unwrap();
let labels: Vec<&str> = health
.failure_kinds
.iter()
.map(|(k, _)| k.as_str())
.collect();
assert_eq!(
labels,
["fetch", "status", "parse"],
"not ordered by count, descending: {:?}",
health.failure_kinds,
);
}
#[tokio::test]
async fn stats_groups_failures_by_cause_without_naming_any_feed() {
let state = test_state(&[]).await;
for (url, kind, detail, errors) in [
(
"https://a.example/f.xml",
feed::FailureKind::Fetch,
"SENTINEL_CONNREFUSED",
3,
),
(
"https://b.example/f.xml",
feed::FailureKind::Fetch,
"SENTINEL_DNSFAIL",
2,
),
(
"https://c.example/f.xml",
feed::FailureKind::Status,
"SENTINEL_404",
1,
),
(
"https://d.example/f.xml",
feed::FailureKind::Parse,
"SENTINEL_UNPARSEABLE",
1,
),
] {
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
next_poll: Some("2099-01-01T00:00:00Z".to_string()),
..Default::default()
},
)
.await
.unwrap();
for _ in 0..errors {
store::bump_feed_errors(&state.db, url, kind, detail)
.await
.unwrap();
}
}
let resp = router(state.clone())
.oneshot(
Request::builder()
.uri("/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
"the cause histogram did not render: {body}",
);
for secret in [
"a.example",
"b.example",
"c.example",
"d.example",
"SENTINEL_CONNREFUSED",
"SENTINEL_DNSFAIL",
"SENTINEL_404",
"SENTINEL_UNPARSEABLE",
] {
assert!(
!body.contains(secret),
"{secret:?} reached the PUBLIC stats page: {body}",
);
}
}
#[tokio::test]
async fn health_checks_the_database_and_reports_the_loops() {
let state = test_state(&[]).await;
let body_of = |state: AppState| async move {
let resp = router(state)
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
(status, body)
};
state
.runtime_health
.set_started_at(chrono::Utc::now().timestamp());
let (status, body) = body_of(state.clone()).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("db: ok"),
"health did not probe the DB: {body}"
);
assert!(
body.contains("uptime:"),
"no uptime — the first thing anyone asks about a container that may \
be restarting: {body}"
);
assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
assert!(body.contains("polling-paused: no"), "{body}");
assert!(body.contains("backend:"), "{body}");
assert!(body.contains("oauth-runtime:"), "{body}");
state.runtime_health.set_watermark(true);
state.runtime_health.set_schedulers_enabled(true);
let (status, body) = body_of(state.clone()).await;
assert_eq!(
status,
StatusCode::OK,
"a watermark pause must not fail the liveness check: {body}"
);
assert!(body.contains("polling-paused: yes"), "{body}");
assert!(
body.contains("poller: not-yet-ticked"),
"a never-ticked poller must say so: {body}"
);
let stale_after = health_tick_stale_secs(configured_poll_tick());
let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
state.runtime_health.poll_tick_completed(long_ago);
let (status, body) = body_of(state.clone()).await;
assert_eq!(
status,
StatusCode::OK,
"a stale poller must not 503: {body}"
);
assert!(body.contains("poller: stale"), "{body}");
state.runtime_health.poll_tick_completed(0); state
.runtime_health
.set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
let (status, body) = body_of(state.clone()).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("poller: stale never-ticked"),
"a poller that never ticked long after boot still reads as benign: {body}"
);
state.db.close().await;
let (status, body) = body_of(state.clone()).await;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"an unreachable database must fail the check: {body}"
);
assert!(body.starts_with("FAIL"), "{body}");
assert!(
!body.contains("PoolClosed") && !body.contains("sqlx"),
"health leaked the raw database error to an unauthenticated caller: {body}"
);
}
#[test]
fn the_stale_threshold_follows_the_poll_tick() {
assert_eq!(
health_tick_stale_secs(Duration::from_secs(60)),
HEALTH_TICK_STALE_FLOOR_SECS
);
let slow = Duration::from_secs(30 * 60);
assert!(
health_tick_stale_secs(slow) > slow.as_secs() as i64,
"a 30-minute tick must not be stale after one interval"
);
assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
}
#[tokio::test]
async fn stats_does_not_call_a_stopped_poller_running() {
let state = test_state(&[]).await;
let render = |state: AppState| async move {
let resp = router(state)
.oneshot(
Request::builder()
.uri("/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap()
};
let body = render(state.clone()).await;
assert!(
body.contains("the poller is not running on this instance"),
"a disabled poller renders as healthy"
);
state.runtime_health.set_schedulers_enabled(true);
let body = render(state.clone()).await;
assert!(
body.contains("no poll has finished since this instance booted"),
"a poller that has not ticked renders as healthy"
);
state
.runtime_health
.poll_tick_completed(chrono::Utc::now().timestamp());
let body = render(state.clone()).await;
assert!(
body.contains("running"),
"a healthy poller must read as running"
);
state.runtime_health.set_watermark(true);
let body = render(state.clone()).await;
assert!(
body.contains("the cache is at its size limit"),
"a watermark pause is hidden once the poller is ticking"
);
}
#[tokio::test]
async fn stats_shows_ingest_starved_of_sanitize_permits() {
let mut state = test_state(&[]).await;
let starvation: &'static crate::feed::Starvation =
Box::leak(Box::new(crate::feed::Starvation::new()));
state.sanitize_starvation = starvation;
let render = |state: AppState| async move {
let resp = router(state)
.oneshot(
Request::builder()
.uri("/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap()
};
let body = render(state.clone()).await;
assert!(
!body.contains("no sanitize capacity"),
"the row shows on an instance that never deferred"
);
let ten_min_ago = chrono::Utc::now().timestamp() - 600;
starvation.record_no_permit(ten_min_ago);
starvation.record_no_permit(ten_min_ago + 1);
starvation.record_no_permit(ten_min_ago + 2);
let body = render(state.clone()).await;
assert!(body.contains("3 polls deferred since boot"), "{body}");
assert!(
body.contains(
"<strong>stalled</strong> — no feed body has been sanitized since 10m ago"
),
"a starved instance does not read as stalled"
);
starvation.record_permit();
let body = render(state).await;
assert!(body.contains("3 polls deferred since boot"));
assert!(
!body.contains("<strong>stalled</strong> — no feed body"),
"a permit came free but /stats still reads stalled"
);
}
#[tokio::test]
async fn health_reports_an_unmeasured_database_without_failing() {
use crate::runtime_health::DbProbe;
let state = test_state(&[]).await;
let held = state
.runtime_health
.begin_db_probe()
.unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
let resp = router(state.clone())
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
drop(held);
assert_eq!(
status,
StatusCode::OK,
"an unmeasured database failed the check, which an unauthenticated \
caller can cause on demand: {body}"
);
assert!(
body.contains("db: unknown"),
"the unmeasured state must still be REPORTED: {body}"
);
assert!(!body.starts_with("FAIL"), "{body}");
assert!(
!body.starts_with("ok"),
"the unmeasured state is indistinguishable from healthy to a \
body-matching monitor: {body}"
);
assert!(body.starts_with("unknown"), "{body}");
let held = state
.runtime_health
.begin_db_probe()
.unwrap_or_else(|_| panic!("claim"));
state
.runtime_health
.record_for_test(DbProbe::Failed("unavailable".to_string()));
let resp = router(state.clone())
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
drop(held);
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"a BORROWED failure verdict must fail the check, not just a freshly \
measured one: {body}"
);
assert!(body.starts_with("FAIL"), "{body}");
state.db.close().await;
let resp = router(state.clone())
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a measured database failure must still fail the check"
);
}
#[tokio::test]
async fn an_abandoned_request_still_records_its_probe() {
use crate::runtime_health::DbProbe;
let state = test_state(&[]).await;
let rh = state.runtime_health.clone();
let app = router(state.clone());
let fut = app.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
);
let handle = tokio::spawn(fut);
handle.abort();
let _ = handle.await;
for _ in 0..50 {
if rh.begin_db_probe().is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let resp = router(state.clone())
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.contains("db: ok"),
"after an abandoned request the next caller still reads an \
unmeasured database — the probe was cancelled with it: {body}"
);
assert_ne!(DbProbe::Unknown, DbProbe::Ok);
}
#[tokio::test]
async fn the_health_probe_opens_a_real_table() {
use sqlx::Row;
let state = test_state(&[]).await;
let opcodes = |sql: &'static str| {
let db = state.db.clone();
async move {
sqlx::query(sql)
.fetch_all(&db)
.await
.unwrap()
.into_iter()
.map(|r| r.get::<String, _>("opcode"))
.collect::<Vec<String>>()
}
};
let explain: &'static str =
Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
let probe = opcodes(explain).await;
assert!(
health_db_probe(&state.db).await.is_ok(),
"the probe does not run against the real schema",
);
assert!(
probe.iter().any(|op| op == "OpenRead"),
"the health probe reads no page; it cannot detect a broken database: {probe:?}"
);
let bare = opcodes("EXPLAIN SELECT 1").await;
assert!(
!bare.iter().any(|op| op == "OpenRead"),
"premise check failed: bare SELECT 1 now reads a page: {bare:?}"
);
}
#[test]
fn an_instance_that_has_never_polled_says_so() {
assert_eq!(humanise_ago(None), "never");
assert_eq!(humanise_ago(Some(0)), "0s ago");
assert_eq!(humanise_ago(Some(59)), "59s ago");
assert_eq!(humanise_ago(Some(60)), "1m ago");
assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
}
async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> 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 (url, title) = (saved_url.to_string(), saved_title.to_string());
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 8192];
let Ok(n) = sock.read(&mut buf).await else {
continue;
};
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let wants_saved = req.contains("community.lexicon.rss.saved");
let records = if wants_saved {
serde_json::json!([{
"uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
"cid": "bafy",
"value": {
"$type": "community.lexicon.rss.saved",
"url": url,
"title": title,
"createdAt": "2026-01-01T00:00:00Z"
}
}])
} else {
serde_json::json!([])
};
let body = serde_json::json!({
"ok": true, "data": { "records": records }
})
.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
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
}
});
format!("http://{addr}")
}
async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
let feed = subscribed_feed.to_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();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 8192];
let Ok(read) = sock.read(&mut buf).await else {
continue;
};
let req = String::from_utf8_lossy(&buf[..read]).to_string();
let records = if req.contains("community.lexicon.rss.saved") {
serde_json::Value::Array(
(0..n)
.map(|i| {
serde_json::json!({
"uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
"cid": "bafy",
"value": {
"$type": "community.lexicon.rss.saved",
"url": format!("https://elsewhere.example/{i}"),
"title": format!("Elsewhere {i}"),
"createdAt": "2026-01-01T00:00:00Z"
}
})
})
.collect(),
)
} else if req.contains("community.lexicon.rss.subscription") {
serde_json::json!([{
"uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
"cid": "bafy",
"value": {
"$type": "community.lexicon.rss.subscription",
"url": feed,
"createdAt": "2026-01-01T00:00:00Z"
}
}])
} else {
serde_json::json!([])
};
let body =
serde_json::json!({ "ok": true, "data": { "records": records } }).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
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
let did = "did:plc:pagerloop";
let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
let state = test_state_with_sidecar(&[], &sidecar).await;
store::grant_access(&state.db, did, None, "test", None)
.await
.unwrap();
let feed = store::upsert_feed(
&state.db,
&store::NewFeed {
url: "https://loop.example/feed.xml".to_string(),
title: Some("Loop".to_string()),
..Default::default()
},
)
.await
.unwrap();
let entries: Vec<store::NewEntry> = (0..250)
.map(|i| store::NewEntry {
guid: format!("s-{i:04}"),
url: Some(format!("https://loop.example/{i}")),
title: Some(format!("Starred {i:04}")),
published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
..Default::default()
})
.collect();
store::insert_entries(&state.db, feed, &entries, 0)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed])
.await
.unwrap();
for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
.await
.unwrap()
{
store::mark_starred(&state.db, did, row.id, true)
.await
.unwrap();
}
let cookie = session_cookie(&state, did, None);
let app = router(state.clone());
let get = |uri: &str| {
let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
async move {
let resp = app
.oneshot(
Request::builder()
.uri(uri)
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
String::from_utf8(
axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
.await
.unwrap()
.to_vec(),
)
.unwrap()
}
};
let p3 = get("/?view=starred&page=3").await;
assert!(
p3.contains("Page 3 of 4"),
"the pager and the clamp disagree on the total: {}",
p3.split("pager-pos")
.nth(1)
.unwrap_or("")
.chars()
.take(120)
.collect::<String>()
);
assert!(
p3.contains("Elsewhere 0"),
"page 3 should start the uncached run"
);
assert_eq!(
p3.matches("<li class=\"entry").count(),
ENTRIES_PER_PAGE as usize,
"the boundary page is not full"
);
{
let body = &p3;
assert!(
body.contains("330 entries"),
"the heading must count the whole sequence: {}",
body.split("content-count")
.nth(1)
.unwrap_or("")
.chars()
.take(120)
.collect::<String>()
);
assert!(
body.contains("(80 saved elsewhere)"),
"the heading must say how many of the total the cache cannot show, \
as a whole-list figure and not a per-page one: {}",
body.split("content-count")
.nth(1)
.unwrap_or("")
.chars()
.take(120)
.collect::<String>()
);
assert!(
!body.contains("plus 50") && !body.contains("plus 80"),
"the heading is adding the uncached rows to a total that already \
includes them"
);
}
let p4 = get("/?view=starred&page=4").await;
assert!(
p4.contains("Page 4 of 4"),
"page 4 was advertised but clamps somewhere else — the unreachable-page bug"
);
assert_eq!(
p4.matches("<li class=\"entry").count(),
30,
"page 4 should hold the remaining 30 uncached records"
);
assert!(
p4.contains("Elsewhere 79"),
"the LAST saved record is unreachable — it can only be removed from here"
);
assert!(
!p4.contains("Elsewhere 0"),
"an uncached record was rendered on more than one page"
);
let first = get("/?view=starred").await;
assert!(
first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
"the heading changed between pages; it describes the list, not the page"
);
assert!(
!first.contains("Elsewhere "),
"uncached saved records leaked onto the first page"
);
}
#[tokio::test]
async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
let sidecar =
spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
let mut state = test_state_with_sidecar(&[did], &sidecar).await;
std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
let resp = router(state)
.oneshot(
Request::builder()
.uri("/?view=starred")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.contains("Starred elsewhere"),
"the saved record was not rendered at all"
);
assert!(
body.contains("entry-uncached"),
"it was not marked as uncached, so it looks like a normal entry"
);
assert!(
body.contains("https://elsewhere.example/article"),
"the row must link straight to the article"
);
assert!(
!body.contains("/entries/0/"),
"an uncached row must not offer entry actions against a nonexistent id"
);
}
#[test]
fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
for hostile in [
"日本語日本語日本",
"é",
"",
"2026",
"🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
] {
let out = display_date(Some(hostile));
assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
}
assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
assert_eq!(display_date(None), "");
}
#[test]
fn the_unsave_route_is_rate_limited() {
use axum::http::Method;
assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
}
#[tokio::test]
async fn health_reports_a_broken_database() {
let state = test_state(&[]).await;
assert!(
health_db_probe(&state.db).await.is_ok(),
"the fixture was not healthy to begin with",
);
sqlx::query("DROP TABLE feeds")
.execute(&state.db)
.await
.unwrap();
assert!(
health_db_probe(&state.db).await.is_err(),
"the probe reported success against a database missing the table it \
claims to read; `SELECT 1` would do exactly this",
);
let resp = router(state)
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.starts_with("FAIL"),
"/health did not report FAIL for a broken database: {body}",
);
assert!(
!body.contains("db: ok"),
"/health still called the database ok: {body}",
);
}
async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> 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();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 8192];
let Ok(n) = sock.read(&mut buf).await else {
continue;
};
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let wants = |c: &str| req.contains(c);
if fail_on.is_some_and(wants) {
let body = r#"{"ok":false,"error":"ShortList"}"#;
let resp = format!(
"HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
continue;
}
let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
serde_json::json!([{
"uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
"cid": "bafy",
"value": {
"$type": crate::lexicon::nsid::SUBSCRIPTION,
"url": "https://kept.example/feed.xml",
"title": "Kept",
"folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
"createdAt": "2026-01-01T00:00:00Z"
}
}])
} else if wants(crate::lexicon::nsid::FOLDER) {
serde_json::json!([{
"uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
"cid": "bafy",
"value": {
"$type": crate::lexicon::nsid::FOLDER,
"name": "Kept folder",
"createdAt": "2026-01-01T00:00:00Z"
}
}])
} else {
serde_json::json!([])
};
let body =
serde_json::json!({ "ok": true, "data": { "records": records } }).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
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
}
});
format!("http://{addr}")
}
async fn spawn_malformed_sidecar() -> 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();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 8192];
let _ = sock.read(&mut buf).await;
let body = serde_json::json!({ "ok": true, "data": { "records": [
{ "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
{ "cid": "bafy", "value": {} },
]}})
.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
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
}
});
format!("http://{addr}")
}
async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
let cookie = session_cookie(&state, did, None);
let resp = router(state)
.oneshot(
Request::builder()
.uri(uri)
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(status, String::from_utf8_lossy(&body).to_string())
}
#[tokio::test]
async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
let did = "did:plc:displayer";
let state = test_state(&[did]).await;
let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
let feed_id = store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.into(),
title: Some("Quiet Journal".into()),
..Default::default()
},
)
.await
.unwrap();
store::replace_sub_refs(&state.db, did, &[feed_id])
.await
.unwrap();
store::insert_entries(
&state.db,
feed_id,
&[store::NewEntry {
guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
.into(),
url: Some("https://quiet.example/no-summary".into()),
title: Some("A title-only article".into()),
published: Some("2026-07-11T00:00:00Z".into()),
content_html: None,
..Default::default()
}],
0,
)
.await
.unwrap();
let (status, list) = page_body(state.clone(), did, "/?view=all").await;
assert_eq!(status, StatusCode::OK);
assert!(
list.contains("A title-only article"),
"the entry is missing from the list"
);
let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
.bind(feed_id)
.fetch_one(&state.db)
.await
.unwrap();
let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
assert_eq!(
status,
StatusCode::OK,
"the article page failed for an entry with no body"
);
assert!(page.contains("A title-only article"));
assert!(
page.contains("https://quiet.example/no-summary"),
"no link to the original"
);
assert!(
page.contains(r#"<time datetime=""#),
"no date on the article page"
);
}
#[tokio::test]
async fn a_malformed_subscription_record_raises_an_alert() {
let did = "did:plc:alerted";
for page in ["/", "/manage"] {
let sidecar = spawn_malformed_sidecar().await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let (status, body) = page_body(state, did, page).await;
assert_eq!(status, StatusCode::OK, "{page} did not render");
assert!(
body.contains(r#"role="alert""#) && body.contains("could not be read"),
"{page} rendered no alert for a refused subscription list"
);
assert!(
body.contains("1 record(s) in your subscription list"),
"{page} gave the generic alert, not the malformed-record one"
);
}
}
#[tokio::test]
async fn a_healthy_subscription_listing_raises_no_alert() {
let did = "did:plc:exporter";
let sidecar = spawn_export_sidecar(None).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let (status, body) = page_body(state, did, "/").await;
assert_eq!(status, StatusCode::OK);
assert!(
!body.contains("could not be read"),
"a healthy listing raised an alert"
);
}
async fn export_opml_response(
fail_on: Option<&'static str>,
) -> (StatusCode, HeaderMap, String) {
let did = "did:plc:exporter";
let sidecar = spawn_export_sidecar(fail_on).await;
let state = test_state_with_sidecar(&[did], &sidecar).await;
let cookie = session_cookie(&state, did, None);
let resp = router(state)
.oneshot(
Request::builder()
.uri("/opml/export")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let body = String::from_utf8_lossy(
&axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap(),
)
.to_string();
(status, headers, body)
}
#[tokio::test]
async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
let (status, headers, body) =
export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
assert_ne!(
status,
StatusCode::OK,
"a failed subscription walk answered 200: {body}",
);
assert!(
!headers.contains_key(header::CONTENT_DISPOSITION),
"a failed subscription walk still offered a download: {headers:?}",
);
assert!(
!body.contains("<opml"),
"a failed subscription walk still served an OPML document: {body}",
);
}
#[tokio::test]
async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
let (status, headers, body) =
export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
assert_ne!(
status,
StatusCode::OK,
"a failed folder walk answered 200: {body}",
);
assert!(
!headers.contains_key(header::CONTENT_DISPOSITION),
"a failed folder walk still offered a download: {headers:?}",
);
assert!(
!body.contains("<opml"),
"a failed folder walk still served an OPML document: {body}",
);
}
#[tokio::test]
async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
let (status, headers, body) = export_opml_response(None).await;
assert_eq!(
status,
StatusCode::OK,
"a healthy export did not answer 200"
);
assert_eq!(
headers
.get(header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok()),
Some("attachment; filename=\"featherreader-subscriptions.opml\""),
"a healthy export did not offer the download",
);
assert!(
body.contains("https://kept.example/feed.xml"),
"the exported OPML lost the subscription: {body}",
);
assert!(
body.contains("Kept folder"),
"the exported OPML lost the folder: {body}",
);
}
}