1use std::collections::HashMap;
60use std::net::IpAddr;
61use std::sync::Mutex;
62use std::time::{Duration, Instant};
63
64use askama::Template;
65use axum::{
66 extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
67 http::{header, HeaderMap, StatusCode},
68 middleware::{self, Next},
69 response::{Html, IntoResponse, Redirect, Response},
70 routing::{get, post},
71 Form, Router,
72};
73use serde::Deserialize;
74use std::net::SocketAddr;
75use tower_http::services::{ServeDir, ServeFile};
76use tower_http::set_header::SetResponseHeaderLayer;
77use tower_http::trace::TraceLayer;
78use tracing::{info, warn};
79
80use crate::config::Config;
81use crate::lexicon::{self, Folder, Saved, Subscription};
82use crate::safe_link::SafeLink;
83use crate::{feed, store, AppState, Session, VERSION};
84
85#[path = "opml.rs"]
90mod opml;
91
92const SESSION_COOKIE: &str = "fr_session";
94
95const INVITE_COOKIE: &str = "fr_invite";
103
104const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
112
113const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
115
116const INVITE_TTL_SECS: i64 = 1800;
119
120const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
123
124const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
126
127const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
129
130const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
146 script-src 'self'; \
147 style-src 'self' 'unsafe-inline'; \
148 img-src 'self' https: data:; \
149 font-src 'self'; \
150 connect-src 'self'; \
151 form-action 'self'; \
152 base-uri 'self'; \
153 frame-ancestors 'none'; \
154 object-src 'none'";
155
156#[derive(Clone, Debug)]
163struct CurrentUser {
164 did: String,
165 handle: Option<String>,
166 sid: Option<String>,
169}
170
171async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
182 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
183 if let Some(session) = state.sessions.get(&sid) {
184 if store::has_beta_access(&state.db, &session.did)
185 .await
186 .unwrap_or(false)
187 {
188 return Some(CurrentUser {
189 did: session.did,
190 handle: session.handle,
191 sid: Some(sid),
192 });
193 }
194 state.sessions.remove(&sid);
197 }
198 }
199 if let Some(did) = state.config.dev_did.clone() {
202 if store::has_beta_access(&state.db, &did)
203 .await
204 .unwrap_or(false)
205 {
206 return Some(CurrentUser {
207 did,
208 handle: None,
209 sid: None,
210 });
211 }
212 }
213 None
214}
215
216async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
218 current_session(state, headers).await.map(|u| u.did)
219}
220
221pub fn router(state: AppState) -> Router {
227 let limiter = RateLimiter::shared();
231 let rl_state = RateLimitState {
235 limiter,
236 trusted_header: state.config.trusted_ip_header.clone(),
237 };
238
239 Router::new()
240 .route("/health", get(health))
241 .route("/about", get(about))
242 .route("/standard-site", get(standard_site))
243 .route("/stats", get(stats))
244 .route("/privacy", get(privacy))
245 .route("/terms", get(terms))
246 .route("/manage", get(manage))
247 .route("/", get(index))
248 .route("/entries/{id}", get(entry_view))
249 .route("/entries/{id}/read", post(mark_read))
250 .route("/entries/{id}/star", post(toggle_star))
251 .route("/saved/{rkey}/delete", post(unsave_record))
252 .route("/read-all", post(mark_all_read))
253 .route("/subscriptions", post(add_subscription))
254 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
255 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
256 .route("/folders", post(create_folder))
257 .route("/folders/{rkey}/rename", post(rename_folder))
258 .route("/folders/{rkey}/delete", post(delete_folder))
259 .route(
262 "/opml",
263 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
264 )
265 .route("/opml/export", get(export_opml))
266 .route("/login", get(login_form).post(login_submit))
267 .route(
268 "/beta/redeem",
269 get(beta_redeem_form).post(beta_redeem_submit),
270 )
271 .route("/claim", get(claim))
274 .route("/bot/claims", post(bot_mint_claim))
277 .route("/admin/invites", post(admin_mint_invites))
278 .route("/admin/metrics", get(admin_metrics))
279 .route("/oauth/client-metadata.json", get(oauth_client_metadata))
280 .route("/oauth/jwks.json", get(oauth_jwks))
281 .route("/account/delete", post(account_delete))
282 .route("/oauth/callback", get(oauth_callback))
283 .route("/logout", post(logout))
284 .nest_service("/static", ServeDir::new("static"))
285 .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
289 .layer(middleware::from_fn(cache_control))
294 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
297 .layer(TraceLayer::new_for_http())
298 .layer(static_header_layer(
302 "content-security-policy",
303 CONTENT_SECURITY_POLICY,
304 ))
305 .layer(static_header_layer("x-content-type-options", "nosniff"))
306 .layer(static_header_layer(
307 "referrer-policy",
308 "strict-origin-when-cross-origin",
309 ))
310 .layer(static_header_layer("x-frame-options", "DENY"))
311 .with_state(state)
312}
313
314const OPML_BODY_LIMIT: usize = 1024 * 1024;
329
330#[cfg(test)]
340const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
341
342#[cfg(test)]
347const _: () = assert!(
348 OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
349 "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
350);
351
352fn static_header_layer(
356 name: &'static str,
357 value: &'static str,
358) -> SetResponseHeaderLayer<header::HeaderValue> {
359 SetResponseHeaderLayer::overriding(
360 header::HeaderName::from_static(name),
361 header::HeaderValue::from_static(value),
362 )
363}
364
365fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
385 use axum::http::Method;
386 if method != Method::POST
394 && !(method == Method::GET
395 && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
396 {
397 return false;
398 }
399 match path {
400 "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
405 | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
406 | "/folders" => true,
407 p => {
410 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
411 || p.starts_with("/saved/")
415 || p.starts_with("/subscriptions/")
416 || p.starts_with("/folders/")
417 }
418 }
419}
420
421#[derive(Clone)]
424struct RateLimitState {
425 limiter: RateLimiter,
426 trusted_header: Option<String>,
429}
430
431#[derive(Clone)]
436struct RateLimiter {
437 inner: std::sync::Arc<Mutex<RateLimiterState>>,
438}
439
440struct RateLimiterState {
442 buckets: HashMap<IpAddr, Bucket>,
443 last_sweep: Instant,
444}
445
446struct Bucket {
448 tokens: f64,
449 last: Instant,
450}
451
452const RATE_BURST: f64 = 20.0;
454const RATE_REFILL_PER_SEC: f64 = 1.0;
456const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
458
459const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
468
469const MAX_RATE_BUCKETS: usize = 10_000;
477
478const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
482
483impl RateLimiter {
484 fn shared() -> Self {
486 Self {
487 inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
488 buckets: HashMap::new(),
489 last_sweep: Instant::now(),
490 })),
491 }
492 }
493
494 fn check(&self, ip: IpAddr) -> bool {
497 self.check_at(ip, Instant::now())
498 }
499
500 fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
503 let mut state = match self.inner.lock() {
504 Ok(m) => m,
505 Err(p) => p.into_inner(),
507 };
508
509 if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
511 state
512 .buckets
513 .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
514 state.last_sweep = now;
515 }
516
517 if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
525 let mut by_age: Vec<(IpAddr, Instant)> =
526 state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
527 by_age.sort_unstable_by_key(|(_, last)| *last);
528 for (victim, _) in by_age
529 .into_iter()
530 .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
531 {
532 state.buckets.remove(&victim);
533 }
534 warn!(
535 buckets = state.buckets.len(),
536 "rate-limit bucket cap reached; evicted the least recently seen clients"
537 );
538 }
539
540 let bucket = state.buckets.entry(ip).or_insert(Bucket {
541 tokens: RATE_BURST,
542 last: now,
543 });
544 let elapsed = now.duration_since(bucket.last).as_secs_f64();
545 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
546 bucket.last = now;
547 if bucket.tokens >= 1.0 {
548 bucket.tokens -= 1.0;
549 true
550 } else {
551 false
552 }
553 }
554}
555
556fn client_ip(
577 headers: &HeaderMap,
578 conn: Option<&SocketAddr>,
579 trusted_header: Option<&str>,
580) -> Option<IpAddr> {
581 if let Some(name) = trusted_header {
582 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
583 if let Some(last) = raw.split(',').next_back() {
586 if let Ok(ip) = last.trim().parse::<IpAddr>() {
587 return Some(ip);
588 }
589 }
590 }
591 }
593 conn.map(|s| s.ip())
594}
595
596async fn rate_limit(
601 State(rl): State<RateLimitState>,
602 req: axum::extract::Request,
603 next: Next,
604) -> Response {
605 let path = req.uri().path().to_string();
606 let method = req.method().clone();
607 if is_rate_limited_path(&path, &method) {
608 let conn = req
609 .extensions()
610 .get::<ConnectInfo<SocketAddr>>()
611 .map(|c| c.0);
612 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
613 if let Some(ip) = ip {
619 if !rl.limiter.check(ip) {
620 warn!(%ip, %path, "rate limit exceeded");
621 return (
622 StatusCode::TOO_MANY_REQUESTS,
623 [(header::RETRY_AFTER, "1")],
624 "rate limit exceeded\n",
625 )
626 .into_response();
627 }
628 }
629 }
630 next.run(req).await
631}
632
633async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
644 let path = req.uri().path().to_string();
645 let is_login_landing = path == "/login"
648 && req.method() == axum::http::Method::GET
649 && !req.uri().query().unwrap_or("").contains("handle=");
650 let public = is_login_landing
651 || path == "/about"
652 || path == "/standard-site"
653 || path == "/privacy"
654 || path == "/terms"
655 || path.starts_with("/static/");
656
657 let mut resp = next.run(req).await;
658 if resp.headers().contains_key(header::CACHE_CONTROL) {
659 return resp;
660 }
661 let value = if public {
662 "public, max-age=300"
663 } else {
664 "no-store"
665 };
666 if let Ok(hv) = header::HeaderValue::from_str(value) {
667 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
668 }
669 resp
670}
671
672async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
681 sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
682 .fetch_optional(pool)
683 .await
684}
685
686const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
693
694const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
700
701const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
709
710fn health_tick_stale_secs(tick: Duration) -> i64 {
714 let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
715 tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
716}
717
718fn configured_poll_tick() -> Duration {
722 std::env::var("FEATHERREADER_POLL_TICK_SECS")
723 .ok()
724 .and_then(|v| v.trim().parse::<u64>().ok())
725 .filter(|s| *s > 0)
726 .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
727}
728
729const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
734
735const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
747
748async fn health(State(state): State<AppState>) -> Response {
792 let now = chrono::Utc::now().timestamp();
793 let rh = &state.runtime_health;
794
795 use crate::runtime_health::DbProbe;
796 let db = match rh.begin_db_probe() {
797 Err(borrowed) => borrowed,
800 Ok(probe) => {
801 let pool = state.db.clone();
811 let task = tokio::spawn(async move {
812 let verdict =
822 match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
823 Ok(Ok(_)) => DbProbe::Ok,
824 Ok(Err(err)) => {
829 warn!(%err, "health: database probe failed");
830 DbProbe::Failed("unavailable".to_string())
831 }
832 Err(_) => {
833 warn!(
834 timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
835 "health: database probe timed out (pool exhausted?)"
836 );
837 DbProbe::Failed("timeout".to_string())
838 }
839 };
840 probe.record(verdict.clone());
841 verdict
842 });
843 task.await.unwrap_or(DbProbe::Unknown)
847 }
848 };
849
850 let uptime = rh.uptime_secs(now);
851 let poller = if !rh.schedulers_enabled() {
852 "disabled".to_string()
855 } else {
856 match rh.secs_since_poll_tick(now) {
857 None => match uptime {
860 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
861 format!("stale never-ticked {up}s")
862 }
863 _ => "not-yet-ticked".to_string(),
864 },
865 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
866 format!("stale {secs}s")
867 }
868 Some(secs) => format!("ok {secs}s"),
869 }
870 };
871
872 let mut body = String::new();
881 let status = match &db {
882 DbProbe::Ok => {
883 body.push_str(&format!("ok featherreader/{VERSION}\n"));
884 body.push_str("db: ok\n");
885 StatusCode::OK
886 }
887 DbProbe::Unknown => {
894 body.push_str(&format!("unknown featherreader/{VERSION}\n"));
895 body.push_str("db: unknown (no probe has completed yet)\n");
896 StatusCode::OK
897 }
898 DbProbe::Failed(why) => {
899 body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
900 body.push_str(&format!("db: {why}\n"));
901 StatusCode::SERVICE_UNAVAILABLE
902 }
903 };
904 body.push_str(&format!(
908 "uptime: {}\n",
909 match uptime {
910 Some(secs) => format!("{secs}s"),
911 None => "unknown".to_string(),
912 }
913 ));
914 body.push_str(&format!("poller: {poller}\n"));
915 body.push_str(&format!(
916 "polling-paused: {}\n",
917 if rh.watermark_paused() { "yes" } else { "no" }
918 ));
919 body.push_str(&format!(
926 "backend: {}\n",
927 state.config.repo_backend.as_str()
928 ));
929 body.push_str(&format!(
930 "oauth-runtime: {}\n",
931 if state.oauth.is_some() {
932 "built"
933 } else {
934 "absent"
935 }
936 ));
937
938 let mut resp = (status, body).into_response();
941 if let Ok(hv) = header::HeaderValue::from_str("no-store") {
942 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
943 }
944 resp
945}
946
947async fn about(State(state): State<AppState>) -> Response {
956 let adoption = if state.config.show_adoption {
957 adoption_line(&state).await
958 } else {
959 None
960 };
961 render(&AboutTemplate {
962 card: Card::public(
963 &state.config,
964 "/about",
965 "About — FeatherReader",
966 "What FeatherReader is and isn't: an open-source, atproto-native reader for \
967 RSS feeds and standard.site publications, run as an experiment, free to \
968 self-host under the AGPL.",
969 ),
970 version: VERSION,
971 repo_url: REPO_URL,
972 kofi_url: KOFI_URL,
973 adoption,
974 standard_site: state.config.standard_site,
975 })
976}
977
978async fn standard_site(State(state): State<AppState>) -> Response {
984 render(&StandardSiteTemplate {
985 card: Card::public(
986 &state.config,
987 "/standard-site",
988 "standard.site — FeatherReader",
989 "Read standard.site publications beside your RSS feeds: articles \
990 published as atproto records, followed with the same portable \
991 subscription record.",
992 ),
993 version: VERSION,
994 repo_url: REPO_URL,
995 kofi_url: KOFI_URL,
996 standard_site: state.config.standard_site,
997 releases: RELEASES,
998 })
999}
1000
1001async fn unsave_record(
1016 State(state): State<AppState>,
1017 headers: HeaderMap,
1018 Path(rkey): Path<String>,
1019) -> Response {
1020 let Some(did) = current_did(&state, &headers).await else {
1021 return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1022 };
1023
1024 let identity = match state.repo().list_saved(&did).await {
1029 Ok(records) => records
1030 .into_iter()
1031 .find(|(k, _)| *k == rkey)
1032 .map(|(_, rec)| (rec.url, rec.entry_id)),
1033 Err(err) => {
1034 warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1035 a local star for the same article may survive");
1036 None
1037 }
1038 };
1039
1040 match state.repo().remove_saved(&did, &rkey).await {
1041 Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1042 Err(err) => {
1043 warn!(%err, %did, %rkey, "could not remove the saved record");
1044 return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1045 }
1046 }
1047
1048 if let Some((url, guid)) = identity {
1052 match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1053 Ok(0) => {}
1054 Ok(n) => {
1055 info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1056 }
1057 Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1058 }
1059 }
1060 if is_htmx(&headers) {
1062 return (StatusCode::OK, "").into_response();
1063 }
1064 Redirect::to("/?view=starred").into_response()
1065}
1066
1067fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1080 if !rh.schedulers_enabled() {
1081 return "off";
1082 }
1083 match rh.secs_since_poll_tick(now_unix) {
1086 None => {
1087 match rh.uptime_secs(now_unix) {
1090 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1091 _ => "starting",
1092 }
1093 }
1094 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1095 _ if rh.watermark_paused() => "paused",
1096 _ => "running",
1097 }
1098}
1099
1100async fn stats(State(state): State<AppState>) -> Response {
1102 let now = chrono::Utc::now();
1103 let health = match store::poll_health(
1104 &state.db,
1105 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1106 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1107 )
1108 .await
1109 {
1110 Ok(health) => health,
1111 Err(err) => {
1112 warn!(%err, "could not compute poll health");
1113 return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1114 }
1115 };
1116
1117 let polled_pct = if health.feeds_tracked == 0 {
1120 100
1121 } else {
1122 health.polled_last_hour * 100 / health.feeds_tracked
1123 };
1124
1125 render(&StatsTemplate {
1126 card: Card::public(
1127 &state.config,
1128 "/stats",
1129 "Stats — FeatherReader",
1130 "Is this instance's poller keeping up? Aggregate feed-polling health — \
1131 counts only; no feed and no reader is named.",
1132 ),
1133 version: VERSION,
1134 repo_url: REPO_URL,
1135 kofi_url: KOFI_URL,
1136 feeds_tracked: health.feeds_tracked,
1137 polled_last_hour: health.polled_last_hour,
1138 polled_pct,
1139 overdue: health.overdue,
1140 last_poll: humanise_ago(health.last_poll_secs_ago),
1141 oldest_poll: if health.never_polled > 0 {
1142 "never".to_string()
1143 } else {
1144 humanise_ago(health.oldest_poll_secs_ago)
1145 },
1146 never_polled: health.never_polled,
1147 poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1148 in_backoff: health.in_backoff,
1157 badly_broken: health.badly_broken,
1158 failure_kinds: health.failure_kinds,
1159 fetching: fetching_state(&state.runtime_health, now.timestamp()),
1160 })
1161}
1162
1163fn humanise_ago(secs: Option<i64>) -> String {
1168 let Some(secs) = secs else {
1169 return "never".to_string();
1170 };
1171 match secs {
1172 s if s < 60 => format!("{s}s ago"),
1173 s if s < 3600 => format!("{}m ago", s / 60),
1174 s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1175 }
1176}
1177
1178async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1186 match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1187 Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1189 repos: stat.value,
1190 truncated: stat.truncated,
1191 observed_on: stat
1192 .observed_at
1193 .split('T')
1194 .next()
1195 .unwrap_or_default()
1196 .to_string(),
1197 }),
1198 Ok(_) => None,
1199 Err(err) => {
1200 warn!(%err, "about: adoption stat read failed; omitting the line");
1201 None
1202 }
1203 }
1204}
1205
1206async fn privacy(State(state): State<AppState>) -> Response {
1210 render(&PrivacyTemplate {
1211 card: Card::public(
1212 &state.config,
1213 "/privacy",
1214 "Privacy — FeatherReader",
1215 "No account and no tracking: your subscriptions and reading state live in \
1216 your own PDS. What this server caches, for how long, and how the session \
1217 token is handled.",
1218 ),
1219 version: VERSION,
1220 repo_url: REPO_URL,
1221 kofi_url: KOFI_URL,
1222 })
1223}
1224
1225async fn terms(State(state): State<AppState>) -> Response {
1229 render(&TermsTemplate {
1230 card: Card::public(
1231 &state.config,
1232 "/terms",
1233 "Terms — FeatherReader",
1234 "The terms of use: an experimental service offered as-is with no warranty, \
1235 what acceptable use means here, and the AGPL self-host note.",
1236 ),
1237 version: VERSION,
1238 repo_url: REPO_URL,
1239 kofi_url: KOFI_URL,
1240 })
1241}
1242
1243struct FeedView {
1250 rkey: String,
1252 url: String,
1254 title: String,
1255 unread: i64,
1256 selected: bool,
1258 folder: Option<String>,
1263}
1264
1265struct FolderView {
1267 rkey: String,
1269 uri: String,
1271 name: String,
1272 feeds: Vec<FeedView>,
1273 selected: bool,
1275}
1276
1277struct EntryRow {
1279 id: i64,
1280 title: String,
1281 feed_title: String,
1282 published: String,
1283 read: bool,
1284 starred: bool,
1285 link: SafeLink,
1288 cached: bool,
1296 rkey: String,
1298}
1299
1300struct FolderOption {
1302 uri: String,
1303 name: String,
1304}
1305
1306struct Nav {
1311 handle: String,
1313 avatar: String,
1315 view: String,
1317 scope_qs: String,
1320 folders: Vec<FolderView>,
1323 loose_feeds: Vec<FeedView>,
1324 manage_active: bool,
1326}
1327
1328pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1337
1338const SITE_TITLE: &str = "FeatherReader — read, quietly";
1345
1346const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1349standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1350password, no tracking.";
1351
1352const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1357
1358#[derive(Debug, Clone)]
1369pub(crate) struct Card {
1370 pub title: String,
1372 pub description: String,
1375 pub url: String,
1377 pub image: String,
1379 pub private: bool,
1383}
1384
1385impl Card {
1386 fn public(
1388 config: &Config,
1389 path: &str,
1390 title: impl Into<String>,
1391 description: impl Into<String>,
1392 ) -> Self {
1393 let origin = config.public_url.trim_end_matches('/');
1394 Card {
1395 title: title.into(),
1396 description: description.into(),
1397 url: format!("{origin}{path}"),
1398 image: format!("{origin}{SHARE_IMAGE_PATH}"),
1399 private: false,
1400 }
1401 }
1402
1403 fn site(config: &Config) -> Self {
1405 Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1406 }
1407
1408 fn private(config: &Config) -> Self {
1412 Card {
1413 private: true,
1414 ..Card::site(config)
1415 }
1416 }
1417}
1418
1419#[derive(Template)]
1421#[template(path = "index.html")]
1422struct IndexTemplate {
1423 card: Card,
1425 version: &'static str,
1426 repo_url: &'static str,
1427 kofi_url: &'static str,
1428 flash: String,
1429 alert: String,
1432 nav: Nav,
1434 entries: Vec<EntryRow>,
1436 heading: String,
1438 feed_scope: Option<String>,
1440 total: i64,
1448 uncached_total: i64,
1456 page: i64,
1458 page_count: i64,
1460 prev_href: Option<String>,
1462 next_href: Option<String>,
1464}
1465
1466#[derive(Template)]
1468#[template(path = "manage.html")]
1469struct ManageTemplate {
1470 card: Card,
1472 version: &'static str,
1473 repo_url: &'static str,
1474 kofi_url: &'static str,
1475 flash: String,
1476 alert: String,
1478 nav: Nav,
1479 folder_options: Vec<FolderOption>,
1481 folders: Vec<FolderView>,
1483 loose_feeds: Vec<FeedView>,
1484 standard_site: bool,
1490}
1491
1492struct AdoptionLine {
1497 repos: i64,
1499 truncated: bool,
1501 observed_on: String,
1503}
1504
1505#[derive(Template)]
1508#[template(path = "about.html")]
1509struct AboutTemplate {
1510 card: Card,
1512 version: &'static str,
1513 repo_url: &'static str,
1514 kofi_url: &'static str,
1515 adoption: Option<AdoptionLine>,
1516 standard_site: bool,
1519}
1520
1521#[derive(Template)]
1525#[template(path = "standard_site.html")]
1526struct StandardSiteTemplate {
1527 card: Card,
1529 version: &'static str,
1530 repo_url: &'static str,
1531 kofi_url: &'static str,
1532 standard_site: bool,
1535 releases: &'static [Release],
1537}
1538
1539pub(crate) struct Release {
1544 pub(crate) version: &'static str,
1546 pub(crate) date: &'static str,
1548 pub(crate) summary: &'static str,
1550}
1551
1552impl Release {
1553 pub(crate) fn url(&self) -> String {
1555 format!("{REPO_URL}/releases/tag/v{}", self.version)
1556 }
1557
1558 pub(crate) fn changelog_url(&self) -> String {
1562 format!(
1563 "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1564 self.version.replace('.', ""),
1565 self.date
1566 )
1567 }
1568}
1569
1570pub(crate) const RELEASES: &[Release] = &[
1575 Release {
1576 version: "0.4.5",
1577 date: "2026-10-06",
1578 summary: "An operator teardown now signs every user out at their own \
1579 server before deleting anything, and the session-writing \
1580 code is hardened against the races that work exposed.",
1581 },
1582 Release {
1583 version: "0.4.4",
1584 date: "2026-10-05",
1585 summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1586 links unchanged and real RSS bylines, and the address guard \
1587 refuses the reserved ranges it missed.",
1588 },
1589 Release {
1590 version: "0.4.3",
1591 date: "2026-10-05",
1592 summary: "Two write-path fixes for any PDS: large OPML imports and \
1593 read-state syncs are sent in calls the PDS accepts, and a \
1594 read-state sync that disagreed with the PDS recovers instead \
1595 of failing every round.",
1596 },
1597 Release {
1598 version: "0.4.2",
1599 date: "2026-10-04",
1600 summary: "A public standard.site feature page with this list of recent \
1601 releases, and link cards: a posted feather-reader.com link \
1602 now unfurls with a description and an image.",
1603 },
1604 Release {
1605 version: "0.4.1",
1606 date: "2026-10-04",
1607 summary: "The public pages explain standard.site publications, and the \
1608 subscribe form can submit the DID form of a publication URI, \
1609 which browsers refused in 0.4.0.",
1610 },
1611 Release {
1612 version: "0.4.0",
1613 date: "2026-10-03",
1614 summary: "standard.site support: publications are read from their \
1615 authors' atproto repos as subscriptions, beside RSS, on their \
1616 own polling loop. Every stored field from a feed or a \
1617 publication now has a size bound.",
1618 },
1619];
1620
1621#[derive(Template)]
1633#[template(path = "stats.html")]
1634struct StatsTemplate {
1635 card: Card,
1637 version: &'static str,
1638 repo_url: &'static str,
1639 kofi_url: &'static str,
1640 feeds_tracked: i64,
1641 polled_last_hour: i64,
1642 polled_pct: i64,
1643 overdue: i64,
1644 last_poll: String,
1645 oldest_poll: String,
1646 never_polled: i64,
1647 poll_interval_mins: i64,
1648 in_backoff: i64,
1650 badly_broken: i64,
1654 failure_kinds: Vec<(String, i64)>,
1656 fetching: &'static str,
1660}
1661
1662#[derive(Template)]
1666#[template(path = "privacy.html")]
1667struct PrivacyTemplate {
1668 card: Card,
1670 version: &'static str,
1671 repo_url: &'static str,
1672 kofi_url: &'static str,
1673}
1674
1675#[derive(Template)]
1678#[template(path = "terms.html")]
1679struct TermsTemplate {
1680 card: Card,
1682 version: &'static str,
1683 repo_url: &'static str,
1684 kofi_url: &'static str,
1685}
1686
1687#[derive(Template)]
1690#[template(path = "landing.html")]
1691struct LandingTemplate {
1692 card: Card,
1694 version: &'static str,
1695 repo_url: &'static str,
1696 crates_url: &'static str,
1697 kofi_url: &'static str,
1698 standard_site: bool,
1701 releases: &'static [Release],
1703}
1704
1705#[derive(Template)]
1707#[template(path = "entry.html")]
1708struct EntryTemplate {
1709 card: Card,
1711 version: &'static str,
1712 repo_url: &'static str,
1713 kofi_url: &'static str,
1714 nav: Nav,
1715 id: i64,
1716 title: String,
1717 feed_title: String,
1718 author: Option<String>,
1719 published: String,
1720 url: Option<SafeLink>,
1730 content_html: Option<String>,
1731 read: bool,
1732 starred: bool,
1733 back_qs: String,
1735 prev_id: Option<i64>,
1737 next_id: Option<i64>,
1738 oob: bool,
1740}
1741
1742#[derive(Template)]
1744#[template(path = "entry_row.html")]
1745struct EntryRowTemplate {
1746 e: EntryRow,
1747}
1748
1749#[derive(Template)]
1754#[template(path = "entry_actionbar.html")]
1755struct EntryActionBarTemplate {
1756 id: i64,
1757 read: bool,
1758 starred: bool,
1759 oob: bool,
1761}
1762
1763#[derive(Template)]
1765#[template(path = "login.html")]
1766struct LoginTemplate {
1767 card: Card,
1769 repo_url: &'static str,
1770 error: String,
1771 flash: String,
1774}
1775
1776#[derive(Template)]
1778#[template(path = "beta_redeem.html")]
1779struct BetaRedeemTemplate {
1780 card: Card,
1782 repo_url: &'static str,
1783 error: String,
1784 capacity_full: bool,
1787}
1788
1789fn render<T: Template>(tmpl: &T) -> Response {
1796 match tmpl.render() {
1797 Ok(body) => Html(body).into_response(),
1798 Err(err) => {
1799 warn!(%err, "template render failed");
1800 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1801 }
1802 }
1803}
1804
1805struct WebError {
1810 err: anyhow::Error,
1811 status: StatusCode,
1812}
1813
1814impl<E: Into<anyhow::Error>> From<E> for WebError {
1815 fn from(err: E) -> Self {
1816 WebError {
1817 err: err.into(),
1818 status: StatusCode::INTERNAL_SERVER_ERROR,
1819 }
1820 }
1821}
1822
1823impl WebError {
1824 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1826 WebError {
1827 err: err.into(),
1828 status,
1829 }
1830 }
1831}
1832
1833impl IntoResponse for WebError {
1834 fn into_response(self) -> Response {
1835 warn!(error = %self.err, status = %self.status, "request failed");
1836 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1837 "internal error"
1838 } else {
1839 self.status.canonical_reason().unwrap_or("error")
1840 };
1841 (self.status, body).into_response()
1842 }
1843}
1844
1845fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1850 let status = err.status();
1851 WebError::with_status(err, status)
1852}
1853
1854fn display_title(title: Option<&str>, url: &str) -> String {
1857 if let Some(t) = title {
1858 let t = t.trim();
1859 if !t.is_empty() {
1860 return t.to_string();
1861 }
1862 }
1863 url::Url::parse(url)
1864 .ok()
1865 .and_then(|u| u.host_str().map(str::to_string))
1866 .unwrap_or_else(|| url.to_string())
1867}
1868
1869fn display_handle(handle: Option<&str>, did: &str) -> String {
1872 match handle {
1873 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1874 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1875 }
1876}
1877
1878fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1880 let source = handle
1881 .map(|h| h.trim().trim_start_matches('@'))
1882 .filter(|h| !h.is_empty())
1883 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1884 let letters: String = source
1885 .chars()
1886 .filter(|c| c.is_alphanumeric())
1887 .take(2)
1888 .collect::<String>()
1889 .to_lowercase();
1890 if letters.is_empty() {
1891 "fr".to_string()
1892 } else {
1893 letters
1894 }
1895}
1896
1897fn display_date(published: Option<&str>) -> String {
1900 match published {
1909 Some(p) => p.chars().take(10).collect(),
1910 None => String::new(),
1911 }
1912}
1913
1914fn qenc(s: &str) -> String {
1918 let mut out = String::with_capacity(s.len() * 3);
1919 for b in s.bytes() {
1920 match b {
1921 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1922 out.push(b as char)
1923 }
1924 _ => out.push_str(&format!("%{b:02X}")),
1925 }
1926 }
1927 out
1928}
1929
1930#[derive(Debug, Deserialize, Default)]
1936struct IndexQuery {
1937 #[serde(default)]
1939 feed: Option<String>,
1940 #[serde(default)]
1942 folder: Option<String>,
1943 #[serde(default)]
1945 view: Option<String>,
1946 #[serde(default)]
1948 page: Option<u32>,
1949 #[serde(default)]
1951 flash: Option<String>,
1952}
1953
1954const ENTRIES_PER_PAGE: i64 = 100;
1962
1963fn page_count_for(total: i64) -> i64 {
1966 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1967}
1968
1969const PREV_NEXT_MAX: i64 = 5_000;
1976
1977const STARRED_IDENTITY_MAX: i64 = 20_000;
1985
1986const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2000
2001struct ResolvedSub {
2004 rkey: String,
2005 sub: Subscription,
2006 feed: Option<store::Feed>,
2007}
2008
2009async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2013 resolve_subscriptions_noting(state, did).await.0
2014}
2015
2016fn subscriptions_alert(err: &anyhow::Error) -> String {
2023 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2024 Some(m) => format!(
2025 "{} record(s) in your subscription list could not be read, so it was not \
2026 refreshed. Showing your last-known subscriptions; nothing was removed.",
2027 m.count
2028 ),
2029 None => "Your subscription list could not be read from your PDS just now. \
2030 Showing your last-known subscriptions."
2031 .to_string(),
2032 }
2033}
2034
2035async fn resolve_subscriptions_noting(
2038 state: &AppState,
2039 did: &str,
2040) -> (Vec<ResolvedSub>, Option<String>) {
2041 let pool = &state.db;
2042 let subs = match state.repo().list_subscriptions_sorted(did).await {
2043 Ok(s) => s,
2044 Err(err) => {
2045 let alert = subscriptions_alert(&err);
2046 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2047 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2060 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2061 projection could not be read; rendering an EMPTY \
2062 feed list, which is not the same as having none");
2063 Vec::new()
2064 });
2065 let cached = feeds
2066 .into_iter()
2067 .map(|f| ResolvedSub {
2068 rkey: String::new(),
2069 sub: Subscription::new(f.url.clone(), now_rfc3339()),
2070 feed: Some(f),
2071 })
2072 .collect();
2073 return (cached, Some(alert));
2074 }
2075 };
2076
2077 let mut out = Vec::with_capacity(subs.len());
2093 for (rkey, sub) in subs {
2094 let feed = match store::get_feed_by_url(pool, &sub.url).await {
2095 Ok(Some(f)) => Some(f),
2096 Ok(None) => {
2097 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2108 || feed::classify_feed_privacy(&sub.url).is_private()
2109 {
2110 warn!(
2111 %did,
2112 "skipping cache row for a subscription URL that is private or not http(s)"
2113 );
2114 out.push(ResolvedSub {
2115 rkey,
2116 sub,
2117 feed: None,
2118 });
2119 continue;
2120 }
2121 if let Err(err) = store::upsert_feed(
2129 pool,
2130 &store::NewFeed {
2131 url: sub.url.clone(),
2132 title: sub.title.clone(),
2133 site_url: sub.site_url.clone(),
2134 ..Default::default()
2135 },
2136 )
2137 .await
2138 {
2139 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2140 it will not be polled");
2141 }
2142 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2143 }
2144 Err(err) => {
2145 warn!(%err, url = %sub.url, "get_feed_by_url failed");
2146 None
2147 }
2148 };
2149 out.push(ResolvedSub { rkey, sub, feed });
2150 }
2151 sync_sub_refs(pool, did, &out).await;
2155 (out, None)
2156}
2157
2158async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2162 let feed_ids: Vec<i64> = subs
2163 .iter()
2164 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2165 .collect();
2166 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2167 warn!(%err, %did, "failed to sync sub_ref projection");
2168 }
2169}
2170
2171async fn index(
2174 State(state): State<AppState>,
2175 headers: HeaderMap,
2176 Query(q): Query<IndexQuery>,
2177) -> Result<Response, WebError> {
2178 let user = match current_session(&state, &headers).await {
2179 Some(u) => u,
2180 None => {
2183 return Ok(render(&LandingTemplate {
2184 card: Card::site(&state.config),
2185 version: VERSION,
2186 repo_url: REPO_URL,
2187 crates_url: CRATES_URL,
2188 kofi_url: KOFI_URL,
2189 standard_site: state.config.standard_site,
2190 releases: RELEASES,
2191 }))
2192 }
2193 };
2194 let did = user.did.clone();
2195 let pool = &state.db;
2196
2197 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2198
2199 let view = match q.view.as_deref() {
2201 Some("all") => "all",
2202 Some("starred") => "starred",
2203 _ => "unread",
2204 }
2205 .to_string();
2206 let list_view = list_view_of(q.view.as_deref());
2207
2208 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2210 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2215
2216 let feed_title_by_id = |id: i64| -> String {
2217 subs.iter()
2218 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2219 .map(|s| {
2220 display_title(
2221 s.sub
2222 .title
2223 .as_deref()
2224 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2225 &s.sub.url,
2226 )
2227 })
2228 .unwrap_or_default()
2229 };
2230
2231 let mut uncached: Vec<EntryRow> = Vec::new();
2244 if view == "starred" {
2245 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2274 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2275 Ok(store::StarredIdentities::Truncated) => {
2280 warn!(
2281 %did,
2282 cap = STARRED_IDENTITY_MAX,
2283 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2284 rather than rendering record-deleting buttons for cached articles"
2285 );
2286 None
2287 }
2288 Err(err) => {
2289 warn!(%err, %did, "cached-starred identity lookup failed; \
2290 suppressing uncached saved rows this render");
2291 None
2292 }
2293 };
2294 let identities_ok = identities.is_some();
2301 let identities = identities.unwrap_or_default();
2302 let cached_urls: std::collections::HashSet<&str> = identities
2303 .iter()
2304 .filter_map(|(url, _)| url.as_deref())
2305 .collect();
2306 let cached_guids: std::collections::HashSet<&str> =
2307 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2308
2309 let mut uncached_dropped = 0usize;
2322 match state.repo().list_saved_sorted(&did).await {
2323 Ok(saved) if identities_ok => {
2324 for (rkey, item) in saved {
2325 let known = cached_urls.contains(item.url.as_str())
2326 || item
2327 .entry_id
2328 .as_deref()
2329 .is_some_and(|g| cached_guids.contains(g));
2330 if known {
2331 continue;
2332 }
2333 if let Some(urls) = &scope_urls {
2337 match item.feed_url.as_deref() {
2338 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2339 _ => continue,
2343 }
2344 }
2345 let link = SafeLink::external(&item.url);
2371 if link.is_empty() {
2372 warn!(
2373 %did, %rkey,
2374 "a saved record has an unusable URL; rendering it without a link \
2375 so it can still be removed"
2376 );
2377 }
2378
2379 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2393 uncached_dropped += 1;
2394 continue;
2395 }
2396 if let Some(feed_url) = item.feed_url.as_deref() {
2397 if subs.iter().any(|s| s.sub.url == feed_url) {
2398 let stale_before = (chrono::Utc::now()
2402 - chrono::Duration::from_std(state.config.poll_interval)
2403 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2404 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2405 if let Err(err) =
2406 store::mark_feed_due(pool, feed_url, &stale_before).await
2407 {
2408 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2409 }
2410 }
2411 }
2412 uncached.push(EntryRow {
2413 id: 0,
2414 title: item
2415 .title
2416 .clone()
2417 .filter(|t| !t.trim().is_empty())
2418 .unwrap_or_else(|| {
2426 if link.is_empty() {
2427 format!("Saved item {rkey}")
2428 } else {
2429 item.url.clone()
2430 }
2431 }),
2432 feed_title: item.feed_url.clone().unwrap_or_default(),
2433 published: display_date(Some(&item.created_at)),
2434 read: false,
2435 starred: true,
2436 link,
2440 cached: false,
2441 rkey,
2442 });
2443 }
2444 }
2445 Ok(_) => {}
2447 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2448 }
2449 if uncached_dropped > 0 {
2450 warn!(
2451 %did,
2452 dropped = uncached_dropped,
2453 cap = MAX_UNCACHED_SAVED_ROWS,
2454 "more saved records than this instance will hold in one response; the \
2455 rest are not reachable from here"
2456 );
2457 }
2458 }
2459
2460 let total_cached =
2476 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2477 let uncached_len = uncached.len();
2478 let total = total_cached + uncached_len as i64;
2479 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2484 let offset = (page - 1) * ENTRIES_PER_PAGE;
2485 let source = store::list_entries(
2488 pool,
2489 &did,
2490 list_view,
2491 scope_ids.as_deref(),
2492 ENTRIES_PER_PAGE,
2493 offset,
2494 )
2495 .await?;
2496 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2516 let cached_here = cached_allotment.min(source.len());
2517 let source = if uncached_len == 0 {
2522 &source[..]
2523 } else {
2524 &source[..cached_here]
2525 };
2526 let uncached_page: Vec<EntryRow> = {
2527 let skip = (offset - total_cached).max(0) as usize;
2528 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2529 uncached.into_iter().skip(skip).take(take).collect()
2530 };
2531 let uncached_total = uncached_len as i64;
2534
2535 let entry_scope_qs = {
2537 let mut parts = Vec::new();
2538 if let Some(f) = q.feed.as_deref() {
2539 parts.push(format!("feed={}", qenc(f)));
2540 }
2541 if let Some(f) = q.folder.as_deref() {
2542 parts.push(format!("folder={}", qenc(f)));
2543 }
2544 if view != "unread" {
2545 parts.push(format!("view={}", qenc(&view)));
2546 }
2547 parts.join("&")
2548 };
2549 let entries: Vec<EntryRow> = source
2550 .iter()
2551 .map(|e| EntryRow {
2552 id: e.id,
2553 title: e
2554 .title
2555 .clone()
2556 .filter(|t| !t.trim().is_empty())
2557 .unwrap_or_else(|| "(untitled)".to_string()),
2558 feed_title: feed_title_by_id(e.feed_id),
2559 published: display_date(e.published.as_deref()),
2560 read: e.read,
2565 starred: e.starred,
2566 link: SafeLink::entry(e.id, &entry_scope_qs),
2567 cached: true,
2568 rkey: String::new(),
2569 })
2570 .collect();
2571
2572 let mut entries = entries;
2574 entries.extend(uncached_page);
2575 let entries = entries;
2576
2577 let selected_feed = q.feed.as_deref();
2578 let selected_folder = q.folder.as_deref();
2579
2580 let (folder_views, loose_feeds, _folder_options) =
2582 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2583
2584 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2586 let name = subs
2587 .iter()
2588 .find(|s| s.sub.url == feed_url)
2589 .map(|s| {
2590 display_title(
2591 s.sub
2592 .title
2593 .as_deref()
2594 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2595 &s.sub.url,
2596 )
2597 })
2598 .unwrap_or_else(|| display_title(None, feed_url));
2599 (name, format!("feed={}", qenc(feed_url)))
2600 } else if let Some(folder_uri) = selected_folder {
2601 let name = folder_views
2602 .iter()
2603 .find(|f| f.uri == folder_uri)
2604 .map(|f| f.name.clone())
2605 .unwrap_or_else(|| "Folder".to_string());
2606 (name, format!("folder={}", qenc(folder_uri)))
2607 } else {
2608 let h = match view.as_str() {
2609 "all" => "All",
2610 "starred" => "Starred",
2611 _ => "Unread",
2612 };
2613 (h.to_string(), String::new())
2614 };
2615
2616 let feed_scope = selected_feed.map(str::to_string);
2617 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2618
2619 let page_href = |n: i64| -> String {
2623 let mut parts = Vec::new();
2624 if !entry_scope_qs.is_empty() {
2625 parts.push(entry_scope_qs.clone());
2626 }
2627 if n > 1 {
2628 parts.push(format!("page={n}"));
2629 }
2630 if parts.is_empty() {
2631 "/".to_string()
2632 } else {
2633 format!("/?{}", parts.join("&"))
2634 }
2635 };
2636 let prev_href = (page > 1).then(|| page_href(page - 1));
2637 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2638
2639 let tmpl = IndexTemplate {
2640 card: Card::private(&state.config),
2641 version: VERSION,
2642 repo_url: REPO_URL,
2643 kofi_url: KOFI_URL,
2644 flash: q.flash.unwrap_or_default(),
2645 alert: alert.unwrap_or_default(),
2646 nav,
2647 entries,
2648 heading,
2649 feed_scope,
2650 total,
2651 uncached_total,
2654 page,
2655 page_count: page_count_for(total),
2656 prev_href,
2657 next_href,
2658 };
2659 Ok(render(&tmpl))
2660}
2661
2662#[derive(Debug, Deserialize, Default)]
2664struct ManageQuery {
2665 #[serde(default)]
2666 flash: Option<String>,
2667}
2668
2669async fn manage(
2674 State(state): State<AppState>,
2675 headers: HeaderMap,
2676 Query(q): Query<ManageQuery>,
2677) -> Result<Response, WebError> {
2678 let user = match current_session(&state, &headers).await {
2679 Some(u) => u,
2680 None => return Ok(Redirect::to("/login").into_response()),
2681 };
2682 let did = user.did.clone();
2683
2684 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2685 let (folder_views, loose_feeds, folder_options) =
2686 build_sidebar(&state, &did, &subs, None, None).await;
2687
2688 let nav = build_nav(
2690 &user,
2691 "unread",
2692 String::new(),
2693 folder_views.iter().map(clone_folder_view).collect(),
2694 loose_feeds.iter().map(clone_feed_view).collect(),
2695 true,
2696 );
2697
2698 let tmpl = ManageTemplate {
2699 card: Card::private(&state.config),
2700 version: VERSION,
2701 repo_url: REPO_URL,
2702 kofi_url: KOFI_URL,
2703 flash: q.flash.unwrap_or_default(),
2704 alert: alert.unwrap_or_default(),
2705 nav,
2706 folder_options,
2707 folders: folder_views,
2708 loose_feeds,
2709 standard_site: state.config.standard_site,
2710 };
2711 Ok(render(&tmpl))
2712}
2713
2714fn clone_feed_view(f: &FeedView) -> FeedView {
2717 FeedView {
2718 rkey: f.rkey.clone(),
2719 url: f.url.clone(),
2720 title: f.title.clone(),
2721 unread: f.unread,
2722 selected: f.selected,
2723 folder: f.folder.clone(),
2724 }
2725}
2726
2727fn clone_folder_view(f: &FolderView) -> FolderView {
2728 FolderView {
2729 rkey: f.rkey.clone(),
2730 uri: f.uri.clone(),
2731 name: f.name.clone(),
2732 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2733 selected: f.selected,
2734 }
2735}
2736
2737fn scope_urls_for(
2742 subs: &[ResolvedSub],
2743 feed: Option<&str>,
2744 folder: Option<&str>,
2745) -> Option<Vec<String>> {
2746 if let Some(feed_url) = feed {
2747 Some(vec![feed_url.to_string()])
2748 } else {
2749 folder.map(|folder_uri| {
2750 subs.iter()
2751 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2752 .map(|s| s.sub.url.clone())
2753 .collect()
2754 })
2755 }
2756}
2757
2758fn folder_uri(did: &str, rkey: &str) -> String {
2760 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2761}
2762
2763async fn build_sidebar(
2767 state: &AppState,
2768 did: &str,
2769 subs: &[ResolvedSub],
2770 selected_feed: Option<&str>,
2771 selected_folder: Option<&str>,
2772) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2773 let pool = &state.db;
2774 let unread_counts = store::unread_counts_by_feed(pool, did)
2779 .await
2780 .unwrap_or_else(|err| {
2781 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2782 Default::default()
2783 });
2784 let folders = state
2785 .repo()
2786 .list_folders_sorted(did)
2787 .await
2788 .unwrap_or_default();
2789
2790 let unread_count = |feed_id: Option<i64>| -> i64 {
2791 feed_id
2792 .and_then(|id| unread_counts.get(&id).copied())
2793 .unwrap_or(0)
2794 };
2795 let mk_feed_view = |s: &ResolvedSub| FeedView {
2796 rkey: s.rkey.clone(),
2797 url: s.sub.url.clone(),
2798 title: display_title(
2799 s.sub
2800 .title
2801 .as_deref()
2802 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2803 &s.sub.url,
2804 ),
2805 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2806 selected: selected_feed == Some(s.sub.url.as_str()),
2807 folder: s.sub.folder.clone(),
2808 };
2809
2810 let mut folder_views = Vec::with_capacity(folders.len());
2811 for (rkey, folder) in &folders {
2812 let uri = folder_uri(did, rkey);
2813 let feeds: Vec<FeedView> = subs
2814 .iter()
2815 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2816 .map(mk_feed_view)
2817 .collect();
2818 folder_views.push(FolderView {
2819 rkey: rkey.clone(),
2820 uri: uri.clone(),
2821 name: folder.name.clone(),
2822 feeds,
2823 selected: selected_folder == Some(uri.as_str()),
2824 });
2825 }
2826
2827 let known_uris: std::collections::HashSet<String> =
2828 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2829 let loose_feeds: Vec<FeedView> = subs
2830 .iter()
2831 .filter(|s| {
2832 s.sub
2833 .folder
2834 .as_deref()
2835 .map(|f| !known_uris.contains(f))
2836 .unwrap_or(true)
2837 })
2838 .map(mk_feed_view)
2839 .collect();
2840
2841 let folder_options: Vec<FolderOption> = folders
2842 .iter()
2843 .map(|(rkey, folder)| FolderOption {
2844 name: folder.name.clone(),
2845 uri: folder_uri(did, rkey),
2846 })
2847 .collect();
2848
2849 (folder_views, loose_feeds, folder_options)
2850}
2851
2852fn build_nav(
2854 user: &CurrentUser,
2855 view: &str,
2856 scope_qs: String,
2857 folders: Vec<FolderView>,
2858 loose_feeds: Vec<FeedView>,
2859 manage_active: bool,
2860) -> Nav {
2861 Nav {
2862 handle: display_handle(user.handle.as_deref(), &user.did),
2863 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2864 view: view.to_string(),
2865 scope_qs,
2866 folders,
2867 loose_feeds,
2868 manage_active,
2869 }
2870}
2871
2872#[derive(Debug, Deserialize, Default)]
2879struct EntryQuery {
2880 #[serde(default)]
2881 feed: Option<String>,
2882 #[serde(default)]
2883 folder: Option<String>,
2884 #[serde(default)]
2885 view: Option<String>,
2886}
2887
2888async fn entry_view(
2891 State(state): State<AppState>,
2892 headers: HeaderMap,
2893 Path(id): Path<i64>,
2894 Query(q): Query<EntryQuery>,
2895) -> Result<Response, WebError> {
2896 let user = match current_session(&state, &headers).await {
2897 Some(u) => u,
2898 None => return Ok(Redirect::to("/login").into_response()),
2899 };
2900 let did = user.did.clone();
2901 let pool = &state.db;
2902
2903 let subs = resolve_subscriptions(&state, &did).await;
2907
2908 let entry = match get_entry_by_id(pool, &did, id).await? {
2909 Some(e) => e,
2910 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2911 };
2912
2913 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2914
2915 let read = entry_is_read(pool, &did, id).await?;
2916 let starred = entry_is_starred(pool, &did, id).await?;
2917
2918 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2921
2922 let back_qs = scope_query(&q);
2923
2924 let (folder_views, loose_feeds, _) =
2925 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2926 let nav_view = match q.view.as_deref() {
2927 Some("all") => "all",
2928 Some("starred") => "starred",
2929 _ => "unread",
2930 };
2931 let nav = build_nav(
2932 &user,
2933 nav_view,
2934 back_qs.clone(),
2935 folder_views,
2936 loose_feeds,
2937 false,
2938 );
2939
2940 let tmpl = EntryTemplate {
2941 card: Card::private(&state.config),
2942 version: VERSION,
2943 repo_url: REPO_URL,
2944 kofi_url: KOFI_URL,
2945 nav,
2946 id: entry.id,
2947 title: entry
2948 .title
2949 .clone()
2950 .filter(|t| !t.trim().is_empty())
2951 .unwrap_or_else(|| "(untitled)".to_string()),
2952 feed_title,
2953 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2954 published: display_date(entry.published.as_deref()),
2955 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2956 content_html: entry.content_html.clone(),
2957 read,
2958 starred,
2959 back_qs,
2960 prev_id,
2961 next_id,
2962 oob: false,
2963 };
2964 Ok(render(&tmpl))
2965}
2966
2967async fn neighbors_in_scope(
2970 state: &AppState,
2971 did: &str,
2972 q: &EntryQuery,
2973 current: i64,
2974) -> (Option<i64>, Option<i64>) {
2975 let idx_q = IndexQuery {
2976 feed: q.feed.clone(),
2977 folder: q.folder.clone(),
2978 view: q.view.clone(),
2979 page: None,
2981 flash: None,
2982 };
2983 let ids = list_entry_ids(state, did, &idx_q).await;
2984 let pos = ids.iter().position(|&x| x == current);
2985 match pos {
2986 Some(p) => {
2987 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2988 let next = ids.get(p + 1).copied();
2989 (prev, next)
2990 }
2991 None => (None, None),
2992 }
2993}
2994
2995async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2998 let pool = &state.db;
2999 let subs = resolve_subscriptions(state, did).await;
3000
3001 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3002
3003 store::list_entry_ids(
3009 pool,
3010 did,
3011 list_view_of(q.view.as_deref()),
3012 scoped_feed_ids(&subs, &scope_urls).as_deref(),
3013 PREV_NEXT_MAX,
3014 )
3015 .await
3016 .unwrap_or_else(|err| {
3017 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3018 Vec::new()
3019 })
3020}
3021
3022fn list_view_of(view: Option<&str>) -> store::ListView {
3025 match view {
3026 Some("all") => store::ListView::All,
3027 Some("starred") => store::ListView::Starred,
3028 _ => store::ListView::Unread,
3029 }
3030}
3031
3032fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3038 let urls = scope_urls.as_ref()?;
3039 Some(
3040 subs.iter()
3041 .filter(|s| urls.contains(&s.sub.url))
3042 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3043 .collect(),
3044 )
3045}
3046
3047fn scope_query(q: &EntryQuery) -> String {
3049 let mut parts = Vec::new();
3050 if let Some(f) = q.feed.as_deref() {
3051 parts.push(format!("feed={}", qenc(f)));
3052 }
3053 if let Some(f) = q.folder.as_deref() {
3054 parts.push(format!("folder={}", qenc(f)));
3055 }
3056 if let Some(v) = q.view.as_deref() {
3057 if v != "unread" {
3058 parts.push(format!("view={}", qenc(v)));
3059 }
3060 }
3061 parts.join("&")
3062}
3063
3064#[derive(Debug, Deserialize)]
3070struct ReadForm {
3071 #[serde(default)]
3072 read: Option<String>,
3073}
3074
3075async fn mark_read(
3077 State(state): State<AppState>,
3078 Path(id): Path<i64>,
3079 headers: HeaderMap,
3080 Form(form): Form<ReadForm>,
3081) -> Result<Response, WebError> {
3082 let did = match current_did(&state, &headers).await {
3083 Some(d) => d,
3084 None => return Ok(Redirect::to("/login").into_response()),
3085 };
3086 let pool = &state.db;
3087
3088 let read = matches!(
3089 form.read.as_deref(),
3090 Some("true") | Some("1") | Some("on") | None
3091 );
3092
3093 resolve_subscriptions(&state, &did).await;
3098 if !store::mark_read(pool, &did, id, read).await? {
3099 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3100 }
3101
3102 if !is_htmx(&headers) {
3103 return Ok(Redirect::to("/").into_response());
3104 }
3105
3106 if is_reader_request(&headers) {
3110 let starred = entry_is_starred(pool, &did, id).await?;
3111 return Ok(render(&EntryActionBarTemplate {
3112 id,
3113 read,
3114 starred,
3115 oob: true,
3116 }));
3117 }
3118
3119 let row = build_entry_row(pool, &did, id, Some(read)).await?;
3120 match row {
3121 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3122 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3123 }
3124}
3125
3126#[derive(Debug, Deserialize)]
3132struct StarForm {
3133 #[serde(default)]
3134 starred: Option<String>,
3135}
3136
3137async fn toggle_star(
3143 State(state): State<AppState>,
3144 Path(id): Path<i64>,
3145 headers: HeaderMap,
3146 Form(form): Form<StarForm>,
3147) -> Result<Response, WebError> {
3148 let did = match current_did(&state, &headers).await {
3149 Some(d) => d,
3150 None => return Ok(Redirect::to("/login").into_response()),
3151 };
3152 let pool = &state.db;
3153
3154 let starred = matches!(
3155 form.starred.as_deref(),
3156 Some("true") | Some("1") | Some("on") | None
3157 );
3158
3159 resolve_subscriptions(&state, &did).await;
3163 if !store::mark_starred(pool, &did, id, starred).await? {
3164 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3165 }
3166
3167 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3170 let entry_url = entry.url.clone().unwrap_or_default();
3171 if !entry_url.is_empty() {
3172 if starred {
3173 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3174 saved.title = entry.title.clone();
3175 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3176 saved.entry_id = Some(entry.guid.clone());
3177 match state.repo().add_saved(&did, &saved).await {
3178 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3179 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3180 }
3181 } else {
3182 match state.repo().list_saved(&did).await {
3184 Ok(records) => {
3185 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3186 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3187 warn!(%err, %did, %rkey, "PDS saved delete failed");
3188 }
3189 }
3190 }
3191 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3192 }
3193 }
3194 }
3195 }
3196
3197 if !is_htmx(&headers) {
3198 return Ok(Redirect::to("/").into_response());
3199 }
3200
3201 if is_reader_request(&headers) {
3203 let read = entry_is_read(pool, &did, id).await?;
3204 return Ok(render(&EntryActionBarTemplate {
3205 id,
3206 read,
3207 starred,
3208 oob: true,
3209 }));
3210 }
3211
3212 let row = build_entry_row(pool, &did, id, None).await?;
3213 match row {
3214 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3215 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3216 }
3217}
3218
3219async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3221 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3222 .bind(feed_id)
3223 .fetch_optional(pool)
3224 .await
3225 .ok()
3226 .flatten()
3227}
3228
3229#[derive(Debug, Deserialize, Default)]
3236struct ReadAllQuery {
3237 #[serde(default)]
3238 feed: Option<String>,
3239}
3240
3241async fn mark_all_read(
3244 State(state): State<AppState>,
3245 headers: HeaderMap,
3246 Query(q): Query<ReadAllQuery>,
3247) -> Result<Response, WebError> {
3248 let did = match current_did(&state, &headers).await {
3249 Some(d) => d,
3250 None => return Ok(Redirect::to("/login").into_response()),
3251 };
3252 let pool = &state.db;
3253
3254 resolve_subscriptions(&state, &did).await;
3257
3258 if let Some(feed_url) = q.feed.as_deref() {
3259 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3260 store::mark_feed_read(pool, &did, feed.id, true).await?;
3261 }
3262 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3263 }
3264
3265 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3270 store::mark_feed_read(pool, &did, feed_id, true).await?;
3271 }
3272 Ok(Redirect::to("/").into_response())
3273}
3274
3275const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3285 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3286
3287const EXPORT_INCOMPLETE_REFUSAL: &str =
3294 "Could not read your subscriptions in full, so nothing was exported. Your \
3295 feeds are unchanged — try again, and if it keeps failing the list may be \
3296 larger than this reader can page through.";
3297
3298const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3304 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3305 feeds for now — private-feed support arrives when atproto's private data \
3306 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3307
3308#[derive(Debug, Deserialize)]
3310struct SubscribeForm {
3311 url: String,
3312 #[serde(default)]
3314 folder: Option<String>,
3315}
3316
3317async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3327 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3328 let canonical = format!(
3329 "{}{}",
3330 crate::atproto::AT_URI_PREFIX,
3331 &input[crate::atproto::AT_URI_PREFIX.len()..]
3332 );
3333 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3334 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3335 return Err(unsupported());
3336 }
3337 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3338 uri.authority.clone()
3339 } else {
3340 let handle =
3341 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3346 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3347 .await
3348 .map_err(|err| {
3349 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3350 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3351 })?
3352 };
3353 let url = format!(
3354 "{}{did}/{}/{}",
3355 crate::atproto::AT_URI_PREFIX,
3356 uri.collection,
3357 uri.rkey
3358 );
3359 if !feed::is_storable_feed_url(&url, true) {
3360 return Err(unsupported());
3361 }
3362 Ok(url)
3363}
3364
3365async fn add_subscription(
3367 State(state): State<AppState>,
3368 headers: HeaderMap,
3369 Form(form): Form<SubscribeForm>,
3370) -> Result<Response, WebError> {
3371 let did = match current_did(&state, &headers).await {
3372 Some(d) => d,
3373 None => return Ok(Redirect::to("/login").into_response()),
3374 };
3375 let pool = &state.db;
3376 let input = form.url.trim().to_string();
3377 if input.is_empty() {
3378 return Ok(Redirect::to("/").into_response());
3379 }
3380
3381 let cap = state.config.max_subs_per_did;
3385 if cap > 0 {
3386 match store::count_subscriptions_for_did(pool, &did).await {
3387 Ok(n) if n >= cap => {
3388 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3389 return Ok(Redirect::to(&format!(
3390 "/?flash={}",
3391 qenc(&format!(
3392 "Subscription limit reached ({cap}). Remove a feed before adding another."
3393 ))
3394 ))
3395 .into_response());
3396 }
3397 Ok(_) => {}
3398 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3399 }
3400 }
3401
3402 let is_at_uri = input
3407 .get(..crate::atproto::AT_URI_PREFIX.len())
3408 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3409 let publication_url = if is_at_uri {
3410 if !state.config.standard_site {
3411 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3412 return Ok(
3413 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3414 .into_response(),
3415 );
3416 }
3417 match publication_url_from_paste(&state, &input).await {
3418 Ok(url) => Some(url),
3419 Err(flash) => {
3420 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3421 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3422 }
3423 }
3424 } else {
3425 None
3426 };
3427
3428 if let feed::FeedPrivacy::Private(reason) =
3429 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3430 {
3431 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3432 return Ok(
3433 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3434 );
3435 }
3436
3437 let resolved = match publication_url {
3438 Some(url) => Ok(url),
3439 None => resolve_feed_url(&state.config, &input).await,
3440 };
3441 let feed_url = match resolved {
3442 Ok(u) => u,
3443 Err(err) => {
3444 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3445 return Ok(Redirect::to(&format!(
3446 "/?flash={}",
3447 qenc("Couldn't find a feed at that URL")
3448 ))
3449 .into_response());
3450 }
3451 };
3452
3453 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3457 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3458 return Ok(
3459 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3460 );
3461 }
3462
3463 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3468 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3469 return Ok(
3470 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3471 .into_response(),
3472 );
3473 }
3474
3475 let feeds_cap = state.config.max_feeds_global;
3479 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3480 match store::count_feeds(pool).await {
3481 Ok(n) if n >= feeds_cap => {
3482 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3483 return Ok(Redirect::to(&format!(
3484 "/?flash={}",
3485 qenc(
3486 "This instance is at its feed capacity right now. Please try again later."
3487 )
3488 ))
3489 .into_response());
3490 }
3491 Ok(_) => {}
3492 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3493 }
3494 }
3495
3496 store::upsert_feed(
3497 pool,
3498 &store::NewFeed {
3499 url: feed_url.clone(),
3500 ..Default::default()
3501 },
3502 )
3503 .await?;
3504
3505 if let Ok(client) = feed::build_client() {
3506 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3507 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3508 Ok(outcome) => {
3509 info!(feed = %feed_url, ?outcome, "polled new subscription");
3510 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3514 }
3515 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3516 }
3517 }
3518 }
3519
3520 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3521 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3522 sub.title = feed_row.title.clone();
3523 sub.site_url = feed_row.site_url.clone();
3524 }
3525 sub.folder = form
3526 .folder
3527 .map(|f| f.trim().to_string())
3528 .filter(|f| !f.is_empty());
3529
3530 match state.repo().add_subscription(&did, &sub).await {
3531 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3532 Err(err) => {
3533 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3534 }
3535 }
3536
3537 Ok(Redirect::to("/").into_response())
3538}
3539
3540async fn delete_subscription(
3542 State(state): State<AppState>,
3543 headers: HeaderMap,
3544 Path(rkey): Path<String>,
3545) -> Result<Response, WebError> {
3546 let did = match current_did(&state, &headers).await {
3547 Some(d) => d,
3548 None => return Ok(Redirect::to("/login").into_response()),
3549 };
3550 match state.repo().remove_subscription(&did, &rkey).await {
3551 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3552 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3553 }
3554 Ok(Redirect::to("/").into_response())
3555}
3556
3557#[derive(Debug, Deserialize)]
3559struct RenameSubForm {
3560 url: String,
3561 #[serde(default)]
3562 title: Option<String>,
3563 #[serde(default)]
3564 site_url: Option<String>,
3565 #[serde(default)]
3566 folder: Option<String>,
3567}
3568
3569async fn rename_subscription(
3572 State(state): State<AppState>,
3573 headers: HeaderMap,
3574 Path(rkey): Path<String>,
3575 Form(form): Form<RenameSubForm>,
3576) -> Result<Response, WebError> {
3577 let did = match current_did(&state, &headers).await {
3578 Some(d) => d,
3579 None => return Ok(Redirect::to("/login").into_response()),
3580 };
3581 let feed_url = form.url.trim().to_string();
3582
3583 if feed_url.is_empty() {
3587 return Ok(Redirect::to("/").into_response());
3588 }
3589
3590 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3615 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3616 Err(err) => {
3617 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3618 return Ok(Redirect::to(&format!(
3619 "/?flash={}",
3620 qenc("Could not reach your PDS — nothing was renamed or moved.")
3621 ))
3622 .into_response());
3623 }
3624 };
3625 let Some(existing) = existing else {
3626 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3630 return Ok(Redirect::to(&format!(
3631 "/?flash={}",
3632 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3633 ))
3634 .into_response());
3635 };
3636
3637 let url_changed = existing.url.trim() != feed_url;
3656
3657 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3670 if url_changed && !storable {
3671 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3672 return Ok(
3673 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3674 .into_response(),
3675 );
3676 }
3677
3678 if url_changed {
3684 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3685 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3686 return Ok(
3687 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3688 );
3689 }
3690 }
3691
3692 let feeds_cap = state.config.max_feeds_global;
3697 if url_changed
3698 && feeds_cap > 0
3699 && store::get_feed_by_url(&state.db, &feed_url)
3700 .await?
3701 .is_none()
3702 {
3703 match store::count_feeds(&state.db).await {
3704 Ok(n) if n >= feeds_cap => {
3705 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3706 return Ok(Redirect::to(&format!(
3707 "/?flash={}",
3708 qenc(
3709 "This instance is at its feed capacity right now. Please try again later."
3710 )
3711 ))
3712 .into_response());
3713 }
3714 Ok(_) => {}
3715 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3716 }
3717 }
3718
3719 let mut sub = existing;
3720 sub.url = feed_url;
3721 sub.title = form
3722 .title
3723 .map(|t| t.trim().to_string())
3724 .filter(|t| !t.is_empty());
3725 sub.folder = form
3726 .folder
3727 .map(|f| f.trim().to_string())
3728 .filter(|f| !f.is_empty());
3729 match form
3737 .site_url
3738 .map(|t| t.trim().to_string())
3739 .filter(|t| !t.is_empty())
3740 {
3741 Some(site) => sub.site_url = Some(site),
3742 None if url_changed => sub.site_url = None,
3743 None => {}
3744 }
3745 if url_changed {
3746 sub.fetch_hint = None;
3747 }
3748
3749 let cache_write =
3764 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3765 if !cache_write {
3766 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3767 } else if let Err(err) = store::upsert_feed(
3768 &state.db,
3769 &store::NewFeed {
3770 url: sub.url.clone(),
3771 title: sub.title.clone(),
3772 site_url: sub.site_url.clone(),
3773 ..Default::default()
3774 },
3775 )
3776 .await
3777 {
3778 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3781 }
3782
3783 match state.repo().update_subscription(&did, &rkey, &sub).await {
3791 Ok(res) => {
3792 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3793 Ok(Redirect::to("/").into_response())
3794 }
3795 Err(err) => {
3796 warn!(%err, %did, %rkey, "PDS subscription update failed");
3797 Ok(Redirect::to(&format!(
3798 "/?flash={}",
3799 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3800 ))
3801 .into_response())
3802 }
3803 }
3804}
3805
3806#[derive(Debug, Deserialize)]
3812struct FolderForm {
3813 name: String,
3814}
3815
3816async fn create_folder(
3818 State(state): State<AppState>,
3819 headers: HeaderMap,
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().add_folder(&did, &folder).await {
3832 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3833 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3834 }
3835 Ok(Redirect::to("/").into_response())
3836}
3837
3838async fn rename_folder(
3840 State(state): State<AppState>,
3841 headers: HeaderMap,
3842 Path(rkey): Path<String>,
3843 Form(form): Form<FolderForm>,
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 let name = form.name.trim();
3850 if name.is_empty() {
3851 return Ok(Redirect::to("/").into_response());
3852 }
3853 let folder = Folder::new(name.to_string(), now_rfc3339());
3854 match state.repo().rename_folder(&did, &rkey, &folder).await {
3855 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3856 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3857 }
3858 Ok(Redirect::to("/").into_response())
3859}
3860
3861async fn delete_folder(
3864 State(state): State<AppState>,
3865 headers: HeaderMap,
3866 Path(rkey): Path<String>,
3867) -> Result<Response, WebError> {
3868 let did = match current_did(&state, &headers).await {
3869 Some(d) => d,
3870 None => return Ok(Redirect::to("/login").into_response()),
3871 };
3872 match state.repo().remove_folder(&did, &rkey).await {
3873 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3874 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3875 }
3876 Ok(Redirect::to("/").into_response())
3877}
3878
3879async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3883 let parsed =
3884 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3885
3886 let client = feed::build_client()?;
3887 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3891 let final_url = resp.url().clone();
3892 let content_type = resp
3893 .headers()
3894 .get(axum::http::header::CONTENT_TYPE)
3895 .and_then(|v| v.to_str().ok())
3896 .unwrap_or("")
3897 .to_ascii_lowercase();
3898 let raw = crate::net::read_capped(resp).await?;
3901 let body = String::from_utf8_lossy(&raw).into_owned();
3902
3903 let looks_like_feed = content_type.contains("xml")
3904 || content_type.contains("rss")
3905 || content_type.contains("atom")
3906 || content_type.contains("application/feed+json")
3907 || {
3908 let head = body.trim_start();
3909 head.starts_with("<?xml")
3910 || head.starts_with("<rss")
3911 || head.starts_with("<feed")
3912 || head.contains("<rss")
3913 || head.contains("<feed")
3914 };
3915 if looks_like_feed {
3916 return Ok(final_url.to_string());
3917 }
3918
3919 match feed::discover_feed(&body, Some(&final_url)) {
3920 Some(u) => Ok(u.to_string()),
3921 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3922 }
3923}
3924
3925#[derive(Debug, Deserialize, Default)]
3931struct LoginQuery {
3932 #[serde(default)]
3933 handle: Option<String>,
3934 #[serde(default)]
3935 error: Option<String>,
3936 #[serde(default)]
3937 flash: Option<String>,
3938}
3939
3940async fn login_form(
3948 State(state): State<AppState>,
3949 headers: HeaderMap,
3950 Query(q): Query<LoginQuery>,
3951) -> Response {
3952 if let Some(handle) = q
3953 .handle
3954 .map(|h| h.trim().to_string())
3955 .filter(|h| !h.is_empty())
3956 {
3957 if !may_start_oauth(&state, &headers, &handle).await {
3958 return Redirect::to("/beta/redeem").into_response();
3959 }
3960 return start_oauth(&state, &handle).await;
3961 }
3962 render(&LoginTemplate {
3963 card: login_card(&state.config),
3964 repo_url: REPO_URL,
3965 error: q.error.unwrap_or_default(),
3966 flash: q.flash.unwrap_or_default(),
3967 })
3968}
3969
3970async fn login_submit(
3973 State(state): State<AppState>,
3974 headers: HeaderMap,
3975 Form(form): Form<LoginForm>,
3976) -> Response {
3977 let handle = form.handle.trim();
3978 if handle.is_empty() {
3979 return login_error(&state, "Enter your atproto handle.");
3980 }
3981 if !may_start_oauth(&state, &headers, handle).await {
3982 return Redirect::to("/beta/redeem").into_response();
3983 }
3984 start_oauth(&state, handle).await
3985}
3986
3987async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4005 may_start_oauth_with(state, headers, handle, |h| async move {
4009 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4010 .await
4011 .ok()
4012 })
4013 .await
4014}
4015
4016async fn may_start_oauth_with<F, Fut>(
4022 state: &AppState,
4023 headers: &HeaderMap,
4024 handle: &str,
4025 resolve: F,
4026) -> bool
4027where
4028 F: FnOnce(String) -> Fut,
4029 Fut: std::future::Future<Output = Option<String>>,
4030{
4031 if let Some(did) = current_did(state, headers).await {
4033 if store::has_beta_access(&state.db, &did)
4034 .await
4035 .unwrap_or(false)
4036 {
4037 return true;
4038 }
4039 }
4040 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4042 return true;
4043 }
4044 match resolve(handle.to_string()).await {
4048 Some(did) => store::has_beta_access(&state.db, &did)
4049 .await
4050 .unwrap_or(false),
4051 None => {
4052 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4053 false
4054 }
4055 }
4056}
4057
4058async fn start_oauth(state: &AppState, handle: &str) -> Response {
4080 match state.config.repo_backend {
4081 crate::metrics::Backend::Sidecar => {
4082 let url = state.sidecar.login_url(handle, None);
4083 info!(%handle, "redirecting to OAuth sidecar login");
4084 Redirect::to(&url).into_response()
4085 }
4086 crate::metrics::Backend::Rust => {
4087 let Some(runtime) = state.oauth.as_deref() else {
4088 warn!("the rust backend is live but its OAuth runtime is absent");
4089 return login_error(state, "Login is not available right now.");
4090 };
4091 match crate::oauth::login::start(
4092 runtime,
4093 &state.http,
4094 &state.db,
4095 handle,
4096 crate::store::now_unix(),
4097 )
4098 .await
4099 {
4100 Ok(started) => {
4101 info!(%handle, "pushed authorization request; redirecting to the PDS");
4102 let mut resp = Redirect::to(&started.authorize_url).into_response();
4103 set_cookie(
4104 &mut resp,
4105 &cookie::sign_value(
4106 OAUTH_BINDING_COOKIE,
4107 &started.binding_token,
4108 &state.config.cookie_secret,
4109 OAUTH_BINDING_MAX_AGE_SECS,
4110 ),
4111 );
4112 resp
4113 }
4114 Err(err) => {
4115 warn!(%err, %handle, "could not start the OAuth login");
4118 login_error(state, "Could not start login for that handle.")
4119 }
4120 }
4121 }
4122 }
4123}
4124
4125fn clear_binding_cookie(resp: &mut Response) {
4129 set_cookie(
4130 resp,
4131 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4132 );
4133}
4134
4135#[derive(Debug, Deserialize)]
4137struct LoginForm {
4138 handle: String,
4139}
4140
4141#[derive(Debug, Deserialize, Default)]
4150struct CallbackQuery {
4151 #[serde(default)]
4153 session_id: Option<String>,
4154 #[serde(default)]
4156 code: Option<String>,
4157 #[serde(default)]
4158 state: Option<String>,
4159 #[serde(default)]
4160 iss: Option<String>,
4161 #[serde(default)]
4164 response: Option<String>,
4165 #[serde(default)]
4166 error: Option<String>,
4167 #[serde(default)]
4168 error_description: Option<String>,
4169}
4170
4171async fn oauth_callback(
4178 State(state): State<AppState>,
4179 headers: HeaderMap,
4180 Query(q): Query<CallbackQuery>,
4181) -> Response {
4182 let sidecar_shape =
4212 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4213 let sidecar_handoff = sidecar_shape
4214 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4215 if let Some(err) = q.error.clone() {
4216 let slug = crate::oauth::flow::known_error_slug(&err);
4232 warn!(
4233 error = slug,
4234 desc_len = q.error_description.as_deref().map_or(0, str::len),
4235 "OAuth callback returned an error"
4236 );
4237 if sidecar_handoff || state.oauth.is_none() {
4238 return login_error(&state, &format!("Login failed: {slug}"));
4239 }
4240 }
4243
4244 let session = if sidecar_handoff {
4247 let session_id = q.session_id.clone().unwrap_or_default();
4248 match state.sidecar.resolve_session(&session_id).await {
4249 Ok(Some(s)) => s,
4250 Ok(None) => {
4251 warn!("OAuth callback session_id did not resolve (expired/unknown)");
4252 return login_error(&state, "Login session expired — please try again.");
4253 }
4254 Err(err) => {
4255 warn!(%err, "failed to resolve OAuth session via the sidecar");
4256 return login_error(&state, "Login failed talking to the auth service.");
4257 }
4258 }
4259 } else {
4260 let Some(runtime) = state.oauth.as_deref() else {
4261 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4262 return login_error(&state, "Login failed: this login could not be completed.");
4263 };
4264 let params = crate::oauth::flow::CallbackParams {
4265 code: q.code.clone(),
4266 state: q.state.clone(),
4267 iss: q.iss.clone(),
4268 error: q.error.clone(),
4272 error_description: q.error_description.clone(),
4273 response: q.response.clone(),
4274 };
4275 let binding =
4276 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4277 match crate::oauth::login::complete(
4278 runtime,
4279 &state.http,
4280 &state.db,
4281 ¶ms,
4282 binding.as_deref(),
4283 crate::store::now_unix(),
4284 )
4285 .await
4286 {
4287 Ok(done) => crate::atproto::SidecarSession {
4288 did: done.did,
4289 handle: done.handle,
4290 },
4291 Err(err) => {
4292 warn!(%err, "could not complete the OAuth callback");
4295 let mut resp = login_error(&state, "Login failed — please try again.");
4296 clear_binding_cookie(&mut resp);
4297 return resp;
4298 }
4299 }
4300 };
4301
4302 let mut clear_invite = false;
4305 if !store::has_beta_access(&state.db, &session.did)
4306 .await
4307 .unwrap_or(false)
4308 {
4309 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4311 Some(c) => c,
4312 None => {
4313 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4314 return Redirect::to("/beta/redeem").into_response();
4315 }
4316 };
4317 match store::redeem_code(
4318 &state.db,
4319 &code,
4320 &session.did,
4321 session.handle.as_deref(),
4322 state.config.beta_cap,
4323 )
4324 .await
4325 {
4326 Ok(Ok(())) => {
4327 clear_invite = true;
4328 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4329 }
4330 Ok(Err(policy)) => {
4331 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4332 let mut resp = redeem_bounce(&state, &policy).into_response();
4333 clear_invite_cookie(&mut resp);
4335 return resp;
4336 }
4337 Err(err) => {
4338 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4339 return login_error(&state, "Login failed while confirming your invite.");
4340 }
4341 }
4342 }
4343
4344 let sid = state.sessions.create(Session {
4347 did: session.did.clone(),
4348 handle: session.handle.clone(),
4349 });
4350 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4351 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4352
4353 let mut resp = Redirect::to("/").into_response();
4354 set_cookie(&mut resp, &cookie);
4355 clear_binding_cookie(&mut resp);
4356 if clear_invite {
4357 clear_invite_cookie(&mut resp);
4358 }
4359 resp
4360}
4361
4362const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4380
4381async fn flush_before_revoke(state: &AppState, did: &str) {
4394 match tokio::time::timeout(
4395 SIGN_OUT_FLUSH_BUDGET,
4396 crate::readstate::flush_did(state, did),
4397 )
4398 .await
4399 {
4400 Ok(Ok(())) => {}
4401 Ok(Err(err)) => {
4402 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4403 }
4404 Err(_) => warn!(
4405 %did,
4406 budget = ?SIGN_OUT_FLUSH_BUDGET,
4407 "sign-out: final read-state flush timed out; it will park until next sign-in"
4408 ),
4409 }
4410}
4411
4412async fn revoke_everywhere(state: &AppState, did: &str) {
4413 let sidecar_started = std::time::Instant::now();
4423 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4424 Ok(res) => {
4425 info!(%did, revoked = res.revoked, "sidecar session revoked");
4426 true
4427 }
4428 Err(err) => {
4429 warn!(%did, %err, "sidecar revoke failed; continuing");
4430 false
4431 }
4432 };
4433 state.metrics.record(
4434 crate::metrics::Backend::Sidecar,
4435 "oauth_revoke",
4436 sidecar_started.elapsed().as_micros() as u64,
4437 sidecar_ok,
4438 );
4439
4440 if let Some(runtime) = state.oauth.as_deref() {
4441 let revoke_started = std::time::Instant::now();
4442 let outcome = crate::oauth::revoke::sign_out_discovering(
4443 runtime,
4444 &state.http,
4445 &state.db,
4446 did,
4447 crate::store::now_unix(),
4448 )
4449 .await;
4450 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4461 state.metrics.record(
4462 crate::metrics::Backend::Rust,
4463 "oauth_revoke",
4464 revoke_started.elapsed().as_micros() as u64,
4465 revoke_ok,
4466 );
4467 match outcome {
4468 crate::oauth::revoke::Revocation::Revoked => {
4469 info!(%did, "rust OAuth session revoked at the PDS")
4470 }
4471 crate::oauth::revoke::Revocation::NoSession => {}
4472 crate::oauth::revoke::Revocation::Failed(reason) => {
4473 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4474 }
4475 }
4476 }
4477}
4478
4479async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4489 if let Some(user) = current_session(&state, &headers).await {
4490 if let Some(sid) = user.sid {
4493 state.sessions.remove(&sid);
4494 flush_before_revoke(&state, &user.did).await;
4496 revoke_everywhere(&state, &user.did).await;
4497 }
4498 }
4499 let mut resp = Redirect::to("/login").into_response();
4500 set_cookie(
4501 &mut resp,
4502 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4503 );
4504 resp
4505}
4506
4507#[derive(Debug, Deserialize)]
4510struct DeleteAccountForm {
4511 #[serde(default)]
4512 confirm: String,
4513}
4514
4515const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4517
4518async fn account_delete(
4534 State(state): State<AppState>,
4535 headers: HeaderMap,
4536 Form(form): Form<DeleteAccountForm>,
4537) -> Result<Response, WebError> {
4538 let user = match current_session(&state, &headers).await {
4539 Some(u) => u,
4540 None => return Ok(Redirect::to("/login").into_response()),
4541 };
4542 let did = user.did.clone();
4543
4544 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4546 return Ok(Redirect::to(&format!(
4547 "/manage?flash={}",
4548 qenc("Type DELETE to confirm — nothing was deleted.")
4549 ))
4550 .into_response());
4551 }
4552
4553 let counts = store::purge_did_data(&state.db, &did).await?;
4555 info!(
4556 %did,
4557 total = counts.total(),
4558 entry_state = counts.entry_state,
4559 read_cursor = counts.read_cursor,
4560 sub_ref = counts.sub_ref,
4561 beta_access = counts.beta_access,
4562 invite_codes = counts.invite_codes,
4563 "account/delete: local rows purged"
4564 );
4565
4566 revoke_everywhere(&state, &did).await;
4569
4570 if let Some(sid) = user.sid {
4572 state.sessions.remove(&sid);
4573 }
4574 let mut resp = Redirect::to(&format!(
4575 "/login?flash={}",
4576 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4577 ))
4578 .into_response();
4579 set_cookie(
4580 &mut resp,
4581 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4582 );
4583 Ok(resp)
4584}
4585
4586fn login_card(config: &Config) -> Card {
4588 Card::public(
4589 config,
4590 "/login",
4591 "Sign in — FeatherReader",
4592 "Sign in to FeatherReader with your atproto handle. You approve access on \
4593 your own server — no signup, no password.",
4594 )
4595}
4596
4597fn login_error(state: &AppState, msg: &str) -> Response {
4599 render(&LoginTemplate {
4600 card: login_card(&state.config),
4601 repo_url: REPO_URL,
4602 error: msg.to_string(),
4603 flash: String::new(),
4604 })
4605}
4606
4607#[derive(Debug, Deserialize)]
4613struct RedeemForm {
4614 code: String,
4615}
4616
4617async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4620 let full = store::count_beta_access(&state.db)
4621 .await
4622 .map(|n| n >= state.config.beta_cap)
4623 .unwrap_or(false);
4624 render(&BetaRedeemTemplate {
4625 card: redeem_card(&state.config),
4626 repo_url: REPO_URL,
4627 error: String::new(),
4628 capacity_full: full,
4629 })
4630}
4631
4632async fn beta_redeem_submit(
4642 State(state): State<AppState>,
4643 Form(form): Form<RedeemForm>,
4644) -> Response {
4645 let code = form.code.trim().to_uppercase();
4646 if code.is_empty() {
4647 return render(&BetaRedeemTemplate {
4648 card: redeem_card(&state.config),
4649 repo_url: REPO_URL,
4650 error: "Enter your invite code.".to_string(),
4651 capacity_full: false,
4652 });
4653 }
4654
4655 match preflight_code(&state, &code).await {
4656 Ok(()) => {
4657 let cookie = sign_invite(&code, &state.config.cookie_secret);
4658 let mut resp = Redirect::to("/login").into_response();
4659 set_cookie(&mut resp, &cookie);
4660 info!("invite code preflight OK; reserving intent + redirecting to /login");
4661 resp
4662 }
4663 Err(policy) => {
4664 warn!(?policy, "invite code preflight rejected");
4665 redeem_bounce(&state, &policy)
4666 }
4667 }
4668}
4669
4670async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4676 let count = match store::count_beta_access(&state.db).await {
4684 Ok(n) => n,
4685 Err(err) => {
4686 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4687 return Err(store::RedeemError::CapacityFull);
4688 }
4689 };
4690 if count >= state.config.beta_cap {
4691 return Err(store::RedeemError::CapacityFull);
4692 }
4693 let row = sqlx::query_as::<_, (String, i64)>(
4695 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4696 )
4697 .bind(code)
4698 .fetch_optional(&state.db)
4699 .await
4700 .ok()
4701 .flatten();
4702 let (status, expires_at) = match row {
4703 Some(r) => r,
4704 None => return Err(store::RedeemError::NotFound),
4705 };
4706 let now = chrono::Utc::now().timestamp();
4707 match status.as_str() {
4708 "active" if expires_at >= now => Ok(()),
4709 "active" => Err(store::RedeemError::Expired),
4710 "expired" => Err(store::RedeemError::Expired),
4711 _ => Err(store::RedeemError::AlreadyRedeemed),
4713 }
4714}
4715
4716fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4719 use store::RedeemError::*;
4720 let (msg, capacity_full) = match policy {
4721 NotFound => ("That invite code isn't valid.", false),
4722 Expired => ("That invite code has expired.", false),
4723 AlreadyRedeemed => ("That invite code has already been used.", false),
4724 CapacityFull => ("", true),
4725 };
4726 render(&BetaRedeemTemplate {
4727 card: redeem_card(&state.config),
4728 repo_url: REPO_URL,
4729 error: msg.to_string(),
4730 capacity_full,
4731 })
4732}
4733
4734fn redeem_card(config: &Config) -> Card {
4737 Card::public(
4738 config,
4739 "/beta/redeem",
4740 "Redeem an invite — FeatherReader",
4741 "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4742 in with your atproto handle.",
4743 )
4744}
4745
4746#[derive(Debug, Deserialize, Default)]
4748struct MintQuery {
4749 #[serde(default)]
4750 n: Option<u32>,
4751}
4752
4753async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4766 let Some(runtime) = state.oauth.as_deref() else {
4767 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4769 };
4770 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4771}
4772
4773async fn oauth_jwks(State(state): State<AppState>) -> Response {
4780 let Some(runtime) = state.oauth.as_deref() else {
4781 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4782 };
4783 match runtime.client_key.as_ref() {
4784 Some(key) => match key.jwks_document() {
4785 Ok(doc) => axum::Json(doc).into_response(),
4786 Err(err) => {
4787 warn!(%err, "could not render the client JWKS");
4788 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4789 }
4790 },
4791 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4792 }
4793}
4794
4795const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4797
4798async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4807 let did = match current_did(&state, &headers).await {
4808 Some(d) => d,
4809 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4810 };
4811 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4812 warn!(%did, "admin metrics denied: not an admin-seed DID");
4813 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4814 }
4815
4816 if let Err(err) =
4821 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4822 {
4823 warn!(%err, "could not flush repo timings before rendering");
4824 }
4825 let rows = match crate::metrics::persisted_rows(&state.db).await {
4826 Ok(rows) => rows,
4827 Err(err) => {
4828 warn!(%err, "could not read persisted repo timings");
4829 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4830 }
4831 };
4832
4833 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4839 Ok(n) => n.to_string(),
4840 Err(err) => {
4841 warn!(%err, "could not count parked read-state DIDs");
4842 "unknown".to_string()
4843 }
4844 };
4845 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4852 Ok(f) => f,
4853 Err(err) => {
4854 warn!(%err, "could not list failing feeds");
4855 Vec::new()
4856 }
4857 };
4858 let mut failing_block = String::new();
4859 if !failing.is_empty() {
4860 failing_block.push_str("\nfailing feeds (worst first)\n");
4861 for f in &failing {
4862 failing_block.push_str(&format!(
4863 " {:>4}x {:<8} {}\n {}\n",
4864 f.consecutive_errors,
4865 f.kind.as_deref().unwrap_or("unknown"),
4866 f.url,
4867 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4868 ));
4869 }
4870 }
4871
4872 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4877 Ok(n) => n,
4878 Err(err) => {
4879 warn!(%err, "could not count unpollable feeds");
4880 -1
4881 }
4882 };
4883 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4884
4885 let body = format!(
4886 "live backend: {}\nparked read-state DIDs: {}\n\
4887 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4888 state.config.repo_backend.as_str(),
4889 parked,
4890 cached,
4891 state.config.max_feeds_global,
4892 unpollable,
4893 crate::metrics::render(&rows),
4894 failing_block,
4895 );
4896 (StatusCode::OK, body).into_response()
4897}
4898
4899async fn admin_mint_invites(
4903 State(state): State<AppState>,
4904 headers: HeaderMap,
4905 Query(q): Query<MintQuery>,
4906) -> Response {
4907 let did = match current_did(&state, &headers).await {
4910 Some(d) => d,
4911 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4912 };
4913 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4914 warn!(%did, "admin mint denied: not an admin-seed DID");
4915 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4916 }
4917
4918 let n = q.n.unwrap_or(1).clamp(1, 100);
4919 let mut codes = Vec::with_capacity(n as usize);
4920 for _ in 0..n {
4921 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4922 Ok(code) => codes.push(code),
4923 Err(err) => {
4924 warn!(%err, %did, "admin mint_code failed");
4925 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4926 }
4927 }
4928 }
4929 info!(%did, count = codes.len(), "admin minted invite codes");
4930 let mut body = codes.join("\n");
4931 body.push('\n');
4932 (StatusCode::OK, body).into_response()
4933}
4934
4935#[derive(Debug, Deserialize)]
4941struct ClaimQuery {
4942 t: Option<String>,
4944}
4945
4946async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4965 let token = match q.t {
4966 Some(t) if !t.is_empty() => t,
4967 _ => {
4968 warn!("claim link with no token");
4969 return redeem_bounce(&state, &store::RedeemError::NotFound);
4970 }
4971 };
4972
4973 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4976 Some(c) => c,
4977 None => {
4978 warn!("claim token invalid (bad signature / malformed)");
4979 return redeem_bounce(&state, &store::RedeemError::NotFound);
4980 }
4981 };
4982
4983 match preflight_code(&state, &code).await {
4987 Ok(()) => {
4988 let cookie = sign_invite(&code, &state.config.cookie_secret);
4989 let mut resp = Redirect::to("/login").into_response();
4990 set_cookie(&mut resp, &cookie);
4991 info!("claim token preflight OK; reserving intent + redirecting to /login");
4992 resp
4993 }
4994 Err(policy) => {
4995 warn!(?policy, "claim token preflight rejected");
4996 redeem_bounce(&state, &policy)
4997 }
4998 }
4999}
5000
5001#[derive(Debug, Default, Deserialize)]
5008struct BotClaimRequest {
5009 #[serde(default)]
5012 did: Option<String>,
5013 #[serde(default)]
5015 #[allow(dead_code)]
5016 handle: Option<String>,
5017}
5018
5019#[derive(Debug, serde::Serialize)]
5021struct BotClaimResponse {
5022 status: &'static str,
5028 code: String,
5032 token: String,
5035 url: String,
5038}
5039
5040async fn bot_mint_claim(
5068 State(state): State<AppState>,
5069 headers: HeaderMap,
5070 body: axum::body::Bytes,
5071) -> Response {
5072 let bot_secret = match state.config.bot_secret.as_deref() {
5074 Some(s) => s,
5075 None => {
5076 warn!(
5077 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5078 );
5079 return (
5080 StatusCode::SERVICE_UNAVAILABLE,
5081 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5082 )
5083 .into_response();
5084 }
5085 };
5086
5087 let presented = headers
5089 .get("x-bot-secret")
5090 .and_then(|v| v.to_str().ok())
5091 .unwrap_or("");
5092 if !bot_secret_matches(presented, bot_secret) {
5093 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5094 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5095 }
5096
5097 let req: BotClaimRequest = if body.is_empty() {
5100 BotClaimRequest::default()
5101 } else {
5102 match serde_json::from_slice(&body) {
5103 Ok(r) => r,
5104 Err(err) => {
5105 warn!(%err, "POST /bot/claims: bad JSON body");
5106 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5107 }
5108 }
5109 };
5110 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5111
5112 if let Some(did) = follower_did {
5114 match store::has_beta_access(&state.db, did).await {
5116 Ok(true) => {
5117 info!("bot mint: DID already holds beta access; already_seated");
5118 return bot_claim_json(BotClaimResponse {
5119 status: "already_seated",
5120 code: String::new(),
5121 token: String::new(),
5122 url: String::new(),
5123 });
5124 }
5125 Ok(false) => {}
5126 Err(err) => {
5127 warn!(%err, "bot mint: has_beta_access failed");
5129 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5130 }
5131 }
5132 match store::find_active_code_for_did(&state.db, did).await {
5135 Ok(Some(code)) => {
5136 info!("bot mint: existing outstanding claim for DID; returning same code");
5137 let token = sign_claim_token(&code, &state.config.cookie_secret);
5138 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5139 return bot_claim_json(BotClaimResponse {
5140 status: "existing",
5141 code,
5142 token,
5143 url,
5144 });
5145 }
5146 Ok(None) => {}
5147 Err(err) => {
5148 warn!(%err, "bot mint: find_active_code_for_did failed");
5149 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5150 }
5151 }
5152 }
5153
5154 let granted = match store::count_beta_access(&state.db).await {
5157 Ok(n) => n,
5158 Err(err) => {
5159 warn!(%err, "bot mint: count_beta_access failed; failing closed");
5160 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5161 }
5162 };
5163 let outstanding = match store::count_active_codes(&state.db).await {
5164 Ok(n) => n,
5165 Err(err) => {
5166 warn!(%err, "bot mint: count_active_codes failed; failing closed");
5167 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5168 }
5169 };
5170 if granted + outstanding >= state.config.beta_cap {
5171 info!(
5172 granted,
5173 outstanding,
5174 cap = state.config.beta_cap,
5175 "bot mint refused: at capacity"
5176 );
5177 return (
5178 StatusCode::CONFLICT,
5179 [(header::CONTENT_TYPE, "application/json")],
5180 "{\"error\":\"full\"}\n",
5181 )
5182 .into_response();
5183 }
5184
5185 let bot_did = state
5188 .config
5189 .admin_seed_dids()
5190 .first()
5191 .cloned()
5192 .unwrap_or_else(|| "did:bot:featherreader".to_string());
5193 let minted = match follower_did {
5194 Some(did) => {
5195 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5196 }
5197 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5198 };
5199 let code = match minted {
5200 Ok(c) => c,
5201 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5208 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5209 Ok(Some(code)) => {
5210 info!("bot mint: lost the mint race; returning the concurrently-minted code");
5211 let token = sign_claim_token(&code, &state.config.cookie_secret);
5212 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5213 return bot_claim_json(BotClaimResponse {
5214 status: "existing",
5215 code,
5216 token,
5217 url,
5218 });
5219 }
5220 Ok(None) => {
5224 warn!("bot mint: conflict but no active code found on recovery");
5225 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5226 }
5227 Err(err) => {
5228 warn!(%err, "bot mint: recovery lookup after conflict failed");
5229 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5230 }
5231 }
5232 }
5233 Err(err) => {
5234 warn!(%err, "bot mint_code failed");
5235 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5236 }
5237 };
5238 let token = sign_claim_token(&code, &state.config.cookie_secret);
5239 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5240 info!("bot minted a claim code + token");
5241
5242 bot_claim_json(BotClaimResponse {
5243 status: "minted",
5244 code,
5245 token,
5246 url,
5247 })
5248}
5249
5250fn bot_claim_json(resp: BotClaimResponse) -> Response {
5253 match serde_json::to_string(&resp) {
5254 Ok(body) => (
5255 StatusCode::OK,
5256 [(header::CONTENT_TYPE, "application/json")],
5257 body,
5258 )
5259 .into_response(),
5260 Err(err) => {
5261 warn!(%err, "serializing bot claim response failed");
5262 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5263 }
5264 }
5265}
5266
5267fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5272 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5273}
5274
5275fn sign_invite(code: &str, secret: &str) -> String {
5284 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5285}
5286
5287fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5292 cookie::verify_value(headers, INVITE_COOKIE, secret)
5293}
5294
5295const CLAIM_TOKEN_LABEL: &str = "claim-token";
5299
5300fn sign_claim_token(code: &str, secret: &str) -> String {
5312 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5313}
5314
5315fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5320 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5321}
5322
5323fn clear_invite_cookie(resp: &mut Response) {
5326 set_cookie(
5327 resp,
5328 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5329 );
5330}
5331
5332async fn import_opml(
5345 State(state): State<AppState>,
5346 headers: HeaderMap,
5347 mut multipart: Multipart,
5348) -> Result<Response, WebError> {
5349 let did = match current_did(&state, &headers).await {
5350 Some(d) => d,
5351 None => return Ok(Redirect::to("/login").into_response()),
5352 };
5353 let pool = &state.db;
5354
5355 let mut opml_text = String::new();
5361 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5362 let name = field.name().unwrap_or("").to_string();
5363 if name == "opml" || name == "file" {
5364 let bytes = field.bytes().await.map_err(multipart_response)?;
5365 if !bytes.is_empty() {
5366 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5367 if name == "file" {
5368 break;
5369 }
5370 }
5371 }
5372 }
5373
5374 let feeds =
5379 match opml::parse_opml(&opml_text) {
5380 Ok(feeds) => feeds,
5381 Err(err) => {
5382 warn!(%err, %did, "OPML import could not parse the uploaded file");
5383 return Ok(Redirect::to(&format!(
5384 "/?flash={}",
5385 qenc("That file could not be read as OPML. Export it again from your other reader?")
5386 ))
5387 .into_response());
5388 }
5389 };
5390 if feeds.is_empty() {
5391 info!(%did, "OPML import found no feeds");
5392 return Ok(
5393 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5394 .into_response(),
5395 );
5396 }
5397
5398 let now = now_rfc3339();
5401 let mut folder_uris: std::collections::HashMap<String, String> =
5402 std::collections::HashMap::new();
5403 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5405 for (rkey, folder) in existing {
5406 folder_uris
5407 .entry(folder.name.clone())
5408 .or_insert_with(|| folder_uri(&did, &rkey));
5409 }
5410 }
5411 let mut wanted_folders: Vec<String> = feeds
5412 .iter()
5413 .filter_map(|f| f.folder.clone())
5414 .filter(|n| !n.is_empty())
5415 .collect();
5416 wanted_folders.sort();
5417 wanted_folders.dedup();
5418 for name in wanted_folders {
5419 if folder_uris.contains_key(&name) {
5420 continue;
5421 }
5422 let folder = Folder::new(name.clone(), now.clone());
5423 match state.repo().add_folder(&did, &folder).await {
5424 Ok(rkey) => {
5425 folder_uris.insert(name, folder_uri(&did, &rkey));
5426 }
5427 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5428 }
5429 }
5430
5431 let sub_cap = state.config.max_subs_per_did;
5440 let mut headroom: Option<i64> = if sub_cap > 0 {
5441 let existing = store::count_subscriptions_for_did(pool, &did)
5442 .await
5443 .unwrap_or(0);
5444 Some((sub_cap - existing).max(0))
5445 } else {
5446 None
5447 };
5448 let mut trimmed_over_cap: usize = 0;
5449
5450 let feeds_cap = state.config.max_feeds_global;
5457 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5458 let existing = store::count_feeds(pool).await.unwrap_or(0);
5459 Some((feeds_cap - existing).max(0))
5460 } else {
5461 None
5462 };
5463 let mut trimmed_over_global: usize = 0;
5464
5465 let mut subs = Vec::with_capacity(feeds.len());
5466 let mut skipped_private: Vec<String> = Vec::new();
5467 let mut uncached: usize = 0;
5470 let mut skipped_unsupported: usize = 0;
5476 for f in &feeds {
5477 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5485 info!(
5486 %did,
5487 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5488 );
5489 skipped_unsupported += 1;
5490 continue;
5491 }
5492 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5493 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5494 let label = f
5496 .title
5497 .clone()
5498 .filter(|t| !t.trim().is_empty())
5499 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5500 skipped_private.push(label);
5501 continue;
5502 }
5503
5504 if let Some(h) = headroom.as_mut() {
5507 if *h <= 0 {
5508 trimmed_over_cap += 1;
5509 continue;
5510 }
5511 }
5512
5513 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5518 Ok(existing) => existing.is_none(),
5519 Err(err) => {
5522 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5523 false
5524 }
5525 };
5526 if is_new {
5527 if let Some(g) = global_headroom.as_mut() {
5528 if *g <= 0 {
5529 trimmed_over_global += 1;
5530 continue;
5531 }
5532 *g -= 1;
5533 }
5534 }
5535
5536 if let Some(h) = headroom.as_mut() {
5539 *h -= 1;
5540 }
5541
5542 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5543 sub.title = f.title.clone();
5544 sub.site_url = f.site_url.clone();
5545 sub.folder = f
5546 .folder
5547 .as_ref()
5548 .and_then(|name| folder_uris.get(name).cloned());
5549 subs.push(sub);
5550 if let Err(err) = store::upsert_feed(
5556 pool,
5557 &store::NewFeed {
5558 url: f.feed_url.clone(),
5559 title: f.title.clone(),
5560 site_url: f.site_url.clone(),
5561 ..Default::default()
5562 },
5563 )
5564 .await
5565 {
5566 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5567 it will not be polled");
5568 uncached += 1;
5569 }
5570 }
5571
5572 let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5589 Ok(rkeys) => {
5590 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5591 rkeys.len()
5592 }
5593 Err(err) => {
5594 let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
5595 warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
5596 landed
5597 }
5598 };
5599 if landed == 0 && !subs.is_empty() {
5600 return Ok(Redirect::to(&format!(
5601 "/?flash={}",
5602 qenc(
5603 "Could not save those subscriptions to your PDS, so nothing was imported. \
5604 Try again in a moment."
5605 )
5606 ))
5607 .into_response());
5608 }
5609
5610 let mut flash = if landed < subs.len() {
5612 format!(
5613 "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
5614 other {} may not have been saved. Importing the same file again would add the first \
5615 {landed} a second time",
5616 subs.len(),
5617 subs.len() - landed
5618 )
5619 } else {
5620 format!("Imported {} feeds", subs.len())
5621 };
5622 if uncached > 0 {
5623 flash.push_str(&format!(
5624 ". {uncached} of them could not be cached locally and may not update until the next import."
5625 ));
5626 }
5627 if trimmed_over_cap > 0 {
5628 flash.push_str(&format!(
5629 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5630 ));
5631 }
5632 if trimmed_over_global > 0 {
5633 flash.push_str(&format!(
5634 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5635 ));
5636 }
5637 if !skipped_private.is_empty() {
5638 flash.push_str(&format!(
5639 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5640 skipped_private.len(),
5641 skipped_private.join(", ")
5642 ));
5643 }
5644 if skipped_unsupported > 0 {
5645 flash.push_str(&format!(
5648 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5649 ));
5650 }
5651 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5652}
5653
5654fn private_feed_label(url: &str) -> String {
5657 url::Url::parse(url)
5658 .ok()
5659 .and_then(|u| u.host_str().map(str::to_string))
5660 .unwrap_or_else(|| "a private feed".to_string())
5661}
5662
5663async fn export_opml(
5665 State(state): State<AppState>,
5666 headers: HeaderMap,
5667) -> Result<Response, WebError> {
5668 let did = match current_did(&state, &headers).await {
5669 Some(d) => d,
5670 None => return Ok(Redirect::to("/login").into_response()),
5671 };
5672
5673 let subs = match state.repo().list_subscriptions_sorted(&did).await {
5680 Ok(subs) => subs,
5681 Err(err) => {
5682 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5683 return Ok(Redirect::to(&format!(
5684 "/manage?flash={}",
5685 qenc(EXPORT_INCOMPLETE_REFUSAL)
5686 ))
5687 .into_response());
5688 }
5689 };
5690 let folders = match state.repo().list_folders_sorted(&did).await {
5691 Ok(folders) => folders,
5692 Err(err) => {
5693 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5694 return Ok(Redirect::to(&format!(
5695 "/manage?flash={}",
5696 qenc(EXPORT_INCOMPLETE_REFUSAL)
5697 ))
5698 .into_response());
5699 }
5700 };
5701 let folder_pairs: Vec<(String, Folder)> = folders
5704 .into_iter()
5705 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5706 .collect();
5707
5708 let body = opml::to_opml(&subs, &folder_pairs);
5709 let mut resp = (StatusCode::OK, body).into_response();
5710 resp.headers_mut().insert(
5711 header::CONTENT_TYPE,
5712 "text/x-opml; charset=utf-8".parse().unwrap(),
5713 );
5714 resp.headers_mut().insert(
5715 header::CONTENT_DISPOSITION,
5716 "attachment; filename=\"featherreader-subscriptions.opml\""
5717 .parse()
5718 .unwrap(),
5719 );
5720 Ok(resp)
5721}
5722
5723fn set_cookie(resp: &mut Response, cookie: &str) {
5729 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5730 resp.headers_mut()
5731 .append(axum::http::header::SET_COOKIE, value);
5732 }
5733}
5734
5735fn is_htmx(headers: &HeaderMap) -> bool {
5737 headers
5738 .get("HX-Request")
5739 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5740}
5741
5742fn is_reader_request(headers: &HeaderMap) -> bool {
5748 headers
5749 .get("X-FR-Reader")
5750 .is_some_and(|v| v.as_bytes() == b"1")
5751}
5752
5753mod cookie {
5758 use super::{HeaderMap, SESSION_COOKIE};
5759
5760 pub fn sign_session(sid: &str, secret: &str) -> String {
5762 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5763 }
5764
5765 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5767 verify_value(headers, SESSION_COOKIE, secret)
5768 }
5769
5770 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5776 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5777 msg.extend_from_slice(name.as_bytes());
5778 msg.push(0);
5779 msg.extend_from_slice(value.as_bytes());
5780 msg
5781 }
5782
5783 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5789 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5790 let b64 = b64url_encode(value.as_bytes());
5791 format!(
5792 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5793 )
5794 }
5795
5796 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5799 let raw = cookie_value(headers, name)?;
5800 let (b64, sig) = raw.split_once('.')?;
5801 let bytes = b64url_decode(b64)?;
5802 let value = String::from_utf8(bytes).ok()?;
5803 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5804 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5805 Some(value)
5806 } else {
5807 None
5808 }
5809 }
5810
5811 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5817 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5818 let b64 = b64url_encode(value.as_bytes());
5819 format!("{b64}.{sig}")
5820 }
5821
5822 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5825 let (b64, sig) = token.split_once('.')?;
5826 let bytes = b64url_decode(b64)?;
5827 let value = String::from_utf8(bytes).ok()?;
5828 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5829 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5830 Some(value)
5831 } else {
5832 None
5833 }
5834 }
5835
5836 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5838 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5839 for part in header.split(';') {
5840 let part = part.trim();
5841 if let Some((k, v)) = part.split_once('=') {
5842 if k == name {
5843 return Some(v.to_string());
5844 }
5845 }
5846 }
5847 None
5848 }
5849
5850 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5854 if a.len() != b.len() {
5855 return false;
5856 }
5857 let mut diff = 0u8;
5858 for (x, y) in a.iter().zip(b.iter()) {
5859 diff |= x ^ y;
5860 }
5861 diff == 0
5862 }
5863
5864 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5867
5868 fn b64url_encode(input: &[u8]) -> String {
5869 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5870 for chunk in input.chunks(3) {
5871 let b = [
5872 chunk[0],
5873 *chunk.get(1).unwrap_or(&0),
5874 *chunk.get(2).unwrap_or(&0),
5875 ];
5876 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5877 out.push(B64[((n >> 18) & 63) as usize] as char);
5878 out.push(B64[((n >> 12) & 63) as usize] as char);
5879 if chunk.len() > 1 {
5880 out.push(B64[((n >> 6) & 63) as usize] as char);
5881 }
5882 if chunk.len() > 2 {
5883 out.push(B64[(n & 63) as usize] as char);
5884 }
5885 }
5886 out
5887 }
5888
5889 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5890 fn val(c: u8) -> Option<u32> {
5891 match c {
5892 b'A'..=b'Z' => Some((c - b'A') as u32),
5893 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5894 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5895 b'-' => Some(62),
5896 b'_' => Some(63),
5897 _ => None,
5898 }
5899 }
5900 let bytes = input.as_bytes();
5901 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5902 for chunk in bytes.chunks(4) {
5903 let mut n = 0u32;
5904 let mut valid = 0;
5905 for (i, &c) in chunk.iter().enumerate() {
5906 n |= val(c)? << (18 - 6 * i);
5907 valid += 1;
5908 }
5909 out.push((n >> 16) as u8);
5910 if valid > 2 {
5911 out.push((n >> 8) as u8);
5912 }
5913 if valid > 3 {
5914 out.push(n as u8);
5915 }
5916 }
5917 Some(out)
5918 }
5919
5920 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5924 const BLOCK: usize = 64;
5925 let mut k = [0u8; BLOCK];
5926 if key.len() > BLOCK {
5927 let d = sha256(key);
5928 k[..32].copy_from_slice(&d);
5929 } else {
5930 k[..key.len()].copy_from_slice(key);
5931 }
5932 let mut ipad = [0x36u8; BLOCK];
5933 let mut opad = [0x5cu8; BLOCK];
5934 for i in 0..BLOCK {
5935 ipad[i] ^= k[i];
5936 opad[i] ^= k[i];
5937 }
5938 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5939 inner.extend_from_slice(&ipad);
5940 inner.extend_from_slice(msg);
5941 let inner_hash = sha256(&inner);
5942 let mut outer = Vec::with_capacity(BLOCK + 32);
5943 outer.extend_from_slice(&opad);
5944 outer.extend_from_slice(&inner_hash);
5945 let mac = sha256(&outer);
5946 let mut hex = String::with_capacity(64);
5947 for b in mac {
5948 hex.push_str(&format!("{b:02x}"));
5949 }
5950 hex
5951 }
5952
5953 fn sha256(data: &[u8]) -> [u8; 32] {
5955 const K: [u32; 64] = [
5956 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5957 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5958 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5959 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5960 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5961 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5962 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5963 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5964 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5965 0xc67178f2,
5966 ];
5967 let mut h: [u32; 8] = [
5968 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5969 0x5be0cd19,
5970 ];
5971
5972 let bit_len = (data.len() as u64) * 8;
5973 let mut msg = data.to_vec();
5974 msg.push(0x80);
5975 while msg.len() % 64 != 56 {
5976 msg.push(0);
5977 }
5978 msg.extend_from_slice(&bit_len.to_be_bytes());
5979
5980 for block in msg.chunks(64) {
5981 let mut w = [0u32; 64];
5982 for i in 0..16 {
5983 w[i] = u32::from_be_bytes([
5984 block[i * 4],
5985 block[i * 4 + 1],
5986 block[i * 4 + 2],
5987 block[i * 4 + 3],
5988 ]);
5989 }
5990 for i in 16..64 {
5991 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5992 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5993 w[i] = w[i - 16]
5994 .wrapping_add(s0)
5995 .wrapping_add(w[i - 7])
5996 .wrapping_add(s1);
5997 }
5998 let mut a = h;
5999 for i in 0..64 {
6000 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6001 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6002 let t1 = a[7]
6003 .wrapping_add(s1)
6004 .wrapping_add(ch)
6005 .wrapping_add(K[i])
6006 .wrapping_add(w[i]);
6007 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6008 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6009 let t2 = s0.wrapping_add(maj);
6010 a[7] = a[6];
6011 a[6] = a[5];
6012 a[5] = a[4];
6013 a[4] = a[3].wrapping_add(t1);
6014 a[3] = a[2];
6015 a[2] = a[1];
6016 a[1] = a[0];
6017 a[0] = t1.wrapping_add(t2);
6018 }
6019 for i in 0..8 {
6020 h[i] = h[i].wrapping_add(a[i]);
6021 }
6022 }
6023
6024 let mut out = [0u8; 32];
6025 for (i, word) in h.iter().enumerate() {
6026 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6027 }
6028 out
6029 }
6030
6031 #[cfg(test)]
6032 mod tests {
6033 use super::*;
6034
6035 #[test]
6036 fn sha256_known_vector() {
6037 let d = sha256(b"abc");
6038 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6039 assert_eq!(
6040 hex,
6041 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6042 );
6043 }
6044
6045 #[test]
6046 fn hmac_known_vector() {
6047 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6048 assert_eq!(
6049 mac,
6050 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6051 );
6052 }
6053
6054 #[test]
6055 fn sign_verify_round_trips() {
6056 let secret = "test-secret";
6057 let sid = "9f2c-opaque-session-id";
6058 let cookie = sign_session(sid, secret);
6059 let pair = cookie.split(';').next().unwrap().to_string();
6060 let mut headers = HeaderMap::new();
6061 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6062 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6063 assert!(verify_session(&headers, "other-secret").is_none());
6065 }
6066
6067 #[test]
6068 fn forged_and_tampered_cookies_are_rejected() {
6069 let secret = "test-secret";
6070
6071 let forged = format!(
6074 "{SESSION_COOKIE}={}.{}",
6075 b64url_encode(b"attacker-chosen-sid"),
6076 "deadbeef".repeat(8) );
6078 let mut headers = HeaderMap::new();
6079 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6080 assert!(verify_session(&headers, secret).is_none());
6081
6082 let cookie = sign_session("real-sid", secret);
6085 let pair = cookie.split(';').next().unwrap();
6086 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6087 let tampered = format!(
6088 "{SESSION_COOKIE}={}.{}",
6089 b64url_encode(b"different-sid"),
6090 sig
6091 );
6092 let mut headers2 = HeaderMap::new();
6093 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6094 assert!(verify_session(&headers2, secret).is_none());
6095 }
6096
6097 #[test]
6098 fn b64url_round_trips() {
6099 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6100 let enc = b64url_encode(s.as_bytes());
6101 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6102 }
6103 }
6104 }
6105}
6106
6107async fn get_entry_by_id(
6123 pool: &store::Pool,
6124 did: &str,
6125 id: i64,
6126) -> anyhow::Result<Option<store::Entry>> {
6127 let entry = sqlx::query_as::<_, store::Entry>(
6128 r#"
6129 SELECT e.* FROM entries e
6130 WHERE e.id = ?2
6131 AND EXISTS (
6132 SELECT 1 FROM sub_ref sr
6133 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6134 )
6135 "#,
6136 )
6137 .bind(did)
6138 .bind(id)
6139 .fetch_optional(pool)
6140 .await?;
6141 Ok(entry)
6142}
6143
6144async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6146 let read: Option<bool> =
6147 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6148 .bind(did)
6149 .bind(entry_id)
6150 .fetch_optional(pool)
6151 .await?
6152 .flatten();
6153 Ok(read.unwrap_or(false))
6154}
6155
6156async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6158 let starred: Option<bool> =
6159 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6160 .bind(did)
6161 .bind(entry_id)
6162 .fetch_optional(pool)
6163 .await?
6164 .flatten();
6165 Ok(starred.unwrap_or(false))
6166}
6167
6168async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6170 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6171 .bind(feed_id)
6172 .fetch_optional(pool)
6173 .await
6174 {
6175 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6176 _ => String::new(),
6177 }
6178}
6179
6180async fn build_entry_row(
6183 pool: &store::Pool,
6184 did: &str,
6185 id: i64,
6186 read: Option<bool>,
6187) -> anyhow::Result<Option<EntryRow>> {
6188 let entry = match get_entry_by_id(pool, did, id).await? {
6189 Some(e) => e,
6190 None => return Ok(None),
6191 };
6192 let read = match read {
6193 Some(r) => r,
6194 None => entry_is_read(pool, did, id).await?,
6195 };
6196 let starred = entry_is_starred(pool, did, id).await?;
6197 Ok(Some(EntryRow {
6198 id: entry.id,
6199 title: entry
6200 .title
6201 .clone()
6202 .filter(|t| !t.trim().is_empty())
6203 .unwrap_or_else(|| "(untitled)".to_string()),
6204 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6205 published: display_date(entry.published.as_deref()),
6206 read,
6207 starred,
6208 link: SafeLink::entry(id, ""),
6209 cached: true,
6210 rkey: String::new(),
6211 }))
6212}
6213
6214fn now_rfc3339() -> String {
6216 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6217}
6218
6219#[cfg(test)]
6220mod tests {
6221 use super::*;
6222
6223 #[test]
6224 fn qenc_encodes_reserved() {
6225 assert_eq!(qenc("a b"), "a%20b");
6226 assert_eq!(
6227 qenc("https://example.com/feed.xml"),
6228 "https%3A%2F%2Fexample.com%2Ffeed.xml"
6229 );
6230 assert_eq!(
6231 qenc("at://did:plc:x/c/r"),
6232 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6233 );
6234 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6236 }
6237
6238 #[test]
6239 fn folder_uri_shape() {
6240 assert_eq!(
6241 folder_uri("did:plc:abc", "3kfolder"),
6242 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6243 );
6244 }
6245
6246 #[test]
6249 fn private_feeds_are_classified_private_across_providers() {
6250 for url in [
6254 "https://author.substack.com/feed/private/deadbeefcafe1234",
6255 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6256 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6257 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6258 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6259 "https://user:pass@example.com/feed",
6260 ] {
6261 assert!(
6262 feed::classify_feed_privacy(url).is_private(),
6263 "expected private: {url}"
6264 );
6265 }
6266 }
6267
6268 #[test]
6269 fn public_feeds_stay_public() {
6270 for url in [
6271 "https://author.substack.com/feed",
6272 "https://wordpress.example.com/feed/",
6273 "https://example.com/rss.xml",
6274 "https://example.org/atom.xml",
6275 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6277 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6278 ] {
6279 assert!(
6280 !feed::classify_feed_privacy(url).is_private(),
6281 "expected public: {url}"
6282 );
6283 }
6284 }
6285
6286 #[test]
6287 fn private_feed_label_is_public_safe_host_only() {
6288 let label =
6290 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6291 assert_eq!(label, "author.substack.com");
6292 assert!(!label.contains("deadbeefcafe1234token"));
6293 assert!(!label.contains("/private/"));
6294 assert_eq!(private_feed_label("not a url"), "a private feed");
6296 }
6297
6298 #[test]
6299 fn refusal_message_promises_nothing_stored() {
6300 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6301 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6302 }
6303
6304 #[test]
6305 fn scope_query_preserves_context() {
6306 let q = EntryQuery {
6307 feed: Some("https://example.com/feed.xml".to_string()),
6308 folder: None,
6309 view: Some("all".to_string()),
6310 };
6311 let s = scope_query(&q);
6312 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6313 assert!(s.contains("view=all"));
6314
6315 let q2 = EntryQuery {
6317 feed: None,
6318 folder: None,
6319 view: Some("unread".to_string()),
6320 };
6321 assert_eq!(scope_query(&q2), "");
6322 }
6323
6324 use axum::body::Body;
6327 use axum::http::Request;
6328 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6334 let db = store::init_url("sqlite::memory:").await.unwrap();
6335 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6336 store::ensure_seed(&db, &dids).await.unwrap();
6337 let config = Config {
6338 allowed_dids: dids,
6339 cookie_secret: "test-cookie-secret-000".to_string(),
6340 beta_cap: 3,
6341 ..Config::default()
6342 };
6343 AppState::new(config, db).unwrap()
6344 }
6345
6346 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6349 let sid = state.sessions.create(Session {
6350 did: did.to_string(),
6351 handle: handle.map(str::to_string),
6352 });
6353 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6354 sc.split(';').next().unwrap().to_string()
6355 }
6356
6357 #[test]
6361 fn the_rate_limit_map_is_bounded() {
6362 let rl = RateLimiter::shared();
6363 let now = Instant::now();
6364 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6365 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6368 rl.check_at(ip, now + Duration::from_millis(i as u64));
6369 }
6370 let len = rl.inner.lock().unwrap().buckets.len();
6371 assert!(
6372 len <= MAX_RATE_BUCKETS,
6373 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6374 );
6375 }
6376
6377 #[test]
6384 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6385 let rl = RateLimiter::shared();
6386 let base = Instant::now();
6387 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6388 let at = |n: u64| base + Duration::from_nanos(n);
6393
6394 for i in 0..(RATE_BURST as u64) {
6396 assert!(rl.check_at(attacker, at(i)));
6397 }
6398 assert!(
6399 !rl.check_at(attacker, at(RATE_BURST as u64)),
6400 "burst was not exhausted; the rest of this test proves nothing"
6401 );
6402
6403 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6406 let t = at(100 + i as u64 * 2);
6407 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6408 rl.check_at(ip, t);
6409 assert!(
6410 !rl.check_at(attacker, t),
6411 "the attacker got a token back after evictions at i={i}"
6412 );
6413 }
6414 }
6415
6416 #[test]
6419 fn the_idle_sweep_does_not_run_on_every_request() {
6420 let rl = RateLimiter::shared();
6421 let start = Instant::now();
6422 let a: IpAddr = "198.51.100.1".parse().unwrap();
6423 let b: IpAddr = "198.51.100.2".parse().unwrap();
6424
6425 rl.check_at(a, start);
6426 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6429 assert!(
6430 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6431 "an idle bucket survived a sweep that was due"
6432 );
6433
6434 let before = rl.inner.lock().unwrap().last_sweep;
6437 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6438 assert_eq!(
6439 rl.inner.lock().unwrap().last_sweep,
6440 before,
6441 "the sweep ran again within the interval"
6442 );
6443 }
6444
6445 #[test]
6446 fn rate_limited_paths_match_expected() {
6447 use axum::http::Method;
6448 assert!(is_rate_limited_path("/login", &Method::GET));
6449 assert!(is_rate_limited_path("/login", &Method::POST));
6450 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6451 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6452 assert!(is_rate_limited_path("/opml", &Method::POST));
6453 assert!(is_rate_limited_path("/read-all", &Method::POST));
6454 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6455 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6456 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6457 assert!(!is_rate_limited_path("/", &Method::GET));
6459 assert!(!is_rate_limited_path("/about", &Method::GET));
6460 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6461 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6462 }
6463
6464 #[test]
6465 fn rate_limiter_allows_burst_then_429s() {
6466 let rl = RateLimiter::shared();
6467 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6468 for _ in 0..(RATE_BURST as usize) {
6470 assert!(rl.check(ip));
6471 }
6472 assert!(!rl.check(ip));
6474 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6476 assert!(rl.check(ip2));
6477 }
6478
6479 #[test]
6480 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6481 let mut h = HeaderMap::new();
6485 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6486 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6487 assert_eq!(
6488 client_ip(&h, Some(&sock), None),
6489 Some("203.0.113.55".parse().unwrap()),
6490 "spoofed XFF must not override the socket peer"
6491 );
6492 }
6493
6494 #[test]
6495 fn client_ip_uses_trusted_header_last_hop() {
6496 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6501
6502 let mut h = HeaderMap::new();
6503 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6504 assert_eq!(
6505 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6506 Some("198.51.100.9".parse().unwrap())
6507 );
6508
6509 let mut h2 = HeaderMap::new();
6511 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6512 assert_eq!(
6513 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6514 Some("198.51.100.9".parse().unwrap()),
6515 "must take the right-most (trusted) hop, not the forged left-most"
6516 );
6517
6518 let h3 = HeaderMap::new();
6520 assert_eq!(
6521 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6522 Some("10.0.0.1".parse().unwrap())
6523 );
6524 }
6525
6526 #[test]
6527 fn invite_cookie_round_trips_and_rejects_tamper() {
6528 let secret = "test-cookie-secret-000";
6529 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6530 let pair = sc.split(';').next().unwrap();
6531 let mut h = HeaderMap::new();
6532 h.insert(header::COOKIE, pair.parse().unwrap());
6533 assert_eq!(
6534 invite_cookie_code(&h, secret).as_deref(),
6535 Some("FEATHER-ABCDWXYZ")
6536 );
6537 assert!(invite_cookie_code(&h, "other").is_none());
6539 }
6540
6541 #[tokio::test]
6542 async fn preflight_valid_expired_and_full() {
6543 let state = test_state(&["did:plc:admin"]).await;
6544 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6546 .await
6547 .unwrap();
6548 assert!(preflight_code(&state, &code).await.is_ok());
6549
6550 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6554 .await
6555 .unwrap();
6556 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6557 .bind(chrono::Utc::now().timestamp() - 3600)
6558 .bind(&expired)
6559 .execute(&state.db)
6560 .await
6561 .unwrap();
6562 assert_eq!(
6563 preflight_code(&state, &expired).await,
6564 Err(store::RedeemError::Expired)
6565 );
6566
6567 assert_eq!(
6569 preflight_code(&state, "FEATHER-NOPENOPE").await,
6570 Err(store::RedeemError::NotFound)
6571 );
6572
6573 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6576 .await
6577 .unwrap();
6578 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6579 .await
6580 .unwrap();
6581 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6582 assert_eq!(
6583 preflight_code(&state, &code).await,
6584 Err(store::RedeemError::CapacityFull)
6585 );
6586 }
6587
6588 async fn bot_state(bot_secret: &str) -> AppState {
6592 let db = store::init_url("sqlite::memory:").await.unwrap();
6593 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6594 .await
6595 .unwrap();
6596 let config = Config {
6597 allowed_dids: vec!["did:plc:admin".to_string()],
6598 cookie_secret: "test-cookie-secret-000".to_string(),
6599 beta_cap: 3,
6600 bot_secret: Some(bot_secret.to_string()),
6601 public_url: "https://feather-reader.com".to_string(),
6602 ..Config::default()
6603 };
6604 AppState::new(config, db).unwrap()
6605 }
6606
6607 #[test]
6608 fn claim_token_round_trips_and_rejects_tamper() {
6609 let secret = "test-cookie-secret-000";
6610 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6611 assert!(!token.contains(';'));
6613 assert_eq!(
6614 claim_token_code(&token, secret).as_deref(),
6615 Some("FEATHER-ABCDWXYZ")
6616 );
6617 assert!(claim_token_code(&token, "other").is_none());
6619 let mut bad = token.clone();
6621 bad.push('x');
6622 assert!(claim_token_code(&bad, secret).is_none());
6623 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6629 assert_eq!(
6630 test_b64url_decode(b64).as_deref(),
6631 Some("FEATHER-ABCDWXYZ".as_bytes()),
6632 "the code half of the token is plain base64url, decodable by anyone"
6633 );
6634 }
6635
6636 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6639 fn val(c: u8) -> Option<u32> {
6640 match c {
6641 b'A'..=b'Z' => Some((c - b'A') as u32),
6642 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6643 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6644 b'-' => Some(62),
6645 b'_' => Some(63),
6646 _ => None,
6647 }
6648 }
6649 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6650 for chunk in input.as_bytes().chunks(4) {
6651 let mut n = 0u32;
6652 let mut bits = 0;
6653 for &c in chunk {
6654 n = (n << 6) | val(c)?;
6655 bits += 6;
6656 }
6657 let bytes = bits / 8;
6658 n <<= 24 - bits;
6659 for i in 0..bytes {
6660 out.push((n >> (16 - i * 8)) as u8);
6661 }
6662 }
6663 Some(out)
6664 }
6665
6666 #[tokio::test]
6667 async fn bot_mint_then_claim_grants_a_seat() {
6668 let state = bot_state("bot-secret-abcdef").await;
6669 let app = router(state.clone());
6670
6671 let resp = app
6673 .clone()
6674 .oneshot(
6675 Request::builder()
6676 .method("POST")
6677 .uri("/bot/claims")
6678 .header("x-bot-secret", "bot-secret-abcdef")
6679 .body(Body::empty())
6680 .unwrap(),
6681 )
6682 .await
6683 .unwrap();
6684 assert_eq!(resp.status(), StatusCode::OK);
6685 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6686 .await
6687 .unwrap();
6688 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6689 let token = json["token"].as_str().unwrap().to_string();
6690 let url = json["url"].as_str().unwrap();
6691 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6692 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6694 assert!(!url.contains("FEATHER-"));
6695
6696 let resp = app
6698 .clone()
6699 .oneshot(
6700 Request::builder()
6701 .method("GET")
6702 .uri(format!("/claim?t={}", qenc(&token)))
6703 .body(Body::empty())
6704 .unwrap(),
6705 )
6706 .await
6707 .unwrap();
6708 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6709 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6710 let set_cookie = resp
6711 .headers()
6712 .get(header::SET_COOKIE)
6713 .unwrap()
6714 .to_str()
6715 .unwrap();
6716 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6717
6718 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6721 let out = store::redeem_code(
6722 &state.db,
6723 &code,
6724 "did:plc:follower",
6725 None,
6726 state.config.beta_cap,
6727 )
6728 .await
6729 .unwrap();
6730 assert_eq!(out, Ok(()));
6731 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6732 .await
6733 .unwrap());
6734 }
6735
6736 #[tokio::test]
6737 async fn claim_with_invalid_token_bounces() {
6738 let state = bot_state("bot-secret-abcdef").await;
6739 let app = router(state);
6740 let resp = app
6741 .oneshot(
6742 Request::builder()
6743 .method("GET")
6744 .uri("/claim?t=not-a-real-token")
6745 .body(Body::empty())
6746 .unwrap(),
6747 )
6748 .await
6749 .unwrap();
6750 assert_eq!(resp.status(), StatusCode::OK);
6752 }
6753
6754 #[tokio::test]
6755 async fn claim_with_used_token_is_refused() {
6756 let state = bot_state("bot-secret-abcdef").await;
6757 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6759 .await
6760 .unwrap();
6761 let token = sign_claim_token(&code, &state.config.cookie_secret);
6762 store::redeem_code(
6763 &state.db,
6764 &code,
6765 "did:plc:someone",
6766 None,
6767 state.config.beta_cap,
6768 )
6769 .await
6770 .unwrap()
6771 .unwrap();
6772 let app = router(state);
6773 let resp = app
6774 .oneshot(
6775 Request::builder()
6776 .method("GET")
6777 .uri(format!("/claim?t={}", qenc(&token)))
6778 .body(Body::empty())
6779 .unwrap(),
6780 )
6781 .await
6782 .unwrap();
6783 assert_eq!(resp.status(), StatusCode::OK);
6785 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6786 }
6787
6788 #[tokio::test]
6789 async fn bot_claims_rejects_bad_and_missing_secret() {
6790 let state = bot_state("bot-secret-abcdef").await;
6791 let app = router(state);
6792 let resp = app
6794 .clone()
6795 .oneshot(
6796 Request::builder()
6797 .method("POST")
6798 .uri("/bot/claims")
6799 .header("x-bot-secret", "wrong")
6800 .body(Body::empty())
6801 .unwrap(),
6802 )
6803 .await
6804 .unwrap();
6805 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6806 let resp = app
6808 .oneshot(
6809 Request::builder()
6810 .method("POST")
6811 .uri("/bot/claims")
6812 .body(Body::empty())
6813 .unwrap(),
6814 )
6815 .await
6816 .unwrap();
6817 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6818 }
6819
6820 #[tokio::test]
6821 async fn bot_claims_disabled_when_secret_unset() {
6822 let state = test_state(&["did:plc:admin"]).await;
6824 let app = router(state);
6825 let resp = app
6826 .oneshot(
6827 Request::builder()
6828 .method("POST")
6829 .uri("/bot/claims")
6830 .header("x-bot-secret", "anything")
6831 .body(Body::empty())
6832 .unwrap(),
6833 )
6834 .await
6835 .unwrap();
6836 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6837 }
6838
6839 #[tokio::test]
6840 async fn bot_claims_refuses_at_capacity() {
6841 let state = bot_state("bot-secret-abcdef").await;
6842 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6844 .await
6845 .unwrap();
6846 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6847 .await
6848 .unwrap();
6849 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6850 let app = router(state);
6851 let resp = app
6852 .oneshot(
6853 Request::builder()
6854 .method("POST")
6855 .uri("/bot/claims")
6856 .header("x-bot-secret", "bot-secret-abcdef")
6857 .body(Body::empty())
6858 .unwrap(),
6859 )
6860 .await
6861 .unwrap();
6862 assert_eq!(resp.status(), StatusCode::CONFLICT);
6863 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6864 .await
6865 .unwrap();
6866 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6867 }
6868
6869 #[tokio::test]
6870 async fn bot_claims_counts_outstanding_codes_against_cap() {
6871 let state = bot_state("bot-secret-abcdef").await;
6872 store::mint_code(&state.db, "did:plc:admin", 3600)
6874 .await
6875 .unwrap();
6876 store::mint_code(&state.db, "did:plc:admin", 3600)
6877 .await
6878 .unwrap();
6879 let app = router(state);
6880 let resp = app
6881 .oneshot(
6882 Request::builder()
6883 .method("POST")
6884 .uri("/bot/claims")
6885 .header("x-bot-secret", "bot-secret-abcdef")
6886 .body(Body::empty())
6887 .unwrap(),
6888 )
6889 .await
6890 .unwrap();
6891 assert_eq!(resp.status(), StatusCode::CONFLICT);
6893 }
6894
6895 async fn post_bot_claim_for(
6897 app: &axum::Router,
6898 secret: &str,
6899 did: &str,
6900 ) -> (StatusCode, serde_json::Value) {
6901 let resp = app
6902 .clone()
6903 .oneshot(
6904 Request::builder()
6905 .method("POST")
6906 .uri("/bot/claims")
6907 .header("x-bot-secret", secret)
6908 .header("content-type", "application/json")
6909 .body(Body::from(format!(
6910 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6911 )))
6912 .unwrap(),
6913 )
6914 .await
6915 .unwrap();
6916 let status = resp.status();
6917 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6918 .await
6919 .unwrap();
6920 let json = if bytes.is_empty() {
6921 serde_json::Value::Null
6922 } else {
6923 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6924 };
6925 (status, json)
6926 }
6927
6928 #[tokio::test]
6929 async fn bot_claims_returns_already_seated_for_a_member() {
6930 let state = bot_state("bot-secret-abcdef").await;
6934 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6935 .await
6936 .unwrap();
6937 let app = router(state.clone());
6938 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6939 assert_eq!(status, StatusCode::OK);
6940 assert_eq!(json["status"], "already_seated");
6941 assert_eq!(json["code"], "");
6942 assert_eq!(json["url"], "");
6943 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6945 .await
6946 .unwrap()
6947 .is_none());
6948 }
6949
6950 #[tokio::test]
6951 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6952 let state = bot_state("bot-secret-abcdef").await;
6956 let app = router(state.clone());
6957
6958 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6959 assert_eq!(s1, StatusCode::OK);
6960 assert_eq!(j1["status"], "minted");
6961 let code1 = j1["code"].as_str().unwrap().to_string();
6962
6963 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6964 assert_eq!(s2, StatusCode::OK);
6965 assert_eq!(j2["status"], "existing");
6966 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6967 assert_eq!(j2["url"], j1["url"], "same url returned");
6968
6969 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6971 }
6972
6973 #[tokio::test]
6974 async fn bot_claims_records_intended_did_at_mint() {
6975 let state = bot_state("bot-secret-abcdef").await;
6977 let app = router(state.clone());
6978 let (status, json) =
6979 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6980 assert_eq!(status, StatusCode::OK);
6981 let code = json["code"].as_str().unwrap();
6982 assert_eq!(
6983 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6984 .await
6985 .unwrap()
6986 .as_deref(),
6987 Some(code)
6988 );
6989 }
6990
6991 #[tokio::test]
6992 async fn bot_claims_concurrent_same_did_never_double_mints() {
6993 let state = bot_state("bot-secret-abcdef").await;
7000 let app = router(state.clone());
7001
7002 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7003 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7004 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7005
7006 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7007 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7008
7009 assert_eq!(
7011 store::count_active_codes(&state.db).await.unwrap(),
7012 1,
7013 "concurrent mints must not create two active codes"
7014 );
7015
7016 let ca = ja["code"].as_str().unwrap_or("");
7018 let cb = jb["code"].as_str().unwrap_or("");
7019 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7020 assert_eq!(ca, cb, "both callers must get the one minted code");
7021 for st in [&ja["status"], &jb["status"]] {
7024 let s = st.as_str().unwrap_or("");
7025 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7026 }
7027 }
7028
7029 #[tokio::test]
7030 async fn bot_claims_rejects_malformed_json_body() {
7031 let state = bot_state("bot-secret-abcdef").await;
7032 let app = router(state);
7033 let resp = app
7034 .oneshot(
7035 Request::builder()
7036 .method("POST")
7037 .uri("/bot/claims")
7038 .header("x-bot-secret", "bot-secret-abcdef")
7039 .header("content-type", "application/json")
7040 .body(Body::from("{not json"))
7041 .unwrap(),
7042 )
7043 .await
7044 .unwrap();
7045 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7046 }
7047
7048 #[tokio::test]
7049 async fn favicon_ico_served_at_root() {
7050 let state = test_state(&[]).await;
7053 let app = router(state);
7054 let resp = app
7055 .oneshot(
7056 Request::builder()
7057 .uri("/favicon.ico")
7058 .body(Body::empty())
7059 .unwrap(),
7060 )
7061 .await
7062 .unwrap();
7063 assert_eq!(resp.status(), StatusCode::OK);
7064 let ct = resp
7065 .headers()
7066 .get(header::CONTENT_TYPE)
7067 .unwrap()
7068 .to_str()
7069 .unwrap();
7070 assert!(
7071 ct.contains("icon") || ct.starts_with("image/"),
7072 "content-type = {ct}"
7073 );
7074 }
7075
7076 #[tokio::test]
7077 async fn login_without_invite_redirects_to_beta_redeem() {
7078 let state = test_state(&[]).await;
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 .body(Body::from("handle=alice.bsky.social"))
7088 .unwrap(),
7089 )
7090 .await
7091 .unwrap();
7092 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7093 assert_eq!(
7094 resp.headers().get(header::LOCATION).unwrap(),
7095 "/beta/redeem"
7096 );
7097 }
7098
7099 #[tokio::test]
7100 async fn login_with_valid_invite_cookie_starts_oauth() {
7101 let state = test_state(&[]).await;
7102 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7103 let cookie = cookie.split(';').next().unwrap().to_string();
7104 let app = router(state);
7105 let resp = app
7106 .oneshot(
7107 Request::builder()
7108 .method("POST")
7109 .uri("/login")
7110 .header("content-type", "application/x-www-form-urlencoded")
7111 .header(header::COOKIE, cookie)
7112 .body(Body::from("handle=alice.bsky.social"))
7113 .unwrap(),
7114 )
7115 .await
7116 .unwrap();
7117 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7119 let loc = resp
7120 .headers()
7121 .get(header::LOCATION)
7122 .unwrap()
7123 .to_str()
7124 .unwrap();
7125 assert!(loc.contains("/login"), "loc = {loc}");
7126 assert_ne!(loc, "/beta/redeem");
7127 }
7128
7129 async fn resolver_never(_handle: String) -> Option<String> {
7132 None
7133 }
7134
7135 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7137 move |_handle| std::future::ready(Some(did.to_string()))
7138 }
7139
7140 #[tokio::test]
7144 async fn may_start_oauth_honors_seat_via_resolved_handle() {
7145 let state = test_state(&["did:plc:admin"]).await;
7148 let headers = HeaderMap::new();
7149 assert!(
7150 may_start_oauth_with(
7151 &state,
7152 &headers,
7153 "admin.example",
7154 resolver_to("did:plc:admin")
7155 )
7156 .await,
7157 "a handle resolving to a seated DID must pass the gate"
7158 );
7159 }
7160
7161 #[tokio::test]
7165 async fn may_start_oauth_bounces_non_member_handle() {
7166 let state = test_state(&["did:plc:admin"]).await;
7167 let headers = HeaderMap::new();
7168 assert!(
7169 !may_start_oauth_with(
7170 &state,
7171 &headers,
7172 "rando.example",
7173 resolver_to("did:plc:rando")
7174 )
7175 .await,
7176 "a resolved DID with no seat must be bounced"
7177 );
7178 }
7179
7180 #[tokio::test]
7183 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7184 let state = test_state(&["did:plc:admin"]).await;
7185 let headers = HeaderMap::new();
7186 assert!(
7187 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7188 "an unresolvable handle must fail closed"
7189 );
7190 }
7191
7192 #[tokio::test]
7196 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7197 let state = test_state(&[]).await;
7198 let did = "did:plc:member";
7199 store::grant_access(&state.db, did, Some("member.example"), "test", None)
7200 .await
7201 .unwrap();
7202 let cookie = session_cookie(&state, did, Some("member.example"));
7203 let mut headers = HeaderMap::new();
7204 headers.insert(header::COOKIE, cookie.parse().unwrap());
7205 assert!(
7206 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7207 "a seated session cookie must pass without resolution"
7208 );
7209 }
7210
7211 #[tokio::test]
7213 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7214 let state = test_state(&[]).await;
7215 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7216 let cookie = cookie.split(';').next().unwrap().to_string();
7217 let mut headers = HeaderMap::new();
7218 headers.insert(header::COOKIE, cookie.parse().unwrap());
7219 assert!(
7220 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7221 "a valid invite cookie must pass without resolution"
7222 );
7223 }
7224
7225 #[tokio::test]
7226 async fn admin_mint_requires_admin_seed_did() {
7227 let state = test_state(&["did:plc:admin"]).await;
7228 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7230 .await
7231 .unwrap();
7232 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7233 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7235 let app = router(state);
7236
7237 let forbidden = app
7238 .clone()
7239 .oneshot(
7240 Request::builder()
7241 .method("POST")
7242 .uri("/admin/invites?n=2")
7243 .header(header::COOKIE, rando_cookie)
7244 .body(Body::empty())
7245 .unwrap(),
7246 )
7247 .await
7248 .unwrap();
7249 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7250
7251 let ok = app
7252 .oneshot(
7253 Request::builder()
7254 .method("POST")
7255 .uri("/admin/invites?n=2")
7256 .header(header::COOKIE, admin_cookie)
7257 .body(Body::empty())
7258 .unwrap(),
7259 )
7260 .await
7261 .unwrap();
7262 assert_eq!(ok.status(), StatusCode::OK);
7263 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7264 .await
7265 .unwrap();
7266 let body = String::from_utf8(bytes.to_vec()).unwrap();
7267 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7268 assert_eq!(minted.len(), 2);
7269 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7270 }
7271
7272 #[tokio::test]
7273 async fn admin_mint_unauthenticated_is_401() {
7274 let state = test_state(&["did:plc:admin"]).await;
7275 let app = router(state);
7276 let resp = app
7277 .oneshot(
7278 Request::builder()
7279 .method("POST")
7280 .uri("/admin/invites")
7281 .body(Body::empty())
7282 .unwrap(),
7283 )
7284 .await
7285 .unwrap();
7286 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7287 }
7288
7289 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7292 let db = store::init_url("sqlite::memory:").await.unwrap();
7293 store::record_network_stat(
7294 &db,
7295 &store::NetworkStat {
7296 key: store::ADOPTION_STAT_KEY.to_string(),
7297 source: "https://relay1.us-west.bsky.network".to_string(),
7298 value: repos,
7299 truncated,
7300 observed_at: "2026-08-13T04:05:06Z".to_string(),
7301 },
7302 )
7303 .await
7304 .unwrap();
7305 let config = Config {
7306 cookie_secret: "test-cookie-secret-000".to_string(),
7307 show_adoption: true,
7308 ..Config::default()
7309 };
7310 AppState::new(config, db).unwrap()
7311 }
7312
7313 async fn about_body(state: AppState) -> String {
7314 let resp = router(state)
7315 .oneshot(
7316 Request::builder()
7317 .uri("/about")
7318 .body(Body::empty())
7319 .unwrap(),
7320 )
7321 .await
7322 .unwrap();
7323 assert_eq!(resp.status(), StatusCode::OK);
7324 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7325 .await
7326 .unwrap();
7327 String::from_utf8(bytes.to_vec()).unwrap()
7328 }
7329
7330 #[tokio::test]
7332 async fn about_omits_adoption_line_by_default() {
7333 let state = test_state(&[]).await;
7334 assert!(!state.config.show_adoption);
7335 let body = about_body(state).await;
7336 assert!(
7337 !body.contains("atproto network"),
7338 "the adoption line must not render by default"
7339 );
7340 }
7341
7342 #[tokio::test]
7343 async fn about_renders_adoption_line_when_enabled() {
7344 let body = about_body(adoption_state(7_318, false).await).await;
7352 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7355 assert!(
7356 flat.contains("7318 accounts on the atproto network hold"),
7357 "the count did not render in its own sentence: {flat}",
7358 );
7359 assert!(
7360 body.contains("accounts on the atproto network hold"),
7361 "{body}"
7362 );
7363 assert!(
7364 body.contains("2026-08-13"),
7365 "the observation date must render"
7366 );
7367 assert!(
7368 body.contains("lower bound"),
7369 "the non-archival caveat must ride along with the number"
7370 );
7371 assert!(
7372 !body.contains("At least"),
7373 "an untruncated count is exact-ish"
7374 );
7375 }
7376
7377 #[tokio::test]
7379 async fn about_adoption_line_is_singular_at_one() {
7380 let body = about_body(adoption_state(1, false).await).await;
7381 assert!(
7382 body.contains("account on the atproto network holds"),
7383 "{body}"
7384 );
7385 }
7386
7387 #[tokio::test]
7389 async fn about_adoption_line_says_at_least_when_truncated() {
7390 let body = about_body(adoption_state(25_000, true).await).await;
7391 assert!(body.contains("At least"), "{body}");
7392 }
7393
7394 #[tokio::test]
7396 async fn about_omits_line_when_enabled_with_no_observation() {
7397 let db = store::init_url("sqlite::memory:").await.unwrap();
7398 let config = Config {
7399 cookie_secret: "test-cookie-secret-000".to_string(),
7400 show_adoption: true,
7401 ..Config::default()
7402 };
7403 let body = about_body(AppState::new(config, db).unwrap()).await;
7404 assert!(!body.contains("atproto network"));
7405 }
7406
7407 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7418 let db = store::init_url("sqlite::memory:").await.unwrap();
7419 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7420 let config = Config {
7421 allowed_dids: vec![did.to_string()],
7422 cookie_secret: "test-cookie-secret-000".to_string(),
7423 beta_cap: 3,
7424 standard_site,
7425 ..Config::default()
7426 };
7427 AppState::new(config, db).unwrap()
7428 }
7429
7430 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7432 let cookie = session_cookie(&state, did, Some("reader.example"));
7433 let resp = router(state)
7434 .oneshot(
7435 Request::builder()
7436 .uri(path)
7437 .header(header::COOKIE, cookie)
7438 .body(Body::empty())
7439 .unwrap(),
7440 )
7441 .await
7442 .unwrap();
7443 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7444 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7445 .await
7446 .unwrap();
7447 String::from_utf8(bytes.to_vec()).unwrap()
7448 }
7449
7450 async fn public_body(state: AppState, path: &str) -> String {
7452 let resp = router(state)
7453 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7454 .await
7455 .unwrap();
7456 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7457 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7458 .await
7459 .unwrap();
7460 String::from_utf8(bytes.to_vec()).unwrap()
7461 }
7462
7463 fn feed_url_input(body: &str) -> &str {
7465 let start = body
7466 .find("id=\"feed-url\"")
7467 .and_then(|i| body[..i].rfind("<input"))
7468 .expect("the subscribe form's URL input renders");
7469 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7470 &body[start..end]
7471 }
7472
7473 #[tokio::test]
7476 async fn manage_hints_at_publications_when_the_flag_is_on() {
7477 let did = "did:plc:reader";
7478 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7479 assert!(
7480 body.contains("at://did:plc:…/site.standard.publication/…"),
7481 "the DID form must be shown: {body}"
7482 );
7483 assert!(
7484 body.contains("at://alice.example.com/site.standard.publication/…"),
7485 "the handle form must be shown: {body}"
7486 );
7487 }
7488
7489 #[tokio::test]
7495 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7496 let did = "did:plc:reader";
7497 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7498 let input = feed_url_input(&body);
7499 assert!(
7500 input.contains("type=\"text\""),
7501 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7502 );
7503 assert!(
7504 input.contains("inputmode=\"url\""),
7505 "the URL keyboard is still wanted: {input}"
7506 );
7507 }
7508
7509 #[tokio::test]
7515 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7516 let did = "did:plc:reader";
7517 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7518 let input = feed_url_input(&body);
7519 assert!(
7520 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7521 "the text input must keep a scheme check: {input}"
7522 );
7523 }
7524
7525 #[tokio::test]
7528 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7529 let did = "did:plc:reader";
7530 let state = standard_site_state(false, did).await;
7531 assert!(!state.config.standard_site);
7532 let page = signed_in_body(state, "/manage", did).await;
7533 let body = &page[page.find("</head>").expect("a <head>")..];
7538 assert!(
7539 !body.contains("site.standard.publication"),
7540 "a refused form must not be advertised: {body}"
7541 );
7542 let above_footer = body
7547 .split("<footer")
7548 .next()
7549 .expect("split yields at least one piece");
7550 assert!(
7551 above_footer.contains("id=\"feed-url\""),
7552 "the form must be above the footer: {body}"
7553 );
7554 assert!(
7555 !above_footer.contains("standard.site"),
7556 "a refused form must not be advertised: {body}"
7557 );
7558 assert!(
7559 feed_url_input(body).contains("type=\"url\""),
7560 "with the flag off the input is unchanged"
7561 );
7562 }
7563
7564 #[tokio::test]
7567 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7568 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7569 assert!(body.contains("standard.site"), "{body}");
7570 assert!(
7571 body.contains("at://did:plc:…/site.standard.publication/…"),
7572 "the landing page must show the DID form: {body}"
7573 );
7574 assert!(
7575 body.contains("at://alice.example.com/site.standard.publication/…"),
7576 "the landing page must show the handle form: {body}"
7577 );
7578 }
7579
7580 #[tokio::test]
7584 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7585 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7586 assert!(body.contains("standard.site"), "{body}");
7587 assert!(
7588 !body.contains("at://did:plc:…/site.standard.publication/…"),
7589 "no paste instructions with the flag off: {body}"
7590 );
7591 assert!(
7592 !body.contains("at://alice.example.com/site.standard.publication/…"),
7593 "no paste instructions with the flag off: {body}"
7594 );
7595 assert!(
7596 body.contains("isn't accepting new publication subscriptions"),
7597 "the page must say the form is closed here: {body}"
7598 );
7599 }
7600
7601 #[tokio::test]
7603 async fn about_describes_publications_and_how_to_subscribe_when_on() {
7604 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7605 assert!(body.contains("site.standard.publication"), "{body}");
7606 assert!(body.contains("site.standard.document"), "{body}");
7607 assert!(
7608 body.contains("at://did:plc:…/site.standard.publication/…"),
7609 "{body}"
7610 );
7611 assert!(
7612 body.contains("at://alice.example.com/site.standard.publication/…"),
7613 "{body}"
7614 );
7615 }
7616
7617 #[tokio::test]
7619 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7620 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7621 assert!(body.contains("site.standard.publication"), "{body}");
7622 assert!(
7623 !body.contains("at://did:plc:…/site.standard.publication/…"),
7624 "no paste instructions with the flag off: {body}"
7625 );
7626 assert!(
7627 !body.contains("at://alice.example.com/site.standard.publication/…"),
7628 "no paste instructions with the flag off: {body}"
7629 );
7630 assert!(
7631 body.contains("isn't accepting new publication subscriptions"),
7632 "{body}"
7633 );
7634 }
7635
7636 #[tokio::test]
7644 async fn standard_site_page_renders_signed_out() {
7645 let body = public_body(test_state(&[]).await, "/standard-site").await;
7646 assert!(body.contains("site.standard.publication"), "{body}");
7647 assert!(body.contains("site.standard.document"), "{body}");
7648 assert!(
7649 body.contains("<title>standard.site — FeatherReader</title>"),
7650 "{body}"
7651 );
7652 }
7653
7654 #[tokio::test]
7657 async fn standard_site_page_tells_how_to_subscribe_when_on() {
7658 let body = public_body(
7659 standard_site_state(true, "did:plc:x").await,
7660 "/standard-site",
7661 )
7662 .await;
7663 assert!(
7664 body.contains("at://did:plc:…/site.standard.publication/…"),
7665 "the DID form must be shown: {body}"
7666 );
7667 assert!(
7668 body.contains("at://alice.example.com/site.standard.publication/…"),
7669 "the handle form must be shown: {body}"
7670 );
7671 assert!(
7672 body.contains("resolved to its DID"),
7673 "the handle resolution must be stated: {body}"
7674 );
7675 assert!(
7676 !body.contains("isn't accepting new publication subscriptions"),
7677 "{body}"
7678 );
7679 }
7680
7681 #[tokio::test]
7685 async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7686 let state = standard_site_state(false, "did:plc:x").await;
7687 assert!(!state.config.standard_site);
7688 let body = public_body(state, "/standard-site").await;
7689 assert!(body.contains("site.standard.publication"), "{body}");
7690 assert!(
7691 !body.contains("at://did:plc:…/site.standard.publication/…"),
7692 "no paste instructions with the flag off: {body}"
7693 );
7694 assert!(
7695 !body.contains("at://alice.example.com/site.standard.publication/…"),
7696 "no paste instructions with the flag off: {body}"
7697 );
7698 assert!(
7699 body.contains("isn't accepting new publication subscriptions"),
7700 "the page must say the form is closed here: {body}"
7701 );
7702 assert!(
7703 body.contains("already follows are still read"),
7704 "stored publications are polled whatever the flag says: {body}"
7705 );
7706 }
7707
7708 #[tokio::test]
7711 async fn releases_callout_links_the_release_pages() {
7712 for path in ["/standard-site", "/"] {
7713 let body = public_body(test_state(&[]).await, path).await;
7714 for tag in ["v0.4.1", "v0.4.0"] {
7715 let href = format!(
7716 "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7717 );
7718 assert!(body.contains(&href), "{path} must link {tag}: {body}");
7719 }
7720 assert!(
7721 body.contains(
7722 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7723 ),
7724 "{path} must link the changelog: {body}"
7725 );
7726 }
7727 }
7728
7729 #[tokio::test]
7733 async fn landing_about_and_footer_link_the_standard_site_page() {
7734 for path in ["/", "/about", "/privacy"] {
7735 let body = public_body(test_state(&[]).await, path).await;
7736 assert!(
7737 body.contains("href=\"/standard-site\""),
7738 "{path} must link the feature page: {body}"
7739 );
7740 }
7741 }
7742
7743 #[test]
7747 fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7748 assert!(!RELEASES.is_empty());
7749 let parse = |v: &str| -> Vec<u32> {
7750 v.split('.')
7751 .map(|p| p.parse::<u32>().expect("a numeric version part"))
7752 .collect()
7753 };
7754 for pair in RELEASES.windows(2) {
7755 assert!(
7756 parse(pair[0].version) > parse(pair[1].version),
7757 "{} must come before {}",
7758 pair[0].version,
7759 pair[1].version
7760 );
7761 }
7762 for r in RELEASES {
7763 assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7764 assert!(
7765 chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7766 "{} is not YYYY-MM-DD",
7767 r.date
7768 );
7769 assert!(!r.summary.trim().is_empty());
7770 assert!(!r.summary.contains('<'), "the summary is plain text");
7771 assert_eq!(
7772 r.url(),
7773 format!(
7774 "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7775 r.version
7776 )
7777 );
7778 }
7779 let latest = &RELEASES[0];
7782 assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7783 assert_eq!(
7784 latest.changelog_url(),
7785 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#045--2026-10-06"
7786 );
7787 }
7788
7789 #[tokio::test]
7791 async fn standard_site_page_is_publicly_cacheable() {
7792 let resp = router(test_state(&[]).await)
7793 .oneshot(
7794 Request::builder()
7795 .uri("/standard-site")
7796 .body(Body::empty())
7797 .unwrap(),
7798 )
7799 .await
7800 .unwrap();
7801 assert_eq!(resp.status(), StatusCode::OK);
7802 assert_eq!(
7803 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7804 "public, max-age=300"
7805 );
7806 }
7807
7808 #[tokio::test]
7809 async fn cache_control_public_on_about_no_store_on_authed() {
7810 let state = test_state(&["did:plc:admin"]).await;
7811 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7812 let app = router(state);
7813
7814 let about = app
7816 .clone()
7817 .oneshot(
7818 Request::builder()
7819 .uri("/about")
7820 .body(Body::empty())
7821 .unwrap(),
7822 )
7823 .await
7824 .unwrap();
7825 assert_eq!(
7826 about.headers().get(header::CACHE_CONTROL).unwrap(),
7827 "public, max-age=300"
7828 );
7829 assert_eq!(
7835 about.headers()["content-security-policy"],
7836 EXPECTED_CSP,
7837 "the CSP is not the policy the router promises"
7838 );
7839 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7840
7841 for path in ["/privacy", "/terms"] {
7843 let resp = app
7844 .clone()
7845 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7846 .await
7847 .unwrap();
7848 assert_eq!(resp.status(), StatusCode::OK);
7849 assert_eq!(
7850 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7851 "public, max-age=300",
7852 "{path} should be publicly cacheable"
7853 );
7854 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7856 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7857 }
7858
7859 let login = app
7861 .clone()
7862 .oneshot(
7863 Request::builder()
7864 .uri("/login")
7865 .body(Body::empty())
7866 .unwrap(),
7867 )
7868 .await
7869 .unwrap();
7870 assert_eq!(
7871 login.headers().get(header::CACHE_CONTROL).unwrap(),
7872 "public, max-age=300"
7873 );
7874
7875 let home = app
7877 .oneshot(
7878 Request::builder()
7879 .uri("/")
7880 .header(header::COOKIE, admin_cookie)
7881 .body(Body::empty())
7882 .unwrap(),
7883 )
7884 .await
7885 .unwrap();
7886 assert_eq!(
7887 home.headers().get(header::CACHE_CONTROL).unwrap(),
7888 "no-store"
7889 );
7890 }
7891
7892 fn head(body: &str) -> &str {
7901 let end = body.find("</head>").expect("a <head>");
7902 &body[..end]
7903 }
7904
7905 fn meta(head: &str, attr: &str) -> Option<String> {
7908 let tag_start = head.find(attr)?;
7909 let rest = &head[tag_start..];
7910 let tag_end = rest.find('>')?;
7911 let tag = &rest[..tag_end];
7912 let content = tag.find("content=\"")? + "content=\"".len();
7913 let close = tag[content..].find('"')?;
7914 Some(tag[content..content + close].to_string())
7915 }
7916
7917 async fn production_origin_state() -> AppState {
7921 let db = store::init_url("sqlite::memory:").await.unwrap();
7922 store::ensure_seed(&db, &["did:plc:admin".to_string()])
7923 .await
7924 .unwrap();
7925 let config = Config {
7926 allowed_dids: vec!["did:plc:admin".to_string()],
7927 cookie_secret: "test-cookie-secret-000".to_string(),
7928 beta_cap: 3,
7929 public_url: "https://feather-reader.com".to_string(),
7930 ..Config::default()
7931 };
7932 AppState::new(config, db).unwrap()
7933 }
7934
7935 #[tokio::test]
7938 async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7939 let landing = public_body(production_origin_state().await, "/").await;
7940 let about = public_body(production_origin_state().await, "/about").await;
7941 let (lh, ah) = (head(&landing), head(&about));
7942
7943 assert_eq!(
7944 meta(lh, "property=\"og:title\"").as_deref(),
7945 Some("FeatherReader — read, quietly"),
7946 "{lh}"
7947 );
7948 assert_eq!(
7949 meta(ah, "property=\"og:title\"").as_deref(),
7950 Some("About — FeatherReader"),
7951 "{ah}"
7952 );
7953 for (h, path) in [(lh, "/"), (ah, "/about")] {
7954 let url = format!("https://feather-reader.com{path}");
7955 assert_eq!(
7956 meta(h, "property=\"og:url\"").as_deref(),
7957 Some(url.as_str())
7958 );
7959 assert!(
7960 h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7961 "{path} must carry a canonical link: {h}"
7962 );
7963 let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7964 assert!(
7965 image.starts_with("https://feather-reader.com/static/"),
7966 "{path}: og:image must be absolute on the public origin, got {image:?}"
7967 );
7968 assert_eq!(
7969 meta(h, "name=\"twitter:card\"").as_deref(),
7970 Some("summary_large_image")
7971 );
7972 assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7973 assert_eq!(
7974 meta(h, "property=\"og:site_name\"").as_deref(),
7975 Some("FeatherReader")
7976 );
7977 let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7978 assert!(!description.is_empty(), "{path}: og:description is empty");
7979 assert_eq!(
7980 meta(h, "name=\"description\"").as_deref(),
7981 Some(description.as_str()),
7982 "{path}: the meta description and og:description must agree"
7983 );
7984 }
7985 assert_ne!(
7986 meta(lh, "property=\"og:description\""),
7987 meta(ah, "property=\"og:description\""),
7988 "the landing page and /about must not share a description"
7989 );
7990 }
7991
7992 #[tokio::test]
7994 async fn card_urls_follow_the_configured_public_url() {
7995 let db = store::init_url("sqlite::memory:").await.unwrap();
7996 store::ensure_seed(&db, &[]).await.unwrap();
7997 let config = Config {
7998 cookie_secret: "test-cookie-secret-000".to_string(),
7999 public_url: "https://reader.example.org".to_string(),
8000 ..Config::default()
8001 };
8002 let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8003 let h = head(&body);
8004 assert_eq!(
8005 meta(h, "property=\"og:url\"").as_deref(),
8006 Some("https://reader.example.org/privacy")
8007 );
8008 assert_eq!(
8009 meta(h, "property=\"og:image\"").as_deref(),
8010 Some("https://reader.example.org/static/social-card.png")
8011 );
8012 }
8013
8014 #[tokio::test]
8017 async fn public_pages_each_carry_their_own_description() {
8018 let paths = [
8019 "/",
8020 "/about",
8021 "/privacy",
8022 "/terms",
8023 "/stats",
8024 "/standard-site",
8025 "/login",
8026 "/beta/redeem",
8027 ];
8028 let mut seen = std::collections::HashSet::new();
8029 for path in paths {
8030 let body = public_body(production_origin_state().await, path).await;
8031 let h = head(&body);
8032 let description = meta(h, "name=\"description\"").unwrap_or_default();
8033 assert!(!description.is_empty(), "{path} has no description: {h}");
8034 assert!(
8035 seen.insert(description.clone()),
8036 "{path} repeats another page's description: {description:?}"
8037 );
8038 assert_eq!(
8039 meta(h, "property=\"og:url\"").as_deref(),
8040 Some(format!("https://feather-reader.com{path}").as_str()),
8041 "{path}"
8042 );
8043 assert!(
8044 !h.contains("name=\"robots\""),
8045 "{path} is public and must not be noindex: {h}"
8046 );
8047 }
8048 }
8049
8050 #[tokio::test]
8053 async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8054 let landing = public_body(production_origin_state().await, "/").await;
8055 let h = head(&landing);
8056 let image = meta(h, "property=\"og:image\"").unwrap();
8057 let path = image.strip_prefix("https://feather-reader.com").unwrap();
8058 let width: u32 = meta(h, "property=\"og:image:width\"")
8059 .unwrap()
8060 .parse()
8061 .unwrap();
8062 let height: u32 = meta(h, "property=\"og:image:height\"")
8063 .unwrap()
8064 .parse()
8065 .unwrap();
8066 assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8067 assert_eq!(
8068 meta(h, "property=\"og:image:type\"").as_deref(),
8069 Some("image/png")
8070 );
8071 assert!(
8072 !meta(h, "property=\"og:image:alt\"")
8073 .unwrap_or_default()
8074 .is_empty(),
8075 "the image needs alt text"
8076 );
8077
8078 let resp = router(production_origin_state().await)
8079 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8080 .await
8081 .unwrap();
8082 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8083 assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8084 assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8085 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8086 .await
8087 .expect("the image is under 1 MB");
8088 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8089 let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8091 assert_eq!(
8092 (be(16), be(20)),
8093 (width, height),
8094 "the PNG's own dimensions must match the tags"
8095 );
8096 }
8097
8098 #[tokio::test]
8101 async fn private_pages_keep_user_data_out_of_the_card() {
8102 for path in ["/", "/manage"] {
8103 let state = production_origin_state().await;
8104 let body = signed_in_body(state, path, "did:plc:admin").await;
8105 let h = head(&body);
8106 assert!(
8107 h.contains("<meta name=\"robots\" content=\"noindex\""),
8108 "{path}: a private view must be noindex: {h}"
8109 );
8110 assert_eq!(
8111 meta(h, "property=\"og:title\"").as_deref(),
8112 Some("FeatherReader — read, quietly"),
8113 "{path}: the card of a private view is the site's generic one"
8114 );
8115 assert_eq!(
8116 meta(h, "property=\"og:url\"").as_deref(),
8117 Some("https://feather-reader.com/"),
8118 "{path}: og:url of a private view is the front door, not the private path"
8119 );
8120 for private in ["reader.example", "did:plc:admin"] {
8121 assert!(
8122 !h.contains(private),
8123 "{path}: {private:?} must not reach <head>: {h}"
8124 );
8125 }
8126 }
8127 }
8128
8129 #[tokio::test]
8130 async fn beta_redeem_page_renders() {
8131 let state = test_state(&[]).await;
8132 let app = router(state);
8133 let resp = app
8134 .oneshot(
8135 Request::builder()
8136 .uri("/beta/redeem")
8137 .body(Body::empty())
8138 .unwrap(),
8139 )
8140 .await
8141 .unwrap();
8142 assert_eq!(resp.status(), StatusCode::OK);
8143 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8144 .await
8145 .unwrap();
8146 let html = String::from_utf8(bytes.to_vec()).unwrap();
8147 assert!(html.contains("Invite code"));
8148 assert!(html.contains("/beta/redeem"));
8149 }
8150
8151 #[tokio::test]
8152 async fn rate_limit_returns_429_after_burst() {
8153 let db = store::init_url("sqlite::memory:").await.unwrap();
8156 store::ensure_seed(&db, &[]).await.unwrap();
8157 let config = Config {
8158 cookie_secret: "test-cookie-secret-000".to_string(),
8159 beta_cap: 3,
8160 trusted_ip_header: Some("cf-connecting-ip".to_string()),
8161 ..Config::default()
8162 };
8163 let state = AppState::new(config, db).unwrap();
8164 let app = router(state);
8165 let mut saw_429 = false;
8169 for _ in 0..(RATE_BURST as usize + 5) {
8170 let resp = app
8171 .clone()
8172 .oneshot(
8173 Request::builder()
8174 .method("POST")
8175 .uri("/beta/redeem")
8176 .header("content-type", "application/x-www-form-urlencoded")
8177 .header("cf-connecting-ip", "203.0.113.200")
8178 .body(Body::from("code=FEATHER-NOPENOPE"))
8179 .unwrap(),
8180 )
8181 .await
8182 .unwrap();
8183 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8184 saw_429 = true;
8185 break;
8186 }
8187 }
8188 assert!(saw_429, "expected a 429 after exhausting the burst");
8189 }
8190
8191 #[tokio::test]
8204 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8205 let state = test_state(&[]).await;
8206 assert!(
8207 state.config.trusted_ip_header.is_none(),
8208 "no proxy header is trusted here"
8209 );
8210 let app = router(state);
8211 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8212 let mut saw_429 = false;
8213 for i in 0..(RATE_BURST as usize + 5) {
8214 let forged = format!("10.9.8.{}", i % 250);
8215 let resp = app
8216 .clone()
8217 .oneshot(
8218 Request::builder()
8219 .method("POST")
8220 .uri("/beta/redeem")
8221 .header("content-type", "application/x-www-form-urlencoded")
8222 .header("x-forwarded-for", forged)
8223 .extension(axum::extract::ConnectInfo(peer))
8224 .body(Body::from("code=FEATHER-NOPENOPE"))
8225 .unwrap(),
8226 )
8227 .await
8228 .unwrap();
8229 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8230 saw_429 = true;
8231 break;
8232 }
8233 }
8234 assert!(
8235 saw_429,
8236 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8237 );
8238 }
8239
8240 #[tokio::test]
8249 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8250 let did = "did:plc:privateadder";
8251 let state = test_state_with_caps(did, 0, 0).await;
8252 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8253 let port: u16 = base
8254 .trim_end_matches('/')
8255 .rsplit(':')
8256 .next()
8257 .unwrap()
8258 .parse()
8259 .unwrap();
8260 crate::net::test_host_override(
8261 "private-add.test",
8262 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8263 );
8264 let cookie = session_cookie(&state, did, None);
8265 let resp = router(state.clone())
8266 .oneshot(
8267 Request::builder()
8268 .method("POST")
8269 .uri("/subscriptions")
8270 .header(header::COOKIE, cookie)
8271 .header("content-type", "application/x-www-form-urlencoded")
8272 .body(Body::from(format!(
8273 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8274 )))
8275 .unwrap(),
8276 )
8277 .await
8278 .unwrap();
8279 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8280 let loc = resp
8281 .headers()
8282 .get(header::LOCATION)
8283 .unwrap()
8284 .to_str()
8285 .unwrap();
8286 assert!(loc.contains("Private"), "not refused as private: {loc}");
8287 assert_eq!(
8288 hits.load(std::sync::atomic::Ordering::SeqCst),
8289 0,
8290 "the private feed was FETCHED before being refused"
8291 );
8292 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8293 }
8294
8295 #[tokio::test]
8300 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8301 let did = "did:plc:renamer4";
8302 let (sidecar, bodies) = spawn_logging_sidecar().await;
8303 let state = test_state_with_sidecar(&[did], &sidecar).await;
8304 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8305 let opml = format!(
8306 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8307 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8308 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8309 </body></opml>"
8310 );
8311 let (ct, body) = opml_multipart(opml.as_bytes());
8312 let cookie = session_cookie(&state, did, None);
8313 let resp = router(state.clone())
8314 .oneshot(
8315 Request::builder()
8316 .method("POST")
8317 .uri("/opml")
8318 .header(header::COOKIE, cookie)
8319 .header("content-type", ct)
8320 .body(Body::from(body))
8321 .unwrap(),
8322 )
8323 .await
8324 .unwrap();
8325 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8326 let loc = resp
8327 .headers()
8328 .get(header::LOCATION)
8329 .unwrap()
8330 .to_str()
8331 .unwrap();
8332 assert!(
8333 loc.contains("skipped%20as%20private"),
8334 "not reported as skipped: {loc}"
8335 );
8336 assert!(store::get_feed_by_url(&state.db, tokened)
8337 .await
8338 .unwrap()
8339 .is_none());
8340 let sent = bodies.lock().unwrap().join("\n");
8341 assert!(
8342 sent.contains("public.example"),
8343 "the public feed was not written: {sent}"
8344 );
8345 assert!(
8346 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8347 "the secret was PUBLISHED to the PDS: {sent}"
8348 );
8349 }
8350
8351 #[tokio::test]
8355 async fn get_login_without_a_seat_is_refused() {
8356 let state = test_state(&[]).await;
8357 let resp = router(state)
8358 .oneshot(
8359 Request::builder()
8360 .method("GET")
8361 .uri("/login?handle=alice.bsky.social")
8362 .body(Body::empty())
8363 .unwrap(),
8364 )
8365 .await
8366 .unwrap();
8367 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8368 assert_eq!(
8369 resp.headers().get(header::LOCATION).unwrap(),
8370 "/beta/redeem"
8371 );
8372 }
8373
8374 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8378 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8379 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8380 let addr = listener.local_addr().unwrap();
8381 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8382 let sink = log.clone();
8383 tokio::spawn(async move {
8384 loop {
8385 let Ok((mut sock, _)) = listener.accept().await else {
8386 break;
8387 };
8388 let mut raw: Vec<u8> = Vec::new();
8389 let mut chunk = [0u8; 4096];
8390 let text = loop {
8391 let Ok(n) = sock.read(&mut chunk).await else {
8392 break String::new();
8393 };
8394 if n == 0 {
8395 break String::from_utf8_lossy(&raw).to_string();
8396 }
8397 raw.extend_from_slice(&chunk[..n]);
8398 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8399 continue;
8400 };
8401 let (head, body) = raw.split_at(split + 4);
8402 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8403 let (k, v) = l.split_once(':')?;
8404 k.eq_ignore_ascii_case("content-length")
8405 .then(|| v.trim().parse::<usize>().ok())?
8406 });
8407 if want.is_none_or(|w| body.len() >= w) {
8408 break String::from_utf8_lossy(&raw).to_string();
8409 }
8410 };
8411 let path = text
8412 .lines()
8413 .next()
8414 .and_then(|l| l.split_whitespace().nth(1))
8415 .unwrap_or("")
8416 .to_string();
8417 let body_text = text
8418 .split_once("\r\n\r\n")
8419 .map(|(_, b)| b)
8420 .unwrap_or("")
8421 .to_string();
8422 sink.lock().unwrap().push(format!("{path} {body_text}"));
8423 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();
8424 let resp = format!(
8425 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8426 body.len(),
8427 body
8428 );
8429 let _ = sock.write_all(resp.as_bytes()).await;
8430 let _ = sock.flush().await;
8431 }
8432 });
8433 (format!("http://{addr}"), log)
8434 }
8435
8436 #[tokio::test]
8442 async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8443 use crate::readstate::tests as rs;
8444 for backend in [
8445 crate::metrics::Backend::Sidecar,
8446 crate::metrics::Backend::Rust,
8447 ] {
8448 let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8449 let state = rs::state_on(backend, &fake).await;
8450 for i in 0..250 {
8451 rs::mark_read(&state, i, "1").await;
8452 }
8453 fake.lock().unwrap().drop_call = Some(2);
8454
8455 flush_before_revoke(&state, rs::DID).await;
8456
8457 let order = rs::send_order(250);
8458 let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8459 for &i in landed {
8460 let c = rs::cursor(&state, i).await;
8461 assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8462 }
8463 for &i in rest {
8464 let c = rs::cursor(&state, i).await;
8465 assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8466 }
8467 assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8468 }
8469 }
8470
8471 #[tokio::test]
8480 async fn signing_out_flushes_before_it_revokes_through_the_route() {
8481 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8482 let (sidecar, log) = spawn_logging_sidecar().await;
8483 let state = test_state_with_sidecar(&[did], &sidecar).await;
8484 crate::store::upsert_cursor(
8485 &state.db,
8486 &crate::store::ReadCursor {
8487 did: did.to_string(),
8488 feed_url: "https://example.com/feed.xml".into(),
8489 read_through: None,
8490 read_ids: "[\"1\"]".into(),
8491 unread_ids: "[]".into(),
8492 dirty: true,
8493 pds_created: false,
8494 updated_at: "2026-09-13T21:22:40Z".into(),
8495 },
8496 )
8497 .await
8498 .unwrap();
8499 let cookie = session_cookie(&state, did, None);
8500 let resp = router(state.clone())
8501 .oneshot(
8502 Request::builder()
8503 .method("POST")
8504 .uri("/logout")
8505 .header(header::COOKIE, cookie)
8506 .body(Body::empty())
8507 .unwrap(),
8508 )
8509 .await
8510 .unwrap();
8511 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8512
8513 let entries = log.lock().unwrap().clone();
8514 let flush = entries
8515 .iter()
8516 .position(|e| e.starts_with("/internal/repo "));
8517 let revoke = entries
8518 .iter()
8519 .position(|e| e.starts_with("/internal/revoke "));
8520 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8521 assert!(
8522 flush.is_some(),
8523 "sign-out did not attempt a flush before revoking: {entries:?}"
8524 );
8525 assert!(
8526 flush < revoke,
8527 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8528 );
8529 }
8530
8531 const EXPECTED_CSP: &str = "default-src 'self'; \
8535 script-src 'self'; \
8536 style-src 'self' 'unsafe-inline'; \
8537 img-src 'self' https: data:; \
8538 font-src 'self'; \
8539 connect-src 'self'; \
8540 form-action 'self'; \
8541 base-uri 'self'; \
8542 frame-ancestors 'none'; \
8543 object-src 'none'";
8544
8545 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8548 let boundary = "----featherreadertestboundary";
8549 let mut body = Vec::new();
8550 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8551 body.extend_from_slice(
8552 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8553 );
8554 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8555 body.extend_from_slice(payload);
8556 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8557 (format!("multipart/form-data; boundary={boundary}"), body)
8558 }
8559
8560 #[tokio::test]
8561 async fn opml_import_oversize_upload_returns_413() {
8562 let state = test_state(&["did:plc:admin"]).await;
8563 let cookie = session_cookie(&state, "did:plc:admin", None);
8564 let app = router(state);
8565
8566 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8568 let (content_type, body) = opml_multipart(&payload);
8569
8570 let resp = app
8571 .oneshot(
8572 Request::builder()
8573 .method("POST")
8574 .uri("/opml")
8575 .header("content-type", content_type)
8576 .header(header::COOKIE, cookie)
8577 .body(Body::from(body))
8578 .unwrap(),
8579 )
8580 .await
8581 .unwrap();
8582 assert_eq!(
8583 resp.status(),
8584 StatusCode::PAYLOAD_TOO_LARGE,
8585 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8586 );
8587 }
8588
8589 #[tokio::test]
8600 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8601 let state = test_state(&["did:plc:admin"]).await;
8602 let cookie = session_cookie(&state, "did:plc:admin", None);
8603 let app = router(state);
8604
8605 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8607 let (content_type, body) = opml_multipart(&payload);
8608
8609 let resp = app
8610 .oneshot(
8611 Request::builder()
8612 .method("POST")
8613 .uri("/opml")
8614 .header("content-type", content_type)
8615 .header(header::COOKIE, cookie)
8616 .body(Body::from(body))
8617 .unwrap(),
8618 )
8619 .await
8620 .unwrap();
8621 assert_eq!(
8622 resp.status(),
8623 StatusCode::PAYLOAD_TOO_LARGE,
8624 "a payload over the route's cap but under the framework's was accepted — \
8625 the route's own DefaultBodyLimit layer is not doing anything"
8626 );
8627 }
8628
8629 #[tokio::test]
8630 async fn opml_import_under_limit_upload_is_accepted() {
8631 let state = test_state(&["did:plc:admin"]).await;
8632 let cookie = session_cookie(&state, "did:plc:admin", None);
8633 let db = state.db.clone();
8634 let app = router(state);
8635
8636 let opml = br#"<?xml version="1.0"?>
8639<opml version="2.0"><body>
8640 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8641</body></opml>"#;
8642 let (content_type, body) = opml_multipart(opml);
8643
8644 let resp = app
8645 .oneshot(
8646 Request::builder()
8647 .method("POST")
8648 .uri("/opml")
8649 .header("content-type", content_type)
8650 .header(header::COOKIE, cookie)
8651 .body(Body::from(body))
8652 .unwrap(),
8653 )
8654 .await
8655 .unwrap();
8656 assert_eq!(
8663 resp.status(),
8664 StatusCode::SEE_OTHER,
8665 "an under-cap OPML upload was not accepted (status {})",
8666 resp.status(),
8667 );
8668 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8672 .bind("https://example.com/feed.xml")
8673 .fetch_one(&db)
8674 .await
8675 .unwrap();
8676 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8677 let location = resp
8678 .headers()
8679 .get(header::LOCATION)
8680 .and_then(|v| v.to_str().ok())
8681 .unwrap_or_default()
8682 .to_string();
8683 assert!(
8684 !location.starts_with("/login"),
8685 "the import bounced to login instead of being accepted: {location}",
8686 );
8687 }
8688
8689 #[tokio::test]
8690 async fn opml_import_logged_out_redirects_to_login() {
8691 let state = test_state(&["did:plc:admin"]).await;
8694 let app = router(state);
8695
8696 let opml = b"<opml version=\"2.0\"><body></body></opml>";
8697 let (content_type, body) = opml_multipart(opml);
8698
8699 let resp = app
8700 .oneshot(
8701 Request::builder()
8702 .method("POST")
8703 .uri("/opml")
8704 .header("content-type", content_type)
8705 .body(Body::from(body))
8706 .unwrap(),
8707 )
8708 .await
8709 .unwrap();
8710 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8711 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8712 }
8713
8714 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8721 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8722 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8723 let addr = listener.local_addr().unwrap();
8724 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8725 tokio::spawn(async move {
8726 let (mut sock, _) = listener.accept().await.unwrap();
8727 let mut buf = vec![0u8; 4096];
8728 let n = sock.read(&mut buf).await.unwrap();
8729 let req = String::from_utf8_lossy(&buf[..n]).to_string();
8730 let did = req
8732 .split("\r\n\r\n")
8733 .nth(1)
8734 .and_then(|body| {
8735 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8736 v.get("did")?.as_str().map(str::to_string)
8737 })
8738 .unwrap_or_default();
8739 let is_revoke = req.starts_with("POST /internal/revoke");
8740 let body = serde_json::json!({
8741 "ok": true, "did": did, "revoked": true, "hadSession": true
8742 })
8743 .to_string();
8744 let resp = format!(
8745 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8746 body.len(),
8747 body
8748 );
8749 sock.write_all(resp.as_bytes()).await.unwrap();
8750 sock.flush().await.unwrap();
8751 let _ = tx.send(if is_revoke { did } else { String::new() });
8752 });
8753 (format!("http://{addr}"), rx)
8754 }
8755
8756 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8758 let defaults = Config::default();
8759 test_state_with_sidecar_and(
8760 allowed,
8761 sidecar_url,
8762 defaults.standard_site,
8763 defaults.max_feeds_global,
8764 )
8765 .await
8766 }
8767
8768 async fn test_state_with_sidecar_and(
8771 allowed: &[&str],
8772 sidecar_url: &str,
8773 standard_site: bool,
8774 max_feeds_global: i64,
8775 ) -> AppState {
8776 let db = store::init_url("sqlite::memory:").await.unwrap();
8777 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8778 store::ensure_seed(&db, &dids).await.unwrap();
8779 let mut config = Config {
8780 allowed_dids: dids,
8781 cookie_secret: "test-cookie-secret-000".to_string(),
8782 beta_cap: 3,
8783 standard_site,
8784 max_feeds_global,
8785 ..Config::default()
8786 };
8787 config.sidecar.public_url = sidecar_url.to_string();
8788 config.sidecar.internal_url = sidecar_url.to_string();
8789 AppState::new(config, db).unwrap()
8790 }
8791
8792 #[tokio::test]
8795 async fn account_delete_purges_rows_and_triggers_revoke() {
8796 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8797 let did = "did:plc:leaver";
8798 let state = test_state_with_sidecar(&[], &sidecar_url).await;
8799
8800 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8802 .await
8803 .unwrap();
8804 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8805 store::mint_code(&state.db, did, 3600).await.unwrap();
8806 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8807
8808 let cookie = session_cookie(&state, did, Some("leaver.example"));
8809 let app = router(state.clone());
8810
8811 let resp = app
8812 .oneshot(
8813 Request::builder()
8814 .method("POST")
8815 .uri("/account/delete")
8816 .header(header::COOKIE, cookie)
8817 .header("content-type", "application/x-www-form-urlencoded")
8818 .body(Body::from("confirm=DELETE"))
8819 .unwrap(),
8820 )
8821 .await
8822 .unwrap();
8823
8824 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8826 assert!(resp
8827 .headers()
8828 .get(header::LOCATION)
8829 .unwrap()
8830 .to_str()
8831 .unwrap()
8832 .starts_with("/login"));
8833 let set_cookie = resp
8834 .headers()
8835 .get(header::SET_COOKIE)
8836 .unwrap()
8837 .to_str()
8838 .unwrap();
8839 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8840
8841 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8848 .await
8849 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8850 .unwrap();
8851 assert_eq!(
8852 revoked_did, did,
8853 "sidecar revoke must fire for the caller DID"
8854 );
8855
8856 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8858 let codes: i64 =
8859 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8860 .bind(did)
8861 .fetch_one(&state.db)
8862 .await
8863 .unwrap();
8864 assert_eq!(codes, 0);
8865 }
8866
8867 #[tokio::test]
8870 async fn account_delete_without_confirm_is_a_noop() {
8871 let did = "did:plc:staying";
8872 let state = test_state(&[]).await;
8873 store::grant_access(&state.db, did, None, "test", None)
8874 .await
8875 .unwrap();
8876 let cookie = session_cookie(&state, did, None);
8877 let app = router(state.clone());
8878
8879 let resp = app
8880 .oneshot(
8881 Request::builder()
8882 .method("POST")
8883 .uri("/account/delete")
8884 .header(header::COOKIE, cookie)
8885 .header("content-type", "application/x-www-form-urlencoded")
8886 .body(Body::from("confirm=nope"))
8887 .unwrap(),
8888 )
8889 .await
8890 .unwrap();
8891
8892 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8893 assert!(resp
8894 .headers()
8895 .get(header::LOCATION)
8896 .unwrap()
8897 .to_str()
8898 .unwrap()
8899 .starts_with("/manage"));
8900 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8902 }
8903
8904 #[tokio::test]
8911 async fn pds_outage_does_not_widen_cross_did_access() {
8912 let did_a = "did:plc:aaaa";
8913 let state = test_state(&[]).await;
8914 store::grant_access(&state.db, did_a, None, "test", None)
8915 .await
8916 .unwrap();
8917
8918 let feed_a = store::upsert_feed(
8921 &state.db,
8922 &store::NewFeed {
8923 url: "https://a.example/feed.xml".to_string(),
8924 title: Some("A".to_string()),
8925 ..Default::default()
8926 },
8927 )
8928 .await
8929 .unwrap();
8930 let feed_b = store::upsert_feed(
8931 &state.db,
8932 &store::NewFeed {
8933 url: "https://b.example/feed.xml".to_string(),
8934 title: Some("B".to_string()),
8935 ..Default::default()
8936 },
8937 )
8938 .await
8939 .unwrap();
8940 store::insert_entries(
8941 &state.db,
8942 feed_b,
8943 &[store::NewEntry {
8944 guid: "b-1".to_string(),
8945 url: Some("https://b.example/1".to_string()),
8946 title: Some("B one".to_string()),
8947 published: Some("2026-07-11T00:00:00Z".to_string()),
8948 content_html: Some("<p>secret B body</p>".to_string()),
8949 ..Default::default()
8950 }],
8951 0,
8952 )
8953 .await
8954 .unwrap();
8955 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8957 .await
8958 .unwrap();
8959 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8962 .await
8963 .unwrap();
8964 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8965 .await
8966 .unwrap()[0]
8967 .id;
8968 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8969 .await
8970 .unwrap();
8971
8972 let cookie = session_cookie(&state, did_a, None);
8973 let app = router(state.clone());
8974
8975 let get_b = app
8977 .clone()
8978 .oneshot(
8979 Request::builder()
8980 .method("GET")
8981 .uri(format!("/entries/{b_entry_id}"))
8982 .header(header::COOKIE, cookie.clone())
8983 .body(Body::empty())
8984 .unwrap(),
8985 )
8986 .await
8987 .unwrap();
8988 assert_eq!(
8989 get_b.status(),
8990 StatusCode::NOT_FOUND,
8991 "A must not read B's entry during a PDS outage"
8992 );
8993
8994 let read_b = app
8996 .oneshot(
8997 Request::builder()
8998 .method("POST")
8999 .uri(format!("/entries/{b_entry_id}/read"))
9000 .header(header::COOKIE, cookie)
9001 .header("content-type", "application/x-www-form-urlencoded")
9002 .body(Body::from("read=true"))
9003 .unwrap(),
9004 )
9005 .await
9006 .unwrap();
9007 assert_eq!(
9008 read_b.status(),
9009 StatusCode::NOT_FOUND,
9010 "A must not mark B's entry read during a PDS outage"
9011 );
9012
9013 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9015 .bind(did_a)
9016 .fetch_all(&state.db)
9017 .await
9018 .unwrap();
9019 assert_eq!(
9020 a_feed_ids,
9021 vec![feed_a],
9022 "outage fallback must not add feeds A never subscribed to"
9023 );
9024 let es_count: i64 =
9026 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9027 .bind(did_a)
9028 .bind(b_entry_id)
9029 .fetch_one(&state.db)
9030 .await
9031 .unwrap();
9032 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9033 }
9034
9035 #[tokio::test]
9049 async fn a_logout_with_no_session_counts_as_success() {
9050 let did = "did:plc:aaaa";
9051 let state = test_state(&[]).await;
9052 assert!(
9053 state.oauth.is_some(),
9054 "meaningless without an oauth runtime; the revoke arm would be skipped",
9055 );
9056
9057 revoke_everywhere(&state, did).await;
9058 let rows = state.metrics.snapshot();
9059 let find = |b: crate::metrics::Backend| {
9060 rows.iter()
9061 .find(|r| r.op == "oauth_revoke" && r.backend == b)
9062 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9063 };
9064
9065 let rust = find(crate::metrics::Backend::Rust);
9067 assert_eq!(
9068 rust.stats.err_count, 0,
9069 "NoSession was counted as a failure; logout is idempotent",
9070 );
9071 assert_eq!(rust.stats.ok_count, 1);
9072
9073 let sidecar = find(crate::metrics::Backend::Sidecar);
9077 assert_eq!(
9078 sidecar.stats.err_count, 1,
9079 "a failed sidecar revoke was not counted",
9080 );
9081 }
9082
9083 #[tokio::test]
9093 async fn a_failed_rust_revoke_counts_as_an_error() {
9094 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9095 let state = test_state(&[]).await;
9096 let runtime = state.oauth.as_deref().expect("oauth runtime");
9097 crate::oauth::store::put_session(
9098 &state.db,
9099 &runtime.codec,
9100 &crate::oauth::store::OAuthSession {
9101 sub: did.into(),
9102 issuer: "https://auth.invalid".into(),
9103 aud: "https://pds.invalid".into(),
9104 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9105 .to_jwk_json()
9106 .unwrap(),
9107 access_token: "at".into(),
9108 refresh_token: "rt".into(),
9109 token_type: "DPoP".into(),
9110 granted_scope: "atproto".into(),
9111 expires_at: Some(crate::store::now_unix() + 3600),
9112 },
9113 )
9114 .await
9115 .unwrap();
9116
9117 revoke_everywhere(&state, did).await;
9118
9119 let rows = state.metrics.snapshot();
9120 let rust = rows
9121 .iter()
9122 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9123 .expect("no rust oauth_revoke row");
9124 assert_eq!(
9125 rust.stats.err_count, 1,
9126 "an unreachable PDS must count as a revocation failure",
9127 );
9128 assert_eq!(rust.stats.ok_count, 0);
9129 }
9130
9131 #[test]
9147 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9148 for hostile in [
9149 "javascript:alert(1)",
9150 "JavaScript:alert(1)",
9151 " javascript:alert(1)",
9152 "data:text/html;base64,PHNjcmlwdD4=",
9153 "vbscript:msgbox(1)",
9154 "file:///etc/passwd",
9155 "//evil.example/path",
9159 ] {
9160 let link = SafeLink::external(hostile);
9161 assert!(
9162 link.is_empty(),
9163 "{hostile:?} produced a non-empty href: {link}",
9164 );
9165 assert!(
9166 !link.to_string().to_ascii_lowercase().contains("script"),
9167 "{hostile:?} leaked into the rendered link",
9168 );
9169 }
9170
9171 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9174 let link = SafeLink::external(good);
9175 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9176 assert_eq!(link.to_string(), good);
9177 }
9178 }
9179
9180 #[tokio::test]
9195 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9196 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9197 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9198 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9199 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9200
9201 let resp = router(state)
9202 .oneshot(
9203 Request::builder()
9204 .uri("/?view=starred")
9205 .body(Body::empty())
9206 .unwrap(),
9207 )
9208 .await
9209 .unwrap();
9210 assert_eq!(resp.status(), StatusCode::OK);
9211 let body = String::from_utf8(
9212 axum::body::to_bytes(resp.into_body(), usize::MAX)
9213 .await
9214 .unwrap()
9215 .to_vec(),
9216 )
9217 .unwrap();
9218
9219 assert!(
9222 !body.to_ascii_lowercase().contains("javascript:"),
9223 "the hostile scheme reached the rendered page",
9224 );
9225 assert!(
9228 body.contains("unusable link"),
9229 "the row was dropped instead of rendering without an anchor",
9230 );
9231 }
9232
9233 #[tokio::test]
9250 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9251 let did = "did:plc:readerhref";
9252 let state = test_state(&[]).await;
9253 store::grant_access(&state.db, did, None, "test", None)
9254 .await
9255 .unwrap();
9256 let feed = store::upsert_feed(
9257 &state.db,
9258 &store::NewFeed {
9259 url: "https://href.example/feed.xml".to_string(),
9260 title: Some("Href".to_string()),
9261 ..Default::default()
9262 },
9263 )
9264 .await
9265 .unwrap();
9266 store::insert_entries(
9268 &state.db,
9269 feed,
9270 &[
9271 store::NewEntry {
9272 guid: "hostile-1".to_string(),
9273 url: Some("javascript:alert(1)".to_string()),
9274 title: Some("Hostile entry".to_string()),
9275 published: Some("2026-07-11T00:00:00Z".to_string()),
9276 ..Default::default()
9277 },
9278 store::NewEntry {
9279 guid: "benign-1".to_string(),
9280 url: Some("https://href.example/post".to_string()),
9281 title: Some("Benign entry".to_string()),
9282 published: Some("2026-07-10T00:00:00Z".to_string()),
9283 ..Default::default()
9284 },
9285 ],
9286 0,
9287 )
9288 .await
9289 .unwrap();
9290 store::replace_sub_refs(&state.db, did, &[feed])
9291 .await
9292 .unwrap();
9293 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9294 let id_of = |guid: &str| {
9295 rows.iter()
9296 .find(|r| r.guid == guid)
9297 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9298 .id
9299 };
9300
9301 let cookie = session_cookie(&state, did, None);
9302 let app = router(state.clone());
9303
9304 let render = |id: i64| {
9305 let app = app.clone();
9306 let cookie = cookie.clone();
9307 async move {
9308 let resp = app
9309 .oneshot(
9310 Request::builder()
9311 .method("GET")
9312 .uri(format!("/entries/{id}"))
9313 .header(header::COOKIE, cookie)
9314 .body(Body::empty())
9315 .unwrap(),
9316 )
9317 .await
9318 .unwrap();
9319 assert_eq!(resp.status(), StatusCode::OK);
9320 String::from_utf8(
9321 axum::body::to_bytes(resp.into_body(), usize::MAX)
9322 .await
9323 .unwrap()
9324 .to_vec(),
9325 )
9326 .unwrap()
9327 }
9328 };
9329
9330 let hostile = render(id_of("hostile-1")).await;
9331 assert!(
9334 hostile.contains("Hostile entry"),
9335 "the reader did not render the entry: {hostile}",
9336 );
9337 assert!(
9338 !hostile.to_ascii_lowercase().contains("javascript:"),
9339 "the hostile scheme reached the reader page: {hostile}",
9340 );
9341 assert!(
9345 !hostile.contains("actionbar-open"),
9346 "the action bar rendered an open-original link for a refused URL: {hostile}",
9347 );
9348 assert!(
9349 !hostile.contains("Original \u{2197}"),
9350 "the byline rendered an original link for a refused URL: {hostile}",
9351 );
9352
9353 let benign = render(id_of("benign-1")).await;
9356 assert!(
9357 benign.contains("Benign entry"),
9358 "the reader did not render the benign entry: {benign}",
9359 );
9360 assert_eq!(
9364 benign
9365 .matches(r#"href="https://href.example/post""#)
9366 .count(),
9367 2,
9368 "entry.html has two `href`s for the entry URL — the byline link and \
9369 the action-bar button — and this render produced a different \
9370 number: {benign}",
9371 );
9372 assert!(
9373 benign.contains("actionbar-open"),
9374 "a legitimate entry lost its open-original button: {benign}",
9375 );
9376 assert!(
9377 benign.contains("Original \u{2197}"),
9378 "a legitimate entry lost its byline link: {benign}",
9379 );
9380 }
9381
9382 #[tokio::test]
9402 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9403 let did_a = "did:plc:aaaa";
9404 let state = test_state(&[]).await;
9405 store::grant_access(&state.db, did_a, None, "test", None)
9406 .await
9407 .unwrap();
9408
9409 let feed_a = store::upsert_feed(
9410 &state.db,
9411 &store::NewFeed {
9412 url: "https://a.example/feed.xml".to_string(),
9413 title: Some("A".to_string()),
9414 ..Default::default()
9415 },
9416 )
9417 .await
9418 .unwrap();
9419 let _feed_b = store::upsert_feed(
9420 &state.db,
9421 &store::NewFeed {
9422 url: "https://b.example/feed.xml".to_string(),
9423 title: Some("B".to_string()),
9424 ..Default::default()
9425 },
9426 )
9427 .await
9428 .unwrap();
9429 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9432 .await
9433 .unwrap();
9434
9435 assert!(
9440 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9441 "this test is only meaningful on the outage path; the repo answered",
9442 );
9443
9444 let resolved = resolve_subscriptions(&state, did_a).await;
9445
9446 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9447 assert_eq!(
9448 urls,
9449 vec!["https://a.example/feed.xml"],
9450 "the outage fallback must return the caller's OWN subscriptions only; \
9451 any other feed here is cross-tenant read access granted by an outage",
9452 );
9453 }
9454
9455 async fn test_state_with_caps(
9458 did: &str,
9459 max_subs_per_did: i64,
9460 max_feeds_global: i64,
9461 ) -> AppState {
9462 let db = store::init_url("sqlite::memory:").await.unwrap();
9463 let config = Config {
9464 cookie_secret: "test-cookie-secret-000".to_string(),
9465 beta_cap: 100,
9466 max_subs_per_did,
9467 max_feeds_global,
9468 ..Config::default()
9469 };
9470 store::grant_access(&db, did, None, "test", None)
9471 .await
9472 .unwrap();
9473 AppState::new(config, db).unwrap()
9474 }
9475
9476 fn opml_with_feeds(n: usize) -> String {
9478 let mut outlines = String::new();
9479 for i in 0..n {
9480 outlines.push_str(&format!(
9481 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9482 ));
9483 }
9484 format!(
9485 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9486 )
9487 }
9488
9489 #[tokio::test]
9494 async fn opml_import_enforces_global_feeds_ceiling() {
9495 let did = "did:plc:importer";
9496 let state = test_state_with_caps(did, 0, 3).await;
9498 let cookie = session_cookie(&state, did, None);
9499 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9500 let app = router(state.clone());
9501
9502 let resp = app
9503 .oneshot(
9504 Request::builder()
9505 .method("POST")
9506 .uri("/opml")
9507 .header(header::COOKIE, cookie)
9508 .header("content-type", ct)
9509 .body(Body::from(body))
9510 .unwrap(),
9511 )
9512 .await
9513 .unwrap();
9514 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9515
9516 let feeds = store::count_feeds(&state.db).await.unwrap();
9517 assert!(
9518 feeds <= 3,
9519 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9520 );
9521 }
9522
9523 async fn import_against_strict_pds(
9526 did: &str,
9527 n: usize,
9528 fail_call: Option<usize>,
9529 ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9530 let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9531 let state = test_state_with_sidecar(&[did], &sidecar).await;
9532 let cookie = session_cookie(&state, did, None);
9533 let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9534 let resp = router(state)
9535 .oneshot(
9536 Request::builder()
9537 .method("POST")
9538 .uri("/opml")
9539 .header(header::COOKIE, cookie)
9540 .header("content-type", ct)
9541 .body(Body::from(body))
9542 .unwrap(),
9543 )
9544 .await
9545 .unwrap();
9546 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9547 let loc = resp.headers()[header::LOCATION].to_str().unwrap();
9548 let flash = url::Url::parse(&format!("http://x{loc}"))
9549 .unwrap()
9550 .query_pairs()
9551 .find(|(k, _)| k == "flash")
9552 .map(|(_, v)| v.into_owned())
9553 .unwrap_or_default();
9554 (flash, log)
9555 }
9556
9557 #[tokio::test]
9561 async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
9562 let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
9563 assert_eq!(flash, "Imported 450 feeds", "{flash}");
9564 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
9565 }
9566
9567 #[tokio::test]
9571 async fn opml_import_that_part_lands_reports_what_landed() {
9572 let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
9573 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
9574 assert!(
9575 flash.contains("200 of 450"),
9576 "the landed count is not reported: {flash}"
9577 );
9578 assert!(
9579 !flash.contains("nothing was imported"),
9580 "200 feeds landed and the reader was told none did: {flash}"
9581 );
9582 }
9583
9584 #[tokio::test]
9587 async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
9588 let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
9589 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
9590 assert!(flash.contains("nothing was imported"), "{flash}");
9591 }
9592
9593 #[tokio::test]
9602 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9603 let did = "did:plc:typoist";
9604 let state = test_state_with_caps(did, 0, 0).await;
9605 let cookie = session_cookie(&state, did, None);
9606 for input in [
9607 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9608 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9609 ] {
9610 let resp = router(state.clone())
9611 .oneshot(
9612 Request::builder()
9613 .method("POST")
9614 .uri("/subscriptions")
9615 .header(header::COOKIE, cookie.clone())
9616 .header("content-type", "application/x-www-form-urlencoded")
9617 .body(Body::from(format!("url={input}")))
9618 .unwrap(),
9619 )
9620 .await
9621 .unwrap();
9622 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9623 let loc = resp
9624 .headers()
9625 .get(header::LOCATION)
9626 .unwrap()
9627 .to_str()
9628 .unwrap();
9629 assert!(
9630 loc.contains("kind%20of%20feed"),
9631 "expected the unsupported-feed flash for {input}, got {loc}"
9632 );
9633 assert!(
9634 !loc.contains("Private"),
9635 "a storability refusal was reported as a privacy one for {input}: {loc}"
9636 );
9637 }
9638 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9639 }
9640
9641 #[tokio::test]
9648 async fn opml_import_reports_entries_this_instance_cannot_store() {
9649 let did = "did:plc:renamer4";
9650 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9651 let state = test_state_with_sidecar(&[did], &sidecar).await;
9652 assert!(!state.config.standard_site);
9653 let opml = format!(
9654 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9655 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9656 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9657 </body></opml>"
9658 );
9659 let (ct, body) = opml_multipart(opml.as_bytes());
9660 let cookie = session_cookie(&state, did, None);
9661 let resp = router(state.clone())
9662 .oneshot(
9663 Request::builder()
9664 .method("POST")
9665 .uri("/opml")
9666 .header(header::COOKIE, cookie)
9667 .header("content-type", ct)
9668 .body(Body::from(body))
9669 .unwrap(),
9670 )
9671 .await
9672 .unwrap();
9673 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9674 let loc = resp
9675 .headers()
9676 .get(header::LOCATION)
9677 .unwrap()
9678 .to_str()
9679 .unwrap();
9680 assert!(
9681 loc.contains("Imported%201%20feed"),
9682 "unexpected flash: {loc}"
9683 );
9684 assert!(
9685 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9686 "the dropped entry was not reported: {loc}"
9687 );
9688 assert!(
9690 !loc.contains("site.standard.publication"),
9691 "the URI was echoed: {loc}"
9692 );
9693 }
9694
9695 #[tokio::test]
9698 async fn opml_import_enforces_per_did_cap() {
9699 let did = "did:plc:capped";
9700 let state = test_state_with_caps(did, 2, 0).await;
9702 let existing_a = store::upsert_feed(
9703 &state.db,
9704 &store::NewFeed {
9705 url: "https://have-a.example/feed.xml".to_string(),
9706 ..Default::default()
9707 },
9708 )
9709 .await
9710 .unwrap();
9711 let existing_b = store::upsert_feed(
9712 &state.db,
9713 &store::NewFeed {
9714 url: "https://have-b.example/feed.xml".to_string(),
9715 ..Default::default()
9716 },
9717 )
9718 .await
9719 .unwrap();
9720 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9721 .await
9722 .unwrap();
9723 let before = store::count_feeds(&state.db).await.unwrap();
9724
9725 let cookie = session_cookie(&state, did, None);
9726 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9727 let app = router(state.clone());
9728 let resp = app
9729 .oneshot(
9730 Request::builder()
9731 .method("POST")
9732 .uri("/opml")
9733 .header(header::COOKIE, cookie)
9734 .header("content-type", ct)
9735 .body(Body::from(body))
9736 .unwrap(),
9737 )
9738 .await
9739 .unwrap();
9740 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9741 let after = store::count_feeds(&state.db).await.unwrap();
9743 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9744 }
9745
9746 #[tokio::test]
9749 async fn single_add_enforces_per_did_cap() {
9750 let did = "did:plc:subcapped";
9751 let state = test_state_with_caps(did, 1, 0).await;
9752 let f = store::upsert_feed(
9753 &state.db,
9754 &store::NewFeed {
9755 url: "https://have.example/feed.xml".to_string(),
9756 ..Default::default()
9757 },
9758 )
9759 .await
9760 .unwrap();
9761 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9762 let cookie = session_cookie(&state, did, None);
9763 let app = router(state.clone());
9764 let resp = app
9765 .oneshot(
9766 Request::builder()
9767 .method("POST")
9768 .uri("/subscriptions")
9769 .header(header::COOKIE, cookie)
9770 .header("content-type", "application/x-www-form-urlencoded")
9771 .body(Body::from("url=https://another.example/feed.xml"))
9772 .unwrap(),
9773 )
9774 .await
9775 .unwrap();
9776 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9777 let loc = resp
9778 .headers()
9779 .get(header::LOCATION)
9780 .unwrap()
9781 .to_str()
9782 .unwrap();
9783 assert!(
9784 loc.contains("Subscription%20limit%20reached"),
9785 "expected sub-limit flash, got {loc}"
9786 );
9787 }
9788
9789 #[tokio::test]
9798 async fn the_reader_index_pages_instead_of_rendering_everything() {
9799 let did = "did:plc:pager";
9800 let state = test_state(&[]).await;
9801 store::grant_access(&state.db, did, None, "test", None)
9802 .await
9803 .unwrap();
9804 let feed = store::upsert_feed(
9805 &state.db,
9806 &store::NewFeed {
9807 url: "https://pager.example/feed.xml".to_string(),
9808 title: Some("Pager".to_string()),
9809 ..Default::default()
9810 },
9811 )
9812 .await
9813 .unwrap();
9814 let total = 250_usize;
9815 let entries: Vec<store::NewEntry> = (0..total)
9816 .map(|i| store::NewEntry {
9817 guid: format!("p-{i:04}"),
9818 url: Some(format!("https://pager.example/{i}")),
9819 title: Some(format!("Article {i:04}")),
9820 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9821 content_html: Some("x".repeat(4_000)),
9822 ..Default::default()
9823 })
9824 .collect();
9825 store::insert_entries(&state.db, feed, &entries, 0)
9826 .await
9827 .unwrap();
9828 store::replace_sub_refs(&state.db, did, &[feed])
9829 .await
9830 .unwrap();
9831
9832 let cookie = session_cookie(&state, did, None);
9833 let app = router(state.clone());
9834 let get = |uri: &str| {
9835 let app = app.clone();
9836 let cookie = cookie.clone();
9837 let uri = uri.to_string();
9838 async move {
9839 let resp = app
9840 .oneshot(
9841 Request::builder()
9842 .uri(uri)
9843 .header(header::COOKIE, cookie)
9844 .body(Body::empty())
9845 .unwrap(),
9846 )
9847 .await
9848 .unwrap();
9849 assert_eq!(resp.status(), StatusCode::OK);
9850 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9851 .await
9852 .unwrap();
9853 String::from_utf8(bytes.to_vec()).unwrap()
9854 }
9855 };
9856
9857 let page1 = get("/").await;
9858 let rows1 = page1.matches("<li class=\"entry").count();
9862 assert!(
9863 rows1 <= ENTRIES_PER_PAGE as usize,
9864 "page 1 rendered {rows1} entry links; the list is unbounded"
9865 );
9866 assert!(
9867 rows1 > 0,
9868 "page 1 rendered nothing at all: the page bound swallowed the list"
9869 );
9870 assert!(
9873 page1.contains("250 entries"),
9874 "heading must report the full total, not the page"
9875 );
9876 assert!(
9877 page1.contains("page=2"),
9878 "no way to reach the rest of the list: {}",
9879 &page1[..page1.len().min(400)]
9880 );
9881 assert!(
9883 !page1.contains(&"x".repeat(4_000)),
9884 "the list response carried an article body"
9885 );
9886
9887 let page2 = get("/?page=2").await;
9888 assert!(
9889 page2.matches("<li class=\"entry").count() > 0,
9890 "page 2 rendered no rows at all"
9891 );
9892 assert!(
9893 page2.contains("page=1") || page2.contains("Newer"),
9894 "page 2 offers no way back"
9895 );
9896 let first_title = (0..total)
9898 .map(|i| format!("Article {i:04}"))
9899 .find(|t| page1.contains(t))
9900 .expect("page 1 shows at least one titled article");
9901 assert!(
9902 !page2.contains(&first_title),
9903 "{first_title} appears on both pages"
9904 );
9905
9906 let past_end = get("/?page=999").await;
9912 assert!(
9913 past_end.matches("<li class=\"entry").count() > 0,
9914 "an out-of-range page rendered nothing and offered no way back"
9915 );
9916 assert!(
9917 past_end.contains("page=2"),
9918 "the clamped page offers no pager"
9919 );
9920 }
9921
9922 #[tokio::test]
9929 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9930 let did = "did:plc:reader";
9931 let state = test_state(&[]).await;
9932 store::grant_access(&state.db, did, None, "test", None)
9933 .await
9934 .unwrap();
9935 let feed = store::upsert_feed(
9936 &state.db,
9937 &store::NewFeed {
9938 url: "https://reader.example/feed.xml".to_string(),
9939 title: Some("Reader".to_string()),
9940 ..Default::default()
9941 },
9942 )
9943 .await
9944 .unwrap();
9945 store::insert_entries(
9946 &state.db,
9947 feed,
9948 &[store::NewEntry {
9949 guid: "r-1".to_string(),
9950 url: Some("https://reader.example/1".to_string()),
9951 title: Some("Article".to_string()),
9952 published: Some("2026-07-11T00:00:00Z".to_string()),
9953 content_html: Some("<p>body</p>".to_string()),
9954 ..Default::default()
9955 }],
9956 0,
9957 )
9958 .await
9959 .unwrap();
9960 store::replace_sub_refs(&state.db, did, &[feed])
9961 .await
9962 .unwrap();
9963 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9964
9965 let cookie = session_cookie(&state, did, None);
9966 let app = router(state.clone());
9967
9968 let resp = app
9970 .clone()
9971 .oneshot(
9972 Request::builder()
9973 .method("POST")
9974 .uri(format!("/entries/{entry_id}/read"))
9975 .header(header::COOKIE, cookie.clone())
9976 .header("HX-Request", "true")
9977 .header("X-FR-Reader", "1")
9978 .header("content-type", "application/x-www-form-urlencoded")
9979 .body(Body::from("read=true"))
9980 .unwrap(),
9981 )
9982 .await
9983 .unwrap();
9984 assert_eq!(resp.status(), StatusCode::OK);
9985 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9986 .await
9987 .unwrap();
9988 let html = String::from_utf8(bytes.to_vec()).unwrap();
9989 assert!(
9990 html.contains("hx-swap-oob=\"outerHTML\""),
9991 "reader response must be an OOB swap: {html}"
9992 );
9993 assert!(
9994 html.contains(r#"id="entry-actionbar""#),
9995 "reader response must be the action-bar fragment: {html}"
9996 );
9997 assert!(
10000 html.contains(r#"aria-pressed="true""#),
10001 "read button must show pressed after marking read: {html}"
10002 );
10003 assert!(
10004 html.contains(r#"name="read" value="false""#),
10005 "hidden read value must flip to false so a second tap reverses: {html}"
10006 );
10007
10008 let resp2 = app
10011 .oneshot(
10012 Request::builder()
10013 .method("POST")
10014 .uri(format!("/entries/{entry_id}/read"))
10015 .header(header::COOKIE, cookie)
10016 .header("HX-Request", "true")
10017 .header("X-FR-Reader", "1")
10018 .header("content-type", "application/x-www-form-urlencoded")
10019 .body(Body::from("read=false"))
10020 .unwrap(),
10021 )
10022 .await
10023 .unwrap();
10024 assert_eq!(resp2.status(), StatusCode::OK);
10025 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10026 .await
10027 .unwrap();
10028 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10029 assert!(
10030 html2.contains(r#"aria-pressed="false""#),
10031 "read button must show un-pressed after reversing: {html2}"
10032 );
10033 assert!(
10034 html2.contains(r#"name="read" value="true""#),
10035 "hidden read value must flip back to true: {html2}"
10036 );
10037 }
10038
10039 #[tokio::test]
10042 async fn list_mark_read_returns_row_not_oob_actionbar() {
10043 let did = "did:plc:listv";
10044 let state = test_state(&[]).await;
10045 store::grant_access(&state.db, did, None, "test", None)
10046 .await
10047 .unwrap();
10048 let feed = store::upsert_feed(
10049 &state.db,
10050 &store::NewFeed {
10051 url: "https://list.example/feed.xml".to_string(),
10052 title: Some("List".to_string()),
10053 ..Default::default()
10054 },
10055 )
10056 .await
10057 .unwrap();
10058 store::insert_entries(
10059 &state.db,
10060 feed,
10061 &[store::NewEntry {
10062 guid: "l-1".to_string(),
10063 url: Some("https://list.example/1".to_string()),
10064 title: Some("Article".to_string()),
10065 published: Some("2026-07-11T00:00:00Z".to_string()),
10066 ..Default::default()
10067 }],
10068 0,
10069 )
10070 .await
10071 .unwrap();
10072 store::replace_sub_refs(&state.db, did, &[feed])
10073 .await
10074 .unwrap();
10075 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10076
10077 let cookie = session_cookie(&state, did, None);
10078 let app = router(state.clone());
10079
10080 let resp = app
10081 .oneshot(
10082 Request::builder()
10083 .method("POST")
10084 .uri(format!("/entries/{entry_id}/read"))
10085 .header(header::COOKIE, cookie)
10086 .header("HX-Request", "true")
10087 .header("content-type", "application/x-www-form-urlencoded")
10088 .body(Body::from("read=true"))
10089 .unwrap(),
10090 )
10091 .await
10092 .unwrap();
10093 assert_eq!(resp.status(), StatusCode::OK);
10094 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10095 .await
10096 .unwrap();
10097 let html = String::from_utf8(bytes.to_vec()).unwrap();
10098 assert!(
10099 !html.contains("hx-swap-oob"),
10100 "list-view response must NOT be an OOB swap: {html}"
10101 );
10102 assert!(
10107 html.contains(&format!("/entries/{entry_id}")),
10108 "the response is not the row for this entry: {html}",
10109 );
10110 assert!(
10111 html.contains("Article"),
10112 "the row rendered without its title: {html}",
10113 );
10114 assert!(
10131 html.contains("is-read"),
10132 "the row came back without the read state it was just given: {html}",
10133 );
10134 }
10135
10136 #[tokio::test]
10161 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10162 let did = "did:plc:autodiscovered";
10163 let state = test_state_with_caps(did, 0, 0).await;
10166
10167 let page = r#"<!doctype html><html><head><title>Blog</title>
10168 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10169 </head><body>hi</body></html>"#;
10170 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10171 let port: u16 = base
10172 .trim_end_matches('/')
10173 .rsplit(':')
10174 .next()
10175 .unwrap()
10176 .parse()
10177 .unwrap();
10178 crate::net::test_host_override(
10179 "autodiscover-ftp.test",
10180 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10181 );
10182
10183 let cookie = session_cookie(&state, did, None);
10184 let resp = router(state.clone())
10185 .oneshot(
10186 Request::builder()
10187 .method("POST")
10188 .uri("/subscriptions")
10189 .header(header::COOKIE, cookie)
10190 .header("content-type", "application/x-www-form-urlencoded")
10191 .body(Body::from(format!(
10192 "url=http://autodiscover-ftp.test:{port}/"
10193 )))
10194 .unwrap(),
10195 )
10196 .await
10197 .unwrap();
10198 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10199 let loc = resp
10200 .headers()
10201 .get(header::LOCATION)
10202 .unwrap()
10203 .to_str()
10204 .unwrap();
10205 assert_ne!(loc, "/login", "the test never reached the add path");
10206 assert_ne!(loc, "/", "the subscribe succeeded");
10207
10208 assert_eq!(
10209 store::count_feeds(&state.db).await.unwrap(),
10210 0,
10211 "a non-http(s) URL from autodiscovery was stored"
10212 );
10213 assert_eq!(
10214 store::count_subscriptions_for_did(&state.db, did)
10215 .await
10216 .unwrap(),
10217 0
10218 );
10219 }
10220
10221 #[tokio::test]
10226 async fn rename_to_new_url_refused_at_global_feeds_cap() {
10227 let did = "did:plc:renamer4";
10228 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10229 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10231 store::upsert_feed(
10232 &state.db,
10233 &store::NewFeed {
10234 url: "https://existing.example/feed.xml".to_string(),
10235 ..Default::default()
10236 },
10237 )
10238 .await
10239 .unwrap();
10240 let before = store::count_feeds(&state.db).await.unwrap();
10241 assert_eq!(before, 1);
10242
10243 let cookie = session_cookie(&state, did, None);
10244 let resp = router(state.clone())
10245 .oneshot(
10246 Request::builder()
10247 .method("POST")
10248 .uri("/subscriptions/rk-keep/rename")
10249 .header(header::COOKIE, cookie)
10250 .header("content-type", "application/x-www-form-urlencoded")
10251 .body(Body::from(
10253 "url=https://brand-new.example/feed.xml&title=Renamed",
10254 ))
10255 .unwrap(),
10256 )
10257 .await
10258 .unwrap();
10259 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10260 let loc = resp
10261 .headers()
10262 .get(header::LOCATION)
10263 .unwrap()
10264 .to_str()
10265 .unwrap();
10266 assert!(
10267 loc.contains("feed%20capacity"),
10268 "expected the feed-capacity flash, got {loc}"
10269 );
10270 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10272 assert!(
10273 puts.lock().unwrap().is_empty(),
10274 "a refused repoint reached the PDS"
10275 );
10276 }
10277
10278 #[tokio::test]
10285 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10286 let did = "did:plc:renamer4";
10287 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10288 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10289 store::upsert_feed(
10290 &state.db,
10291 &store::NewFeed {
10292 url: "https://existing.example/feed.xml".to_string(),
10293 ..Default::default()
10294 },
10295 )
10296 .await
10297 .unwrap();
10298 let before = store::count_feeds(&state.db).await.unwrap();
10299
10300 let cookie = session_cookie(&state, did, None);
10301 let resp = router(state.clone())
10302 .oneshot(
10303 Request::builder()
10304 .method("POST")
10305 .uri("/subscriptions/rk-keep/rename")
10306 .header(header::COOKIE, cookie)
10307 .header("content-type", "application/x-www-form-urlencoded")
10308 .body(Body::from(
10309 "url=https://existing.example/feed.xml&title=Retitled",
10310 ))
10311 .unwrap(),
10312 )
10313 .await
10314 .unwrap();
10315 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10316 let loc = resp
10317 .headers()
10318 .get(header::LOCATION)
10319 .unwrap()
10320 .to_str()
10321 .unwrap();
10322 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10323 assert_eq!(
10324 puts.lock().unwrap().len(),
10325 1,
10326 "the repoint did not reach the PDS"
10327 );
10328 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10329 }
10330
10331 #[tokio::test]
10333 async fn rename_with_blank_url_writes_nothing() {
10334 let did = "did:plc:renamer3";
10335 let state = test_state_with_caps(did, 0, 0).await;
10336 let before = store::count_feeds(&state.db).await.unwrap();
10337 assert_eq!(before, 0);
10338
10339 let cookie = session_cookie(&state, did, None);
10340 let app = router(state.clone());
10341 let resp = app
10342 .oneshot(
10343 Request::builder()
10344 .method("POST")
10345 .uri("/subscriptions/rkey123/rename")
10346 .header(header::COOKIE, cookie)
10347 .header("content-type", "application/x-www-form-urlencoded")
10348 .body(Body::from("url=%20%20&title=Nope"))
10350 .unwrap(),
10351 )
10352 .await
10353 .unwrap();
10354 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10355 assert_eq!(
10356 resp.headers()
10357 .get(header::LOCATION)
10358 .unwrap()
10359 .to_str()
10360 .unwrap(),
10361 "/",
10362 );
10363 assert_eq!(
10365 store::count_feeds(&state.db).await.unwrap(),
10366 0,
10367 "blank-URL rename wrote a junk feeds row"
10368 );
10369 }
10370
10371 async fn spawn_rename_sidecar(
10380 existing: serde_json::Value,
10381 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10382 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10383 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10384 let addr = listener.local_addr().unwrap();
10385 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10386 let sink = puts.clone();
10387 tokio::spawn(async move {
10388 loop {
10389 let Ok((mut sock, _)) = listener.accept().await else {
10390 break;
10391 };
10392 let mut raw: Vec<u8> = Vec::new();
10393 let mut chunk = [0u8; 4096];
10394 let body_text = loop {
10395 let Ok(n) = sock.read(&mut chunk).await else {
10396 break String::new();
10397 };
10398 if n == 0 {
10399 break String::from_utf8_lossy(&raw).to_string();
10400 }
10401 raw.extend_from_slice(&chunk[..n]);
10402 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10403 continue;
10404 };
10405 let (head, body) = raw.split_at(split + 4);
10406 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10407 let (k, v) = l.split_once(':')?;
10408 k.eq_ignore_ascii_case("content-length")
10409 .then(|| v.trim().parse::<usize>().ok())?
10410 });
10411 if want.is_none_or(|want| body.len() >= want) {
10412 break String::from_utf8_lossy(body).to_string();
10413 }
10414 };
10415
10416 let is_put = body_text.contains("\"action\":\"put\"");
10418 let data = if is_put {
10419 sink.lock().unwrap().push(body_text.clone());
10420 serde_json::json!({
10421 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10422 "cid": "bafyreiafter"
10423 })
10424 } else {
10425 serde_json::json!({ "records": [existing.clone()] })
10426 };
10427 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10428 let resp = format!(
10429 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10430 body.len(),
10431 body
10432 );
10433 let _ = sock.write_all(resp.as_bytes()).await;
10434 let _ = sock.flush().await;
10435 }
10436 });
10437 (format!("http://{addr}"), puts)
10438 }
10439
10440 fn seeded_subscription() -> serde_json::Value {
10442 serde_json::json!({
10443 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10444 "cid": "bafyreibefore",
10445 "value": {
10446 "$type": "community.lexicon.rss.subscription",
10447 "url": "https://example.com/feed.xml",
10448 "title": "Old title",
10449 "siteUrl": "https://example.com/blog",
10450 "fetchHint": "hourly",
10451 "private": false,
10452 "createdAt": "2024-03-01T00:00:00.000Z"
10453 }
10454 })
10455 }
10456
10457 fn seeded_at_uri_subscription() -> serde_json::Value {
10460 seeded_subscription_with_url(AT_URI_SUB)
10461 }
10462 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10464 serde_json::json!({
10465 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10466 "cid": "bafyreibefore",
10467 "value": {
10468 "$type": "community.lexicon.rss.subscription",
10469 "url": url,
10470 "title": "Old title",
10471 "private": false,
10472 "createdAt": "2024-03-01T00:00:00.000Z"
10473 }
10474 })
10475 }
10476 const AT_URI_SUB: &str =
10477 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10478 const AT_URI_SUB_ENC: &str =
10479 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10480
10481 #[tokio::test]
10490 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10491 let did = "did:plc:renamer5";
10492 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10493 let state = test_state_with_sidecar(&[did], &sidecar).await;
10494 assert!(
10495 !state.config.standard_site,
10496 "the flag must be off for this test"
10497 );
10498 let cookie = session_cookie(&state, did, None);
10499 let resp = router(state.clone())
10500 .oneshot(
10501 Request::builder()
10502 .method("POST")
10503 .uri("/subscriptions/rk-keep/rename")
10504 .header(header::COOKIE, cookie)
10505 .header("content-type", "application/x-www-form-urlencoded")
10506 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10507 .unwrap(),
10508 )
10509 .await
10510 .unwrap();
10511 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10512 let loc = resp
10513 .headers()
10514 .get(header::LOCATION)
10515 .unwrap()
10516 .to_str()
10517 .unwrap();
10518 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10519
10520 let bodies = puts.lock().unwrap().clone();
10521 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10522 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10523 assert_eq!(
10524 sent["record"]["title"], "New title",
10525 "the rename did not apply"
10526 );
10527 assert_eq!(
10528 sent["record"]["url"], AT_URI_SUB,
10529 "the rename changed the URL"
10530 );
10531
10532 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10534 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10535 }
10536
10537 #[tokio::test]
10541 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10542 let did = "did:plc:renamer4";
10543 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10544 let state = test_state_with_sidecar(&[did], &sidecar).await;
10545 let cookie = session_cookie(&state, did, None);
10546 let resp = router(state.clone())
10547 .oneshot(
10548 Request::builder()
10549 .method("POST")
10550 .uri("/subscriptions/rk-keep/rename")
10551 .header(header::COOKIE, cookie)
10552 .header("content-type", "application/x-www-form-urlencoded")
10553 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10554 .unwrap(),
10555 )
10556 .await
10557 .unwrap();
10558 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10559 let loc = resp
10560 .headers()
10561 .get(header::LOCATION)
10562 .unwrap()
10563 .to_str()
10564 .unwrap();
10565 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10566 assert!(
10567 !loc.contains("Private"),
10568 "a storability refusal was reported as a privacy one: {loc}"
10569 );
10570 assert!(
10571 puts.lock().unwrap().is_empty(),
10572 "the repoint reached the PDS"
10573 );
10574 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10575 assert_eq!(cached, 0);
10576 }
10577
10578 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10581 let cookie = session_cookie(state, did, None);
10582 let resp = router(state.clone())
10583 .oneshot(
10584 Request::builder()
10585 .method("POST")
10586 .uri("/subscriptions/rk-keep/rename")
10587 .header(header::COOKIE, cookie)
10588 .header("content-type", "application/x-www-form-urlencoded")
10589 .body(Body::from(format!("url={url_enc}&title=New+title")))
10590 .unwrap(),
10591 )
10592 .await
10593 .unwrap();
10594 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10595 resp.headers()
10596 .get(header::LOCATION)
10597 .unwrap()
10598 .to_str()
10599 .unwrap()
10600 .to_string()
10601 }
10602
10603 #[tokio::test]
10613 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10614 let did = "did:plc:renamer5";
10615 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10616 let other_enc =
10617 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10618 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10619 let state = test_state_with_sidecar(&[did], &sidecar).await;
10620 let loc = retitle_unchanged(&state, did, other_enc).await;
10621 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10622 let bodies = puts.lock().unwrap().clone();
10623 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10624 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10625 assert_eq!(sent["record"]["title"], "New title");
10626 assert_eq!(sent["record"]["url"], other);
10627 }
10628
10629 #[tokio::test]
10633 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10634 let did = "did:plc:renamer4";
10635 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10636 let state = test_state_with_sidecar(&[did], &sidecar).await;
10637 let cookie = session_cookie(&state, did, None);
10638 let resp = router(state.clone())
10639 .oneshot(
10640 Request::builder()
10641 .method("POST")
10642 .uri("/subscriptions/rk-keep/rename")
10643 .header(header::COOKIE, cookie)
10644 .header("content-type", "application/x-www-form-urlencoded")
10645 .body(Body::from(
10646 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10647 ))
10648 .unwrap(),
10649 )
10650 .await
10651 .unwrap();
10652 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10653 let loc = resp
10654 .headers()
10655 .get(header::LOCATION)
10656 .unwrap()
10657 .to_str()
10658 .unwrap();
10659 assert!(
10660 loc.contains("Private"),
10661 "the private repoint was not refused: {loc}"
10662 );
10663 assert!(
10664 puts.lock().unwrap().is_empty(),
10665 "a secret-bearing URL reached the PDS"
10666 );
10667 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10670 assert!(store::get_feed_by_url(&state.db, leaked)
10671 .await
10672 .unwrap()
10673 .is_none());
10674 }
10675
10676 #[tokio::test]
10682 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10683 let did = "did:plc:renamer5";
10684 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10685 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10687 store::upsert_feed(
10688 &state.db,
10689 &store::NewFeed {
10690 url: "https://filler.example/feed.xml".to_string(),
10691 ..Default::default()
10692 },
10693 )
10694 .await
10695 .unwrap();
10696 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10697 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10698 assert_eq!(
10699 puts.lock().unwrap().len(),
10700 1,
10701 "the retitle did not reach the PDS"
10702 );
10703 assert_eq!(
10704 store::count_feeds(&state.db).await.unwrap(),
10705 1,
10706 "a row was inserted"
10707 );
10708 }
10709
10710 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10712 let cookie = session_cookie(state, did, None);
10713 let resp = router(state.clone())
10714 .oneshot(
10715 Request::builder()
10716 .method("POST")
10717 .uri("/subscriptions")
10718 .header(header::COOKIE, cookie)
10719 .header("content-type", "application/x-www-form-urlencoded")
10720 .body(Body::from(format!("url={url_enc}")))
10721 .unwrap(),
10722 )
10723 .await
10724 .unwrap();
10725 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10726 resp.headers()
10727 .get(header::LOCATION)
10728 .unwrap()
10729 .to_str()
10730 .unwrap()
10731 .to_string()
10732 }
10733
10734 async fn serve_resolver(did: &str) -> String {
10736 let base = crate::net::tests::serve_body(
10737 serde_json::json!({ "did": did }).to_string().into_bytes(),
10738 )
10739 .await;
10740 let port: u16 = base
10741 .trim_end_matches('/')
10742 .rsplit(':')
10743 .next()
10744 .unwrap()
10745 .parse()
10746 .unwrap();
10747 let host = format!("resolver-{port}.test");
10748 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10749 format!("http://{host}:{port}")
10750 }
10751
10752 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10753 let mut config = (*state.config).clone();
10754 f(&mut config);
10755 state.config = std::sync::Arc::new(config);
10756 state
10757 }
10758
10759 #[tokio::test]
10764 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10765 let did = "did:plc:renamer5";
10766 let (sidecar, log) = spawn_logging_sidecar().await;
10767 let state = with_config(
10768 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10769 |c| {
10770 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10771 },
10772 );
10773 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10774 assert_eq!(loc, "/", "the paste was refused: {loc}");
10775 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10776 .await
10777 .unwrap()
10778 .expect("no feed row");
10779 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10780 let sent = log.lock().unwrap().join("\n");
10781 assert!(
10782 sent.contains(AT_URI_SUB),
10783 "the subscription was not written to the PDS: {sent}"
10784 );
10785 }
10786
10787 #[tokio::test]
10791 async fn a0_subscribing_from_the_form_delivers_entries() {
10792 let did = "did:plc:renamer5";
10793 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10794 let site = AT_URI_SUB;
10795 let (plc, _) = crate::standard_site::tests::serve_repo(
10796 author,
10797 vec![
10798 (
10799 lexicon::nsid::STANDARD_PUBLICATION,
10800 "3lab2c4d5e6f7g8h",
10801 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10802 ),
10803 (
10804 lexicon::nsid::STANDARD_DOCUMENT,
10805 "3l2a0frmaaa2a",
10806 serde_json::json!({ "title": "From the form", "path": "/f",
10807 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10808 ),
10809 ],
10810 )
10811 .await;
10812 let (sidecar, _log) = spawn_logging_sidecar().await;
10813 let state = with_config(
10814 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10815 |c| {
10816 c.oauth.plc_directory = plc;
10817 },
10818 );
10819 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10820 let row = store::get_feed_by_url(&state.db, site)
10821 .await
10822 .unwrap()
10823 .unwrap();
10824 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10825 .bind(row.id)
10826 .fetch_all(&state.db)
10827 .await
10828 .unwrap();
10829 assert_eq!(
10830 titles,
10831 vec!["From the form".to_string()],
10832 "the first poll stored nothing"
10833 );
10834 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10835 }
10836
10837 #[tokio::test]
10840 async fn a_handle_form_paste_is_stored_by_its_did() {
10841 let did = "did:plc:renamer5";
10842 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10843 let (sidecar, _log) = spawn_logging_sidecar().await;
10844 let resolver = serve_resolver(author).await;
10845 let state = with_config(
10846 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10847 |c| {
10848 c.resolver_base = resolver;
10849 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10850 },
10851 );
10852 let loc = subscribe(
10853 &state,
10854 did,
10855 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10856 )
10857 .await;
10858 assert_eq!(loc, "/", "the paste was refused: {loc}");
10859 assert!(
10860 store::get_feed_by_url(&state.db, AT_URI_SUB)
10861 .await
10862 .unwrap()
10863 .is_some(),
10864 "not stored by its DID"
10865 );
10866 assert_eq!(
10867 store::count_feeds(&state.db).await.unwrap(),
10868 1,
10869 "the handle form was stored too"
10870 );
10871 }
10872
10873 async fn serve_counting_resolver(
10875 did: &str,
10876 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10877 let (base, hits) = crate::net::tests::serve_body_counted(
10878 serde_json::json!({ "did": did }).to_string().into_bytes(),
10879 )
10880 .await;
10881 let port: u16 = base
10882 .trim_end_matches('/')
10883 .rsplit(':')
10884 .next()
10885 .unwrap()
10886 .parse()
10887 .unwrap();
10888 let host = format!("counting-resolver-{port}.test");
10889 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10890 (format!("http://{host}:{port}"), hits)
10891 }
10892
10893 #[tokio::test]
10897 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10898 let did = "did:plc:renamer5";
10899 let (sidecar, _log) = spawn_logging_sidecar().await;
10900 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10901 let state = with_config(
10902 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10903 |c| {
10904 c.resolver_base = resolver;
10905 c.max_subs_per_did = 1;
10906 },
10907 );
10908 let feed_id = store::upsert_feed(
10909 &state.db,
10910 &store::NewFeed {
10911 url: "https://already.example/feed.xml".into(),
10912 ..Default::default()
10913 },
10914 )
10915 .await
10916 .unwrap();
10917 store::replace_sub_refs(&state.db, did, &[feed_id])
10918 .await
10919 .unwrap();
10920 let loc = subscribe(
10921 &state,
10922 did,
10923 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10924 )
10925 .await;
10926 assert!(
10927 loc.contains("Subscription%20limit"),
10928 "expected the cap flash: {loc}"
10929 );
10930 assert_eq!(
10931 hits.load(std::sync::atomic::Ordering::SeqCst),
10932 0,
10933 "an over-cap paste resolved a handle"
10934 );
10935 }
10936
10937 #[tokio::test]
10941 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10942 let did = "did:plc:renamer5";
10943 let (sidecar, _log) = spawn_logging_sidecar().await;
10944 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10945 let state = with_config(
10946 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10947 |c| {
10948 c.resolver_base = resolver;
10949 },
10950 );
10951 for authority in [
10952 "did%3Aplc%3ATOOSHORT",
10953 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10954 "bad%0Ahandle.example",
10955 ] {
10956 let loc = subscribe(
10957 &state,
10958 did,
10959 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10960 )
10961 .await;
10962 assert!(
10963 loc.contains("kind%20of%20feed"),
10964 "{authority}: expected the unsupported flash: {loc}"
10965 );
10966 }
10967 assert_eq!(
10968 hits.load(std::sync::atomic::Ordering::SeqCst),
10969 0,
10970 "a malformed authority reached the resolver"
10971 );
10972 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10973 }
10974
10975 #[tokio::test]
10977 async fn an_unresolvable_handle_paste_is_refused() {
10978 let did = "did:plc:renamer5";
10979 let (sidecar, _log) = spawn_logging_sidecar().await;
10980 let state = with_config(
10981 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10982 |c| {
10983 c.resolver_base = "http://resolver.nowhere.invalid".into();
10984 },
10985 );
10986 let loc = subscribe(
10987 &state,
10988 did,
10989 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10990 )
10991 .await;
10992 assert!(
10993 loc.contains("resolve%20the%20handle"),
10994 "expected the unresolvable-handle flash: {loc}"
10995 );
10996 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10997 }
10998
10999 #[tokio::test]
11001 async fn a_non_publication_at_uri_paste_is_refused() {
11002 let did = "did:plc:renamer5";
11003 let (sidecar, _log) = spawn_logging_sidecar().await;
11004 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11005 let loc = subscribe(
11006 &state,
11007 did,
11008 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
11009 )
11010 .await;
11011 assert!(
11012 loc.contains("kind%20of%20feed"),
11013 "expected the unsupported flash: {loc}"
11014 );
11015 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11016 }
11017
11018 #[tokio::test]
11021 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11022 let did = "did:plc:renamer5";
11023 let (sidecar, _log) = spawn_logging_sidecar().await;
11024 let state = with_config(
11025 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11026 |c| {
11027 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11028 },
11029 );
11030 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11031 assert_eq!(loc, "/", "the paste was refused: {loc}");
11032 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11033 .await
11034 .unwrap()
11035 .is_some());
11036 }
11037
11038 #[tokio::test]
11041 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11042 let did = "did:plc:renamer5";
11043 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11044 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11045 let opml = format!(
11046 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11047 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11048 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11049 </body></opml>"
11050 );
11051 let (ct, body) = opml_multipart(opml.as_bytes());
11052 let cookie = session_cookie(&state, did, None);
11053 let resp = router(state.clone())
11054 .oneshot(
11055 Request::builder()
11056 .method("POST")
11057 .uri("/opml")
11058 .header(header::COOKIE, cookie)
11059 .header("content-type", ct)
11060 .body(Body::from(body))
11061 .unwrap(),
11062 )
11063 .await
11064 .unwrap();
11065 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11066 let loc = resp
11067 .headers()
11068 .get(header::LOCATION)
11069 .unwrap()
11070 .to_str()
11071 .unwrap();
11072 assert!(
11073 loc.contains("Imported%202%20feeds"),
11074 "unexpected flash: {loc}"
11075 );
11076 assert!(
11077 !loc.contains("skipped"),
11078 "the at:// entry was skipped with the flag on: {loc}"
11079 );
11080 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11081 assert!(
11082 stored.is_some(),
11083 "the at:// entry was not stored with the flag on"
11084 );
11085 }
11086
11087 #[tokio::test]
11097 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11098 let did = "did:plc:renamer5";
11099 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11100 let tokened_enc =
11101 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11102 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11103 let state = test_state_with_sidecar(&[did], &sidecar).await;
11104 let loc = retitle_unchanged(&state, did, tokened_enc).await;
11105 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11106 assert_eq!(
11107 puts.lock().unwrap().len(),
11108 1,
11109 "the retitle did not reach the PDS"
11110 );
11111 assert!(
11112 store::get_feed_by_url(&state.db, tokened)
11113 .await
11114 .unwrap()
11115 .is_none(),
11116 "a secret-bearing URL was written to the shared cache by a retitle"
11117 );
11118 }
11119
11120 #[tokio::test]
11125 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11126 let did = "did:plc:renamer4";
11127 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11128 let state = test_state_with_sidecar(&[did], &sidecar).await;
11129 let cookie = session_cookie(&state, did, None);
11130 let resp = router(state.clone())
11131 .oneshot(
11132 Request::builder()
11133 .method("POST")
11134 .uri("/subscriptions/rk-keep/rename")
11135 .header(header::COOKIE, cookie)
11136 .header("content-type", "application/x-www-form-urlencoded")
11137 .body(Body::from(
11138 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11139 ))
11140 .unwrap(),
11141 )
11142 .await
11143 .unwrap();
11144 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11145 let loc = resp
11146 .headers()
11147 .get(header::LOCATION)
11148 .unwrap()
11149 .to_str()
11150 .unwrap();
11151 assert!(
11152 loc.contains("kind%20of%20feed"),
11153 "expected the unsupported flash: {loc}"
11154 );
11155 assert!(
11156 !loc.contains("Private"),
11157 "a typo was reported as a paid feed: {loc}"
11158 );
11159 assert!(puts.lock().unwrap().is_empty());
11160 }
11161
11162 #[tokio::test]
11163 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11164 let did = "did:plc:renamer4";
11165 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11166 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11167 store::upsert_feed(
11168 &state.db,
11169 &store::NewFeed {
11170 url: "https://filler.example/feed.xml".to_string(),
11171 ..Default::default()
11172 },
11173 )
11174 .await
11175 .unwrap();
11176 let cookie = session_cookie(&state, did, None);
11177 let resp = router(state.clone())
11178 .oneshot(
11179 Request::builder()
11180 .method("POST")
11181 .uri("/subscriptions/rk-keep/rename")
11182 .header(header::COOKIE, cookie)
11183 .header("content-type", "application/x-www-form-urlencoded")
11184 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11185 .unwrap(),
11186 )
11187 .await
11188 .unwrap();
11189 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11190 let loc = resp
11191 .headers()
11192 .get(header::LOCATION)
11193 .unwrap()
11194 .to_str()
11195 .unwrap();
11196 assert!(
11197 loc.contains("kind%20of%20feed"),
11198 "expected the unsupported flash: {loc}"
11199 );
11200 assert!(
11201 !loc.contains("capacity"),
11202 "an unacceptable URL was reported as a capacity problem: {loc}"
11203 );
11204 assert!(puts.lock().unwrap().is_empty());
11205 }
11206
11207 #[tokio::test]
11212 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11213 let did = "did:plc:renamer5";
11214 let padded = format!("{AT_URI_SUB} ");
11215 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11216 let state = test_state_with_sidecar(&[did], &sidecar).await;
11217 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11219 assert_eq!(
11220 loc, "/",
11221 "the retitle was treated as a repoint and refused: {loc}"
11222 );
11223 let bodies = puts.lock().unwrap().clone();
11224 assert_eq!(bodies.len(), 1);
11225 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11226 assert_eq!(
11227 sent["record"]["url"], AT_URI_SUB,
11228 "the padding was not normalised away"
11229 );
11230 }
11231
11232 #[tokio::test]
11238 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11239 let did = "did:plc:renamer5";
11240 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11241 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11242 store::upsert_feed(
11243 &state.db,
11244 &store::NewFeed {
11245 url: "https://filler.example/feed.xml".to_string(),
11246 ..Default::default()
11247 },
11248 )
11249 .await
11250 .unwrap();
11251 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11252 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11253 assert_eq!(puts.lock().unwrap().len(), 1);
11254 assert_eq!(
11255 store::count_feeds(&state.db).await.unwrap(),
11256 1,
11257 "a retitle inserted a cache row past the ceiling"
11258 );
11259 }
11260
11261 #[tokio::test]
11268 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11269 let did = "did:plc:typoist";
11270 let state = test_state_with_caps(did, 0, 0).await;
11271 let cookie = session_cookie(&state, did, None);
11272 let resp = router(state.clone())
11273 .oneshot(
11274 Request::builder()
11275 .method("POST")
11276 .uri("/subscriptions")
11277 .header(header::COOKIE, cookie)
11278 .header("content-type", "application/x-www-form-urlencoded")
11279 .body(Body::from(
11280 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11281 ))
11282 .unwrap(),
11283 )
11284 .await
11285 .unwrap();
11286 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11287 let loc = resp
11288 .headers()
11289 .get(header::LOCATION)
11290 .unwrap()
11291 .to_str()
11292 .unwrap();
11293 assert!(
11294 loc.contains("kind%20of%20feed"),
11295 "expected the unsupported flash: {loc}"
11296 );
11297 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11298 }
11299
11300 #[tokio::test]
11322 async fn renaming_preserves_the_fields_the_form_never_carries() {
11323 let did = "did:plc:renamer4";
11324 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11325 let state = test_state_with_sidecar(&[did], &sidecar).await;
11326 let cookie = session_cookie(&state, did, None);
11327
11328 let resp = router(state.clone())
11329 .oneshot(
11330 Request::builder()
11331 .method("POST")
11332 .uri("/subscriptions/rk-keep/rename")
11333 .header(header::COOKIE, cookie)
11334 .header("content-type", "application/x-www-form-urlencoded")
11335 .body(Body::from(
11337 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11338 ))
11339 .unwrap(),
11340 )
11341 .await
11342 .unwrap();
11343 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11344
11345 let bodies = puts.lock().unwrap().clone();
11346 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11347 let body = &bodies[0];
11348 assert!(
11350 body.contains("community.lexicon.rss.subscription"),
11351 "captured no usable put body: {body:?}"
11352 );
11353
11354 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11355 let record = &sent["record"];
11356
11357 assert_eq!(record["title"], "New title", "the rename did not apply");
11359 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11360
11361 assert_eq!(
11363 record["createdAt"], "2024-03-01T00:00:00.000Z",
11364 "the rename reset createdAt — the reader's subscribe time is gone \
11365 from their own repo, and nothing told them"
11366 );
11367 assert_eq!(
11368 record["siteUrl"], "https://example.com/blog",
11369 "the rename erased siteUrl"
11370 );
11371 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11372 assert_eq!(record["private"], false, "the rename erased private");
11373 }
11374
11375 #[tokio::test]
11384 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11385 let did = "did:plc:renamer4";
11386 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11387 let state = test_state_with_sidecar(&[did], &sidecar).await;
11388 let cookie = session_cookie(&state, did, None);
11389
11390 let resp = router(state.clone())
11391 .oneshot(
11392 Request::builder()
11393 .method("POST")
11394 .uri("/subscriptions/rk-keep/rename")
11395 .header(header::COOKIE, cookie)
11396 .header("content-type", "application/x-www-form-urlencoded")
11397 .body(Body::from(
11399 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11400 ))
11401 .unwrap(),
11402 )
11403 .await
11404 .unwrap();
11405 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11406
11407 let bodies = puts.lock().unwrap().clone();
11408 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11409 assert!(
11410 bodies[0].contains("community.lexicon.rss.subscription"),
11411 "captured no usable put body: {:?}",
11412 bodies[0]
11413 );
11414 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11415 let record = &sent["record"];
11416
11417 assert_eq!(record["url"], "https://other.example/feed.xml");
11418 assert!(
11420 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11421 "the old feed's site link followed the subscription to a new feed: {record}"
11422 );
11423 assert!(
11424 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11425 "the old feed's fetch hint followed the subscription to a new feed: {record}"
11426 );
11427 assert_eq!(
11429 record["createdAt"], "2024-03-01T00:00:00.000Z",
11430 "a repoint is still not a new subscription; createdAt must not move"
11431 );
11432 assert_eq!(record["private"], false, "the repoint erased private");
11433 }
11434
11435 #[tokio::test]
11447 async fn renaming_an_unknown_rkey_writes_nothing() {
11448 let did = "did:plc:renamer4";
11449 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11451 let state = test_state_with_sidecar(&[did], &sidecar).await;
11452 let cookie = session_cookie(&state, did, None);
11453
11454 let resp = router(state.clone())
11455 .oneshot(
11456 Request::builder()
11457 .method("POST")
11458 .uri("/subscriptions/rk-does-not-exist/rename")
11460 .header(header::COOKIE, cookie)
11461 .header("content-type", "application/x-www-form-urlencoded")
11462 .body(Body::from(
11463 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11464 ))
11465 .unwrap(),
11466 )
11467 .await
11468 .unwrap();
11469
11470 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11471 let loc = resp
11472 .headers()
11473 .get(header::LOCATION)
11474 .unwrap()
11475 .to_str()
11476 .unwrap();
11477 assert!(
11478 loc.contains("flash="),
11479 "an unknown rkey redirected as though the rename had worked: {loc}"
11480 );
11481 assert!(
11482 puts.lock().unwrap().is_empty(),
11483 "a rename against an unknown rkey wrote a record — putRecord would \
11484 CREATE it, dated today: {:?}",
11485 puts.lock().unwrap()
11486 );
11487 }
11488
11489 #[tokio::test]
11501 async fn a_client_supplied_site_url_reaches_the_record() {
11502 let did = "did:plc:renamer4";
11503 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11504 let state = test_state_with_sidecar(&[did], &sidecar).await;
11505 let cookie = session_cookie(&state, did, None);
11506
11507 let resp = router(state.clone())
11508 .oneshot(
11509 Request::builder()
11510 .method("POST")
11511 .uri("/subscriptions/rk-keep/rename")
11512 .header(header::COOKIE, cookie)
11513 .header("content-type", "application/x-www-form-urlencoded")
11514 .body(Body::from(
11517 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11518 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11519 ))
11520 .unwrap(),
11521 )
11522 .await
11523 .unwrap();
11524 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11525
11526 let bodies = puts.lock().unwrap().clone();
11527 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11528 assert!(
11529 bodies[0].contains("community.lexicon.rss.subscription"),
11530 "captured no usable put body: {:?}",
11531 bodies[0]
11532 );
11533 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11534 assert_eq!(
11535 sent["record"]["siteUrl"], "https://typed.example/site",
11536 "the client's siteUrl was dropped; the seeded record's survived instead"
11537 );
11538 }
11539
11540 #[tokio::test]
11548 async fn a_rename_whose_read_fails_writes_nothing() {
11549 let did = "did:plc:renamer5";
11550 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11552 let dead = format!("http://{}", listener.local_addr().unwrap());
11553 drop(listener);
11554
11555 let state = test_state_with_sidecar(&[did], &dead).await;
11556 let cookie = session_cookie(&state, did, None);
11557 let before = store::count_feeds(&state.db).await.unwrap();
11558
11559 let resp = router(state.clone())
11560 .oneshot(
11561 Request::builder()
11562 .method("POST")
11563 .uri("/subscriptions/rk-keep/rename")
11564 .header(header::COOKIE, cookie)
11565 .header("content-type", "application/x-www-form-urlencoded")
11566 .body(Body::from(
11567 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11568 ))
11569 .unwrap(),
11570 )
11571 .await
11572 .unwrap();
11573
11574 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11575 let loc = resp
11576 .headers()
11577 .get(header::LOCATION)
11578 .unwrap()
11579 .to_str()
11580 .unwrap();
11581 assert!(
11582 loc.contains("flash="),
11583 "a failed read redirected as though the rename had worked: {loc}"
11584 );
11585 assert_eq!(
11586 store::count_feeds(&state.db).await.unwrap(),
11587 before,
11588 "a rename that could not read the record still wrote to the cache"
11589 );
11590 }
11591
11592 #[test]
11598 fn manage_rename_row_preselects_current_folder() {
11599 let nav = Nav {
11600 handle: "@reader.example".to_string(),
11601 avatar: "RE".to_string(),
11602 view: "unread".to_string(),
11603 scope_qs: String::new(),
11604 folders: Vec::new(),
11605 loose_feeds: Vec::new(),
11606 manage_active: true,
11607 };
11608 let folder_options = vec![
11609 FolderOption {
11610 uri: "at://did:plc:x/app.folder/work".to_string(),
11611 name: "Work".to_string(),
11612 },
11613 FolderOption {
11614 uri: "at://did:plc:x/app.folder/fun".to_string(),
11615 name: "Fun".to_string(),
11616 },
11617 ];
11618 let foldered = FeedView {
11621 rkey: "sub-foldered".to_string(),
11622 url: "https://work.example/feed.xml".to_string(),
11623 title: "Work Feed".to_string(),
11624 unread: 0,
11625 selected: false,
11626 folder: Some("at://did:plc:x/app.folder/work".to_string()),
11627 };
11628 let loose = FeedView {
11629 rkey: "sub-loose".to_string(),
11630 url: "https://loose.example/feed.xml".to_string(),
11631 title: "Loose Feed".to_string(),
11632 unread: 0,
11633 selected: false,
11634 folder: None,
11635 };
11636 let tmpl = ManageTemplate {
11637 card: Card::private(&Config::default()),
11638 version: VERSION,
11639 repo_url: REPO_URL,
11640 kofi_url: KOFI_URL,
11641 flash: String::new(),
11642 alert: String::new(),
11643 nav,
11644 folder_options,
11645 folders: vec![FolderView {
11646 rkey: "folder-work".to_string(),
11647 uri: "at://did:plc:x/app.folder/work".to_string(),
11648 name: "Work".to_string(),
11649 feeds: vec![foldered],
11650 selected: false,
11651 }],
11652 loose_feeds: vec![loose],
11653 standard_site: false,
11654 };
11655 let html = tmpl.render().unwrap();
11656
11657 assert!(
11659 html.contains(
11660 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11661 ),
11662 "foldered feed must pre-select its current folder: {html}"
11663 );
11664 assert!(
11667 html.contains(r#"<option value="" selected>No folder</option>"#),
11668 "loose feed must pre-select 'No folder': {html}"
11669 );
11670 }
11671
11672 #[tokio::test]
11678 async fn the_public_stats_page_exposes_no_user_data() {
11679 let state = test_state(&[]).await;
11680 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11681 .await
11682 .unwrap();
11683
11684 let resp = router(state)
11685 .oneshot(
11686 Request::builder()
11687 .uri("/stats")
11688 .body(Body::empty())
11689 .unwrap(),
11690 )
11691 .await
11692 .unwrap();
11693 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11694
11695 let body = String::from_utf8(
11696 axum::body::to_bytes(resp.into_body(), usize::MAX)
11697 .await
11698 .unwrap()
11699 .to_vec(),
11700 )
11701 .unwrap();
11702
11703 assert!(
11709 !body.contains("did:"),
11710 "the public stats page leaked an identifier"
11711 );
11712 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11713 assert!(
11714 !body.contains(admin_only),
11715 "the public page is showing the admin metrics column {admin_only:?}"
11716 );
11717 }
11718 assert!(body.contains("Feeds tracked"));
11720 assert!(body.contains("Waiting to be polled"));
11721 }
11722
11723 #[tokio::test]
11731 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11732 let state = test_state(&[]).await;
11733 for (url, errors) in [
11735 ("https://ok.example/f.xml", 0),
11736 ("https://flaky.example/f.xml", 2),
11737 ("https://dead.example/f.xml", 9),
11738 ] {
11739 store::upsert_feed(
11740 &state.db,
11741 &store::NewFeed {
11742 url: url.to_string(),
11743 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11746 ..Default::default()
11747 },
11748 )
11749 .await
11750 .unwrap();
11751 for _ in 0..errors {
11752 store::bump_feed_errors(
11753 &state.db,
11754 url,
11755 feed::FailureKind::Fetch,
11756 "connection refused",
11757 )
11758 .await
11759 .unwrap();
11760 }
11761 }
11762
11763 let render_stats = |state: AppState| async move {
11764 let resp = router(state)
11765 .oneshot(
11766 Request::builder()
11767 .uri("/stats")
11768 .body(Body::empty())
11769 .unwrap(),
11770 )
11771 .await
11772 .unwrap();
11773 assert_eq!(resp.status(), StatusCode::OK);
11774 String::from_utf8(
11775 axum::body::to_bytes(resp.into_body(), usize::MAX)
11776 .await
11777 .unwrap()
11778 .to_vec(),
11779 )
11780 .unwrap()
11781 };
11782
11783 state.runtime_health.set_schedulers_enabled(true);
11790 state
11791 .runtime_health
11792 .poll_tick_completed(crate::store::now_unix());
11793
11794 let body = render_stats(state.clone()).await;
11795 assert!(
11796 body.contains("Failing"),
11797 "backoff is still invisible on the public page"
11798 );
11799 assert!(
11803 body.contains("2, 1 badly"),
11804 "expected '2, 1 badly' in the failing row; got:\n{}",
11805 body.split("Failing")
11806 .nth(1)
11807 .unwrap_or("")
11808 .chars()
11809 .take(300)
11810 .collect::<String>()
11811 );
11812 assert!(
11820 !body.contains("the poller is not running")
11821 && !body.contains("the cache is at its size limit")
11822 && !body.contains("has not completed a round"),
11823 "expected the running state; the page reported a stopped one",
11824 );
11825
11826 state.runtime_health.set_watermark(true);
11830 let paused = render_stats(state.clone()).await;
11831 assert!(
11836 paused.contains("the cache is at its size limit"),
11837 "a watermark pause is still invisible on the public page"
11838 );
11839
11840 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11842 assert!(
11843 !paused.contains(leak),
11844 "the public page leaked {leak:?} while reporting failures"
11845 );
11846 }
11847 }
11848
11849 #[tokio::test]
11861 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11862 let admin = "did:plc:adminseed";
11863 let state = test_state(&[admin]).await;
11872 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11873 .await
11874 .unwrap();
11875 let url = "https://broken.example/f.xml";
11876 store::upsert_feed(
11877 &state.db,
11878 &store::NewFeed {
11879 url: url.to_string(),
11880 ..Default::default()
11881 },
11882 )
11883 .await
11884 .unwrap();
11885 store::bump_feed_errors(
11886 &state.db,
11887 url,
11888 feed::FailureKind::Fetch,
11889 "SENTINEL_ADMIN_ONLY",
11890 )
11891 .await
11892 .unwrap();
11893
11894 let get = |state: AppState, cookie: Option<String>| async move {
11895 let mut req = Request::builder().uri("/admin/metrics");
11896 if let Some(c) = cookie {
11897 req = req.header(header::COOKIE, c);
11898 }
11899 let resp = router(state)
11900 .oneshot(req.body(Body::empty()).unwrap())
11901 .await
11902 .unwrap();
11903 let status = resp.status();
11904 let body = String::from_utf8(
11905 axum::body::to_bytes(resp.into_body(), usize::MAX)
11906 .await
11907 .unwrap()
11908 .to_vec(),
11909 )
11910 .unwrap();
11911 (status, body)
11912 };
11913
11914 let (status, body) = get(state.clone(), None).await;
11916 assert_eq!(status, StatusCode::UNAUTHORIZED);
11917 assert!(
11918 !body.contains("SENTINEL_ADMIN_ONLY"),
11919 "leaked to anonymous: {body}"
11920 );
11921
11922 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11924 let (status, body) = get(state.clone(), Some(ordinary)).await;
11925 assert_eq!(
11926 status,
11927 StatusCode::FORBIDDEN,
11928 "a non-admin session was let in"
11929 );
11930 assert!(
11931 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11932 "leaked to a non-admin: {body}",
11933 );
11934
11935 let admin_cookie = session_cookie(&state, admin, None);
11938 let (status, body) = get(state, Some(admin_cookie)).await;
11939 assert_eq!(status, StatusCode::OK);
11940 assert!(
11941 body.contains("SENTINEL_ADMIN_ONLY"),
11942 "admin cannot see it: {body}"
11943 );
11944 }
11945
11946 #[tokio::test]
11963 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11964 let admin = "did:plc:adminseed";
11965 let state = test_state(&[admin]).await;
11966 let url = "https://broken.example/f.xml";
11967 store::upsert_feed(
11968 &state.db,
11969 &store::NewFeed {
11970 url: url.to_string(),
11971 ..Default::default()
11972 },
11973 )
11974 .await
11975 .unwrap();
11976 store::bump_feed_errors(
11977 &state.db,
11978 url,
11979 feed::FailureKind::Fetch,
11980 "SENTINEL_REDIRECT_NO_LOCATION",
11981 )
11982 .await
11983 .unwrap();
11984
11985 let cookie = session_cookie(&state, admin, None);
11986 let resp = router(state.clone())
11987 .oneshot(
11988 Request::builder()
11989 .uri("/admin/metrics")
11990 .header(header::COOKIE, cookie)
11991 .body(Body::empty())
11992 .unwrap(),
11993 )
11994 .await
11995 .unwrap();
11996 assert_eq!(resp.status(), StatusCode::OK);
11997 let admin_body = String::from_utf8(
11998 axum::body::to_bytes(resp.into_body(), usize::MAX)
11999 .await
12000 .unwrap()
12001 .to_vec(),
12002 )
12003 .unwrap();
12004 assert!(
12005 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
12006 "the admin page does not carry the failure detail: {admin_body}",
12007 );
12008 assert!(
12009 admin_body.contains("broken.example"),
12010 "the admin page does not name the failing feed: {admin_body}",
12011 );
12012
12013 let resp = router(state)
12015 .oneshot(
12016 Request::builder()
12017 .uri("/stats")
12018 .body(Body::empty())
12019 .unwrap(),
12020 )
12021 .await
12022 .unwrap();
12023 let public = String::from_utf8(
12024 axum::body::to_bytes(resp.into_body(), usize::MAX)
12025 .await
12026 .unwrap()
12027 .to_vec(),
12028 )
12029 .unwrap();
12030 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
12031 assert!(
12032 !public.contains(secret),
12033 "{secret:?} reached the PUBLIC stats page: {public}",
12034 );
12035 }
12036 }
12037
12038 #[tokio::test]
12051 async fn a_successful_direct_poll_clears_a_stale_failure() {
12052 let state = test_state(&[]).await;
12053 let url = "https://recovered.example/f.xml";
12054 store::upsert_feed(
12055 &state.db,
12056 &store::NewFeed {
12057 url: url.to_string(),
12058 ..Default::default()
12059 },
12060 )
12061 .await
12062 .unwrap();
12063 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
12064 .await
12065 .unwrap();
12066 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
12068 .bind(url)
12069 .execute(&state.db)
12070 .await
12071 .unwrap();
12072
12073 feed::settle_poll(
12075 &state.db,
12076 url,
12077 &feed::PollOutcome::NotModified,
12078 state.config.poll_interval,
12079 )
12080 .await;
12081
12082 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12083 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12084 )
12085 .bind(url)
12086 .fetch_one(&state.db)
12087 .await
12088 .unwrap();
12089 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
12090 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
12091 let next = row.2.expect("next_poll was cleared to NULL");
12095 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12099 let delta = parsed
12100 .signed_duration_since(chrono::Utc::now())
12101 .num_seconds();
12102 let cadence = state.config.poll_interval.as_secs() as i64;
12103 assert!(
12104 (cadence - 60..=cadence + 60).contains(&delta),
12105 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
12106 );
12107 }
12108
12109 #[tokio::test]
12115 async fn a_failing_direct_poll_is_recorded() {
12116 let state = test_state(&[]).await;
12117 let url = "https://born-broken.example/f.xml";
12118 store::upsert_feed(
12119 &state.db,
12120 &store::NewFeed {
12121 url: url.to_string(),
12122 ..Default::default()
12123 },
12124 )
12125 .await
12126 .unwrap();
12127
12128 feed::settle_poll(
12129 &state.db,
12130 url,
12131 &feed::PollOutcome::Failed {
12132 backoff: std::time::Duration::from_secs(300),
12133 kind: feed::FailureKind::Parse,
12134 detail: "SENTINEL_BORN_BROKEN".to_string(),
12135 },
12136 state.config.poll_interval,
12137 )
12138 .await;
12139
12140 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12141 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12142 )
12143 .bind(url)
12144 .fetch_one(&state.db)
12145 .await
12146 .unwrap();
12147 assert_eq!(row.0, 1, "a failed first poll was not counted");
12148 assert_eq!(
12149 row.1.as_deref(),
12150 Some("parse"),
12151 "its cause was not recorded"
12152 );
12153 let next = row.2.expect("a failed direct poll left next_poll NULL");
12157 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12158 let delta = parsed
12159 .signed_duration_since(chrono::Utc::now())
12160 .num_seconds();
12161 assert!(
12162 (240..=360).contains(&delta),
12163 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12164 );
12165 }
12166
12167 #[tokio::test]
12179 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12180 let state = test_state(&[]).await;
12181 for url in [
12183 "https://legacy1.example/f.xml",
12184 "https://legacy2.example/f.xml",
12185 ] {
12186 store::upsert_feed(
12187 &state.db,
12188 &store::NewFeed {
12189 url: url.to_string(),
12190 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12191 ..Default::default()
12192 },
12193 )
12194 .await
12195 .unwrap();
12196 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12197 .bind(url)
12198 .execute(&state.db)
12199 .await
12200 .unwrap();
12201 }
12202 store::upsert_feed(
12204 &state.db,
12205 &store::NewFeed {
12206 url: "https://known.example/f.xml".to_string(),
12207 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12208 ..Default::default()
12209 },
12210 )
12211 .await
12212 .unwrap();
12213 store::bump_feed_errors(
12214 &state.db,
12215 "https://known.example/f.xml",
12216 feed::FailureKind::Status,
12217 "SENTINEL",
12218 )
12219 .await
12220 .unwrap();
12221
12222 let now = chrono::Utc::now();
12223 let health = store::poll_health(
12224 &state.db,
12225 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12226 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12227 )
12228 .await
12229 .unwrap();
12230 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12231 assert_eq!(
12232 counted, health.in_backoff,
12233 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12234 health.in_backoff, health.failure_kinds,
12235 );
12236 assert!(
12237 health
12238 .failure_kinds
12239 .iter()
12240 .any(|(k, n)| k == "unknown" && *n == 2),
12241 "no unknown bucket for the legacy rows: {:?}",
12242 health.failure_kinds,
12243 );
12244 }
12245
12246 #[tokio::test]
12251 async fn the_failure_breakdown_is_ordered_by_count() {
12252 let state = test_state(&[]).await;
12253 for (url, kind, n) in [
12254 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12255 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12256 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12257 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12258 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12259 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12260 ] {
12261 store::upsert_feed(
12262 &state.db,
12263 &store::NewFeed {
12264 url: url.to_string(),
12265 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12266 ..Default::default()
12267 },
12268 )
12269 .await
12270 .unwrap();
12271 for _ in 0..n {
12272 store::bump_feed_errors(&state.db, url, kind, "d")
12273 .await
12274 .unwrap();
12275 }
12276 }
12277 let now = chrono::Utc::now();
12278 let health = store::poll_health(
12279 &state.db,
12280 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12281 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12282 )
12283 .await
12284 .unwrap();
12285 let labels: Vec<&str> = health
12286 .failure_kinds
12287 .iter()
12288 .map(|(k, _)| k.as_str())
12289 .collect();
12290 assert_eq!(
12291 labels,
12292 ["fetch", "status", "parse"],
12293 "not ordered by count, descending: {:?}",
12294 health.failure_kinds,
12295 );
12296 }
12297
12298 #[tokio::test]
12310 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12311 let state = test_state(&[]).await;
12312 for (url, kind, detail, errors) in [
12313 (
12319 "https://a.example/f.xml",
12320 feed::FailureKind::Fetch,
12321 "SENTINEL_CONNREFUSED",
12322 3,
12323 ),
12324 (
12325 "https://b.example/f.xml",
12326 feed::FailureKind::Fetch,
12327 "SENTINEL_DNSFAIL",
12328 2,
12329 ),
12330 (
12331 "https://c.example/f.xml",
12332 feed::FailureKind::Status,
12333 "SENTINEL_404",
12334 1,
12335 ),
12336 (
12337 "https://d.example/f.xml",
12338 feed::FailureKind::Parse,
12339 "SENTINEL_UNPARSEABLE",
12340 1,
12341 ),
12342 ] {
12343 store::upsert_feed(
12344 &state.db,
12345 &store::NewFeed {
12346 url: url.to_string(),
12347 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12348 ..Default::default()
12349 },
12350 )
12351 .await
12352 .unwrap();
12353 for _ in 0..errors {
12354 store::bump_feed_errors(&state.db, url, kind, detail)
12355 .await
12356 .unwrap();
12357 }
12358 }
12359
12360 let resp = router(state.clone())
12361 .oneshot(
12362 Request::builder()
12363 .uri("/stats")
12364 .body(Body::empty())
12365 .unwrap(),
12366 )
12367 .await
12368 .unwrap();
12369 assert_eq!(resp.status(), StatusCode::OK);
12370 let body = String::from_utf8(
12371 axum::body::to_bytes(resp.into_body(), usize::MAX)
12372 .await
12373 .unwrap()
12374 .to_vec(),
12375 )
12376 .unwrap();
12377
12378 assert!(
12380 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12381 "the cause histogram did not render: {body}",
12382 );
12383
12384 for secret in [
12387 "a.example",
12388 "b.example",
12389 "c.example",
12390 "d.example",
12391 "SENTINEL_CONNREFUSED",
12392 "SENTINEL_DNSFAIL",
12393 "SENTINEL_404",
12394 "SENTINEL_UNPARSEABLE",
12395 ] {
12396 assert!(
12397 !body.contains(secret),
12398 "{secret:?} reached the PUBLIC stats page: {body}",
12399 );
12400 }
12401 }
12402
12403 #[tokio::test]
12406 async fn health_checks_the_database_and_reports_the_loops() {
12407 let state = test_state(&[]).await;
12408 let body_of = |state: AppState| async move {
12409 let resp = router(state)
12410 .oneshot(
12411 Request::builder()
12412 .uri("/health")
12413 .body(Body::empty())
12414 .unwrap(),
12415 )
12416 .await
12417 .unwrap();
12418 let status = resp.status();
12419 let body = String::from_utf8(
12420 axum::body::to_bytes(resp.into_body(), usize::MAX)
12421 .await
12422 .unwrap()
12423 .to_vec(),
12424 )
12425 .unwrap();
12426 (status, body)
12427 };
12428
12429 state
12432 .runtime_health
12433 .set_started_at(chrono::Utc::now().timestamp());
12434
12435 let (status, body) = body_of(state.clone()).await;
12436 assert_eq!(status, StatusCode::OK);
12437 assert!(
12438 body.contains("db: ok"),
12439 "health did not probe the DB: {body}"
12440 );
12441 assert!(
12442 body.contains("uptime:"),
12443 "no uptime — the first thing anyone asks about a container that may \
12444 be restarting: {body}"
12445 );
12446 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12447 assert!(body.contains("polling-paused: no"), "{body}");
12448 assert!(body.contains("backend:"), "{body}");
12449 assert!(body.contains("oauth-runtime:"), "{body}");
12450
12451 state.runtime_health.set_watermark(true);
12456 state.runtime_health.set_schedulers_enabled(true);
12457 let (status, body) = body_of(state.clone()).await;
12458 assert_eq!(
12459 status,
12460 StatusCode::OK,
12461 "a watermark pause must not fail the liveness check: {body}"
12462 );
12463 assert!(body.contains("polling-paused: yes"), "{body}");
12464 assert!(
12467 body.contains("poller: not-yet-ticked"),
12468 "a never-ticked poller must say so: {body}"
12469 );
12470
12471 let stale_after = health_tick_stale_secs(configured_poll_tick());
12473 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12474 state.runtime_health.poll_tick_completed(long_ago);
12475 let (status, body) = body_of(state.clone()).await;
12476 assert_eq!(
12477 status,
12478 StatusCode::OK,
12479 "a stale poller must not 503: {body}"
12480 );
12481 assert!(body.contains("poller: stale"), "{body}");
12482
12483 state.runtime_health.poll_tick_completed(0); state
12491 .runtime_health
12492 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12493 let (status, body) = body_of(state.clone()).await;
12494 assert_eq!(status, StatusCode::OK);
12495 assert!(
12496 body.contains("poller: stale never-ticked"),
12497 "a poller that never ticked long after boot still reads as benign: {body}"
12498 );
12499
12500 state.db.close().await;
12503 let (status, body) = body_of(state.clone()).await;
12504 assert_eq!(
12505 status,
12506 StatusCode::SERVICE_UNAVAILABLE,
12507 "an unreachable database must fail the check: {body}"
12508 );
12509 assert!(body.starts_with("FAIL"), "{body}");
12510 assert!(
12514 !body.contains("PoolClosed") && !body.contains("sqlx"),
12515 "health leaked the raw database error to an unauthenticated caller: {body}"
12516 );
12517 }
12518
12519 #[test]
12525 fn the_stale_threshold_follows_the_poll_tick() {
12526 assert_eq!(
12529 health_tick_stale_secs(Duration::from_secs(60)),
12530 HEALTH_TICK_STALE_FLOOR_SECS
12531 );
12532 let slow = Duration::from_secs(30 * 60);
12535 assert!(
12536 health_tick_stale_secs(slow) > slow.as_secs() as i64,
12537 "a 30-minute tick must not be stale after one interval"
12538 );
12539 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12540 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12542 }
12543
12544 #[tokio::test]
12550 async fn stats_does_not_call_a_stopped_poller_running() {
12551 let state = test_state(&[]).await;
12552 let render = |state: AppState| async move {
12553 let resp = router(state)
12554 .oneshot(
12555 Request::builder()
12556 .uri("/stats")
12557 .body(Body::empty())
12558 .unwrap(),
12559 )
12560 .await
12561 .unwrap();
12562 assert_eq!(resp.status(), StatusCode::OK);
12563 String::from_utf8(
12564 axum::body::to_bytes(resp.into_body(), usize::MAX)
12565 .await
12566 .unwrap()
12567 .to_vec(),
12568 )
12569 .unwrap()
12570 };
12571
12572 let body = render(state.clone()).await;
12574 assert!(
12575 body.contains("the poller is not running on this instance"),
12576 "a disabled poller renders as healthy"
12577 );
12578
12579 state.runtime_health.set_schedulers_enabled(true);
12581 let body = render(state.clone()).await;
12582 assert!(
12583 body.contains("no poll has finished since this instance booted"),
12584 "a poller that has not ticked renders as healthy"
12585 );
12586
12587 state
12589 .runtime_health
12590 .poll_tick_completed(chrono::Utc::now().timestamp());
12591 let body = render(state.clone()).await;
12592 assert!(
12593 body.contains("running"),
12594 "a healthy poller must read as running"
12595 );
12596
12597 state.runtime_health.set_watermark(true);
12599 let body = render(state.clone()).await;
12600 assert!(
12601 body.contains("the cache is at its size limit"),
12602 "a watermark pause is hidden once the poller is ticking"
12603 );
12604 }
12605
12606 #[tokio::test]
12617 async fn health_reports_an_unmeasured_database_without_failing() {
12618 use crate::runtime_health::DbProbe;
12619 let state = test_state(&[]).await;
12620
12621 let held = state
12624 .runtime_health
12625 .begin_db_probe()
12626 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12627
12628 let resp = router(state.clone())
12629 .oneshot(
12630 Request::builder()
12631 .uri("/health")
12632 .body(Body::empty())
12633 .unwrap(),
12634 )
12635 .await
12636 .unwrap();
12637 let status = resp.status();
12638 let body = String::from_utf8(
12639 axum::body::to_bytes(resp.into_body(), usize::MAX)
12640 .await
12641 .unwrap()
12642 .to_vec(),
12643 )
12644 .unwrap();
12645 drop(held);
12646
12647 assert_eq!(
12648 status,
12649 StatusCode::OK,
12650 "an unmeasured database failed the check, which an unauthenticated \
12651 caller can cause on demand: {body}"
12652 );
12653 assert!(
12654 body.contains("db: unknown"),
12655 "the unmeasured state must still be REPORTED: {body}"
12656 );
12657 assert!(!body.starts_with("FAIL"), "{body}");
12658 assert!(
12663 !body.starts_with("ok"),
12664 "the unmeasured state is indistinguishable from healthy to a \
12665 body-matching monitor: {body}"
12666 );
12667 assert!(body.starts_with("unknown"), "{body}");
12668
12669 let held = state
12680 .runtime_health
12681 .begin_db_probe()
12682 .unwrap_or_else(|_| panic!("claim"));
12683 state
12684 .runtime_health
12685 .record_for_test(DbProbe::Failed("unavailable".to_string()));
12686 let resp = router(state.clone())
12687 .oneshot(
12688 Request::builder()
12689 .uri("/health")
12690 .body(Body::empty())
12691 .unwrap(),
12692 )
12693 .await
12694 .unwrap();
12695 let status = resp.status();
12696 let body = String::from_utf8(
12697 axum::body::to_bytes(resp.into_body(), usize::MAX)
12698 .await
12699 .unwrap()
12700 .to_vec(),
12701 )
12702 .unwrap();
12703 drop(held);
12704 assert_eq!(
12705 status,
12706 StatusCode::SERVICE_UNAVAILABLE,
12707 "a BORROWED failure verdict must fail the check, not just a freshly \
12708 measured one: {body}"
12709 );
12710 assert!(body.starts_with("FAIL"), "{body}");
12711
12712 state.db.close().await;
12713 let resp = router(state.clone())
12714 .oneshot(
12715 Request::builder()
12716 .uri("/health")
12717 .body(Body::empty())
12718 .unwrap(),
12719 )
12720 .await
12721 .unwrap();
12722 assert_eq!(
12723 resp.status(),
12724 StatusCode::SERVICE_UNAVAILABLE,
12725 "a measured database failure must still fail the check"
12726 );
12727 }
12728
12729 #[tokio::test]
12738 async fn an_abandoned_request_still_records_its_probe() {
12739 use crate::runtime_health::DbProbe;
12740 let state = test_state(&[]).await;
12741 let rh = state.runtime_health.clone();
12742
12743 let app = router(state.clone());
12745 let fut = app.oneshot(
12746 Request::builder()
12747 .uri("/health")
12748 .body(Body::empty())
12749 .unwrap(),
12750 );
12751 let handle = tokio::spawn(fut);
12752 handle.abort();
12753 let _ = handle.await;
12754
12755 for _ in 0..50 {
12758 if rh.begin_db_probe().is_ok() {
12759 break;
12760 }
12761 tokio::time::sleep(Duration::from_millis(20)).await;
12762 }
12763 let resp = router(state.clone())
12764 .oneshot(
12765 Request::builder()
12766 .uri("/health")
12767 .body(Body::empty())
12768 .unwrap(),
12769 )
12770 .await
12771 .unwrap();
12772 let body = String::from_utf8(
12773 axum::body::to_bytes(resp.into_body(), usize::MAX)
12774 .await
12775 .unwrap()
12776 .to_vec(),
12777 )
12778 .unwrap();
12779 assert!(
12780 body.contains("db: ok"),
12781 "after an abandoned request the next caller still reads an \
12782 unmeasured database — the probe was cancelled with it: {body}"
12783 );
12784 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12786 }
12787
12788 #[tokio::test]
12795 async fn the_health_probe_opens_a_real_table() {
12796 use sqlx::Row;
12797 let state = test_state(&[]).await;
12798 let opcodes = |sql: &'static str| {
12800 let db = state.db.clone();
12801 async move {
12802 sqlx::query(sql)
12803 .fetch_all(&db)
12804 .await
12805 .unwrap()
12806 .into_iter()
12807 .map(|r| r.get::<String, _>("opcode"))
12808 .collect::<Vec<String>>()
12809 }
12810 };
12811
12812 let explain: &'static str =
12815 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12816 let probe = opcodes(explain).await;
12817 assert!(
12819 health_db_probe(&state.db).await.is_ok(),
12820 "the probe does not run against the real schema",
12821 );
12822 assert!(
12823 probe.iter().any(|op| op == "OpenRead"),
12824 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12825 );
12826 let bare = opcodes("EXPLAIN SELECT 1").await;
12828 assert!(
12829 !bare.iter().any(|op| op == "OpenRead"),
12830 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12831 );
12832 }
12833
12834 #[test]
12837 fn an_instance_that_has_never_polled_says_so() {
12838 assert_eq!(humanise_ago(None), "never");
12839 assert_eq!(humanise_ago(Some(0)), "0s ago");
12840 assert_eq!(humanise_ago(Some(59)), "59s ago");
12841 assert_eq!(humanise_ago(Some(60)), "1m ago");
12842 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12843 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12844 }
12845
12846 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12849 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12850 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12851 let addr = listener.local_addr().unwrap();
12852 let (url, title) = (saved_url.to_string(), saved_title.to_string());
12853 tokio::spawn(async move {
12854 loop {
12855 let Ok((mut sock, _)) = listener.accept().await else {
12856 break;
12857 };
12858 let mut buf = vec![0u8; 8192];
12859 let Ok(n) = sock.read(&mut buf).await else {
12860 continue;
12861 };
12862 let req = String::from_utf8_lossy(&buf[..n]).to_string();
12863 let wants_saved = req.contains("community.lexicon.rss.saved");
12864 let records = if wants_saved {
12865 serde_json::json!([{
12866 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12867 "cid": "bafy",
12868 "value": {
12869 "$type": "community.lexicon.rss.saved",
12870 "url": url,
12871 "title": title,
12872 "createdAt": "2026-01-01T00:00:00Z"
12873 }
12874 }])
12875 } else {
12876 serde_json::json!([])
12877 };
12878 let body = serde_json::json!({
12879 "ok": true, "data": { "records": records }
12880 })
12881 .to_string();
12882 let resp = format!(
12883 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12884 body.len(), body
12885 );
12886 let _ = sock.write_all(resp.as_bytes()).await;
12887 let _ = sock.flush().await;
12888 }
12889 });
12890 format!("http://{addr}")
12891 }
12892
12893 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12896 let feed = subscribed_feed.to_string();
12897 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12898 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12899 let addr = listener.local_addr().unwrap();
12900 tokio::spawn(async move {
12901 loop {
12902 let Ok((mut sock, _)) = listener.accept().await else {
12903 break;
12904 };
12905 let mut buf = vec![0u8; 8192];
12906 let Ok(read) = sock.read(&mut buf).await else {
12907 continue;
12908 };
12909 let req = String::from_utf8_lossy(&buf[..read]).to_string();
12910 let records = if req.contains("community.lexicon.rss.saved") {
12911 serde_json::Value::Array(
12912 (0..n)
12913 .map(|i| {
12914 serde_json::json!({
12915 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12916 "cid": "bafy",
12917 "value": {
12918 "$type": "community.lexicon.rss.saved",
12919 "url": format!("https://elsewhere.example/{i}"),
12920 "title": format!("Elsewhere {i}"),
12921 "createdAt": "2026-01-01T00:00:00Z"
12922 }
12923 })
12924 })
12925 .collect(),
12926 )
12927 } else if req.contains("community.lexicon.rss.subscription") {
12928 serde_json::json!([{
12933 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12934 "cid": "bafy",
12935 "value": {
12936 "$type": "community.lexicon.rss.subscription",
12937 "url": feed,
12938 "createdAt": "2026-01-01T00:00:00Z"
12939 }
12940 }])
12941 } else {
12942 serde_json::json!([])
12943 };
12944 let body =
12945 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12946 let resp = format!(
12947 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12948 body.len(), body
12949 );
12950 let _ = sock.write_all(resp.as_bytes()).await;
12951 let _ = sock.flush().await;
12952 }
12953 });
12954 format!("http://{addr}")
12955 }
12956
12957 #[tokio::test]
12965 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12966 let did = "did:plc:pagerloop";
12967 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12968 let state = test_state_with_sidecar(&[], &sidecar).await;
12969 store::grant_access(&state.db, did, None, "test", None)
12970 .await
12971 .unwrap();
12972 let feed = store::upsert_feed(
12973 &state.db,
12974 &store::NewFeed {
12975 url: "https://loop.example/feed.xml".to_string(),
12976 title: Some("Loop".to_string()),
12977 ..Default::default()
12978 },
12979 )
12980 .await
12981 .unwrap();
12982 let entries: Vec<store::NewEntry> = (0..250)
12985 .map(|i| store::NewEntry {
12986 guid: format!("s-{i:04}"),
12987 url: Some(format!("https://loop.example/{i}")),
12988 title: Some(format!("Starred {i:04}")),
12989 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12990 ..Default::default()
12991 })
12992 .collect();
12993 store::insert_entries(&state.db, feed, &entries, 0)
12994 .await
12995 .unwrap();
12996 store::replace_sub_refs(&state.db, did, &[feed])
12997 .await
12998 .unwrap();
12999 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
13000 .await
13001 .unwrap()
13002 {
13003 store::mark_starred(&state.db, did, row.id, true)
13004 .await
13005 .unwrap();
13006 }
13007
13008 let cookie = session_cookie(&state, did, None);
13009 let app = router(state.clone());
13010 let get = |uri: &str| {
13011 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
13012 async move {
13013 let resp = app
13014 .oneshot(
13015 Request::builder()
13016 .uri(uri)
13017 .header(header::COOKIE, cookie)
13018 .body(Body::empty())
13019 .unwrap(),
13020 )
13021 .await
13022 .unwrap();
13023 assert_eq!(resp.status(), StatusCode::OK);
13024 String::from_utf8(
13025 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
13026 .await
13027 .unwrap()
13028 .to_vec(),
13029 )
13030 .unwrap()
13031 }
13032 };
13033
13034 let p3 = get("/?view=starred&page=3").await;
13039 assert!(
13040 p3.contains("Page 3 of 4"),
13041 "the pager and the clamp disagree on the total: {}",
13042 p3.split("pager-pos")
13043 .nth(1)
13044 .unwrap_or("")
13045 .chars()
13046 .take(120)
13047 .collect::<String>()
13048 );
13049 assert!(
13052 p3.contains("Elsewhere 0"),
13053 "page 3 should start the uncached run"
13054 );
13055 assert_eq!(
13056 p3.matches("<li class=\"entry").count(),
13057 ENTRIES_PER_PAGE as usize,
13058 "the boundary page is not full"
13059 );
13060
13061 {
13070 let body = &p3;
13071 assert!(
13072 body.contains("330 entries"),
13073 "the heading must count the whole sequence: {}",
13074 body.split("content-count")
13075 .nth(1)
13076 .unwrap_or("")
13077 .chars()
13078 .take(120)
13079 .collect::<String>()
13080 );
13081 assert!(
13082 body.contains("(80 saved elsewhere)"),
13083 "the heading must say how many of the total the cache cannot show, \
13084 as a whole-list figure and not a per-page one: {}",
13085 body.split("content-count")
13086 .nth(1)
13087 .unwrap_or("")
13088 .chars()
13089 .take(120)
13090 .collect::<String>()
13091 );
13092 assert!(
13093 !body.contains("plus 50") && !body.contains("plus 80"),
13094 "the heading is adding the uncached rows to a total that already \
13095 includes them"
13096 );
13097 }
13098
13099 let p4 = get("/?view=starred&page=4").await;
13100 assert!(
13101 p4.contains("Page 4 of 4"),
13102 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
13103 );
13104 assert_eq!(
13105 p4.matches("<li class=\"entry").count(),
13106 30,
13107 "page 4 should hold the remaining 30 uncached records"
13108 );
13109 assert!(
13110 p4.contains("Elsewhere 79"),
13111 "the LAST saved record is unreachable — it can only be removed from here"
13112 );
13113
13114 assert!(
13116 !p4.contains("Elsewhere 0"),
13117 "an uncached record was rendered on more than one page"
13118 );
13119 let first = get("/?view=starred").await;
13122 assert!(
13123 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
13124 "the heading changed between pages; it describes the list, not the page"
13125 );
13126 assert!(
13127 !first.contains("Elsewhere "),
13128 "uncached saved records leaked onto the first page"
13129 );
13130 }
13131
13132 #[tokio::test]
13139 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
13140 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
13141 let sidecar =
13142 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
13143 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
13144 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
13145
13146 let resp = router(state)
13147 .oneshot(
13148 Request::builder()
13149 .uri("/?view=starred")
13150 .body(Body::empty())
13151 .unwrap(),
13152 )
13153 .await
13154 .unwrap();
13155 assert_eq!(resp.status(), StatusCode::OK);
13156 let body = String::from_utf8(
13157 axum::body::to_bytes(resp.into_body(), usize::MAX)
13158 .await
13159 .unwrap()
13160 .to_vec(),
13161 )
13162 .unwrap();
13163
13164 assert!(
13165 body.contains("Starred elsewhere"),
13166 "the saved record was not rendered at all"
13167 );
13168 assert!(
13169 body.contains("entry-uncached"),
13170 "it was not marked as uncached, so it looks like a normal entry"
13171 );
13172 assert!(
13173 body.contains("https://elsewhere.example/article"),
13174 "the row must link straight to the article"
13175 );
13176 assert!(
13177 !body.contains("/entries/0/"),
13178 "an uncached row must not offer entry actions against a nonexistent id"
13179 );
13180 }
13181
13182 #[test]
13190 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13191 for hostile in [
13192 "日本語日本語日本",
13193 "é",
13194 "",
13195 "2026",
13196 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13197 ] {
13198 let out = display_date(Some(hostile));
13199 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13200 }
13201 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13202 assert_eq!(display_date(None), "");
13203 }
13204
13205 #[test]
13208 fn the_unsave_route_is_rate_limited() {
13209 use axum::http::Method;
13210 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13211 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13213 }
13214
13215 #[tokio::test]
13224 async fn health_reports_a_broken_database() {
13225 let state = test_state(&[]).await;
13226 assert!(
13228 health_db_probe(&state.db).await.is_ok(),
13229 "the fixture was not healthy to begin with",
13230 );
13231
13232 sqlx::query("DROP TABLE feeds")
13233 .execute(&state.db)
13234 .await
13235 .unwrap();
13236
13237 assert!(
13238 health_db_probe(&state.db).await.is_err(),
13239 "the probe reported success against a database missing the table it \
13240 claims to read; `SELECT 1` would do exactly this",
13241 );
13242
13243 let resp = router(state)
13244 .oneshot(
13245 Request::builder()
13246 .uri("/health")
13247 .body(Body::empty())
13248 .unwrap(),
13249 )
13250 .await
13251 .unwrap();
13252 let body = String::from_utf8(
13253 axum::body::to_bytes(resp.into_body(), usize::MAX)
13254 .await
13255 .unwrap()
13256 .to_vec(),
13257 )
13258 .unwrap();
13259 assert!(
13261 body.starts_with("FAIL"),
13262 "/health did not report FAIL for a broken database: {body}",
13263 );
13264 assert!(
13265 !body.contains("db: ok"),
13266 "/health still called the database ok: {body}",
13267 );
13268 }
13269
13270 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13275 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13276 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13277 let addr = listener.local_addr().unwrap();
13278 tokio::spawn(async move {
13279 loop {
13280 let Ok((mut sock, _)) = listener.accept().await else {
13281 break;
13282 };
13283 let mut buf = vec![0u8; 8192];
13284 let Ok(n) = sock.read(&mut buf).await else {
13285 continue;
13286 };
13287 let req = String::from_utf8_lossy(&buf[..n]).to_string();
13288 let wants = |c: &str| req.contains(c);
13289 if fail_on.is_some_and(wants) {
13290 let body = r#"{"ok":false,"error":"ShortList"}"#;
13291 let resp = format!(
13292 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13293 body.len(),
13294 body
13295 );
13296 let _ = sock.write_all(resp.as_bytes()).await;
13297 let _ = sock.flush().await;
13298 continue;
13299 }
13300 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13301 serde_json::json!([{
13302 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13303 "cid": "bafy",
13304 "value": {
13305 "$type": crate::lexicon::nsid::SUBSCRIPTION,
13306 "url": "https://kept.example/feed.xml",
13307 "title": "Kept",
13308 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13313 "createdAt": "2026-01-01T00:00:00Z"
13314 }
13315 }])
13316 } else if wants(crate::lexicon::nsid::FOLDER) {
13317 serde_json::json!([{
13318 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13319 "cid": "bafy",
13320 "value": {
13321 "$type": crate::lexicon::nsid::FOLDER,
13322 "name": "Kept folder",
13323 "createdAt": "2026-01-01T00:00:00Z"
13324 }
13325 }])
13326 } else {
13327 serde_json::json!([])
13328 };
13329 let body =
13330 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13331 let resp = format!(
13332 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13333 body.len(),
13334 body
13335 );
13336 let _ = sock.write_all(resp.as_bytes()).await;
13337 let _ = sock.flush().await;
13338 }
13339 });
13340 format!("http://{addr}")
13341 }
13342
13343 async fn spawn_malformed_sidecar() -> String {
13346 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13347 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13348 let addr = listener.local_addr().unwrap();
13349 tokio::spawn(async move {
13350 loop {
13351 let Ok((mut sock, _)) = listener.accept().await else {
13352 break;
13353 };
13354 let mut buf = vec![0u8; 8192];
13355 let _ = sock.read(&mut buf).await;
13356 let body = serde_json::json!({ "ok": true, "data": { "records": [
13357 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13358 { "cid": "bafy", "value": {} },
13359 ]}})
13360 .to_string();
13361 let resp = format!(
13362 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13363 body.len(),
13364 body
13365 );
13366 let _ = sock.write_all(resp.as_bytes()).await;
13367 let _ = sock.flush().await;
13368 }
13369 });
13370 format!("http://{addr}")
13371 }
13372
13373 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13374 let cookie = session_cookie(&state, did, None);
13375 let resp = router(state)
13376 .oneshot(
13377 Request::builder()
13378 .uri(uri)
13379 .header(header::COOKIE, cookie)
13380 .body(Body::empty())
13381 .unwrap(),
13382 )
13383 .await
13384 .unwrap();
13385 let status = resp.status();
13386 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13387 .await
13388 .unwrap();
13389 (status, String::from_utf8_lossy(&body).to_string())
13390 }
13391
13392 #[tokio::test]
13398 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13399 let did = "did:plc:displayer";
13400 let state = test_state(&[did]).await;
13401 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13402 let feed_id = store::upsert_feed(
13403 &state.db,
13404 &store::NewFeed {
13405 url: url.into(),
13406 title: Some("Quiet Journal".into()),
13407 ..Default::default()
13408 },
13409 )
13410 .await
13411 .unwrap();
13412 store::replace_sub_refs(&state.db, did, &[feed_id])
13413 .await
13414 .unwrap();
13415 store::insert_entries(
13416 &state.db,
13417 feed_id,
13418 &[store::NewEntry {
13419 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13420 .into(),
13421 url: Some("https://quiet.example/no-summary".into()),
13422 title: Some("A title-only article".into()),
13423 published: Some("2026-07-11T00:00:00Z".into()),
13424 content_html: None,
13425 ..Default::default()
13426 }],
13427 0,
13428 )
13429 .await
13430 .unwrap();
13431 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13432 assert_eq!(status, StatusCode::OK);
13433 assert!(
13434 list.contains("A title-only article"),
13435 "the entry is missing from the list"
13436 );
13437
13438 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13439 .bind(feed_id)
13440 .fetch_one(&state.db)
13441 .await
13442 .unwrap();
13443 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13444 assert_eq!(
13445 status,
13446 StatusCode::OK,
13447 "the article page failed for an entry with no body"
13448 );
13449 assert!(page.contains("A title-only article"));
13450 assert!(
13451 page.contains("https://quiet.example/no-summary"),
13452 "no link to the original"
13453 );
13454 assert!(
13455 page.contains(r#"<time datetime=""#),
13456 "no date on the article page"
13457 );
13458 }
13459
13460 #[tokio::test]
13465 async fn a_malformed_subscription_record_raises_an_alert() {
13466 let did = "did:plc:alerted";
13467 for page in ["/", "/manage"] {
13468 let sidecar = spawn_malformed_sidecar().await;
13469 let state = test_state_with_sidecar(&[did], &sidecar).await;
13470 let (status, body) = page_body(state, did, page).await;
13471 assert_eq!(status, StatusCode::OK, "{page} did not render");
13472 assert!(
13473 body.contains(r#"role="alert""#) && body.contains("could not be read"),
13474 "{page} rendered no alert for a refused subscription list"
13475 );
13476 assert!(
13477 body.contains("1 record(s) in your subscription list"),
13478 "{page} gave the generic alert, not the malformed-record one"
13479 );
13480 }
13481 }
13482
13483 #[tokio::test]
13485 async fn a_healthy_subscription_listing_raises_no_alert() {
13486 let did = "did:plc:exporter";
13487 let sidecar = spawn_export_sidecar(None).await;
13488 let state = test_state_with_sidecar(&[did], &sidecar).await;
13489 let (status, body) = page_body(state, did, "/").await;
13490 assert_eq!(status, StatusCode::OK);
13491 assert!(
13492 !body.contains("could not be read"),
13493 "a healthy listing raised an alert"
13494 );
13495 }
13496
13497 async fn export_opml_response(
13499 fail_on: Option<&'static str>,
13500 ) -> (StatusCode, HeaderMap, String) {
13501 let did = "did:plc:exporter";
13502 let sidecar = spawn_export_sidecar(fail_on).await;
13503 let state = test_state_with_sidecar(&[did], &sidecar).await;
13504 let cookie = session_cookie(&state, did, None);
13505 let resp = router(state)
13506 .oneshot(
13507 Request::builder()
13508 .uri("/opml/export")
13509 .header(header::COOKIE, cookie)
13510 .body(Body::empty())
13511 .unwrap(),
13512 )
13513 .await
13514 .unwrap();
13515 let status = resp.status();
13516 let headers = resp.headers().clone();
13517 let body = String::from_utf8_lossy(
13518 &axum::body::to_bytes(resp.into_body(), usize::MAX)
13519 .await
13520 .unwrap(),
13521 )
13522 .to_string();
13523 (status, headers, body)
13524 }
13525
13526 #[tokio::test]
13539 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13540 let (status, headers, body) =
13541 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13542
13543 assert_ne!(
13544 status,
13545 StatusCode::OK,
13546 "a failed subscription walk answered 200: {body}",
13547 );
13548 assert!(
13549 !headers.contains_key(header::CONTENT_DISPOSITION),
13550 "a failed subscription walk still offered a download: {headers:?}",
13551 );
13552 assert!(
13553 !body.contains("<opml"),
13554 "a failed subscription walk still served an OPML document: {body}",
13555 );
13556 }
13557
13558 #[tokio::test]
13562 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13563 let (status, headers, body) =
13564 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13565
13566 assert_ne!(
13567 status,
13568 StatusCode::OK,
13569 "a failed folder walk answered 200: {body}",
13570 );
13571 assert!(
13572 !headers.contains_key(header::CONTENT_DISPOSITION),
13573 "a failed folder walk still offered a download: {headers:?}",
13574 );
13575 assert!(
13576 !body.contains("<opml"),
13577 "a failed folder walk still served an OPML document: {body}",
13578 );
13579 }
13580
13581 #[tokio::test]
13584 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13585 let (status, headers, body) = export_opml_response(None).await;
13586
13587 assert_eq!(
13588 status,
13589 StatusCode::OK,
13590 "a healthy export did not answer 200"
13591 );
13592 assert_eq!(
13593 headers
13594 .get(header::CONTENT_DISPOSITION)
13595 .and_then(|v| v.to_str().ok()),
13596 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13597 "a healthy export did not offer the download",
13598 );
13599 assert!(
13600 body.contains("https://kept.example/feed.xml"),
13601 "the exported OPML lost the subscription: {body}",
13602 );
13603 assert!(
13604 body.contains("Kept folder"),
13605 "the exported OPML lost the folder: {body}",
13606 );
13607 }
13608}