1use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57 extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58 http::{header, HeaderMap, StatusCode},
59 middleware::{self, Next},
60 response::{Html, IntoResponse, Redirect, Response},
61 routing::{get, post},
62 Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::safe_link::SafeLink;
74use crate::{feed, store, AppState, Session, VERSION};
75
76#[path = "opml.rs"]
81mod opml;
82
83const SESSION_COOKIE: &str = "fr_session";
85
86const INVITE_COOKIE: &str = "fr_invite";
94
95const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
103
104const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
106
107const INVITE_TTL_SECS: i64 = 1800;
110
111const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
114
115const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
117
118const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
120
121const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
137 script-src 'self'; \
138 style-src 'self' 'unsafe-inline'; \
139 img-src 'self' https: data:; \
140 font-src 'self'; \
141 connect-src 'self'; \
142 form-action 'self'; \
143 base-uri 'self'; \
144 frame-ancestors 'none'; \
145 object-src 'none'";
146
147#[derive(Clone, Debug)]
154struct CurrentUser {
155 did: String,
156 handle: Option<String>,
157 sid: Option<String>,
160}
161
162async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
173 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
174 if let Some(session) = state.sessions.get(&sid) {
175 if store::has_beta_access(&state.db, &session.did)
176 .await
177 .unwrap_or(false)
178 {
179 return Some(CurrentUser {
180 did: session.did,
181 handle: session.handle,
182 sid: Some(sid),
183 });
184 }
185 state.sessions.remove(&sid);
188 }
189 }
190 if let Some(did) = state.config.dev_did.clone() {
193 if store::has_beta_access(&state.db, &did)
194 .await
195 .unwrap_or(false)
196 {
197 return Some(CurrentUser {
198 did,
199 handle: None,
200 sid: None,
201 });
202 }
203 }
204 None
205}
206
207async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
209 current_session(state, headers).await.map(|u| u.did)
210}
211
212pub fn router(state: AppState) -> Router {
218 let limiter = RateLimiter::shared();
222 let rl_state = RateLimitState {
226 limiter,
227 trusted_header: state.config.trusted_ip_header.clone(),
228 };
229
230 Router::new()
231 .route("/health", get(health))
232 .route("/about", get(about))
233 .route("/standard-site", get(standard_site))
234 .route("/stats", get(stats))
235 .route("/privacy", get(privacy))
236 .route("/terms", get(terms))
237 .route("/manage", get(manage))
238 .route("/", get(index))
239 .route("/entries/{id}", get(entry_view))
240 .route("/entries/{id}/read", post(mark_read))
241 .route("/entries/{id}/star", post(toggle_star))
242 .route("/saved/{rkey}/delete", post(unsave_record))
243 .route("/read-all", post(mark_all_read))
244 .route("/subscriptions", post(add_subscription))
245 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
246 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
247 .route("/folders", post(create_folder))
248 .route("/folders/{rkey}/rename", post(rename_folder))
249 .route("/folders/{rkey}/delete", post(delete_folder))
250 .route(
253 "/opml",
254 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
255 )
256 .route("/opml/export", get(export_opml))
257 .route("/login", get(login_form).post(login_submit))
258 .route(
259 "/beta/redeem",
260 get(beta_redeem_form).post(beta_redeem_submit),
261 )
262 .route("/claim", get(claim))
265 .route("/bot/claims", post(bot_mint_claim))
268 .route("/admin/invites", post(admin_mint_invites))
269 .route("/admin/metrics", get(admin_metrics))
270 .route("/oauth/client-metadata.json", get(oauth_client_metadata))
271 .route("/oauth/jwks.json", get(oauth_jwks))
272 .route("/account/delete", post(account_delete))
273 .route("/oauth/callback", get(oauth_callback))
274 .route("/logout", post(logout))
275 .nest_service("/static", ServeDir::new("static"))
276 .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
280 .layer(middleware::from_fn(cache_control))
285 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
288 .layer(TraceLayer::new_for_http())
289 .layer(static_header_layer(
293 "content-security-policy",
294 CONTENT_SECURITY_POLICY,
295 ))
296 .layer(static_header_layer("x-content-type-options", "nosniff"))
297 .layer(static_header_layer(
298 "referrer-policy",
299 "strict-origin-when-cross-origin",
300 ))
301 .layer(static_header_layer("x-frame-options", "DENY"))
302 .with_state(state)
303}
304
305const OPML_BODY_LIMIT: usize = 1024 * 1024;
320
321#[cfg(test)]
331const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
332
333#[cfg(test)]
338const _: () = assert!(
339 OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
340 "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
341);
342
343fn static_header_layer(
347 name: &'static str,
348 value: &'static str,
349) -> SetResponseHeaderLayer<header::HeaderValue> {
350 SetResponseHeaderLayer::overriding(
351 header::HeaderName::from_static(name),
352 header::HeaderValue::from_static(value),
353 )
354}
355
356fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
376 use axum::http::Method;
377 if method != Method::POST
385 && !(method == Method::GET
386 && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
387 {
388 return false;
389 }
390 match path {
391 "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
396 | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
397 | "/folders" => true,
398 p => {
401 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
402 || p.starts_with("/saved/")
406 || p.starts_with("/subscriptions/")
407 || p.starts_with("/folders/")
408 }
409 }
410}
411
412#[derive(Clone)]
415struct RateLimitState {
416 limiter: RateLimiter,
417 trusted_header: Option<String>,
420}
421
422#[derive(Clone)]
427struct RateLimiter {
428 inner: std::sync::Arc<Mutex<RateLimiterState>>,
429}
430
431struct RateLimiterState {
433 buckets: HashMap<IpAddr, Bucket>,
434 last_sweep: Instant,
435}
436
437struct Bucket {
439 tokens: f64,
440 last: Instant,
441}
442
443const RATE_BURST: f64 = 20.0;
445const RATE_REFILL_PER_SEC: f64 = 1.0;
447const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
449
450const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
459
460const MAX_RATE_BUCKETS: usize = 10_000;
468
469const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
473
474impl RateLimiter {
475 fn shared() -> Self {
477 Self {
478 inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
479 buckets: HashMap::new(),
480 last_sweep: Instant::now(),
481 })),
482 }
483 }
484
485 fn check(&self, ip: IpAddr) -> bool {
488 self.check_at(ip, Instant::now())
489 }
490
491 fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
494 let mut state = match self.inner.lock() {
495 Ok(m) => m,
496 Err(p) => p.into_inner(),
498 };
499
500 if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
502 state
503 .buckets
504 .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
505 state.last_sweep = now;
506 }
507
508 if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
516 let mut by_age: Vec<(IpAddr, Instant)> =
517 state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
518 by_age.sort_unstable_by_key(|(_, last)| *last);
519 for (victim, _) in by_age
520 .into_iter()
521 .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
522 {
523 state.buckets.remove(&victim);
524 }
525 warn!(
526 buckets = state.buckets.len(),
527 "rate-limit bucket cap reached; evicted the least recently seen clients"
528 );
529 }
530
531 let bucket = state.buckets.entry(ip).or_insert(Bucket {
532 tokens: RATE_BURST,
533 last: now,
534 });
535 let elapsed = now.duration_since(bucket.last).as_secs_f64();
536 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
537 bucket.last = now;
538 if bucket.tokens >= 1.0 {
539 bucket.tokens -= 1.0;
540 true
541 } else {
542 false
543 }
544 }
545}
546
547fn client_ip(
568 headers: &HeaderMap,
569 conn: Option<&SocketAddr>,
570 trusted_header: Option<&str>,
571) -> Option<IpAddr> {
572 if let Some(name) = trusted_header {
573 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
574 if let Some(last) = raw.split(',').next_back() {
577 if let Ok(ip) = last.trim().parse::<IpAddr>() {
578 return Some(ip);
579 }
580 }
581 }
582 }
584 conn.map(|s| s.ip())
585}
586
587async fn rate_limit(
592 State(rl): State<RateLimitState>,
593 req: axum::extract::Request,
594 next: Next,
595) -> Response {
596 let path = req.uri().path().to_string();
597 let method = req.method().clone();
598 if is_rate_limited_path(&path, &method) {
599 let conn = req
600 .extensions()
601 .get::<ConnectInfo<SocketAddr>>()
602 .map(|c| c.0);
603 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
604 if let Some(ip) = ip {
610 if !rl.limiter.check(ip) {
611 warn!(%ip, %path, "rate limit exceeded");
612 return (
613 StatusCode::TOO_MANY_REQUESTS,
614 [(header::RETRY_AFTER, "1")],
615 "rate limit exceeded\n",
616 )
617 .into_response();
618 }
619 }
620 }
621 next.run(req).await
622}
623
624async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
635 let path = req.uri().path().to_string();
636 let is_login_landing = path == "/login"
639 && req.method() == axum::http::Method::GET
640 && !req.uri().query().unwrap_or("").contains("handle=");
641 let public = is_login_landing
642 || path == "/about"
643 || path == "/standard-site"
644 || path == "/privacy"
645 || path == "/terms"
646 || path.starts_with("/static/");
647
648 let mut resp = next.run(req).await;
649 if resp.headers().contains_key(header::CACHE_CONTROL) {
650 return resp;
651 }
652 let value = if public {
653 "public, max-age=300"
654 } else {
655 "no-store"
656 };
657 if let Ok(hv) = header::HeaderValue::from_str(value) {
658 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
659 }
660 resp
661}
662
663async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
672 sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
673 .fetch_optional(pool)
674 .await
675}
676
677const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
684
685const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
691
692const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
700
701fn health_tick_stale_secs(tick: Duration) -> i64 {
705 let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
706 tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
707}
708
709fn configured_poll_tick() -> Duration {
713 std::env::var("FEATHERREADER_POLL_TICK_SECS")
714 .ok()
715 .and_then(|v| v.trim().parse::<u64>().ok())
716 .filter(|s| *s > 0)
717 .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
718}
719
720const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
725
726const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
738
739async fn health(State(state): State<AppState>) -> Response {
783 let now = chrono::Utc::now().timestamp();
784 let rh = &state.runtime_health;
785
786 use crate::runtime_health::DbProbe;
787 let db = match rh.begin_db_probe() {
788 Err(borrowed) => borrowed,
791 Ok(probe) => {
792 let pool = state.db.clone();
802 let task = tokio::spawn(async move {
803 let verdict =
813 match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
814 Ok(Ok(_)) => DbProbe::Ok,
815 Ok(Err(err)) => {
820 warn!(%err, "health: database probe failed");
821 DbProbe::Failed("unavailable".to_string())
822 }
823 Err(_) => {
824 warn!(
825 timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
826 "health: database probe timed out (pool exhausted?)"
827 );
828 DbProbe::Failed("timeout".to_string())
829 }
830 };
831 probe.record(verdict.clone());
832 verdict
833 });
834 task.await.unwrap_or(DbProbe::Unknown)
838 }
839 };
840
841 let uptime = rh.uptime_secs(now);
842 let poller = if !rh.schedulers_enabled() {
843 "disabled".to_string()
846 } else {
847 match rh.secs_since_poll_tick(now) {
848 None => match uptime {
851 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
852 format!("stale never-ticked {up}s")
853 }
854 _ => "not-yet-ticked".to_string(),
855 },
856 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
857 format!("stale {secs}s")
858 }
859 Some(secs) => format!("ok {secs}s"),
860 }
861 };
862
863 let mut body = String::new();
872 let status = match &db {
873 DbProbe::Ok => {
874 body.push_str(&format!("ok featherreader/{VERSION}\n"));
875 body.push_str("db: ok\n");
876 StatusCode::OK
877 }
878 DbProbe::Unknown => {
885 body.push_str(&format!("unknown featherreader/{VERSION}\n"));
886 body.push_str("db: unknown (no probe has completed yet)\n");
887 StatusCode::OK
888 }
889 DbProbe::Failed(why) => {
890 body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
891 body.push_str(&format!("db: {why}\n"));
892 StatusCode::SERVICE_UNAVAILABLE
893 }
894 };
895 body.push_str(&format!(
899 "uptime: {}\n",
900 match uptime {
901 Some(secs) => format!("{secs}s"),
902 None => "unknown".to_string(),
903 }
904 ));
905 body.push_str(&format!("poller: {poller}\n"));
906 body.push_str(&format!(
907 "polling-paused: {}\n",
908 if rh.watermark_paused() { "yes" } else { "no" }
909 ));
910 body.push_str(&format!(
917 "backend: {}\n",
918 state.config.repo_backend.as_str()
919 ));
920 body.push_str(&format!(
921 "oauth-runtime: {}\n",
922 if state.oauth.is_some() {
923 "built"
924 } else {
925 "absent"
926 }
927 ));
928
929 let mut resp = (status, body).into_response();
932 if let Ok(hv) = header::HeaderValue::from_str("no-store") {
933 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
934 }
935 resp
936}
937
938async fn about(State(state): State<AppState>) -> Response {
947 let adoption = if state.config.show_adoption {
948 adoption_line(&state).await
949 } else {
950 None
951 };
952 render(&AboutTemplate {
953 card: Card::public(
954 &state.config,
955 "/about",
956 "About — FeatherReader",
957 "What FeatherReader is and isn't: an open-source, atproto-native reader for \
958 RSS feeds and standard.site publications, run as an experiment, free to \
959 self-host under the AGPL.",
960 ),
961 version: VERSION,
962 repo_url: REPO_URL,
963 kofi_url: KOFI_URL,
964 adoption,
965 standard_site: state.config.standard_site,
966 })
967}
968
969async fn standard_site(State(state): State<AppState>) -> Response {
975 render(&StandardSiteTemplate {
976 card: Card::public(
977 &state.config,
978 "/standard-site",
979 "standard.site — FeatherReader",
980 "Read standard.site publications beside your RSS feeds: articles \
981 published as atproto records, followed with the same portable \
982 subscription record.",
983 ),
984 version: VERSION,
985 repo_url: REPO_URL,
986 kofi_url: KOFI_URL,
987 standard_site: state.config.standard_site,
988 releases: RELEASES,
989 })
990}
991
992async fn unsave_record(
1007 State(state): State<AppState>,
1008 headers: HeaderMap,
1009 Path(rkey): Path<String>,
1010) -> Response {
1011 let Some(did) = current_did(&state, &headers).await else {
1012 return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1013 };
1014
1015 let identity = match state.repo().list_saved(&did).await {
1020 Ok(records) => records
1021 .into_iter()
1022 .find(|(k, _)| *k == rkey)
1023 .map(|(_, rec)| (rec.url, rec.entry_id)),
1024 Err(err) => {
1025 warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1026 a local star for the same article may survive");
1027 None
1028 }
1029 };
1030
1031 match state.repo().remove_saved(&did, &rkey).await {
1032 Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1033 Err(err) => {
1034 warn!(%err, %did, %rkey, "could not remove the saved record");
1035 return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1036 }
1037 }
1038
1039 if let Some((url, guid)) = identity {
1043 match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1044 Ok(0) => {}
1045 Ok(n) => {
1046 info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1047 }
1048 Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1049 }
1050 }
1051 if is_htmx(&headers) {
1053 return (StatusCode::OK, "").into_response();
1054 }
1055 Redirect::to("/?view=starred").into_response()
1056}
1057
1058fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1071 if !rh.schedulers_enabled() {
1072 return "off";
1073 }
1074 match rh.secs_since_poll_tick(now_unix) {
1077 None => {
1078 match rh.uptime_secs(now_unix) {
1081 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1082 _ => "starting",
1083 }
1084 }
1085 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1086 _ if rh.watermark_paused() => "paused",
1087 _ => "running",
1088 }
1089}
1090
1091async fn stats(State(state): State<AppState>) -> Response {
1093 let now = chrono::Utc::now();
1094 let health = match store::poll_health(
1095 &state.db,
1096 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1097 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1098 )
1099 .await
1100 {
1101 Ok(health) => health,
1102 Err(err) => {
1103 warn!(%err, "could not compute poll health");
1104 return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1105 }
1106 };
1107
1108 let polled_pct = if health.feeds_tracked == 0 {
1111 100
1112 } else {
1113 health.polled_last_hour * 100 / health.feeds_tracked
1114 };
1115
1116 render(&StatsTemplate {
1117 card: Card::public(
1118 &state.config,
1119 "/stats",
1120 "Stats — FeatherReader",
1121 "Is this instance's poller keeping up? Aggregate feed-polling health — \
1122 counts only; no feed and no reader is named.",
1123 ),
1124 version: VERSION,
1125 repo_url: REPO_URL,
1126 kofi_url: KOFI_URL,
1127 feeds_tracked: health.feeds_tracked,
1128 polled_last_hour: health.polled_last_hour,
1129 polled_pct,
1130 overdue: health.overdue,
1131 last_poll: humanise_ago(health.last_poll_secs_ago),
1132 oldest_poll: if health.never_polled > 0 {
1133 "never".to_string()
1134 } else {
1135 humanise_ago(health.oldest_poll_secs_ago)
1136 },
1137 never_polled: health.never_polled,
1138 poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1139 in_backoff: health.in_backoff,
1148 badly_broken: health.badly_broken,
1149 failure_kinds: health.failure_kinds,
1150 fetching: fetching_state(&state.runtime_health, now.timestamp()),
1151 })
1152}
1153
1154fn humanise_ago(secs: Option<i64>) -> String {
1159 let Some(secs) = secs else {
1160 return "never".to_string();
1161 };
1162 match secs {
1163 s if s < 60 => format!("{s}s ago"),
1164 s if s < 3600 => format!("{}m ago", s / 60),
1165 s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1166 }
1167}
1168
1169async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1177 match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1178 Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1180 repos: stat.value,
1181 truncated: stat.truncated,
1182 observed_on: stat
1183 .observed_at
1184 .split('T')
1185 .next()
1186 .unwrap_or_default()
1187 .to_string(),
1188 }),
1189 Ok(_) => None,
1190 Err(err) => {
1191 warn!(%err, "about: adoption stat read failed; omitting the line");
1192 None
1193 }
1194 }
1195}
1196
1197async fn privacy(State(state): State<AppState>) -> Response {
1201 render(&PrivacyTemplate {
1202 card: Card::public(
1203 &state.config,
1204 "/privacy",
1205 "Privacy — FeatherReader",
1206 "No account and no tracking: your subscriptions and reading state live in \
1207 your own PDS. What this server caches, for how long, and how the session \
1208 token is handled.",
1209 ),
1210 version: VERSION,
1211 repo_url: REPO_URL,
1212 kofi_url: KOFI_URL,
1213 })
1214}
1215
1216async fn terms(State(state): State<AppState>) -> Response {
1220 render(&TermsTemplate {
1221 card: Card::public(
1222 &state.config,
1223 "/terms",
1224 "Terms — FeatherReader",
1225 "The terms of use: an experimental service offered as-is with no warranty, \
1226 what acceptable use means here, and the AGPL self-host note.",
1227 ),
1228 version: VERSION,
1229 repo_url: REPO_URL,
1230 kofi_url: KOFI_URL,
1231 })
1232}
1233
1234struct FeedView {
1241 rkey: String,
1243 url: String,
1245 title: String,
1246 unread: i64,
1247 selected: bool,
1249 folder: Option<String>,
1254}
1255
1256struct FolderView {
1258 rkey: String,
1260 uri: String,
1262 name: String,
1263 feeds: Vec<FeedView>,
1264 selected: bool,
1266}
1267
1268struct EntryRow {
1270 id: i64,
1271 title: String,
1272 feed_title: String,
1273 published: String,
1274 read: bool,
1275 starred: bool,
1276 link: SafeLink,
1279 cached: bool,
1287 rkey: String,
1289}
1290
1291struct FolderOption {
1293 uri: String,
1294 name: String,
1295}
1296
1297struct Nav {
1302 handle: String,
1304 avatar: String,
1306 view: String,
1308 scope_qs: String,
1311 folders: Vec<FolderView>,
1314 loose_feeds: Vec<FeedView>,
1315 manage_active: bool,
1317}
1318
1319pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1328
1329const SITE_TITLE: &str = "FeatherReader — read, quietly";
1336
1337const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1340standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1341password, no tracking.";
1342
1343const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1348
1349#[derive(Debug, Clone)]
1360pub(crate) struct Card {
1361 pub title: String,
1363 pub description: String,
1366 pub url: String,
1368 pub image: String,
1370 pub private: bool,
1374}
1375
1376impl Card {
1377 fn public(
1379 config: &Config,
1380 path: &str,
1381 title: impl Into<String>,
1382 description: impl Into<String>,
1383 ) -> Self {
1384 let origin = config.public_url.trim_end_matches('/');
1385 Card {
1386 title: title.into(),
1387 description: description.into(),
1388 url: format!("{origin}{path}"),
1389 image: format!("{origin}{SHARE_IMAGE_PATH}"),
1390 private: false,
1391 }
1392 }
1393
1394 fn site(config: &Config) -> Self {
1396 Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1397 }
1398
1399 fn private(config: &Config) -> Self {
1403 Card {
1404 private: true,
1405 ..Card::site(config)
1406 }
1407 }
1408}
1409
1410#[derive(Template)]
1412#[template(path = "index.html")]
1413struct IndexTemplate {
1414 card: Card,
1416 version: &'static str,
1417 repo_url: &'static str,
1418 kofi_url: &'static str,
1419 flash: String,
1420 alert: String,
1423 nav: Nav,
1425 entries: Vec<EntryRow>,
1427 heading: String,
1429 feed_scope: Option<String>,
1431 total: i64,
1439 uncached_total: i64,
1447 page: i64,
1449 page_count: i64,
1451 prev_href: Option<String>,
1453 next_href: Option<String>,
1455}
1456
1457#[derive(Template)]
1459#[template(path = "manage.html")]
1460struct ManageTemplate {
1461 card: Card,
1463 version: &'static str,
1464 repo_url: &'static str,
1465 kofi_url: &'static str,
1466 flash: String,
1467 alert: String,
1469 nav: Nav,
1470 folder_options: Vec<FolderOption>,
1472 folders: Vec<FolderView>,
1474 loose_feeds: Vec<FeedView>,
1475 standard_site: bool,
1481}
1482
1483struct AdoptionLine {
1488 repos: i64,
1490 truncated: bool,
1492 observed_on: String,
1494}
1495
1496#[derive(Template)]
1499#[template(path = "about.html")]
1500struct AboutTemplate {
1501 card: Card,
1503 version: &'static str,
1504 repo_url: &'static str,
1505 kofi_url: &'static str,
1506 adoption: Option<AdoptionLine>,
1507 standard_site: bool,
1510}
1511
1512#[derive(Template)]
1516#[template(path = "standard_site.html")]
1517struct StandardSiteTemplate {
1518 card: Card,
1520 version: &'static str,
1521 repo_url: &'static str,
1522 kofi_url: &'static str,
1523 standard_site: bool,
1526 releases: &'static [Release],
1528}
1529
1530pub(crate) struct Release {
1535 pub(crate) version: &'static str,
1537 pub(crate) date: &'static str,
1539 pub(crate) summary: &'static str,
1541}
1542
1543impl Release {
1544 pub(crate) fn url(&self) -> String {
1546 format!("{REPO_URL}/releases/tag/v{}", self.version)
1547 }
1548
1549 pub(crate) fn changelog_url(&self) -> String {
1553 format!(
1554 "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1555 self.version.replace('.', ""),
1556 self.date
1557 )
1558 }
1559}
1560
1561pub(crate) const RELEASES: &[Release] = &[
1566 Release {
1567 version: "0.4.3",
1568 date: "2026-10-05",
1569 summary: "Two write-path fixes for any PDS: large OPML imports and \
1570 read-state syncs are sent in calls the PDS accepts, and a \
1571 read-state sync that disagreed with the PDS recovers instead \
1572 of failing every round.",
1573 },
1574 Release {
1575 version: "0.4.2",
1576 date: "2026-10-04",
1577 summary: "A public standard.site feature page with this list of recent \
1578 releases, and link cards: a posted feather-reader.com link \
1579 now unfurls with a description and an image.",
1580 },
1581 Release {
1582 version: "0.4.1",
1583 date: "2026-10-04",
1584 summary: "The public pages explain standard.site publications, and the \
1585 subscribe form can submit the DID form of a publication URI, \
1586 which browsers refused in 0.4.0.",
1587 },
1588 Release {
1589 version: "0.4.0",
1590 date: "2026-10-03",
1591 summary: "standard.site support: publications are read from their \
1592 authors' atproto repos as subscriptions, beside RSS, on their \
1593 own polling loop. Every stored field from a feed or a \
1594 publication now has a size bound.",
1595 },
1596];
1597
1598#[derive(Template)]
1610#[template(path = "stats.html")]
1611struct StatsTemplate {
1612 card: Card,
1614 version: &'static str,
1615 repo_url: &'static str,
1616 kofi_url: &'static str,
1617 feeds_tracked: i64,
1618 polled_last_hour: i64,
1619 polled_pct: i64,
1620 overdue: i64,
1621 last_poll: String,
1622 oldest_poll: String,
1623 never_polled: i64,
1624 poll_interval_mins: i64,
1625 in_backoff: i64,
1627 badly_broken: i64,
1631 failure_kinds: Vec<(String, i64)>,
1633 fetching: &'static str,
1637}
1638
1639#[derive(Template)]
1643#[template(path = "privacy.html")]
1644struct PrivacyTemplate {
1645 card: Card,
1647 version: &'static str,
1648 repo_url: &'static str,
1649 kofi_url: &'static str,
1650}
1651
1652#[derive(Template)]
1655#[template(path = "terms.html")]
1656struct TermsTemplate {
1657 card: Card,
1659 version: &'static str,
1660 repo_url: &'static str,
1661 kofi_url: &'static str,
1662}
1663
1664#[derive(Template)]
1667#[template(path = "landing.html")]
1668struct LandingTemplate {
1669 card: Card,
1671 version: &'static str,
1672 repo_url: &'static str,
1673 crates_url: &'static str,
1674 kofi_url: &'static str,
1675 standard_site: bool,
1678 releases: &'static [Release],
1680}
1681
1682#[derive(Template)]
1684#[template(path = "entry.html")]
1685struct EntryTemplate {
1686 card: Card,
1688 version: &'static str,
1689 repo_url: &'static str,
1690 kofi_url: &'static str,
1691 nav: Nav,
1692 id: i64,
1693 title: String,
1694 feed_title: String,
1695 author: Option<String>,
1696 published: String,
1697 url: Option<SafeLink>,
1707 content_html: Option<String>,
1708 read: bool,
1709 starred: bool,
1710 back_qs: String,
1712 prev_id: Option<i64>,
1714 next_id: Option<i64>,
1715 oob: bool,
1717}
1718
1719#[derive(Template)]
1721#[template(path = "entry_row.html")]
1722struct EntryRowTemplate {
1723 e: EntryRow,
1724}
1725
1726#[derive(Template)]
1731#[template(path = "entry_actionbar.html")]
1732struct EntryActionBarTemplate {
1733 id: i64,
1734 read: bool,
1735 starred: bool,
1736 oob: bool,
1738}
1739
1740#[derive(Template)]
1742#[template(path = "login.html")]
1743struct LoginTemplate {
1744 card: Card,
1746 repo_url: &'static str,
1747 error: String,
1748 flash: String,
1751}
1752
1753#[derive(Template)]
1755#[template(path = "beta_redeem.html")]
1756struct BetaRedeemTemplate {
1757 card: Card,
1759 repo_url: &'static str,
1760 error: String,
1761 capacity_full: bool,
1764}
1765
1766fn render<T: Template>(tmpl: &T) -> Response {
1773 match tmpl.render() {
1774 Ok(body) => Html(body).into_response(),
1775 Err(err) => {
1776 warn!(%err, "template render failed");
1777 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1778 }
1779 }
1780}
1781
1782struct WebError {
1787 err: anyhow::Error,
1788 status: StatusCode,
1789}
1790
1791impl<E: Into<anyhow::Error>> From<E> for WebError {
1792 fn from(err: E) -> Self {
1793 WebError {
1794 err: err.into(),
1795 status: StatusCode::INTERNAL_SERVER_ERROR,
1796 }
1797 }
1798}
1799
1800impl WebError {
1801 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1803 WebError {
1804 err: err.into(),
1805 status,
1806 }
1807 }
1808}
1809
1810impl IntoResponse for WebError {
1811 fn into_response(self) -> Response {
1812 warn!(error = %self.err, status = %self.status, "request failed");
1813 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1814 "internal error"
1815 } else {
1816 self.status.canonical_reason().unwrap_or("error")
1817 };
1818 (self.status, body).into_response()
1819 }
1820}
1821
1822fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1827 let status = err.status();
1828 WebError::with_status(err, status)
1829}
1830
1831fn display_title(title: Option<&str>, url: &str) -> String {
1834 if let Some(t) = title {
1835 let t = t.trim();
1836 if !t.is_empty() {
1837 return t.to_string();
1838 }
1839 }
1840 url::Url::parse(url)
1841 .ok()
1842 .and_then(|u| u.host_str().map(str::to_string))
1843 .unwrap_or_else(|| url.to_string())
1844}
1845
1846fn display_handle(handle: Option<&str>, did: &str) -> String {
1849 match handle {
1850 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1851 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1852 }
1853}
1854
1855fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1857 let source = handle
1858 .map(|h| h.trim().trim_start_matches('@'))
1859 .filter(|h| !h.is_empty())
1860 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1861 let letters: String = source
1862 .chars()
1863 .filter(|c| c.is_alphanumeric())
1864 .take(2)
1865 .collect::<String>()
1866 .to_lowercase();
1867 if letters.is_empty() {
1868 "fr".to_string()
1869 } else {
1870 letters
1871 }
1872}
1873
1874fn display_date(published: Option<&str>) -> String {
1877 match published {
1886 Some(p) => p.chars().take(10).collect(),
1887 None => String::new(),
1888 }
1889}
1890
1891fn qenc(s: &str) -> String {
1895 let mut out = String::with_capacity(s.len() * 3);
1896 for b in s.bytes() {
1897 match b {
1898 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1899 out.push(b as char)
1900 }
1901 _ => out.push_str(&format!("%{b:02X}")),
1902 }
1903 }
1904 out
1905}
1906
1907#[derive(Debug, Deserialize, Default)]
1913struct IndexQuery {
1914 #[serde(default)]
1916 feed: Option<String>,
1917 #[serde(default)]
1919 folder: Option<String>,
1920 #[serde(default)]
1922 view: Option<String>,
1923 #[serde(default)]
1925 page: Option<u32>,
1926 #[serde(default)]
1928 flash: Option<String>,
1929}
1930
1931const ENTRIES_PER_PAGE: i64 = 100;
1939
1940fn page_count_for(total: i64) -> i64 {
1943 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1944}
1945
1946const PREV_NEXT_MAX: i64 = 5_000;
1953
1954const STARRED_IDENTITY_MAX: i64 = 20_000;
1962
1963const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1977
1978struct ResolvedSub {
1981 rkey: String,
1982 sub: Subscription,
1983 feed: Option<store::Feed>,
1984}
1985
1986async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1990 resolve_subscriptions_noting(state, did).await.0
1991}
1992
1993fn subscriptions_alert(err: &anyhow::Error) -> String {
2000 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2001 Some(m) => format!(
2002 "{} record(s) in your subscription list could not be read, so it was not \
2003 refreshed. Showing your last-known subscriptions; nothing was removed.",
2004 m.count
2005 ),
2006 None => "Your subscription list could not be read from your PDS just now. \
2007 Showing your last-known subscriptions."
2008 .to_string(),
2009 }
2010}
2011
2012async fn resolve_subscriptions_noting(
2015 state: &AppState,
2016 did: &str,
2017) -> (Vec<ResolvedSub>, Option<String>) {
2018 let pool = &state.db;
2019 let subs = match state.repo().list_subscriptions_sorted(did).await {
2020 Ok(s) => s,
2021 Err(err) => {
2022 let alert = subscriptions_alert(&err);
2023 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2024 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2037 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2038 projection could not be read; rendering an EMPTY \
2039 feed list, which is not the same as having none");
2040 Vec::new()
2041 });
2042 let cached = feeds
2043 .into_iter()
2044 .map(|f| ResolvedSub {
2045 rkey: String::new(),
2046 sub: Subscription::new(f.url.clone(), now_rfc3339()),
2047 feed: Some(f),
2048 })
2049 .collect();
2050 return (cached, Some(alert));
2051 }
2052 };
2053
2054 let mut out = Vec::with_capacity(subs.len());
2070 for (rkey, sub) in subs {
2071 let feed = match store::get_feed_by_url(pool, &sub.url).await {
2072 Ok(Some(f)) => Some(f),
2073 Ok(None) => {
2074 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2085 || feed::classify_feed_privacy(&sub.url).is_private()
2086 {
2087 warn!(
2088 %did,
2089 "skipping cache row for a subscription URL that is private or not http(s)"
2090 );
2091 out.push(ResolvedSub {
2092 rkey,
2093 sub,
2094 feed: None,
2095 });
2096 continue;
2097 }
2098 if let Err(err) = store::upsert_feed(
2106 pool,
2107 &store::NewFeed {
2108 url: sub.url.clone(),
2109 title: sub.title.clone(),
2110 site_url: sub.site_url.clone(),
2111 ..Default::default()
2112 },
2113 )
2114 .await
2115 {
2116 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2117 it will not be polled");
2118 }
2119 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2120 }
2121 Err(err) => {
2122 warn!(%err, url = %sub.url, "get_feed_by_url failed");
2123 None
2124 }
2125 };
2126 out.push(ResolvedSub { rkey, sub, feed });
2127 }
2128 sync_sub_refs(pool, did, &out).await;
2132 (out, None)
2133}
2134
2135async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2139 let feed_ids: Vec<i64> = subs
2140 .iter()
2141 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2142 .collect();
2143 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2144 warn!(%err, %did, "failed to sync sub_ref projection");
2145 }
2146}
2147
2148async fn index(
2151 State(state): State<AppState>,
2152 headers: HeaderMap,
2153 Query(q): Query<IndexQuery>,
2154) -> Result<Response, WebError> {
2155 let user = match current_session(&state, &headers).await {
2156 Some(u) => u,
2157 None => {
2160 return Ok(render(&LandingTemplate {
2161 card: Card::site(&state.config),
2162 version: VERSION,
2163 repo_url: REPO_URL,
2164 crates_url: CRATES_URL,
2165 kofi_url: KOFI_URL,
2166 standard_site: state.config.standard_site,
2167 releases: RELEASES,
2168 }))
2169 }
2170 };
2171 let did = user.did.clone();
2172 let pool = &state.db;
2173
2174 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2175
2176 let view = match q.view.as_deref() {
2178 Some("all") => "all",
2179 Some("starred") => "starred",
2180 _ => "unread",
2181 }
2182 .to_string();
2183 let list_view = list_view_of(q.view.as_deref());
2184
2185 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2187 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2192
2193 let feed_title_by_id = |id: i64| -> String {
2194 subs.iter()
2195 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2196 .map(|s| {
2197 display_title(
2198 s.sub
2199 .title
2200 .as_deref()
2201 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2202 &s.sub.url,
2203 )
2204 })
2205 .unwrap_or_default()
2206 };
2207
2208 let mut uncached: Vec<EntryRow> = Vec::new();
2221 if view == "starred" {
2222 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2251 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2252 Ok(store::StarredIdentities::Truncated) => {
2257 warn!(
2258 %did,
2259 cap = STARRED_IDENTITY_MAX,
2260 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2261 rather than rendering record-deleting buttons for cached articles"
2262 );
2263 None
2264 }
2265 Err(err) => {
2266 warn!(%err, %did, "cached-starred identity lookup failed; \
2267 suppressing uncached saved rows this render");
2268 None
2269 }
2270 };
2271 let identities_ok = identities.is_some();
2278 let identities = identities.unwrap_or_default();
2279 let cached_urls: std::collections::HashSet<&str> = identities
2280 .iter()
2281 .filter_map(|(url, _)| url.as_deref())
2282 .collect();
2283 let cached_guids: std::collections::HashSet<&str> =
2284 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2285
2286 let mut uncached_dropped = 0usize;
2299 match state.repo().list_saved_sorted(&did).await {
2300 Ok(saved) if identities_ok => {
2301 for (rkey, item) in saved {
2302 let known = cached_urls.contains(item.url.as_str())
2303 || item
2304 .entry_id
2305 .as_deref()
2306 .is_some_and(|g| cached_guids.contains(g));
2307 if known {
2308 continue;
2309 }
2310 if let Some(urls) = &scope_urls {
2314 match item.feed_url.as_deref() {
2315 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2316 _ => continue,
2320 }
2321 }
2322 let link = SafeLink::external(&item.url);
2348 if link.is_empty() {
2349 warn!(
2350 %did, %rkey,
2351 "a saved record has an unusable URL; rendering it without a link \
2352 so it can still be removed"
2353 );
2354 }
2355
2356 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2370 uncached_dropped += 1;
2371 continue;
2372 }
2373 if let Some(feed_url) = item.feed_url.as_deref() {
2374 if subs.iter().any(|s| s.sub.url == feed_url) {
2375 let stale_before = (chrono::Utc::now()
2379 - chrono::Duration::from_std(state.config.poll_interval)
2380 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2381 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2382 if let Err(err) =
2383 store::mark_feed_due(pool, feed_url, &stale_before).await
2384 {
2385 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2386 }
2387 }
2388 }
2389 uncached.push(EntryRow {
2390 id: 0,
2391 title: item
2392 .title
2393 .clone()
2394 .filter(|t| !t.trim().is_empty())
2395 .unwrap_or_else(|| {
2403 if link.is_empty() {
2404 format!("Saved item {rkey}")
2405 } else {
2406 item.url.clone()
2407 }
2408 }),
2409 feed_title: item.feed_url.clone().unwrap_or_default(),
2410 published: display_date(Some(&item.created_at)),
2411 read: false,
2412 starred: true,
2413 link,
2417 cached: false,
2418 rkey,
2419 });
2420 }
2421 }
2422 Ok(_) => {}
2424 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2425 }
2426 if uncached_dropped > 0 {
2427 warn!(
2428 %did,
2429 dropped = uncached_dropped,
2430 cap = MAX_UNCACHED_SAVED_ROWS,
2431 "more saved records than this instance will hold in one response; the \
2432 rest are not reachable from here"
2433 );
2434 }
2435 }
2436
2437 let total_cached =
2453 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2454 let uncached_len = uncached.len();
2455 let total = total_cached + uncached_len as i64;
2456 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2461 let offset = (page - 1) * ENTRIES_PER_PAGE;
2462 let source = store::list_entries(
2465 pool,
2466 &did,
2467 list_view,
2468 scope_ids.as_deref(),
2469 ENTRIES_PER_PAGE,
2470 offset,
2471 )
2472 .await?;
2473 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2493 let cached_here = cached_allotment.min(source.len());
2494 let source = if uncached_len == 0 {
2499 &source[..]
2500 } else {
2501 &source[..cached_here]
2502 };
2503 let uncached_page: Vec<EntryRow> = {
2504 let skip = (offset - total_cached).max(0) as usize;
2505 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2506 uncached.into_iter().skip(skip).take(take).collect()
2507 };
2508 let uncached_total = uncached_len as i64;
2511
2512 let entry_scope_qs = {
2514 let mut parts = Vec::new();
2515 if let Some(f) = q.feed.as_deref() {
2516 parts.push(format!("feed={}", qenc(f)));
2517 }
2518 if let Some(f) = q.folder.as_deref() {
2519 parts.push(format!("folder={}", qenc(f)));
2520 }
2521 if view != "unread" {
2522 parts.push(format!("view={}", qenc(&view)));
2523 }
2524 parts.join("&")
2525 };
2526 let entries: Vec<EntryRow> = source
2527 .iter()
2528 .map(|e| EntryRow {
2529 id: e.id,
2530 title: e
2531 .title
2532 .clone()
2533 .filter(|t| !t.trim().is_empty())
2534 .unwrap_or_else(|| "(untitled)".to_string()),
2535 feed_title: feed_title_by_id(e.feed_id),
2536 published: display_date(e.published.as_deref()),
2537 read: e.read,
2542 starred: e.starred,
2543 link: SafeLink::entry(e.id, &entry_scope_qs),
2544 cached: true,
2545 rkey: String::new(),
2546 })
2547 .collect();
2548
2549 let mut entries = entries;
2551 entries.extend(uncached_page);
2552 let entries = entries;
2553
2554 let selected_feed = q.feed.as_deref();
2555 let selected_folder = q.folder.as_deref();
2556
2557 let (folder_views, loose_feeds, _folder_options) =
2559 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2560
2561 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2563 let name = subs
2564 .iter()
2565 .find(|s| s.sub.url == feed_url)
2566 .map(|s| {
2567 display_title(
2568 s.sub
2569 .title
2570 .as_deref()
2571 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2572 &s.sub.url,
2573 )
2574 })
2575 .unwrap_or_else(|| display_title(None, feed_url));
2576 (name, format!("feed={}", qenc(feed_url)))
2577 } else if let Some(folder_uri) = selected_folder {
2578 let name = folder_views
2579 .iter()
2580 .find(|f| f.uri == folder_uri)
2581 .map(|f| f.name.clone())
2582 .unwrap_or_else(|| "Folder".to_string());
2583 (name, format!("folder={}", qenc(folder_uri)))
2584 } else {
2585 let h = match view.as_str() {
2586 "all" => "All",
2587 "starred" => "Starred",
2588 _ => "Unread",
2589 };
2590 (h.to_string(), String::new())
2591 };
2592
2593 let feed_scope = selected_feed.map(str::to_string);
2594 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2595
2596 let page_href = |n: i64| -> String {
2600 let mut parts = Vec::new();
2601 if !entry_scope_qs.is_empty() {
2602 parts.push(entry_scope_qs.clone());
2603 }
2604 if n > 1 {
2605 parts.push(format!("page={n}"));
2606 }
2607 if parts.is_empty() {
2608 "/".to_string()
2609 } else {
2610 format!("/?{}", parts.join("&"))
2611 }
2612 };
2613 let prev_href = (page > 1).then(|| page_href(page - 1));
2614 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2615
2616 let tmpl = IndexTemplate {
2617 card: Card::private(&state.config),
2618 version: VERSION,
2619 repo_url: REPO_URL,
2620 kofi_url: KOFI_URL,
2621 flash: q.flash.unwrap_or_default(),
2622 alert: alert.unwrap_or_default(),
2623 nav,
2624 entries,
2625 heading,
2626 feed_scope,
2627 total,
2628 uncached_total,
2631 page,
2632 page_count: page_count_for(total),
2633 prev_href,
2634 next_href,
2635 };
2636 Ok(render(&tmpl))
2637}
2638
2639#[derive(Debug, Deserialize, Default)]
2641struct ManageQuery {
2642 #[serde(default)]
2643 flash: Option<String>,
2644}
2645
2646async fn manage(
2651 State(state): State<AppState>,
2652 headers: HeaderMap,
2653 Query(q): Query<ManageQuery>,
2654) -> Result<Response, WebError> {
2655 let user = match current_session(&state, &headers).await {
2656 Some(u) => u,
2657 None => return Ok(Redirect::to("/login").into_response()),
2658 };
2659 let did = user.did.clone();
2660
2661 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2662 let (folder_views, loose_feeds, folder_options) =
2663 build_sidebar(&state, &did, &subs, None, None).await;
2664
2665 let nav = build_nav(
2667 &user,
2668 "unread",
2669 String::new(),
2670 folder_views.iter().map(clone_folder_view).collect(),
2671 loose_feeds.iter().map(clone_feed_view).collect(),
2672 true,
2673 );
2674
2675 let tmpl = ManageTemplate {
2676 card: Card::private(&state.config),
2677 version: VERSION,
2678 repo_url: REPO_URL,
2679 kofi_url: KOFI_URL,
2680 flash: q.flash.unwrap_or_default(),
2681 alert: alert.unwrap_or_default(),
2682 nav,
2683 folder_options,
2684 folders: folder_views,
2685 loose_feeds,
2686 standard_site: state.config.standard_site,
2687 };
2688 Ok(render(&tmpl))
2689}
2690
2691fn clone_feed_view(f: &FeedView) -> FeedView {
2694 FeedView {
2695 rkey: f.rkey.clone(),
2696 url: f.url.clone(),
2697 title: f.title.clone(),
2698 unread: f.unread,
2699 selected: f.selected,
2700 folder: f.folder.clone(),
2701 }
2702}
2703
2704fn clone_folder_view(f: &FolderView) -> FolderView {
2705 FolderView {
2706 rkey: f.rkey.clone(),
2707 uri: f.uri.clone(),
2708 name: f.name.clone(),
2709 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2710 selected: f.selected,
2711 }
2712}
2713
2714fn scope_urls_for(
2719 subs: &[ResolvedSub],
2720 feed: Option<&str>,
2721 folder: Option<&str>,
2722) -> Option<Vec<String>> {
2723 if let Some(feed_url) = feed {
2724 Some(vec![feed_url.to_string()])
2725 } else {
2726 folder.map(|folder_uri| {
2727 subs.iter()
2728 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2729 .map(|s| s.sub.url.clone())
2730 .collect()
2731 })
2732 }
2733}
2734
2735fn folder_uri(did: &str, rkey: &str) -> String {
2737 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2738}
2739
2740async fn build_sidebar(
2744 state: &AppState,
2745 did: &str,
2746 subs: &[ResolvedSub],
2747 selected_feed: Option<&str>,
2748 selected_folder: Option<&str>,
2749) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2750 let pool = &state.db;
2751 let unread_counts = store::unread_counts_by_feed(pool, did)
2756 .await
2757 .unwrap_or_else(|err| {
2758 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2759 Default::default()
2760 });
2761 let folders = state
2762 .repo()
2763 .list_folders_sorted(did)
2764 .await
2765 .unwrap_or_default();
2766
2767 let unread_count = |feed_id: Option<i64>| -> i64 {
2768 feed_id
2769 .and_then(|id| unread_counts.get(&id).copied())
2770 .unwrap_or(0)
2771 };
2772 let mk_feed_view = |s: &ResolvedSub| FeedView {
2773 rkey: s.rkey.clone(),
2774 url: s.sub.url.clone(),
2775 title: display_title(
2776 s.sub
2777 .title
2778 .as_deref()
2779 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2780 &s.sub.url,
2781 ),
2782 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2783 selected: selected_feed == Some(s.sub.url.as_str()),
2784 folder: s.sub.folder.clone(),
2785 };
2786
2787 let mut folder_views = Vec::with_capacity(folders.len());
2788 for (rkey, folder) in &folders {
2789 let uri = folder_uri(did, rkey);
2790 let feeds: Vec<FeedView> = subs
2791 .iter()
2792 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2793 .map(mk_feed_view)
2794 .collect();
2795 folder_views.push(FolderView {
2796 rkey: rkey.clone(),
2797 uri: uri.clone(),
2798 name: folder.name.clone(),
2799 feeds,
2800 selected: selected_folder == Some(uri.as_str()),
2801 });
2802 }
2803
2804 let known_uris: std::collections::HashSet<String> =
2805 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2806 let loose_feeds: Vec<FeedView> = subs
2807 .iter()
2808 .filter(|s| {
2809 s.sub
2810 .folder
2811 .as_deref()
2812 .map(|f| !known_uris.contains(f))
2813 .unwrap_or(true)
2814 })
2815 .map(mk_feed_view)
2816 .collect();
2817
2818 let folder_options: Vec<FolderOption> = folders
2819 .iter()
2820 .map(|(rkey, folder)| FolderOption {
2821 name: folder.name.clone(),
2822 uri: folder_uri(did, rkey),
2823 })
2824 .collect();
2825
2826 (folder_views, loose_feeds, folder_options)
2827}
2828
2829fn build_nav(
2831 user: &CurrentUser,
2832 view: &str,
2833 scope_qs: String,
2834 folders: Vec<FolderView>,
2835 loose_feeds: Vec<FeedView>,
2836 manage_active: bool,
2837) -> Nav {
2838 Nav {
2839 handle: display_handle(user.handle.as_deref(), &user.did),
2840 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2841 view: view.to_string(),
2842 scope_qs,
2843 folders,
2844 loose_feeds,
2845 manage_active,
2846 }
2847}
2848
2849#[derive(Debug, Deserialize, Default)]
2856struct EntryQuery {
2857 #[serde(default)]
2858 feed: Option<String>,
2859 #[serde(default)]
2860 folder: Option<String>,
2861 #[serde(default)]
2862 view: Option<String>,
2863}
2864
2865async fn entry_view(
2868 State(state): State<AppState>,
2869 headers: HeaderMap,
2870 Path(id): Path<i64>,
2871 Query(q): Query<EntryQuery>,
2872) -> Result<Response, WebError> {
2873 let user = match current_session(&state, &headers).await {
2874 Some(u) => u,
2875 None => return Ok(Redirect::to("/login").into_response()),
2876 };
2877 let did = user.did.clone();
2878 let pool = &state.db;
2879
2880 let subs = resolve_subscriptions(&state, &did).await;
2884
2885 let entry = match get_entry_by_id(pool, &did, id).await? {
2886 Some(e) => e,
2887 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2888 };
2889
2890 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2891
2892 let read = entry_is_read(pool, &did, id).await?;
2893 let starred = entry_is_starred(pool, &did, id).await?;
2894
2895 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2898
2899 let back_qs = scope_query(&q);
2900
2901 let (folder_views, loose_feeds, _) =
2902 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2903 let nav_view = match q.view.as_deref() {
2904 Some("all") => "all",
2905 Some("starred") => "starred",
2906 _ => "unread",
2907 };
2908 let nav = build_nav(
2909 &user,
2910 nav_view,
2911 back_qs.clone(),
2912 folder_views,
2913 loose_feeds,
2914 false,
2915 );
2916
2917 let tmpl = EntryTemplate {
2918 card: Card::private(&state.config),
2919 version: VERSION,
2920 repo_url: REPO_URL,
2921 kofi_url: KOFI_URL,
2922 nav,
2923 id: entry.id,
2924 title: entry
2925 .title
2926 .clone()
2927 .filter(|t| !t.trim().is_empty())
2928 .unwrap_or_else(|| "(untitled)".to_string()),
2929 feed_title,
2930 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2931 published: display_date(entry.published.as_deref()),
2932 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2933 content_html: entry.content_html.clone(),
2934 read,
2935 starred,
2936 back_qs,
2937 prev_id,
2938 next_id,
2939 oob: false,
2940 };
2941 Ok(render(&tmpl))
2942}
2943
2944async fn neighbors_in_scope(
2947 state: &AppState,
2948 did: &str,
2949 q: &EntryQuery,
2950 current: i64,
2951) -> (Option<i64>, Option<i64>) {
2952 let idx_q = IndexQuery {
2953 feed: q.feed.clone(),
2954 folder: q.folder.clone(),
2955 view: q.view.clone(),
2956 page: None,
2958 flash: None,
2959 };
2960 let ids = list_entry_ids(state, did, &idx_q).await;
2961 let pos = ids.iter().position(|&x| x == current);
2962 match pos {
2963 Some(p) => {
2964 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2965 let next = ids.get(p + 1).copied();
2966 (prev, next)
2967 }
2968 None => (None, None),
2969 }
2970}
2971
2972async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2975 let pool = &state.db;
2976 let subs = resolve_subscriptions(state, did).await;
2977
2978 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2979
2980 store::list_entry_ids(
2986 pool,
2987 did,
2988 list_view_of(q.view.as_deref()),
2989 scoped_feed_ids(&subs, &scope_urls).as_deref(),
2990 PREV_NEXT_MAX,
2991 )
2992 .await
2993 .unwrap_or_else(|err| {
2994 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2995 Vec::new()
2996 })
2997}
2998
2999fn list_view_of(view: Option<&str>) -> store::ListView {
3002 match view {
3003 Some("all") => store::ListView::All,
3004 Some("starred") => store::ListView::Starred,
3005 _ => store::ListView::Unread,
3006 }
3007}
3008
3009fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3015 let urls = scope_urls.as_ref()?;
3016 Some(
3017 subs.iter()
3018 .filter(|s| urls.contains(&s.sub.url))
3019 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3020 .collect(),
3021 )
3022}
3023
3024fn scope_query(q: &EntryQuery) -> String {
3026 let mut parts = Vec::new();
3027 if let Some(f) = q.feed.as_deref() {
3028 parts.push(format!("feed={}", qenc(f)));
3029 }
3030 if let Some(f) = q.folder.as_deref() {
3031 parts.push(format!("folder={}", qenc(f)));
3032 }
3033 if let Some(v) = q.view.as_deref() {
3034 if v != "unread" {
3035 parts.push(format!("view={}", qenc(v)));
3036 }
3037 }
3038 parts.join("&")
3039}
3040
3041#[derive(Debug, Deserialize)]
3047struct ReadForm {
3048 #[serde(default)]
3049 read: Option<String>,
3050}
3051
3052async fn mark_read(
3054 State(state): State<AppState>,
3055 Path(id): Path<i64>,
3056 headers: HeaderMap,
3057 Form(form): Form<ReadForm>,
3058) -> Result<Response, WebError> {
3059 let did = match current_did(&state, &headers).await {
3060 Some(d) => d,
3061 None => return Ok(Redirect::to("/login").into_response()),
3062 };
3063 let pool = &state.db;
3064
3065 let read = matches!(
3066 form.read.as_deref(),
3067 Some("true") | Some("1") | Some("on") | None
3068 );
3069
3070 resolve_subscriptions(&state, &did).await;
3075 if !store::mark_read(pool, &did, id, read).await? {
3076 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3077 }
3078
3079 if !is_htmx(&headers) {
3080 return Ok(Redirect::to("/").into_response());
3081 }
3082
3083 if is_reader_request(&headers) {
3087 let starred = entry_is_starred(pool, &did, id).await?;
3088 return Ok(render(&EntryActionBarTemplate {
3089 id,
3090 read,
3091 starred,
3092 oob: true,
3093 }));
3094 }
3095
3096 let row = build_entry_row(pool, &did, id, Some(read)).await?;
3097 match row {
3098 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3099 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3100 }
3101}
3102
3103#[derive(Debug, Deserialize)]
3109struct StarForm {
3110 #[serde(default)]
3111 starred: Option<String>,
3112}
3113
3114async fn toggle_star(
3120 State(state): State<AppState>,
3121 Path(id): Path<i64>,
3122 headers: HeaderMap,
3123 Form(form): Form<StarForm>,
3124) -> Result<Response, WebError> {
3125 let did = match current_did(&state, &headers).await {
3126 Some(d) => d,
3127 None => return Ok(Redirect::to("/login").into_response()),
3128 };
3129 let pool = &state.db;
3130
3131 let starred = matches!(
3132 form.starred.as_deref(),
3133 Some("true") | Some("1") | Some("on") | None
3134 );
3135
3136 resolve_subscriptions(&state, &did).await;
3140 if !store::mark_starred(pool, &did, id, starred).await? {
3141 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3142 }
3143
3144 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3147 let entry_url = entry.url.clone().unwrap_or_default();
3148 if !entry_url.is_empty() {
3149 if starred {
3150 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3151 saved.title = entry.title.clone();
3152 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3153 saved.entry_id = Some(entry.guid.clone());
3154 match state.repo().add_saved(&did, &saved).await {
3155 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3156 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3157 }
3158 } else {
3159 match state.repo().list_saved(&did).await {
3161 Ok(records) => {
3162 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3163 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3164 warn!(%err, %did, %rkey, "PDS saved delete failed");
3165 }
3166 }
3167 }
3168 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3169 }
3170 }
3171 }
3172 }
3173
3174 if !is_htmx(&headers) {
3175 return Ok(Redirect::to("/").into_response());
3176 }
3177
3178 if is_reader_request(&headers) {
3180 let read = entry_is_read(pool, &did, id).await?;
3181 return Ok(render(&EntryActionBarTemplate {
3182 id,
3183 read,
3184 starred,
3185 oob: true,
3186 }));
3187 }
3188
3189 let row = build_entry_row(pool, &did, id, None).await?;
3190 match row {
3191 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3192 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3193 }
3194}
3195
3196async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3198 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3199 .bind(feed_id)
3200 .fetch_optional(pool)
3201 .await
3202 .ok()
3203 .flatten()
3204}
3205
3206#[derive(Debug, Deserialize, Default)]
3213struct ReadAllQuery {
3214 #[serde(default)]
3215 feed: Option<String>,
3216}
3217
3218async fn mark_all_read(
3221 State(state): State<AppState>,
3222 headers: HeaderMap,
3223 Query(q): Query<ReadAllQuery>,
3224) -> Result<Response, WebError> {
3225 let did = match current_did(&state, &headers).await {
3226 Some(d) => d,
3227 None => return Ok(Redirect::to("/login").into_response()),
3228 };
3229 let pool = &state.db;
3230
3231 resolve_subscriptions(&state, &did).await;
3234
3235 if let Some(feed_url) = q.feed.as_deref() {
3236 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3237 store::mark_feed_read(pool, &did, feed.id, true).await?;
3238 }
3239 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3240 }
3241
3242 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3247 store::mark_feed_read(pool, &did, feed_id, true).await?;
3248 }
3249 Ok(Redirect::to("/").into_response())
3250}
3251
3252const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3262 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3263
3264const EXPORT_INCOMPLETE_REFUSAL: &str =
3271 "Could not read your subscriptions in full, so nothing was exported. Your \
3272 feeds are unchanged — try again, and if it keeps failing the list may be \
3273 larger than this reader can page through.";
3274
3275const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3281 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3282 feeds for now — private-feed support arrives when atproto's private data \
3283 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3284
3285#[derive(Debug, Deserialize)]
3287struct SubscribeForm {
3288 url: String,
3289 #[serde(default)]
3291 folder: Option<String>,
3292}
3293
3294async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3304 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3305 let canonical = format!(
3306 "{}{}",
3307 crate::atproto::AT_URI_PREFIX,
3308 &input[crate::atproto::AT_URI_PREFIX.len()..]
3309 );
3310 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3311 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3312 return Err(unsupported());
3313 }
3314 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3315 uri.authority.clone()
3316 } else {
3317 let handle =
3318 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3323 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3324 .await
3325 .map_err(|err| {
3326 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3327 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3328 })?
3329 };
3330 let url = format!(
3331 "{}{did}/{}/{}",
3332 crate::atproto::AT_URI_PREFIX,
3333 uri.collection,
3334 uri.rkey
3335 );
3336 if !feed::is_storable_feed_url(&url, true) {
3337 return Err(unsupported());
3338 }
3339 Ok(url)
3340}
3341
3342async fn add_subscription(
3344 State(state): State<AppState>,
3345 headers: HeaderMap,
3346 Form(form): Form<SubscribeForm>,
3347) -> Result<Response, WebError> {
3348 let did = match current_did(&state, &headers).await {
3349 Some(d) => d,
3350 None => return Ok(Redirect::to("/login").into_response()),
3351 };
3352 let pool = &state.db;
3353 let input = form.url.trim().to_string();
3354 if input.is_empty() {
3355 return Ok(Redirect::to("/").into_response());
3356 }
3357
3358 let cap = state.config.max_subs_per_did;
3362 if cap > 0 {
3363 match store::count_subscriptions_for_did(pool, &did).await {
3364 Ok(n) if n >= cap => {
3365 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3366 return Ok(Redirect::to(&format!(
3367 "/?flash={}",
3368 qenc(&format!(
3369 "Subscription limit reached ({cap}). Remove a feed before adding another."
3370 ))
3371 ))
3372 .into_response());
3373 }
3374 Ok(_) => {}
3375 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3376 }
3377 }
3378
3379 let is_at_uri = input
3384 .get(..crate::atproto::AT_URI_PREFIX.len())
3385 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3386 let publication_url = if is_at_uri {
3387 if !state.config.standard_site {
3388 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3389 return Ok(
3390 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3391 .into_response(),
3392 );
3393 }
3394 match publication_url_from_paste(&state, &input).await {
3395 Ok(url) => Some(url),
3396 Err(flash) => {
3397 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3398 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3399 }
3400 }
3401 } else {
3402 None
3403 };
3404
3405 if let feed::FeedPrivacy::Private(reason) =
3406 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3407 {
3408 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3409 return Ok(
3410 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3411 );
3412 }
3413
3414 let resolved = match publication_url {
3415 Some(url) => Ok(url),
3416 None => resolve_feed_url(&state.config, &input).await,
3417 };
3418 let feed_url = match resolved {
3419 Ok(u) => u,
3420 Err(err) => {
3421 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3422 return Ok(Redirect::to(&format!(
3423 "/?flash={}",
3424 qenc("Couldn't find a feed at that URL")
3425 ))
3426 .into_response());
3427 }
3428 };
3429
3430 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3434 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3435 return Ok(
3436 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3437 );
3438 }
3439
3440 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3445 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3446 return Ok(
3447 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3448 .into_response(),
3449 );
3450 }
3451
3452 let feeds_cap = state.config.max_feeds_global;
3456 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3457 match store::count_feeds(pool).await {
3458 Ok(n) if n >= feeds_cap => {
3459 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3460 return Ok(Redirect::to(&format!(
3461 "/?flash={}",
3462 qenc(
3463 "This instance is at its feed capacity right now. Please try again later."
3464 )
3465 ))
3466 .into_response());
3467 }
3468 Ok(_) => {}
3469 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3470 }
3471 }
3472
3473 store::upsert_feed(
3474 pool,
3475 &store::NewFeed {
3476 url: feed_url.clone(),
3477 ..Default::default()
3478 },
3479 )
3480 .await?;
3481
3482 if let Ok(client) = feed::build_client() {
3483 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3484 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3485 Ok(outcome) => {
3486 info!(feed = %feed_url, ?outcome, "polled new subscription");
3487 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3491 }
3492 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3493 }
3494 }
3495 }
3496
3497 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3498 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3499 sub.title = feed_row.title.clone();
3500 sub.site_url = feed_row.site_url.clone();
3501 }
3502 sub.folder = form
3503 .folder
3504 .map(|f| f.trim().to_string())
3505 .filter(|f| !f.is_empty());
3506
3507 match state.repo().add_subscription(&did, &sub).await {
3508 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3509 Err(err) => {
3510 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3511 }
3512 }
3513
3514 Ok(Redirect::to("/").into_response())
3515}
3516
3517async fn delete_subscription(
3519 State(state): State<AppState>,
3520 headers: HeaderMap,
3521 Path(rkey): Path<String>,
3522) -> Result<Response, WebError> {
3523 let did = match current_did(&state, &headers).await {
3524 Some(d) => d,
3525 None => return Ok(Redirect::to("/login").into_response()),
3526 };
3527 match state.repo().remove_subscription(&did, &rkey).await {
3528 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3529 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3530 }
3531 Ok(Redirect::to("/").into_response())
3532}
3533
3534#[derive(Debug, Deserialize)]
3536struct RenameSubForm {
3537 url: String,
3538 #[serde(default)]
3539 title: Option<String>,
3540 #[serde(default)]
3541 site_url: Option<String>,
3542 #[serde(default)]
3543 folder: Option<String>,
3544}
3545
3546async fn rename_subscription(
3549 State(state): State<AppState>,
3550 headers: HeaderMap,
3551 Path(rkey): Path<String>,
3552 Form(form): Form<RenameSubForm>,
3553) -> Result<Response, WebError> {
3554 let did = match current_did(&state, &headers).await {
3555 Some(d) => d,
3556 None => return Ok(Redirect::to("/login").into_response()),
3557 };
3558 let feed_url = form.url.trim().to_string();
3559
3560 if feed_url.is_empty() {
3564 return Ok(Redirect::to("/").into_response());
3565 }
3566
3567 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3592 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3593 Err(err) => {
3594 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3595 return Ok(Redirect::to(&format!(
3596 "/?flash={}",
3597 qenc("Could not reach your PDS — nothing was renamed or moved.")
3598 ))
3599 .into_response());
3600 }
3601 };
3602 let Some(existing) = existing else {
3603 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3607 return Ok(Redirect::to(&format!(
3608 "/?flash={}",
3609 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3610 ))
3611 .into_response());
3612 };
3613
3614 let url_changed = existing.url.trim() != feed_url;
3633
3634 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3647 if url_changed && !storable {
3648 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3649 return Ok(
3650 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3651 .into_response(),
3652 );
3653 }
3654
3655 if url_changed {
3661 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3662 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3663 return Ok(
3664 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3665 );
3666 }
3667 }
3668
3669 let feeds_cap = state.config.max_feeds_global;
3674 if url_changed
3675 && feeds_cap > 0
3676 && store::get_feed_by_url(&state.db, &feed_url)
3677 .await?
3678 .is_none()
3679 {
3680 match store::count_feeds(&state.db).await {
3681 Ok(n) if n >= feeds_cap => {
3682 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3683 return Ok(Redirect::to(&format!(
3684 "/?flash={}",
3685 qenc(
3686 "This instance is at its feed capacity right now. Please try again later."
3687 )
3688 ))
3689 .into_response());
3690 }
3691 Ok(_) => {}
3692 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3693 }
3694 }
3695
3696 let mut sub = existing;
3697 sub.url = feed_url;
3698 sub.title = form
3699 .title
3700 .map(|t| t.trim().to_string())
3701 .filter(|t| !t.is_empty());
3702 sub.folder = form
3703 .folder
3704 .map(|f| f.trim().to_string())
3705 .filter(|f| !f.is_empty());
3706 match form
3714 .site_url
3715 .map(|t| t.trim().to_string())
3716 .filter(|t| !t.is_empty())
3717 {
3718 Some(site) => sub.site_url = Some(site),
3719 None if url_changed => sub.site_url = None,
3720 None => {}
3721 }
3722 if url_changed {
3723 sub.fetch_hint = None;
3724 }
3725
3726 let cache_write =
3741 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3742 if !cache_write {
3743 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3744 } else if let Err(err) = store::upsert_feed(
3745 &state.db,
3746 &store::NewFeed {
3747 url: sub.url.clone(),
3748 title: sub.title.clone(),
3749 site_url: sub.site_url.clone(),
3750 ..Default::default()
3751 },
3752 )
3753 .await
3754 {
3755 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3758 }
3759
3760 match state.repo().update_subscription(&did, &rkey, &sub).await {
3768 Ok(res) => {
3769 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3770 Ok(Redirect::to("/").into_response())
3771 }
3772 Err(err) => {
3773 warn!(%err, %did, %rkey, "PDS subscription update failed");
3774 Ok(Redirect::to(&format!(
3775 "/?flash={}",
3776 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3777 ))
3778 .into_response())
3779 }
3780 }
3781}
3782
3783#[derive(Debug, Deserialize)]
3789struct FolderForm {
3790 name: String,
3791}
3792
3793async fn create_folder(
3795 State(state): State<AppState>,
3796 headers: HeaderMap,
3797 Form(form): Form<FolderForm>,
3798) -> Result<Response, WebError> {
3799 let did = match current_did(&state, &headers).await {
3800 Some(d) => d,
3801 None => return Ok(Redirect::to("/login").into_response()),
3802 };
3803 let name = form.name.trim();
3804 if name.is_empty() {
3805 return Ok(Redirect::to("/").into_response());
3806 }
3807 let folder = Folder::new(name.to_string(), now_rfc3339());
3808 match state.repo().add_folder(&did, &folder).await {
3809 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3810 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3811 }
3812 Ok(Redirect::to("/").into_response())
3813}
3814
3815async fn rename_folder(
3817 State(state): State<AppState>,
3818 headers: HeaderMap,
3819 Path(rkey): Path<String>,
3820 Form(form): Form<FolderForm>,
3821) -> Result<Response, WebError> {
3822 let did = match current_did(&state, &headers).await {
3823 Some(d) => d,
3824 None => return Ok(Redirect::to("/login").into_response()),
3825 };
3826 let name = form.name.trim();
3827 if name.is_empty() {
3828 return Ok(Redirect::to("/").into_response());
3829 }
3830 let folder = Folder::new(name.to_string(), now_rfc3339());
3831 match state.repo().rename_folder(&did, &rkey, &folder).await {
3832 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3833 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3834 }
3835 Ok(Redirect::to("/").into_response())
3836}
3837
3838async fn delete_folder(
3841 State(state): State<AppState>,
3842 headers: HeaderMap,
3843 Path(rkey): Path<String>,
3844) -> Result<Response, WebError> {
3845 let did = match current_did(&state, &headers).await {
3846 Some(d) => d,
3847 None => return Ok(Redirect::to("/login").into_response()),
3848 };
3849 match state.repo().remove_folder(&did, &rkey).await {
3850 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3851 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3852 }
3853 Ok(Redirect::to("/").into_response())
3854}
3855
3856async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3860 let parsed =
3861 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3862
3863 let client = feed::build_client()?;
3864 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3868 let final_url = resp.url().clone();
3869 let content_type = resp
3870 .headers()
3871 .get(axum::http::header::CONTENT_TYPE)
3872 .and_then(|v| v.to_str().ok())
3873 .unwrap_or("")
3874 .to_ascii_lowercase();
3875 let raw = crate::net::read_capped(resp).await?;
3878 let body = String::from_utf8_lossy(&raw).into_owned();
3879
3880 let looks_like_feed = content_type.contains("xml")
3881 || content_type.contains("rss")
3882 || content_type.contains("atom")
3883 || content_type.contains("application/feed+json")
3884 || {
3885 let head = body.trim_start();
3886 head.starts_with("<?xml")
3887 || head.starts_with("<rss")
3888 || head.starts_with("<feed")
3889 || head.contains("<rss")
3890 || head.contains("<feed")
3891 };
3892 if looks_like_feed {
3893 return Ok(final_url.to_string());
3894 }
3895
3896 match feed::discover_feed(&body, Some(&final_url)) {
3897 Some(u) => Ok(u.to_string()),
3898 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3899 }
3900}
3901
3902#[derive(Debug, Deserialize, Default)]
3908struct LoginQuery {
3909 #[serde(default)]
3910 handle: Option<String>,
3911 #[serde(default)]
3912 error: Option<String>,
3913 #[serde(default)]
3914 flash: Option<String>,
3915}
3916
3917async fn login_form(
3925 State(state): State<AppState>,
3926 headers: HeaderMap,
3927 Query(q): Query<LoginQuery>,
3928) -> Response {
3929 if let Some(handle) = q
3930 .handle
3931 .map(|h| h.trim().to_string())
3932 .filter(|h| !h.is_empty())
3933 {
3934 if !may_start_oauth(&state, &headers, &handle).await {
3935 return Redirect::to("/beta/redeem").into_response();
3936 }
3937 return start_oauth(&state, &handle).await;
3938 }
3939 render(&LoginTemplate {
3940 card: login_card(&state.config),
3941 repo_url: REPO_URL,
3942 error: q.error.unwrap_or_default(),
3943 flash: q.flash.unwrap_or_default(),
3944 })
3945}
3946
3947async fn login_submit(
3950 State(state): State<AppState>,
3951 headers: HeaderMap,
3952 Form(form): Form<LoginForm>,
3953) -> Response {
3954 let handle = form.handle.trim();
3955 if handle.is_empty() {
3956 return login_error(&state, "Enter your atproto handle.");
3957 }
3958 if !may_start_oauth(&state, &headers, handle).await {
3959 return Redirect::to("/beta/redeem").into_response();
3960 }
3961 start_oauth(&state, handle).await
3962}
3963
3964async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3982 may_start_oauth_with(state, headers, handle, |h| async move {
3986 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3987 .await
3988 .ok()
3989 })
3990 .await
3991}
3992
3993async fn may_start_oauth_with<F, Fut>(
3999 state: &AppState,
4000 headers: &HeaderMap,
4001 handle: &str,
4002 resolve: F,
4003) -> bool
4004where
4005 F: FnOnce(String) -> Fut,
4006 Fut: std::future::Future<Output = Option<String>>,
4007{
4008 if let Some(did) = current_did(state, headers).await {
4010 if store::has_beta_access(&state.db, &did)
4011 .await
4012 .unwrap_or(false)
4013 {
4014 return true;
4015 }
4016 }
4017 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4019 return true;
4020 }
4021 match resolve(handle.to_string()).await {
4025 Some(did) => store::has_beta_access(&state.db, &did)
4026 .await
4027 .unwrap_or(false),
4028 None => {
4029 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4030 false
4031 }
4032 }
4033}
4034
4035async fn start_oauth(state: &AppState, handle: &str) -> Response {
4057 match state.config.repo_backend {
4058 crate::metrics::Backend::Sidecar => {
4059 let url = state.sidecar.login_url(handle, None);
4060 info!(%handle, "redirecting to OAuth sidecar login");
4061 Redirect::to(&url).into_response()
4062 }
4063 crate::metrics::Backend::Rust => {
4064 let Some(runtime) = state.oauth.as_deref() else {
4065 warn!("the rust backend is live but its OAuth runtime is absent");
4066 return login_error(state, "Login is not available right now.");
4067 };
4068 match crate::oauth::login::start(
4069 runtime,
4070 &state.http,
4071 &state.db,
4072 handle,
4073 crate::store::now_unix(),
4074 )
4075 .await
4076 {
4077 Ok(started) => {
4078 info!(%handle, "pushed authorization request; redirecting to the PDS");
4079 let mut resp = Redirect::to(&started.authorize_url).into_response();
4080 set_cookie(
4081 &mut resp,
4082 &cookie::sign_value(
4083 OAUTH_BINDING_COOKIE,
4084 &started.binding_token,
4085 &state.config.cookie_secret,
4086 OAUTH_BINDING_MAX_AGE_SECS,
4087 ),
4088 );
4089 resp
4090 }
4091 Err(err) => {
4092 warn!(%err, %handle, "could not start the OAuth login");
4095 login_error(state, "Could not start login for that handle.")
4096 }
4097 }
4098 }
4099 }
4100}
4101
4102fn clear_binding_cookie(resp: &mut Response) {
4106 set_cookie(
4107 resp,
4108 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4109 );
4110}
4111
4112#[derive(Debug, Deserialize)]
4114struct LoginForm {
4115 handle: String,
4116}
4117
4118#[derive(Debug, Deserialize, Default)]
4127struct CallbackQuery {
4128 #[serde(default)]
4130 session_id: Option<String>,
4131 #[serde(default)]
4133 code: Option<String>,
4134 #[serde(default)]
4135 state: Option<String>,
4136 #[serde(default)]
4137 iss: Option<String>,
4138 #[serde(default)]
4141 response: Option<String>,
4142 #[serde(default)]
4143 error: Option<String>,
4144 #[serde(default)]
4145 error_description: Option<String>,
4146}
4147
4148async fn oauth_callback(
4155 State(state): State<AppState>,
4156 headers: HeaderMap,
4157 Query(q): Query<CallbackQuery>,
4158) -> Response {
4159 let sidecar_shape =
4189 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4190 let sidecar_handoff = sidecar_shape
4191 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4192 if let Some(err) = q.error.clone() {
4193 let slug = crate::oauth::flow::known_error_slug(&err);
4209 warn!(
4210 error = slug,
4211 desc_len = q.error_description.as_deref().map_or(0, str::len),
4212 "OAuth callback returned an error"
4213 );
4214 if sidecar_handoff || state.oauth.is_none() {
4215 return login_error(&state, &format!("Login failed: {slug}"));
4216 }
4217 }
4220
4221 let session = if sidecar_handoff {
4224 let session_id = q.session_id.clone().unwrap_or_default();
4225 match state.sidecar.resolve_session(&session_id).await {
4226 Ok(Some(s)) => s,
4227 Ok(None) => {
4228 warn!("OAuth callback session_id did not resolve (expired/unknown)");
4229 return login_error(&state, "Login session expired — please try again.");
4230 }
4231 Err(err) => {
4232 warn!(%err, "failed to resolve OAuth session via the sidecar");
4233 return login_error(&state, "Login failed talking to the auth service.");
4234 }
4235 }
4236 } else {
4237 let Some(runtime) = state.oauth.as_deref() else {
4238 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4239 return login_error(&state, "Login failed: this login could not be completed.");
4240 };
4241 let params = crate::oauth::flow::CallbackParams {
4242 code: q.code.clone(),
4243 state: q.state.clone(),
4244 iss: q.iss.clone(),
4245 error: q.error.clone(),
4249 error_description: q.error_description.clone(),
4250 response: q.response.clone(),
4251 };
4252 let binding =
4253 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4254 match crate::oauth::login::complete(
4255 runtime,
4256 &state.http,
4257 &state.db,
4258 ¶ms,
4259 binding.as_deref(),
4260 crate::store::now_unix(),
4261 )
4262 .await
4263 {
4264 Ok(done) => crate::atproto::SidecarSession {
4265 did: done.did,
4266 handle: done.handle,
4267 },
4268 Err(err) => {
4269 warn!(%err, "could not complete the OAuth callback");
4272 let mut resp = login_error(&state, "Login failed — please try again.");
4273 clear_binding_cookie(&mut resp);
4274 return resp;
4275 }
4276 }
4277 };
4278
4279 let mut clear_invite = false;
4282 if !store::has_beta_access(&state.db, &session.did)
4283 .await
4284 .unwrap_or(false)
4285 {
4286 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4288 Some(c) => c,
4289 None => {
4290 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4291 return Redirect::to("/beta/redeem").into_response();
4292 }
4293 };
4294 match store::redeem_code(
4295 &state.db,
4296 &code,
4297 &session.did,
4298 session.handle.as_deref(),
4299 state.config.beta_cap,
4300 )
4301 .await
4302 {
4303 Ok(Ok(())) => {
4304 clear_invite = true;
4305 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4306 }
4307 Ok(Err(policy)) => {
4308 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4309 let mut resp = redeem_bounce(&state, &policy).into_response();
4310 clear_invite_cookie(&mut resp);
4312 return resp;
4313 }
4314 Err(err) => {
4315 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4316 return login_error(&state, "Login failed while confirming your invite.");
4317 }
4318 }
4319 }
4320
4321 let sid = state.sessions.create(Session {
4324 did: session.did.clone(),
4325 handle: session.handle.clone(),
4326 });
4327 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4328 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4329
4330 let mut resp = Redirect::to("/").into_response();
4331 set_cookie(&mut resp, &cookie);
4332 clear_binding_cookie(&mut resp);
4333 if clear_invite {
4334 clear_invite_cookie(&mut resp);
4335 }
4336 resp
4337}
4338
4339const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4357
4358async fn flush_before_revoke(state: &AppState, did: &str) {
4371 match tokio::time::timeout(
4372 SIGN_OUT_FLUSH_BUDGET,
4373 crate::readstate::flush_did(state, did),
4374 )
4375 .await
4376 {
4377 Ok(Ok(())) => {}
4378 Ok(Err(err)) => {
4379 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4380 }
4381 Err(_) => warn!(
4382 %did,
4383 budget = ?SIGN_OUT_FLUSH_BUDGET,
4384 "sign-out: final read-state flush timed out; it will park until next sign-in"
4385 ),
4386 }
4387}
4388
4389async fn revoke_everywhere(state: &AppState, did: &str) {
4390 let sidecar_started = std::time::Instant::now();
4400 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4401 Ok(res) => {
4402 info!(%did, revoked = res.revoked, "sidecar session revoked");
4403 true
4404 }
4405 Err(err) => {
4406 warn!(%did, %err, "sidecar revoke failed; continuing");
4407 false
4408 }
4409 };
4410 state.metrics.record(
4411 crate::metrics::Backend::Sidecar,
4412 "oauth_revoke",
4413 sidecar_started.elapsed().as_micros() as u64,
4414 sidecar_ok,
4415 );
4416
4417 if let Some(runtime) = state.oauth.as_deref() {
4418 let revoke_started = std::time::Instant::now();
4419 let outcome = crate::oauth::revoke::sign_out_discovering(
4420 runtime,
4421 &state.http,
4422 &state.db,
4423 did,
4424 crate::store::now_unix(),
4425 )
4426 .await;
4427 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4438 state.metrics.record(
4439 crate::metrics::Backend::Rust,
4440 "oauth_revoke",
4441 revoke_started.elapsed().as_micros() as u64,
4442 revoke_ok,
4443 );
4444 match outcome {
4445 crate::oauth::revoke::Revocation::Revoked => {
4446 info!(%did, "rust OAuth session revoked at the PDS")
4447 }
4448 crate::oauth::revoke::Revocation::NoSession => {}
4449 crate::oauth::revoke::Revocation::Failed(reason) => {
4450 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4451 }
4452 }
4453 }
4454}
4455
4456async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4466 if let Some(user) = current_session(&state, &headers).await {
4467 if let Some(sid) = user.sid {
4470 state.sessions.remove(&sid);
4471 flush_before_revoke(&state, &user.did).await;
4473 revoke_everywhere(&state, &user.did).await;
4474 }
4475 }
4476 let mut resp = Redirect::to("/login").into_response();
4477 set_cookie(
4478 &mut resp,
4479 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4480 );
4481 resp
4482}
4483
4484#[derive(Debug, Deserialize)]
4487struct DeleteAccountForm {
4488 #[serde(default)]
4489 confirm: String,
4490}
4491
4492const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4494
4495async fn account_delete(
4510 State(state): State<AppState>,
4511 headers: HeaderMap,
4512 Form(form): Form<DeleteAccountForm>,
4513) -> Result<Response, WebError> {
4514 let user = match current_session(&state, &headers).await {
4515 Some(u) => u,
4516 None => return Ok(Redirect::to("/login").into_response()),
4517 };
4518 let did = user.did.clone();
4519
4520 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4522 return Ok(Redirect::to(&format!(
4523 "/manage?flash={}",
4524 qenc("Type DELETE to confirm — nothing was deleted.")
4525 ))
4526 .into_response());
4527 }
4528
4529 let counts = store::purge_did_data(&state.db, &did).await?;
4531 info!(
4532 %did,
4533 total = counts.total(),
4534 entry_state = counts.entry_state,
4535 read_cursor = counts.read_cursor,
4536 sub_ref = counts.sub_ref,
4537 beta_access = counts.beta_access,
4538 invite_codes = counts.invite_codes,
4539 "account/delete: local rows purged"
4540 );
4541
4542 revoke_everywhere(&state, &did).await;
4545
4546 if let Some(sid) = user.sid {
4548 state.sessions.remove(&sid);
4549 }
4550 let mut resp = Redirect::to(&format!(
4551 "/login?flash={}",
4552 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4553 ))
4554 .into_response();
4555 set_cookie(
4556 &mut resp,
4557 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4558 );
4559 Ok(resp)
4560}
4561
4562fn login_card(config: &Config) -> Card {
4564 Card::public(
4565 config,
4566 "/login",
4567 "Sign in — FeatherReader",
4568 "Sign in to FeatherReader with your atproto handle. You approve access on \
4569 your own server — no signup, no password.",
4570 )
4571}
4572
4573fn login_error(state: &AppState, msg: &str) -> Response {
4575 render(&LoginTemplate {
4576 card: login_card(&state.config),
4577 repo_url: REPO_URL,
4578 error: msg.to_string(),
4579 flash: String::new(),
4580 })
4581}
4582
4583#[derive(Debug, Deserialize)]
4589struct RedeemForm {
4590 code: String,
4591}
4592
4593async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4596 let full = store::count_beta_access(&state.db)
4597 .await
4598 .map(|n| n >= state.config.beta_cap)
4599 .unwrap_or(false);
4600 render(&BetaRedeemTemplate {
4601 card: redeem_card(&state.config),
4602 repo_url: REPO_URL,
4603 error: String::new(),
4604 capacity_full: full,
4605 })
4606}
4607
4608async fn beta_redeem_submit(
4618 State(state): State<AppState>,
4619 Form(form): Form<RedeemForm>,
4620) -> Response {
4621 let code = form.code.trim().to_uppercase();
4622 if code.is_empty() {
4623 return render(&BetaRedeemTemplate {
4624 card: redeem_card(&state.config),
4625 repo_url: REPO_URL,
4626 error: "Enter your invite code.".to_string(),
4627 capacity_full: false,
4628 });
4629 }
4630
4631 match preflight_code(&state, &code).await {
4632 Ok(()) => {
4633 let cookie = sign_invite(&code, &state.config.cookie_secret);
4634 let mut resp = Redirect::to("/login").into_response();
4635 set_cookie(&mut resp, &cookie);
4636 info!("invite code preflight OK; reserving intent + redirecting to /login");
4637 resp
4638 }
4639 Err(policy) => {
4640 warn!(?policy, "invite code preflight rejected");
4641 redeem_bounce(&state, &policy)
4642 }
4643 }
4644}
4645
4646async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4652 let count = match store::count_beta_access(&state.db).await {
4660 Ok(n) => n,
4661 Err(err) => {
4662 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4663 return Err(store::RedeemError::CapacityFull);
4664 }
4665 };
4666 if count >= state.config.beta_cap {
4667 return Err(store::RedeemError::CapacityFull);
4668 }
4669 let row = sqlx::query_as::<_, (String, i64)>(
4671 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4672 )
4673 .bind(code)
4674 .fetch_optional(&state.db)
4675 .await
4676 .ok()
4677 .flatten();
4678 let (status, expires_at) = match row {
4679 Some(r) => r,
4680 None => return Err(store::RedeemError::NotFound),
4681 };
4682 let now = chrono::Utc::now().timestamp();
4683 match status.as_str() {
4684 "active" if expires_at >= now => Ok(()),
4685 "active" => Err(store::RedeemError::Expired),
4686 "expired" => Err(store::RedeemError::Expired),
4687 _ => Err(store::RedeemError::AlreadyRedeemed),
4689 }
4690}
4691
4692fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4695 use store::RedeemError::*;
4696 let (msg, capacity_full) = match policy {
4697 NotFound => ("That invite code isn't valid.", false),
4698 Expired => ("That invite code has expired.", false),
4699 AlreadyRedeemed => ("That invite code has already been used.", false),
4700 CapacityFull => ("", true),
4701 };
4702 render(&BetaRedeemTemplate {
4703 card: redeem_card(&state.config),
4704 repo_url: REPO_URL,
4705 error: msg.to_string(),
4706 capacity_full,
4707 })
4708}
4709
4710fn redeem_card(config: &Config) -> Card {
4713 Card::public(
4714 config,
4715 "/beta/redeem",
4716 "Redeem an invite — FeatherReader",
4717 "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4718 in with your atproto handle.",
4719 )
4720}
4721
4722#[derive(Debug, Deserialize, Default)]
4724struct MintQuery {
4725 #[serde(default)]
4726 n: Option<u32>,
4727}
4728
4729async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4742 let Some(runtime) = state.oauth.as_deref() else {
4743 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4745 };
4746 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4747}
4748
4749async fn oauth_jwks(State(state): State<AppState>) -> Response {
4756 let Some(runtime) = state.oauth.as_deref() else {
4757 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4758 };
4759 match runtime.client_key.as_ref() {
4760 Some(key) => match key.jwks_document() {
4761 Ok(doc) => axum::Json(doc).into_response(),
4762 Err(err) => {
4763 warn!(%err, "could not render the client JWKS");
4764 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4765 }
4766 },
4767 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4768 }
4769}
4770
4771const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4773
4774async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4783 let did = match current_did(&state, &headers).await {
4784 Some(d) => d,
4785 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4786 };
4787 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4788 warn!(%did, "admin metrics denied: not an admin-seed DID");
4789 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4790 }
4791
4792 if let Err(err) =
4797 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4798 {
4799 warn!(%err, "could not flush repo timings before rendering");
4800 }
4801 let rows = match crate::metrics::persisted_rows(&state.db).await {
4802 Ok(rows) => rows,
4803 Err(err) => {
4804 warn!(%err, "could not read persisted repo timings");
4805 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4806 }
4807 };
4808
4809 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4815 Ok(n) => n.to_string(),
4816 Err(err) => {
4817 warn!(%err, "could not count parked read-state DIDs");
4818 "unknown".to_string()
4819 }
4820 };
4821 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4828 Ok(f) => f,
4829 Err(err) => {
4830 warn!(%err, "could not list failing feeds");
4831 Vec::new()
4832 }
4833 };
4834 let mut failing_block = String::new();
4835 if !failing.is_empty() {
4836 failing_block.push_str("\nfailing feeds (worst first)\n");
4837 for f in &failing {
4838 failing_block.push_str(&format!(
4839 " {:>4}x {:<8} {}\n {}\n",
4840 f.consecutive_errors,
4841 f.kind.as_deref().unwrap_or("unknown"),
4842 f.url,
4843 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4844 ));
4845 }
4846 }
4847
4848 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4853 Ok(n) => n,
4854 Err(err) => {
4855 warn!(%err, "could not count unpollable feeds");
4856 -1
4857 }
4858 };
4859 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4860
4861 let body = format!(
4862 "live backend: {}\nparked read-state DIDs: {}\n\
4863 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4864 state.config.repo_backend.as_str(),
4865 parked,
4866 cached,
4867 state.config.max_feeds_global,
4868 unpollable,
4869 crate::metrics::render(&rows),
4870 failing_block,
4871 );
4872 (StatusCode::OK, body).into_response()
4873}
4874
4875async fn admin_mint_invites(
4879 State(state): State<AppState>,
4880 headers: HeaderMap,
4881 Query(q): Query<MintQuery>,
4882) -> Response {
4883 let did = match current_did(&state, &headers).await {
4886 Some(d) => d,
4887 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4888 };
4889 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4890 warn!(%did, "admin mint denied: not an admin-seed DID");
4891 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4892 }
4893
4894 let n = q.n.unwrap_or(1).clamp(1, 100);
4895 let mut codes = Vec::with_capacity(n as usize);
4896 for _ in 0..n {
4897 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4898 Ok(code) => codes.push(code),
4899 Err(err) => {
4900 warn!(%err, %did, "admin mint_code failed");
4901 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4902 }
4903 }
4904 }
4905 info!(%did, count = codes.len(), "admin minted invite codes");
4906 let mut body = codes.join("\n");
4907 body.push('\n');
4908 (StatusCode::OK, body).into_response()
4909}
4910
4911#[derive(Debug, Deserialize)]
4917struct ClaimQuery {
4918 t: Option<String>,
4920}
4921
4922async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4941 let token = match q.t {
4942 Some(t) if !t.is_empty() => t,
4943 _ => {
4944 warn!("claim link with no token");
4945 return redeem_bounce(&state, &store::RedeemError::NotFound);
4946 }
4947 };
4948
4949 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4952 Some(c) => c,
4953 None => {
4954 warn!("claim token invalid (bad signature / malformed)");
4955 return redeem_bounce(&state, &store::RedeemError::NotFound);
4956 }
4957 };
4958
4959 match preflight_code(&state, &code).await {
4963 Ok(()) => {
4964 let cookie = sign_invite(&code, &state.config.cookie_secret);
4965 let mut resp = Redirect::to("/login").into_response();
4966 set_cookie(&mut resp, &cookie);
4967 info!("claim token preflight OK; reserving intent + redirecting to /login");
4968 resp
4969 }
4970 Err(policy) => {
4971 warn!(?policy, "claim token preflight rejected");
4972 redeem_bounce(&state, &policy)
4973 }
4974 }
4975}
4976
4977#[derive(Debug, Default, Deserialize)]
4984struct BotClaimRequest {
4985 #[serde(default)]
4988 did: Option<String>,
4989 #[serde(default)]
4991 #[allow(dead_code)]
4992 handle: Option<String>,
4993}
4994
4995#[derive(Debug, serde::Serialize)]
4997struct BotClaimResponse {
4998 status: &'static str,
5004 code: String,
5008 token: String,
5011 url: String,
5014}
5015
5016async fn bot_mint_claim(
5044 State(state): State<AppState>,
5045 headers: HeaderMap,
5046 body: axum::body::Bytes,
5047) -> Response {
5048 let bot_secret = match state.config.bot_secret.as_deref() {
5050 Some(s) => s,
5051 None => {
5052 warn!(
5053 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5054 );
5055 return (
5056 StatusCode::SERVICE_UNAVAILABLE,
5057 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5058 )
5059 .into_response();
5060 }
5061 };
5062
5063 let presented = headers
5065 .get("x-bot-secret")
5066 .and_then(|v| v.to_str().ok())
5067 .unwrap_or("");
5068 if !bot_secret_matches(presented, bot_secret) {
5069 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5070 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5071 }
5072
5073 let req: BotClaimRequest = if body.is_empty() {
5076 BotClaimRequest::default()
5077 } else {
5078 match serde_json::from_slice(&body) {
5079 Ok(r) => r,
5080 Err(err) => {
5081 warn!(%err, "POST /bot/claims: bad JSON body");
5082 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5083 }
5084 }
5085 };
5086 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5087
5088 if let Some(did) = follower_did {
5090 match store::has_beta_access(&state.db, did).await {
5092 Ok(true) => {
5093 info!("bot mint: DID already holds beta access; already_seated");
5094 return bot_claim_json(BotClaimResponse {
5095 status: "already_seated",
5096 code: String::new(),
5097 token: String::new(),
5098 url: String::new(),
5099 });
5100 }
5101 Ok(false) => {}
5102 Err(err) => {
5103 warn!(%err, "bot mint: has_beta_access failed");
5105 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5106 }
5107 }
5108 match store::find_active_code_for_did(&state.db, did).await {
5111 Ok(Some(code)) => {
5112 info!("bot mint: existing outstanding claim for DID; returning same code");
5113 let token = sign_claim_token(&code, &state.config.cookie_secret);
5114 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5115 return bot_claim_json(BotClaimResponse {
5116 status: "existing",
5117 code,
5118 token,
5119 url,
5120 });
5121 }
5122 Ok(None) => {}
5123 Err(err) => {
5124 warn!(%err, "bot mint: find_active_code_for_did failed");
5125 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5126 }
5127 }
5128 }
5129
5130 let granted = match store::count_beta_access(&state.db).await {
5133 Ok(n) => n,
5134 Err(err) => {
5135 warn!(%err, "bot mint: count_beta_access failed; failing closed");
5136 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5137 }
5138 };
5139 let outstanding = match store::count_active_codes(&state.db).await {
5140 Ok(n) => n,
5141 Err(err) => {
5142 warn!(%err, "bot mint: count_active_codes failed; failing closed");
5143 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5144 }
5145 };
5146 if granted + outstanding >= state.config.beta_cap {
5147 info!(
5148 granted,
5149 outstanding,
5150 cap = state.config.beta_cap,
5151 "bot mint refused: at capacity"
5152 );
5153 return (
5154 StatusCode::CONFLICT,
5155 [(header::CONTENT_TYPE, "application/json")],
5156 "{\"error\":\"full\"}\n",
5157 )
5158 .into_response();
5159 }
5160
5161 let bot_did = state
5164 .config
5165 .admin_seed_dids()
5166 .first()
5167 .cloned()
5168 .unwrap_or_else(|| "did:bot:featherreader".to_string());
5169 let minted = match follower_did {
5170 Some(did) => {
5171 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5172 }
5173 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5174 };
5175 let code = match minted {
5176 Ok(c) => c,
5177 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5184 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5185 Ok(Some(code)) => {
5186 info!("bot mint: lost the mint race; returning the concurrently-minted code");
5187 let token = sign_claim_token(&code, &state.config.cookie_secret);
5188 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5189 return bot_claim_json(BotClaimResponse {
5190 status: "existing",
5191 code,
5192 token,
5193 url,
5194 });
5195 }
5196 Ok(None) => {
5200 warn!("bot mint: conflict but no active code found on recovery");
5201 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5202 }
5203 Err(err) => {
5204 warn!(%err, "bot mint: recovery lookup after conflict failed");
5205 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5206 }
5207 }
5208 }
5209 Err(err) => {
5210 warn!(%err, "bot mint_code failed");
5211 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5212 }
5213 };
5214 let token = sign_claim_token(&code, &state.config.cookie_secret);
5215 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5216 info!("bot minted a claim code + token");
5217
5218 bot_claim_json(BotClaimResponse {
5219 status: "minted",
5220 code,
5221 token,
5222 url,
5223 })
5224}
5225
5226fn bot_claim_json(resp: BotClaimResponse) -> Response {
5229 match serde_json::to_string(&resp) {
5230 Ok(body) => (
5231 StatusCode::OK,
5232 [(header::CONTENT_TYPE, "application/json")],
5233 body,
5234 )
5235 .into_response(),
5236 Err(err) => {
5237 warn!(%err, "serializing bot claim response failed");
5238 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5239 }
5240 }
5241}
5242
5243fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5248 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5249}
5250
5251fn sign_invite(code: &str, secret: &str) -> String {
5260 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5261}
5262
5263fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5268 cookie::verify_value(headers, INVITE_COOKIE, secret)
5269}
5270
5271const CLAIM_TOKEN_LABEL: &str = "claim-token";
5275
5276fn sign_claim_token(code: &str, secret: &str) -> String {
5288 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5289}
5290
5291fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5296 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5297}
5298
5299fn clear_invite_cookie(resp: &mut Response) {
5302 set_cookie(
5303 resp,
5304 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5305 );
5306}
5307
5308async fn import_opml(
5321 State(state): State<AppState>,
5322 headers: HeaderMap,
5323 mut multipart: Multipart,
5324) -> Result<Response, WebError> {
5325 let did = match current_did(&state, &headers).await {
5326 Some(d) => d,
5327 None => return Ok(Redirect::to("/login").into_response()),
5328 };
5329 let pool = &state.db;
5330
5331 let mut opml_text = String::new();
5337 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5338 let name = field.name().unwrap_or("").to_string();
5339 if name == "opml" || name == "file" {
5340 let bytes = field.bytes().await.map_err(multipart_response)?;
5341 if !bytes.is_empty() {
5342 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5343 if name == "file" {
5344 break;
5345 }
5346 }
5347 }
5348 }
5349
5350 let feeds =
5355 match opml::parse_opml(&opml_text) {
5356 Ok(feeds) => feeds,
5357 Err(err) => {
5358 warn!(%err, %did, "OPML import could not parse the uploaded file");
5359 return Ok(Redirect::to(&format!(
5360 "/?flash={}",
5361 qenc("That file could not be read as OPML. Export it again from your other reader?")
5362 ))
5363 .into_response());
5364 }
5365 };
5366 if feeds.is_empty() {
5367 info!(%did, "OPML import found no feeds");
5368 return Ok(
5369 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5370 .into_response(),
5371 );
5372 }
5373
5374 let now = now_rfc3339();
5377 let mut folder_uris: std::collections::HashMap<String, String> =
5378 std::collections::HashMap::new();
5379 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5381 for (rkey, folder) in existing {
5382 folder_uris
5383 .entry(folder.name.clone())
5384 .or_insert_with(|| folder_uri(&did, &rkey));
5385 }
5386 }
5387 let mut wanted_folders: Vec<String> = feeds
5388 .iter()
5389 .filter_map(|f| f.folder.clone())
5390 .filter(|n| !n.is_empty())
5391 .collect();
5392 wanted_folders.sort();
5393 wanted_folders.dedup();
5394 for name in wanted_folders {
5395 if folder_uris.contains_key(&name) {
5396 continue;
5397 }
5398 let folder = Folder::new(name.clone(), now.clone());
5399 match state.repo().add_folder(&did, &folder).await {
5400 Ok(rkey) => {
5401 folder_uris.insert(name, folder_uri(&did, &rkey));
5402 }
5403 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5404 }
5405 }
5406
5407 let sub_cap = state.config.max_subs_per_did;
5416 let mut headroom: Option<i64> = if sub_cap > 0 {
5417 let existing = store::count_subscriptions_for_did(pool, &did)
5418 .await
5419 .unwrap_or(0);
5420 Some((sub_cap - existing).max(0))
5421 } else {
5422 None
5423 };
5424 let mut trimmed_over_cap: usize = 0;
5425
5426 let feeds_cap = state.config.max_feeds_global;
5433 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5434 let existing = store::count_feeds(pool).await.unwrap_or(0);
5435 Some((feeds_cap - existing).max(0))
5436 } else {
5437 None
5438 };
5439 let mut trimmed_over_global: usize = 0;
5440
5441 let mut subs = Vec::with_capacity(feeds.len());
5442 let mut skipped_private: Vec<String> = Vec::new();
5443 let mut uncached: usize = 0;
5446 let mut skipped_unsupported: usize = 0;
5452 for f in &feeds {
5453 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5461 info!(
5462 %did,
5463 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5464 );
5465 skipped_unsupported += 1;
5466 continue;
5467 }
5468 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5469 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5470 let label = f
5472 .title
5473 .clone()
5474 .filter(|t| !t.trim().is_empty())
5475 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5476 skipped_private.push(label);
5477 continue;
5478 }
5479
5480 if let Some(h) = headroom.as_mut() {
5483 if *h <= 0 {
5484 trimmed_over_cap += 1;
5485 continue;
5486 }
5487 }
5488
5489 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5494 Ok(existing) => existing.is_none(),
5495 Err(err) => {
5498 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5499 false
5500 }
5501 };
5502 if is_new {
5503 if let Some(g) = global_headroom.as_mut() {
5504 if *g <= 0 {
5505 trimmed_over_global += 1;
5506 continue;
5507 }
5508 *g -= 1;
5509 }
5510 }
5511
5512 if let Some(h) = headroom.as_mut() {
5515 *h -= 1;
5516 }
5517
5518 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5519 sub.title = f.title.clone();
5520 sub.site_url = f.site_url.clone();
5521 sub.folder = f
5522 .folder
5523 .as_ref()
5524 .and_then(|name| folder_uris.get(name).cloned());
5525 subs.push(sub);
5526 if let Err(err) = store::upsert_feed(
5532 pool,
5533 &store::NewFeed {
5534 url: f.feed_url.clone(),
5535 title: f.title.clone(),
5536 site_url: f.site_url.clone(),
5537 ..Default::default()
5538 },
5539 )
5540 .await
5541 {
5542 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5543 it will not be polled");
5544 uncached += 1;
5545 }
5546 }
5547
5548 let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5565 Ok(rkeys) => {
5566 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5567 rkeys.len()
5568 }
5569 Err(err) => {
5570 let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
5571 warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
5572 landed
5573 }
5574 };
5575 if landed == 0 && !subs.is_empty() {
5576 return Ok(Redirect::to(&format!(
5577 "/?flash={}",
5578 qenc(
5579 "Could not save those subscriptions to your PDS, so nothing was imported. \
5580 Try again in a moment."
5581 )
5582 ))
5583 .into_response());
5584 }
5585
5586 let mut flash = if landed < subs.len() {
5588 format!(
5589 "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
5590 other {} may not have been saved. Importing the same file again would add the first \
5591 {landed} a second time",
5592 subs.len(),
5593 subs.len() - landed
5594 )
5595 } else {
5596 format!("Imported {} feeds", subs.len())
5597 };
5598 if uncached > 0 {
5599 flash.push_str(&format!(
5600 ". {uncached} of them could not be cached locally and may not update until the next import."
5601 ));
5602 }
5603 if trimmed_over_cap > 0 {
5604 flash.push_str(&format!(
5605 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5606 ));
5607 }
5608 if trimmed_over_global > 0 {
5609 flash.push_str(&format!(
5610 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5611 ));
5612 }
5613 if !skipped_private.is_empty() {
5614 flash.push_str(&format!(
5615 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5616 skipped_private.len(),
5617 skipped_private.join(", ")
5618 ));
5619 }
5620 if skipped_unsupported > 0 {
5621 flash.push_str(&format!(
5624 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5625 ));
5626 }
5627 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5628}
5629
5630fn private_feed_label(url: &str) -> String {
5633 url::Url::parse(url)
5634 .ok()
5635 .and_then(|u| u.host_str().map(str::to_string))
5636 .unwrap_or_else(|| "a private feed".to_string())
5637}
5638
5639async fn export_opml(
5641 State(state): State<AppState>,
5642 headers: HeaderMap,
5643) -> Result<Response, WebError> {
5644 let did = match current_did(&state, &headers).await {
5645 Some(d) => d,
5646 None => return Ok(Redirect::to("/login").into_response()),
5647 };
5648
5649 let subs = match state.repo().list_subscriptions_sorted(&did).await {
5656 Ok(subs) => subs,
5657 Err(err) => {
5658 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5659 return Ok(Redirect::to(&format!(
5660 "/manage?flash={}",
5661 qenc(EXPORT_INCOMPLETE_REFUSAL)
5662 ))
5663 .into_response());
5664 }
5665 };
5666 let folders = match state.repo().list_folders_sorted(&did).await {
5667 Ok(folders) => folders,
5668 Err(err) => {
5669 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5670 return Ok(Redirect::to(&format!(
5671 "/manage?flash={}",
5672 qenc(EXPORT_INCOMPLETE_REFUSAL)
5673 ))
5674 .into_response());
5675 }
5676 };
5677 let folder_pairs: Vec<(String, Folder)> = folders
5680 .into_iter()
5681 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5682 .collect();
5683
5684 let body = opml::to_opml(&subs, &folder_pairs);
5685 let mut resp = (StatusCode::OK, body).into_response();
5686 resp.headers_mut().insert(
5687 header::CONTENT_TYPE,
5688 "text/x-opml; charset=utf-8".parse().unwrap(),
5689 );
5690 resp.headers_mut().insert(
5691 header::CONTENT_DISPOSITION,
5692 "attachment; filename=\"featherreader-subscriptions.opml\""
5693 .parse()
5694 .unwrap(),
5695 );
5696 Ok(resp)
5697}
5698
5699fn set_cookie(resp: &mut Response, cookie: &str) {
5705 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5706 resp.headers_mut()
5707 .append(axum::http::header::SET_COOKIE, value);
5708 }
5709}
5710
5711fn is_htmx(headers: &HeaderMap) -> bool {
5713 headers
5714 .get("HX-Request")
5715 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5716}
5717
5718fn is_reader_request(headers: &HeaderMap) -> bool {
5724 headers
5725 .get("X-FR-Reader")
5726 .is_some_and(|v| v.as_bytes() == b"1")
5727}
5728
5729mod cookie {
5734 use super::{HeaderMap, SESSION_COOKIE};
5735
5736 pub fn sign_session(sid: &str, secret: &str) -> String {
5738 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5739 }
5740
5741 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5743 verify_value(headers, SESSION_COOKIE, secret)
5744 }
5745
5746 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5752 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5753 msg.extend_from_slice(name.as_bytes());
5754 msg.push(0);
5755 msg.extend_from_slice(value.as_bytes());
5756 msg
5757 }
5758
5759 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5765 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5766 let b64 = b64url_encode(value.as_bytes());
5767 format!(
5768 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5769 )
5770 }
5771
5772 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5775 let raw = cookie_value(headers, name)?;
5776 let (b64, sig) = raw.split_once('.')?;
5777 let bytes = b64url_decode(b64)?;
5778 let value = String::from_utf8(bytes).ok()?;
5779 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5780 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5781 Some(value)
5782 } else {
5783 None
5784 }
5785 }
5786
5787 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5793 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5794 let b64 = b64url_encode(value.as_bytes());
5795 format!("{b64}.{sig}")
5796 }
5797
5798 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5801 let (b64, sig) = token.split_once('.')?;
5802 let bytes = b64url_decode(b64)?;
5803 let value = String::from_utf8(bytes).ok()?;
5804 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5805 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5806 Some(value)
5807 } else {
5808 None
5809 }
5810 }
5811
5812 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5814 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5815 for part in header.split(';') {
5816 let part = part.trim();
5817 if let Some((k, v)) = part.split_once('=') {
5818 if k == name {
5819 return Some(v.to_string());
5820 }
5821 }
5822 }
5823 None
5824 }
5825
5826 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5830 if a.len() != b.len() {
5831 return false;
5832 }
5833 let mut diff = 0u8;
5834 for (x, y) in a.iter().zip(b.iter()) {
5835 diff |= x ^ y;
5836 }
5837 diff == 0
5838 }
5839
5840 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5843
5844 fn b64url_encode(input: &[u8]) -> String {
5845 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5846 for chunk in input.chunks(3) {
5847 let b = [
5848 chunk[0],
5849 *chunk.get(1).unwrap_or(&0),
5850 *chunk.get(2).unwrap_or(&0),
5851 ];
5852 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5853 out.push(B64[((n >> 18) & 63) as usize] as char);
5854 out.push(B64[((n >> 12) & 63) as usize] as char);
5855 if chunk.len() > 1 {
5856 out.push(B64[((n >> 6) & 63) as usize] as char);
5857 }
5858 if chunk.len() > 2 {
5859 out.push(B64[(n & 63) as usize] as char);
5860 }
5861 }
5862 out
5863 }
5864
5865 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5866 fn val(c: u8) -> Option<u32> {
5867 match c {
5868 b'A'..=b'Z' => Some((c - b'A') as u32),
5869 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5870 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5871 b'-' => Some(62),
5872 b'_' => Some(63),
5873 _ => None,
5874 }
5875 }
5876 let bytes = input.as_bytes();
5877 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5878 for chunk in bytes.chunks(4) {
5879 let mut n = 0u32;
5880 let mut valid = 0;
5881 for (i, &c) in chunk.iter().enumerate() {
5882 n |= val(c)? << (18 - 6 * i);
5883 valid += 1;
5884 }
5885 out.push((n >> 16) as u8);
5886 if valid > 2 {
5887 out.push((n >> 8) as u8);
5888 }
5889 if valid > 3 {
5890 out.push(n as u8);
5891 }
5892 }
5893 Some(out)
5894 }
5895
5896 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5900 const BLOCK: usize = 64;
5901 let mut k = [0u8; BLOCK];
5902 if key.len() > BLOCK {
5903 let d = sha256(key);
5904 k[..32].copy_from_slice(&d);
5905 } else {
5906 k[..key.len()].copy_from_slice(key);
5907 }
5908 let mut ipad = [0x36u8; BLOCK];
5909 let mut opad = [0x5cu8; BLOCK];
5910 for i in 0..BLOCK {
5911 ipad[i] ^= k[i];
5912 opad[i] ^= k[i];
5913 }
5914 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5915 inner.extend_from_slice(&ipad);
5916 inner.extend_from_slice(msg);
5917 let inner_hash = sha256(&inner);
5918 let mut outer = Vec::with_capacity(BLOCK + 32);
5919 outer.extend_from_slice(&opad);
5920 outer.extend_from_slice(&inner_hash);
5921 let mac = sha256(&outer);
5922 let mut hex = String::with_capacity(64);
5923 for b in mac {
5924 hex.push_str(&format!("{b:02x}"));
5925 }
5926 hex
5927 }
5928
5929 fn sha256(data: &[u8]) -> [u8; 32] {
5931 const K: [u32; 64] = [
5932 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5933 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5934 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5935 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5936 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5937 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5938 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5939 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5940 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5941 0xc67178f2,
5942 ];
5943 let mut h: [u32; 8] = [
5944 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5945 0x5be0cd19,
5946 ];
5947
5948 let bit_len = (data.len() as u64) * 8;
5949 let mut msg = data.to_vec();
5950 msg.push(0x80);
5951 while msg.len() % 64 != 56 {
5952 msg.push(0);
5953 }
5954 msg.extend_from_slice(&bit_len.to_be_bytes());
5955
5956 for block in msg.chunks(64) {
5957 let mut w = [0u32; 64];
5958 for i in 0..16 {
5959 w[i] = u32::from_be_bytes([
5960 block[i * 4],
5961 block[i * 4 + 1],
5962 block[i * 4 + 2],
5963 block[i * 4 + 3],
5964 ]);
5965 }
5966 for i in 16..64 {
5967 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5968 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5969 w[i] = w[i - 16]
5970 .wrapping_add(s0)
5971 .wrapping_add(w[i - 7])
5972 .wrapping_add(s1);
5973 }
5974 let mut a = h;
5975 for i in 0..64 {
5976 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5977 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5978 let t1 = a[7]
5979 .wrapping_add(s1)
5980 .wrapping_add(ch)
5981 .wrapping_add(K[i])
5982 .wrapping_add(w[i]);
5983 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5984 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5985 let t2 = s0.wrapping_add(maj);
5986 a[7] = a[6];
5987 a[6] = a[5];
5988 a[5] = a[4];
5989 a[4] = a[3].wrapping_add(t1);
5990 a[3] = a[2];
5991 a[2] = a[1];
5992 a[1] = a[0];
5993 a[0] = t1.wrapping_add(t2);
5994 }
5995 for i in 0..8 {
5996 h[i] = h[i].wrapping_add(a[i]);
5997 }
5998 }
5999
6000 let mut out = [0u8; 32];
6001 for (i, word) in h.iter().enumerate() {
6002 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6003 }
6004 out
6005 }
6006
6007 #[cfg(test)]
6008 mod tests {
6009 use super::*;
6010
6011 #[test]
6012 fn sha256_known_vector() {
6013 let d = sha256(b"abc");
6014 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6015 assert_eq!(
6016 hex,
6017 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6018 );
6019 }
6020
6021 #[test]
6022 fn hmac_known_vector() {
6023 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6024 assert_eq!(
6025 mac,
6026 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6027 );
6028 }
6029
6030 #[test]
6031 fn sign_verify_round_trips() {
6032 let secret = "test-secret";
6033 let sid = "9f2c-opaque-session-id";
6034 let cookie = sign_session(sid, secret);
6035 let pair = cookie.split(';').next().unwrap().to_string();
6036 let mut headers = HeaderMap::new();
6037 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6038 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6039 assert!(verify_session(&headers, "other-secret").is_none());
6041 }
6042
6043 #[test]
6044 fn forged_and_tampered_cookies_are_rejected() {
6045 let secret = "test-secret";
6046
6047 let forged = format!(
6050 "{SESSION_COOKIE}={}.{}",
6051 b64url_encode(b"attacker-chosen-sid"),
6052 "deadbeef".repeat(8) );
6054 let mut headers = HeaderMap::new();
6055 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6056 assert!(verify_session(&headers, secret).is_none());
6057
6058 let cookie = sign_session("real-sid", secret);
6061 let pair = cookie.split(';').next().unwrap();
6062 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6063 let tampered = format!(
6064 "{SESSION_COOKIE}={}.{}",
6065 b64url_encode(b"different-sid"),
6066 sig
6067 );
6068 let mut headers2 = HeaderMap::new();
6069 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6070 assert!(verify_session(&headers2, secret).is_none());
6071 }
6072
6073 #[test]
6074 fn b64url_round_trips() {
6075 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6076 let enc = b64url_encode(s.as_bytes());
6077 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6078 }
6079 }
6080 }
6081}
6082
6083async fn get_entry_by_id(
6099 pool: &store::Pool,
6100 did: &str,
6101 id: i64,
6102) -> anyhow::Result<Option<store::Entry>> {
6103 let entry = sqlx::query_as::<_, store::Entry>(
6104 r#"
6105 SELECT e.* FROM entries e
6106 WHERE e.id = ?2
6107 AND EXISTS (
6108 SELECT 1 FROM sub_ref sr
6109 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6110 )
6111 "#,
6112 )
6113 .bind(did)
6114 .bind(id)
6115 .fetch_optional(pool)
6116 .await?;
6117 Ok(entry)
6118}
6119
6120async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6122 let read: Option<bool> =
6123 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6124 .bind(did)
6125 .bind(entry_id)
6126 .fetch_optional(pool)
6127 .await?
6128 .flatten();
6129 Ok(read.unwrap_or(false))
6130}
6131
6132async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6134 let starred: Option<bool> =
6135 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6136 .bind(did)
6137 .bind(entry_id)
6138 .fetch_optional(pool)
6139 .await?
6140 .flatten();
6141 Ok(starred.unwrap_or(false))
6142}
6143
6144async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6146 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6147 .bind(feed_id)
6148 .fetch_optional(pool)
6149 .await
6150 {
6151 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6152 _ => String::new(),
6153 }
6154}
6155
6156async fn build_entry_row(
6159 pool: &store::Pool,
6160 did: &str,
6161 id: i64,
6162 read: Option<bool>,
6163) -> anyhow::Result<Option<EntryRow>> {
6164 let entry = match get_entry_by_id(pool, did, id).await? {
6165 Some(e) => e,
6166 None => return Ok(None),
6167 };
6168 let read = match read {
6169 Some(r) => r,
6170 None => entry_is_read(pool, did, id).await?,
6171 };
6172 let starred = entry_is_starred(pool, did, id).await?;
6173 Ok(Some(EntryRow {
6174 id: entry.id,
6175 title: entry
6176 .title
6177 .clone()
6178 .filter(|t| !t.trim().is_empty())
6179 .unwrap_or_else(|| "(untitled)".to_string()),
6180 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6181 published: display_date(entry.published.as_deref()),
6182 read,
6183 starred,
6184 link: SafeLink::entry(id, ""),
6185 cached: true,
6186 rkey: String::new(),
6187 }))
6188}
6189
6190fn now_rfc3339() -> String {
6192 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6193}
6194
6195#[cfg(test)]
6196mod tests {
6197 use super::*;
6198
6199 #[test]
6200 fn qenc_encodes_reserved() {
6201 assert_eq!(qenc("a b"), "a%20b");
6202 assert_eq!(
6203 qenc("https://example.com/feed.xml"),
6204 "https%3A%2F%2Fexample.com%2Ffeed.xml"
6205 );
6206 assert_eq!(
6207 qenc("at://did:plc:x/c/r"),
6208 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6209 );
6210 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6212 }
6213
6214 #[test]
6215 fn folder_uri_shape() {
6216 assert_eq!(
6217 folder_uri("did:plc:abc", "3kfolder"),
6218 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6219 );
6220 }
6221
6222 #[test]
6225 fn private_feeds_are_classified_private_across_providers() {
6226 for url in [
6230 "https://author.substack.com/feed/private/deadbeefcafe1234",
6231 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6232 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6233 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6234 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6235 "https://user:pass@example.com/feed",
6236 ] {
6237 assert!(
6238 feed::classify_feed_privacy(url).is_private(),
6239 "expected private: {url}"
6240 );
6241 }
6242 }
6243
6244 #[test]
6245 fn public_feeds_stay_public() {
6246 for url in [
6247 "https://author.substack.com/feed",
6248 "https://wordpress.example.com/feed/",
6249 "https://example.com/rss.xml",
6250 "https://example.org/atom.xml",
6251 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6253 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6254 ] {
6255 assert!(
6256 !feed::classify_feed_privacy(url).is_private(),
6257 "expected public: {url}"
6258 );
6259 }
6260 }
6261
6262 #[test]
6263 fn private_feed_label_is_public_safe_host_only() {
6264 let label =
6266 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6267 assert_eq!(label, "author.substack.com");
6268 assert!(!label.contains("deadbeefcafe1234token"));
6269 assert!(!label.contains("/private/"));
6270 assert_eq!(private_feed_label("not a url"), "a private feed");
6272 }
6273
6274 #[test]
6275 fn refusal_message_promises_nothing_stored() {
6276 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6277 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6278 }
6279
6280 #[test]
6281 fn scope_query_preserves_context() {
6282 let q = EntryQuery {
6283 feed: Some("https://example.com/feed.xml".to_string()),
6284 folder: None,
6285 view: Some("all".to_string()),
6286 };
6287 let s = scope_query(&q);
6288 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6289 assert!(s.contains("view=all"));
6290
6291 let q2 = EntryQuery {
6293 feed: None,
6294 folder: None,
6295 view: Some("unread".to_string()),
6296 };
6297 assert_eq!(scope_query(&q2), "");
6298 }
6299
6300 use axum::body::Body;
6303 use axum::http::Request;
6304 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6310 let db = store::init_url("sqlite::memory:").await.unwrap();
6311 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6312 store::ensure_seed(&db, &dids).await.unwrap();
6313 let config = Config {
6314 allowed_dids: dids,
6315 cookie_secret: "test-cookie-secret-000".to_string(),
6316 beta_cap: 3,
6317 ..Config::default()
6318 };
6319 AppState::new(config, db).unwrap()
6320 }
6321
6322 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6325 let sid = state.sessions.create(Session {
6326 did: did.to_string(),
6327 handle: handle.map(str::to_string),
6328 });
6329 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6330 sc.split(';').next().unwrap().to_string()
6331 }
6332
6333 #[test]
6337 fn the_rate_limit_map_is_bounded() {
6338 let rl = RateLimiter::shared();
6339 let now = Instant::now();
6340 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6341 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6344 rl.check_at(ip, now + Duration::from_millis(i as u64));
6345 }
6346 let len = rl.inner.lock().unwrap().buckets.len();
6347 assert!(
6348 len <= MAX_RATE_BUCKETS,
6349 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6350 );
6351 }
6352
6353 #[test]
6360 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6361 let rl = RateLimiter::shared();
6362 let base = Instant::now();
6363 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6364 let at = |n: u64| base + Duration::from_nanos(n);
6369
6370 for i in 0..(RATE_BURST as u64) {
6372 assert!(rl.check_at(attacker, at(i)));
6373 }
6374 assert!(
6375 !rl.check_at(attacker, at(RATE_BURST as u64)),
6376 "burst was not exhausted; the rest of this test proves nothing"
6377 );
6378
6379 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6382 let t = at(100 + i as u64 * 2);
6383 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6384 rl.check_at(ip, t);
6385 assert!(
6386 !rl.check_at(attacker, t),
6387 "the attacker got a token back after evictions at i={i}"
6388 );
6389 }
6390 }
6391
6392 #[test]
6395 fn the_idle_sweep_does_not_run_on_every_request() {
6396 let rl = RateLimiter::shared();
6397 let start = Instant::now();
6398 let a: IpAddr = "198.51.100.1".parse().unwrap();
6399 let b: IpAddr = "198.51.100.2".parse().unwrap();
6400
6401 rl.check_at(a, start);
6402 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6405 assert!(
6406 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6407 "an idle bucket survived a sweep that was due"
6408 );
6409
6410 let before = rl.inner.lock().unwrap().last_sweep;
6413 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6414 assert_eq!(
6415 rl.inner.lock().unwrap().last_sweep,
6416 before,
6417 "the sweep ran again within the interval"
6418 );
6419 }
6420
6421 #[test]
6422 fn rate_limited_paths_match_expected() {
6423 use axum::http::Method;
6424 assert!(is_rate_limited_path("/login", &Method::GET));
6425 assert!(is_rate_limited_path("/login", &Method::POST));
6426 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6427 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6428 assert!(is_rate_limited_path("/opml", &Method::POST));
6429 assert!(is_rate_limited_path("/read-all", &Method::POST));
6430 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6431 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6432 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6433 assert!(!is_rate_limited_path("/", &Method::GET));
6435 assert!(!is_rate_limited_path("/about", &Method::GET));
6436 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6437 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6438 }
6439
6440 #[test]
6441 fn rate_limiter_allows_burst_then_429s() {
6442 let rl = RateLimiter::shared();
6443 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6444 for _ in 0..(RATE_BURST as usize) {
6446 assert!(rl.check(ip));
6447 }
6448 assert!(!rl.check(ip));
6450 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6452 assert!(rl.check(ip2));
6453 }
6454
6455 #[test]
6456 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6457 let mut h = HeaderMap::new();
6461 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6462 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6463 assert_eq!(
6464 client_ip(&h, Some(&sock), None),
6465 Some("203.0.113.55".parse().unwrap()),
6466 "spoofed XFF must not override the socket peer"
6467 );
6468 }
6469
6470 #[test]
6471 fn client_ip_uses_trusted_header_last_hop() {
6472 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6477
6478 let mut h = HeaderMap::new();
6479 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6480 assert_eq!(
6481 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6482 Some("198.51.100.9".parse().unwrap())
6483 );
6484
6485 let mut h2 = HeaderMap::new();
6487 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6488 assert_eq!(
6489 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6490 Some("198.51.100.9".parse().unwrap()),
6491 "must take the right-most (trusted) hop, not the forged left-most"
6492 );
6493
6494 let h3 = HeaderMap::new();
6496 assert_eq!(
6497 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6498 Some("10.0.0.1".parse().unwrap())
6499 );
6500 }
6501
6502 #[test]
6503 fn invite_cookie_round_trips_and_rejects_tamper() {
6504 let secret = "test-cookie-secret-000";
6505 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6506 let pair = sc.split(';').next().unwrap();
6507 let mut h = HeaderMap::new();
6508 h.insert(header::COOKIE, pair.parse().unwrap());
6509 assert_eq!(
6510 invite_cookie_code(&h, secret).as_deref(),
6511 Some("FEATHER-ABCDWXYZ")
6512 );
6513 assert!(invite_cookie_code(&h, "other").is_none());
6515 }
6516
6517 #[tokio::test]
6518 async fn preflight_valid_expired_and_full() {
6519 let state = test_state(&["did:plc:admin"]).await;
6520 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6522 .await
6523 .unwrap();
6524 assert!(preflight_code(&state, &code).await.is_ok());
6525
6526 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6530 .await
6531 .unwrap();
6532 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6533 .bind(chrono::Utc::now().timestamp() - 3600)
6534 .bind(&expired)
6535 .execute(&state.db)
6536 .await
6537 .unwrap();
6538 assert_eq!(
6539 preflight_code(&state, &expired).await,
6540 Err(store::RedeemError::Expired)
6541 );
6542
6543 assert_eq!(
6545 preflight_code(&state, "FEATHER-NOPENOPE").await,
6546 Err(store::RedeemError::NotFound)
6547 );
6548
6549 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6552 .await
6553 .unwrap();
6554 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6555 .await
6556 .unwrap();
6557 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6558 assert_eq!(
6559 preflight_code(&state, &code).await,
6560 Err(store::RedeemError::CapacityFull)
6561 );
6562 }
6563
6564 async fn bot_state(bot_secret: &str) -> AppState {
6568 let db = store::init_url("sqlite::memory:").await.unwrap();
6569 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6570 .await
6571 .unwrap();
6572 let config = Config {
6573 allowed_dids: vec!["did:plc:admin".to_string()],
6574 cookie_secret: "test-cookie-secret-000".to_string(),
6575 beta_cap: 3,
6576 bot_secret: Some(bot_secret.to_string()),
6577 public_url: "https://feather-reader.com".to_string(),
6578 ..Config::default()
6579 };
6580 AppState::new(config, db).unwrap()
6581 }
6582
6583 #[test]
6584 fn claim_token_round_trips_and_rejects_tamper() {
6585 let secret = "test-cookie-secret-000";
6586 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6587 assert!(!token.contains(';'));
6589 assert_eq!(
6590 claim_token_code(&token, secret).as_deref(),
6591 Some("FEATHER-ABCDWXYZ")
6592 );
6593 assert!(claim_token_code(&token, "other").is_none());
6595 let mut bad = token.clone();
6597 bad.push('x');
6598 assert!(claim_token_code(&bad, secret).is_none());
6599 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6605 assert_eq!(
6606 test_b64url_decode(b64).as_deref(),
6607 Some("FEATHER-ABCDWXYZ".as_bytes()),
6608 "the code half of the token is plain base64url, decodable by anyone"
6609 );
6610 }
6611
6612 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6615 fn val(c: u8) -> Option<u32> {
6616 match c {
6617 b'A'..=b'Z' => Some((c - b'A') as u32),
6618 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6619 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6620 b'-' => Some(62),
6621 b'_' => Some(63),
6622 _ => None,
6623 }
6624 }
6625 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6626 for chunk in input.as_bytes().chunks(4) {
6627 let mut n = 0u32;
6628 let mut bits = 0;
6629 for &c in chunk {
6630 n = (n << 6) | val(c)?;
6631 bits += 6;
6632 }
6633 let bytes = bits / 8;
6634 n <<= 24 - bits;
6635 for i in 0..bytes {
6636 out.push((n >> (16 - i * 8)) as u8);
6637 }
6638 }
6639 Some(out)
6640 }
6641
6642 #[tokio::test]
6643 async fn bot_mint_then_claim_grants_a_seat() {
6644 let state = bot_state("bot-secret-abcdef").await;
6645 let app = router(state.clone());
6646
6647 let resp = app
6649 .clone()
6650 .oneshot(
6651 Request::builder()
6652 .method("POST")
6653 .uri("/bot/claims")
6654 .header("x-bot-secret", "bot-secret-abcdef")
6655 .body(Body::empty())
6656 .unwrap(),
6657 )
6658 .await
6659 .unwrap();
6660 assert_eq!(resp.status(), StatusCode::OK);
6661 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6662 .await
6663 .unwrap();
6664 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6665 let token = json["token"].as_str().unwrap().to_string();
6666 let url = json["url"].as_str().unwrap();
6667 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6668 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6670 assert!(!url.contains("FEATHER-"));
6671
6672 let resp = app
6674 .clone()
6675 .oneshot(
6676 Request::builder()
6677 .method("GET")
6678 .uri(format!("/claim?t={}", qenc(&token)))
6679 .body(Body::empty())
6680 .unwrap(),
6681 )
6682 .await
6683 .unwrap();
6684 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6685 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6686 let set_cookie = resp
6687 .headers()
6688 .get(header::SET_COOKIE)
6689 .unwrap()
6690 .to_str()
6691 .unwrap();
6692 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6693
6694 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6697 let out = store::redeem_code(
6698 &state.db,
6699 &code,
6700 "did:plc:follower",
6701 None,
6702 state.config.beta_cap,
6703 )
6704 .await
6705 .unwrap();
6706 assert_eq!(out, Ok(()));
6707 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6708 .await
6709 .unwrap());
6710 }
6711
6712 #[tokio::test]
6713 async fn claim_with_invalid_token_bounces() {
6714 let state = bot_state("bot-secret-abcdef").await;
6715 let app = router(state);
6716 let resp = app
6717 .oneshot(
6718 Request::builder()
6719 .method("GET")
6720 .uri("/claim?t=not-a-real-token")
6721 .body(Body::empty())
6722 .unwrap(),
6723 )
6724 .await
6725 .unwrap();
6726 assert_eq!(resp.status(), StatusCode::OK);
6728 }
6729
6730 #[tokio::test]
6731 async fn claim_with_used_token_is_refused() {
6732 let state = bot_state("bot-secret-abcdef").await;
6733 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6735 .await
6736 .unwrap();
6737 let token = sign_claim_token(&code, &state.config.cookie_secret);
6738 store::redeem_code(
6739 &state.db,
6740 &code,
6741 "did:plc:someone",
6742 None,
6743 state.config.beta_cap,
6744 )
6745 .await
6746 .unwrap()
6747 .unwrap();
6748 let app = router(state);
6749 let resp = app
6750 .oneshot(
6751 Request::builder()
6752 .method("GET")
6753 .uri(format!("/claim?t={}", qenc(&token)))
6754 .body(Body::empty())
6755 .unwrap(),
6756 )
6757 .await
6758 .unwrap();
6759 assert_eq!(resp.status(), StatusCode::OK);
6761 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6762 }
6763
6764 #[tokio::test]
6765 async fn bot_claims_rejects_bad_and_missing_secret() {
6766 let state = bot_state("bot-secret-abcdef").await;
6767 let app = router(state);
6768 let resp = app
6770 .clone()
6771 .oneshot(
6772 Request::builder()
6773 .method("POST")
6774 .uri("/bot/claims")
6775 .header("x-bot-secret", "wrong")
6776 .body(Body::empty())
6777 .unwrap(),
6778 )
6779 .await
6780 .unwrap();
6781 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6782 let resp = app
6784 .oneshot(
6785 Request::builder()
6786 .method("POST")
6787 .uri("/bot/claims")
6788 .body(Body::empty())
6789 .unwrap(),
6790 )
6791 .await
6792 .unwrap();
6793 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6794 }
6795
6796 #[tokio::test]
6797 async fn bot_claims_disabled_when_secret_unset() {
6798 let state = test_state(&["did:plc:admin"]).await;
6800 let app = router(state);
6801 let resp = app
6802 .oneshot(
6803 Request::builder()
6804 .method("POST")
6805 .uri("/bot/claims")
6806 .header("x-bot-secret", "anything")
6807 .body(Body::empty())
6808 .unwrap(),
6809 )
6810 .await
6811 .unwrap();
6812 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6813 }
6814
6815 #[tokio::test]
6816 async fn bot_claims_refuses_at_capacity() {
6817 let state = bot_state("bot-secret-abcdef").await;
6818 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6820 .await
6821 .unwrap();
6822 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6823 .await
6824 .unwrap();
6825 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6826 let app = router(state);
6827 let resp = app
6828 .oneshot(
6829 Request::builder()
6830 .method("POST")
6831 .uri("/bot/claims")
6832 .header("x-bot-secret", "bot-secret-abcdef")
6833 .body(Body::empty())
6834 .unwrap(),
6835 )
6836 .await
6837 .unwrap();
6838 assert_eq!(resp.status(), StatusCode::CONFLICT);
6839 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6840 .await
6841 .unwrap();
6842 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6843 }
6844
6845 #[tokio::test]
6846 async fn bot_claims_counts_outstanding_codes_against_cap() {
6847 let state = bot_state("bot-secret-abcdef").await;
6848 store::mint_code(&state.db, "did:plc:admin", 3600)
6850 .await
6851 .unwrap();
6852 store::mint_code(&state.db, "did:plc:admin", 3600)
6853 .await
6854 .unwrap();
6855 let app = router(state);
6856 let resp = app
6857 .oneshot(
6858 Request::builder()
6859 .method("POST")
6860 .uri("/bot/claims")
6861 .header("x-bot-secret", "bot-secret-abcdef")
6862 .body(Body::empty())
6863 .unwrap(),
6864 )
6865 .await
6866 .unwrap();
6867 assert_eq!(resp.status(), StatusCode::CONFLICT);
6869 }
6870
6871 async fn post_bot_claim_for(
6873 app: &axum::Router,
6874 secret: &str,
6875 did: &str,
6876 ) -> (StatusCode, serde_json::Value) {
6877 let resp = app
6878 .clone()
6879 .oneshot(
6880 Request::builder()
6881 .method("POST")
6882 .uri("/bot/claims")
6883 .header("x-bot-secret", secret)
6884 .header("content-type", "application/json")
6885 .body(Body::from(format!(
6886 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6887 )))
6888 .unwrap(),
6889 )
6890 .await
6891 .unwrap();
6892 let status = resp.status();
6893 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6894 .await
6895 .unwrap();
6896 let json = if bytes.is_empty() {
6897 serde_json::Value::Null
6898 } else {
6899 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6900 };
6901 (status, json)
6902 }
6903
6904 #[tokio::test]
6905 async fn bot_claims_returns_already_seated_for_a_member() {
6906 let state = bot_state("bot-secret-abcdef").await;
6910 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6911 .await
6912 .unwrap();
6913 let app = router(state.clone());
6914 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6915 assert_eq!(status, StatusCode::OK);
6916 assert_eq!(json["status"], "already_seated");
6917 assert_eq!(json["code"], "");
6918 assert_eq!(json["url"], "");
6919 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6921 .await
6922 .unwrap()
6923 .is_none());
6924 }
6925
6926 #[tokio::test]
6927 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6928 let state = bot_state("bot-secret-abcdef").await;
6932 let app = router(state.clone());
6933
6934 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6935 assert_eq!(s1, StatusCode::OK);
6936 assert_eq!(j1["status"], "minted");
6937 let code1 = j1["code"].as_str().unwrap().to_string();
6938
6939 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6940 assert_eq!(s2, StatusCode::OK);
6941 assert_eq!(j2["status"], "existing");
6942 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6943 assert_eq!(j2["url"], j1["url"], "same url returned");
6944
6945 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6947 }
6948
6949 #[tokio::test]
6950 async fn bot_claims_records_intended_did_at_mint() {
6951 let state = bot_state("bot-secret-abcdef").await;
6953 let app = router(state.clone());
6954 let (status, json) =
6955 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6956 assert_eq!(status, StatusCode::OK);
6957 let code = json["code"].as_str().unwrap();
6958 assert_eq!(
6959 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6960 .await
6961 .unwrap()
6962 .as_deref(),
6963 Some(code)
6964 );
6965 }
6966
6967 #[tokio::test]
6968 async fn bot_claims_concurrent_same_did_never_double_mints() {
6969 let state = bot_state("bot-secret-abcdef").await;
6976 let app = router(state.clone());
6977
6978 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6979 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6980 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6981
6982 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6983 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6984
6985 assert_eq!(
6987 store::count_active_codes(&state.db).await.unwrap(),
6988 1,
6989 "concurrent mints must not create two active codes"
6990 );
6991
6992 let ca = ja["code"].as_str().unwrap_or("");
6994 let cb = jb["code"].as_str().unwrap_or("");
6995 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6996 assert_eq!(ca, cb, "both callers must get the one minted code");
6997 for st in [&ja["status"], &jb["status"]] {
7000 let s = st.as_str().unwrap_or("");
7001 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7002 }
7003 }
7004
7005 #[tokio::test]
7006 async fn bot_claims_rejects_malformed_json_body() {
7007 let state = bot_state("bot-secret-abcdef").await;
7008 let app = router(state);
7009 let resp = app
7010 .oneshot(
7011 Request::builder()
7012 .method("POST")
7013 .uri("/bot/claims")
7014 .header("x-bot-secret", "bot-secret-abcdef")
7015 .header("content-type", "application/json")
7016 .body(Body::from("{not json"))
7017 .unwrap(),
7018 )
7019 .await
7020 .unwrap();
7021 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7022 }
7023
7024 #[tokio::test]
7025 async fn favicon_ico_served_at_root() {
7026 let state = test_state(&[]).await;
7029 let app = router(state);
7030 let resp = app
7031 .oneshot(
7032 Request::builder()
7033 .uri("/favicon.ico")
7034 .body(Body::empty())
7035 .unwrap(),
7036 )
7037 .await
7038 .unwrap();
7039 assert_eq!(resp.status(), StatusCode::OK);
7040 let ct = resp
7041 .headers()
7042 .get(header::CONTENT_TYPE)
7043 .unwrap()
7044 .to_str()
7045 .unwrap();
7046 assert!(
7047 ct.contains("icon") || ct.starts_with("image/"),
7048 "content-type = {ct}"
7049 );
7050 }
7051
7052 #[tokio::test]
7053 async fn login_without_invite_redirects_to_beta_redeem() {
7054 let state = test_state(&[]).await;
7056 let app = router(state);
7057 let resp = app
7058 .oneshot(
7059 Request::builder()
7060 .method("POST")
7061 .uri("/login")
7062 .header("content-type", "application/x-www-form-urlencoded")
7063 .body(Body::from("handle=alice.bsky.social"))
7064 .unwrap(),
7065 )
7066 .await
7067 .unwrap();
7068 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7069 assert_eq!(
7070 resp.headers().get(header::LOCATION).unwrap(),
7071 "/beta/redeem"
7072 );
7073 }
7074
7075 #[tokio::test]
7076 async fn login_with_valid_invite_cookie_starts_oauth() {
7077 let state = test_state(&[]).await;
7078 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7079 let cookie = cookie.split(';').next().unwrap().to_string();
7080 let app = router(state);
7081 let resp = app
7082 .oneshot(
7083 Request::builder()
7084 .method("POST")
7085 .uri("/login")
7086 .header("content-type", "application/x-www-form-urlencoded")
7087 .header(header::COOKIE, cookie)
7088 .body(Body::from("handle=alice.bsky.social"))
7089 .unwrap(),
7090 )
7091 .await
7092 .unwrap();
7093 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7095 let loc = resp
7096 .headers()
7097 .get(header::LOCATION)
7098 .unwrap()
7099 .to_str()
7100 .unwrap();
7101 assert!(loc.contains("/login"), "loc = {loc}");
7102 assert_ne!(loc, "/beta/redeem");
7103 }
7104
7105 async fn resolver_never(_handle: String) -> Option<String> {
7108 None
7109 }
7110
7111 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7113 move |_handle| std::future::ready(Some(did.to_string()))
7114 }
7115
7116 #[tokio::test]
7120 async fn may_start_oauth_honors_seat_via_resolved_handle() {
7121 let state = test_state(&["did:plc:admin"]).await;
7124 let headers = HeaderMap::new();
7125 assert!(
7126 may_start_oauth_with(
7127 &state,
7128 &headers,
7129 "admin.example",
7130 resolver_to("did:plc:admin")
7131 )
7132 .await,
7133 "a handle resolving to a seated DID must pass the gate"
7134 );
7135 }
7136
7137 #[tokio::test]
7141 async fn may_start_oauth_bounces_non_member_handle() {
7142 let state = test_state(&["did:plc:admin"]).await;
7143 let headers = HeaderMap::new();
7144 assert!(
7145 !may_start_oauth_with(
7146 &state,
7147 &headers,
7148 "rando.example",
7149 resolver_to("did:plc:rando")
7150 )
7151 .await,
7152 "a resolved DID with no seat must be bounced"
7153 );
7154 }
7155
7156 #[tokio::test]
7159 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7160 let state = test_state(&["did:plc:admin"]).await;
7161 let headers = HeaderMap::new();
7162 assert!(
7163 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7164 "an unresolvable handle must fail closed"
7165 );
7166 }
7167
7168 #[tokio::test]
7172 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7173 let state = test_state(&[]).await;
7174 let did = "did:plc:member";
7175 store::grant_access(&state.db, did, Some("member.example"), "test", None)
7176 .await
7177 .unwrap();
7178 let cookie = session_cookie(&state, did, Some("member.example"));
7179 let mut headers = HeaderMap::new();
7180 headers.insert(header::COOKIE, cookie.parse().unwrap());
7181 assert!(
7182 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7183 "a seated session cookie must pass without resolution"
7184 );
7185 }
7186
7187 #[tokio::test]
7189 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7190 let state = test_state(&[]).await;
7191 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7192 let cookie = cookie.split(';').next().unwrap().to_string();
7193 let mut headers = HeaderMap::new();
7194 headers.insert(header::COOKIE, cookie.parse().unwrap());
7195 assert!(
7196 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7197 "a valid invite cookie must pass without resolution"
7198 );
7199 }
7200
7201 #[tokio::test]
7202 async fn admin_mint_requires_admin_seed_did() {
7203 let state = test_state(&["did:plc:admin"]).await;
7204 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7206 .await
7207 .unwrap();
7208 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7209 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7211 let app = router(state);
7212
7213 let forbidden = app
7214 .clone()
7215 .oneshot(
7216 Request::builder()
7217 .method("POST")
7218 .uri("/admin/invites?n=2")
7219 .header(header::COOKIE, rando_cookie)
7220 .body(Body::empty())
7221 .unwrap(),
7222 )
7223 .await
7224 .unwrap();
7225 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7226
7227 let ok = app
7228 .oneshot(
7229 Request::builder()
7230 .method("POST")
7231 .uri("/admin/invites?n=2")
7232 .header(header::COOKIE, admin_cookie)
7233 .body(Body::empty())
7234 .unwrap(),
7235 )
7236 .await
7237 .unwrap();
7238 assert_eq!(ok.status(), StatusCode::OK);
7239 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7240 .await
7241 .unwrap();
7242 let body = String::from_utf8(bytes.to_vec()).unwrap();
7243 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7244 assert_eq!(minted.len(), 2);
7245 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7246 }
7247
7248 #[tokio::test]
7249 async fn admin_mint_unauthenticated_is_401() {
7250 let state = test_state(&["did:plc:admin"]).await;
7251 let app = router(state);
7252 let resp = app
7253 .oneshot(
7254 Request::builder()
7255 .method("POST")
7256 .uri("/admin/invites")
7257 .body(Body::empty())
7258 .unwrap(),
7259 )
7260 .await
7261 .unwrap();
7262 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7263 }
7264
7265 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7268 let db = store::init_url("sqlite::memory:").await.unwrap();
7269 store::record_network_stat(
7270 &db,
7271 &store::NetworkStat {
7272 key: store::ADOPTION_STAT_KEY.to_string(),
7273 source: "https://relay1.us-west.bsky.network".to_string(),
7274 value: repos,
7275 truncated,
7276 observed_at: "2026-08-13T04:05:06Z".to_string(),
7277 },
7278 )
7279 .await
7280 .unwrap();
7281 let config = Config {
7282 cookie_secret: "test-cookie-secret-000".to_string(),
7283 show_adoption: true,
7284 ..Config::default()
7285 };
7286 AppState::new(config, db).unwrap()
7287 }
7288
7289 async fn about_body(state: AppState) -> String {
7290 let resp = router(state)
7291 .oneshot(
7292 Request::builder()
7293 .uri("/about")
7294 .body(Body::empty())
7295 .unwrap(),
7296 )
7297 .await
7298 .unwrap();
7299 assert_eq!(resp.status(), StatusCode::OK);
7300 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7301 .await
7302 .unwrap();
7303 String::from_utf8(bytes.to_vec()).unwrap()
7304 }
7305
7306 #[tokio::test]
7308 async fn about_omits_adoption_line_by_default() {
7309 let state = test_state(&[]).await;
7310 assert!(!state.config.show_adoption);
7311 let body = about_body(state).await;
7312 assert!(
7313 !body.contains("atproto network"),
7314 "the adoption line must not render by default"
7315 );
7316 }
7317
7318 #[tokio::test]
7319 async fn about_renders_adoption_line_when_enabled() {
7320 let body = about_body(adoption_state(7_318, false).await).await;
7328 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7331 assert!(
7332 flat.contains("7318 accounts on the atproto network hold"),
7333 "the count did not render in its own sentence: {flat}",
7334 );
7335 assert!(
7336 body.contains("accounts on the atproto network hold"),
7337 "{body}"
7338 );
7339 assert!(
7340 body.contains("2026-08-13"),
7341 "the observation date must render"
7342 );
7343 assert!(
7344 body.contains("lower bound"),
7345 "the non-archival caveat must ride along with the number"
7346 );
7347 assert!(
7348 !body.contains("At least"),
7349 "an untruncated count is exact-ish"
7350 );
7351 }
7352
7353 #[tokio::test]
7355 async fn about_adoption_line_is_singular_at_one() {
7356 let body = about_body(adoption_state(1, false).await).await;
7357 assert!(
7358 body.contains("account on the atproto network holds"),
7359 "{body}"
7360 );
7361 }
7362
7363 #[tokio::test]
7365 async fn about_adoption_line_says_at_least_when_truncated() {
7366 let body = about_body(adoption_state(25_000, true).await).await;
7367 assert!(body.contains("At least"), "{body}");
7368 }
7369
7370 #[tokio::test]
7372 async fn about_omits_line_when_enabled_with_no_observation() {
7373 let db = store::init_url("sqlite::memory:").await.unwrap();
7374 let config = Config {
7375 cookie_secret: "test-cookie-secret-000".to_string(),
7376 show_adoption: true,
7377 ..Config::default()
7378 };
7379 let body = about_body(AppState::new(config, db).unwrap()).await;
7380 assert!(!body.contains("atproto network"));
7381 }
7382
7383 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7394 let db = store::init_url("sqlite::memory:").await.unwrap();
7395 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7396 let config = Config {
7397 allowed_dids: vec![did.to_string()],
7398 cookie_secret: "test-cookie-secret-000".to_string(),
7399 beta_cap: 3,
7400 standard_site,
7401 ..Config::default()
7402 };
7403 AppState::new(config, db).unwrap()
7404 }
7405
7406 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7408 let cookie = session_cookie(&state, did, Some("reader.example"));
7409 let resp = router(state)
7410 .oneshot(
7411 Request::builder()
7412 .uri(path)
7413 .header(header::COOKIE, cookie)
7414 .body(Body::empty())
7415 .unwrap(),
7416 )
7417 .await
7418 .unwrap();
7419 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7420 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7421 .await
7422 .unwrap();
7423 String::from_utf8(bytes.to_vec()).unwrap()
7424 }
7425
7426 async fn public_body(state: AppState, path: &str) -> String {
7428 let resp = router(state)
7429 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7430 .await
7431 .unwrap();
7432 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7433 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7434 .await
7435 .unwrap();
7436 String::from_utf8(bytes.to_vec()).unwrap()
7437 }
7438
7439 fn feed_url_input(body: &str) -> &str {
7441 let start = body
7442 .find("id=\"feed-url\"")
7443 .and_then(|i| body[..i].rfind("<input"))
7444 .expect("the subscribe form's URL input renders");
7445 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7446 &body[start..end]
7447 }
7448
7449 #[tokio::test]
7452 async fn manage_hints_at_publications_when_the_flag_is_on() {
7453 let did = "did:plc:reader";
7454 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7455 assert!(
7456 body.contains("at://did:plc:…/site.standard.publication/…"),
7457 "the DID form must be shown: {body}"
7458 );
7459 assert!(
7460 body.contains("at://alice.example.com/site.standard.publication/…"),
7461 "the handle form must be shown: {body}"
7462 );
7463 }
7464
7465 #[tokio::test]
7471 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7472 let did = "did:plc:reader";
7473 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7474 let input = feed_url_input(&body);
7475 assert!(
7476 input.contains("type=\"text\""),
7477 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7478 );
7479 assert!(
7480 input.contains("inputmode=\"url\""),
7481 "the URL keyboard is still wanted: {input}"
7482 );
7483 }
7484
7485 #[tokio::test]
7491 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7492 let did = "did:plc:reader";
7493 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7494 let input = feed_url_input(&body);
7495 assert!(
7496 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7497 "the text input must keep a scheme check: {input}"
7498 );
7499 }
7500
7501 #[tokio::test]
7504 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7505 let did = "did:plc:reader";
7506 let state = standard_site_state(false, did).await;
7507 assert!(!state.config.standard_site);
7508 let page = signed_in_body(state, "/manage", did).await;
7509 let body = &page[page.find("</head>").expect("a <head>")..];
7514 assert!(
7515 !body.contains("site.standard.publication"),
7516 "a refused form must not be advertised: {body}"
7517 );
7518 let above_footer = body
7523 .split("<footer")
7524 .next()
7525 .expect("split yields at least one piece");
7526 assert!(
7527 above_footer.contains("id=\"feed-url\""),
7528 "the form must be above the footer: {body}"
7529 );
7530 assert!(
7531 !above_footer.contains("standard.site"),
7532 "a refused form must not be advertised: {body}"
7533 );
7534 assert!(
7535 feed_url_input(body).contains("type=\"url\""),
7536 "with the flag off the input is unchanged"
7537 );
7538 }
7539
7540 #[tokio::test]
7543 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7544 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7545 assert!(body.contains("standard.site"), "{body}");
7546 assert!(
7547 body.contains("at://did:plc:…/site.standard.publication/…"),
7548 "the landing page must show the DID form: {body}"
7549 );
7550 assert!(
7551 body.contains("at://alice.example.com/site.standard.publication/…"),
7552 "the landing page must show the handle form: {body}"
7553 );
7554 }
7555
7556 #[tokio::test]
7560 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7561 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7562 assert!(body.contains("standard.site"), "{body}");
7563 assert!(
7564 !body.contains("at://did:plc:…/site.standard.publication/…"),
7565 "no paste instructions with the flag off: {body}"
7566 );
7567 assert!(
7568 !body.contains("at://alice.example.com/site.standard.publication/…"),
7569 "no paste instructions with the flag off: {body}"
7570 );
7571 assert!(
7572 body.contains("isn't accepting new publication subscriptions"),
7573 "the page must say the form is closed here: {body}"
7574 );
7575 }
7576
7577 #[tokio::test]
7579 async fn about_describes_publications_and_how_to_subscribe_when_on() {
7580 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7581 assert!(body.contains("site.standard.publication"), "{body}");
7582 assert!(body.contains("site.standard.document"), "{body}");
7583 assert!(
7584 body.contains("at://did:plc:…/site.standard.publication/…"),
7585 "{body}"
7586 );
7587 assert!(
7588 body.contains("at://alice.example.com/site.standard.publication/…"),
7589 "{body}"
7590 );
7591 }
7592
7593 #[tokio::test]
7595 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7596 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7597 assert!(body.contains("site.standard.publication"), "{body}");
7598 assert!(
7599 !body.contains("at://did:plc:…/site.standard.publication/…"),
7600 "no paste instructions with the flag off: {body}"
7601 );
7602 assert!(
7603 !body.contains("at://alice.example.com/site.standard.publication/…"),
7604 "no paste instructions with the flag off: {body}"
7605 );
7606 assert!(
7607 body.contains("isn't accepting new publication subscriptions"),
7608 "{body}"
7609 );
7610 }
7611
7612 #[tokio::test]
7620 async fn standard_site_page_renders_signed_out() {
7621 let body = public_body(test_state(&[]).await, "/standard-site").await;
7622 assert!(body.contains("site.standard.publication"), "{body}");
7623 assert!(body.contains("site.standard.document"), "{body}");
7624 assert!(
7625 body.contains("<title>standard.site — FeatherReader</title>"),
7626 "{body}"
7627 );
7628 }
7629
7630 #[tokio::test]
7633 async fn standard_site_page_tells_how_to_subscribe_when_on() {
7634 let body = public_body(
7635 standard_site_state(true, "did:plc:x").await,
7636 "/standard-site",
7637 )
7638 .await;
7639 assert!(
7640 body.contains("at://did:plc:…/site.standard.publication/…"),
7641 "the DID form must be shown: {body}"
7642 );
7643 assert!(
7644 body.contains("at://alice.example.com/site.standard.publication/…"),
7645 "the handle form must be shown: {body}"
7646 );
7647 assert!(
7648 body.contains("resolved to its DID"),
7649 "the handle resolution must be stated: {body}"
7650 );
7651 assert!(
7652 !body.contains("isn't accepting new publication subscriptions"),
7653 "{body}"
7654 );
7655 }
7656
7657 #[tokio::test]
7661 async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7662 let state = standard_site_state(false, "did:plc:x").await;
7663 assert!(!state.config.standard_site);
7664 let body = public_body(state, "/standard-site").await;
7665 assert!(body.contains("site.standard.publication"), "{body}");
7666 assert!(
7667 !body.contains("at://did:plc:…/site.standard.publication/…"),
7668 "no paste instructions with the flag off: {body}"
7669 );
7670 assert!(
7671 !body.contains("at://alice.example.com/site.standard.publication/…"),
7672 "no paste instructions with the flag off: {body}"
7673 );
7674 assert!(
7675 body.contains("isn't accepting new publication subscriptions"),
7676 "the page must say the form is closed here: {body}"
7677 );
7678 assert!(
7679 body.contains("already follows are still read"),
7680 "stored publications are polled whatever the flag says: {body}"
7681 );
7682 }
7683
7684 #[tokio::test]
7687 async fn releases_callout_links_the_release_pages() {
7688 for path in ["/standard-site", "/"] {
7689 let body = public_body(test_state(&[]).await, path).await;
7690 for tag in ["v0.4.1", "v0.4.0"] {
7691 let href = format!(
7692 "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7693 );
7694 assert!(body.contains(&href), "{path} must link {tag}: {body}");
7695 }
7696 assert!(
7697 body.contains(
7698 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7699 ),
7700 "{path} must link the changelog: {body}"
7701 );
7702 }
7703 }
7704
7705 #[tokio::test]
7709 async fn landing_about_and_footer_link_the_standard_site_page() {
7710 for path in ["/", "/about", "/privacy"] {
7711 let body = public_body(test_state(&[]).await, path).await;
7712 assert!(
7713 body.contains("href=\"/standard-site\""),
7714 "{path} must link the feature page: {body}"
7715 );
7716 }
7717 }
7718
7719 #[test]
7723 fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7724 assert!(!RELEASES.is_empty());
7725 let parse = |v: &str| -> Vec<u32> {
7726 v.split('.')
7727 .map(|p| p.parse::<u32>().expect("a numeric version part"))
7728 .collect()
7729 };
7730 for pair in RELEASES.windows(2) {
7731 assert!(
7732 parse(pair[0].version) > parse(pair[1].version),
7733 "{} must come before {}",
7734 pair[0].version,
7735 pair[1].version
7736 );
7737 }
7738 for r in RELEASES {
7739 assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7740 assert!(
7741 chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7742 "{} is not YYYY-MM-DD",
7743 r.date
7744 );
7745 assert!(!r.summary.trim().is_empty());
7746 assert!(!r.summary.contains('<'), "the summary is plain text");
7747 assert_eq!(
7748 r.url(),
7749 format!(
7750 "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7751 r.version
7752 )
7753 );
7754 }
7755 let latest = &RELEASES[0];
7758 assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7759 assert_eq!(
7760 latest.changelog_url(),
7761 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#043--2026-10-05"
7762 );
7763 }
7764
7765 #[tokio::test]
7767 async fn standard_site_page_is_publicly_cacheable() {
7768 let resp = router(test_state(&[]).await)
7769 .oneshot(
7770 Request::builder()
7771 .uri("/standard-site")
7772 .body(Body::empty())
7773 .unwrap(),
7774 )
7775 .await
7776 .unwrap();
7777 assert_eq!(resp.status(), StatusCode::OK);
7778 assert_eq!(
7779 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7780 "public, max-age=300"
7781 );
7782 }
7783
7784 #[tokio::test]
7785 async fn cache_control_public_on_about_no_store_on_authed() {
7786 let state = test_state(&["did:plc:admin"]).await;
7787 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7788 let app = router(state);
7789
7790 let about = app
7792 .clone()
7793 .oneshot(
7794 Request::builder()
7795 .uri("/about")
7796 .body(Body::empty())
7797 .unwrap(),
7798 )
7799 .await
7800 .unwrap();
7801 assert_eq!(
7802 about.headers().get(header::CACHE_CONTROL).unwrap(),
7803 "public, max-age=300"
7804 );
7805 assert_eq!(
7811 about.headers()["content-security-policy"],
7812 EXPECTED_CSP,
7813 "the CSP is not the policy the router promises"
7814 );
7815 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7816
7817 for path in ["/privacy", "/terms"] {
7819 let resp = app
7820 .clone()
7821 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7822 .await
7823 .unwrap();
7824 assert_eq!(resp.status(), StatusCode::OK);
7825 assert_eq!(
7826 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7827 "public, max-age=300",
7828 "{path} should be publicly cacheable"
7829 );
7830 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7832 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7833 }
7834
7835 let login = app
7837 .clone()
7838 .oneshot(
7839 Request::builder()
7840 .uri("/login")
7841 .body(Body::empty())
7842 .unwrap(),
7843 )
7844 .await
7845 .unwrap();
7846 assert_eq!(
7847 login.headers().get(header::CACHE_CONTROL).unwrap(),
7848 "public, max-age=300"
7849 );
7850
7851 let home = app
7853 .oneshot(
7854 Request::builder()
7855 .uri("/")
7856 .header(header::COOKIE, admin_cookie)
7857 .body(Body::empty())
7858 .unwrap(),
7859 )
7860 .await
7861 .unwrap();
7862 assert_eq!(
7863 home.headers().get(header::CACHE_CONTROL).unwrap(),
7864 "no-store"
7865 );
7866 }
7867
7868 fn head(body: &str) -> &str {
7877 let end = body.find("</head>").expect("a <head>");
7878 &body[..end]
7879 }
7880
7881 fn meta(head: &str, attr: &str) -> Option<String> {
7884 let tag_start = head.find(attr)?;
7885 let rest = &head[tag_start..];
7886 let tag_end = rest.find('>')?;
7887 let tag = &rest[..tag_end];
7888 let content = tag.find("content=\"")? + "content=\"".len();
7889 let close = tag[content..].find('"')?;
7890 Some(tag[content..content + close].to_string())
7891 }
7892
7893 async fn production_origin_state() -> AppState {
7897 let db = store::init_url("sqlite::memory:").await.unwrap();
7898 store::ensure_seed(&db, &["did:plc:admin".to_string()])
7899 .await
7900 .unwrap();
7901 let config = Config {
7902 allowed_dids: vec!["did:plc:admin".to_string()],
7903 cookie_secret: "test-cookie-secret-000".to_string(),
7904 beta_cap: 3,
7905 public_url: "https://feather-reader.com".to_string(),
7906 ..Config::default()
7907 };
7908 AppState::new(config, db).unwrap()
7909 }
7910
7911 #[tokio::test]
7914 async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7915 let landing = public_body(production_origin_state().await, "/").await;
7916 let about = public_body(production_origin_state().await, "/about").await;
7917 let (lh, ah) = (head(&landing), head(&about));
7918
7919 assert_eq!(
7920 meta(lh, "property=\"og:title\"").as_deref(),
7921 Some("FeatherReader — read, quietly"),
7922 "{lh}"
7923 );
7924 assert_eq!(
7925 meta(ah, "property=\"og:title\"").as_deref(),
7926 Some("About — FeatherReader"),
7927 "{ah}"
7928 );
7929 for (h, path) in [(lh, "/"), (ah, "/about")] {
7930 let url = format!("https://feather-reader.com{path}");
7931 assert_eq!(
7932 meta(h, "property=\"og:url\"").as_deref(),
7933 Some(url.as_str())
7934 );
7935 assert!(
7936 h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7937 "{path} must carry a canonical link: {h}"
7938 );
7939 let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7940 assert!(
7941 image.starts_with("https://feather-reader.com/static/"),
7942 "{path}: og:image must be absolute on the public origin, got {image:?}"
7943 );
7944 assert_eq!(
7945 meta(h, "name=\"twitter:card\"").as_deref(),
7946 Some("summary_large_image")
7947 );
7948 assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7949 assert_eq!(
7950 meta(h, "property=\"og:site_name\"").as_deref(),
7951 Some("FeatherReader")
7952 );
7953 let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7954 assert!(!description.is_empty(), "{path}: og:description is empty");
7955 assert_eq!(
7956 meta(h, "name=\"description\"").as_deref(),
7957 Some(description.as_str()),
7958 "{path}: the meta description and og:description must agree"
7959 );
7960 }
7961 assert_ne!(
7962 meta(lh, "property=\"og:description\""),
7963 meta(ah, "property=\"og:description\""),
7964 "the landing page and /about must not share a description"
7965 );
7966 }
7967
7968 #[tokio::test]
7970 async fn card_urls_follow_the_configured_public_url() {
7971 let db = store::init_url("sqlite::memory:").await.unwrap();
7972 store::ensure_seed(&db, &[]).await.unwrap();
7973 let config = Config {
7974 cookie_secret: "test-cookie-secret-000".to_string(),
7975 public_url: "https://reader.example.org".to_string(),
7976 ..Config::default()
7977 };
7978 let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
7979 let h = head(&body);
7980 assert_eq!(
7981 meta(h, "property=\"og:url\"").as_deref(),
7982 Some("https://reader.example.org/privacy")
7983 );
7984 assert_eq!(
7985 meta(h, "property=\"og:image\"").as_deref(),
7986 Some("https://reader.example.org/static/social-card.png")
7987 );
7988 }
7989
7990 #[tokio::test]
7993 async fn public_pages_each_carry_their_own_description() {
7994 let paths = [
7995 "/",
7996 "/about",
7997 "/privacy",
7998 "/terms",
7999 "/stats",
8000 "/standard-site",
8001 "/login",
8002 "/beta/redeem",
8003 ];
8004 let mut seen = std::collections::HashSet::new();
8005 for path in paths {
8006 let body = public_body(production_origin_state().await, path).await;
8007 let h = head(&body);
8008 let description = meta(h, "name=\"description\"").unwrap_or_default();
8009 assert!(!description.is_empty(), "{path} has no description: {h}");
8010 assert!(
8011 seen.insert(description.clone()),
8012 "{path} repeats another page's description: {description:?}"
8013 );
8014 assert_eq!(
8015 meta(h, "property=\"og:url\"").as_deref(),
8016 Some(format!("https://feather-reader.com{path}").as_str()),
8017 "{path}"
8018 );
8019 assert!(
8020 !h.contains("name=\"robots\""),
8021 "{path} is public and must not be noindex: {h}"
8022 );
8023 }
8024 }
8025
8026 #[tokio::test]
8029 async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8030 let landing = public_body(production_origin_state().await, "/").await;
8031 let h = head(&landing);
8032 let image = meta(h, "property=\"og:image\"").unwrap();
8033 let path = image.strip_prefix("https://feather-reader.com").unwrap();
8034 let width: u32 = meta(h, "property=\"og:image:width\"")
8035 .unwrap()
8036 .parse()
8037 .unwrap();
8038 let height: u32 = meta(h, "property=\"og:image:height\"")
8039 .unwrap()
8040 .parse()
8041 .unwrap();
8042 assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8043 assert_eq!(
8044 meta(h, "property=\"og:image:type\"").as_deref(),
8045 Some("image/png")
8046 );
8047 assert!(
8048 !meta(h, "property=\"og:image:alt\"")
8049 .unwrap_or_default()
8050 .is_empty(),
8051 "the image needs alt text"
8052 );
8053
8054 let resp = router(production_origin_state().await)
8055 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8056 .await
8057 .unwrap();
8058 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8059 assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8060 assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8061 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8062 .await
8063 .expect("the image is under 1 MB");
8064 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8065 let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8067 assert_eq!(
8068 (be(16), be(20)),
8069 (width, height),
8070 "the PNG's own dimensions must match the tags"
8071 );
8072 }
8073
8074 #[tokio::test]
8077 async fn private_pages_keep_user_data_out_of_the_card() {
8078 for path in ["/", "/manage"] {
8079 let state = production_origin_state().await;
8080 let body = signed_in_body(state, path, "did:plc:admin").await;
8081 let h = head(&body);
8082 assert!(
8083 h.contains("<meta name=\"robots\" content=\"noindex\""),
8084 "{path}: a private view must be noindex: {h}"
8085 );
8086 assert_eq!(
8087 meta(h, "property=\"og:title\"").as_deref(),
8088 Some("FeatherReader — read, quietly"),
8089 "{path}: the card of a private view is the site's generic one"
8090 );
8091 assert_eq!(
8092 meta(h, "property=\"og:url\"").as_deref(),
8093 Some("https://feather-reader.com/"),
8094 "{path}: og:url of a private view is the front door, not the private path"
8095 );
8096 for private in ["reader.example", "did:plc:admin"] {
8097 assert!(
8098 !h.contains(private),
8099 "{path}: {private:?} must not reach <head>: {h}"
8100 );
8101 }
8102 }
8103 }
8104
8105 #[tokio::test]
8106 async fn beta_redeem_page_renders() {
8107 let state = test_state(&[]).await;
8108 let app = router(state);
8109 let resp = app
8110 .oneshot(
8111 Request::builder()
8112 .uri("/beta/redeem")
8113 .body(Body::empty())
8114 .unwrap(),
8115 )
8116 .await
8117 .unwrap();
8118 assert_eq!(resp.status(), StatusCode::OK);
8119 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8120 .await
8121 .unwrap();
8122 let html = String::from_utf8(bytes.to_vec()).unwrap();
8123 assert!(html.contains("Invite code"));
8124 assert!(html.contains("/beta/redeem"));
8125 }
8126
8127 #[tokio::test]
8128 async fn rate_limit_returns_429_after_burst() {
8129 let db = store::init_url("sqlite::memory:").await.unwrap();
8132 store::ensure_seed(&db, &[]).await.unwrap();
8133 let config = Config {
8134 cookie_secret: "test-cookie-secret-000".to_string(),
8135 beta_cap: 3,
8136 trusted_ip_header: Some("cf-connecting-ip".to_string()),
8137 ..Config::default()
8138 };
8139 let state = AppState::new(config, db).unwrap();
8140 let app = router(state);
8141 let mut saw_429 = false;
8145 for _ in 0..(RATE_BURST as usize + 5) {
8146 let resp = app
8147 .clone()
8148 .oneshot(
8149 Request::builder()
8150 .method("POST")
8151 .uri("/beta/redeem")
8152 .header("content-type", "application/x-www-form-urlencoded")
8153 .header("cf-connecting-ip", "203.0.113.200")
8154 .body(Body::from("code=FEATHER-NOPENOPE"))
8155 .unwrap(),
8156 )
8157 .await
8158 .unwrap();
8159 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8160 saw_429 = true;
8161 break;
8162 }
8163 }
8164 assert!(saw_429, "expected a 429 after exhausting the burst");
8165 }
8166
8167 #[tokio::test]
8180 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8181 let state = test_state(&[]).await;
8182 assert!(
8183 state.config.trusted_ip_header.is_none(),
8184 "no proxy header is trusted here"
8185 );
8186 let app = router(state);
8187 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8188 let mut saw_429 = false;
8189 for i in 0..(RATE_BURST as usize + 5) {
8190 let forged = format!("10.9.8.{}", i % 250);
8191 let resp = app
8192 .clone()
8193 .oneshot(
8194 Request::builder()
8195 .method("POST")
8196 .uri("/beta/redeem")
8197 .header("content-type", "application/x-www-form-urlencoded")
8198 .header("x-forwarded-for", forged)
8199 .extension(axum::extract::ConnectInfo(peer))
8200 .body(Body::from("code=FEATHER-NOPENOPE"))
8201 .unwrap(),
8202 )
8203 .await
8204 .unwrap();
8205 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8206 saw_429 = true;
8207 break;
8208 }
8209 }
8210 assert!(
8211 saw_429,
8212 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8213 );
8214 }
8215
8216 #[tokio::test]
8225 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8226 let did = "did:plc:privateadder";
8227 let state = test_state_with_caps(did, 0, 0).await;
8228 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8229 let port: u16 = base
8230 .trim_end_matches('/')
8231 .rsplit(':')
8232 .next()
8233 .unwrap()
8234 .parse()
8235 .unwrap();
8236 crate::net::test_host_override(
8237 "private-add.test",
8238 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8239 );
8240 let cookie = session_cookie(&state, did, None);
8241 let resp = router(state.clone())
8242 .oneshot(
8243 Request::builder()
8244 .method("POST")
8245 .uri("/subscriptions")
8246 .header(header::COOKIE, cookie)
8247 .header("content-type", "application/x-www-form-urlencoded")
8248 .body(Body::from(format!(
8249 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8250 )))
8251 .unwrap(),
8252 )
8253 .await
8254 .unwrap();
8255 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8256 let loc = resp
8257 .headers()
8258 .get(header::LOCATION)
8259 .unwrap()
8260 .to_str()
8261 .unwrap();
8262 assert!(loc.contains("Private"), "not refused as private: {loc}");
8263 assert_eq!(
8264 hits.load(std::sync::atomic::Ordering::SeqCst),
8265 0,
8266 "the private feed was FETCHED before being refused"
8267 );
8268 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8269 }
8270
8271 #[tokio::test]
8276 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8277 let did = "did:plc:renamer4";
8278 let (sidecar, bodies) = spawn_logging_sidecar().await;
8279 let state = test_state_with_sidecar(&[did], &sidecar).await;
8280 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8281 let opml = format!(
8282 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8283 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8284 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8285 </body></opml>"
8286 );
8287 let (ct, body) = opml_multipart(opml.as_bytes());
8288 let cookie = session_cookie(&state, did, None);
8289 let resp = router(state.clone())
8290 .oneshot(
8291 Request::builder()
8292 .method("POST")
8293 .uri("/opml")
8294 .header(header::COOKIE, cookie)
8295 .header("content-type", ct)
8296 .body(Body::from(body))
8297 .unwrap(),
8298 )
8299 .await
8300 .unwrap();
8301 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8302 let loc = resp
8303 .headers()
8304 .get(header::LOCATION)
8305 .unwrap()
8306 .to_str()
8307 .unwrap();
8308 assert!(
8309 loc.contains("skipped%20as%20private"),
8310 "not reported as skipped: {loc}"
8311 );
8312 assert!(store::get_feed_by_url(&state.db, tokened)
8313 .await
8314 .unwrap()
8315 .is_none());
8316 let sent = bodies.lock().unwrap().join("\n");
8317 assert!(
8318 sent.contains("public.example"),
8319 "the public feed was not written: {sent}"
8320 );
8321 assert!(
8322 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8323 "the secret was PUBLISHED to the PDS: {sent}"
8324 );
8325 }
8326
8327 #[tokio::test]
8331 async fn get_login_without_a_seat_is_refused() {
8332 let state = test_state(&[]).await;
8333 let resp = router(state)
8334 .oneshot(
8335 Request::builder()
8336 .method("GET")
8337 .uri("/login?handle=alice.bsky.social")
8338 .body(Body::empty())
8339 .unwrap(),
8340 )
8341 .await
8342 .unwrap();
8343 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8344 assert_eq!(
8345 resp.headers().get(header::LOCATION).unwrap(),
8346 "/beta/redeem"
8347 );
8348 }
8349
8350 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8354 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8355 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8356 let addr = listener.local_addr().unwrap();
8357 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8358 let sink = log.clone();
8359 tokio::spawn(async move {
8360 loop {
8361 let Ok((mut sock, _)) = listener.accept().await else {
8362 break;
8363 };
8364 let mut raw: Vec<u8> = Vec::new();
8365 let mut chunk = [0u8; 4096];
8366 let text = loop {
8367 let Ok(n) = sock.read(&mut chunk).await else {
8368 break String::new();
8369 };
8370 if n == 0 {
8371 break String::from_utf8_lossy(&raw).to_string();
8372 }
8373 raw.extend_from_slice(&chunk[..n]);
8374 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8375 continue;
8376 };
8377 let (head, body) = raw.split_at(split + 4);
8378 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8379 let (k, v) = l.split_once(':')?;
8380 k.eq_ignore_ascii_case("content-length")
8381 .then(|| v.trim().parse::<usize>().ok())?
8382 });
8383 if want.is_none_or(|w| body.len() >= w) {
8384 break String::from_utf8_lossy(&raw).to_string();
8385 }
8386 };
8387 let path = text
8388 .lines()
8389 .next()
8390 .and_then(|l| l.split_whitespace().nth(1))
8391 .unwrap_or("")
8392 .to_string();
8393 let body_text = text
8394 .split_once("\r\n\r\n")
8395 .map(|(_, b)| b)
8396 .unwrap_or("")
8397 .to_string();
8398 sink.lock().unwrap().push(format!("{path} {body_text}"));
8399 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();
8400 let resp = format!(
8401 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8402 body.len(),
8403 body
8404 );
8405 let _ = sock.write_all(resp.as_bytes()).await;
8406 let _ = sock.flush().await;
8407 }
8408 });
8409 (format!("http://{addr}"), log)
8410 }
8411
8412 #[tokio::test]
8418 async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8419 use crate::readstate::tests as rs;
8420 for backend in [
8421 crate::metrics::Backend::Sidecar,
8422 crate::metrics::Backend::Rust,
8423 ] {
8424 let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8425 let state = rs::state_on(backend, &fake).await;
8426 for i in 0..250 {
8427 rs::mark_read(&state, i, "1").await;
8428 }
8429 fake.lock().unwrap().drop_call = Some(2);
8430
8431 flush_before_revoke(&state, rs::DID).await;
8432
8433 let order = rs::send_order(250);
8434 let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8435 for &i in landed {
8436 let c = rs::cursor(&state, i).await;
8437 assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8438 }
8439 for &i in rest {
8440 let c = rs::cursor(&state, i).await;
8441 assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8442 }
8443 assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8444 }
8445 }
8446
8447 #[tokio::test]
8456 async fn signing_out_flushes_before_it_revokes_through_the_route() {
8457 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8458 let (sidecar, log) = spawn_logging_sidecar().await;
8459 let state = test_state_with_sidecar(&[did], &sidecar).await;
8460 crate::store::upsert_cursor(
8461 &state.db,
8462 &crate::store::ReadCursor {
8463 did: did.to_string(),
8464 feed_url: "https://example.com/feed.xml".into(),
8465 read_through: None,
8466 read_ids: "[\"1\"]".into(),
8467 unread_ids: "[]".into(),
8468 dirty: true,
8469 pds_created: false,
8470 updated_at: "2026-09-13T21:22:40Z".into(),
8471 },
8472 )
8473 .await
8474 .unwrap();
8475 let cookie = session_cookie(&state, did, None);
8476 let resp = router(state.clone())
8477 .oneshot(
8478 Request::builder()
8479 .method("POST")
8480 .uri("/logout")
8481 .header(header::COOKIE, cookie)
8482 .body(Body::empty())
8483 .unwrap(),
8484 )
8485 .await
8486 .unwrap();
8487 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8488
8489 let entries = log.lock().unwrap().clone();
8490 let flush = entries
8491 .iter()
8492 .position(|e| e.starts_with("/internal/repo "));
8493 let revoke = entries
8494 .iter()
8495 .position(|e| e.starts_with("/internal/revoke "));
8496 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8497 assert!(
8498 flush.is_some(),
8499 "sign-out did not attempt a flush before revoking: {entries:?}"
8500 );
8501 assert!(
8502 flush < revoke,
8503 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8504 );
8505 }
8506
8507 const EXPECTED_CSP: &str = "default-src 'self'; \
8511 script-src 'self'; \
8512 style-src 'self' 'unsafe-inline'; \
8513 img-src 'self' https: data:; \
8514 font-src 'self'; \
8515 connect-src 'self'; \
8516 form-action 'self'; \
8517 base-uri 'self'; \
8518 frame-ancestors 'none'; \
8519 object-src 'none'";
8520
8521 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8524 let boundary = "----featherreadertestboundary";
8525 let mut body = Vec::new();
8526 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8527 body.extend_from_slice(
8528 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8529 );
8530 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8531 body.extend_from_slice(payload);
8532 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8533 (format!("multipart/form-data; boundary={boundary}"), body)
8534 }
8535
8536 #[tokio::test]
8537 async fn opml_import_oversize_upload_returns_413() {
8538 let state = test_state(&["did:plc:admin"]).await;
8539 let cookie = session_cookie(&state, "did:plc:admin", None);
8540 let app = router(state);
8541
8542 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8544 let (content_type, body) = opml_multipart(&payload);
8545
8546 let resp = app
8547 .oneshot(
8548 Request::builder()
8549 .method("POST")
8550 .uri("/opml")
8551 .header("content-type", content_type)
8552 .header(header::COOKIE, cookie)
8553 .body(Body::from(body))
8554 .unwrap(),
8555 )
8556 .await
8557 .unwrap();
8558 assert_eq!(
8559 resp.status(),
8560 StatusCode::PAYLOAD_TOO_LARGE,
8561 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8562 );
8563 }
8564
8565 #[tokio::test]
8576 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8577 let state = test_state(&["did:plc:admin"]).await;
8578 let cookie = session_cookie(&state, "did:plc:admin", None);
8579 let app = router(state);
8580
8581 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8583 let (content_type, body) = opml_multipart(&payload);
8584
8585 let resp = app
8586 .oneshot(
8587 Request::builder()
8588 .method("POST")
8589 .uri("/opml")
8590 .header("content-type", content_type)
8591 .header(header::COOKIE, cookie)
8592 .body(Body::from(body))
8593 .unwrap(),
8594 )
8595 .await
8596 .unwrap();
8597 assert_eq!(
8598 resp.status(),
8599 StatusCode::PAYLOAD_TOO_LARGE,
8600 "a payload over the route's cap but under the framework's was accepted — \
8601 the route's own DefaultBodyLimit layer is not doing anything"
8602 );
8603 }
8604
8605 #[tokio::test]
8606 async fn opml_import_under_limit_upload_is_accepted() {
8607 let state = test_state(&["did:plc:admin"]).await;
8608 let cookie = session_cookie(&state, "did:plc:admin", None);
8609 let db = state.db.clone();
8610 let app = router(state);
8611
8612 let opml = br#"<?xml version="1.0"?>
8615<opml version="2.0"><body>
8616 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8617</body></opml>"#;
8618 let (content_type, body) = opml_multipart(opml);
8619
8620 let resp = app
8621 .oneshot(
8622 Request::builder()
8623 .method("POST")
8624 .uri("/opml")
8625 .header("content-type", content_type)
8626 .header(header::COOKIE, cookie)
8627 .body(Body::from(body))
8628 .unwrap(),
8629 )
8630 .await
8631 .unwrap();
8632 assert_eq!(
8639 resp.status(),
8640 StatusCode::SEE_OTHER,
8641 "an under-cap OPML upload was not accepted (status {})",
8642 resp.status(),
8643 );
8644 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8648 .bind("https://example.com/feed.xml")
8649 .fetch_one(&db)
8650 .await
8651 .unwrap();
8652 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8653 let location = resp
8654 .headers()
8655 .get(header::LOCATION)
8656 .and_then(|v| v.to_str().ok())
8657 .unwrap_or_default()
8658 .to_string();
8659 assert!(
8660 !location.starts_with("/login"),
8661 "the import bounced to login instead of being accepted: {location}",
8662 );
8663 }
8664
8665 #[tokio::test]
8666 async fn opml_import_logged_out_redirects_to_login() {
8667 let state = test_state(&["did:plc:admin"]).await;
8670 let app = router(state);
8671
8672 let opml = b"<opml version=\"2.0\"><body></body></opml>";
8673 let (content_type, body) = opml_multipart(opml);
8674
8675 let resp = app
8676 .oneshot(
8677 Request::builder()
8678 .method("POST")
8679 .uri("/opml")
8680 .header("content-type", content_type)
8681 .body(Body::from(body))
8682 .unwrap(),
8683 )
8684 .await
8685 .unwrap();
8686 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8687 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8688 }
8689
8690 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8697 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8698 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8699 let addr = listener.local_addr().unwrap();
8700 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8701 tokio::spawn(async move {
8702 let (mut sock, _) = listener.accept().await.unwrap();
8703 let mut buf = vec![0u8; 4096];
8704 let n = sock.read(&mut buf).await.unwrap();
8705 let req = String::from_utf8_lossy(&buf[..n]).to_string();
8706 let did = req
8708 .split("\r\n\r\n")
8709 .nth(1)
8710 .and_then(|body| {
8711 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8712 v.get("did")?.as_str().map(str::to_string)
8713 })
8714 .unwrap_or_default();
8715 let is_revoke = req.starts_with("POST /internal/revoke");
8716 let body = serde_json::json!({
8717 "ok": true, "did": did, "revoked": true, "hadSession": true
8718 })
8719 .to_string();
8720 let resp = format!(
8721 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8722 body.len(),
8723 body
8724 );
8725 sock.write_all(resp.as_bytes()).await.unwrap();
8726 sock.flush().await.unwrap();
8727 let _ = tx.send(if is_revoke { did } else { String::new() });
8728 });
8729 (format!("http://{addr}"), rx)
8730 }
8731
8732 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8734 let defaults = Config::default();
8735 test_state_with_sidecar_and(
8736 allowed,
8737 sidecar_url,
8738 defaults.standard_site,
8739 defaults.max_feeds_global,
8740 )
8741 .await
8742 }
8743
8744 async fn test_state_with_sidecar_and(
8747 allowed: &[&str],
8748 sidecar_url: &str,
8749 standard_site: bool,
8750 max_feeds_global: i64,
8751 ) -> AppState {
8752 let db = store::init_url("sqlite::memory:").await.unwrap();
8753 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8754 store::ensure_seed(&db, &dids).await.unwrap();
8755 let mut config = Config {
8756 allowed_dids: dids,
8757 cookie_secret: "test-cookie-secret-000".to_string(),
8758 beta_cap: 3,
8759 standard_site,
8760 max_feeds_global,
8761 ..Config::default()
8762 };
8763 config.sidecar.public_url = sidecar_url.to_string();
8764 config.sidecar.internal_url = sidecar_url.to_string();
8765 AppState::new(config, db).unwrap()
8766 }
8767
8768 #[tokio::test]
8771 async fn account_delete_purges_rows_and_triggers_revoke() {
8772 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8773 let did = "did:plc:leaver";
8774 let state = test_state_with_sidecar(&[], &sidecar_url).await;
8775
8776 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8778 .await
8779 .unwrap();
8780 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8781 store::mint_code(&state.db, did, 3600).await.unwrap();
8782 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8783
8784 let cookie = session_cookie(&state, did, Some("leaver.example"));
8785 let app = router(state.clone());
8786
8787 let resp = app
8788 .oneshot(
8789 Request::builder()
8790 .method("POST")
8791 .uri("/account/delete")
8792 .header(header::COOKIE, cookie)
8793 .header("content-type", "application/x-www-form-urlencoded")
8794 .body(Body::from("confirm=DELETE"))
8795 .unwrap(),
8796 )
8797 .await
8798 .unwrap();
8799
8800 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8802 assert!(resp
8803 .headers()
8804 .get(header::LOCATION)
8805 .unwrap()
8806 .to_str()
8807 .unwrap()
8808 .starts_with("/login"));
8809 let set_cookie = resp
8810 .headers()
8811 .get(header::SET_COOKIE)
8812 .unwrap()
8813 .to_str()
8814 .unwrap();
8815 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8816
8817 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8824 .await
8825 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8826 .unwrap();
8827 assert_eq!(
8828 revoked_did, did,
8829 "sidecar revoke must fire for the caller DID"
8830 );
8831
8832 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8834 let codes: i64 =
8835 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8836 .bind(did)
8837 .fetch_one(&state.db)
8838 .await
8839 .unwrap();
8840 assert_eq!(codes, 0);
8841 }
8842
8843 #[tokio::test]
8846 async fn account_delete_without_confirm_is_a_noop() {
8847 let did = "did:plc:staying";
8848 let state = test_state(&[]).await;
8849 store::grant_access(&state.db, did, None, "test", None)
8850 .await
8851 .unwrap();
8852 let cookie = session_cookie(&state, did, None);
8853 let app = router(state.clone());
8854
8855 let resp = app
8856 .oneshot(
8857 Request::builder()
8858 .method("POST")
8859 .uri("/account/delete")
8860 .header(header::COOKIE, cookie)
8861 .header("content-type", "application/x-www-form-urlencoded")
8862 .body(Body::from("confirm=nope"))
8863 .unwrap(),
8864 )
8865 .await
8866 .unwrap();
8867
8868 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8869 assert!(resp
8870 .headers()
8871 .get(header::LOCATION)
8872 .unwrap()
8873 .to_str()
8874 .unwrap()
8875 .starts_with("/manage"));
8876 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8878 }
8879
8880 #[tokio::test]
8887 async fn pds_outage_does_not_widen_cross_did_access() {
8888 let did_a = "did:plc:aaaa";
8889 let state = test_state(&[]).await;
8890 store::grant_access(&state.db, did_a, None, "test", None)
8891 .await
8892 .unwrap();
8893
8894 let feed_a = store::upsert_feed(
8897 &state.db,
8898 &store::NewFeed {
8899 url: "https://a.example/feed.xml".to_string(),
8900 title: Some("A".to_string()),
8901 ..Default::default()
8902 },
8903 )
8904 .await
8905 .unwrap();
8906 let feed_b = store::upsert_feed(
8907 &state.db,
8908 &store::NewFeed {
8909 url: "https://b.example/feed.xml".to_string(),
8910 title: Some("B".to_string()),
8911 ..Default::default()
8912 },
8913 )
8914 .await
8915 .unwrap();
8916 store::insert_entries(
8917 &state.db,
8918 feed_b,
8919 &[store::NewEntry {
8920 guid: "b-1".to_string(),
8921 url: Some("https://b.example/1".to_string()),
8922 title: Some("B one".to_string()),
8923 published: Some("2026-07-11T00:00:00Z".to_string()),
8924 content_html: Some("<p>secret B body</p>".to_string()),
8925 ..Default::default()
8926 }],
8927 0,
8928 )
8929 .await
8930 .unwrap();
8931 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8933 .await
8934 .unwrap();
8935 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8938 .await
8939 .unwrap();
8940 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8941 .await
8942 .unwrap()[0]
8943 .id;
8944 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8945 .await
8946 .unwrap();
8947
8948 let cookie = session_cookie(&state, did_a, None);
8949 let app = router(state.clone());
8950
8951 let get_b = app
8953 .clone()
8954 .oneshot(
8955 Request::builder()
8956 .method("GET")
8957 .uri(format!("/entries/{b_entry_id}"))
8958 .header(header::COOKIE, cookie.clone())
8959 .body(Body::empty())
8960 .unwrap(),
8961 )
8962 .await
8963 .unwrap();
8964 assert_eq!(
8965 get_b.status(),
8966 StatusCode::NOT_FOUND,
8967 "A must not read B's entry during a PDS outage"
8968 );
8969
8970 let read_b = app
8972 .oneshot(
8973 Request::builder()
8974 .method("POST")
8975 .uri(format!("/entries/{b_entry_id}/read"))
8976 .header(header::COOKIE, cookie)
8977 .header("content-type", "application/x-www-form-urlencoded")
8978 .body(Body::from("read=true"))
8979 .unwrap(),
8980 )
8981 .await
8982 .unwrap();
8983 assert_eq!(
8984 read_b.status(),
8985 StatusCode::NOT_FOUND,
8986 "A must not mark B's entry read during a PDS outage"
8987 );
8988
8989 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8991 .bind(did_a)
8992 .fetch_all(&state.db)
8993 .await
8994 .unwrap();
8995 assert_eq!(
8996 a_feed_ids,
8997 vec![feed_a],
8998 "outage fallback must not add feeds A never subscribed to"
8999 );
9000 let es_count: i64 =
9002 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9003 .bind(did_a)
9004 .bind(b_entry_id)
9005 .fetch_one(&state.db)
9006 .await
9007 .unwrap();
9008 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9009 }
9010
9011 #[tokio::test]
9025 async fn a_logout_with_no_session_counts_as_success() {
9026 let did = "did:plc:aaaa";
9027 let state = test_state(&[]).await;
9028 assert!(
9029 state.oauth.is_some(),
9030 "meaningless without an oauth runtime; the revoke arm would be skipped",
9031 );
9032
9033 revoke_everywhere(&state, did).await;
9034 let rows = state.metrics.snapshot();
9035 let find = |b: crate::metrics::Backend| {
9036 rows.iter()
9037 .find(|r| r.op == "oauth_revoke" && r.backend == b)
9038 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9039 };
9040
9041 let rust = find(crate::metrics::Backend::Rust);
9043 assert_eq!(
9044 rust.stats.err_count, 0,
9045 "NoSession was counted as a failure; logout is idempotent",
9046 );
9047 assert_eq!(rust.stats.ok_count, 1);
9048
9049 let sidecar = find(crate::metrics::Backend::Sidecar);
9053 assert_eq!(
9054 sidecar.stats.err_count, 1,
9055 "a failed sidecar revoke was not counted",
9056 );
9057 }
9058
9059 #[tokio::test]
9069 async fn a_failed_rust_revoke_counts_as_an_error() {
9070 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9071 let state = test_state(&[]).await;
9072 let runtime = state.oauth.as_deref().expect("oauth runtime");
9073 crate::oauth::store::put_session(
9074 &state.db,
9075 &runtime.codec,
9076 &crate::oauth::store::OAuthSession {
9077 sub: did.into(),
9078 issuer: "https://auth.invalid".into(),
9079 aud: "https://pds.invalid".into(),
9080 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9081 .to_jwk_json()
9082 .unwrap(),
9083 access_token: "at".into(),
9084 refresh_token: "rt".into(),
9085 token_type: "DPoP".into(),
9086 granted_scope: "atproto".into(),
9087 expires_at: Some(crate::store::now_unix() + 3600),
9088 },
9089 )
9090 .await
9091 .unwrap();
9092
9093 revoke_everywhere(&state, did).await;
9094
9095 let rows = state.metrics.snapshot();
9096 let rust = rows
9097 .iter()
9098 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9099 .expect("no rust oauth_revoke row");
9100 assert_eq!(
9101 rust.stats.err_count, 1,
9102 "an unreachable PDS must count as a revocation failure",
9103 );
9104 assert_eq!(rust.stats.ok_count, 0);
9105 }
9106
9107 #[test]
9123 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9124 for hostile in [
9125 "javascript:alert(1)",
9126 "JavaScript:alert(1)",
9127 " javascript:alert(1)",
9128 "data:text/html;base64,PHNjcmlwdD4=",
9129 "vbscript:msgbox(1)",
9130 "file:///etc/passwd",
9131 "//evil.example/path",
9135 ] {
9136 let link = SafeLink::external(hostile);
9137 assert!(
9138 link.is_empty(),
9139 "{hostile:?} produced a non-empty href: {link}",
9140 );
9141 assert!(
9142 !link.to_string().to_ascii_lowercase().contains("script"),
9143 "{hostile:?} leaked into the rendered link",
9144 );
9145 }
9146
9147 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9150 let link = SafeLink::external(good);
9151 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9152 assert_eq!(link.to_string(), good);
9153 }
9154 }
9155
9156 #[tokio::test]
9171 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9172 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9173 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9174 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9175 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9176
9177 let resp = router(state)
9178 .oneshot(
9179 Request::builder()
9180 .uri("/?view=starred")
9181 .body(Body::empty())
9182 .unwrap(),
9183 )
9184 .await
9185 .unwrap();
9186 assert_eq!(resp.status(), StatusCode::OK);
9187 let body = String::from_utf8(
9188 axum::body::to_bytes(resp.into_body(), usize::MAX)
9189 .await
9190 .unwrap()
9191 .to_vec(),
9192 )
9193 .unwrap();
9194
9195 assert!(
9198 !body.to_ascii_lowercase().contains("javascript:"),
9199 "the hostile scheme reached the rendered page",
9200 );
9201 assert!(
9204 body.contains("unusable link"),
9205 "the row was dropped instead of rendering without an anchor",
9206 );
9207 }
9208
9209 #[tokio::test]
9226 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9227 let did = "did:plc:readerhref";
9228 let state = test_state(&[]).await;
9229 store::grant_access(&state.db, did, None, "test", None)
9230 .await
9231 .unwrap();
9232 let feed = store::upsert_feed(
9233 &state.db,
9234 &store::NewFeed {
9235 url: "https://href.example/feed.xml".to_string(),
9236 title: Some("Href".to_string()),
9237 ..Default::default()
9238 },
9239 )
9240 .await
9241 .unwrap();
9242 store::insert_entries(
9244 &state.db,
9245 feed,
9246 &[
9247 store::NewEntry {
9248 guid: "hostile-1".to_string(),
9249 url: Some("javascript:alert(1)".to_string()),
9250 title: Some("Hostile entry".to_string()),
9251 published: Some("2026-07-11T00:00:00Z".to_string()),
9252 ..Default::default()
9253 },
9254 store::NewEntry {
9255 guid: "benign-1".to_string(),
9256 url: Some("https://href.example/post".to_string()),
9257 title: Some("Benign entry".to_string()),
9258 published: Some("2026-07-10T00:00:00Z".to_string()),
9259 ..Default::default()
9260 },
9261 ],
9262 0,
9263 )
9264 .await
9265 .unwrap();
9266 store::replace_sub_refs(&state.db, did, &[feed])
9267 .await
9268 .unwrap();
9269 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9270 let id_of = |guid: &str| {
9271 rows.iter()
9272 .find(|r| r.guid == guid)
9273 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9274 .id
9275 };
9276
9277 let cookie = session_cookie(&state, did, None);
9278 let app = router(state.clone());
9279
9280 let render = |id: i64| {
9281 let app = app.clone();
9282 let cookie = cookie.clone();
9283 async move {
9284 let resp = app
9285 .oneshot(
9286 Request::builder()
9287 .method("GET")
9288 .uri(format!("/entries/{id}"))
9289 .header(header::COOKIE, cookie)
9290 .body(Body::empty())
9291 .unwrap(),
9292 )
9293 .await
9294 .unwrap();
9295 assert_eq!(resp.status(), StatusCode::OK);
9296 String::from_utf8(
9297 axum::body::to_bytes(resp.into_body(), usize::MAX)
9298 .await
9299 .unwrap()
9300 .to_vec(),
9301 )
9302 .unwrap()
9303 }
9304 };
9305
9306 let hostile = render(id_of("hostile-1")).await;
9307 assert!(
9310 hostile.contains("Hostile entry"),
9311 "the reader did not render the entry: {hostile}",
9312 );
9313 assert!(
9314 !hostile.to_ascii_lowercase().contains("javascript:"),
9315 "the hostile scheme reached the reader page: {hostile}",
9316 );
9317 assert!(
9321 !hostile.contains("actionbar-open"),
9322 "the action bar rendered an open-original link for a refused URL: {hostile}",
9323 );
9324 assert!(
9325 !hostile.contains("Original \u{2197}"),
9326 "the byline rendered an original link for a refused URL: {hostile}",
9327 );
9328
9329 let benign = render(id_of("benign-1")).await;
9332 assert!(
9333 benign.contains("Benign entry"),
9334 "the reader did not render the benign entry: {benign}",
9335 );
9336 assert_eq!(
9340 benign
9341 .matches(r#"href="https://href.example/post""#)
9342 .count(),
9343 2,
9344 "entry.html has two `href`s for the entry URL — the byline link and \
9345 the action-bar button — and this render produced a different \
9346 number: {benign}",
9347 );
9348 assert!(
9349 benign.contains("actionbar-open"),
9350 "a legitimate entry lost its open-original button: {benign}",
9351 );
9352 assert!(
9353 benign.contains("Original \u{2197}"),
9354 "a legitimate entry lost its byline link: {benign}",
9355 );
9356 }
9357
9358 #[tokio::test]
9378 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9379 let did_a = "did:plc:aaaa";
9380 let state = test_state(&[]).await;
9381 store::grant_access(&state.db, did_a, None, "test", None)
9382 .await
9383 .unwrap();
9384
9385 let feed_a = store::upsert_feed(
9386 &state.db,
9387 &store::NewFeed {
9388 url: "https://a.example/feed.xml".to_string(),
9389 title: Some("A".to_string()),
9390 ..Default::default()
9391 },
9392 )
9393 .await
9394 .unwrap();
9395 let _feed_b = store::upsert_feed(
9396 &state.db,
9397 &store::NewFeed {
9398 url: "https://b.example/feed.xml".to_string(),
9399 title: Some("B".to_string()),
9400 ..Default::default()
9401 },
9402 )
9403 .await
9404 .unwrap();
9405 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9408 .await
9409 .unwrap();
9410
9411 assert!(
9416 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9417 "this test is only meaningful on the outage path; the repo answered",
9418 );
9419
9420 let resolved = resolve_subscriptions(&state, did_a).await;
9421
9422 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9423 assert_eq!(
9424 urls,
9425 vec!["https://a.example/feed.xml"],
9426 "the outage fallback must return the caller's OWN subscriptions only; \
9427 any other feed here is cross-tenant read access granted by an outage",
9428 );
9429 }
9430
9431 async fn test_state_with_caps(
9434 did: &str,
9435 max_subs_per_did: i64,
9436 max_feeds_global: i64,
9437 ) -> AppState {
9438 let db = store::init_url("sqlite::memory:").await.unwrap();
9439 let config = Config {
9440 cookie_secret: "test-cookie-secret-000".to_string(),
9441 beta_cap: 100,
9442 max_subs_per_did,
9443 max_feeds_global,
9444 ..Config::default()
9445 };
9446 store::grant_access(&db, did, None, "test", None)
9447 .await
9448 .unwrap();
9449 AppState::new(config, db).unwrap()
9450 }
9451
9452 fn opml_with_feeds(n: usize) -> String {
9454 let mut outlines = String::new();
9455 for i in 0..n {
9456 outlines.push_str(&format!(
9457 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9458 ));
9459 }
9460 format!(
9461 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9462 )
9463 }
9464
9465 #[tokio::test]
9470 async fn opml_import_enforces_global_feeds_ceiling() {
9471 let did = "did:plc:importer";
9472 let state = test_state_with_caps(did, 0, 3).await;
9474 let cookie = session_cookie(&state, did, None);
9475 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9476 let app = router(state.clone());
9477
9478 let resp = app
9479 .oneshot(
9480 Request::builder()
9481 .method("POST")
9482 .uri("/opml")
9483 .header(header::COOKIE, cookie)
9484 .header("content-type", ct)
9485 .body(Body::from(body))
9486 .unwrap(),
9487 )
9488 .await
9489 .unwrap();
9490 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9491
9492 let feeds = store::count_feeds(&state.db).await.unwrap();
9493 assert!(
9494 feeds <= 3,
9495 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9496 );
9497 }
9498
9499 async fn import_against_strict_pds(
9502 did: &str,
9503 n: usize,
9504 fail_call: Option<usize>,
9505 ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9506 let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9507 let state = test_state_with_sidecar(&[did], &sidecar).await;
9508 let cookie = session_cookie(&state, did, None);
9509 let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9510 let resp = router(state)
9511 .oneshot(
9512 Request::builder()
9513 .method("POST")
9514 .uri("/opml")
9515 .header(header::COOKIE, cookie)
9516 .header("content-type", ct)
9517 .body(Body::from(body))
9518 .unwrap(),
9519 )
9520 .await
9521 .unwrap();
9522 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9523 let loc = resp.headers()[header::LOCATION].to_str().unwrap();
9524 let flash = url::Url::parse(&format!("http://x{loc}"))
9525 .unwrap()
9526 .query_pairs()
9527 .find(|(k, _)| k == "flash")
9528 .map(|(_, v)| v.into_owned())
9529 .unwrap_or_default();
9530 (flash, log)
9531 }
9532
9533 #[tokio::test]
9537 async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
9538 let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
9539 assert_eq!(flash, "Imported 450 feeds", "{flash}");
9540 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
9541 }
9542
9543 #[tokio::test]
9547 async fn opml_import_that_part_lands_reports_what_landed() {
9548 let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
9549 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
9550 assert!(
9551 flash.contains("200 of 450"),
9552 "the landed count is not reported: {flash}"
9553 );
9554 assert!(
9555 !flash.contains("nothing was imported"),
9556 "200 feeds landed and the reader was told none did: {flash}"
9557 );
9558 }
9559
9560 #[tokio::test]
9563 async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
9564 let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
9565 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
9566 assert!(flash.contains("nothing was imported"), "{flash}");
9567 }
9568
9569 #[tokio::test]
9578 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9579 let did = "did:plc:typoist";
9580 let state = test_state_with_caps(did, 0, 0).await;
9581 let cookie = session_cookie(&state, did, None);
9582 for input in [
9583 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9584 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9585 ] {
9586 let resp = router(state.clone())
9587 .oneshot(
9588 Request::builder()
9589 .method("POST")
9590 .uri("/subscriptions")
9591 .header(header::COOKIE, cookie.clone())
9592 .header("content-type", "application/x-www-form-urlencoded")
9593 .body(Body::from(format!("url={input}")))
9594 .unwrap(),
9595 )
9596 .await
9597 .unwrap();
9598 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9599 let loc = resp
9600 .headers()
9601 .get(header::LOCATION)
9602 .unwrap()
9603 .to_str()
9604 .unwrap();
9605 assert!(
9606 loc.contains("kind%20of%20feed"),
9607 "expected the unsupported-feed flash for {input}, got {loc}"
9608 );
9609 assert!(
9610 !loc.contains("Private"),
9611 "a storability refusal was reported as a privacy one for {input}: {loc}"
9612 );
9613 }
9614 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9615 }
9616
9617 #[tokio::test]
9624 async fn opml_import_reports_entries_this_instance_cannot_store() {
9625 let did = "did:plc:renamer4";
9626 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9627 let state = test_state_with_sidecar(&[did], &sidecar).await;
9628 assert!(!state.config.standard_site);
9629 let opml = format!(
9630 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9631 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9632 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9633 </body></opml>"
9634 );
9635 let (ct, body) = opml_multipart(opml.as_bytes());
9636 let cookie = session_cookie(&state, did, None);
9637 let resp = router(state.clone())
9638 .oneshot(
9639 Request::builder()
9640 .method("POST")
9641 .uri("/opml")
9642 .header(header::COOKIE, cookie)
9643 .header("content-type", ct)
9644 .body(Body::from(body))
9645 .unwrap(),
9646 )
9647 .await
9648 .unwrap();
9649 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9650 let loc = resp
9651 .headers()
9652 .get(header::LOCATION)
9653 .unwrap()
9654 .to_str()
9655 .unwrap();
9656 assert!(
9657 loc.contains("Imported%201%20feed"),
9658 "unexpected flash: {loc}"
9659 );
9660 assert!(
9661 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9662 "the dropped entry was not reported: {loc}"
9663 );
9664 assert!(
9666 !loc.contains("site.standard.publication"),
9667 "the URI was echoed: {loc}"
9668 );
9669 }
9670
9671 #[tokio::test]
9674 async fn opml_import_enforces_per_did_cap() {
9675 let did = "did:plc:capped";
9676 let state = test_state_with_caps(did, 2, 0).await;
9678 let existing_a = store::upsert_feed(
9679 &state.db,
9680 &store::NewFeed {
9681 url: "https://have-a.example/feed.xml".to_string(),
9682 ..Default::default()
9683 },
9684 )
9685 .await
9686 .unwrap();
9687 let existing_b = store::upsert_feed(
9688 &state.db,
9689 &store::NewFeed {
9690 url: "https://have-b.example/feed.xml".to_string(),
9691 ..Default::default()
9692 },
9693 )
9694 .await
9695 .unwrap();
9696 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9697 .await
9698 .unwrap();
9699 let before = store::count_feeds(&state.db).await.unwrap();
9700
9701 let cookie = session_cookie(&state, did, None);
9702 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9703 let app = router(state.clone());
9704 let resp = app
9705 .oneshot(
9706 Request::builder()
9707 .method("POST")
9708 .uri("/opml")
9709 .header(header::COOKIE, cookie)
9710 .header("content-type", ct)
9711 .body(Body::from(body))
9712 .unwrap(),
9713 )
9714 .await
9715 .unwrap();
9716 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9717 let after = store::count_feeds(&state.db).await.unwrap();
9719 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9720 }
9721
9722 #[tokio::test]
9725 async fn single_add_enforces_per_did_cap() {
9726 let did = "did:plc:subcapped";
9727 let state = test_state_with_caps(did, 1, 0).await;
9728 let f = store::upsert_feed(
9729 &state.db,
9730 &store::NewFeed {
9731 url: "https://have.example/feed.xml".to_string(),
9732 ..Default::default()
9733 },
9734 )
9735 .await
9736 .unwrap();
9737 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9738 let cookie = session_cookie(&state, did, None);
9739 let app = router(state.clone());
9740 let resp = app
9741 .oneshot(
9742 Request::builder()
9743 .method("POST")
9744 .uri("/subscriptions")
9745 .header(header::COOKIE, cookie)
9746 .header("content-type", "application/x-www-form-urlencoded")
9747 .body(Body::from("url=https://another.example/feed.xml"))
9748 .unwrap(),
9749 )
9750 .await
9751 .unwrap();
9752 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9753 let loc = resp
9754 .headers()
9755 .get(header::LOCATION)
9756 .unwrap()
9757 .to_str()
9758 .unwrap();
9759 assert!(
9760 loc.contains("Subscription%20limit%20reached"),
9761 "expected sub-limit flash, got {loc}"
9762 );
9763 }
9764
9765 #[tokio::test]
9774 async fn the_reader_index_pages_instead_of_rendering_everything() {
9775 let did = "did:plc:pager";
9776 let state = test_state(&[]).await;
9777 store::grant_access(&state.db, did, None, "test", None)
9778 .await
9779 .unwrap();
9780 let feed = store::upsert_feed(
9781 &state.db,
9782 &store::NewFeed {
9783 url: "https://pager.example/feed.xml".to_string(),
9784 title: Some("Pager".to_string()),
9785 ..Default::default()
9786 },
9787 )
9788 .await
9789 .unwrap();
9790 let total = 250_usize;
9791 let entries: Vec<store::NewEntry> = (0..total)
9792 .map(|i| store::NewEntry {
9793 guid: format!("p-{i:04}"),
9794 url: Some(format!("https://pager.example/{i}")),
9795 title: Some(format!("Article {i:04}")),
9796 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9797 content_html: Some("x".repeat(4_000)),
9798 ..Default::default()
9799 })
9800 .collect();
9801 store::insert_entries(&state.db, feed, &entries, 0)
9802 .await
9803 .unwrap();
9804 store::replace_sub_refs(&state.db, did, &[feed])
9805 .await
9806 .unwrap();
9807
9808 let cookie = session_cookie(&state, did, None);
9809 let app = router(state.clone());
9810 let get = |uri: &str| {
9811 let app = app.clone();
9812 let cookie = cookie.clone();
9813 let uri = uri.to_string();
9814 async move {
9815 let resp = app
9816 .oneshot(
9817 Request::builder()
9818 .uri(uri)
9819 .header(header::COOKIE, cookie)
9820 .body(Body::empty())
9821 .unwrap(),
9822 )
9823 .await
9824 .unwrap();
9825 assert_eq!(resp.status(), StatusCode::OK);
9826 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9827 .await
9828 .unwrap();
9829 String::from_utf8(bytes.to_vec()).unwrap()
9830 }
9831 };
9832
9833 let page1 = get("/").await;
9834 let rows1 = page1.matches("<li class=\"entry").count();
9838 assert!(
9839 rows1 <= ENTRIES_PER_PAGE as usize,
9840 "page 1 rendered {rows1} entry links; the list is unbounded"
9841 );
9842 assert!(
9843 rows1 > 0,
9844 "page 1 rendered nothing at all: the page bound swallowed the list"
9845 );
9846 assert!(
9849 page1.contains("250 entries"),
9850 "heading must report the full total, not the page"
9851 );
9852 assert!(
9853 page1.contains("page=2"),
9854 "no way to reach the rest of the list: {}",
9855 &page1[..page1.len().min(400)]
9856 );
9857 assert!(
9859 !page1.contains(&"x".repeat(4_000)),
9860 "the list response carried an article body"
9861 );
9862
9863 let page2 = get("/?page=2").await;
9864 assert!(
9865 page2.matches("<li class=\"entry").count() > 0,
9866 "page 2 rendered no rows at all"
9867 );
9868 assert!(
9869 page2.contains("page=1") || page2.contains("Newer"),
9870 "page 2 offers no way back"
9871 );
9872 let first_title = (0..total)
9874 .map(|i| format!("Article {i:04}"))
9875 .find(|t| page1.contains(t))
9876 .expect("page 1 shows at least one titled article");
9877 assert!(
9878 !page2.contains(&first_title),
9879 "{first_title} appears on both pages"
9880 );
9881
9882 let past_end = get("/?page=999").await;
9888 assert!(
9889 past_end.matches("<li class=\"entry").count() > 0,
9890 "an out-of-range page rendered nothing and offered no way back"
9891 );
9892 assert!(
9893 past_end.contains("page=2"),
9894 "the clamped page offers no pager"
9895 );
9896 }
9897
9898 #[tokio::test]
9905 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9906 let did = "did:plc:reader";
9907 let state = test_state(&[]).await;
9908 store::grant_access(&state.db, did, None, "test", None)
9909 .await
9910 .unwrap();
9911 let feed = store::upsert_feed(
9912 &state.db,
9913 &store::NewFeed {
9914 url: "https://reader.example/feed.xml".to_string(),
9915 title: Some("Reader".to_string()),
9916 ..Default::default()
9917 },
9918 )
9919 .await
9920 .unwrap();
9921 store::insert_entries(
9922 &state.db,
9923 feed,
9924 &[store::NewEntry {
9925 guid: "r-1".to_string(),
9926 url: Some("https://reader.example/1".to_string()),
9927 title: Some("Article".to_string()),
9928 published: Some("2026-07-11T00:00:00Z".to_string()),
9929 content_html: Some("<p>body</p>".to_string()),
9930 ..Default::default()
9931 }],
9932 0,
9933 )
9934 .await
9935 .unwrap();
9936 store::replace_sub_refs(&state.db, did, &[feed])
9937 .await
9938 .unwrap();
9939 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9940
9941 let cookie = session_cookie(&state, did, None);
9942 let app = router(state.clone());
9943
9944 let resp = app
9946 .clone()
9947 .oneshot(
9948 Request::builder()
9949 .method("POST")
9950 .uri(format!("/entries/{entry_id}/read"))
9951 .header(header::COOKIE, cookie.clone())
9952 .header("HX-Request", "true")
9953 .header("X-FR-Reader", "1")
9954 .header("content-type", "application/x-www-form-urlencoded")
9955 .body(Body::from("read=true"))
9956 .unwrap(),
9957 )
9958 .await
9959 .unwrap();
9960 assert_eq!(resp.status(), StatusCode::OK);
9961 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9962 .await
9963 .unwrap();
9964 let html = String::from_utf8(bytes.to_vec()).unwrap();
9965 assert!(
9966 html.contains("hx-swap-oob=\"outerHTML\""),
9967 "reader response must be an OOB swap: {html}"
9968 );
9969 assert!(
9970 html.contains(r#"id="entry-actionbar""#),
9971 "reader response must be the action-bar fragment: {html}"
9972 );
9973 assert!(
9976 html.contains(r#"aria-pressed="true""#),
9977 "read button must show pressed after marking read: {html}"
9978 );
9979 assert!(
9980 html.contains(r#"name="read" value="false""#),
9981 "hidden read value must flip to false so a second tap reverses: {html}"
9982 );
9983
9984 let resp2 = app
9987 .oneshot(
9988 Request::builder()
9989 .method("POST")
9990 .uri(format!("/entries/{entry_id}/read"))
9991 .header(header::COOKIE, cookie)
9992 .header("HX-Request", "true")
9993 .header("X-FR-Reader", "1")
9994 .header("content-type", "application/x-www-form-urlencoded")
9995 .body(Body::from("read=false"))
9996 .unwrap(),
9997 )
9998 .await
9999 .unwrap();
10000 assert_eq!(resp2.status(), StatusCode::OK);
10001 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10002 .await
10003 .unwrap();
10004 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10005 assert!(
10006 html2.contains(r#"aria-pressed="false""#),
10007 "read button must show un-pressed after reversing: {html2}"
10008 );
10009 assert!(
10010 html2.contains(r#"name="read" value="true""#),
10011 "hidden read value must flip back to true: {html2}"
10012 );
10013 }
10014
10015 #[tokio::test]
10018 async fn list_mark_read_returns_row_not_oob_actionbar() {
10019 let did = "did:plc:listv";
10020 let state = test_state(&[]).await;
10021 store::grant_access(&state.db, did, None, "test", None)
10022 .await
10023 .unwrap();
10024 let feed = store::upsert_feed(
10025 &state.db,
10026 &store::NewFeed {
10027 url: "https://list.example/feed.xml".to_string(),
10028 title: Some("List".to_string()),
10029 ..Default::default()
10030 },
10031 )
10032 .await
10033 .unwrap();
10034 store::insert_entries(
10035 &state.db,
10036 feed,
10037 &[store::NewEntry {
10038 guid: "l-1".to_string(),
10039 url: Some("https://list.example/1".to_string()),
10040 title: Some("Article".to_string()),
10041 published: Some("2026-07-11T00:00:00Z".to_string()),
10042 ..Default::default()
10043 }],
10044 0,
10045 )
10046 .await
10047 .unwrap();
10048 store::replace_sub_refs(&state.db, did, &[feed])
10049 .await
10050 .unwrap();
10051 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10052
10053 let cookie = session_cookie(&state, did, None);
10054 let app = router(state.clone());
10055
10056 let resp = app
10057 .oneshot(
10058 Request::builder()
10059 .method("POST")
10060 .uri(format!("/entries/{entry_id}/read"))
10061 .header(header::COOKIE, cookie)
10062 .header("HX-Request", "true")
10063 .header("content-type", "application/x-www-form-urlencoded")
10064 .body(Body::from("read=true"))
10065 .unwrap(),
10066 )
10067 .await
10068 .unwrap();
10069 assert_eq!(resp.status(), StatusCode::OK);
10070 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10071 .await
10072 .unwrap();
10073 let html = String::from_utf8(bytes.to_vec()).unwrap();
10074 assert!(
10075 !html.contains("hx-swap-oob"),
10076 "list-view response must NOT be an OOB swap: {html}"
10077 );
10078 assert!(
10083 html.contains(&format!("/entries/{entry_id}")),
10084 "the response is not the row for this entry: {html}",
10085 );
10086 assert!(
10087 html.contains("Article"),
10088 "the row rendered without its title: {html}",
10089 );
10090 assert!(
10107 html.contains("is-read"),
10108 "the row came back without the read state it was just given: {html}",
10109 );
10110 }
10111
10112 #[tokio::test]
10137 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10138 let did = "did:plc:autodiscovered";
10139 let state = test_state_with_caps(did, 0, 0).await;
10142
10143 let page = r#"<!doctype html><html><head><title>Blog</title>
10144 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10145 </head><body>hi</body></html>"#;
10146 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10147 let port: u16 = base
10148 .trim_end_matches('/')
10149 .rsplit(':')
10150 .next()
10151 .unwrap()
10152 .parse()
10153 .unwrap();
10154 crate::net::test_host_override(
10155 "autodiscover-ftp.test",
10156 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10157 );
10158
10159 let cookie = session_cookie(&state, did, None);
10160 let resp = router(state.clone())
10161 .oneshot(
10162 Request::builder()
10163 .method("POST")
10164 .uri("/subscriptions")
10165 .header(header::COOKIE, cookie)
10166 .header("content-type", "application/x-www-form-urlencoded")
10167 .body(Body::from(format!(
10168 "url=http://autodiscover-ftp.test:{port}/"
10169 )))
10170 .unwrap(),
10171 )
10172 .await
10173 .unwrap();
10174 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10175 let loc = resp
10176 .headers()
10177 .get(header::LOCATION)
10178 .unwrap()
10179 .to_str()
10180 .unwrap();
10181 assert_ne!(loc, "/login", "the test never reached the add path");
10182 assert_ne!(loc, "/", "the subscribe succeeded");
10183
10184 assert_eq!(
10185 store::count_feeds(&state.db).await.unwrap(),
10186 0,
10187 "a non-http(s) URL from autodiscovery was stored"
10188 );
10189 assert_eq!(
10190 store::count_subscriptions_for_did(&state.db, did)
10191 .await
10192 .unwrap(),
10193 0
10194 );
10195 }
10196
10197 #[tokio::test]
10202 async fn rename_to_new_url_refused_at_global_feeds_cap() {
10203 let did = "did:plc:renamer4";
10204 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10205 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10207 store::upsert_feed(
10208 &state.db,
10209 &store::NewFeed {
10210 url: "https://existing.example/feed.xml".to_string(),
10211 ..Default::default()
10212 },
10213 )
10214 .await
10215 .unwrap();
10216 let before = store::count_feeds(&state.db).await.unwrap();
10217 assert_eq!(before, 1);
10218
10219 let cookie = session_cookie(&state, did, None);
10220 let resp = router(state.clone())
10221 .oneshot(
10222 Request::builder()
10223 .method("POST")
10224 .uri("/subscriptions/rk-keep/rename")
10225 .header(header::COOKIE, cookie)
10226 .header("content-type", "application/x-www-form-urlencoded")
10227 .body(Body::from(
10229 "url=https://brand-new.example/feed.xml&title=Renamed",
10230 ))
10231 .unwrap(),
10232 )
10233 .await
10234 .unwrap();
10235 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10236 let loc = resp
10237 .headers()
10238 .get(header::LOCATION)
10239 .unwrap()
10240 .to_str()
10241 .unwrap();
10242 assert!(
10243 loc.contains("feed%20capacity"),
10244 "expected the feed-capacity flash, got {loc}"
10245 );
10246 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10248 assert!(
10249 puts.lock().unwrap().is_empty(),
10250 "a refused repoint reached the PDS"
10251 );
10252 }
10253
10254 #[tokio::test]
10261 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10262 let did = "did:plc:renamer4";
10263 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10264 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10265 store::upsert_feed(
10266 &state.db,
10267 &store::NewFeed {
10268 url: "https://existing.example/feed.xml".to_string(),
10269 ..Default::default()
10270 },
10271 )
10272 .await
10273 .unwrap();
10274 let before = store::count_feeds(&state.db).await.unwrap();
10275
10276 let cookie = session_cookie(&state, did, None);
10277 let resp = router(state.clone())
10278 .oneshot(
10279 Request::builder()
10280 .method("POST")
10281 .uri("/subscriptions/rk-keep/rename")
10282 .header(header::COOKIE, cookie)
10283 .header("content-type", "application/x-www-form-urlencoded")
10284 .body(Body::from(
10285 "url=https://existing.example/feed.xml&title=Retitled",
10286 ))
10287 .unwrap(),
10288 )
10289 .await
10290 .unwrap();
10291 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10292 let loc = resp
10293 .headers()
10294 .get(header::LOCATION)
10295 .unwrap()
10296 .to_str()
10297 .unwrap();
10298 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10299 assert_eq!(
10300 puts.lock().unwrap().len(),
10301 1,
10302 "the repoint did not reach the PDS"
10303 );
10304 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10305 }
10306
10307 #[tokio::test]
10309 async fn rename_with_blank_url_writes_nothing() {
10310 let did = "did:plc:renamer3";
10311 let state = test_state_with_caps(did, 0, 0).await;
10312 let before = store::count_feeds(&state.db).await.unwrap();
10313 assert_eq!(before, 0);
10314
10315 let cookie = session_cookie(&state, did, None);
10316 let app = router(state.clone());
10317 let resp = app
10318 .oneshot(
10319 Request::builder()
10320 .method("POST")
10321 .uri("/subscriptions/rkey123/rename")
10322 .header(header::COOKIE, cookie)
10323 .header("content-type", "application/x-www-form-urlencoded")
10324 .body(Body::from("url=%20%20&title=Nope"))
10326 .unwrap(),
10327 )
10328 .await
10329 .unwrap();
10330 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10331 assert_eq!(
10332 resp.headers()
10333 .get(header::LOCATION)
10334 .unwrap()
10335 .to_str()
10336 .unwrap(),
10337 "/",
10338 );
10339 assert_eq!(
10341 store::count_feeds(&state.db).await.unwrap(),
10342 0,
10343 "blank-URL rename wrote a junk feeds row"
10344 );
10345 }
10346
10347 async fn spawn_rename_sidecar(
10356 existing: serde_json::Value,
10357 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10358 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10359 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10360 let addr = listener.local_addr().unwrap();
10361 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10362 let sink = puts.clone();
10363 tokio::spawn(async move {
10364 loop {
10365 let Ok((mut sock, _)) = listener.accept().await else {
10366 break;
10367 };
10368 let mut raw: Vec<u8> = Vec::new();
10369 let mut chunk = [0u8; 4096];
10370 let body_text = loop {
10371 let Ok(n) = sock.read(&mut chunk).await else {
10372 break String::new();
10373 };
10374 if n == 0 {
10375 break String::from_utf8_lossy(&raw).to_string();
10376 }
10377 raw.extend_from_slice(&chunk[..n]);
10378 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10379 continue;
10380 };
10381 let (head, body) = raw.split_at(split + 4);
10382 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10383 let (k, v) = l.split_once(':')?;
10384 k.eq_ignore_ascii_case("content-length")
10385 .then(|| v.trim().parse::<usize>().ok())?
10386 });
10387 if want.is_none_or(|want| body.len() >= want) {
10388 break String::from_utf8_lossy(body).to_string();
10389 }
10390 };
10391
10392 let is_put = body_text.contains("\"action\":\"put\"");
10394 let data = if is_put {
10395 sink.lock().unwrap().push(body_text.clone());
10396 serde_json::json!({
10397 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10398 "cid": "bafyreiafter"
10399 })
10400 } else {
10401 serde_json::json!({ "records": [existing.clone()] })
10402 };
10403 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10404 let resp = format!(
10405 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10406 body.len(),
10407 body
10408 );
10409 let _ = sock.write_all(resp.as_bytes()).await;
10410 let _ = sock.flush().await;
10411 }
10412 });
10413 (format!("http://{addr}"), puts)
10414 }
10415
10416 fn seeded_subscription() -> serde_json::Value {
10418 serde_json::json!({
10419 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10420 "cid": "bafyreibefore",
10421 "value": {
10422 "$type": "community.lexicon.rss.subscription",
10423 "url": "https://example.com/feed.xml",
10424 "title": "Old title",
10425 "siteUrl": "https://example.com/blog",
10426 "fetchHint": "hourly",
10427 "private": false,
10428 "createdAt": "2024-03-01T00:00:00.000Z"
10429 }
10430 })
10431 }
10432
10433 fn seeded_at_uri_subscription() -> serde_json::Value {
10436 seeded_subscription_with_url(AT_URI_SUB)
10437 }
10438 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10440 serde_json::json!({
10441 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10442 "cid": "bafyreibefore",
10443 "value": {
10444 "$type": "community.lexicon.rss.subscription",
10445 "url": url,
10446 "title": "Old title",
10447 "private": false,
10448 "createdAt": "2024-03-01T00:00:00.000Z"
10449 }
10450 })
10451 }
10452 const AT_URI_SUB: &str =
10453 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10454 const AT_URI_SUB_ENC: &str =
10455 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10456
10457 #[tokio::test]
10466 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10467 let did = "did:plc:renamer5";
10468 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10469 let state = test_state_with_sidecar(&[did], &sidecar).await;
10470 assert!(
10471 !state.config.standard_site,
10472 "the flag must be off for this test"
10473 );
10474 let cookie = session_cookie(&state, did, None);
10475 let resp = router(state.clone())
10476 .oneshot(
10477 Request::builder()
10478 .method("POST")
10479 .uri("/subscriptions/rk-keep/rename")
10480 .header(header::COOKIE, cookie)
10481 .header("content-type", "application/x-www-form-urlencoded")
10482 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10483 .unwrap(),
10484 )
10485 .await
10486 .unwrap();
10487 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10488 let loc = resp
10489 .headers()
10490 .get(header::LOCATION)
10491 .unwrap()
10492 .to_str()
10493 .unwrap();
10494 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10495
10496 let bodies = puts.lock().unwrap().clone();
10497 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10498 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10499 assert_eq!(
10500 sent["record"]["title"], "New title",
10501 "the rename did not apply"
10502 );
10503 assert_eq!(
10504 sent["record"]["url"], AT_URI_SUB,
10505 "the rename changed the URL"
10506 );
10507
10508 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10510 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10511 }
10512
10513 #[tokio::test]
10517 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10518 let did = "did:plc:renamer4";
10519 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10520 let state = test_state_with_sidecar(&[did], &sidecar).await;
10521 let cookie = session_cookie(&state, did, None);
10522 let resp = router(state.clone())
10523 .oneshot(
10524 Request::builder()
10525 .method("POST")
10526 .uri("/subscriptions/rk-keep/rename")
10527 .header(header::COOKIE, cookie)
10528 .header("content-type", "application/x-www-form-urlencoded")
10529 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10530 .unwrap(),
10531 )
10532 .await
10533 .unwrap();
10534 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10535 let loc = resp
10536 .headers()
10537 .get(header::LOCATION)
10538 .unwrap()
10539 .to_str()
10540 .unwrap();
10541 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10542 assert!(
10543 !loc.contains("Private"),
10544 "a storability refusal was reported as a privacy one: {loc}"
10545 );
10546 assert!(
10547 puts.lock().unwrap().is_empty(),
10548 "the repoint reached the PDS"
10549 );
10550 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10551 assert_eq!(cached, 0);
10552 }
10553
10554 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10557 let cookie = session_cookie(state, did, None);
10558 let resp = router(state.clone())
10559 .oneshot(
10560 Request::builder()
10561 .method("POST")
10562 .uri("/subscriptions/rk-keep/rename")
10563 .header(header::COOKIE, cookie)
10564 .header("content-type", "application/x-www-form-urlencoded")
10565 .body(Body::from(format!("url={url_enc}&title=New+title")))
10566 .unwrap(),
10567 )
10568 .await
10569 .unwrap();
10570 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10571 resp.headers()
10572 .get(header::LOCATION)
10573 .unwrap()
10574 .to_str()
10575 .unwrap()
10576 .to_string()
10577 }
10578
10579 #[tokio::test]
10589 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10590 let did = "did:plc:renamer5";
10591 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10592 let other_enc =
10593 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10594 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10595 let state = test_state_with_sidecar(&[did], &sidecar).await;
10596 let loc = retitle_unchanged(&state, did, other_enc).await;
10597 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10598 let bodies = puts.lock().unwrap().clone();
10599 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10600 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10601 assert_eq!(sent["record"]["title"], "New title");
10602 assert_eq!(sent["record"]["url"], other);
10603 }
10604
10605 #[tokio::test]
10609 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10610 let did = "did:plc:renamer4";
10611 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10612 let state = test_state_with_sidecar(&[did], &sidecar).await;
10613 let cookie = session_cookie(&state, did, None);
10614 let resp = router(state.clone())
10615 .oneshot(
10616 Request::builder()
10617 .method("POST")
10618 .uri("/subscriptions/rk-keep/rename")
10619 .header(header::COOKIE, cookie)
10620 .header("content-type", "application/x-www-form-urlencoded")
10621 .body(Body::from(
10622 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10623 ))
10624 .unwrap(),
10625 )
10626 .await
10627 .unwrap();
10628 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10629 let loc = resp
10630 .headers()
10631 .get(header::LOCATION)
10632 .unwrap()
10633 .to_str()
10634 .unwrap();
10635 assert!(
10636 loc.contains("Private"),
10637 "the private repoint was not refused: {loc}"
10638 );
10639 assert!(
10640 puts.lock().unwrap().is_empty(),
10641 "a secret-bearing URL reached the PDS"
10642 );
10643 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10646 assert!(store::get_feed_by_url(&state.db, leaked)
10647 .await
10648 .unwrap()
10649 .is_none());
10650 }
10651
10652 #[tokio::test]
10658 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10659 let did = "did:plc:renamer5";
10660 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10661 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10663 store::upsert_feed(
10664 &state.db,
10665 &store::NewFeed {
10666 url: "https://filler.example/feed.xml".to_string(),
10667 ..Default::default()
10668 },
10669 )
10670 .await
10671 .unwrap();
10672 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10673 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10674 assert_eq!(
10675 puts.lock().unwrap().len(),
10676 1,
10677 "the retitle did not reach the PDS"
10678 );
10679 assert_eq!(
10680 store::count_feeds(&state.db).await.unwrap(),
10681 1,
10682 "a row was inserted"
10683 );
10684 }
10685
10686 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10688 let cookie = session_cookie(state, did, None);
10689 let resp = router(state.clone())
10690 .oneshot(
10691 Request::builder()
10692 .method("POST")
10693 .uri("/subscriptions")
10694 .header(header::COOKIE, cookie)
10695 .header("content-type", "application/x-www-form-urlencoded")
10696 .body(Body::from(format!("url={url_enc}")))
10697 .unwrap(),
10698 )
10699 .await
10700 .unwrap();
10701 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10702 resp.headers()
10703 .get(header::LOCATION)
10704 .unwrap()
10705 .to_str()
10706 .unwrap()
10707 .to_string()
10708 }
10709
10710 async fn serve_resolver(did: &str) -> String {
10712 let base = crate::net::tests::serve_body(
10713 serde_json::json!({ "did": did }).to_string().into_bytes(),
10714 )
10715 .await;
10716 let port: u16 = base
10717 .trim_end_matches('/')
10718 .rsplit(':')
10719 .next()
10720 .unwrap()
10721 .parse()
10722 .unwrap();
10723 let host = format!("resolver-{port}.test");
10724 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10725 format!("http://{host}:{port}")
10726 }
10727
10728 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10729 let mut config = (*state.config).clone();
10730 f(&mut config);
10731 state.config = std::sync::Arc::new(config);
10732 state
10733 }
10734
10735 #[tokio::test]
10740 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10741 let did = "did:plc:renamer5";
10742 let (sidecar, log) = spawn_logging_sidecar().await;
10743 let state = with_config(
10744 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10745 |c| {
10746 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10747 },
10748 );
10749 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10750 assert_eq!(loc, "/", "the paste was refused: {loc}");
10751 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10752 .await
10753 .unwrap()
10754 .expect("no feed row");
10755 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10756 let sent = log.lock().unwrap().join("\n");
10757 assert!(
10758 sent.contains(AT_URI_SUB),
10759 "the subscription was not written to the PDS: {sent}"
10760 );
10761 }
10762
10763 #[tokio::test]
10767 async fn a0_subscribing_from_the_form_delivers_entries() {
10768 let did = "did:plc:renamer5";
10769 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10770 let site = AT_URI_SUB;
10771 let (plc, _) = crate::standard_site::tests::serve_repo(
10772 author,
10773 vec![
10774 (
10775 lexicon::nsid::STANDARD_PUBLICATION,
10776 "3lab2c4d5e6f7g8h",
10777 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10778 ),
10779 (
10780 lexicon::nsid::STANDARD_DOCUMENT,
10781 "3l2a0frmaaa2a",
10782 serde_json::json!({ "title": "From the form", "path": "/f",
10783 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10784 ),
10785 ],
10786 )
10787 .await;
10788 let (sidecar, _log) = spawn_logging_sidecar().await;
10789 let state = with_config(
10790 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10791 |c| {
10792 c.oauth.plc_directory = plc;
10793 },
10794 );
10795 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10796 let row = store::get_feed_by_url(&state.db, site)
10797 .await
10798 .unwrap()
10799 .unwrap();
10800 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10801 .bind(row.id)
10802 .fetch_all(&state.db)
10803 .await
10804 .unwrap();
10805 assert_eq!(
10806 titles,
10807 vec!["From the form".to_string()],
10808 "the first poll stored nothing"
10809 );
10810 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10811 }
10812
10813 #[tokio::test]
10816 async fn a_handle_form_paste_is_stored_by_its_did() {
10817 let did = "did:plc:renamer5";
10818 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10819 let (sidecar, _log) = spawn_logging_sidecar().await;
10820 let resolver = serve_resolver(author).await;
10821 let state = with_config(
10822 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10823 |c| {
10824 c.resolver_base = resolver;
10825 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10826 },
10827 );
10828 let loc = subscribe(
10829 &state,
10830 did,
10831 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10832 )
10833 .await;
10834 assert_eq!(loc, "/", "the paste was refused: {loc}");
10835 assert!(
10836 store::get_feed_by_url(&state.db, AT_URI_SUB)
10837 .await
10838 .unwrap()
10839 .is_some(),
10840 "not stored by its DID"
10841 );
10842 assert_eq!(
10843 store::count_feeds(&state.db).await.unwrap(),
10844 1,
10845 "the handle form was stored too"
10846 );
10847 }
10848
10849 async fn serve_counting_resolver(
10851 did: &str,
10852 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10853 let (base, hits) = crate::net::tests::serve_body_counted(
10854 serde_json::json!({ "did": did }).to_string().into_bytes(),
10855 )
10856 .await;
10857 let port: u16 = base
10858 .trim_end_matches('/')
10859 .rsplit(':')
10860 .next()
10861 .unwrap()
10862 .parse()
10863 .unwrap();
10864 let host = format!("counting-resolver-{port}.test");
10865 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10866 (format!("http://{host}:{port}"), hits)
10867 }
10868
10869 #[tokio::test]
10873 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10874 let did = "did:plc:renamer5";
10875 let (sidecar, _log) = spawn_logging_sidecar().await;
10876 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10877 let state = with_config(
10878 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10879 |c| {
10880 c.resolver_base = resolver;
10881 c.max_subs_per_did = 1;
10882 },
10883 );
10884 let feed_id = store::upsert_feed(
10885 &state.db,
10886 &store::NewFeed {
10887 url: "https://already.example/feed.xml".into(),
10888 ..Default::default()
10889 },
10890 )
10891 .await
10892 .unwrap();
10893 store::replace_sub_refs(&state.db, did, &[feed_id])
10894 .await
10895 .unwrap();
10896 let loc = subscribe(
10897 &state,
10898 did,
10899 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10900 )
10901 .await;
10902 assert!(
10903 loc.contains("Subscription%20limit"),
10904 "expected the cap flash: {loc}"
10905 );
10906 assert_eq!(
10907 hits.load(std::sync::atomic::Ordering::SeqCst),
10908 0,
10909 "an over-cap paste resolved a handle"
10910 );
10911 }
10912
10913 #[tokio::test]
10917 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10918 let did = "did:plc:renamer5";
10919 let (sidecar, _log) = spawn_logging_sidecar().await;
10920 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10921 let state = with_config(
10922 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10923 |c| {
10924 c.resolver_base = resolver;
10925 },
10926 );
10927 for authority in [
10928 "did%3Aplc%3ATOOSHORT",
10929 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10930 "bad%0Ahandle.example",
10931 ] {
10932 let loc = subscribe(
10933 &state,
10934 did,
10935 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10936 )
10937 .await;
10938 assert!(
10939 loc.contains("kind%20of%20feed"),
10940 "{authority}: expected the unsupported flash: {loc}"
10941 );
10942 }
10943 assert_eq!(
10944 hits.load(std::sync::atomic::Ordering::SeqCst),
10945 0,
10946 "a malformed authority reached the resolver"
10947 );
10948 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10949 }
10950
10951 #[tokio::test]
10953 async fn an_unresolvable_handle_paste_is_refused() {
10954 let did = "did:plc:renamer5";
10955 let (sidecar, _log) = spawn_logging_sidecar().await;
10956 let state = with_config(
10957 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10958 |c| {
10959 c.resolver_base = "http://resolver.nowhere.invalid".into();
10960 },
10961 );
10962 let loc = subscribe(
10963 &state,
10964 did,
10965 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10966 )
10967 .await;
10968 assert!(
10969 loc.contains("resolve%20the%20handle"),
10970 "expected the unresolvable-handle flash: {loc}"
10971 );
10972 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10973 }
10974
10975 #[tokio::test]
10977 async fn a_non_publication_at_uri_paste_is_refused() {
10978 let did = "did:plc:renamer5";
10979 let (sidecar, _log) = spawn_logging_sidecar().await;
10980 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10981 let loc = subscribe(
10982 &state,
10983 did,
10984 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10985 )
10986 .await;
10987 assert!(
10988 loc.contains("kind%20of%20feed"),
10989 "expected the unsupported flash: {loc}"
10990 );
10991 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10992 }
10993
10994 #[tokio::test]
10997 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
10998 let did = "did:plc:renamer5";
10999 let (sidecar, _log) = spawn_logging_sidecar().await;
11000 let state = with_config(
11001 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11002 |c| {
11003 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11004 },
11005 );
11006 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11007 assert_eq!(loc, "/", "the paste was refused: {loc}");
11008 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11009 .await
11010 .unwrap()
11011 .is_some());
11012 }
11013
11014 #[tokio::test]
11017 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11018 let did = "did:plc:renamer5";
11019 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11020 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11021 let opml = format!(
11022 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11023 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11024 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11025 </body></opml>"
11026 );
11027 let (ct, body) = opml_multipart(opml.as_bytes());
11028 let cookie = session_cookie(&state, did, None);
11029 let resp = router(state.clone())
11030 .oneshot(
11031 Request::builder()
11032 .method("POST")
11033 .uri("/opml")
11034 .header(header::COOKIE, cookie)
11035 .header("content-type", ct)
11036 .body(Body::from(body))
11037 .unwrap(),
11038 )
11039 .await
11040 .unwrap();
11041 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11042 let loc = resp
11043 .headers()
11044 .get(header::LOCATION)
11045 .unwrap()
11046 .to_str()
11047 .unwrap();
11048 assert!(
11049 loc.contains("Imported%202%20feeds"),
11050 "unexpected flash: {loc}"
11051 );
11052 assert!(
11053 !loc.contains("skipped"),
11054 "the at:// entry was skipped with the flag on: {loc}"
11055 );
11056 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11057 assert!(
11058 stored.is_some(),
11059 "the at:// entry was not stored with the flag on"
11060 );
11061 }
11062
11063 #[tokio::test]
11073 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11074 let did = "did:plc:renamer5";
11075 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11076 let tokened_enc =
11077 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11078 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11079 let state = test_state_with_sidecar(&[did], &sidecar).await;
11080 let loc = retitle_unchanged(&state, did, tokened_enc).await;
11081 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11082 assert_eq!(
11083 puts.lock().unwrap().len(),
11084 1,
11085 "the retitle did not reach the PDS"
11086 );
11087 assert!(
11088 store::get_feed_by_url(&state.db, tokened)
11089 .await
11090 .unwrap()
11091 .is_none(),
11092 "a secret-bearing URL was written to the shared cache by a retitle"
11093 );
11094 }
11095
11096 #[tokio::test]
11101 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11102 let did = "did:plc:renamer4";
11103 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11104 let state = test_state_with_sidecar(&[did], &sidecar).await;
11105 let cookie = session_cookie(&state, did, None);
11106 let resp = router(state.clone())
11107 .oneshot(
11108 Request::builder()
11109 .method("POST")
11110 .uri("/subscriptions/rk-keep/rename")
11111 .header(header::COOKIE, cookie)
11112 .header("content-type", "application/x-www-form-urlencoded")
11113 .body(Body::from(
11114 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11115 ))
11116 .unwrap(),
11117 )
11118 .await
11119 .unwrap();
11120 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11121 let loc = resp
11122 .headers()
11123 .get(header::LOCATION)
11124 .unwrap()
11125 .to_str()
11126 .unwrap();
11127 assert!(
11128 loc.contains("kind%20of%20feed"),
11129 "expected the unsupported flash: {loc}"
11130 );
11131 assert!(
11132 !loc.contains("Private"),
11133 "a typo was reported as a paid feed: {loc}"
11134 );
11135 assert!(puts.lock().unwrap().is_empty());
11136 }
11137
11138 #[tokio::test]
11139 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11140 let did = "did:plc:renamer4";
11141 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11142 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11143 store::upsert_feed(
11144 &state.db,
11145 &store::NewFeed {
11146 url: "https://filler.example/feed.xml".to_string(),
11147 ..Default::default()
11148 },
11149 )
11150 .await
11151 .unwrap();
11152 let cookie = session_cookie(&state, did, None);
11153 let resp = router(state.clone())
11154 .oneshot(
11155 Request::builder()
11156 .method("POST")
11157 .uri("/subscriptions/rk-keep/rename")
11158 .header(header::COOKIE, cookie)
11159 .header("content-type", "application/x-www-form-urlencoded")
11160 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11161 .unwrap(),
11162 )
11163 .await
11164 .unwrap();
11165 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11166 let loc = resp
11167 .headers()
11168 .get(header::LOCATION)
11169 .unwrap()
11170 .to_str()
11171 .unwrap();
11172 assert!(
11173 loc.contains("kind%20of%20feed"),
11174 "expected the unsupported flash: {loc}"
11175 );
11176 assert!(
11177 !loc.contains("capacity"),
11178 "an unacceptable URL was reported as a capacity problem: {loc}"
11179 );
11180 assert!(puts.lock().unwrap().is_empty());
11181 }
11182
11183 #[tokio::test]
11188 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11189 let did = "did:plc:renamer5";
11190 let padded = format!("{AT_URI_SUB} ");
11191 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11192 let state = test_state_with_sidecar(&[did], &sidecar).await;
11193 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11195 assert_eq!(
11196 loc, "/",
11197 "the retitle was treated as a repoint and refused: {loc}"
11198 );
11199 let bodies = puts.lock().unwrap().clone();
11200 assert_eq!(bodies.len(), 1);
11201 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11202 assert_eq!(
11203 sent["record"]["url"], AT_URI_SUB,
11204 "the padding was not normalised away"
11205 );
11206 }
11207
11208 #[tokio::test]
11214 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11215 let did = "did:plc:renamer5";
11216 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11217 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11218 store::upsert_feed(
11219 &state.db,
11220 &store::NewFeed {
11221 url: "https://filler.example/feed.xml".to_string(),
11222 ..Default::default()
11223 },
11224 )
11225 .await
11226 .unwrap();
11227 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11228 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11229 assert_eq!(puts.lock().unwrap().len(), 1);
11230 assert_eq!(
11231 store::count_feeds(&state.db).await.unwrap(),
11232 1,
11233 "a retitle inserted a cache row past the ceiling"
11234 );
11235 }
11236
11237 #[tokio::test]
11244 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11245 let did = "did:plc:typoist";
11246 let state = test_state_with_caps(did, 0, 0).await;
11247 let cookie = session_cookie(&state, did, None);
11248 let resp = router(state.clone())
11249 .oneshot(
11250 Request::builder()
11251 .method("POST")
11252 .uri("/subscriptions")
11253 .header(header::COOKIE, cookie)
11254 .header("content-type", "application/x-www-form-urlencoded")
11255 .body(Body::from(
11256 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11257 ))
11258 .unwrap(),
11259 )
11260 .await
11261 .unwrap();
11262 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11263 let loc = resp
11264 .headers()
11265 .get(header::LOCATION)
11266 .unwrap()
11267 .to_str()
11268 .unwrap();
11269 assert!(
11270 loc.contains("kind%20of%20feed"),
11271 "expected the unsupported flash: {loc}"
11272 );
11273 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11274 }
11275
11276 #[tokio::test]
11298 async fn renaming_preserves_the_fields_the_form_never_carries() {
11299 let did = "did:plc:renamer4";
11300 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11301 let state = test_state_with_sidecar(&[did], &sidecar).await;
11302 let cookie = session_cookie(&state, did, None);
11303
11304 let resp = router(state.clone())
11305 .oneshot(
11306 Request::builder()
11307 .method("POST")
11308 .uri("/subscriptions/rk-keep/rename")
11309 .header(header::COOKIE, cookie)
11310 .header("content-type", "application/x-www-form-urlencoded")
11311 .body(Body::from(
11313 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11314 ))
11315 .unwrap(),
11316 )
11317 .await
11318 .unwrap();
11319 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11320
11321 let bodies = puts.lock().unwrap().clone();
11322 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11323 let body = &bodies[0];
11324 assert!(
11326 body.contains("community.lexicon.rss.subscription"),
11327 "captured no usable put body: {body:?}"
11328 );
11329
11330 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11331 let record = &sent["record"];
11332
11333 assert_eq!(record["title"], "New title", "the rename did not apply");
11335 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11336
11337 assert_eq!(
11339 record["createdAt"], "2024-03-01T00:00:00.000Z",
11340 "the rename reset createdAt — the reader's subscribe time is gone \
11341 from their own repo, and nothing told them"
11342 );
11343 assert_eq!(
11344 record["siteUrl"], "https://example.com/blog",
11345 "the rename erased siteUrl"
11346 );
11347 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11348 assert_eq!(record["private"], false, "the rename erased private");
11349 }
11350
11351 #[tokio::test]
11360 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11361 let did = "did:plc:renamer4";
11362 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11363 let state = test_state_with_sidecar(&[did], &sidecar).await;
11364 let cookie = session_cookie(&state, did, None);
11365
11366 let resp = router(state.clone())
11367 .oneshot(
11368 Request::builder()
11369 .method("POST")
11370 .uri("/subscriptions/rk-keep/rename")
11371 .header(header::COOKIE, cookie)
11372 .header("content-type", "application/x-www-form-urlencoded")
11373 .body(Body::from(
11375 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11376 ))
11377 .unwrap(),
11378 )
11379 .await
11380 .unwrap();
11381 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11382
11383 let bodies = puts.lock().unwrap().clone();
11384 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11385 assert!(
11386 bodies[0].contains("community.lexicon.rss.subscription"),
11387 "captured no usable put body: {:?}",
11388 bodies[0]
11389 );
11390 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11391 let record = &sent["record"];
11392
11393 assert_eq!(record["url"], "https://other.example/feed.xml");
11394 assert!(
11396 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11397 "the old feed's site link followed the subscription to a new feed: {record}"
11398 );
11399 assert!(
11400 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11401 "the old feed's fetch hint followed the subscription to a new feed: {record}"
11402 );
11403 assert_eq!(
11405 record["createdAt"], "2024-03-01T00:00:00.000Z",
11406 "a repoint is still not a new subscription; createdAt must not move"
11407 );
11408 assert_eq!(record["private"], false, "the repoint erased private");
11409 }
11410
11411 #[tokio::test]
11423 async fn renaming_an_unknown_rkey_writes_nothing() {
11424 let did = "did:plc:renamer4";
11425 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11427 let state = test_state_with_sidecar(&[did], &sidecar).await;
11428 let cookie = session_cookie(&state, did, None);
11429
11430 let resp = router(state.clone())
11431 .oneshot(
11432 Request::builder()
11433 .method("POST")
11434 .uri("/subscriptions/rk-does-not-exist/rename")
11436 .header(header::COOKIE, cookie)
11437 .header("content-type", "application/x-www-form-urlencoded")
11438 .body(Body::from(
11439 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11440 ))
11441 .unwrap(),
11442 )
11443 .await
11444 .unwrap();
11445
11446 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11447 let loc = resp
11448 .headers()
11449 .get(header::LOCATION)
11450 .unwrap()
11451 .to_str()
11452 .unwrap();
11453 assert!(
11454 loc.contains("flash="),
11455 "an unknown rkey redirected as though the rename had worked: {loc}"
11456 );
11457 assert!(
11458 puts.lock().unwrap().is_empty(),
11459 "a rename against an unknown rkey wrote a record — putRecord would \
11460 CREATE it, dated today: {:?}",
11461 puts.lock().unwrap()
11462 );
11463 }
11464
11465 #[tokio::test]
11477 async fn a_client_supplied_site_url_reaches_the_record() {
11478 let did = "did:plc:renamer4";
11479 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11480 let state = test_state_with_sidecar(&[did], &sidecar).await;
11481 let cookie = session_cookie(&state, did, None);
11482
11483 let resp = router(state.clone())
11484 .oneshot(
11485 Request::builder()
11486 .method("POST")
11487 .uri("/subscriptions/rk-keep/rename")
11488 .header(header::COOKIE, cookie)
11489 .header("content-type", "application/x-www-form-urlencoded")
11490 .body(Body::from(
11493 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11494 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11495 ))
11496 .unwrap(),
11497 )
11498 .await
11499 .unwrap();
11500 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11501
11502 let bodies = puts.lock().unwrap().clone();
11503 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11504 assert!(
11505 bodies[0].contains("community.lexicon.rss.subscription"),
11506 "captured no usable put body: {:?}",
11507 bodies[0]
11508 );
11509 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11510 assert_eq!(
11511 sent["record"]["siteUrl"], "https://typed.example/site",
11512 "the client's siteUrl was dropped; the seeded record's survived instead"
11513 );
11514 }
11515
11516 #[tokio::test]
11524 async fn a_rename_whose_read_fails_writes_nothing() {
11525 let did = "did:plc:renamer5";
11526 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11528 let dead = format!("http://{}", listener.local_addr().unwrap());
11529 drop(listener);
11530
11531 let state = test_state_with_sidecar(&[did], &dead).await;
11532 let cookie = session_cookie(&state, did, None);
11533 let before = store::count_feeds(&state.db).await.unwrap();
11534
11535 let resp = router(state.clone())
11536 .oneshot(
11537 Request::builder()
11538 .method("POST")
11539 .uri("/subscriptions/rk-keep/rename")
11540 .header(header::COOKIE, cookie)
11541 .header("content-type", "application/x-www-form-urlencoded")
11542 .body(Body::from(
11543 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11544 ))
11545 .unwrap(),
11546 )
11547 .await
11548 .unwrap();
11549
11550 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11551 let loc = resp
11552 .headers()
11553 .get(header::LOCATION)
11554 .unwrap()
11555 .to_str()
11556 .unwrap();
11557 assert!(
11558 loc.contains("flash="),
11559 "a failed read redirected as though the rename had worked: {loc}"
11560 );
11561 assert_eq!(
11562 store::count_feeds(&state.db).await.unwrap(),
11563 before,
11564 "a rename that could not read the record still wrote to the cache"
11565 );
11566 }
11567
11568 #[test]
11574 fn manage_rename_row_preselects_current_folder() {
11575 let nav = Nav {
11576 handle: "@reader.example".to_string(),
11577 avatar: "RE".to_string(),
11578 view: "unread".to_string(),
11579 scope_qs: String::new(),
11580 folders: Vec::new(),
11581 loose_feeds: Vec::new(),
11582 manage_active: true,
11583 };
11584 let folder_options = vec![
11585 FolderOption {
11586 uri: "at://did:plc:x/app.folder/work".to_string(),
11587 name: "Work".to_string(),
11588 },
11589 FolderOption {
11590 uri: "at://did:plc:x/app.folder/fun".to_string(),
11591 name: "Fun".to_string(),
11592 },
11593 ];
11594 let foldered = FeedView {
11597 rkey: "sub-foldered".to_string(),
11598 url: "https://work.example/feed.xml".to_string(),
11599 title: "Work Feed".to_string(),
11600 unread: 0,
11601 selected: false,
11602 folder: Some("at://did:plc:x/app.folder/work".to_string()),
11603 };
11604 let loose = FeedView {
11605 rkey: "sub-loose".to_string(),
11606 url: "https://loose.example/feed.xml".to_string(),
11607 title: "Loose Feed".to_string(),
11608 unread: 0,
11609 selected: false,
11610 folder: None,
11611 };
11612 let tmpl = ManageTemplate {
11613 card: Card::private(&Config::default()),
11614 version: VERSION,
11615 repo_url: REPO_URL,
11616 kofi_url: KOFI_URL,
11617 flash: String::new(),
11618 alert: String::new(),
11619 nav,
11620 folder_options,
11621 folders: vec![FolderView {
11622 rkey: "folder-work".to_string(),
11623 uri: "at://did:plc:x/app.folder/work".to_string(),
11624 name: "Work".to_string(),
11625 feeds: vec![foldered],
11626 selected: false,
11627 }],
11628 loose_feeds: vec![loose],
11629 standard_site: false,
11630 };
11631 let html = tmpl.render().unwrap();
11632
11633 assert!(
11635 html.contains(
11636 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11637 ),
11638 "foldered feed must pre-select its current folder: {html}"
11639 );
11640 assert!(
11643 html.contains(r#"<option value="" selected>No folder</option>"#),
11644 "loose feed must pre-select 'No folder': {html}"
11645 );
11646 }
11647
11648 #[tokio::test]
11654 async fn the_public_stats_page_exposes_no_user_data() {
11655 let state = test_state(&[]).await;
11656 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11657 .await
11658 .unwrap();
11659
11660 let resp = router(state)
11661 .oneshot(
11662 Request::builder()
11663 .uri("/stats")
11664 .body(Body::empty())
11665 .unwrap(),
11666 )
11667 .await
11668 .unwrap();
11669 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11670
11671 let body = String::from_utf8(
11672 axum::body::to_bytes(resp.into_body(), usize::MAX)
11673 .await
11674 .unwrap()
11675 .to_vec(),
11676 )
11677 .unwrap();
11678
11679 assert!(
11685 !body.contains("did:"),
11686 "the public stats page leaked an identifier"
11687 );
11688 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11689 assert!(
11690 !body.contains(admin_only),
11691 "the public page is showing the admin metrics column {admin_only:?}"
11692 );
11693 }
11694 assert!(body.contains("Feeds tracked"));
11696 assert!(body.contains("Waiting to be polled"));
11697 }
11698
11699 #[tokio::test]
11707 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11708 let state = test_state(&[]).await;
11709 for (url, errors) in [
11711 ("https://ok.example/f.xml", 0),
11712 ("https://flaky.example/f.xml", 2),
11713 ("https://dead.example/f.xml", 9),
11714 ] {
11715 store::upsert_feed(
11716 &state.db,
11717 &store::NewFeed {
11718 url: url.to_string(),
11719 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11722 ..Default::default()
11723 },
11724 )
11725 .await
11726 .unwrap();
11727 for _ in 0..errors {
11728 store::bump_feed_errors(
11729 &state.db,
11730 url,
11731 feed::FailureKind::Fetch,
11732 "connection refused",
11733 )
11734 .await
11735 .unwrap();
11736 }
11737 }
11738
11739 let render_stats = |state: AppState| async move {
11740 let resp = router(state)
11741 .oneshot(
11742 Request::builder()
11743 .uri("/stats")
11744 .body(Body::empty())
11745 .unwrap(),
11746 )
11747 .await
11748 .unwrap();
11749 assert_eq!(resp.status(), StatusCode::OK);
11750 String::from_utf8(
11751 axum::body::to_bytes(resp.into_body(), usize::MAX)
11752 .await
11753 .unwrap()
11754 .to_vec(),
11755 )
11756 .unwrap()
11757 };
11758
11759 state.runtime_health.set_schedulers_enabled(true);
11766 state
11767 .runtime_health
11768 .poll_tick_completed(crate::store::now_unix());
11769
11770 let body = render_stats(state.clone()).await;
11771 assert!(
11772 body.contains("Failing"),
11773 "backoff is still invisible on the public page"
11774 );
11775 assert!(
11779 body.contains("2, 1 badly"),
11780 "expected '2, 1 badly' in the failing row; got:\n{}",
11781 body.split("Failing")
11782 .nth(1)
11783 .unwrap_or("")
11784 .chars()
11785 .take(300)
11786 .collect::<String>()
11787 );
11788 assert!(
11796 !body.contains("the poller is not running")
11797 && !body.contains("the cache is at its size limit")
11798 && !body.contains("has not completed a round"),
11799 "expected the running state; the page reported a stopped one",
11800 );
11801
11802 state.runtime_health.set_watermark(true);
11806 let paused = render_stats(state.clone()).await;
11807 assert!(
11812 paused.contains("the cache is at its size limit"),
11813 "a watermark pause is still invisible on the public page"
11814 );
11815
11816 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11818 assert!(
11819 !paused.contains(leak),
11820 "the public page leaked {leak:?} while reporting failures"
11821 );
11822 }
11823 }
11824
11825 #[tokio::test]
11837 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11838 let admin = "did:plc:adminseed";
11839 let state = test_state(&[admin]).await;
11848 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11849 .await
11850 .unwrap();
11851 let url = "https://broken.example/f.xml";
11852 store::upsert_feed(
11853 &state.db,
11854 &store::NewFeed {
11855 url: url.to_string(),
11856 ..Default::default()
11857 },
11858 )
11859 .await
11860 .unwrap();
11861 store::bump_feed_errors(
11862 &state.db,
11863 url,
11864 feed::FailureKind::Fetch,
11865 "SENTINEL_ADMIN_ONLY",
11866 )
11867 .await
11868 .unwrap();
11869
11870 let get = |state: AppState, cookie: Option<String>| async move {
11871 let mut req = Request::builder().uri("/admin/metrics");
11872 if let Some(c) = cookie {
11873 req = req.header(header::COOKIE, c);
11874 }
11875 let resp = router(state)
11876 .oneshot(req.body(Body::empty()).unwrap())
11877 .await
11878 .unwrap();
11879 let status = resp.status();
11880 let body = String::from_utf8(
11881 axum::body::to_bytes(resp.into_body(), usize::MAX)
11882 .await
11883 .unwrap()
11884 .to_vec(),
11885 )
11886 .unwrap();
11887 (status, body)
11888 };
11889
11890 let (status, body) = get(state.clone(), None).await;
11892 assert_eq!(status, StatusCode::UNAUTHORIZED);
11893 assert!(
11894 !body.contains("SENTINEL_ADMIN_ONLY"),
11895 "leaked to anonymous: {body}"
11896 );
11897
11898 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11900 let (status, body) = get(state.clone(), Some(ordinary)).await;
11901 assert_eq!(
11902 status,
11903 StatusCode::FORBIDDEN,
11904 "a non-admin session was let in"
11905 );
11906 assert!(
11907 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11908 "leaked to a non-admin: {body}",
11909 );
11910
11911 let admin_cookie = session_cookie(&state, admin, None);
11914 let (status, body) = get(state, Some(admin_cookie)).await;
11915 assert_eq!(status, StatusCode::OK);
11916 assert!(
11917 body.contains("SENTINEL_ADMIN_ONLY"),
11918 "admin cannot see it: {body}"
11919 );
11920 }
11921
11922 #[tokio::test]
11939 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11940 let admin = "did:plc:adminseed";
11941 let state = test_state(&[admin]).await;
11942 let url = "https://broken.example/f.xml";
11943 store::upsert_feed(
11944 &state.db,
11945 &store::NewFeed {
11946 url: url.to_string(),
11947 ..Default::default()
11948 },
11949 )
11950 .await
11951 .unwrap();
11952 store::bump_feed_errors(
11953 &state.db,
11954 url,
11955 feed::FailureKind::Fetch,
11956 "SENTINEL_REDIRECT_NO_LOCATION",
11957 )
11958 .await
11959 .unwrap();
11960
11961 let cookie = session_cookie(&state, admin, None);
11962 let resp = router(state.clone())
11963 .oneshot(
11964 Request::builder()
11965 .uri("/admin/metrics")
11966 .header(header::COOKIE, cookie)
11967 .body(Body::empty())
11968 .unwrap(),
11969 )
11970 .await
11971 .unwrap();
11972 assert_eq!(resp.status(), StatusCode::OK);
11973 let admin_body = String::from_utf8(
11974 axum::body::to_bytes(resp.into_body(), usize::MAX)
11975 .await
11976 .unwrap()
11977 .to_vec(),
11978 )
11979 .unwrap();
11980 assert!(
11981 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11982 "the admin page does not carry the failure detail: {admin_body}",
11983 );
11984 assert!(
11985 admin_body.contains("broken.example"),
11986 "the admin page does not name the failing feed: {admin_body}",
11987 );
11988
11989 let resp = router(state)
11991 .oneshot(
11992 Request::builder()
11993 .uri("/stats")
11994 .body(Body::empty())
11995 .unwrap(),
11996 )
11997 .await
11998 .unwrap();
11999 let public = String::from_utf8(
12000 axum::body::to_bytes(resp.into_body(), usize::MAX)
12001 .await
12002 .unwrap()
12003 .to_vec(),
12004 )
12005 .unwrap();
12006 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
12007 assert!(
12008 !public.contains(secret),
12009 "{secret:?} reached the PUBLIC stats page: {public}",
12010 );
12011 }
12012 }
12013
12014 #[tokio::test]
12027 async fn a_successful_direct_poll_clears_a_stale_failure() {
12028 let state = test_state(&[]).await;
12029 let url = "https://recovered.example/f.xml";
12030 store::upsert_feed(
12031 &state.db,
12032 &store::NewFeed {
12033 url: url.to_string(),
12034 ..Default::default()
12035 },
12036 )
12037 .await
12038 .unwrap();
12039 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
12040 .await
12041 .unwrap();
12042 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
12044 .bind(url)
12045 .execute(&state.db)
12046 .await
12047 .unwrap();
12048
12049 feed::settle_poll(
12051 &state.db,
12052 url,
12053 &feed::PollOutcome::NotModified,
12054 state.config.poll_interval,
12055 )
12056 .await;
12057
12058 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12059 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12060 )
12061 .bind(url)
12062 .fetch_one(&state.db)
12063 .await
12064 .unwrap();
12065 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
12066 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
12067 let next = row.2.expect("next_poll was cleared to NULL");
12071 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12075 let delta = parsed
12076 .signed_duration_since(chrono::Utc::now())
12077 .num_seconds();
12078 let cadence = state.config.poll_interval.as_secs() as i64;
12079 assert!(
12080 (cadence - 60..=cadence + 60).contains(&delta),
12081 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
12082 );
12083 }
12084
12085 #[tokio::test]
12091 async fn a_failing_direct_poll_is_recorded() {
12092 let state = test_state(&[]).await;
12093 let url = "https://born-broken.example/f.xml";
12094 store::upsert_feed(
12095 &state.db,
12096 &store::NewFeed {
12097 url: url.to_string(),
12098 ..Default::default()
12099 },
12100 )
12101 .await
12102 .unwrap();
12103
12104 feed::settle_poll(
12105 &state.db,
12106 url,
12107 &feed::PollOutcome::Failed {
12108 backoff: std::time::Duration::from_secs(300),
12109 kind: feed::FailureKind::Parse,
12110 detail: "SENTINEL_BORN_BROKEN".to_string(),
12111 },
12112 state.config.poll_interval,
12113 )
12114 .await;
12115
12116 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12117 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12118 )
12119 .bind(url)
12120 .fetch_one(&state.db)
12121 .await
12122 .unwrap();
12123 assert_eq!(row.0, 1, "a failed first poll was not counted");
12124 assert_eq!(
12125 row.1.as_deref(),
12126 Some("parse"),
12127 "its cause was not recorded"
12128 );
12129 let next = row.2.expect("a failed direct poll left next_poll NULL");
12133 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12134 let delta = parsed
12135 .signed_duration_since(chrono::Utc::now())
12136 .num_seconds();
12137 assert!(
12138 (240..=360).contains(&delta),
12139 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12140 );
12141 }
12142
12143 #[tokio::test]
12155 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12156 let state = test_state(&[]).await;
12157 for url in [
12159 "https://legacy1.example/f.xml",
12160 "https://legacy2.example/f.xml",
12161 ] {
12162 store::upsert_feed(
12163 &state.db,
12164 &store::NewFeed {
12165 url: url.to_string(),
12166 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12167 ..Default::default()
12168 },
12169 )
12170 .await
12171 .unwrap();
12172 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12173 .bind(url)
12174 .execute(&state.db)
12175 .await
12176 .unwrap();
12177 }
12178 store::upsert_feed(
12180 &state.db,
12181 &store::NewFeed {
12182 url: "https://known.example/f.xml".to_string(),
12183 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12184 ..Default::default()
12185 },
12186 )
12187 .await
12188 .unwrap();
12189 store::bump_feed_errors(
12190 &state.db,
12191 "https://known.example/f.xml",
12192 feed::FailureKind::Status,
12193 "SENTINEL",
12194 )
12195 .await
12196 .unwrap();
12197
12198 let now = chrono::Utc::now();
12199 let health = store::poll_health(
12200 &state.db,
12201 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12202 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12203 )
12204 .await
12205 .unwrap();
12206 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12207 assert_eq!(
12208 counted, health.in_backoff,
12209 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12210 health.in_backoff, health.failure_kinds,
12211 );
12212 assert!(
12213 health
12214 .failure_kinds
12215 .iter()
12216 .any(|(k, n)| k == "unknown" && *n == 2),
12217 "no unknown bucket for the legacy rows: {:?}",
12218 health.failure_kinds,
12219 );
12220 }
12221
12222 #[tokio::test]
12227 async fn the_failure_breakdown_is_ordered_by_count() {
12228 let state = test_state(&[]).await;
12229 for (url, kind, n) in [
12230 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12231 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12232 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12233 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12234 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12235 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12236 ] {
12237 store::upsert_feed(
12238 &state.db,
12239 &store::NewFeed {
12240 url: url.to_string(),
12241 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12242 ..Default::default()
12243 },
12244 )
12245 .await
12246 .unwrap();
12247 for _ in 0..n {
12248 store::bump_feed_errors(&state.db, url, kind, "d")
12249 .await
12250 .unwrap();
12251 }
12252 }
12253 let now = chrono::Utc::now();
12254 let health = store::poll_health(
12255 &state.db,
12256 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12257 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12258 )
12259 .await
12260 .unwrap();
12261 let labels: Vec<&str> = health
12262 .failure_kinds
12263 .iter()
12264 .map(|(k, _)| k.as_str())
12265 .collect();
12266 assert_eq!(
12267 labels,
12268 ["fetch", "status", "parse"],
12269 "not ordered by count, descending: {:?}",
12270 health.failure_kinds,
12271 );
12272 }
12273
12274 #[tokio::test]
12286 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12287 let state = test_state(&[]).await;
12288 for (url, kind, detail, errors) in [
12289 (
12295 "https://a.example/f.xml",
12296 feed::FailureKind::Fetch,
12297 "SENTINEL_CONNREFUSED",
12298 3,
12299 ),
12300 (
12301 "https://b.example/f.xml",
12302 feed::FailureKind::Fetch,
12303 "SENTINEL_DNSFAIL",
12304 2,
12305 ),
12306 (
12307 "https://c.example/f.xml",
12308 feed::FailureKind::Status,
12309 "SENTINEL_404",
12310 1,
12311 ),
12312 (
12313 "https://d.example/f.xml",
12314 feed::FailureKind::Parse,
12315 "SENTINEL_UNPARSEABLE",
12316 1,
12317 ),
12318 ] {
12319 store::upsert_feed(
12320 &state.db,
12321 &store::NewFeed {
12322 url: url.to_string(),
12323 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12324 ..Default::default()
12325 },
12326 )
12327 .await
12328 .unwrap();
12329 for _ in 0..errors {
12330 store::bump_feed_errors(&state.db, url, kind, detail)
12331 .await
12332 .unwrap();
12333 }
12334 }
12335
12336 let resp = router(state.clone())
12337 .oneshot(
12338 Request::builder()
12339 .uri("/stats")
12340 .body(Body::empty())
12341 .unwrap(),
12342 )
12343 .await
12344 .unwrap();
12345 assert_eq!(resp.status(), StatusCode::OK);
12346 let body = String::from_utf8(
12347 axum::body::to_bytes(resp.into_body(), usize::MAX)
12348 .await
12349 .unwrap()
12350 .to_vec(),
12351 )
12352 .unwrap();
12353
12354 assert!(
12356 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12357 "the cause histogram did not render: {body}",
12358 );
12359
12360 for secret in [
12363 "a.example",
12364 "b.example",
12365 "c.example",
12366 "d.example",
12367 "SENTINEL_CONNREFUSED",
12368 "SENTINEL_DNSFAIL",
12369 "SENTINEL_404",
12370 "SENTINEL_UNPARSEABLE",
12371 ] {
12372 assert!(
12373 !body.contains(secret),
12374 "{secret:?} reached the PUBLIC stats page: {body}",
12375 );
12376 }
12377 }
12378
12379 #[tokio::test]
12382 async fn health_checks_the_database_and_reports_the_loops() {
12383 let state = test_state(&[]).await;
12384 let body_of = |state: AppState| async move {
12385 let resp = router(state)
12386 .oneshot(
12387 Request::builder()
12388 .uri("/health")
12389 .body(Body::empty())
12390 .unwrap(),
12391 )
12392 .await
12393 .unwrap();
12394 let status = resp.status();
12395 let body = String::from_utf8(
12396 axum::body::to_bytes(resp.into_body(), usize::MAX)
12397 .await
12398 .unwrap()
12399 .to_vec(),
12400 )
12401 .unwrap();
12402 (status, body)
12403 };
12404
12405 state
12408 .runtime_health
12409 .set_started_at(chrono::Utc::now().timestamp());
12410
12411 let (status, body) = body_of(state.clone()).await;
12412 assert_eq!(status, StatusCode::OK);
12413 assert!(
12414 body.contains("db: ok"),
12415 "health did not probe the DB: {body}"
12416 );
12417 assert!(
12418 body.contains("uptime:"),
12419 "no uptime — the first thing anyone asks about a container that may \
12420 be restarting: {body}"
12421 );
12422 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12423 assert!(body.contains("polling-paused: no"), "{body}");
12424 assert!(body.contains("backend:"), "{body}");
12425 assert!(body.contains("oauth-runtime:"), "{body}");
12426
12427 state.runtime_health.set_watermark(true);
12432 state.runtime_health.set_schedulers_enabled(true);
12433 let (status, body) = body_of(state.clone()).await;
12434 assert_eq!(
12435 status,
12436 StatusCode::OK,
12437 "a watermark pause must not fail the liveness check: {body}"
12438 );
12439 assert!(body.contains("polling-paused: yes"), "{body}");
12440 assert!(
12443 body.contains("poller: not-yet-ticked"),
12444 "a never-ticked poller must say so: {body}"
12445 );
12446
12447 let stale_after = health_tick_stale_secs(configured_poll_tick());
12449 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12450 state.runtime_health.poll_tick_completed(long_ago);
12451 let (status, body) = body_of(state.clone()).await;
12452 assert_eq!(
12453 status,
12454 StatusCode::OK,
12455 "a stale poller must not 503: {body}"
12456 );
12457 assert!(body.contains("poller: stale"), "{body}");
12458
12459 state.runtime_health.poll_tick_completed(0); state
12467 .runtime_health
12468 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12469 let (status, body) = body_of(state.clone()).await;
12470 assert_eq!(status, StatusCode::OK);
12471 assert!(
12472 body.contains("poller: stale never-ticked"),
12473 "a poller that never ticked long after boot still reads as benign: {body}"
12474 );
12475
12476 state.db.close().await;
12479 let (status, body) = body_of(state.clone()).await;
12480 assert_eq!(
12481 status,
12482 StatusCode::SERVICE_UNAVAILABLE,
12483 "an unreachable database must fail the check: {body}"
12484 );
12485 assert!(body.starts_with("FAIL"), "{body}");
12486 assert!(
12490 !body.contains("PoolClosed") && !body.contains("sqlx"),
12491 "health leaked the raw database error to an unauthenticated caller: {body}"
12492 );
12493 }
12494
12495 #[test]
12501 fn the_stale_threshold_follows_the_poll_tick() {
12502 assert_eq!(
12505 health_tick_stale_secs(Duration::from_secs(60)),
12506 HEALTH_TICK_STALE_FLOOR_SECS
12507 );
12508 let slow = Duration::from_secs(30 * 60);
12511 assert!(
12512 health_tick_stale_secs(slow) > slow.as_secs() as i64,
12513 "a 30-minute tick must not be stale after one interval"
12514 );
12515 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12516 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12518 }
12519
12520 #[tokio::test]
12526 async fn stats_does_not_call_a_stopped_poller_running() {
12527 let state = test_state(&[]).await;
12528 let render = |state: AppState| async move {
12529 let resp = router(state)
12530 .oneshot(
12531 Request::builder()
12532 .uri("/stats")
12533 .body(Body::empty())
12534 .unwrap(),
12535 )
12536 .await
12537 .unwrap();
12538 assert_eq!(resp.status(), StatusCode::OK);
12539 String::from_utf8(
12540 axum::body::to_bytes(resp.into_body(), usize::MAX)
12541 .await
12542 .unwrap()
12543 .to_vec(),
12544 )
12545 .unwrap()
12546 };
12547
12548 let body = render(state.clone()).await;
12550 assert!(
12551 body.contains("the poller is not running on this instance"),
12552 "a disabled poller renders as healthy"
12553 );
12554
12555 state.runtime_health.set_schedulers_enabled(true);
12557 let body = render(state.clone()).await;
12558 assert!(
12559 body.contains("no poll has finished since this instance booted"),
12560 "a poller that has not ticked renders as healthy"
12561 );
12562
12563 state
12565 .runtime_health
12566 .poll_tick_completed(chrono::Utc::now().timestamp());
12567 let body = render(state.clone()).await;
12568 assert!(
12569 body.contains("running"),
12570 "a healthy poller must read as running"
12571 );
12572
12573 state.runtime_health.set_watermark(true);
12575 let body = render(state.clone()).await;
12576 assert!(
12577 body.contains("the cache is at its size limit"),
12578 "a watermark pause is hidden once the poller is ticking"
12579 );
12580 }
12581
12582 #[tokio::test]
12593 async fn health_reports_an_unmeasured_database_without_failing() {
12594 use crate::runtime_health::DbProbe;
12595 let state = test_state(&[]).await;
12596
12597 let held = state
12600 .runtime_health
12601 .begin_db_probe()
12602 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12603
12604 let resp = router(state.clone())
12605 .oneshot(
12606 Request::builder()
12607 .uri("/health")
12608 .body(Body::empty())
12609 .unwrap(),
12610 )
12611 .await
12612 .unwrap();
12613 let status = resp.status();
12614 let body = String::from_utf8(
12615 axum::body::to_bytes(resp.into_body(), usize::MAX)
12616 .await
12617 .unwrap()
12618 .to_vec(),
12619 )
12620 .unwrap();
12621 drop(held);
12622
12623 assert_eq!(
12624 status,
12625 StatusCode::OK,
12626 "an unmeasured database failed the check, which an unauthenticated \
12627 caller can cause on demand: {body}"
12628 );
12629 assert!(
12630 body.contains("db: unknown"),
12631 "the unmeasured state must still be REPORTED: {body}"
12632 );
12633 assert!(!body.starts_with("FAIL"), "{body}");
12634 assert!(
12639 !body.starts_with("ok"),
12640 "the unmeasured state is indistinguishable from healthy to a \
12641 body-matching monitor: {body}"
12642 );
12643 assert!(body.starts_with("unknown"), "{body}");
12644
12645 let held = state
12656 .runtime_health
12657 .begin_db_probe()
12658 .unwrap_or_else(|_| panic!("claim"));
12659 state
12660 .runtime_health
12661 .record_for_test(DbProbe::Failed("unavailable".to_string()));
12662 let resp = router(state.clone())
12663 .oneshot(
12664 Request::builder()
12665 .uri("/health")
12666 .body(Body::empty())
12667 .unwrap(),
12668 )
12669 .await
12670 .unwrap();
12671 let status = resp.status();
12672 let body = String::from_utf8(
12673 axum::body::to_bytes(resp.into_body(), usize::MAX)
12674 .await
12675 .unwrap()
12676 .to_vec(),
12677 )
12678 .unwrap();
12679 drop(held);
12680 assert_eq!(
12681 status,
12682 StatusCode::SERVICE_UNAVAILABLE,
12683 "a BORROWED failure verdict must fail the check, not just a freshly \
12684 measured one: {body}"
12685 );
12686 assert!(body.starts_with("FAIL"), "{body}");
12687
12688 state.db.close().await;
12689 let resp = router(state.clone())
12690 .oneshot(
12691 Request::builder()
12692 .uri("/health")
12693 .body(Body::empty())
12694 .unwrap(),
12695 )
12696 .await
12697 .unwrap();
12698 assert_eq!(
12699 resp.status(),
12700 StatusCode::SERVICE_UNAVAILABLE,
12701 "a measured database failure must still fail the check"
12702 );
12703 }
12704
12705 #[tokio::test]
12714 async fn an_abandoned_request_still_records_its_probe() {
12715 use crate::runtime_health::DbProbe;
12716 let state = test_state(&[]).await;
12717 let rh = state.runtime_health.clone();
12718
12719 let app = router(state.clone());
12721 let fut = app.oneshot(
12722 Request::builder()
12723 .uri("/health")
12724 .body(Body::empty())
12725 .unwrap(),
12726 );
12727 let handle = tokio::spawn(fut);
12728 handle.abort();
12729 let _ = handle.await;
12730
12731 for _ in 0..50 {
12734 if rh.begin_db_probe().is_ok() {
12735 break;
12736 }
12737 tokio::time::sleep(Duration::from_millis(20)).await;
12738 }
12739 let resp = router(state.clone())
12740 .oneshot(
12741 Request::builder()
12742 .uri("/health")
12743 .body(Body::empty())
12744 .unwrap(),
12745 )
12746 .await
12747 .unwrap();
12748 let body = String::from_utf8(
12749 axum::body::to_bytes(resp.into_body(), usize::MAX)
12750 .await
12751 .unwrap()
12752 .to_vec(),
12753 )
12754 .unwrap();
12755 assert!(
12756 body.contains("db: ok"),
12757 "after an abandoned request the next caller still reads an \
12758 unmeasured database — the probe was cancelled with it: {body}"
12759 );
12760 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12762 }
12763
12764 #[tokio::test]
12771 async fn the_health_probe_opens_a_real_table() {
12772 use sqlx::Row;
12773 let state = test_state(&[]).await;
12774 let opcodes = |sql: &'static str| {
12776 let db = state.db.clone();
12777 async move {
12778 sqlx::query(sql)
12779 .fetch_all(&db)
12780 .await
12781 .unwrap()
12782 .into_iter()
12783 .map(|r| r.get::<String, _>("opcode"))
12784 .collect::<Vec<String>>()
12785 }
12786 };
12787
12788 let explain: &'static str =
12791 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12792 let probe = opcodes(explain).await;
12793 assert!(
12795 health_db_probe(&state.db).await.is_ok(),
12796 "the probe does not run against the real schema",
12797 );
12798 assert!(
12799 probe.iter().any(|op| op == "OpenRead"),
12800 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12801 );
12802 let bare = opcodes("EXPLAIN SELECT 1").await;
12804 assert!(
12805 !bare.iter().any(|op| op == "OpenRead"),
12806 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12807 );
12808 }
12809
12810 #[test]
12813 fn an_instance_that_has_never_polled_says_so() {
12814 assert_eq!(humanise_ago(None), "never");
12815 assert_eq!(humanise_ago(Some(0)), "0s ago");
12816 assert_eq!(humanise_ago(Some(59)), "59s ago");
12817 assert_eq!(humanise_ago(Some(60)), "1m ago");
12818 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12819 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12820 }
12821
12822 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12825 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12826 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12827 let addr = listener.local_addr().unwrap();
12828 let (url, title) = (saved_url.to_string(), saved_title.to_string());
12829 tokio::spawn(async move {
12830 loop {
12831 let Ok((mut sock, _)) = listener.accept().await else {
12832 break;
12833 };
12834 let mut buf = vec![0u8; 8192];
12835 let Ok(n) = sock.read(&mut buf).await else {
12836 continue;
12837 };
12838 let req = String::from_utf8_lossy(&buf[..n]).to_string();
12839 let wants_saved = req.contains("community.lexicon.rss.saved");
12840 let records = if wants_saved {
12841 serde_json::json!([{
12842 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12843 "cid": "bafy",
12844 "value": {
12845 "$type": "community.lexicon.rss.saved",
12846 "url": url,
12847 "title": title,
12848 "createdAt": "2026-01-01T00:00:00Z"
12849 }
12850 }])
12851 } else {
12852 serde_json::json!([])
12853 };
12854 let body = serde_json::json!({
12855 "ok": true, "data": { "records": records }
12856 })
12857 .to_string();
12858 let resp = format!(
12859 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12860 body.len(), body
12861 );
12862 let _ = sock.write_all(resp.as_bytes()).await;
12863 let _ = sock.flush().await;
12864 }
12865 });
12866 format!("http://{addr}")
12867 }
12868
12869 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12872 let feed = subscribed_feed.to_string();
12873 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12874 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12875 let addr = listener.local_addr().unwrap();
12876 tokio::spawn(async move {
12877 loop {
12878 let Ok((mut sock, _)) = listener.accept().await else {
12879 break;
12880 };
12881 let mut buf = vec![0u8; 8192];
12882 let Ok(read) = sock.read(&mut buf).await else {
12883 continue;
12884 };
12885 let req = String::from_utf8_lossy(&buf[..read]).to_string();
12886 let records = if req.contains("community.lexicon.rss.saved") {
12887 serde_json::Value::Array(
12888 (0..n)
12889 .map(|i| {
12890 serde_json::json!({
12891 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12892 "cid": "bafy",
12893 "value": {
12894 "$type": "community.lexicon.rss.saved",
12895 "url": format!("https://elsewhere.example/{i}"),
12896 "title": format!("Elsewhere {i}"),
12897 "createdAt": "2026-01-01T00:00:00Z"
12898 }
12899 })
12900 })
12901 .collect(),
12902 )
12903 } else if req.contains("community.lexicon.rss.subscription") {
12904 serde_json::json!([{
12909 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12910 "cid": "bafy",
12911 "value": {
12912 "$type": "community.lexicon.rss.subscription",
12913 "url": feed,
12914 "createdAt": "2026-01-01T00:00:00Z"
12915 }
12916 }])
12917 } else {
12918 serde_json::json!([])
12919 };
12920 let body =
12921 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12922 let resp = format!(
12923 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12924 body.len(), body
12925 );
12926 let _ = sock.write_all(resp.as_bytes()).await;
12927 let _ = sock.flush().await;
12928 }
12929 });
12930 format!("http://{addr}")
12931 }
12932
12933 #[tokio::test]
12941 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12942 let did = "did:plc:pagerloop";
12943 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12944 let state = test_state_with_sidecar(&[], &sidecar).await;
12945 store::grant_access(&state.db, did, None, "test", None)
12946 .await
12947 .unwrap();
12948 let feed = store::upsert_feed(
12949 &state.db,
12950 &store::NewFeed {
12951 url: "https://loop.example/feed.xml".to_string(),
12952 title: Some("Loop".to_string()),
12953 ..Default::default()
12954 },
12955 )
12956 .await
12957 .unwrap();
12958 let entries: Vec<store::NewEntry> = (0..250)
12961 .map(|i| store::NewEntry {
12962 guid: format!("s-{i:04}"),
12963 url: Some(format!("https://loop.example/{i}")),
12964 title: Some(format!("Starred {i:04}")),
12965 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12966 ..Default::default()
12967 })
12968 .collect();
12969 store::insert_entries(&state.db, feed, &entries, 0)
12970 .await
12971 .unwrap();
12972 store::replace_sub_refs(&state.db, did, &[feed])
12973 .await
12974 .unwrap();
12975 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12976 .await
12977 .unwrap()
12978 {
12979 store::mark_starred(&state.db, did, row.id, true)
12980 .await
12981 .unwrap();
12982 }
12983
12984 let cookie = session_cookie(&state, did, None);
12985 let app = router(state.clone());
12986 let get = |uri: &str| {
12987 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12988 async move {
12989 let resp = app
12990 .oneshot(
12991 Request::builder()
12992 .uri(uri)
12993 .header(header::COOKIE, cookie)
12994 .body(Body::empty())
12995 .unwrap(),
12996 )
12997 .await
12998 .unwrap();
12999 assert_eq!(resp.status(), StatusCode::OK);
13000 String::from_utf8(
13001 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
13002 .await
13003 .unwrap()
13004 .to_vec(),
13005 )
13006 .unwrap()
13007 }
13008 };
13009
13010 let p3 = get("/?view=starred&page=3").await;
13015 assert!(
13016 p3.contains("Page 3 of 4"),
13017 "the pager and the clamp disagree on the total: {}",
13018 p3.split("pager-pos")
13019 .nth(1)
13020 .unwrap_or("")
13021 .chars()
13022 .take(120)
13023 .collect::<String>()
13024 );
13025 assert!(
13028 p3.contains("Elsewhere 0"),
13029 "page 3 should start the uncached run"
13030 );
13031 assert_eq!(
13032 p3.matches("<li class=\"entry").count(),
13033 ENTRIES_PER_PAGE as usize,
13034 "the boundary page is not full"
13035 );
13036
13037 {
13046 let body = &p3;
13047 assert!(
13048 body.contains("330 entries"),
13049 "the heading must count the whole sequence: {}",
13050 body.split("content-count")
13051 .nth(1)
13052 .unwrap_or("")
13053 .chars()
13054 .take(120)
13055 .collect::<String>()
13056 );
13057 assert!(
13058 body.contains("(80 saved elsewhere)"),
13059 "the heading must say how many of the total the cache cannot show, \
13060 as a whole-list figure and not a per-page one: {}",
13061 body.split("content-count")
13062 .nth(1)
13063 .unwrap_or("")
13064 .chars()
13065 .take(120)
13066 .collect::<String>()
13067 );
13068 assert!(
13069 !body.contains("plus 50") && !body.contains("plus 80"),
13070 "the heading is adding the uncached rows to a total that already \
13071 includes them"
13072 );
13073 }
13074
13075 let p4 = get("/?view=starred&page=4").await;
13076 assert!(
13077 p4.contains("Page 4 of 4"),
13078 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
13079 );
13080 assert_eq!(
13081 p4.matches("<li class=\"entry").count(),
13082 30,
13083 "page 4 should hold the remaining 30 uncached records"
13084 );
13085 assert!(
13086 p4.contains("Elsewhere 79"),
13087 "the LAST saved record is unreachable — it can only be removed from here"
13088 );
13089
13090 assert!(
13092 !p4.contains("Elsewhere 0"),
13093 "an uncached record was rendered on more than one page"
13094 );
13095 let first = get("/?view=starred").await;
13098 assert!(
13099 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
13100 "the heading changed between pages; it describes the list, not the page"
13101 );
13102 assert!(
13103 !first.contains("Elsewhere "),
13104 "uncached saved records leaked onto the first page"
13105 );
13106 }
13107
13108 #[tokio::test]
13115 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
13116 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
13117 let sidecar =
13118 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
13119 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
13120 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
13121
13122 let resp = router(state)
13123 .oneshot(
13124 Request::builder()
13125 .uri("/?view=starred")
13126 .body(Body::empty())
13127 .unwrap(),
13128 )
13129 .await
13130 .unwrap();
13131 assert_eq!(resp.status(), StatusCode::OK);
13132 let body = String::from_utf8(
13133 axum::body::to_bytes(resp.into_body(), usize::MAX)
13134 .await
13135 .unwrap()
13136 .to_vec(),
13137 )
13138 .unwrap();
13139
13140 assert!(
13141 body.contains("Starred elsewhere"),
13142 "the saved record was not rendered at all"
13143 );
13144 assert!(
13145 body.contains("entry-uncached"),
13146 "it was not marked as uncached, so it looks like a normal entry"
13147 );
13148 assert!(
13149 body.contains("https://elsewhere.example/article"),
13150 "the row must link straight to the article"
13151 );
13152 assert!(
13153 !body.contains("/entries/0/"),
13154 "an uncached row must not offer entry actions against a nonexistent id"
13155 );
13156 }
13157
13158 #[test]
13166 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13167 for hostile in [
13168 "日本語日本語日本",
13169 "é",
13170 "",
13171 "2026",
13172 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13173 ] {
13174 let out = display_date(Some(hostile));
13175 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13176 }
13177 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13178 assert_eq!(display_date(None), "");
13179 }
13180
13181 #[test]
13184 fn the_unsave_route_is_rate_limited() {
13185 use axum::http::Method;
13186 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13187 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13189 }
13190
13191 #[tokio::test]
13200 async fn health_reports_a_broken_database() {
13201 let state = test_state(&[]).await;
13202 assert!(
13204 health_db_probe(&state.db).await.is_ok(),
13205 "the fixture was not healthy to begin with",
13206 );
13207
13208 sqlx::query("DROP TABLE feeds")
13209 .execute(&state.db)
13210 .await
13211 .unwrap();
13212
13213 assert!(
13214 health_db_probe(&state.db).await.is_err(),
13215 "the probe reported success against a database missing the table it \
13216 claims to read; `SELECT 1` would do exactly this",
13217 );
13218
13219 let resp = router(state)
13220 .oneshot(
13221 Request::builder()
13222 .uri("/health")
13223 .body(Body::empty())
13224 .unwrap(),
13225 )
13226 .await
13227 .unwrap();
13228 let body = String::from_utf8(
13229 axum::body::to_bytes(resp.into_body(), usize::MAX)
13230 .await
13231 .unwrap()
13232 .to_vec(),
13233 )
13234 .unwrap();
13235 assert!(
13237 body.starts_with("FAIL"),
13238 "/health did not report FAIL for a broken database: {body}",
13239 );
13240 assert!(
13241 !body.contains("db: ok"),
13242 "/health still called the database ok: {body}",
13243 );
13244 }
13245
13246 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13251 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13252 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13253 let addr = listener.local_addr().unwrap();
13254 tokio::spawn(async move {
13255 loop {
13256 let Ok((mut sock, _)) = listener.accept().await else {
13257 break;
13258 };
13259 let mut buf = vec![0u8; 8192];
13260 let Ok(n) = sock.read(&mut buf).await else {
13261 continue;
13262 };
13263 let req = String::from_utf8_lossy(&buf[..n]).to_string();
13264 let wants = |c: &str| req.contains(c);
13265 if fail_on.is_some_and(wants) {
13266 let body = r#"{"ok":false,"error":"ShortList"}"#;
13267 let resp = format!(
13268 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13269 body.len(),
13270 body
13271 );
13272 let _ = sock.write_all(resp.as_bytes()).await;
13273 let _ = sock.flush().await;
13274 continue;
13275 }
13276 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13277 serde_json::json!([{
13278 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13279 "cid": "bafy",
13280 "value": {
13281 "$type": crate::lexicon::nsid::SUBSCRIPTION,
13282 "url": "https://kept.example/feed.xml",
13283 "title": "Kept",
13284 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13289 "createdAt": "2026-01-01T00:00:00Z"
13290 }
13291 }])
13292 } else if wants(crate::lexicon::nsid::FOLDER) {
13293 serde_json::json!([{
13294 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13295 "cid": "bafy",
13296 "value": {
13297 "$type": crate::lexicon::nsid::FOLDER,
13298 "name": "Kept folder",
13299 "createdAt": "2026-01-01T00:00:00Z"
13300 }
13301 }])
13302 } else {
13303 serde_json::json!([])
13304 };
13305 let body =
13306 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13307 let resp = format!(
13308 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13309 body.len(),
13310 body
13311 );
13312 let _ = sock.write_all(resp.as_bytes()).await;
13313 let _ = sock.flush().await;
13314 }
13315 });
13316 format!("http://{addr}")
13317 }
13318
13319 async fn spawn_malformed_sidecar() -> String {
13322 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13323 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13324 let addr = listener.local_addr().unwrap();
13325 tokio::spawn(async move {
13326 loop {
13327 let Ok((mut sock, _)) = listener.accept().await else {
13328 break;
13329 };
13330 let mut buf = vec![0u8; 8192];
13331 let _ = sock.read(&mut buf).await;
13332 let body = serde_json::json!({ "ok": true, "data": { "records": [
13333 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13334 { "cid": "bafy", "value": {} },
13335 ]}})
13336 .to_string();
13337 let resp = format!(
13338 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13339 body.len(),
13340 body
13341 );
13342 let _ = sock.write_all(resp.as_bytes()).await;
13343 let _ = sock.flush().await;
13344 }
13345 });
13346 format!("http://{addr}")
13347 }
13348
13349 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13350 let cookie = session_cookie(&state, did, None);
13351 let resp = router(state)
13352 .oneshot(
13353 Request::builder()
13354 .uri(uri)
13355 .header(header::COOKIE, cookie)
13356 .body(Body::empty())
13357 .unwrap(),
13358 )
13359 .await
13360 .unwrap();
13361 let status = resp.status();
13362 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13363 .await
13364 .unwrap();
13365 (status, String::from_utf8_lossy(&body).to_string())
13366 }
13367
13368 #[tokio::test]
13374 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13375 let did = "did:plc:displayer";
13376 let state = test_state(&[did]).await;
13377 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13378 let feed_id = store::upsert_feed(
13379 &state.db,
13380 &store::NewFeed {
13381 url: url.into(),
13382 title: Some("Quiet Journal".into()),
13383 ..Default::default()
13384 },
13385 )
13386 .await
13387 .unwrap();
13388 store::replace_sub_refs(&state.db, did, &[feed_id])
13389 .await
13390 .unwrap();
13391 store::insert_entries(
13392 &state.db,
13393 feed_id,
13394 &[store::NewEntry {
13395 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13396 .into(),
13397 url: Some("https://quiet.example/no-summary".into()),
13398 title: Some("A title-only article".into()),
13399 published: Some("2026-07-11T00:00:00Z".into()),
13400 content_html: None,
13401 ..Default::default()
13402 }],
13403 0,
13404 )
13405 .await
13406 .unwrap();
13407 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13408 assert_eq!(status, StatusCode::OK);
13409 assert!(
13410 list.contains("A title-only article"),
13411 "the entry is missing from the list"
13412 );
13413
13414 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13415 .bind(feed_id)
13416 .fetch_one(&state.db)
13417 .await
13418 .unwrap();
13419 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13420 assert_eq!(
13421 status,
13422 StatusCode::OK,
13423 "the article page failed for an entry with no body"
13424 );
13425 assert!(page.contains("A title-only article"));
13426 assert!(
13427 page.contains("https://quiet.example/no-summary"),
13428 "no link to the original"
13429 );
13430 assert!(
13431 page.contains(r#"<time datetime=""#),
13432 "no date on the article page"
13433 );
13434 }
13435
13436 #[tokio::test]
13441 async fn a_malformed_subscription_record_raises_an_alert() {
13442 let did = "did:plc:alerted";
13443 for page in ["/", "/manage"] {
13444 let sidecar = spawn_malformed_sidecar().await;
13445 let state = test_state_with_sidecar(&[did], &sidecar).await;
13446 let (status, body) = page_body(state, did, page).await;
13447 assert_eq!(status, StatusCode::OK, "{page} did not render");
13448 assert!(
13449 body.contains(r#"role="alert""#) && body.contains("could not be read"),
13450 "{page} rendered no alert for a refused subscription list"
13451 );
13452 assert!(
13453 body.contains("1 record(s) in your subscription list"),
13454 "{page} gave the generic alert, not the malformed-record one"
13455 );
13456 }
13457 }
13458
13459 #[tokio::test]
13461 async fn a_healthy_subscription_listing_raises_no_alert() {
13462 let did = "did:plc:exporter";
13463 let sidecar = spawn_export_sidecar(None).await;
13464 let state = test_state_with_sidecar(&[did], &sidecar).await;
13465 let (status, body) = page_body(state, did, "/").await;
13466 assert_eq!(status, StatusCode::OK);
13467 assert!(
13468 !body.contains("could not be read"),
13469 "a healthy listing raised an alert"
13470 );
13471 }
13472
13473 async fn export_opml_response(
13475 fail_on: Option<&'static str>,
13476 ) -> (StatusCode, HeaderMap, String) {
13477 let did = "did:plc:exporter";
13478 let sidecar = spawn_export_sidecar(fail_on).await;
13479 let state = test_state_with_sidecar(&[did], &sidecar).await;
13480 let cookie = session_cookie(&state, did, None);
13481 let resp = router(state)
13482 .oneshot(
13483 Request::builder()
13484 .uri("/opml/export")
13485 .header(header::COOKIE, cookie)
13486 .body(Body::empty())
13487 .unwrap(),
13488 )
13489 .await
13490 .unwrap();
13491 let status = resp.status();
13492 let headers = resp.headers().clone();
13493 let body = String::from_utf8_lossy(
13494 &axum::body::to_bytes(resp.into_body(), usize::MAX)
13495 .await
13496 .unwrap(),
13497 )
13498 .to_string();
13499 (status, headers, body)
13500 }
13501
13502 #[tokio::test]
13515 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13516 let (status, headers, body) =
13517 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13518
13519 assert_ne!(
13520 status,
13521 StatusCode::OK,
13522 "a failed subscription walk answered 200: {body}",
13523 );
13524 assert!(
13525 !headers.contains_key(header::CONTENT_DISPOSITION),
13526 "a failed subscription walk still offered a download: {headers:?}",
13527 );
13528 assert!(
13529 !body.contains("<opml"),
13530 "a failed subscription walk still served an OPML document: {body}",
13531 );
13532 }
13533
13534 #[tokio::test]
13538 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13539 let (status, headers, body) =
13540 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13541
13542 assert_ne!(
13543 status,
13544 StatusCode::OK,
13545 "a failed folder walk answered 200: {body}",
13546 );
13547 assert!(
13548 !headers.contains_key(header::CONTENT_DISPOSITION),
13549 "a failed folder walk still offered a download: {headers:?}",
13550 );
13551 assert!(
13552 !body.contains("<opml"),
13553 "a failed folder walk still served an OPML document: {body}",
13554 );
13555 }
13556
13557 #[tokio::test]
13560 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13561 let (status, headers, body) = export_opml_response(None).await;
13562
13563 assert_eq!(
13564 status,
13565 StatusCode::OK,
13566 "a healthy export did not answer 200"
13567 );
13568 assert_eq!(
13569 headers
13570 .get(header::CONTENT_DISPOSITION)
13571 .and_then(|v| v.to_str().ok()),
13572 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13573 "a healthy export did not offer the download",
13574 );
13575 assert!(
13576 body.contains("https://kept.example/feed.xml"),
13577 "the exported OPML lost the subscription: {body}",
13578 );
13579 assert!(
13580 body.contains("Kept folder"),
13581 "the exported OPML lost the folder: {body}",
13582 );
13583 }
13584}