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.6",
1577 date: "2026-10-06",
1578 summary: "Renaming a subscription or a folder no longer overwrites \
1579 what another app changed at the same moment, and a folder \
1580 rename keeps everything but the name.",
1581 },
1582 Release {
1583 version: "0.4.5",
1584 date: "2026-10-06",
1585 summary: "An operator teardown now signs every user out at their own \
1586 server before deleting anything, and the session-writing \
1587 code is hardened against the races that work exposed.",
1588 },
1589 Release {
1590 version: "0.4.4",
1591 date: "2026-10-05",
1592 summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1593 links unchanged and real RSS bylines, and the address guard \
1594 refuses the reserved ranges it missed.",
1595 },
1596 Release {
1597 version: "0.4.3",
1598 date: "2026-10-05",
1599 summary: "Two write-path fixes for any PDS: large OPML imports and \
1600 read-state syncs are sent in calls the PDS accepts, and a \
1601 read-state sync that disagreed with the PDS recovers instead \
1602 of failing every round.",
1603 },
1604 Release {
1605 version: "0.4.2",
1606 date: "2026-10-04",
1607 summary: "A public standard.site feature page with this list of recent \
1608 releases, and link cards: a posted feather-reader.com link \
1609 now unfurls with a description and an image.",
1610 },
1611 Release {
1612 version: "0.4.1",
1613 date: "2026-10-04",
1614 summary: "The public pages explain standard.site publications, and the \
1615 subscribe form can submit the DID form of a publication URI, \
1616 which browsers refused in 0.4.0.",
1617 },
1618 Release {
1619 version: "0.4.0",
1620 date: "2026-10-03",
1621 summary: "standard.site support: publications are read from their \
1622 authors' atproto repos as subscriptions, beside RSS, on their \
1623 own polling loop. Every stored field from a feed or a \
1624 publication now has a size bound.",
1625 },
1626];
1627
1628#[derive(Template)]
1640#[template(path = "stats.html")]
1641struct StatsTemplate {
1642 card: Card,
1644 version: &'static str,
1645 repo_url: &'static str,
1646 kofi_url: &'static str,
1647 feeds_tracked: i64,
1648 polled_last_hour: i64,
1649 polled_pct: i64,
1650 overdue: i64,
1651 last_poll: String,
1652 oldest_poll: String,
1653 never_polled: i64,
1654 poll_interval_mins: i64,
1655 in_backoff: i64,
1657 badly_broken: i64,
1661 failure_kinds: Vec<(String, i64)>,
1663 fetching: &'static str,
1667}
1668
1669#[derive(Template)]
1673#[template(path = "privacy.html")]
1674struct PrivacyTemplate {
1675 card: Card,
1677 version: &'static str,
1678 repo_url: &'static str,
1679 kofi_url: &'static str,
1680}
1681
1682#[derive(Template)]
1685#[template(path = "terms.html")]
1686struct TermsTemplate {
1687 card: Card,
1689 version: &'static str,
1690 repo_url: &'static str,
1691 kofi_url: &'static str,
1692}
1693
1694#[derive(Template)]
1697#[template(path = "landing.html")]
1698struct LandingTemplate {
1699 card: Card,
1701 version: &'static str,
1702 repo_url: &'static str,
1703 crates_url: &'static str,
1704 kofi_url: &'static str,
1705 standard_site: bool,
1708 releases: &'static [Release],
1710}
1711
1712#[derive(Template)]
1714#[template(path = "entry.html")]
1715struct EntryTemplate {
1716 card: Card,
1718 version: &'static str,
1719 repo_url: &'static str,
1720 kofi_url: &'static str,
1721 nav: Nav,
1722 id: i64,
1723 title: String,
1724 feed_title: String,
1725 author: Option<String>,
1726 published: String,
1727 url: Option<SafeLink>,
1737 content_html: Option<String>,
1738 read: bool,
1739 starred: bool,
1740 back_qs: String,
1742 prev_id: Option<i64>,
1744 next_id: Option<i64>,
1745 oob: bool,
1747}
1748
1749#[derive(Template)]
1751#[template(path = "entry_row.html")]
1752struct EntryRowTemplate {
1753 e: EntryRow,
1754}
1755
1756#[derive(Template)]
1761#[template(path = "entry_actionbar.html")]
1762struct EntryActionBarTemplate {
1763 id: i64,
1764 read: bool,
1765 starred: bool,
1766 oob: bool,
1768}
1769
1770#[derive(Template)]
1772#[template(path = "login.html")]
1773struct LoginTemplate {
1774 card: Card,
1776 repo_url: &'static str,
1777 error: String,
1778 flash: String,
1781}
1782
1783#[derive(Template)]
1785#[template(path = "beta_redeem.html")]
1786struct BetaRedeemTemplate {
1787 card: Card,
1789 repo_url: &'static str,
1790 error: String,
1791 capacity_full: bool,
1794}
1795
1796fn render<T: Template>(tmpl: &T) -> Response {
1803 match tmpl.render() {
1804 Ok(body) => Html(body).into_response(),
1805 Err(err) => {
1806 warn!(%err, "template render failed");
1807 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1808 }
1809 }
1810}
1811
1812struct WebError {
1817 err: anyhow::Error,
1818 status: StatusCode,
1819}
1820
1821impl<E: Into<anyhow::Error>> From<E> for WebError {
1822 fn from(err: E) -> Self {
1823 WebError {
1824 err: err.into(),
1825 status: StatusCode::INTERNAL_SERVER_ERROR,
1826 }
1827 }
1828}
1829
1830impl WebError {
1831 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1833 WebError {
1834 err: err.into(),
1835 status,
1836 }
1837 }
1838}
1839
1840impl IntoResponse for WebError {
1841 fn into_response(self) -> Response {
1842 warn!(error = %self.err, status = %self.status, "request failed");
1843 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1844 "internal error"
1845 } else {
1846 self.status.canonical_reason().unwrap_or("error")
1847 };
1848 (self.status, body).into_response()
1849 }
1850}
1851
1852fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1857 let status = err.status();
1858 WebError::with_status(err, status)
1859}
1860
1861fn display_title(title: Option<&str>, url: &str) -> String {
1864 if let Some(t) = title {
1865 let t = t.trim();
1866 if !t.is_empty() {
1867 return t.to_string();
1868 }
1869 }
1870 url::Url::parse(url)
1871 .ok()
1872 .and_then(|u| u.host_str().map(str::to_string))
1873 .unwrap_or_else(|| url.to_string())
1874}
1875
1876fn display_handle(handle: Option<&str>, did: &str) -> String {
1879 match handle {
1880 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1881 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1882 }
1883}
1884
1885fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1887 let source = handle
1888 .map(|h| h.trim().trim_start_matches('@'))
1889 .filter(|h| !h.is_empty())
1890 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1891 let letters: String = source
1892 .chars()
1893 .filter(|c| c.is_alphanumeric())
1894 .take(2)
1895 .collect::<String>()
1896 .to_lowercase();
1897 if letters.is_empty() {
1898 "fr".to_string()
1899 } else {
1900 letters
1901 }
1902}
1903
1904fn display_date(published: Option<&str>) -> String {
1907 match published {
1916 Some(p) => p.chars().take(10).collect(),
1917 None => String::new(),
1918 }
1919}
1920
1921fn qenc(s: &str) -> String {
1925 let mut out = String::with_capacity(s.len() * 3);
1926 for b in s.bytes() {
1927 match b {
1928 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1929 out.push(b as char)
1930 }
1931 _ => out.push_str(&format!("%{b:02X}")),
1932 }
1933 }
1934 out
1935}
1936
1937#[derive(Debug, Deserialize, Default)]
1943struct IndexQuery {
1944 #[serde(default)]
1946 feed: Option<String>,
1947 #[serde(default)]
1949 folder: Option<String>,
1950 #[serde(default)]
1952 view: Option<String>,
1953 #[serde(default)]
1955 page: Option<u32>,
1956 #[serde(default)]
1958 flash: Option<String>,
1959}
1960
1961const ENTRIES_PER_PAGE: i64 = 100;
1969
1970fn page_count_for(total: i64) -> i64 {
1973 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1974}
1975
1976const PREV_NEXT_MAX: i64 = 5_000;
1983
1984const STARRED_IDENTITY_MAX: i64 = 20_000;
1992
1993const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2007
2008struct ResolvedSub {
2011 rkey: String,
2012 sub: Subscription,
2013 feed: Option<store::Feed>,
2014}
2015
2016async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2020 resolve_subscriptions_noting(state, did).await.0
2021}
2022
2023fn subscriptions_alert(err: &anyhow::Error) -> String {
2030 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2031 Some(m) => format!(
2032 "{} record(s) in your subscription list could not be read, so it was not \
2033 refreshed. Showing your last-known subscriptions; nothing was removed.",
2034 m.count
2035 ),
2036 None => "Your subscription list could not be read from your PDS just now. \
2037 Showing your last-known subscriptions."
2038 .to_string(),
2039 }
2040}
2041
2042async fn resolve_subscriptions_noting(
2045 state: &AppState,
2046 did: &str,
2047) -> (Vec<ResolvedSub>, Option<String>) {
2048 let pool = &state.db;
2049 let subs = match state.repo().list_subscriptions_sorted(did).await {
2050 Ok(s) => s,
2051 Err(err) => {
2052 let alert = subscriptions_alert(&err);
2053 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2054 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2067 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2068 projection could not be read; rendering an EMPTY \
2069 feed list, which is not the same as having none");
2070 Vec::new()
2071 });
2072 let cached = feeds
2073 .into_iter()
2074 .map(|f| ResolvedSub {
2075 rkey: String::new(),
2076 sub: Subscription::new(f.url.clone(), now_rfc3339()),
2077 feed: Some(f),
2078 })
2079 .collect();
2080 return (cached, Some(alert));
2081 }
2082 };
2083
2084 let mut out = Vec::with_capacity(subs.len());
2100 for (rkey, sub) in subs {
2101 let feed = match store::get_feed_by_url(pool, &sub.url).await {
2102 Ok(Some(f)) => Some(f),
2103 Ok(None) => {
2104 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2115 || feed::classify_feed_privacy(&sub.url).is_private()
2116 {
2117 warn!(
2118 %did,
2119 "skipping cache row for a subscription URL that is private or not http(s)"
2120 );
2121 out.push(ResolvedSub {
2122 rkey,
2123 sub,
2124 feed: None,
2125 });
2126 continue;
2127 }
2128 if let Err(err) = store::upsert_feed(
2136 pool,
2137 &store::NewFeed {
2138 url: sub.url.clone(),
2139 title: sub.title.clone(),
2140 site_url: sub.site_url.clone(),
2141 ..Default::default()
2142 },
2143 )
2144 .await
2145 {
2146 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2147 it will not be polled");
2148 }
2149 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2150 }
2151 Err(err) => {
2152 warn!(%err, url = %sub.url, "get_feed_by_url failed");
2153 None
2154 }
2155 };
2156 out.push(ResolvedSub { rkey, sub, feed });
2157 }
2158 sync_sub_refs(pool, did, &out).await;
2162 (out, None)
2163}
2164
2165async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2169 let feed_ids: Vec<i64> = subs
2170 .iter()
2171 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2172 .collect();
2173 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2174 warn!(%err, %did, "failed to sync sub_ref projection");
2175 }
2176}
2177
2178async fn index(
2181 State(state): State<AppState>,
2182 headers: HeaderMap,
2183 Query(q): Query<IndexQuery>,
2184) -> Result<Response, WebError> {
2185 let user = match current_session(&state, &headers).await {
2186 Some(u) => u,
2187 None => {
2190 return Ok(render(&LandingTemplate {
2191 card: Card::site(&state.config),
2192 version: VERSION,
2193 repo_url: REPO_URL,
2194 crates_url: CRATES_URL,
2195 kofi_url: KOFI_URL,
2196 standard_site: state.config.standard_site,
2197 releases: RELEASES,
2198 }))
2199 }
2200 };
2201 let did = user.did.clone();
2202 let pool = &state.db;
2203
2204 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2205
2206 let view = match q.view.as_deref() {
2208 Some("all") => "all",
2209 Some("starred") => "starred",
2210 _ => "unread",
2211 }
2212 .to_string();
2213 let list_view = list_view_of(q.view.as_deref());
2214
2215 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2217 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2222
2223 let feed_title_by_id = |id: i64| -> String {
2224 subs.iter()
2225 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2226 .map(|s| {
2227 display_title(
2228 s.sub
2229 .title
2230 .as_deref()
2231 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2232 &s.sub.url,
2233 )
2234 })
2235 .unwrap_or_default()
2236 };
2237
2238 let mut uncached: Vec<EntryRow> = Vec::new();
2251 if view == "starred" {
2252 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2281 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2282 Ok(store::StarredIdentities::Truncated) => {
2287 warn!(
2288 %did,
2289 cap = STARRED_IDENTITY_MAX,
2290 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2291 rather than rendering record-deleting buttons for cached articles"
2292 );
2293 None
2294 }
2295 Err(err) => {
2296 warn!(%err, %did, "cached-starred identity lookup failed; \
2297 suppressing uncached saved rows this render");
2298 None
2299 }
2300 };
2301 let identities_ok = identities.is_some();
2308 let identities = identities.unwrap_or_default();
2309 let cached_urls: std::collections::HashSet<&str> = identities
2310 .iter()
2311 .filter_map(|(url, _)| url.as_deref())
2312 .collect();
2313 let cached_guids: std::collections::HashSet<&str> =
2314 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2315
2316 let mut uncached_dropped = 0usize;
2329 match state.repo().list_saved_sorted(&did).await {
2330 Ok(saved) if identities_ok => {
2331 for (rkey, item) in saved {
2332 let known = cached_urls.contains(item.url.as_str())
2333 || item
2334 .entry_id
2335 .as_deref()
2336 .is_some_and(|g| cached_guids.contains(g));
2337 if known {
2338 continue;
2339 }
2340 if let Some(urls) = &scope_urls {
2344 match item.feed_url.as_deref() {
2345 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2346 _ => continue,
2350 }
2351 }
2352 let link = SafeLink::external(&item.url);
2378 if link.is_empty() {
2379 warn!(
2380 %did, %rkey,
2381 "a saved record has an unusable URL; rendering it without a link \
2382 so it can still be removed"
2383 );
2384 }
2385
2386 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2400 uncached_dropped += 1;
2401 continue;
2402 }
2403 if let Some(feed_url) = item.feed_url.as_deref() {
2404 if subs.iter().any(|s| s.sub.url == feed_url) {
2405 let stale_before = (chrono::Utc::now()
2409 - chrono::Duration::from_std(state.config.poll_interval)
2410 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2411 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2412 if let Err(err) =
2413 store::mark_feed_due(pool, feed_url, &stale_before).await
2414 {
2415 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2416 }
2417 }
2418 }
2419 uncached.push(EntryRow {
2420 id: 0,
2421 title: item
2422 .title
2423 .clone()
2424 .filter(|t| !t.trim().is_empty())
2425 .unwrap_or_else(|| {
2433 if link.is_empty() {
2434 format!("Saved item {rkey}")
2435 } else {
2436 item.url.clone()
2437 }
2438 }),
2439 feed_title: item.feed_url.clone().unwrap_or_default(),
2440 published: display_date(Some(&item.created_at)),
2441 read: false,
2442 starred: true,
2443 link,
2447 cached: false,
2448 rkey,
2449 });
2450 }
2451 }
2452 Ok(_) => {}
2454 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2455 }
2456 if uncached_dropped > 0 {
2457 warn!(
2458 %did,
2459 dropped = uncached_dropped,
2460 cap = MAX_UNCACHED_SAVED_ROWS,
2461 "more saved records than this instance will hold in one response; the \
2462 rest are not reachable from here"
2463 );
2464 }
2465 }
2466
2467 let total_cached =
2483 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2484 let uncached_len = uncached.len();
2485 let total = total_cached + uncached_len as i64;
2486 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2491 let offset = (page - 1) * ENTRIES_PER_PAGE;
2492 let source = store::list_entries(
2495 pool,
2496 &did,
2497 list_view,
2498 scope_ids.as_deref(),
2499 ENTRIES_PER_PAGE,
2500 offset,
2501 )
2502 .await?;
2503 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2523 let cached_here = cached_allotment.min(source.len());
2524 let source = if uncached_len == 0 {
2529 &source[..]
2530 } else {
2531 &source[..cached_here]
2532 };
2533 let uncached_page: Vec<EntryRow> = {
2534 let skip = (offset - total_cached).max(0) as usize;
2535 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2536 uncached.into_iter().skip(skip).take(take).collect()
2537 };
2538 let uncached_total = uncached_len as i64;
2541
2542 let entry_scope_qs = {
2544 let mut parts = Vec::new();
2545 if let Some(f) = q.feed.as_deref() {
2546 parts.push(format!("feed={}", qenc(f)));
2547 }
2548 if let Some(f) = q.folder.as_deref() {
2549 parts.push(format!("folder={}", qenc(f)));
2550 }
2551 if view != "unread" {
2552 parts.push(format!("view={}", qenc(&view)));
2553 }
2554 parts.join("&")
2555 };
2556 let entries: Vec<EntryRow> = source
2557 .iter()
2558 .map(|e| EntryRow {
2559 id: e.id,
2560 title: e
2561 .title
2562 .clone()
2563 .filter(|t| !t.trim().is_empty())
2564 .unwrap_or_else(|| "(untitled)".to_string()),
2565 feed_title: feed_title_by_id(e.feed_id),
2566 published: display_date(e.published.as_deref()),
2567 read: e.read,
2572 starred: e.starred,
2573 link: SafeLink::entry(e.id, &entry_scope_qs),
2574 cached: true,
2575 rkey: String::new(),
2576 })
2577 .collect();
2578
2579 let mut entries = entries;
2581 entries.extend(uncached_page);
2582 let entries = entries;
2583
2584 let selected_feed = q.feed.as_deref();
2585 let selected_folder = q.folder.as_deref();
2586
2587 let (folder_views, loose_feeds, _folder_options) =
2589 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2590
2591 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2593 let name = subs
2594 .iter()
2595 .find(|s| s.sub.url == feed_url)
2596 .map(|s| {
2597 display_title(
2598 s.sub
2599 .title
2600 .as_deref()
2601 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2602 &s.sub.url,
2603 )
2604 })
2605 .unwrap_or_else(|| display_title(None, feed_url));
2606 (name, format!("feed={}", qenc(feed_url)))
2607 } else if let Some(folder_uri) = selected_folder {
2608 let name = folder_views
2609 .iter()
2610 .find(|f| f.uri == folder_uri)
2611 .map(|f| f.name.clone())
2612 .unwrap_or_else(|| "Folder".to_string());
2613 (name, format!("folder={}", qenc(folder_uri)))
2614 } else {
2615 let h = match view.as_str() {
2616 "all" => "All",
2617 "starred" => "Starred",
2618 _ => "Unread",
2619 };
2620 (h.to_string(), String::new())
2621 };
2622
2623 let feed_scope = selected_feed.map(str::to_string);
2624 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2625
2626 let page_href = |n: i64| -> String {
2630 let mut parts = Vec::new();
2631 if !entry_scope_qs.is_empty() {
2632 parts.push(entry_scope_qs.clone());
2633 }
2634 if n > 1 {
2635 parts.push(format!("page={n}"));
2636 }
2637 if parts.is_empty() {
2638 "/".to_string()
2639 } else {
2640 format!("/?{}", parts.join("&"))
2641 }
2642 };
2643 let prev_href = (page > 1).then(|| page_href(page - 1));
2644 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2645
2646 let tmpl = IndexTemplate {
2647 card: Card::private(&state.config),
2648 version: VERSION,
2649 repo_url: REPO_URL,
2650 kofi_url: KOFI_URL,
2651 flash: q.flash.unwrap_or_default(),
2652 alert: alert.unwrap_or_default(),
2653 nav,
2654 entries,
2655 heading,
2656 feed_scope,
2657 total,
2658 uncached_total,
2661 page,
2662 page_count: page_count_for(total),
2663 prev_href,
2664 next_href,
2665 };
2666 Ok(render(&tmpl))
2667}
2668
2669#[derive(Debug, Deserialize, Default)]
2671struct ManageQuery {
2672 #[serde(default)]
2673 flash: Option<String>,
2674}
2675
2676async fn manage(
2681 State(state): State<AppState>,
2682 headers: HeaderMap,
2683 Query(q): Query<ManageQuery>,
2684) -> Result<Response, WebError> {
2685 let user = match current_session(&state, &headers).await {
2686 Some(u) => u,
2687 None => return Ok(Redirect::to("/login").into_response()),
2688 };
2689 let did = user.did.clone();
2690
2691 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2692 let (folder_views, loose_feeds, folder_options) =
2693 build_sidebar(&state, &did, &subs, None, None).await;
2694
2695 let nav = build_nav(
2697 &user,
2698 "unread",
2699 String::new(),
2700 folder_views.iter().map(clone_folder_view).collect(),
2701 loose_feeds.iter().map(clone_feed_view).collect(),
2702 true,
2703 );
2704
2705 let tmpl = ManageTemplate {
2706 card: Card::private(&state.config),
2707 version: VERSION,
2708 repo_url: REPO_URL,
2709 kofi_url: KOFI_URL,
2710 flash: q.flash.unwrap_or_default(),
2711 alert: alert.unwrap_or_default(),
2712 nav,
2713 folder_options,
2714 folders: folder_views,
2715 loose_feeds,
2716 standard_site: state.config.standard_site,
2717 };
2718 Ok(render(&tmpl))
2719}
2720
2721fn clone_feed_view(f: &FeedView) -> FeedView {
2724 FeedView {
2725 rkey: f.rkey.clone(),
2726 url: f.url.clone(),
2727 title: f.title.clone(),
2728 unread: f.unread,
2729 selected: f.selected,
2730 folder: f.folder.clone(),
2731 }
2732}
2733
2734fn clone_folder_view(f: &FolderView) -> FolderView {
2735 FolderView {
2736 rkey: f.rkey.clone(),
2737 uri: f.uri.clone(),
2738 name: f.name.clone(),
2739 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2740 selected: f.selected,
2741 }
2742}
2743
2744fn scope_urls_for(
2749 subs: &[ResolvedSub],
2750 feed: Option<&str>,
2751 folder: Option<&str>,
2752) -> Option<Vec<String>> {
2753 if let Some(feed_url) = feed {
2754 Some(vec![feed_url.to_string()])
2755 } else {
2756 folder.map(|folder_uri| {
2757 subs.iter()
2758 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2759 .map(|s| s.sub.url.clone())
2760 .collect()
2761 })
2762 }
2763}
2764
2765fn folder_uri(did: &str, rkey: &str) -> String {
2767 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2768}
2769
2770async fn build_sidebar(
2774 state: &AppState,
2775 did: &str,
2776 subs: &[ResolvedSub],
2777 selected_feed: Option<&str>,
2778 selected_folder: Option<&str>,
2779) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2780 let pool = &state.db;
2781 let unread_counts = store::unread_counts_by_feed(pool, did)
2786 .await
2787 .unwrap_or_else(|err| {
2788 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2789 Default::default()
2790 });
2791 let folders = state
2792 .repo()
2793 .list_folders_sorted(did)
2794 .await
2795 .unwrap_or_default();
2796
2797 let unread_count = |feed_id: Option<i64>| -> i64 {
2798 feed_id
2799 .and_then(|id| unread_counts.get(&id).copied())
2800 .unwrap_or(0)
2801 };
2802 let mk_feed_view = |s: &ResolvedSub| FeedView {
2803 rkey: s.rkey.clone(),
2804 url: s.sub.url.clone(),
2805 title: display_title(
2806 s.sub
2807 .title
2808 .as_deref()
2809 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2810 &s.sub.url,
2811 ),
2812 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2813 selected: selected_feed == Some(s.sub.url.as_str()),
2814 folder: s.sub.folder.clone(),
2815 };
2816
2817 let mut folder_views = Vec::with_capacity(folders.len());
2818 for (rkey, folder) in &folders {
2819 let uri = folder_uri(did, rkey);
2820 let feeds: Vec<FeedView> = subs
2821 .iter()
2822 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2823 .map(mk_feed_view)
2824 .collect();
2825 folder_views.push(FolderView {
2826 rkey: rkey.clone(),
2827 uri: uri.clone(),
2828 name: folder.name.clone(),
2829 feeds,
2830 selected: selected_folder == Some(uri.as_str()),
2831 });
2832 }
2833
2834 let known_uris: std::collections::HashSet<String> =
2835 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2836 let loose_feeds: Vec<FeedView> = subs
2837 .iter()
2838 .filter(|s| {
2839 s.sub
2840 .folder
2841 .as_deref()
2842 .map(|f| !known_uris.contains(f))
2843 .unwrap_or(true)
2844 })
2845 .map(mk_feed_view)
2846 .collect();
2847
2848 let folder_options: Vec<FolderOption> = folders
2849 .iter()
2850 .map(|(rkey, folder)| FolderOption {
2851 name: folder.name.clone(),
2852 uri: folder_uri(did, rkey),
2853 })
2854 .collect();
2855
2856 (folder_views, loose_feeds, folder_options)
2857}
2858
2859fn build_nav(
2861 user: &CurrentUser,
2862 view: &str,
2863 scope_qs: String,
2864 folders: Vec<FolderView>,
2865 loose_feeds: Vec<FeedView>,
2866 manage_active: bool,
2867) -> Nav {
2868 Nav {
2869 handle: display_handle(user.handle.as_deref(), &user.did),
2870 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2871 view: view.to_string(),
2872 scope_qs,
2873 folders,
2874 loose_feeds,
2875 manage_active,
2876 }
2877}
2878
2879#[derive(Debug, Deserialize, Default)]
2886struct EntryQuery {
2887 #[serde(default)]
2888 feed: Option<String>,
2889 #[serde(default)]
2890 folder: Option<String>,
2891 #[serde(default)]
2892 view: Option<String>,
2893}
2894
2895async fn entry_view(
2898 State(state): State<AppState>,
2899 headers: HeaderMap,
2900 Path(id): Path<i64>,
2901 Query(q): Query<EntryQuery>,
2902) -> Result<Response, WebError> {
2903 let user = match current_session(&state, &headers).await {
2904 Some(u) => u,
2905 None => return Ok(Redirect::to("/login").into_response()),
2906 };
2907 let did = user.did.clone();
2908 let pool = &state.db;
2909
2910 let subs = resolve_subscriptions(&state, &did).await;
2914
2915 let entry = match get_entry_by_id(pool, &did, id).await? {
2916 Some(e) => e,
2917 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2918 };
2919
2920 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2921
2922 let read = entry_is_read(pool, &did, id).await?;
2923 let starred = entry_is_starred(pool, &did, id).await?;
2924
2925 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2928
2929 let back_qs = scope_query(&q);
2930
2931 let (folder_views, loose_feeds, _) =
2932 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2933 let nav_view = match q.view.as_deref() {
2934 Some("all") => "all",
2935 Some("starred") => "starred",
2936 _ => "unread",
2937 };
2938 let nav = build_nav(
2939 &user,
2940 nav_view,
2941 back_qs.clone(),
2942 folder_views,
2943 loose_feeds,
2944 false,
2945 );
2946
2947 let tmpl = EntryTemplate {
2948 card: Card::private(&state.config),
2949 version: VERSION,
2950 repo_url: REPO_URL,
2951 kofi_url: KOFI_URL,
2952 nav,
2953 id: entry.id,
2954 title: entry
2955 .title
2956 .clone()
2957 .filter(|t| !t.trim().is_empty())
2958 .unwrap_or_else(|| "(untitled)".to_string()),
2959 feed_title,
2960 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2961 published: display_date(entry.published.as_deref()),
2962 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2963 content_html: entry.content_html.clone(),
2964 read,
2965 starred,
2966 back_qs,
2967 prev_id,
2968 next_id,
2969 oob: false,
2970 };
2971 Ok(render(&tmpl))
2972}
2973
2974async fn neighbors_in_scope(
2977 state: &AppState,
2978 did: &str,
2979 q: &EntryQuery,
2980 current: i64,
2981) -> (Option<i64>, Option<i64>) {
2982 let idx_q = IndexQuery {
2983 feed: q.feed.clone(),
2984 folder: q.folder.clone(),
2985 view: q.view.clone(),
2986 page: None,
2988 flash: None,
2989 };
2990 let ids = list_entry_ids(state, did, &idx_q).await;
2991 let pos = ids.iter().position(|&x| x == current);
2992 match pos {
2993 Some(p) => {
2994 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2995 let next = ids.get(p + 1).copied();
2996 (prev, next)
2997 }
2998 None => (None, None),
2999 }
3000}
3001
3002async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
3005 let pool = &state.db;
3006 let subs = resolve_subscriptions(state, did).await;
3007
3008 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3009
3010 store::list_entry_ids(
3016 pool,
3017 did,
3018 list_view_of(q.view.as_deref()),
3019 scoped_feed_ids(&subs, &scope_urls).as_deref(),
3020 PREV_NEXT_MAX,
3021 )
3022 .await
3023 .unwrap_or_else(|err| {
3024 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3025 Vec::new()
3026 })
3027}
3028
3029fn list_view_of(view: Option<&str>) -> store::ListView {
3032 match view {
3033 Some("all") => store::ListView::All,
3034 Some("starred") => store::ListView::Starred,
3035 _ => store::ListView::Unread,
3036 }
3037}
3038
3039fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3045 let urls = scope_urls.as_ref()?;
3046 Some(
3047 subs.iter()
3048 .filter(|s| urls.contains(&s.sub.url))
3049 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3050 .collect(),
3051 )
3052}
3053
3054fn scope_query(q: &EntryQuery) -> String {
3056 let mut parts = Vec::new();
3057 if let Some(f) = q.feed.as_deref() {
3058 parts.push(format!("feed={}", qenc(f)));
3059 }
3060 if let Some(f) = q.folder.as_deref() {
3061 parts.push(format!("folder={}", qenc(f)));
3062 }
3063 if let Some(v) = q.view.as_deref() {
3064 if v != "unread" {
3065 parts.push(format!("view={}", qenc(v)));
3066 }
3067 }
3068 parts.join("&")
3069}
3070
3071#[derive(Debug, Deserialize)]
3077struct ReadForm {
3078 #[serde(default)]
3079 read: Option<String>,
3080}
3081
3082async fn mark_read(
3084 State(state): State<AppState>,
3085 Path(id): Path<i64>,
3086 headers: HeaderMap,
3087 Form(form): Form<ReadForm>,
3088) -> Result<Response, WebError> {
3089 let did = match current_did(&state, &headers).await {
3090 Some(d) => d,
3091 None => return Ok(Redirect::to("/login").into_response()),
3092 };
3093 let pool = &state.db;
3094
3095 let read = matches!(
3096 form.read.as_deref(),
3097 Some("true") | Some("1") | Some("on") | None
3098 );
3099
3100 resolve_subscriptions(&state, &did).await;
3105 if !store::mark_read(pool, &did, id, read).await? {
3106 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3107 }
3108
3109 if !is_htmx(&headers) {
3110 return Ok(Redirect::to("/").into_response());
3111 }
3112
3113 if is_reader_request(&headers) {
3117 let starred = entry_is_starred(pool, &did, id).await?;
3118 return Ok(render(&EntryActionBarTemplate {
3119 id,
3120 read,
3121 starred,
3122 oob: true,
3123 }));
3124 }
3125
3126 let row = build_entry_row(pool, &did, id, Some(read)).await?;
3127 match row {
3128 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3129 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3130 }
3131}
3132
3133#[derive(Debug, Deserialize)]
3139struct StarForm {
3140 #[serde(default)]
3141 starred: Option<String>,
3142}
3143
3144async fn toggle_star(
3150 State(state): State<AppState>,
3151 Path(id): Path<i64>,
3152 headers: HeaderMap,
3153 Form(form): Form<StarForm>,
3154) -> Result<Response, WebError> {
3155 let did = match current_did(&state, &headers).await {
3156 Some(d) => d,
3157 None => return Ok(Redirect::to("/login").into_response()),
3158 };
3159 let pool = &state.db;
3160
3161 let starred = matches!(
3162 form.starred.as_deref(),
3163 Some("true") | Some("1") | Some("on") | None
3164 );
3165
3166 resolve_subscriptions(&state, &did).await;
3170 if !store::mark_starred(pool, &did, id, starred).await? {
3171 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3172 }
3173
3174 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3177 let entry_url = entry.url.clone().unwrap_or_default();
3178 if !entry_url.is_empty() {
3179 if starred {
3180 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3181 saved.title = entry.title.clone();
3182 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3183 saved.entry_id = Some(entry.guid.clone());
3184 match state.repo().add_saved(&did, &saved).await {
3185 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3186 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3187 }
3188 } else {
3189 match state.repo().list_saved(&did).await {
3191 Ok(records) => {
3192 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3193 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3194 warn!(%err, %did, %rkey, "PDS saved delete failed");
3195 }
3196 }
3197 }
3198 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3199 }
3200 }
3201 }
3202 }
3203
3204 if !is_htmx(&headers) {
3205 return Ok(Redirect::to("/").into_response());
3206 }
3207
3208 if is_reader_request(&headers) {
3210 let read = entry_is_read(pool, &did, id).await?;
3211 return Ok(render(&EntryActionBarTemplate {
3212 id,
3213 read,
3214 starred,
3215 oob: true,
3216 }));
3217 }
3218
3219 let row = build_entry_row(pool, &did, id, None).await?;
3220 match row {
3221 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3222 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3223 }
3224}
3225
3226async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3228 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3229 .bind(feed_id)
3230 .fetch_optional(pool)
3231 .await
3232 .ok()
3233 .flatten()
3234}
3235
3236#[derive(Debug, Deserialize, Default)]
3243struct ReadAllQuery {
3244 #[serde(default)]
3245 feed: Option<String>,
3246}
3247
3248async fn mark_all_read(
3251 State(state): State<AppState>,
3252 headers: HeaderMap,
3253 Query(q): Query<ReadAllQuery>,
3254) -> Result<Response, WebError> {
3255 let did = match current_did(&state, &headers).await {
3256 Some(d) => d,
3257 None => return Ok(Redirect::to("/login").into_response()),
3258 };
3259 let pool = &state.db;
3260
3261 resolve_subscriptions(&state, &did).await;
3264
3265 if let Some(feed_url) = q.feed.as_deref() {
3266 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3267 store::mark_feed_read(pool, &did, feed.id, true).await?;
3268 }
3269 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3270 }
3271
3272 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3277 store::mark_feed_read(pool, &did, feed_id, true).await?;
3278 }
3279 Ok(Redirect::to("/").into_response())
3280}
3281
3282const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3292 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3293
3294const EXPORT_INCOMPLETE_REFUSAL: &str =
3301 "Could not read your subscriptions in full, so nothing was exported. Your \
3302 feeds are unchanged — try again, and if it keeps failing the list may be \
3303 larger than this reader can page through.";
3304
3305const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3311 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3312 feeds for now — private-feed support arrives when atproto's private data \
3313 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3314
3315#[derive(Debug, Deserialize)]
3317struct SubscribeForm {
3318 url: String,
3319 #[serde(default)]
3321 folder: Option<String>,
3322}
3323
3324async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3334 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3335 let canonical = format!(
3336 "{}{}",
3337 crate::atproto::AT_URI_PREFIX,
3338 &input[crate::atproto::AT_URI_PREFIX.len()..]
3339 );
3340 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3341 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3342 return Err(unsupported());
3343 }
3344 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3345 uri.authority.clone()
3346 } else {
3347 let handle =
3348 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3353 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3354 .await
3355 .map_err(|err| {
3356 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3357 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3358 })?
3359 };
3360 let url = format!(
3361 "{}{did}/{}/{}",
3362 crate::atproto::AT_URI_PREFIX,
3363 uri.collection,
3364 uri.rkey
3365 );
3366 if !feed::is_storable_feed_url(&url, true) {
3367 return Err(unsupported());
3368 }
3369 Ok(url)
3370}
3371
3372async fn add_subscription(
3374 State(state): State<AppState>,
3375 headers: HeaderMap,
3376 Form(form): Form<SubscribeForm>,
3377) -> Result<Response, WebError> {
3378 let did = match current_did(&state, &headers).await {
3379 Some(d) => d,
3380 None => return Ok(Redirect::to("/login").into_response()),
3381 };
3382 let pool = &state.db;
3383 let input = form.url.trim().to_string();
3384 if input.is_empty() {
3385 return Ok(Redirect::to("/").into_response());
3386 }
3387
3388 let cap = state.config.max_subs_per_did;
3392 if cap > 0 {
3393 match store::count_subscriptions_for_did(pool, &did).await {
3394 Ok(n) if n >= cap => {
3395 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3396 return Ok(Redirect::to(&format!(
3397 "/?flash={}",
3398 qenc(&format!(
3399 "Subscription limit reached ({cap}). Remove a feed before adding another."
3400 ))
3401 ))
3402 .into_response());
3403 }
3404 Ok(_) => {}
3405 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3406 }
3407 }
3408
3409 let is_at_uri = input
3414 .get(..crate::atproto::AT_URI_PREFIX.len())
3415 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3416 let publication_url = if is_at_uri {
3417 if !state.config.standard_site {
3418 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3419 return Ok(
3420 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3421 .into_response(),
3422 );
3423 }
3424 match publication_url_from_paste(&state, &input).await {
3425 Ok(url) => Some(url),
3426 Err(flash) => {
3427 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3428 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3429 }
3430 }
3431 } else {
3432 None
3433 };
3434
3435 if let feed::FeedPrivacy::Private(reason) =
3436 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3437 {
3438 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3439 return Ok(
3440 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3441 );
3442 }
3443
3444 let resolved = match publication_url {
3445 Some(url) => Ok(url),
3446 None => resolve_feed_url(&state.config, &input).await,
3447 };
3448 let feed_url = match resolved {
3449 Ok(u) => u,
3450 Err(err) => {
3451 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3452 return Ok(Redirect::to(&format!(
3453 "/?flash={}",
3454 qenc("Couldn't find a feed at that URL")
3455 ))
3456 .into_response());
3457 }
3458 };
3459
3460 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3464 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3465 return Ok(
3466 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3467 );
3468 }
3469
3470 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3475 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3476 return Ok(
3477 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3478 .into_response(),
3479 );
3480 }
3481
3482 let feeds_cap = state.config.max_feeds_global;
3486 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3487 match store::count_feeds(pool).await {
3488 Ok(n) if n >= feeds_cap => {
3489 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3490 return Ok(Redirect::to(&format!(
3491 "/?flash={}",
3492 qenc(
3493 "This instance is at its feed capacity right now. Please try again later."
3494 )
3495 ))
3496 .into_response());
3497 }
3498 Ok(_) => {}
3499 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3500 }
3501 }
3502
3503 store::upsert_feed(
3504 pool,
3505 &store::NewFeed {
3506 url: feed_url.clone(),
3507 ..Default::default()
3508 },
3509 )
3510 .await?;
3511
3512 if let Ok(client) = feed::build_client() {
3513 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3514 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3515 Ok(outcome) => {
3516 info!(feed = %feed_url, ?outcome, "polled new subscription");
3517 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3521 }
3522 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3523 }
3524 }
3525 }
3526
3527 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3528 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3529 sub.title = feed_row.title.clone();
3530 sub.site_url = feed_row.site_url.clone();
3531 }
3532 sub.folder = form
3533 .folder
3534 .map(|f| f.trim().to_string())
3535 .filter(|f| !f.is_empty());
3536
3537 match state.repo().add_subscription(&did, &sub).await {
3538 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3539 Err(err) => {
3540 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3541 }
3542 }
3543
3544 Ok(Redirect::to("/").into_response())
3545}
3546
3547async fn delete_subscription(
3549 State(state): State<AppState>,
3550 headers: HeaderMap,
3551 Path(rkey): Path<String>,
3552) -> Result<Response, WebError> {
3553 let did = match current_did(&state, &headers).await {
3554 Some(d) => d,
3555 None => return Ok(Redirect::to("/login").into_response()),
3556 };
3557 match state.repo().remove_subscription(&did, &rkey).await {
3558 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3559 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3560 }
3561 Ok(Redirect::to("/").into_response())
3562}
3563
3564#[derive(Debug, Deserialize)]
3566struct RenameSubForm {
3567 url: String,
3568 #[serde(default)]
3569 title: Option<String>,
3570 #[serde(default)]
3571 site_url: Option<String>,
3572 #[serde(default)]
3573 folder: Option<String>,
3574 #[serde(default)]
3580 seen_url: Option<String>,
3581 #[serde(default)]
3584 seen_title: Option<String>,
3585 #[serde(default)]
3589 seen_folder: Option<String>,
3590}
3591
3592async fn rename_subscription(
3595 State(state): State<AppState>,
3596 headers: HeaderMap,
3597 Path(rkey): Path<String>,
3598 Form(form): Form<RenameSubForm>,
3599) -> Result<Response, WebError> {
3600 let did = match current_did(&state, &headers).await {
3601 Some(d) => d,
3602 None => return Ok(Redirect::to("/login").into_response()),
3603 };
3604 let feed_url = form.url.trim().to_string();
3605
3606 if feed_url.is_empty() {
3610 return Ok(Redirect::to("/").into_response());
3611 }
3612
3613 let mut base: Option<Subscription> = None;
3629 for attempt in 1..=RENAME_ATTEMPTS {
3630 match rename_subscription_once(&state, &did, &rkey, &form, &mut base).await? {
3631 RenameAttempt::Done(resp) => return Ok(resp),
3632 RenameAttempt::Raced => {
3633 info!(%did, %rkey, attempt, "subscription changed between read and write; re-reading");
3634 }
3635 }
3636 }
3637 warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused rename: the subscription kept changing elsewhere");
3638 Ok(rename_conflict_response())
3639}
3640
3641fn rename_conflict_response() -> Response {
3644 Redirect::to(&format!(
3645 "/?flash={}",
3646 qenc(
3647 "This subscription was changed elsewhere while you were editing it — \
3648 nothing was renamed or moved. Reload and try again."
3649 )
3650 ))
3651 .into_response()
3652}
3653
3654fn form_value(v: Option<&str>) -> Option<String> {
3657 v.map(str::trim)
3658 .filter(|t| !t.is_empty())
3659 .map(str::to_string)
3660}
3661
3662#[derive(Debug, PartialEq, Eq)]
3664struct MergedRename {
3665 sub: Subscription,
3667 repoint: bool,
3669 already_saved: bool,
3672}
3673
3674#[derive(Debug, PartialEq, Eq)]
3676struct RenameConflict(&'static str);
3677
3678fn merge_rename(
3711 form: &RenameSubForm,
3712 base: &Subscription,
3713 fresh: Subscription,
3714) -> Result<MergedRename, RenameConflict> {
3715 let mut sub = fresh;
3716 let mut edited = 0;
3721 let mut to_write = 0;
3722
3723 let posted_url = form.url.trim();
3724 let seen_url = form.seen_url.as_deref().unwrap_or(&base.url).trim();
3725 let mut repoint = false;
3726 if posted_url != seen_url {
3727 edited += 1;
3728 if sub.url.trim() == posted_url {
3729 } else if sub.url.trim() != base.url.trim() {
3732 return Err(RenameConflict("url"));
3733 } else {
3734 to_write += 1;
3735 repoint = true;
3736 }
3737 }
3738 sub.url = if repoint { posted_url } else { sub.url.trim() }.to_string();
3740
3741 let posted_title = form_value(form.title.as_deref());
3742 let seen_title = match form.seen_title.as_deref() {
3743 Some(seen) => form_value(Some(seen)),
3744 None => base.title.clone(),
3745 };
3746 if posted_title != seen_title {
3747 edited += 1;
3748 if sub.title == posted_title {
3749 } else if sub.title != base.title {
3751 return Err(RenameConflict("title"));
3752 } else {
3753 to_write += 1;
3754 sub.title = posted_title;
3755 }
3756 }
3757
3758 if form.folder.is_some() || form.seen_folder.is_some() {
3766 let posted_folder = form_value(form.folder.as_deref());
3767 let seen_folder = match form.seen_folder.as_deref() {
3768 Some(seen) => form_value(Some(seen)),
3769 None => base.folder.clone(),
3770 };
3771 if posted_folder != seen_folder {
3772 edited += 1;
3773 if sub.folder == posted_folder {
3774 } else if sub.folder != base.folder {
3776 return Err(RenameConflict("folder"));
3777 } else {
3778 to_write += 1;
3779 sub.folder = posted_folder;
3780 }
3781 }
3782 }
3783
3784 match form_value(form.site_url.as_deref()) {
3793 Some(site) if Some(&site) != base.site_url.as_ref() => {
3794 edited += 1;
3795 if sub.site_url.as_ref() == Some(&site) {
3796 } else if sub.site_url != base.site_url {
3798 return Err(RenameConflict("siteUrl"));
3799 } else {
3800 to_write += 1;
3801 sub.site_url = Some(site);
3802 }
3803 }
3804 Some(_) => {}
3805 None if repoint => sub.site_url = None,
3806 None => {}
3807 }
3808 if repoint {
3809 sub.fetch_hint = None;
3810 }
3811 Ok(MergedRename {
3812 sub,
3813 repoint,
3814 already_saved: edited > 0 && to_write == 0,
3817 })
3818}
3819
3820const RENAME_ATTEMPTS: u32 = 2;
3824
3825enum RenameAttempt {
3827 Done(Response),
3830 Raced,
3833}
3834
3835async fn rename_subscription_once(
3842 state: &AppState,
3843 did: &str,
3844 rkey: &str,
3845 form: &RenameSubForm,
3846 base: &mut Option<Subscription>,
3847) -> Result<RenameAttempt, WebError> {
3848 use RenameAttempt::Done;
3849
3850 let found = match state.repo().list_subscriptions_with_cids(did).await {
3880 Ok(subs) => subs
3881 .into_iter()
3882 .find(|(k, _, _)| k == rkey)
3883 .map(|(_, cid, s)| (cid, s)),
3884 Err(err) => {
3885 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3886 return Ok(Done(
3887 Redirect::to(&format!(
3888 "/?flash={}",
3889 qenc("Could not reach your PDS — nothing was renamed or moved.")
3890 ))
3891 .into_response(),
3892 ));
3893 }
3894 };
3895 let Some((read_cid, fresh)) = found else {
3896 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3900 return Ok(Done(
3901 Redirect::to(&format!(
3902 "/?flash={}",
3903 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3904 ))
3905 .into_response(),
3906 ));
3907 };
3908
3909 let base = base.get_or_insert_with(|| fresh.clone());
3931 let MergedRename {
3932 sub,
3933 repoint: url_changed,
3934 already_saved,
3935 } = match merge_rename(form, base, fresh) {
3936 Ok(merged) => merged,
3937 Err(RenameConflict(field)) => {
3938 warn!(%did, %rkey, field, "refused rename: the reader and another client both changed the same field");
3939 return Ok(Done(rename_conflict_response()));
3940 }
3941 };
3942 if already_saved {
3946 info!(%did, %rkey, "rename already in the record; nothing to write");
3947 return Ok(Done(Redirect::to("/").into_response()));
3948 }
3949 let feed_url = sub.url.clone();
3950
3951 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3964 if url_changed && !storable {
3965 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3966 return Ok(Done(
3967 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3968 .into_response(),
3969 ));
3970 }
3971
3972 if url_changed {
3978 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3979 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3980 return Ok(Done(
3981 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3982 ));
3983 }
3984 }
3985
3986 let feeds_cap = state.config.max_feeds_global;
3991 if url_changed
3992 && feeds_cap > 0
3993 && store::get_feed_by_url(&state.db, &feed_url)
3994 .await?
3995 .is_none()
3996 {
3997 match store::count_feeds(&state.db).await {
3998 Ok(n) if n >= feeds_cap => {
3999 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
4000 return Ok(Done(
4001 Redirect::to(&format!(
4002 "/?flash={}",
4003 qenc(
4004 "This instance is at its feed capacity right now. Please try again later."
4005 )
4006 ))
4007 .into_response(),
4008 ));
4009 }
4010 Ok(_) => {}
4011 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
4012 }
4013 }
4014
4015 if read_cid.is_none() {
4027 warn!(%did, %rkey, "the PDS listed this subscription without a CID; renaming without a compare-and-swap");
4028 }
4029 let res = match state
4030 .repo()
4031 .update_subscription(did, rkey, &sub, read_cid.as_deref())
4032 .await
4033 {
4034 Ok(res) => res,
4035 Err(err) if crate::atproto::is_invalid_swap(&err) => return Ok(RenameAttempt::Raced),
4038 Err(err) => {
4039 warn!(%err, %did, %rkey, "PDS subscription update failed");
4040 return Ok(Done(
4041 Redirect::to(&format!(
4042 "/?flash={}",
4043 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
4044 ))
4045 .into_response(),
4046 ));
4047 }
4048 };
4049 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
4050
4051 let cache_write = storable
4077 && (url_changed
4078 || match store::get_feed_by_url(&state.db, &sub.url).await {
4079 Ok(row) => row.is_some(),
4080 Err(err) => {
4081 warn!(%err, %did, url = %sub.url, "could not look up the cached feed row after a rename");
4082 false
4083 }
4084 });
4085 if !cache_write {
4086 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
4087 } else if let Err(err) = store::upsert_feed(
4088 &state.db,
4089 &store::NewFeed {
4090 url: sub.url.clone(),
4091 title: sub.title.clone(),
4092 site_url: sub.site_url.clone(),
4093 ..Default::default()
4094 },
4095 )
4096 .await
4097 {
4098 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
4101 }
4102
4103 Ok(Done(Redirect::to("/").into_response()))
4104}
4105
4106#[derive(Debug, Deserialize)]
4112struct FolderForm {
4113 name: String,
4114}
4115
4116async fn create_folder(
4118 State(state): State<AppState>,
4119 headers: HeaderMap,
4120 Form(form): Form<FolderForm>,
4121) -> Result<Response, WebError> {
4122 let did = match current_did(&state, &headers).await {
4123 Some(d) => d,
4124 None => return Ok(Redirect::to("/login").into_response()),
4125 };
4126 let name = form.name.trim();
4127 if name.is_empty() {
4128 return Ok(Redirect::to("/").into_response());
4129 }
4130 let folder = Folder::new(name.to_string(), now_rfc3339());
4131 match state.repo().add_folder(&did, &folder).await {
4132 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
4133 Err(err) => warn!(%err, %did, "PDS folder create failed"),
4134 }
4135 Ok(Redirect::to("/").into_response())
4136}
4137
4138#[derive(Debug, Deserialize)]
4140struct RenameFolderForm {
4141 name: String,
4142 #[serde(default)]
4147 seen_name: Option<String>,
4148}
4149
4150async fn rename_folder(
4163 State(state): State<AppState>,
4164 headers: HeaderMap,
4165 Path(rkey): Path<String>,
4166 Form(form): Form<RenameFolderForm>,
4167) -> Result<Response, WebError> {
4168 let did = match current_did(&state, &headers).await {
4169 Some(d) => d,
4170 None => return Ok(Redirect::to("/login").into_response()),
4171 };
4172 if form.name.trim().is_empty() {
4173 return Ok(Redirect::to("/").into_response());
4174 }
4175 let mut base: Option<Folder> = None;
4176 for attempt in 1..=RENAME_ATTEMPTS {
4177 match rename_folder_once(&state, &did, &rkey, &form, &mut base).await {
4178 RenameAttempt::Done(resp) => return Ok(resp),
4179 RenameAttempt::Raced => {
4180 info!(%did, %rkey, attempt, "folder changed between read and write; re-reading");
4181 }
4182 }
4183 }
4184 warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused folder rename: the folder kept changing elsewhere");
4185 Ok(folder_flash(FOLDER_RENAME_CONFLICT))
4186}
4187
4188const FOLDER_RENAME_CONFLICT: &str = "This folder was changed elsewhere while you were renaming \
4190 it — it was not renamed. Reload and try again.";
4191
4192fn folder_flash(message: &str) -> Response {
4194 Redirect::to(&format!("/?flash={}", qenc(message))).into_response()
4195}
4196
4197#[derive(Debug, PartialEq, Eq)]
4199enum FolderMerge {
4200 Write(Folder),
4202 AlreadySaved,
4205 Unchanged,
4207}
4208
4209fn merge_folder_rename(
4225 posted: &str,
4226 seen: Option<&str>,
4227 base: &Folder,
4228 fresh: Folder,
4229) -> Result<FolderMerge, RenameConflict> {
4230 let posted = posted.trim();
4231 let ancestor = seen.unwrap_or(&base.name).trim();
4232 if posted == ancestor {
4233 return Ok(FolderMerge::Unchanged);
4234 }
4235 let current = fresh.name.trim();
4236 if current == posted {
4237 return Ok(FolderMerge::AlreadySaved);
4238 }
4239 if current != ancestor {
4240 return Err(RenameConflict("name"));
4241 }
4242 let mut folder = fresh;
4243 folder.name = posted.to_string();
4244 Ok(FolderMerge::Write(folder))
4245}
4246
4247async fn rename_folder_once(
4251 state: &AppState,
4252 did: &str,
4253 rkey: &str,
4254 form: &RenameFolderForm,
4255 base: &mut Option<Folder>,
4256) -> RenameAttempt {
4257 use RenameAttempt::Done;
4258
4259 let found = match state.repo().list_folders_with_cids(did).await {
4264 Ok(folders) => folders
4265 .into_iter()
4266 .find(|(k, _, _)| k == rkey)
4267 .map(|(_, cid, f)| (cid, f)),
4268 Err(err) => {
4269 warn!(%err, %did, %rkey, "could not read the folder before renaming it");
4270 return Done(folder_flash(
4271 "Could not reach your PDS — the folder was not renamed.",
4272 ));
4273 }
4274 };
4275 let Some((read_cid, fresh)) = found else {
4276 warn!(%did, %rkey, "refused folder rename: no such folder in the repo");
4279 return Done(folder_flash(
4280 "That folder no longer exists — it may have been deleted elsewhere. \
4281 Nothing was renamed.",
4282 ));
4283 };
4284
4285 let base = base.get_or_insert_with(|| fresh.clone());
4286 let folder = match merge_folder_rename(&form.name, form.seen_name.as_deref(), base, fresh) {
4287 Ok(FolderMerge::Write(folder)) => folder,
4288 Ok(FolderMerge::AlreadySaved) => {
4289 info!(%did, %rkey, "folder already has this name; nothing to write");
4290 return Done(Redirect::to("/").into_response());
4291 }
4292 Ok(FolderMerge::Unchanged) => return Done(Redirect::to("/").into_response()),
4293 Err(RenameConflict(field)) => {
4294 warn!(%did, %rkey, field, "refused folder rename: the reader and another client both renamed it");
4295 return Done(folder_flash(FOLDER_RENAME_CONFLICT));
4296 }
4297 };
4298
4299 if read_cid.is_none() {
4300 warn!(%did, %rkey, "the PDS listed this folder without a CID; renaming without a compare-and-swap");
4301 }
4302 match state
4303 .repo()
4304 .rename_folder(did, rkey, &folder, read_cid.as_deref())
4305 .await
4306 {
4307 Ok(res) => {
4308 info!(%did, %rkey, uri = %res.uri, "renamed folder");
4309 Done(Redirect::to("/").into_response())
4310 }
4311 Err(err) if crate::atproto::is_invalid_swap(&err) => RenameAttempt::Raced,
4312 Err(err) => {
4313 warn!(%err, %did, %rkey, "PDS folder rename failed");
4314 Done(folder_flash(
4315 "Could not save that change to your PDS — the folder was not renamed.",
4316 ))
4317 }
4318 }
4319}
4320
4321async fn delete_folder(
4324 State(state): State<AppState>,
4325 headers: HeaderMap,
4326 Path(rkey): Path<String>,
4327) -> Result<Response, WebError> {
4328 let did = match current_did(&state, &headers).await {
4329 Some(d) => d,
4330 None => return Ok(Redirect::to("/login").into_response()),
4331 };
4332 match state.repo().remove_folder(&did, &rkey).await {
4333 Ok(()) => info!(%did, %rkey, "deleted folder record"),
4334 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
4335 }
4336 Ok(Redirect::to("/").into_response())
4337}
4338
4339async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
4343 let parsed =
4344 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
4345
4346 let client = feed::build_client()?;
4347 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
4351 let final_url = resp.url().clone();
4352 let content_type = resp
4353 .headers()
4354 .get(axum::http::header::CONTENT_TYPE)
4355 .and_then(|v| v.to_str().ok())
4356 .unwrap_or("")
4357 .to_ascii_lowercase();
4358 let raw = crate::net::read_capped(resp).await?;
4361 let body = String::from_utf8_lossy(&raw).into_owned();
4362
4363 let looks_like_feed = content_type.contains("xml")
4364 || content_type.contains("rss")
4365 || content_type.contains("atom")
4366 || content_type.contains("application/feed+json")
4367 || {
4368 let head = body.trim_start();
4369 head.starts_with("<?xml")
4370 || head.starts_with("<rss")
4371 || head.starts_with("<feed")
4372 || head.contains("<rss")
4373 || head.contains("<feed")
4374 };
4375 if looks_like_feed {
4376 return Ok(final_url.to_string());
4377 }
4378
4379 match feed::discover_feed(&body, Some(&final_url)) {
4380 Some(u) => Ok(u.to_string()),
4381 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
4382 }
4383}
4384
4385#[derive(Debug, Deserialize, Default)]
4391struct LoginQuery {
4392 #[serde(default)]
4393 handle: Option<String>,
4394 #[serde(default)]
4395 error: Option<String>,
4396 #[serde(default)]
4397 flash: Option<String>,
4398}
4399
4400async fn login_form(
4408 State(state): State<AppState>,
4409 headers: HeaderMap,
4410 Query(q): Query<LoginQuery>,
4411) -> Response {
4412 if let Some(handle) = q
4413 .handle
4414 .map(|h| h.trim().to_string())
4415 .filter(|h| !h.is_empty())
4416 {
4417 if !may_start_oauth(&state, &headers, &handle).await {
4418 return Redirect::to("/beta/redeem").into_response();
4419 }
4420 return start_oauth(&state, &handle).await;
4421 }
4422 render(&LoginTemplate {
4423 card: login_card(&state.config),
4424 repo_url: REPO_URL,
4425 error: q.error.unwrap_or_default(),
4426 flash: q.flash.unwrap_or_default(),
4427 })
4428}
4429
4430async fn login_submit(
4433 State(state): State<AppState>,
4434 headers: HeaderMap,
4435 Form(form): Form<LoginForm>,
4436) -> Response {
4437 let handle = form.handle.trim();
4438 if handle.is_empty() {
4439 return login_error(&state, "Enter your atproto handle.");
4440 }
4441 if !may_start_oauth(&state, &headers, handle).await {
4442 return Redirect::to("/beta/redeem").into_response();
4443 }
4444 start_oauth(&state, handle).await
4445}
4446
4447async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4465 may_start_oauth_with(state, headers, handle, |h| async move {
4469 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4470 .await
4471 .ok()
4472 })
4473 .await
4474}
4475
4476async fn may_start_oauth_with<F, Fut>(
4482 state: &AppState,
4483 headers: &HeaderMap,
4484 handle: &str,
4485 resolve: F,
4486) -> bool
4487where
4488 F: FnOnce(String) -> Fut,
4489 Fut: std::future::Future<Output = Option<String>>,
4490{
4491 if let Some(did) = current_did(state, headers).await {
4493 if store::has_beta_access(&state.db, &did)
4494 .await
4495 .unwrap_or(false)
4496 {
4497 return true;
4498 }
4499 }
4500 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4502 return true;
4503 }
4504 match resolve(handle.to_string()).await {
4508 Some(did) => store::has_beta_access(&state.db, &did)
4509 .await
4510 .unwrap_or(false),
4511 None => {
4512 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4513 false
4514 }
4515 }
4516}
4517
4518async fn start_oauth(state: &AppState, handle: &str) -> Response {
4540 match state.config.repo_backend {
4541 crate::metrics::Backend::Sidecar => {
4542 let url = state.sidecar.login_url(handle, None);
4543 info!(%handle, "redirecting to OAuth sidecar login");
4544 Redirect::to(&url).into_response()
4545 }
4546 crate::metrics::Backend::Rust => {
4547 let Some(runtime) = state.oauth.as_deref() else {
4548 warn!("the rust backend is live but its OAuth runtime is absent");
4549 return login_error(state, "Login is not available right now.");
4550 };
4551 match crate::oauth::login::start(
4552 runtime,
4553 &state.http,
4554 &state.db,
4555 handle,
4556 crate::store::now_unix(),
4557 )
4558 .await
4559 {
4560 Ok(started) => {
4561 info!(%handle, "pushed authorization request; redirecting to the PDS");
4562 let mut resp = Redirect::to(&started.authorize_url).into_response();
4563 set_cookie(
4564 &mut resp,
4565 &cookie::sign_value(
4566 OAUTH_BINDING_COOKIE,
4567 &started.binding_token,
4568 &state.config.cookie_secret,
4569 OAUTH_BINDING_MAX_AGE_SECS,
4570 ),
4571 );
4572 resp
4573 }
4574 Err(err) => {
4575 warn!(%err, %handle, "could not start the OAuth login");
4578 login_error(state, "Could not start login for that handle.")
4579 }
4580 }
4581 }
4582 }
4583}
4584
4585fn clear_binding_cookie(resp: &mut Response) {
4589 set_cookie(
4590 resp,
4591 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4592 );
4593}
4594
4595#[derive(Debug, Deserialize)]
4597struct LoginForm {
4598 handle: String,
4599}
4600
4601#[derive(Debug, Deserialize, Default)]
4610struct CallbackQuery {
4611 #[serde(default)]
4613 session_id: Option<String>,
4614 #[serde(default)]
4616 code: Option<String>,
4617 #[serde(default)]
4618 state: Option<String>,
4619 #[serde(default)]
4620 iss: Option<String>,
4621 #[serde(default)]
4624 response: Option<String>,
4625 #[serde(default)]
4626 error: Option<String>,
4627 #[serde(default)]
4628 error_description: Option<String>,
4629}
4630
4631async fn oauth_callback(
4638 State(state): State<AppState>,
4639 headers: HeaderMap,
4640 Query(q): Query<CallbackQuery>,
4641) -> Response {
4642 let sidecar_shape =
4672 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4673 let sidecar_handoff = sidecar_shape
4674 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4675 if let Some(err) = q.error.clone() {
4676 let slug = crate::oauth::flow::known_error_slug(&err);
4692 warn!(
4693 error = slug,
4694 desc_len = q.error_description.as_deref().map_or(0, str::len),
4695 "OAuth callback returned an error"
4696 );
4697 if sidecar_handoff || state.oauth.is_none() {
4698 return login_error(&state, &format!("Login failed: {slug}"));
4699 }
4700 }
4703
4704 let session = if sidecar_handoff {
4707 let session_id = q.session_id.clone().unwrap_or_default();
4708 match state.sidecar.resolve_session(&session_id).await {
4709 Ok(Some(s)) => s,
4710 Ok(None) => {
4711 warn!("OAuth callback session_id did not resolve (expired/unknown)");
4712 return login_error(&state, "Login session expired — please try again.");
4713 }
4714 Err(err) => {
4715 warn!(%err, "failed to resolve OAuth session via the sidecar");
4716 return login_error(&state, "Login failed talking to the auth service.");
4717 }
4718 }
4719 } else {
4720 let Some(runtime) = state.oauth.as_deref() else {
4721 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4722 return login_error(&state, "Login failed: this login could not be completed.");
4723 };
4724 let params = crate::oauth::flow::CallbackParams {
4725 code: q.code.clone(),
4726 state: q.state.clone(),
4727 iss: q.iss.clone(),
4728 error: q.error.clone(),
4732 error_description: q.error_description.clone(),
4733 response: q.response.clone(),
4734 };
4735 let binding =
4736 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4737 match crate::oauth::login::complete(
4738 runtime,
4739 &state.http,
4740 &state.db,
4741 ¶ms,
4742 binding.as_deref(),
4743 crate::store::now_unix(),
4744 )
4745 .await
4746 {
4747 Ok(done) => crate::atproto::SidecarSession {
4748 did: done.did,
4749 handle: done.handle,
4750 },
4751 Err(err) => {
4752 warn!(%err, "could not complete the OAuth callback");
4755 let mut resp = login_error(&state, "Login failed — please try again.");
4756 clear_binding_cookie(&mut resp);
4757 return resp;
4758 }
4759 }
4760 };
4761
4762 let mut clear_invite = false;
4765 if !store::has_beta_access(&state.db, &session.did)
4766 .await
4767 .unwrap_or(false)
4768 {
4769 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4771 Some(c) => c,
4772 None => {
4773 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4774 return Redirect::to("/beta/redeem").into_response();
4775 }
4776 };
4777 match store::redeem_code(
4778 &state.db,
4779 &code,
4780 &session.did,
4781 session.handle.as_deref(),
4782 state.config.beta_cap,
4783 )
4784 .await
4785 {
4786 Ok(Ok(())) => {
4787 clear_invite = true;
4788 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4789 }
4790 Ok(Err(policy)) => {
4791 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4792 let mut resp = redeem_bounce(&state, &policy).into_response();
4793 clear_invite_cookie(&mut resp);
4795 return resp;
4796 }
4797 Err(err) => {
4798 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4799 return login_error(&state, "Login failed while confirming your invite.");
4800 }
4801 }
4802 }
4803
4804 let sid = state.sessions.create(Session {
4807 did: session.did.clone(),
4808 handle: session.handle.clone(),
4809 });
4810 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4811 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4812
4813 let mut resp = Redirect::to("/").into_response();
4814 set_cookie(&mut resp, &cookie);
4815 clear_binding_cookie(&mut resp);
4816 if clear_invite {
4817 clear_invite_cookie(&mut resp);
4818 }
4819 resp
4820}
4821
4822const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4840
4841async fn flush_before_revoke(state: &AppState, did: &str) {
4854 match tokio::time::timeout(
4855 SIGN_OUT_FLUSH_BUDGET,
4856 crate::readstate::flush_did(state, did),
4857 )
4858 .await
4859 {
4860 Ok(Ok(())) => {}
4861 Ok(Err(err)) => {
4862 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4863 }
4864 Err(_) => warn!(
4865 %did,
4866 budget = ?SIGN_OUT_FLUSH_BUDGET,
4867 "sign-out: final read-state flush timed out; it will park until next sign-in"
4868 ),
4869 }
4870}
4871
4872async fn revoke_everywhere(state: &AppState, did: &str) {
4873 let sidecar_started = std::time::Instant::now();
4883 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4884 Ok(res) => {
4885 info!(%did, revoked = res.revoked, "sidecar session revoked");
4886 true
4887 }
4888 Err(err) => {
4889 warn!(%did, %err, "sidecar revoke failed; continuing");
4890 false
4891 }
4892 };
4893 state.metrics.record(
4894 crate::metrics::Backend::Sidecar,
4895 "oauth_revoke",
4896 sidecar_started.elapsed().as_micros() as u64,
4897 sidecar_ok,
4898 );
4899
4900 if let Some(runtime) = state.oauth.as_deref() {
4901 let revoke_started = std::time::Instant::now();
4902 let outcome = crate::oauth::revoke::sign_out_discovering(
4903 runtime,
4904 &state.http,
4905 &state.db,
4906 did,
4907 crate::store::now_unix(),
4908 )
4909 .await;
4910 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4921 state.metrics.record(
4922 crate::metrics::Backend::Rust,
4923 "oauth_revoke",
4924 revoke_started.elapsed().as_micros() as u64,
4925 revoke_ok,
4926 );
4927 match outcome {
4928 crate::oauth::revoke::Revocation::Revoked => {
4929 info!(%did, "rust OAuth session revoked at the PDS")
4930 }
4931 crate::oauth::revoke::Revocation::NoSession => {}
4932 crate::oauth::revoke::Revocation::Failed(reason) => {
4933 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4934 }
4935 }
4936 }
4937}
4938
4939async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4949 if let Some(user) = current_session(&state, &headers).await {
4950 if let Some(sid) = user.sid {
4953 state.sessions.remove(&sid);
4954 flush_before_revoke(&state, &user.did).await;
4956 revoke_everywhere(&state, &user.did).await;
4957 }
4958 }
4959 let mut resp = Redirect::to("/login").into_response();
4960 set_cookie(
4961 &mut resp,
4962 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4963 );
4964 resp
4965}
4966
4967#[derive(Debug, Deserialize)]
4970struct DeleteAccountForm {
4971 #[serde(default)]
4972 confirm: String,
4973}
4974
4975const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4977
4978async fn account_delete(
4994 State(state): State<AppState>,
4995 headers: HeaderMap,
4996 Form(form): Form<DeleteAccountForm>,
4997) -> Result<Response, WebError> {
4998 let user = match current_session(&state, &headers).await {
4999 Some(u) => u,
5000 None => return Ok(Redirect::to("/login").into_response()),
5001 };
5002 let did = user.did.clone();
5003
5004 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
5006 return Ok(Redirect::to(&format!(
5007 "/manage?flash={}",
5008 qenc("Type DELETE to confirm — nothing was deleted.")
5009 ))
5010 .into_response());
5011 }
5012
5013 let counts = store::purge_did_data(&state.db, &did).await?;
5015 info!(
5016 %did,
5017 total = counts.total(),
5018 entry_state = counts.entry_state,
5019 read_cursor = counts.read_cursor,
5020 sub_ref = counts.sub_ref,
5021 beta_access = counts.beta_access,
5022 invite_codes = counts.invite_codes,
5023 "account/delete: local rows purged"
5024 );
5025
5026 revoke_everywhere(&state, &did).await;
5029
5030 if let Some(sid) = user.sid {
5032 state.sessions.remove(&sid);
5033 }
5034 let mut resp = Redirect::to(&format!(
5035 "/login?flash={}",
5036 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
5037 ))
5038 .into_response();
5039 set_cookie(
5040 &mut resp,
5041 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5042 );
5043 Ok(resp)
5044}
5045
5046fn login_card(config: &Config) -> Card {
5048 Card::public(
5049 config,
5050 "/login",
5051 "Sign in — FeatherReader",
5052 "Sign in to FeatherReader with your atproto handle. You approve access on \
5053 your own server — no signup, no password.",
5054 )
5055}
5056
5057fn login_error(state: &AppState, msg: &str) -> Response {
5059 render(&LoginTemplate {
5060 card: login_card(&state.config),
5061 repo_url: REPO_URL,
5062 error: msg.to_string(),
5063 flash: String::new(),
5064 })
5065}
5066
5067#[derive(Debug, Deserialize)]
5073struct RedeemForm {
5074 code: String,
5075}
5076
5077async fn beta_redeem_form(State(state): State<AppState>) -> Response {
5080 let full = store::count_beta_access(&state.db)
5081 .await
5082 .map(|n| n >= state.config.beta_cap)
5083 .unwrap_or(false);
5084 render(&BetaRedeemTemplate {
5085 card: redeem_card(&state.config),
5086 repo_url: REPO_URL,
5087 error: String::new(),
5088 capacity_full: full,
5089 })
5090}
5091
5092async fn beta_redeem_submit(
5102 State(state): State<AppState>,
5103 Form(form): Form<RedeemForm>,
5104) -> Response {
5105 let code = form.code.trim().to_uppercase();
5106 if code.is_empty() {
5107 return render(&BetaRedeemTemplate {
5108 card: redeem_card(&state.config),
5109 repo_url: REPO_URL,
5110 error: "Enter your invite code.".to_string(),
5111 capacity_full: false,
5112 });
5113 }
5114
5115 match preflight_code(&state, &code).await {
5116 Ok(()) => {
5117 let cookie = sign_invite(&code, &state.config.cookie_secret);
5118 let mut resp = Redirect::to("/login").into_response();
5119 set_cookie(&mut resp, &cookie);
5120 info!("invite code preflight OK; reserving intent + redirecting to /login");
5121 resp
5122 }
5123 Err(policy) => {
5124 warn!(?policy, "invite code preflight rejected");
5125 redeem_bounce(&state, &policy)
5126 }
5127 }
5128}
5129
5130async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
5136 let count = match store::count_beta_access(&state.db).await {
5144 Ok(n) => n,
5145 Err(err) => {
5146 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
5147 return Err(store::RedeemError::CapacityFull);
5148 }
5149 };
5150 if count >= state.config.beta_cap {
5151 return Err(store::RedeemError::CapacityFull);
5152 }
5153 let row = sqlx::query_as::<_, (String, i64)>(
5155 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
5156 )
5157 .bind(code)
5158 .fetch_optional(&state.db)
5159 .await
5160 .ok()
5161 .flatten();
5162 let (status, expires_at) = match row {
5163 Some(r) => r,
5164 None => return Err(store::RedeemError::NotFound),
5165 };
5166 let now = chrono::Utc::now().timestamp();
5167 match status.as_str() {
5168 "active" if expires_at >= now => Ok(()),
5169 "active" => Err(store::RedeemError::Expired),
5170 "expired" => Err(store::RedeemError::Expired),
5171 _ => Err(store::RedeemError::AlreadyRedeemed),
5173 }
5174}
5175
5176fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
5179 use store::RedeemError::*;
5180 let (msg, capacity_full) = match policy {
5181 NotFound => ("That invite code isn't valid.", false),
5182 Expired => ("That invite code has expired.", false),
5183 AlreadyRedeemed => ("That invite code has already been used.", false),
5184 CapacityFull => ("", true),
5185 };
5186 render(&BetaRedeemTemplate {
5187 card: redeem_card(&state.config),
5188 repo_url: REPO_URL,
5189 error: msg.to_string(),
5190 capacity_full,
5191 })
5192}
5193
5194fn redeem_card(config: &Config) -> Card {
5197 Card::public(
5198 config,
5199 "/beta/redeem",
5200 "Redeem an invite — FeatherReader",
5201 "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
5202 in with your atproto handle.",
5203 )
5204}
5205
5206#[derive(Debug, Deserialize, Default)]
5208struct MintQuery {
5209 #[serde(default)]
5210 n: Option<u32>,
5211}
5212
5213async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
5226 let Some(runtime) = state.oauth.as_deref() else {
5227 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
5229 };
5230 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
5231}
5232
5233async fn oauth_jwks(State(state): State<AppState>) -> Response {
5240 let Some(runtime) = state.oauth.as_deref() else {
5241 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
5242 };
5243 match runtime.client_key.as_ref() {
5244 Some(key) => match key.jwks_document() {
5245 Ok(doc) => axum::Json(doc).into_response(),
5246 Err(err) => {
5247 warn!(%err, "could not render the client JWKS");
5248 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
5249 }
5250 },
5251 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
5252 }
5253}
5254
5255const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
5257
5258async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
5267 let did = match current_did(&state, &headers).await {
5268 Some(d) => d,
5269 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5270 };
5271 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5272 warn!(%did, "admin metrics denied: not an admin-seed DID");
5273 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5274 }
5275
5276 if let Err(err) =
5281 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
5282 {
5283 warn!(%err, "could not flush repo timings before rendering");
5284 }
5285 let rows = match crate::metrics::persisted_rows(&state.db).await {
5286 Ok(rows) => rows,
5287 Err(err) => {
5288 warn!(%err, "could not read persisted repo timings");
5289 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
5290 }
5291 };
5292
5293 let parked = match crate::store::parked_readstate_dids(&state.db).await {
5299 Ok(n) => n.to_string(),
5300 Err(err) => {
5301 warn!(%err, "could not count parked read-state DIDs");
5302 "unknown".to_string()
5303 }
5304 };
5305 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
5312 Ok(f) => f,
5313 Err(err) => {
5314 warn!(%err, "could not list failing feeds");
5315 Vec::new()
5316 }
5317 };
5318 let mut failing_block = String::new();
5319 if !failing.is_empty() {
5320 failing_block.push_str("\nfailing feeds (worst first)\n");
5321 for f in &failing {
5322 failing_block.push_str(&format!(
5323 " {:>4}x {:<8} {}\n {}\n",
5324 f.consecutive_errors,
5325 f.kind.as_deref().unwrap_or("unknown"),
5326 f.url,
5327 f.detail.as_deref().unwrap_or("(no detail recorded)"),
5328 ));
5329 }
5330 }
5331
5332 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
5337 Ok(n) => n,
5338 Err(err) => {
5339 warn!(%err, "could not count unpollable feeds");
5340 -1
5341 }
5342 };
5343 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
5344
5345 let body = format!(
5346 "live backend: {}\nparked read-state DIDs: {}\n\
5347 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
5348 state.config.repo_backend.as_str(),
5349 parked,
5350 cached,
5351 state.config.max_feeds_global,
5352 unpollable,
5353 crate::metrics::render(&rows),
5354 failing_block,
5355 );
5356 (StatusCode::OK, body).into_response()
5357}
5358
5359async fn admin_mint_invites(
5363 State(state): State<AppState>,
5364 headers: HeaderMap,
5365 Query(q): Query<MintQuery>,
5366) -> Response {
5367 let did = match current_did(&state, &headers).await {
5370 Some(d) => d,
5371 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5372 };
5373 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5374 warn!(%did, "admin mint denied: not an admin-seed DID");
5375 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5376 }
5377
5378 let n = q.n.unwrap_or(1).clamp(1, 100);
5379 let mut codes = Vec::with_capacity(n as usize);
5380 for _ in 0..n {
5381 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
5382 Ok(code) => codes.push(code),
5383 Err(err) => {
5384 warn!(%err, %did, "admin mint_code failed");
5385 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5386 }
5387 }
5388 }
5389 info!(%did, count = codes.len(), "admin minted invite codes");
5390 let mut body = codes.join("\n");
5391 body.push('\n');
5392 (StatusCode::OK, body).into_response()
5393}
5394
5395#[derive(Debug, Deserialize)]
5401struct ClaimQuery {
5402 t: Option<String>,
5404}
5405
5406async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
5425 let token = match q.t {
5426 Some(t) if !t.is_empty() => t,
5427 _ => {
5428 warn!("claim link with no token");
5429 return redeem_bounce(&state, &store::RedeemError::NotFound);
5430 }
5431 };
5432
5433 let code = match claim_token_code(&token, &state.config.cookie_secret) {
5436 Some(c) => c,
5437 None => {
5438 warn!("claim token invalid (bad signature / malformed)");
5439 return redeem_bounce(&state, &store::RedeemError::NotFound);
5440 }
5441 };
5442
5443 match preflight_code(&state, &code).await {
5447 Ok(()) => {
5448 let cookie = sign_invite(&code, &state.config.cookie_secret);
5449 let mut resp = Redirect::to("/login").into_response();
5450 set_cookie(&mut resp, &cookie);
5451 info!("claim token preflight OK; reserving intent + redirecting to /login");
5452 resp
5453 }
5454 Err(policy) => {
5455 warn!(?policy, "claim token preflight rejected");
5456 redeem_bounce(&state, &policy)
5457 }
5458 }
5459}
5460
5461#[derive(Debug, Default, Deserialize)]
5468struct BotClaimRequest {
5469 #[serde(default)]
5472 did: Option<String>,
5473 #[serde(default)]
5475 #[allow(dead_code)]
5476 handle: Option<String>,
5477}
5478
5479#[derive(Debug, serde::Serialize)]
5481struct BotClaimResponse {
5482 status: &'static str,
5488 code: String,
5492 token: String,
5495 url: String,
5498}
5499
5500async fn bot_mint_claim(
5528 State(state): State<AppState>,
5529 headers: HeaderMap,
5530 body: axum::body::Bytes,
5531) -> Response {
5532 let bot_secret = match state.config.bot_secret.as_deref() {
5534 Some(s) => s,
5535 None => {
5536 warn!(
5537 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5538 );
5539 return (
5540 StatusCode::SERVICE_UNAVAILABLE,
5541 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5542 )
5543 .into_response();
5544 }
5545 };
5546
5547 let presented = headers
5549 .get("x-bot-secret")
5550 .and_then(|v| v.to_str().ok())
5551 .unwrap_or("");
5552 if !bot_secret_matches(presented, bot_secret) {
5553 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5554 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5555 }
5556
5557 let req: BotClaimRequest = if body.is_empty() {
5560 BotClaimRequest::default()
5561 } else {
5562 match serde_json::from_slice(&body) {
5563 Ok(r) => r,
5564 Err(err) => {
5565 warn!(%err, "POST /bot/claims: bad JSON body");
5566 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5567 }
5568 }
5569 };
5570 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5571
5572 if let Some(did) = follower_did {
5574 match store::has_beta_access(&state.db, did).await {
5576 Ok(true) => {
5577 info!("bot mint: DID already holds beta access; already_seated");
5578 return bot_claim_json(BotClaimResponse {
5579 status: "already_seated",
5580 code: String::new(),
5581 token: String::new(),
5582 url: String::new(),
5583 });
5584 }
5585 Ok(false) => {}
5586 Err(err) => {
5587 warn!(%err, "bot mint: has_beta_access failed");
5589 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5590 }
5591 }
5592 match store::find_active_code_for_did(&state.db, did).await {
5595 Ok(Some(code)) => {
5596 info!("bot mint: existing outstanding claim for DID; returning same code");
5597 let token = sign_claim_token(&code, &state.config.cookie_secret);
5598 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5599 return bot_claim_json(BotClaimResponse {
5600 status: "existing",
5601 code,
5602 token,
5603 url,
5604 });
5605 }
5606 Ok(None) => {}
5607 Err(err) => {
5608 warn!(%err, "bot mint: find_active_code_for_did failed");
5609 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5610 }
5611 }
5612 }
5613
5614 let granted = match store::count_beta_access(&state.db).await {
5617 Ok(n) => n,
5618 Err(err) => {
5619 warn!(%err, "bot mint: count_beta_access failed; failing closed");
5620 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5621 }
5622 };
5623 let outstanding = match store::count_active_codes(&state.db).await {
5624 Ok(n) => n,
5625 Err(err) => {
5626 warn!(%err, "bot mint: count_active_codes failed; failing closed");
5627 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5628 }
5629 };
5630 if granted + outstanding >= state.config.beta_cap {
5631 info!(
5632 granted,
5633 outstanding,
5634 cap = state.config.beta_cap,
5635 "bot mint refused: at capacity"
5636 );
5637 return (
5638 StatusCode::CONFLICT,
5639 [(header::CONTENT_TYPE, "application/json")],
5640 "{\"error\":\"full\"}\n",
5641 )
5642 .into_response();
5643 }
5644
5645 let bot_did = state
5648 .config
5649 .admin_seed_dids()
5650 .first()
5651 .cloned()
5652 .unwrap_or_else(|| "did:bot:featherreader".to_string());
5653 let minted = match follower_did {
5654 Some(did) => {
5655 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5656 }
5657 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5658 };
5659 let code = match minted {
5660 Ok(c) => c,
5661 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5668 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5669 Ok(Some(code)) => {
5670 info!("bot mint: lost the mint race; returning the concurrently-minted code");
5671 let token = sign_claim_token(&code, &state.config.cookie_secret);
5672 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5673 return bot_claim_json(BotClaimResponse {
5674 status: "existing",
5675 code,
5676 token,
5677 url,
5678 });
5679 }
5680 Ok(None) => {
5684 warn!("bot mint: conflict but no active code found on recovery");
5685 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5686 }
5687 Err(err) => {
5688 warn!(%err, "bot mint: recovery lookup after conflict failed");
5689 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5690 }
5691 }
5692 }
5693 Err(err) => {
5694 warn!(%err, "bot mint_code failed");
5695 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5696 }
5697 };
5698 let token = sign_claim_token(&code, &state.config.cookie_secret);
5699 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5700 info!("bot minted a claim code + token");
5701
5702 bot_claim_json(BotClaimResponse {
5703 status: "minted",
5704 code,
5705 token,
5706 url,
5707 })
5708}
5709
5710fn bot_claim_json(resp: BotClaimResponse) -> Response {
5713 match serde_json::to_string(&resp) {
5714 Ok(body) => (
5715 StatusCode::OK,
5716 [(header::CONTENT_TYPE, "application/json")],
5717 body,
5718 )
5719 .into_response(),
5720 Err(err) => {
5721 warn!(%err, "serializing bot claim response failed");
5722 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5723 }
5724 }
5725}
5726
5727fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5732 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5733}
5734
5735fn sign_invite(code: &str, secret: &str) -> String {
5744 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5745}
5746
5747fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5752 cookie::verify_value(headers, INVITE_COOKIE, secret)
5753}
5754
5755const CLAIM_TOKEN_LABEL: &str = "claim-token";
5759
5760fn sign_claim_token(code: &str, secret: &str) -> String {
5772 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5773}
5774
5775fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5780 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5781}
5782
5783fn clear_invite_cookie(resp: &mut Response) {
5786 set_cookie(
5787 resp,
5788 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5789 );
5790}
5791
5792async fn import_opml(
5805 State(state): State<AppState>,
5806 headers: HeaderMap,
5807 mut multipart: Multipart,
5808) -> Result<Response, WebError> {
5809 let did = match current_did(&state, &headers).await {
5810 Some(d) => d,
5811 None => return Ok(Redirect::to("/login").into_response()),
5812 };
5813 let pool = &state.db;
5814
5815 let mut opml_text = String::new();
5821 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5822 let name = field.name().unwrap_or("").to_string();
5823 if name == "opml" || name == "file" {
5824 let bytes = field.bytes().await.map_err(multipart_response)?;
5825 if !bytes.is_empty() {
5826 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5827 if name == "file" {
5828 break;
5829 }
5830 }
5831 }
5832 }
5833
5834 let feeds =
5839 match opml::parse_opml(&opml_text) {
5840 Ok(feeds) => feeds,
5841 Err(err) => {
5842 warn!(%err, %did, "OPML import could not parse the uploaded file");
5843 return Ok(Redirect::to(&format!(
5844 "/?flash={}",
5845 qenc("That file could not be read as OPML. Export it again from your other reader?")
5846 ))
5847 .into_response());
5848 }
5849 };
5850 if feeds.is_empty() {
5851 info!(%did, "OPML import found no feeds");
5852 return Ok(
5853 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5854 .into_response(),
5855 );
5856 }
5857
5858 let now = now_rfc3339();
5861 let mut folder_uris: std::collections::HashMap<String, String> =
5862 std::collections::HashMap::new();
5863 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5865 for (rkey, folder) in existing {
5866 folder_uris
5867 .entry(folder.name.clone())
5868 .or_insert_with(|| folder_uri(&did, &rkey));
5869 }
5870 }
5871 let mut wanted_folders: Vec<String> = feeds
5872 .iter()
5873 .filter_map(|f| f.folder.clone())
5874 .filter(|n| !n.is_empty())
5875 .collect();
5876 wanted_folders.sort();
5877 wanted_folders.dedup();
5878 for name in wanted_folders {
5879 if folder_uris.contains_key(&name) {
5880 continue;
5881 }
5882 let folder = Folder::new(name.clone(), now.clone());
5883 match state.repo().add_folder(&did, &folder).await {
5884 Ok(rkey) => {
5885 folder_uris.insert(name, folder_uri(&did, &rkey));
5886 }
5887 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5888 }
5889 }
5890
5891 let sub_cap = state.config.max_subs_per_did;
5900 let mut headroom: Option<i64> = if sub_cap > 0 {
5901 let existing = store::count_subscriptions_for_did(pool, &did)
5902 .await
5903 .unwrap_or(0);
5904 Some((sub_cap - existing).max(0))
5905 } else {
5906 None
5907 };
5908 let mut trimmed_over_cap: usize = 0;
5909
5910 let feeds_cap = state.config.max_feeds_global;
5917 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5918 let existing = store::count_feeds(pool).await.unwrap_or(0);
5919 Some((feeds_cap - existing).max(0))
5920 } else {
5921 None
5922 };
5923 let mut trimmed_over_global: usize = 0;
5924
5925 let mut subs = Vec::with_capacity(feeds.len());
5926 let mut skipped_private: Vec<String> = Vec::new();
5927 let mut uncached: usize = 0;
5930 let mut skipped_unsupported: usize = 0;
5936 for f in &feeds {
5937 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5945 info!(
5946 %did,
5947 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5948 );
5949 skipped_unsupported += 1;
5950 continue;
5951 }
5952 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5953 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5954 let label = f
5956 .title
5957 .clone()
5958 .filter(|t| !t.trim().is_empty())
5959 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5960 skipped_private.push(label);
5961 continue;
5962 }
5963
5964 if let Some(h) = headroom.as_mut() {
5967 if *h <= 0 {
5968 trimmed_over_cap += 1;
5969 continue;
5970 }
5971 }
5972
5973 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5978 Ok(existing) => existing.is_none(),
5979 Err(err) => {
5982 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5983 false
5984 }
5985 };
5986 if is_new {
5987 if let Some(g) = global_headroom.as_mut() {
5988 if *g <= 0 {
5989 trimmed_over_global += 1;
5990 continue;
5991 }
5992 *g -= 1;
5993 }
5994 }
5995
5996 if let Some(h) = headroom.as_mut() {
5999 *h -= 1;
6000 }
6001
6002 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
6003 sub.title = f.title.clone();
6004 sub.site_url = f.site_url.clone();
6005 sub.folder = f
6006 .folder
6007 .as_ref()
6008 .and_then(|name| folder_uris.get(name).cloned());
6009 subs.push(sub);
6010 if let Err(err) = store::upsert_feed(
6016 pool,
6017 &store::NewFeed {
6018 url: f.feed_url.clone(),
6019 title: f.title.clone(),
6020 site_url: f.site_url.clone(),
6021 ..Default::default()
6022 },
6023 )
6024 .await
6025 {
6026 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
6027 it will not be polled");
6028 uncached += 1;
6029 }
6030 }
6031
6032 let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
6049 Ok(rkeys) => {
6050 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
6051 rkeys.len()
6052 }
6053 Err(err) => {
6054 let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
6055 warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
6056 landed
6057 }
6058 };
6059 if landed == 0 && !subs.is_empty() {
6060 return Ok(Redirect::to(&format!(
6061 "/?flash={}",
6062 qenc(
6063 "Could not save those subscriptions to your PDS, so nothing was imported. \
6064 Try again in a moment."
6065 )
6066 ))
6067 .into_response());
6068 }
6069
6070 let mut flash = if landed < subs.len() {
6072 format!(
6073 "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
6074 other {} may not have been saved. Importing the same file again would add the first \
6075 {landed} a second time",
6076 subs.len(),
6077 subs.len() - landed
6078 )
6079 } else {
6080 format!("Imported {} feeds", subs.len())
6081 };
6082 if uncached > 0 {
6083 flash.push_str(&format!(
6084 ". {uncached} of them could not be cached locally and may not update until the next import."
6085 ));
6086 }
6087 if trimmed_over_cap > 0 {
6088 flash.push_str(&format!(
6089 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
6090 ));
6091 }
6092 if trimmed_over_global > 0 {
6093 flash.push_str(&format!(
6094 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
6095 ));
6096 }
6097 if !skipped_private.is_empty() {
6098 flash.push_str(&format!(
6099 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
6100 skipped_private.len(),
6101 skipped_private.join(", ")
6102 ));
6103 }
6104 if skipped_unsupported > 0 {
6105 flash.push_str(&format!(
6108 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
6109 ));
6110 }
6111 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
6112}
6113
6114fn private_feed_label(url: &str) -> String {
6117 url::Url::parse(url)
6118 .ok()
6119 .and_then(|u| u.host_str().map(str::to_string))
6120 .unwrap_or_else(|| "a private feed".to_string())
6121}
6122
6123async fn export_opml(
6125 State(state): State<AppState>,
6126 headers: HeaderMap,
6127) -> Result<Response, WebError> {
6128 let did = match current_did(&state, &headers).await {
6129 Some(d) => d,
6130 None => return Ok(Redirect::to("/login").into_response()),
6131 };
6132
6133 let subs = match state.repo().list_subscriptions_sorted(&did).await {
6140 Ok(subs) => subs,
6141 Err(err) => {
6142 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
6143 return Ok(Redirect::to(&format!(
6144 "/manage?flash={}",
6145 qenc(EXPORT_INCOMPLETE_REFUSAL)
6146 ))
6147 .into_response());
6148 }
6149 };
6150 let folders = match state.repo().list_folders_sorted(&did).await {
6151 Ok(folders) => folders,
6152 Err(err) => {
6153 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
6154 return Ok(Redirect::to(&format!(
6155 "/manage?flash={}",
6156 qenc(EXPORT_INCOMPLETE_REFUSAL)
6157 ))
6158 .into_response());
6159 }
6160 };
6161 let folder_pairs: Vec<(String, Folder)> = folders
6164 .into_iter()
6165 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
6166 .collect();
6167
6168 let body = opml::to_opml(&subs, &folder_pairs);
6169 let mut resp = (StatusCode::OK, body).into_response();
6170 resp.headers_mut().insert(
6171 header::CONTENT_TYPE,
6172 "text/x-opml; charset=utf-8".parse().unwrap(),
6173 );
6174 resp.headers_mut().insert(
6175 header::CONTENT_DISPOSITION,
6176 "attachment; filename=\"featherreader-subscriptions.opml\""
6177 .parse()
6178 .unwrap(),
6179 );
6180 Ok(resp)
6181}
6182
6183fn set_cookie(resp: &mut Response, cookie: &str) {
6189 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
6190 resp.headers_mut()
6191 .append(axum::http::header::SET_COOKIE, value);
6192 }
6193}
6194
6195fn is_htmx(headers: &HeaderMap) -> bool {
6197 headers
6198 .get("HX-Request")
6199 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
6200}
6201
6202fn is_reader_request(headers: &HeaderMap) -> bool {
6208 headers
6209 .get("X-FR-Reader")
6210 .is_some_and(|v| v.as_bytes() == b"1")
6211}
6212
6213mod cookie {
6218 use super::{HeaderMap, SESSION_COOKIE};
6219
6220 pub fn sign_session(sid: &str, secret: &str) -> String {
6222 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
6223 }
6224
6225 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
6227 verify_value(headers, SESSION_COOKIE, secret)
6228 }
6229
6230 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
6236 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
6237 msg.extend_from_slice(name.as_bytes());
6238 msg.push(0);
6239 msg.extend_from_slice(value.as_bytes());
6240 msg
6241 }
6242
6243 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
6249 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
6250 let b64 = b64url_encode(value.as_bytes());
6251 format!(
6252 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
6253 )
6254 }
6255
6256 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
6259 let raw = cookie_value(headers, name)?;
6260 let (b64, sig) = raw.split_once('.')?;
6261 let bytes = b64url_decode(b64)?;
6262 let value = String::from_utf8(bytes).ok()?;
6263 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
6264 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6265 Some(value)
6266 } else {
6267 None
6268 }
6269 }
6270
6271 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
6277 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
6278 let b64 = b64url_encode(value.as_bytes());
6279 format!("{b64}.{sig}")
6280 }
6281
6282 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
6285 let (b64, sig) = token.split_once('.')?;
6286 let bytes = b64url_decode(b64)?;
6287 let value = String::from_utf8(bytes).ok()?;
6288 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
6289 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6290 Some(value)
6291 } else {
6292 None
6293 }
6294 }
6295
6296 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
6298 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
6299 for part in header.split(';') {
6300 let part = part.trim();
6301 if let Some((k, v)) = part.split_once('=') {
6302 if k == name {
6303 return Some(v.to_string());
6304 }
6305 }
6306 }
6307 None
6308 }
6309
6310 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6314 if a.len() != b.len() {
6315 return false;
6316 }
6317 let mut diff = 0u8;
6318 for (x, y) in a.iter().zip(b.iter()) {
6319 diff |= x ^ y;
6320 }
6321 diff == 0
6322 }
6323
6324 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6327
6328 fn b64url_encode(input: &[u8]) -> String {
6329 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
6330 for chunk in input.chunks(3) {
6331 let b = [
6332 chunk[0],
6333 *chunk.get(1).unwrap_or(&0),
6334 *chunk.get(2).unwrap_or(&0),
6335 ];
6336 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
6337 out.push(B64[((n >> 18) & 63) as usize] as char);
6338 out.push(B64[((n >> 12) & 63) as usize] as char);
6339 if chunk.len() > 1 {
6340 out.push(B64[((n >> 6) & 63) as usize] as char);
6341 }
6342 if chunk.len() > 2 {
6343 out.push(B64[(n & 63) as usize] as char);
6344 }
6345 }
6346 out
6347 }
6348
6349 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
6350 fn val(c: u8) -> Option<u32> {
6351 match c {
6352 b'A'..=b'Z' => Some((c - b'A') as u32),
6353 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6354 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6355 b'-' => Some(62),
6356 b'_' => Some(63),
6357 _ => None,
6358 }
6359 }
6360 let bytes = input.as_bytes();
6361 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
6362 for chunk in bytes.chunks(4) {
6363 let mut n = 0u32;
6364 let mut valid = 0;
6365 for (i, &c) in chunk.iter().enumerate() {
6366 n |= val(c)? << (18 - 6 * i);
6367 valid += 1;
6368 }
6369 out.push((n >> 16) as u8);
6370 if valid > 2 {
6371 out.push((n >> 8) as u8);
6372 }
6373 if valid > 3 {
6374 out.push(n as u8);
6375 }
6376 }
6377 Some(out)
6378 }
6379
6380 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
6384 const BLOCK: usize = 64;
6385 let mut k = [0u8; BLOCK];
6386 if key.len() > BLOCK {
6387 let d = sha256(key);
6388 k[..32].copy_from_slice(&d);
6389 } else {
6390 k[..key.len()].copy_from_slice(key);
6391 }
6392 let mut ipad = [0x36u8; BLOCK];
6393 let mut opad = [0x5cu8; BLOCK];
6394 for i in 0..BLOCK {
6395 ipad[i] ^= k[i];
6396 opad[i] ^= k[i];
6397 }
6398 let mut inner = Vec::with_capacity(BLOCK + msg.len());
6399 inner.extend_from_slice(&ipad);
6400 inner.extend_from_slice(msg);
6401 let inner_hash = sha256(&inner);
6402 let mut outer = Vec::with_capacity(BLOCK + 32);
6403 outer.extend_from_slice(&opad);
6404 outer.extend_from_slice(&inner_hash);
6405 let mac = sha256(&outer);
6406 let mut hex = String::with_capacity(64);
6407 for b in mac {
6408 hex.push_str(&format!("{b:02x}"));
6409 }
6410 hex
6411 }
6412
6413 fn sha256(data: &[u8]) -> [u8; 32] {
6415 const K: [u32; 64] = [
6416 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
6417 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
6418 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
6419 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
6420 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
6421 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
6422 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
6423 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
6424 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
6425 0xc67178f2,
6426 ];
6427 let mut h: [u32; 8] = [
6428 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
6429 0x5be0cd19,
6430 ];
6431
6432 let bit_len = (data.len() as u64) * 8;
6433 let mut msg = data.to_vec();
6434 msg.push(0x80);
6435 while msg.len() % 64 != 56 {
6436 msg.push(0);
6437 }
6438 msg.extend_from_slice(&bit_len.to_be_bytes());
6439
6440 for block in msg.chunks(64) {
6441 let mut w = [0u32; 64];
6442 for i in 0..16 {
6443 w[i] = u32::from_be_bytes([
6444 block[i * 4],
6445 block[i * 4 + 1],
6446 block[i * 4 + 2],
6447 block[i * 4 + 3],
6448 ]);
6449 }
6450 for i in 16..64 {
6451 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
6452 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
6453 w[i] = w[i - 16]
6454 .wrapping_add(s0)
6455 .wrapping_add(w[i - 7])
6456 .wrapping_add(s1);
6457 }
6458 let mut a = h;
6459 for i in 0..64 {
6460 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6461 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6462 let t1 = a[7]
6463 .wrapping_add(s1)
6464 .wrapping_add(ch)
6465 .wrapping_add(K[i])
6466 .wrapping_add(w[i]);
6467 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6468 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6469 let t2 = s0.wrapping_add(maj);
6470 a[7] = a[6];
6471 a[6] = a[5];
6472 a[5] = a[4];
6473 a[4] = a[3].wrapping_add(t1);
6474 a[3] = a[2];
6475 a[2] = a[1];
6476 a[1] = a[0];
6477 a[0] = t1.wrapping_add(t2);
6478 }
6479 for i in 0..8 {
6480 h[i] = h[i].wrapping_add(a[i]);
6481 }
6482 }
6483
6484 let mut out = [0u8; 32];
6485 for (i, word) in h.iter().enumerate() {
6486 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6487 }
6488 out
6489 }
6490
6491 #[cfg(test)]
6492 mod tests {
6493 use super::*;
6494
6495 #[test]
6496 fn sha256_known_vector() {
6497 let d = sha256(b"abc");
6498 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6499 assert_eq!(
6500 hex,
6501 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6502 );
6503 }
6504
6505 #[test]
6506 fn hmac_known_vector() {
6507 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6508 assert_eq!(
6509 mac,
6510 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6511 );
6512 }
6513
6514 #[test]
6515 fn sign_verify_round_trips() {
6516 let secret = "test-secret";
6517 let sid = "9f2c-opaque-session-id";
6518 let cookie = sign_session(sid, secret);
6519 let pair = cookie.split(';').next().unwrap().to_string();
6520 let mut headers = HeaderMap::new();
6521 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6522 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6523 assert!(verify_session(&headers, "other-secret").is_none());
6525 }
6526
6527 #[test]
6528 fn forged_and_tampered_cookies_are_rejected() {
6529 let secret = "test-secret";
6530
6531 let forged = format!(
6534 "{SESSION_COOKIE}={}.{}",
6535 b64url_encode(b"attacker-chosen-sid"),
6536 "deadbeef".repeat(8) );
6538 let mut headers = HeaderMap::new();
6539 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6540 assert!(verify_session(&headers, secret).is_none());
6541
6542 let cookie = sign_session("real-sid", secret);
6545 let pair = cookie.split(';').next().unwrap();
6546 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6547 let tampered = format!(
6548 "{SESSION_COOKIE}={}.{}",
6549 b64url_encode(b"different-sid"),
6550 sig
6551 );
6552 let mut headers2 = HeaderMap::new();
6553 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6554 assert!(verify_session(&headers2, secret).is_none());
6555 }
6556
6557 #[test]
6558 fn b64url_round_trips() {
6559 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6560 let enc = b64url_encode(s.as_bytes());
6561 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6562 }
6563 }
6564 }
6565}
6566
6567async fn get_entry_by_id(
6583 pool: &store::Pool,
6584 did: &str,
6585 id: i64,
6586) -> anyhow::Result<Option<store::Entry>> {
6587 let entry = sqlx::query_as::<_, store::Entry>(
6588 r#"
6589 SELECT e.* FROM entries e
6590 WHERE e.id = ?2
6591 AND EXISTS (
6592 SELECT 1 FROM sub_ref sr
6593 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6594 )
6595 "#,
6596 )
6597 .bind(did)
6598 .bind(id)
6599 .fetch_optional(pool)
6600 .await?;
6601 Ok(entry)
6602}
6603
6604async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6606 let read: Option<bool> =
6607 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6608 .bind(did)
6609 .bind(entry_id)
6610 .fetch_optional(pool)
6611 .await?
6612 .flatten();
6613 Ok(read.unwrap_or(false))
6614}
6615
6616async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6618 let starred: Option<bool> =
6619 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6620 .bind(did)
6621 .bind(entry_id)
6622 .fetch_optional(pool)
6623 .await?
6624 .flatten();
6625 Ok(starred.unwrap_or(false))
6626}
6627
6628async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6630 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6631 .bind(feed_id)
6632 .fetch_optional(pool)
6633 .await
6634 {
6635 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6636 _ => String::new(),
6637 }
6638}
6639
6640async fn build_entry_row(
6643 pool: &store::Pool,
6644 did: &str,
6645 id: i64,
6646 read: Option<bool>,
6647) -> anyhow::Result<Option<EntryRow>> {
6648 let entry = match get_entry_by_id(pool, did, id).await? {
6649 Some(e) => e,
6650 None => return Ok(None),
6651 };
6652 let read = match read {
6653 Some(r) => r,
6654 None => entry_is_read(pool, did, id).await?,
6655 };
6656 let starred = entry_is_starred(pool, did, id).await?;
6657 Ok(Some(EntryRow {
6658 id: entry.id,
6659 title: entry
6660 .title
6661 .clone()
6662 .filter(|t| !t.trim().is_empty())
6663 .unwrap_or_else(|| "(untitled)".to_string()),
6664 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6665 published: display_date(entry.published.as_deref()),
6666 read,
6667 starred,
6668 link: SafeLink::entry(id, ""),
6669 cached: true,
6670 rkey: String::new(),
6671 }))
6672}
6673
6674fn now_rfc3339() -> String {
6676 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6677}
6678
6679#[cfg(test)]
6680mod tests {
6681 use super::*;
6682
6683 #[test]
6684 fn qenc_encodes_reserved() {
6685 assert_eq!(qenc("a b"), "a%20b");
6686 assert_eq!(
6687 qenc("https://example.com/feed.xml"),
6688 "https%3A%2F%2Fexample.com%2Ffeed.xml"
6689 );
6690 assert_eq!(
6691 qenc("at://did:plc:x/c/r"),
6692 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6693 );
6694 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6696 }
6697
6698 #[test]
6699 fn folder_uri_shape() {
6700 assert_eq!(
6701 folder_uri("did:plc:abc", "3kfolder"),
6702 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6703 );
6704 }
6705
6706 #[test]
6709 fn private_feeds_are_classified_private_across_providers() {
6710 for url in [
6714 "https://author.substack.com/feed/private/deadbeefcafe1234",
6715 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6716 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6717 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6718 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6719 "https://user:pass@example.com/feed",
6720 ] {
6721 assert!(
6722 feed::classify_feed_privacy(url).is_private(),
6723 "expected private: {url}"
6724 );
6725 }
6726 }
6727
6728 #[test]
6729 fn public_feeds_stay_public() {
6730 for url in [
6731 "https://author.substack.com/feed",
6732 "https://wordpress.example.com/feed/",
6733 "https://example.com/rss.xml",
6734 "https://example.org/atom.xml",
6735 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6737 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6738 ] {
6739 assert!(
6740 !feed::classify_feed_privacy(url).is_private(),
6741 "expected public: {url}"
6742 );
6743 }
6744 }
6745
6746 #[test]
6747 fn private_feed_label_is_public_safe_host_only() {
6748 let label =
6750 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6751 assert_eq!(label, "author.substack.com");
6752 assert!(!label.contains("deadbeefcafe1234token"));
6753 assert!(!label.contains("/private/"));
6754 assert_eq!(private_feed_label("not a url"), "a private feed");
6756 }
6757
6758 #[test]
6759 fn refusal_message_promises_nothing_stored() {
6760 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6761 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6762 }
6763
6764 #[test]
6765 fn scope_query_preserves_context() {
6766 let q = EntryQuery {
6767 feed: Some("https://example.com/feed.xml".to_string()),
6768 folder: None,
6769 view: Some("all".to_string()),
6770 };
6771 let s = scope_query(&q);
6772 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6773 assert!(s.contains("view=all"));
6774
6775 let q2 = EntryQuery {
6777 feed: None,
6778 folder: None,
6779 view: Some("unread".to_string()),
6780 };
6781 assert_eq!(scope_query(&q2), "");
6782 }
6783
6784 use axum::body::Body;
6787 use axum::http::Request;
6788 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6794 let db = store::init_url("sqlite::memory:").await.unwrap();
6795 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6796 store::ensure_seed(&db, &dids).await.unwrap();
6797 let config = Config {
6798 allowed_dids: dids,
6799 cookie_secret: "test-cookie-secret-000".to_string(),
6800 beta_cap: 3,
6801 ..Config::default()
6802 };
6803 AppState::new(config, db).unwrap()
6804 }
6805
6806 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6809 let sid = state.sessions.create(Session {
6810 did: did.to_string(),
6811 handle: handle.map(str::to_string),
6812 });
6813 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6814 sc.split(';').next().unwrap().to_string()
6815 }
6816
6817 #[test]
6821 fn the_rate_limit_map_is_bounded() {
6822 let rl = RateLimiter::shared();
6823 let now = Instant::now();
6824 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6825 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6828 rl.check_at(ip, now + Duration::from_millis(i as u64));
6829 }
6830 let len = rl.inner.lock().unwrap().buckets.len();
6831 assert!(
6832 len <= MAX_RATE_BUCKETS,
6833 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6834 );
6835 }
6836
6837 #[test]
6844 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6845 let rl = RateLimiter::shared();
6846 let base = Instant::now();
6847 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6848 let at = |n: u64| base + Duration::from_nanos(n);
6853
6854 for i in 0..(RATE_BURST as u64) {
6856 assert!(rl.check_at(attacker, at(i)));
6857 }
6858 assert!(
6859 !rl.check_at(attacker, at(RATE_BURST as u64)),
6860 "burst was not exhausted; the rest of this test proves nothing"
6861 );
6862
6863 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6866 let t = at(100 + i as u64 * 2);
6867 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6868 rl.check_at(ip, t);
6869 assert!(
6870 !rl.check_at(attacker, t),
6871 "the attacker got a token back after evictions at i={i}"
6872 );
6873 }
6874 }
6875
6876 #[test]
6879 fn the_idle_sweep_does_not_run_on_every_request() {
6880 let rl = RateLimiter::shared();
6881 let start = Instant::now();
6882 let a: IpAddr = "198.51.100.1".parse().unwrap();
6883 let b: IpAddr = "198.51.100.2".parse().unwrap();
6884
6885 rl.check_at(a, start);
6886 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6889 assert!(
6890 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6891 "an idle bucket survived a sweep that was due"
6892 );
6893
6894 let before = rl.inner.lock().unwrap().last_sweep;
6897 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6898 assert_eq!(
6899 rl.inner.lock().unwrap().last_sweep,
6900 before,
6901 "the sweep ran again within the interval"
6902 );
6903 }
6904
6905 #[test]
6906 fn rate_limited_paths_match_expected() {
6907 use axum::http::Method;
6908 assert!(is_rate_limited_path("/login", &Method::GET));
6909 assert!(is_rate_limited_path("/login", &Method::POST));
6910 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6911 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6912 assert!(is_rate_limited_path("/opml", &Method::POST));
6913 assert!(is_rate_limited_path("/read-all", &Method::POST));
6914 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6915 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6916 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6917 assert!(!is_rate_limited_path("/", &Method::GET));
6919 assert!(!is_rate_limited_path("/about", &Method::GET));
6920 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6921 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6922 }
6923
6924 #[test]
6925 fn rate_limiter_allows_burst_then_429s() {
6926 let rl = RateLimiter::shared();
6927 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6928 for _ in 0..(RATE_BURST as usize) {
6930 assert!(rl.check(ip));
6931 }
6932 assert!(!rl.check(ip));
6934 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6936 assert!(rl.check(ip2));
6937 }
6938
6939 #[test]
6940 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6941 let mut h = HeaderMap::new();
6945 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6946 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6947 assert_eq!(
6948 client_ip(&h, Some(&sock), None),
6949 Some("203.0.113.55".parse().unwrap()),
6950 "spoofed XFF must not override the socket peer"
6951 );
6952 }
6953
6954 #[test]
6955 fn client_ip_uses_trusted_header_last_hop() {
6956 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6961
6962 let mut h = HeaderMap::new();
6963 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6964 assert_eq!(
6965 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6966 Some("198.51.100.9".parse().unwrap())
6967 );
6968
6969 let mut h2 = HeaderMap::new();
6971 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6972 assert_eq!(
6973 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6974 Some("198.51.100.9".parse().unwrap()),
6975 "must take the right-most (trusted) hop, not the forged left-most"
6976 );
6977
6978 let h3 = HeaderMap::new();
6980 assert_eq!(
6981 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6982 Some("10.0.0.1".parse().unwrap())
6983 );
6984 }
6985
6986 #[test]
6987 fn invite_cookie_round_trips_and_rejects_tamper() {
6988 let secret = "test-cookie-secret-000";
6989 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6990 let pair = sc.split(';').next().unwrap();
6991 let mut h = HeaderMap::new();
6992 h.insert(header::COOKIE, pair.parse().unwrap());
6993 assert_eq!(
6994 invite_cookie_code(&h, secret).as_deref(),
6995 Some("FEATHER-ABCDWXYZ")
6996 );
6997 assert!(invite_cookie_code(&h, "other").is_none());
6999 }
7000
7001 #[tokio::test]
7002 async fn preflight_valid_expired_and_full() {
7003 let state = test_state(&["did:plc:admin"]).await;
7004 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7006 .await
7007 .unwrap();
7008 assert!(preflight_code(&state, &code).await.is_ok());
7009
7010 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
7014 .await
7015 .unwrap();
7016 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
7017 .bind(chrono::Utc::now().timestamp() - 3600)
7018 .bind(&expired)
7019 .execute(&state.db)
7020 .await
7021 .unwrap();
7022 assert_eq!(
7023 preflight_code(&state, &expired).await,
7024 Err(store::RedeemError::Expired)
7025 );
7026
7027 assert_eq!(
7029 preflight_code(&state, "FEATHER-NOPENOPE").await,
7030 Err(store::RedeemError::NotFound)
7031 );
7032
7033 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7036 .await
7037 .unwrap();
7038 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7039 .await
7040 .unwrap();
7041 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7042 assert_eq!(
7043 preflight_code(&state, &code).await,
7044 Err(store::RedeemError::CapacityFull)
7045 );
7046 }
7047
7048 async fn bot_state(bot_secret: &str) -> AppState {
7052 let db = store::init_url("sqlite::memory:").await.unwrap();
7053 store::ensure_seed(&db, &["did:plc:admin".to_string()])
7054 .await
7055 .unwrap();
7056 let config = Config {
7057 allowed_dids: vec!["did:plc:admin".to_string()],
7058 cookie_secret: "test-cookie-secret-000".to_string(),
7059 beta_cap: 3,
7060 bot_secret: Some(bot_secret.to_string()),
7061 public_url: "https://feather-reader.com".to_string(),
7062 ..Config::default()
7063 };
7064 AppState::new(config, db).unwrap()
7065 }
7066
7067 #[test]
7068 fn claim_token_round_trips_and_rejects_tamper() {
7069 let secret = "test-cookie-secret-000";
7070 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
7071 assert!(!token.contains(';'));
7073 assert_eq!(
7074 claim_token_code(&token, secret).as_deref(),
7075 Some("FEATHER-ABCDWXYZ")
7076 );
7077 assert!(claim_token_code(&token, "other").is_none());
7079 let mut bad = token.clone();
7081 bad.push('x');
7082 assert!(claim_token_code(&bad, secret).is_none());
7083 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
7089 assert_eq!(
7090 test_b64url_decode(b64).as_deref(),
7091 Some("FEATHER-ABCDWXYZ".as_bytes()),
7092 "the code half of the token is plain base64url, decodable by anyone"
7093 );
7094 }
7095
7096 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
7099 fn val(c: u8) -> Option<u32> {
7100 match c {
7101 b'A'..=b'Z' => Some((c - b'A') as u32),
7102 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
7103 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
7104 b'-' => Some(62),
7105 b'_' => Some(63),
7106 _ => None,
7107 }
7108 }
7109 let mut out = Vec::with_capacity(input.len() / 4 * 3);
7110 for chunk in input.as_bytes().chunks(4) {
7111 let mut n = 0u32;
7112 let mut bits = 0;
7113 for &c in chunk {
7114 n = (n << 6) | val(c)?;
7115 bits += 6;
7116 }
7117 let bytes = bits / 8;
7118 n <<= 24 - bits;
7119 for i in 0..bytes {
7120 out.push((n >> (16 - i * 8)) as u8);
7121 }
7122 }
7123 Some(out)
7124 }
7125
7126 #[tokio::test]
7127 async fn bot_mint_then_claim_grants_a_seat() {
7128 let state = bot_state("bot-secret-abcdef").await;
7129 let app = router(state.clone());
7130
7131 let resp = app
7133 .clone()
7134 .oneshot(
7135 Request::builder()
7136 .method("POST")
7137 .uri("/bot/claims")
7138 .header("x-bot-secret", "bot-secret-abcdef")
7139 .body(Body::empty())
7140 .unwrap(),
7141 )
7142 .await
7143 .unwrap();
7144 assert_eq!(resp.status(), StatusCode::OK);
7145 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7146 .await
7147 .unwrap();
7148 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7149 let token = json["token"].as_str().unwrap().to_string();
7150 let url = json["url"].as_str().unwrap();
7151 assert!(url.starts_with("https://feather-reader.com/claim?t="));
7152 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
7154 assert!(!url.contains("FEATHER-"));
7155
7156 let resp = app
7158 .clone()
7159 .oneshot(
7160 Request::builder()
7161 .method("GET")
7162 .uri(format!("/claim?t={}", qenc(&token)))
7163 .body(Body::empty())
7164 .unwrap(),
7165 )
7166 .await
7167 .unwrap();
7168 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7169 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7170 let set_cookie = resp
7171 .headers()
7172 .get(header::SET_COOKIE)
7173 .unwrap()
7174 .to_str()
7175 .unwrap();
7176 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
7177
7178 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
7181 let out = store::redeem_code(
7182 &state.db,
7183 &code,
7184 "did:plc:follower",
7185 None,
7186 state.config.beta_cap,
7187 )
7188 .await
7189 .unwrap();
7190 assert_eq!(out, Ok(()));
7191 assert!(store::has_beta_access(&state.db, "did:plc:follower")
7192 .await
7193 .unwrap());
7194 }
7195
7196 #[tokio::test]
7197 async fn claim_with_invalid_token_bounces() {
7198 let state = bot_state("bot-secret-abcdef").await;
7199 let app = router(state);
7200 let resp = app
7201 .oneshot(
7202 Request::builder()
7203 .method("GET")
7204 .uri("/claim?t=not-a-real-token")
7205 .body(Body::empty())
7206 .unwrap(),
7207 )
7208 .await
7209 .unwrap();
7210 assert_eq!(resp.status(), StatusCode::OK);
7212 }
7213
7214 #[tokio::test]
7215 async fn claim_with_used_token_is_refused() {
7216 let state = bot_state("bot-secret-abcdef").await;
7217 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7219 .await
7220 .unwrap();
7221 let token = sign_claim_token(&code, &state.config.cookie_secret);
7222 store::redeem_code(
7223 &state.db,
7224 &code,
7225 "did:plc:someone",
7226 None,
7227 state.config.beta_cap,
7228 )
7229 .await
7230 .unwrap()
7231 .unwrap();
7232 let app = router(state);
7233 let resp = app
7234 .oneshot(
7235 Request::builder()
7236 .method("GET")
7237 .uri(format!("/claim?t={}", qenc(&token)))
7238 .body(Body::empty())
7239 .unwrap(),
7240 )
7241 .await
7242 .unwrap();
7243 assert_eq!(resp.status(), StatusCode::OK);
7245 assert!(resp.headers().get(header::SET_COOKIE).is_none());
7246 }
7247
7248 #[tokio::test]
7249 async fn bot_claims_rejects_bad_and_missing_secret() {
7250 let state = bot_state("bot-secret-abcdef").await;
7251 let app = router(state);
7252 let resp = app
7254 .clone()
7255 .oneshot(
7256 Request::builder()
7257 .method("POST")
7258 .uri("/bot/claims")
7259 .header("x-bot-secret", "wrong")
7260 .body(Body::empty())
7261 .unwrap(),
7262 )
7263 .await
7264 .unwrap();
7265 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7266 let resp = app
7268 .oneshot(
7269 Request::builder()
7270 .method("POST")
7271 .uri("/bot/claims")
7272 .body(Body::empty())
7273 .unwrap(),
7274 )
7275 .await
7276 .unwrap();
7277 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7278 }
7279
7280 #[tokio::test]
7281 async fn bot_claims_disabled_when_secret_unset() {
7282 let state = test_state(&["did:plc:admin"]).await;
7284 let app = router(state);
7285 let resp = app
7286 .oneshot(
7287 Request::builder()
7288 .method("POST")
7289 .uri("/bot/claims")
7290 .header("x-bot-secret", "anything")
7291 .body(Body::empty())
7292 .unwrap(),
7293 )
7294 .await
7295 .unwrap();
7296 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7297 }
7298
7299 #[tokio::test]
7300 async fn bot_claims_refuses_at_capacity() {
7301 let state = bot_state("bot-secret-abcdef").await;
7302 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7304 .await
7305 .unwrap();
7306 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7307 .await
7308 .unwrap();
7309 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7310 let app = router(state);
7311 let resp = app
7312 .oneshot(
7313 Request::builder()
7314 .method("POST")
7315 .uri("/bot/claims")
7316 .header("x-bot-secret", "bot-secret-abcdef")
7317 .body(Body::empty())
7318 .unwrap(),
7319 )
7320 .await
7321 .unwrap();
7322 assert_eq!(resp.status(), StatusCode::CONFLICT);
7323 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7324 .await
7325 .unwrap();
7326 assert!(String::from_utf8_lossy(&bytes).contains("full"));
7327 }
7328
7329 #[tokio::test]
7330 async fn bot_claims_counts_outstanding_codes_against_cap() {
7331 let state = bot_state("bot-secret-abcdef").await;
7332 store::mint_code(&state.db, "did:plc:admin", 3600)
7334 .await
7335 .unwrap();
7336 store::mint_code(&state.db, "did:plc:admin", 3600)
7337 .await
7338 .unwrap();
7339 let app = router(state);
7340 let resp = app
7341 .oneshot(
7342 Request::builder()
7343 .method("POST")
7344 .uri("/bot/claims")
7345 .header("x-bot-secret", "bot-secret-abcdef")
7346 .body(Body::empty())
7347 .unwrap(),
7348 )
7349 .await
7350 .unwrap();
7351 assert_eq!(resp.status(), StatusCode::CONFLICT);
7353 }
7354
7355 async fn post_bot_claim_for(
7357 app: &axum::Router,
7358 secret: &str,
7359 did: &str,
7360 ) -> (StatusCode, serde_json::Value) {
7361 let resp = app
7362 .clone()
7363 .oneshot(
7364 Request::builder()
7365 .method("POST")
7366 .uri("/bot/claims")
7367 .header("x-bot-secret", secret)
7368 .header("content-type", "application/json")
7369 .body(Body::from(format!(
7370 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
7371 )))
7372 .unwrap(),
7373 )
7374 .await
7375 .unwrap();
7376 let status = resp.status();
7377 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7378 .await
7379 .unwrap();
7380 let json = if bytes.is_empty() {
7381 serde_json::Value::Null
7382 } else {
7383 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
7384 };
7385 (status, json)
7386 }
7387
7388 #[tokio::test]
7389 async fn bot_claims_returns_already_seated_for_a_member() {
7390 let state = bot_state("bot-secret-abcdef").await;
7394 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
7395 .await
7396 .unwrap();
7397 let app = router(state.clone());
7398 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
7399 assert_eq!(status, StatusCode::OK);
7400 assert_eq!(json["status"], "already_seated");
7401 assert_eq!(json["code"], "");
7402 assert_eq!(json["url"], "");
7403 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
7405 .await
7406 .unwrap()
7407 .is_none());
7408 }
7409
7410 #[tokio::test]
7411 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
7412 let state = bot_state("bot-secret-abcdef").await;
7416 let app = router(state.clone());
7417
7418 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7419 assert_eq!(s1, StatusCode::OK);
7420 assert_eq!(j1["status"], "minted");
7421 let code1 = j1["code"].as_str().unwrap().to_string();
7422
7423 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7424 assert_eq!(s2, StatusCode::OK);
7425 assert_eq!(j2["status"], "existing");
7426 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
7427 assert_eq!(j2["url"], j1["url"], "same url returned");
7428
7429 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
7431 }
7432
7433 #[tokio::test]
7434 async fn bot_claims_records_intended_did_at_mint() {
7435 let state = bot_state("bot-secret-abcdef").await;
7437 let app = router(state.clone());
7438 let (status, json) =
7439 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
7440 assert_eq!(status, StatusCode::OK);
7441 let code = json["code"].as_str().unwrap();
7442 assert_eq!(
7443 store::find_active_code_for_did(&state.db, "did:plc:follower2")
7444 .await
7445 .unwrap()
7446 .as_deref(),
7447 Some(code)
7448 );
7449 }
7450
7451 #[tokio::test]
7452 async fn bot_claims_concurrent_same_did_never_double_mints() {
7453 let state = bot_state("bot-secret-abcdef").await;
7460 let app = router(state.clone());
7461
7462 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7463 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7464 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7465
7466 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7467 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7468
7469 assert_eq!(
7471 store::count_active_codes(&state.db).await.unwrap(),
7472 1,
7473 "concurrent mints must not create two active codes"
7474 );
7475
7476 let ca = ja["code"].as_str().unwrap_or("");
7478 let cb = jb["code"].as_str().unwrap_or("");
7479 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7480 assert_eq!(ca, cb, "both callers must get the one minted code");
7481 for st in [&ja["status"], &jb["status"]] {
7484 let s = st.as_str().unwrap_or("");
7485 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7486 }
7487 }
7488
7489 #[tokio::test]
7490 async fn bot_claims_rejects_malformed_json_body() {
7491 let state = bot_state("bot-secret-abcdef").await;
7492 let app = router(state);
7493 let resp = app
7494 .oneshot(
7495 Request::builder()
7496 .method("POST")
7497 .uri("/bot/claims")
7498 .header("x-bot-secret", "bot-secret-abcdef")
7499 .header("content-type", "application/json")
7500 .body(Body::from("{not json"))
7501 .unwrap(),
7502 )
7503 .await
7504 .unwrap();
7505 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7506 }
7507
7508 #[tokio::test]
7509 async fn favicon_ico_served_at_root() {
7510 let state = test_state(&[]).await;
7513 let app = router(state);
7514 let resp = app
7515 .oneshot(
7516 Request::builder()
7517 .uri("/favicon.ico")
7518 .body(Body::empty())
7519 .unwrap(),
7520 )
7521 .await
7522 .unwrap();
7523 assert_eq!(resp.status(), StatusCode::OK);
7524 let ct = resp
7525 .headers()
7526 .get(header::CONTENT_TYPE)
7527 .unwrap()
7528 .to_str()
7529 .unwrap();
7530 assert!(
7531 ct.contains("icon") || ct.starts_with("image/"),
7532 "content-type = {ct}"
7533 );
7534 }
7535
7536 #[tokio::test]
7537 async fn login_without_invite_redirects_to_beta_redeem() {
7538 let state = test_state(&[]).await;
7540 let app = router(state);
7541 let resp = app
7542 .oneshot(
7543 Request::builder()
7544 .method("POST")
7545 .uri("/login")
7546 .header("content-type", "application/x-www-form-urlencoded")
7547 .body(Body::from("handle=alice.bsky.social"))
7548 .unwrap(),
7549 )
7550 .await
7551 .unwrap();
7552 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7553 assert_eq!(
7554 resp.headers().get(header::LOCATION).unwrap(),
7555 "/beta/redeem"
7556 );
7557 }
7558
7559 #[tokio::test]
7560 async fn login_with_valid_invite_cookie_starts_oauth() {
7561 let state = test_state(&[]).await;
7562 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7563 let cookie = cookie.split(';').next().unwrap().to_string();
7564 let app = router(state);
7565 let resp = app
7566 .oneshot(
7567 Request::builder()
7568 .method("POST")
7569 .uri("/login")
7570 .header("content-type", "application/x-www-form-urlencoded")
7571 .header(header::COOKIE, cookie)
7572 .body(Body::from("handle=alice.bsky.social"))
7573 .unwrap(),
7574 )
7575 .await
7576 .unwrap();
7577 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7579 let loc = resp
7580 .headers()
7581 .get(header::LOCATION)
7582 .unwrap()
7583 .to_str()
7584 .unwrap();
7585 assert!(loc.contains("/login"), "loc = {loc}");
7586 assert_ne!(loc, "/beta/redeem");
7587 }
7588
7589 async fn resolver_never(_handle: String) -> Option<String> {
7592 None
7593 }
7594
7595 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7597 move |_handle| std::future::ready(Some(did.to_string()))
7598 }
7599
7600 #[tokio::test]
7604 async fn may_start_oauth_honors_seat_via_resolved_handle() {
7605 let state = test_state(&["did:plc:admin"]).await;
7608 let headers = HeaderMap::new();
7609 assert!(
7610 may_start_oauth_with(
7611 &state,
7612 &headers,
7613 "admin.example",
7614 resolver_to("did:plc:admin")
7615 )
7616 .await,
7617 "a handle resolving to a seated DID must pass the gate"
7618 );
7619 }
7620
7621 #[tokio::test]
7625 async fn may_start_oauth_bounces_non_member_handle() {
7626 let state = test_state(&["did:plc:admin"]).await;
7627 let headers = HeaderMap::new();
7628 assert!(
7629 !may_start_oauth_with(
7630 &state,
7631 &headers,
7632 "rando.example",
7633 resolver_to("did:plc:rando")
7634 )
7635 .await,
7636 "a resolved DID with no seat must be bounced"
7637 );
7638 }
7639
7640 #[tokio::test]
7643 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7644 let state = test_state(&["did:plc:admin"]).await;
7645 let headers = HeaderMap::new();
7646 assert!(
7647 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7648 "an unresolvable handle must fail closed"
7649 );
7650 }
7651
7652 #[tokio::test]
7656 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7657 let state = test_state(&[]).await;
7658 let did = "did:plc:member";
7659 store::grant_access(&state.db, did, Some("member.example"), "test", None)
7660 .await
7661 .unwrap();
7662 let cookie = session_cookie(&state, did, Some("member.example"));
7663 let mut headers = HeaderMap::new();
7664 headers.insert(header::COOKIE, cookie.parse().unwrap());
7665 assert!(
7666 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7667 "a seated session cookie must pass without resolution"
7668 );
7669 }
7670
7671 #[tokio::test]
7673 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7674 let state = test_state(&[]).await;
7675 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7676 let cookie = cookie.split(';').next().unwrap().to_string();
7677 let mut headers = HeaderMap::new();
7678 headers.insert(header::COOKIE, cookie.parse().unwrap());
7679 assert!(
7680 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7681 "a valid invite cookie must pass without resolution"
7682 );
7683 }
7684
7685 #[tokio::test]
7686 async fn admin_mint_requires_admin_seed_did() {
7687 let state = test_state(&["did:plc:admin"]).await;
7688 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7690 .await
7691 .unwrap();
7692 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7693 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7695 let app = router(state);
7696
7697 let forbidden = app
7698 .clone()
7699 .oneshot(
7700 Request::builder()
7701 .method("POST")
7702 .uri("/admin/invites?n=2")
7703 .header(header::COOKIE, rando_cookie)
7704 .body(Body::empty())
7705 .unwrap(),
7706 )
7707 .await
7708 .unwrap();
7709 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7710
7711 let ok = app
7712 .oneshot(
7713 Request::builder()
7714 .method("POST")
7715 .uri("/admin/invites?n=2")
7716 .header(header::COOKIE, admin_cookie)
7717 .body(Body::empty())
7718 .unwrap(),
7719 )
7720 .await
7721 .unwrap();
7722 assert_eq!(ok.status(), StatusCode::OK);
7723 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7724 .await
7725 .unwrap();
7726 let body = String::from_utf8(bytes.to_vec()).unwrap();
7727 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7728 assert_eq!(minted.len(), 2);
7729 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7730 }
7731
7732 #[tokio::test]
7733 async fn admin_mint_unauthenticated_is_401() {
7734 let state = test_state(&["did:plc:admin"]).await;
7735 let app = router(state);
7736 let resp = app
7737 .oneshot(
7738 Request::builder()
7739 .method("POST")
7740 .uri("/admin/invites")
7741 .body(Body::empty())
7742 .unwrap(),
7743 )
7744 .await
7745 .unwrap();
7746 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7747 }
7748
7749 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7752 let db = store::init_url("sqlite::memory:").await.unwrap();
7753 store::record_network_stat(
7754 &db,
7755 &store::NetworkStat {
7756 key: store::ADOPTION_STAT_KEY.to_string(),
7757 source: "https://relay1.us-west.bsky.network".to_string(),
7758 value: repos,
7759 truncated,
7760 observed_at: "2026-08-13T04:05:06Z".to_string(),
7761 },
7762 )
7763 .await
7764 .unwrap();
7765 let config = Config {
7766 cookie_secret: "test-cookie-secret-000".to_string(),
7767 show_adoption: true,
7768 ..Config::default()
7769 };
7770 AppState::new(config, db).unwrap()
7771 }
7772
7773 async fn about_body(state: AppState) -> String {
7774 let resp = router(state)
7775 .oneshot(
7776 Request::builder()
7777 .uri("/about")
7778 .body(Body::empty())
7779 .unwrap(),
7780 )
7781 .await
7782 .unwrap();
7783 assert_eq!(resp.status(), StatusCode::OK);
7784 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7785 .await
7786 .unwrap();
7787 String::from_utf8(bytes.to_vec()).unwrap()
7788 }
7789
7790 #[tokio::test]
7792 async fn about_omits_adoption_line_by_default() {
7793 let state = test_state(&[]).await;
7794 assert!(!state.config.show_adoption);
7795 let body = about_body(state).await;
7796 assert!(
7797 !body.contains("atproto network"),
7798 "the adoption line must not render by default"
7799 );
7800 }
7801
7802 #[tokio::test]
7803 async fn about_renders_adoption_line_when_enabled() {
7804 let body = about_body(adoption_state(7_318, false).await).await;
7812 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7815 assert!(
7816 flat.contains("7318 accounts on the atproto network hold"),
7817 "the count did not render in its own sentence: {flat}",
7818 );
7819 assert!(
7820 body.contains("accounts on the atproto network hold"),
7821 "{body}"
7822 );
7823 assert!(
7824 body.contains("2026-08-13"),
7825 "the observation date must render"
7826 );
7827 assert!(
7828 body.contains("lower bound"),
7829 "the non-archival caveat must ride along with the number"
7830 );
7831 assert!(
7832 !body.contains("At least"),
7833 "an untruncated count is exact-ish"
7834 );
7835 }
7836
7837 #[tokio::test]
7839 async fn about_adoption_line_is_singular_at_one() {
7840 let body = about_body(adoption_state(1, false).await).await;
7841 assert!(
7842 body.contains("account on the atproto network holds"),
7843 "{body}"
7844 );
7845 }
7846
7847 #[tokio::test]
7849 async fn about_adoption_line_says_at_least_when_truncated() {
7850 let body = about_body(adoption_state(25_000, true).await).await;
7851 assert!(body.contains("At least"), "{body}");
7852 }
7853
7854 #[tokio::test]
7856 async fn about_omits_line_when_enabled_with_no_observation() {
7857 let db = store::init_url("sqlite::memory:").await.unwrap();
7858 let config = Config {
7859 cookie_secret: "test-cookie-secret-000".to_string(),
7860 show_adoption: true,
7861 ..Config::default()
7862 };
7863 let body = about_body(AppState::new(config, db).unwrap()).await;
7864 assert!(!body.contains("atproto network"));
7865 }
7866
7867 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7878 let db = store::init_url("sqlite::memory:").await.unwrap();
7879 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7880 let config = Config {
7881 allowed_dids: vec![did.to_string()],
7882 cookie_secret: "test-cookie-secret-000".to_string(),
7883 beta_cap: 3,
7884 standard_site,
7885 ..Config::default()
7886 };
7887 AppState::new(config, db).unwrap()
7888 }
7889
7890 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7892 let cookie = session_cookie(&state, did, Some("reader.example"));
7893 let resp = router(state)
7894 .oneshot(
7895 Request::builder()
7896 .uri(path)
7897 .header(header::COOKIE, cookie)
7898 .body(Body::empty())
7899 .unwrap(),
7900 )
7901 .await
7902 .unwrap();
7903 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7904 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7905 .await
7906 .unwrap();
7907 String::from_utf8(bytes.to_vec()).unwrap()
7908 }
7909
7910 async fn public_body(state: AppState, path: &str) -> String {
7912 let resp = router(state)
7913 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7914 .await
7915 .unwrap();
7916 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7917 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7918 .await
7919 .unwrap();
7920 String::from_utf8(bytes.to_vec()).unwrap()
7921 }
7922
7923 fn feed_url_input(body: &str) -> &str {
7925 let start = body
7926 .find("id=\"feed-url\"")
7927 .and_then(|i| body[..i].rfind("<input"))
7928 .expect("the subscribe form's URL input renders");
7929 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7930 &body[start..end]
7931 }
7932
7933 #[tokio::test]
7936 async fn manage_hints_at_publications_when_the_flag_is_on() {
7937 let did = "did:plc:reader";
7938 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7939 assert!(
7940 body.contains("at://did:plc:…/site.standard.publication/…"),
7941 "the DID form must be shown: {body}"
7942 );
7943 assert!(
7944 body.contains("at://alice.example.com/site.standard.publication/…"),
7945 "the handle form must be shown: {body}"
7946 );
7947 }
7948
7949 #[tokio::test]
7955 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7956 let did = "did:plc:reader";
7957 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7958 let input = feed_url_input(&body);
7959 assert!(
7960 input.contains("type=\"text\""),
7961 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7962 );
7963 assert!(
7964 input.contains("inputmode=\"url\""),
7965 "the URL keyboard is still wanted: {input}"
7966 );
7967 }
7968
7969 #[tokio::test]
7975 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7976 let did = "did:plc:reader";
7977 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7978 let input = feed_url_input(&body);
7979 assert!(
7980 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7981 "the text input must keep a scheme check: {input}"
7982 );
7983 }
7984
7985 #[tokio::test]
7988 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7989 let did = "did:plc:reader";
7990 let state = standard_site_state(false, did).await;
7991 assert!(!state.config.standard_site);
7992 let page = signed_in_body(state, "/manage", did).await;
7993 let body = &page[page.find("</head>").expect("a <head>")..];
7998 assert!(
7999 !body.contains("site.standard.publication"),
8000 "a refused form must not be advertised: {body}"
8001 );
8002 let above_footer = body
8007 .split("<footer")
8008 .next()
8009 .expect("split yields at least one piece");
8010 assert!(
8011 above_footer.contains("id=\"feed-url\""),
8012 "the form must be above the footer: {body}"
8013 );
8014 assert!(
8015 !above_footer.contains("standard.site"),
8016 "a refused form must not be advertised: {body}"
8017 );
8018 assert!(
8019 feed_url_input(body).contains("type=\"url\""),
8020 "with the flag off the input is unchanged"
8021 );
8022 }
8023
8024 #[tokio::test]
8027 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
8028 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
8029 assert!(body.contains("standard.site"), "{body}");
8030 assert!(
8031 body.contains("at://did:plc:…/site.standard.publication/…"),
8032 "the landing page must show the DID form: {body}"
8033 );
8034 assert!(
8035 body.contains("at://alice.example.com/site.standard.publication/…"),
8036 "the landing page must show the handle form: {body}"
8037 );
8038 }
8039
8040 #[tokio::test]
8044 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
8045 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
8046 assert!(body.contains("standard.site"), "{body}");
8047 assert!(
8048 !body.contains("at://did:plc:…/site.standard.publication/…"),
8049 "no paste instructions with the flag off: {body}"
8050 );
8051 assert!(
8052 !body.contains("at://alice.example.com/site.standard.publication/…"),
8053 "no paste instructions with the flag off: {body}"
8054 );
8055 assert!(
8056 body.contains("isn't accepting new publication subscriptions"),
8057 "the page must say the form is closed here: {body}"
8058 );
8059 }
8060
8061 #[tokio::test]
8063 async fn about_describes_publications_and_how_to_subscribe_when_on() {
8064 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
8065 assert!(body.contains("site.standard.publication"), "{body}");
8066 assert!(body.contains("site.standard.document"), "{body}");
8067 assert!(
8068 body.contains("at://did:plc:…/site.standard.publication/…"),
8069 "{body}"
8070 );
8071 assert!(
8072 body.contains("at://alice.example.com/site.standard.publication/…"),
8073 "{body}"
8074 );
8075 }
8076
8077 #[tokio::test]
8079 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
8080 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
8081 assert!(body.contains("site.standard.publication"), "{body}");
8082 assert!(
8083 !body.contains("at://did:plc:…/site.standard.publication/…"),
8084 "no paste instructions with the flag off: {body}"
8085 );
8086 assert!(
8087 !body.contains("at://alice.example.com/site.standard.publication/…"),
8088 "no paste instructions with the flag off: {body}"
8089 );
8090 assert!(
8091 body.contains("isn't accepting new publication subscriptions"),
8092 "{body}"
8093 );
8094 }
8095
8096 #[tokio::test]
8104 async fn standard_site_page_renders_signed_out() {
8105 let body = public_body(test_state(&[]).await, "/standard-site").await;
8106 assert!(body.contains("site.standard.publication"), "{body}");
8107 assert!(body.contains("site.standard.document"), "{body}");
8108 assert!(
8109 body.contains("<title>standard.site — FeatherReader</title>"),
8110 "{body}"
8111 );
8112 }
8113
8114 #[tokio::test]
8117 async fn standard_site_page_tells_how_to_subscribe_when_on() {
8118 let body = public_body(
8119 standard_site_state(true, "did:plc:x").await,
8120 "/standard-site",
8121 )
8122 .await;
8123 assert!(
8124 body.contains("at://did:plc:…/site.standard.publication/…"),
8125 "the DID form must be shown: {body}"
8126 );
8127 assert!(
8128 body.contains("at://alice.example.com/site.standard.publication/…"),
8129 "the handle form must be shown: {body}"
8130 );
8131 assert!(
8132 body.contains("resolved to its DID"),
8133 "the handle resolution must be stated: {body}"
8134 );
8135 assert!(
8136 !body.contains("isn't accepting new publication subscriptions"),
8137 "{body}"
8138 );
8139 }
8140
8141 #[tokio::test]
8145 async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
8146 let state = standard_site_state(false, "did:plc:x").await;
8147 assert!(!state.config.standard_site);
8148 let body = public_body(state, "/standard-site").await;
8149 assert!(body.contains("site.standard.publication"), "{body}");
8150 assert!(
8151 !body.contains("at://did:plc:…/site.standard.publication/…"),
8152 "no paste instructions with the flag off: {body}"
8153 );
8154 assert!(
8155 !body.contains("at://alice.example.com/site.standard.publication/…"),
8156 "no paste instructions with the flag off: {body}"
8157 );
8158 assert!(
8159 body.contains("isn't accepting new publication subscriptions"),
8160 "the page must say the form is closed here: {body}"
8161 );
8162 assert!(
8163 body.contains("already follows are still read"),
8164 "stored publications are polled whatever the flag says: {body}"
8165 );
8166 }
8167
8168 #[tokio::test]
8171 async fn releases_callout_links_the_release_pages() {
8172 for path in ["/standard-site", "/"] {
8173 let body = public_body(test_state(&[]).await, path).await;
8174 for tag in ["v0.4.1", "v0.4.0"] {
8175 let href = format!(
8176 "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
8177 );
8178 assert!(body.contains(&href), "{path} must link {tag}: {body}");
8179 }
8180 assert!(
8181 body.contains(
8182 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
8183 ),
8184 "{path} must link the changelog: {body}"
8185 );
8186 }
8187 }
8188
8189 #[tokio::test]
8193 async fn landing_about_and_footer_link_the_standard_site_page() {
8194 for path in ["/", "/about", "/privacy"] {
8195 let body = public_body(test_state(&[]).await, path).await;
8196 assert!(
8197 body.contains("href=\"/standard-site\""),
8198 "{path} must link the feature page: {body}"
8199 );
8200 }
8201 }
8202
8203 #[test]
8207 fn releases_are_newest_first_and_link_the_tag_and_changelog() {
8208 assert!(!RELEASES.is_empty());
8209 let parse = |v: &str| -> Vec<u32> {
8210 v.split('.')
8211 .map(|p| p.parse::<u32>().expect("a numeric version part"))
8212 .collect()
8213 };
8214 for pair in RELEASES.windows(2) {
8215 assert!(
8216 parse(pair[0].version) > parse(pair[1].version),
8217 "{} must come before {}",
8218 pair[0].version,
8219 pair[1].version
8220 );
8221 }
8222 for r in RELEASES {
8223 assert_eq!(parse(r.version).len(), 3, "{}", r.version);
8224 assert!(
8225 chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
8226 "{} is not YYYY-MM-DD",
8227 r.date
8228 );
8229 assert!(!r.summary.trim().is_empty());
8230 assert!(!r.summary.contains('<'), "the summary is plain text");
8231 assert_eq!(
8232 r.url(),
8233 format!(
8234 "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
8235 r.version
8236 )
8237 );
8238 }
8239 let latest = &RELEASES[0];
8242 assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
8243 assert_eq!(
8244 latest.changelog_url(),
8245 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#046--2026-10-06"
8246 );
8247 }
8248
8249 #[tokio::test]
8251 async fn standard_site_page_is_publicly_cacheable() {
8252 let resp = router(test_state(&[]).await)
8253 .oneshot(
8254 Request::builder()
8255 .uri("/standard-site")
8256 .body(Body::empty())
8257 .unwrap(),
8258 )
8259 .await
8260 .unwrap();
8261 assert_eq!(resp.status(), StatusCode::OK);
8262 assert_eq!(
8263 resp.headers().get(header::CACHE_CONTROL).unwrap(),
8264 "public, max-age=300"
8265 );
8266 }
8267
8268 #[tokio::test]
8269 async fn cache_control_public_on_about_no_store_on_authed() {
8270 let state = test_state(&["did:plc:admin"]).await;
8271 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
8272 let app = router(state);
8273
8274 let about = app
8276 .clone()
8277 .oneshot(
8278 Request::builder()
8279 .uri("/about")
8280 .body(Body::empty())
8281 .unwrap(),
8282 )
8283 .await
8284 .unwrap();
8285 assert_eq!(
8286 about.headers().get(header::CACHE_CONTROL).unwrap(),
8287 "public, max-age=300"
8288 );
8289 assert_eq!(
8295 about.headers()["content-security-policy"],
8296 EXPECTED_CSP,
8297 "the CSP is not the policy the router promises"
8298 );
8299 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
8300
8301 for path in ["/privacy", "/terms"] {
8303 let resp = app
8304 .clone()
8305 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8306 .await
8307 .unwrap();
8308 assert_eq!(resp.status(), StatusCode::OK);
8309 assert_eq!(
8310 resp.headers().get(header::CACHE_CONTROL).unwrap(),
8311 "public, max-age=300",
8312 "{path} should be publicly cacheable"
8313 );
8314 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
8316 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
8317 }
8318
8319 let login = app
8321 .clone()
8322 .oneshot(
8323 Request::builder()
8324 .uri("/login")
8325 .body(Body::empty())
8326 .unwrap(),
8327 )
8328 .await
8329 .unwrap();
8330 assert_eq!(
8331 login.headers().get(header::CACHE_CONTROL).unwrap(),
8332 "public, max-age=300"
8333 );
8334
8335 let home = app
8337 .oneshot(
8338 Request::builder()
8339 .uri("/")
8340 .header(header::COOKIE, admin_cookie)
8341 .body(Body::empty())
8342 .unwrap(),
8343 )
8344 .await
8345 .unwrap();
8346 assert_eq!(
8347 home.headers().get(header::CACHE_CONTROL).unwrap(),
8348 "no-store"
8349 );
8350 }
8351
8352 fn head(body: &str) -> &str {
8361 let end = body.find("</head>").expect("a <head>");
8362 &body[..end]
8363 }
8364
8365 fn meta(head: &str, attr: &str) -> Option<String> {
8368 let tag_start = head.find(attr)?;
8369 let rest = &head[tag_start..];
8370 let tag_end = rest.find('>')?;
8371 let tag = &rest[..tag_end];
8372 let content = tag.find("content=\"")? + "content=\"".len();
8373 let close = tag[content..].find('"')?;
8374 Some(tag[content..content + close].to_string())
8375 }
8376
8377 async fn production_origin_state() -> AppState {
8381 let db = store::init_url("sqlite::memory:").await.unwrap();
8382 store::ensure_seed(&db, &["did:plc:admin".to_string()])
8383 .await
8384 .unwrap();
8385 let config = Config {
8386 allowed_dids: vec!["did:plc:admin".to_string()],
8387 cookie_secret: "test-cookie-secret-000".to_string(),
8388 beta_cap: 3,
8389 public_url: "https://feather-reader.com".to_string(),
8390 ..Config::default()
8391 };
8392 AppState::new(config, db).unwrap()
8393 }
8394
8395 #[tokio::test]
8398 async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
8399 let landing = public_body(production_origin_state().await, "/").await;
8400 let about = public_body(production_origin_state().await, "/about").await;
8401 let (lh, ah) = (head(&landing), head(&about));
8402
8403 assert_eq!(
8404 meta(lh, "property=\"og:title\"").as_deref(),
8405 Some("FeatherReader — read, quietly"),
8406 "{lh}"
8407 );
8408 assert_eq!(
8409 meta(ah, "property=\"og:title\"").as_deref(),
8410 Some("About — FeatherReader"),
8411 "{ah}"
8412 );
8413 for (h, path) in [(lh, "/"), (ah, "/about")] {
8414 let url = format!("https://feather-reader.com{path}");
8415 assert_eq!(
8416 meta(h, "property=\"og:url\"").as_deref(),
8417 Some(url.as_str())
8418 );
8419 assert!(
8420 h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
8421 "{path} must carry a canonical link: {h}"
8422 );
8423 let image = meta(h, "property=\"og:image\"").unwrap_or_default();
8424 assert!(
8425 image.starts_with("https://feather-reader.com/static/"),
8426 "{path}: og:image must be absolute on the public origin, got {image:?}"
8427 );
8428 assert_eq!(
8429 meta(h, "name=\"twitter:card\"").as_deref(),
8430 Some("summary_large_image")
8431 );
8432 assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
8433 assert_eq!(
8434 meta(h, "property=\"og:site_name\"").as_deref(),
8435 Some("FeatherReader")
8436 );
8437 let description = meta(h, "property=\"og:description\"").unwrap_or_default();
8438 assert!(!description.is_empty(), "{path}: og:description is empty");
8439 assert_eq!(
8440 meta(h, "name=\"description\"").as_deref(),
8441 Some(description.as_str()),
8442 "{path}: the meta description and og:description must agree"
8443 );
8444 }
8445 assert_ne!(
8446 meta(lh, "property=\"og:description\""),
8447 meta(ah, "property=\"og:description\""),
8448 "the landing page and /about must not share a description"
8449 );
8450 }
8451
8452 #[tokio::test]
8454 async fn card_urls_follow_the_configured_public_url() {
8455 let db = store::init_url("sqlite::memory:").await.unwrap();
8456 store::ensure_seed(&db, &[]).await.unwrap();
8457 let config = Config {
8458 cookie_secret: "test-cookie-secret-000".to_string(),
8459 public_url: "https://reader.example.org".to_string(),
8460 ..Config::default()
8461 };
8462 let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8463 let h = head(&body);
8464 assert_eq!(
8465 meta(h, "property=\"og:url\"").as_deref(),
8466 Some("https://reader.example.org/privacy")
8467 );
8468 assert_eq!(
8469 meta(h, "property=\"og:image\"").as_deref(),
8470 Some("https://reader.example.org/static/social-card.png")
8471 );
8472 }
8473
8474 #[tokio::test]
8477 async fn public_pages_each_carry_their_own_description() {
8478 let paths = [
8479 "/",
8480 "/about",
8481 "/privacy",
8482 "/terms",
8483 "/stats",
8484 "/standard-site",
8485 "/login",
8486 "/beta/redeem",
8487 ];
8488 let mut seen = std::collections::HashSet::new();
8489 for path in paths {
8490 let body = public_body(production_origin_state().await, path).await;
8491 let h = head(&body);
8492 let description = meta(h, "name=\"description\"").unwrap_or_default();
8493 assert!(!description.is_empty(), "{path} has no description: {h}");
8494 assert!(
8495 seen.insert(description.clone()),
8496 "{path} repeats another page's description: {description:?}"
8497 );
8498 assert_eq!(
8499 meta(h, "property=\"og:url\"").as_deref(),
8500 Some(format!("https://feather-reader.com{path}").as_str()),
8501 "{path}"
8502 );
8503 assert!(
8504 !h.contains("name=\"robots\""),
8505 "{path} is public and must not be noindex: {h}"
8506 );
8507 }
8508 }
8509
8510 #[tokio::test]
8513 async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8514 let landing = public_body(production_origin_state().await, "/").await;
8515 let h = head(&landing);
8516 let image = meta(h, "property=\"og:image\"").unwrap();
8517 let path = image.strip_prefix("https://feather-reader.com").unwrap();
8518 let width: u32 = meta(h, "property=\"og:image:width\"")
8519 .unwrap()
8520 .parse()
8521 .unwrap();
8522 let height: u32 = meta(h, "property=\"og:image:height\"")
8523 .unwrap()
8524 .parse()
8525 .unwrap();
8526 assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8527 assert_eq!(
8528 meta(h, "property=\"og:image:type\"").as_deref(),
8529 Some("image/png")
8530 );
8531 assert!(
8532 !meta(h, "property=\"og:image:alt\"")
8533 .unwrap_or_default()
8534 .is_empty(),
8535 "the image needs alt text"
8536 );
8537
8538 let resp = router(production_origin_state().await)
8539 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8540 .await
8541 .unwrap();
8542 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8543 assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8544 assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8545 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8546 .await
8547 .expect("the image is under 1 MB");
8548 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8549 let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8551 assert_eq!(
8552 (be(16), be(20)),
8553 (width, height),
8554 "the PNG's own dimensions must match the tags"
8555 );
8556 }
8557
8558 #[tokio::test]
8561 async fn private_pages_keep_user_data_out_of_the_card() {
8562 for path in ["/", "/manage"] {
8563 let state = production_origin_state().await;
8564 let body = signed_in_body(state, path, "did:plc:admin").await;
8565 let h = head(&body);
8566 assert!(
8567 h.contains("<meta name=\"robots\" content=\"noindex\""),
8568 "{path}: a private view must be noindex: {h}"
8569 );
8570 assert_eq!(
8571 meta(h, "property=\"og:title\"").as_deref(),
8572 Some("FeatherReader — read, quietly"),
8573 "{path}: the card of a private view is the site's generic one"
8574 );
8575 assert_eq!(
8576 meta(h, "property=\"og:url\"").as_deref(),
8577 Some("https://feather-reader.com/"),
8578 "{path}: og:url of a private view is the front door, not the private path"
8579 );
8580 for private in ["reader.example", "did:plc:admin"] {
8581 assert!(
8582 !h.contains(private),
8583 "{path}: {private:?} must not reach <head>: {h}"
8584 );
8585 }
8586 }
8587 }
8588
8589 #[tokio::test]
8590 async fn beta_redeem_page_renders() {
8591 let state = test_state(&[]).await;
8592 let app = router(state);
8593 let resp = app
8594 .oneshot(
8595 Request::builder()
8596 .uri("/beta/redeem")
8597 .body(Body::empty())
8598 .unwrap(),
8599 )
8600 .await
8601 .unwrap();
8602 assert_eq!(resp.status(), StatusCode::OK);
8603 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8604 .await
8605 .unwrap();
8606 let html = String::from_utf8(bytes.to_vec()).unwrap();
8607 assert!(html.contains("Invite code"));
8608 assert!(html.contains("/beta/redeem"));
8609 }
8610
8611 #[tokio::test]
8612 async fn rate_limit_returns_429_after_burst() {
8613 let db = store::init_url("sqlite::memory:").await.unwrap();
8616 store::ensure_seed(&db, &[]).await.unwrap();
8617 let config = Config {
8618 cookie_secret: "test-cookie-secret-000".to_string(),
8619 beta_cap: 3,
8620 trusted_ip_header: Some("cf-connecting-ip".to_string()),
8621 ..Config::default()
8622 };
8623 let state = AppState::new(config, db).unwrap();
8624 let app = router(state);
8625 let mut saw_429 = false;
8629 for _ in 0..(RATE_BURST as usize + 5) {
8630 let resp = app
8631 .clone()
8632 .oneshot(
8633 Request::builder()
8634 .method("POST")
8635 .uri("/beta/redeem")
8636 .header("content-type", "application/x-www-form-urlencoded")
8637 .header("cf-connecting-ip", "203.0.113.200")
8638 .body(Body::from("code=FEATHER-NOPENOPE"))
8639 .unwrap(),
8640 )
8641 .await
8642 .unwrap();
8643 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8644 saw_429 = true;
8645 break;
8646 }
8647 }
8648 assert!(saw_429, "expected a 429 after exhausting the burst");
8649 }
8650
8651 #[tokio::test]
8664 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8665 let state = test_state(&[]).await;
8666 assert!(
8667 state.config.trusted_ip_header.is_none(),
8668 "no proxy header is trusted here"
8669 );
8670 let app = router(state);
8671 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8672 let mut saw_429 = false;
8673 for i in 0..(RATE_BURST as usize + 5) {
8674 let forged = format!("10.9.8.{}", i % 250);
8675 let resp = app
8676 .clone()
8677 .oneshot(
8678 Request::builder()
8679 .method("POST")
8680 .uri("/beta/redeem")
8681 .header("content-type", "application/x-www-form-urlencoded")
8682 .header("x-forwarded-for", forged)
8683 .extension(axum::extract::ConnectInfo(peer))
8684 .body(Body::from("code=FEATHER-NOPENOPE"))
8685 .unwrap(),
8686 )
8687 .await
8688 .unwrap();
8689 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8690 saw_429 = true;
8691 break;
8692 }
8693 }
8694 assert!(
8695 saw_429,
8696 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8697 );
8698 }
8699
8700 #[tokio::test]
8709 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8710 let did = "did:plc:privateadder";
8711 let state = test_state_with_caps(did, 0, 0).await;
8712 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8713 let port: u16 = base
8714 .trim_end_matches('/')
8715 .rsplit(':')
8716 .next()
8717 .unwrap()
8718 .parse()
8719 .unwrap();
8720 crate::net::test_host_override(
8721 "private-add.test",
8722 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8723 );
8724 let cookie = session_cookie(&state, did, None);
8725 let resp = router(state.clone())
8726 .oneshot(
8727 Request::builder()
8728 .method("POST")
8729 .uri("/subscriptions")
8730 .header(header::COOKIE, cookie)
8731 .header("content-type", "application/x-www-form-urlencoded")
8732 .body(Body::from(format!(
8733 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8734 )))
8735 .unwrap(),
8736 )
8737 .await
8738 .unwrap();
8739 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8740 let loc = resp
8741 .headers()
8742 .get(header::LOCATION)
8743 .unwrap()
8744 .to_str()
8745 .unwrap();
8746 assert!(loc.contains("Private"), "not refused as private: {loc}");
8747 assert_eq!(
8748 hits.load(std::sync::atomic::Ordering::SeqCst),
8749 0,
8750 "the private feed was FETCHED before being refused"
8751 );
8752 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8753 }
8754
8755 #[tokio::test]
8760 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8761 let did = "did:plc:renamer4";
8762 let (sidecar, bodies) = spawn_logging_sidecar().await;
8763 let state = test_state_with_sidecar(&[did], &sidecar).await;
8764 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8765 let opml = format!(
8766 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8767 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8768 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8769 </body></opml>"
8770 );
8771 let (ct, body) = opml_multipart(opml.as_bytes());
8772 let cookie = session_cookie(&state, did, None);
8773 let resp = router(state.clone())
8774 .oneshot(
8775 Request::builder()
8776 .method("POST")
8777 .uri("/opml")
8778 .header(header::COOKIE, cookie)
8779 .header("content-type", ct)
8780 .body(Body::from(body))
8781 .unwrap(),
8782 )
8783 .await
8784 .unwrap();
8785 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8786 let loc = resp
8787 .headers()
8788 .get(header::LOCATION)
8789 .unwrap()
8790 .to_str()
8791 .unwrap();
8792 assert!(
8793 loc.contains("skipped%20as%20private"),
8794 "not reported as skipped: {loc}"
8795 );
8796 assert!(store::get_feed_by_url(&state.db, tokened)
8797 .await
8798 .unwrap()
8799 .is_none());
8800 let sent = bodies.lock().unwrap().join("\n");
8801 assert!(
8802 sent.contains("public.example"),
8803 "the public feed was not written: {sent}"
8804 );
8805 assert!(
8806 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8807 "the secret was PUBLISHED to the PDS: {sent}"
8808 );
8809 }
8810
8811 #[tokio::test]
8815 async fn get_login_without_a_seat_is_refused() {
8816 let state = test_state(&[]).await;
8817 let resp = router(state)
8818 .oneshot(
8819 Request::builder()
8820 .method("GET")
8821 .uri("/login?handle=alice.bsky.social")
8822 .body(Body::empty())
8823 .unwrap(),
8824 )
8825 .await
8826 .unwrap();
8827 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8828 assert_eq!(
8829 resp.headers().get(header::LOCATION).unwrap(),
8830 "/beta/redeem"
8831 );
8832 }
8833
8834 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8838 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8839 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8840 let addr = listener.local_addr().unwrap();
8841 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8842 let sink = log.clone();
8843 tokio::spawn(async move {
8844 loop {
8845 let Ok((mut sock, _)) = listener.accept().await else {
8846 break;
8847 };
8848 let mut raw: Vec<u8> = Vec::new();
8849 let mut chunk = [0u8; 4096];
8850 let text = loop {
8851 let Ok(n) = sock.read(&mut chunk).await else {
8852 break String::new();
8853 };
8854 if n == 0 {
8855 break String::from_utf8_lossy(&raw).to_string();
8856 }
8857 raw.extend_from_slice(&chunk[..n]);
8858 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8859 continue;
8860 };
8861 let (head, body) = raw.split_at(split + 4);
8862 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8863 let (k, v) = l.split_once(':')?;
8864 k.eq_ignore_ascii_case("content-length")
8865 .then(|| v.trim().parse::<usize>().ok())?
8866 });
8867 if want.is_none_or(|w| body.len() >= w) {
8868 break String::from_utf8_lossy(&raw).to_string();
8869 }
8870 };
8871 let path = text
8872 .lines()
8873 .next()
8874 .and_then(|l| l.split_whitespace().nth(1))
8875 .unwrap_or("")
8876 .to_string();
8877 let body_text = text
8878 .split_once("\r\n\r\n")
8879 .map(|(_, b)| b)
8880 .unwrap_or("")
8881 .to_string();
8882 sink.lock().unwrap().push(format!("{path} {body_text}"));
8883 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();
8884 let resp = format!(
8885 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8886 body.len(),
8887 body
8888 );
8889 let _ = sock.write_all(resp.as_bytes()).await;
8890 let _ = sock.flush().await;
8891 }
8892 });
8893 (format!("http://{addr}"), log)
8894 }
8895
8896 #[tokio::test]
8902 async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8903 use crate::readstate::tests as rs;
8904 for backend in [
8905 crate::metrics::Backend::Sidecar,
8906 crate::metrics::Backend::Rust,
8907 ] {
8908 let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8909 let state = rs::state_on(backend, &fake).await;
8910 for i in 0..250 {
8911 rs::mark_read(&state, i, "1").await;
8912 }
8913 fake.lock().unwrap().drop_call = Some(2);
8914
8915 flush_before_revoke(&state, rs::DID).await;
8916
8917 let order = rs::send_order(250);
8918 let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8919 for &i in landed {
8920 let c = rs::cursor(&state, i).await;
8921 assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8922 }
8923 for &i in rest {
8924 let c = rs::cursor(&state, i).await;
8925 assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8926 }
8927 assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8928 }
8929 }
8930
8931 #[tokio::test]
8940 async fn signing_out_flushes_before_it_revokes_through_the_route() {
8941 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8942 let (sidecar, log) = spawn_logging_sidecar().await;
8943 let state = test_state_with_sidecar(&[did], &sidecar).await;
8944 crate::store::upsert_cursor(
8945 &state.db,
8946 &crate::store::ReadCursor {
8947 did: did.to_string(),
8948 feed_url: "https://example.com/feed.xml".into(),
8949 read_through: None,
8950 read_ids: "[\"1\"]".into(),
8951 unread_ids: "[]".into(),
8952 dirty: true,
8953 pds_created: false,
8954 updated_at: "2026-09-13T21:22:40Z".into(),
8955 },
8956 )
8957 .await
8958 .unwrap();
8959 let cookie = session_cookie(&state, did, None);
8960 let resp = router(state.clone())
8961 .oneshot(
8962 Request::builder()
8963 .method("POST")
8964 .uri("/logout")
8965 .header(header::COOKIE, cookie)
8966 .body(Body::empty())
8967 .unwrap(),
8968 )
8969 .await
8970 .unwrap();
8971 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8972
8973 let entries = log.lock().unwrap().clone();
8974 let flush = entries
8975 .iter()
8976 .position(|e| e.starts_with("/internal/repo "));
8977 let revoke = entries
8978 .iter()
8979 .position(|e| e.starts_with("/internal/revoke "));
8980 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8981 assert!(
8982 flush.is_some(),
8983 "sign-out did not attempt a flush before revoking: {entries:?}"
8984 );
8985 assert!(
8986 flush < revoke,
8987 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8988 );
8989 }
8990
8991 const EXPECTED_CSP: &str = "default-src 'self'; \
8995 script-src 'self'; \
8996 style-src 'self' 'unsafe-inline'; \
8997 img-src 'self' https: data:; \
8998 font-src 'self'; \
8999 connect-src 'self'; \
9000 form-action 'self'; \
9001 base-uri 'self'; \
9002 frame-ancestors 'none'; \
9003 object-src 'none'";
9004
9005 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
9008 let boundary = "----featherreadertestboundary";
9009 let mut body = Vec::new();
9010 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
9011 body.extend_from_slice(
9012 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
9013 );
9014 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
9015 body.extend_from_slice(payload);
9016 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
9017 (format!("multipart/form-data; boundary={boundary}"), body)
9018 }
9019
9020 #[tokio::test]
9021 async fn opml_import_oversize_upload_returns_413() {
9022 let state = test_state(&["did:plc:admin"]).await;
9023 let cookie = session_cookie(&state, "did:plc:admin", None);
9024 let app = router(state);
9025
9026 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
9028 let (content_type, body) = opml_multipart(&payload);
9029
9030 let resp = app
9031 .oneshot(
9032 Request::builder()
9033 .method("POST")
9034 .uri("/opml")
9035 .header("content-type", content_type)
9036 .header(header::COOKIE, cookie)
9037 .body(Body::from(body))
9038 .unwrap(),
9039 )
9040 .await
9041 .unwrap();
9042 assert_eq!(
9043 resp.status(),
9044 StatusCode::PAYLOAD_TOO_LARGE,
9045 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
9046 );
9047 }
9048
9049 #[tokio::test]
9060 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
9061 let state = test_state(&["did:plc:admin"]).await;
9062 let cookie = session_cookie(&state, "did:plc:admin", None);
9063 let app = router(state);
9064
9065 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
9067 let (content_type, body) = opml_multipart(&payload);
9068
9069 let resp = app
9070 .oneshot(
9071 Request::builder()
9072 .method("POST")
9073 .uri("/opml")
9074 .header("content-type", content_type)
9075 .header(header::COOKIE, cookie)
9076 .body(Body::from(body))
9077 .unwrap(),
9078 )
9079 .await
9080 .unwrap();
9081 assert_eq!(
9082 resp.status(),
9083 StatusCode::PAYLOAD_TOO_LARGE,
9084 "a payload over the route's cap but under the framework's was accepted — \
9085 the route's own DefaultBodyLimit layer is not doing anything"
9086 );
9087 }
9088
9089 #[tokio::test]
9090 async fn opml_import_under_limit_upload_is_accepted() {
9091 let state = test_state(&["did:plc:admin"]).await;
9092 let cookie = session_cookie(&state, "did:plc:admin", None);
9093 let db = state.db.clone();
9094 let app = router(state);
9095
9096 let opml = br#"<?xml version="1.0"?>
9099<opml version="2.0"><body>
9100 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
9101</body></opml>"#;
9102 let (content_type, body) = opml_multipart(opml);
9103
9104 let resp = app
9105 .oneshot(
9106 Request::builder()
9107 .method("POST")
9108 .uri("/opml")
9109 .header("content-type", content_type)
9110 .header(header::COOKIE, cookie)
9111 .body(Body::from(body))
9112 .unwrap(),
9113 )
9114 .await
9115 .unwrap();
9116 assert_eq!(
9123 resp.status(),
9124 StatusCode::SEE_OTHER,
9125 "an under-cap OPML upload was not accepted (status {})",
9126 resp.status(),
9127 );
9128 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
9132 .bind("https://example.com/feed.xml")
9133 .fetch_one(&db)
9134 .await
9135 .unwrap();
9136 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
9137 let location = resp
9138 .headers()
9139 .get(header::LOCATION)
9140 .and_then(|v| v.to_str().ok())
9141 .unwrap_or_default()
9142 .to_string();
9143 assert!(
9144 !location.starts_with("/login"),
9145 "the import bounced to login instead of being accepted: {location}",
9146 );
9147 }
9148
9149 #[tokio::test]
9150 async fn opml_import_logged_out_redirects_to_login() {
9151 let state = test_state(&["did:plc:admin"]).await;
9154 let app = router(state);
9155
9156 let opml = b"<opml version=\"2.0\"><body></body></opml>";
9157 let (content_type, body) = opml_multipart(opml);
9158
9159 let resp = app
9160 .oneshot(
9161 Request::builder()
9162 .method("POST")
9163 .uri("/opml")
9164 .header("content-type", content_type)
9165 .body(Body::from(body))
9166 .unwrap(),
9167 )
9168 .await
9169 .unwrap();
9170 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9171 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
9172 }
9173
9174 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
9181 use tokio::io::{AsyncReadExt, AsyncWriteExt};
9182 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9183 let addr = listener.local_addr().unwrap();
9184 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
9185 tokio::spawn(async move {
9186 let (mut sock, _) = listener.accept().await.unwrap();
9187 let mut buf = vec![0u8; 4096];
9188 let n = sock.read(&mut buf).await.unwrap();
9189 let req = String::from_utf8_lossy(&buf[..n]).to_string();
9190 let did = req
9192 .split("\r\n\r\n")
9193 .nth(1)
9194 .and_then(|body| {
9195 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
9196 v.get("did")?.as_str().map(str::to_string)
9197 })
9198 .unwrap_or_default();
9199 let is_revoke = req.starts_with("POST /internal/revoke");
9200 let body = serde_json::json!({
9201 "ok": true, "did": did, "revoked": true, "hadSession": true
9202 })
9203 .to_string();
9204 let resp = format!(
9205 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9206 body.len(),
9207 body
9208 );
9209 sock.write_all(resp.as_bytes()).await.unwrap();
9210 sock.flush().await.unwrap();
9211 let _ = tx.send(if is_revoke { did } else { String::new() });
9212 });
9213 (format!("http://{addr}"), rx)
9214 }
9215
9216 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
9218 let defaults = Config::default();
9219 test_state_with_sidecar_and(
9220 allowed,
9221 sidecar_url,
9222 defaults.standard_site,
9223 defaults.max_feeds_global,
9224 )
9225 .await
9226 }
9227
9228 async fn test_state_with_sidecar_and(
9231 allowed: &[&str],
9232 sidecar_url: &str,
9233 standard_site: bool,
9234 max_feeds_global: i64,
9235 ) -> AppState {
9236 let db = store::init_url("sqlite::memory:").await.unwrap();
9237 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
9238 store::ensure_seed(&db, &dids).await.unwrap();
9239 let mut config = Config {
9240 allowed_dids: dids,
9241 cookie_secret: "test-cookie-secret-000".to_string(),
9242 beta_cap: 3,
9243 standard_site,
9244 max_feeds_global,
9245 ..Config::default()
9246 };
9247 config.sidecar.public_url = sidecar_url.to_string();
9248 config.sidecar.internal_url = sidecar_url.to_string();
9249 AppState::new(config, db).unwrap()
9250 }
9251
9252 #[tokio::test]
9255 async fn account_delete_purges_rows_and_triggers_revoke() {
9256 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
9257 let did = "did:plc:leaver";
9258 let state = test_state_with_sidecar(&[], &sidecar_url).await;
9259
9260 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
9262 .await
9263 .unwrap();
9264 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
9265 store::mint_code(&state.db, did, 3600).await.unwrap();
9266 assert!(store::has_beta_access(&state.db, did).await.unwrap());
9267
9268 let cookie = session_cookie(&state, did, Some("leaver.example"));
9269 let app = router(state.clone());
9270
9271 let resp = app
9272 .oneshot(
9273 Request::builder()
9274 .method("POST")
9275 .uri("/account/delete")
9276 .header(header::COOKIE, cookie)
9277 .header("content-type", "application/x-www-form-urlencoded")
9278 .body(Body::from("confirm=DELETE"))
9279 .unwrap(),
9280 )
9281 .await
9282 .unwrap();
9283
9284 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9286 assert!(resp
9287 .headers()
9288 .get(header::LOCATION)
9289 .unwrap()
9290 .to_str()
9291 .unwrap()
9292 .starts_with("/login"));
9293 let set_cookie = resp
9294 .headers()
9295 .get(header::SET_COOKIE)
9296 .unwrap()
9297 .to_str()
9298 .unwrap();
9299 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
9300
9301 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
9308 .await
9309 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
9310 .unwrap();
9311 assert_eq!(
9312 revoked_did, did,
9313 "sidecar revoke must fire for the caller DID"
9314 );
9315
9316 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
9318 let codes: i64 =
9319 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
9320 .bind(did)
9321 .fetch_one(&state.db)
9322 .await
9323 .unwrap();
9324 assert_eq!(codes, 0);
9325 }
9326
9327 #[tokio::test]
9330 async fn account_delete_without_confirm_is_a_noop() {
9331 let did = "did:plc:staying";
9332 let state = test_state(&[]).await;
9333 store::grant_access(&state.db, did, None, "test", None)
9334 .await
9335 .unwrap();
9336 let cookie = session_cookie(&state, did, None);
9337 let app = router(state.clone());
9338
9339 let resp = app
9340 .oneshot(
9341 Request::builder()
9342 .method("POST")
9343 .uri("/account/delete")
9344 .header(header::COOKIE, cookie)
9345 .header("content-type", "application/x-www-form-urlencoded")
9346 .body(Body::from("confirm=nope"))
9347 .unwrap(),
9348 )
9349 .await
9350 .unwrap();
9351
9352 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9353 assert!(resp
9354 .headers()
9355 .get(header::LOCATION)
9356 .unwrap()
9357 .to_str()
9358 .unwrap()
9359 .starts_with("/manage"));
9360 assert!(store::has_beta_access(&state.db, did).await.unwrap());
9362 }
9363
9364 #[tokio::test]
9371 async fn pds_outage_does_not_widen_cross_did_access() {
9372 let did_a = "did:plc:aaaa";
9373 let state = test_state(&[]).await;
9374 store::grant_access(&state.db, did_a, None, "test", None)
9375 .await
9376 .unwrap();
9377
9378 let feed_a = store::upsert_feed(
9381 &state.db,
9382 &store::NewFeed {
9383 url: "https://a.example/feed.xml".to_string(),
9384 title: Some("A".to_string()),
9385 ..Default::default()
9386 },
9387 )
9388 .await
9389 .unwrap();
9390 let feed_b = store::upsert_feed(
9391 &state.db,
9392 &store::NewFeed {
9393 url: "https://b.example/feed.xml".to_string(),
9394 title: Some("B".to_string()),
9395 ..Default::default()
9396 },
9397 )
9398 .await
9399 .unwrap();
9400 store::insert_entries(
9401 &state.db,
9402 feed_b,
9403 &[store::NewEntry {
9404 guid: "b-1".to_string(),
9405 url: Some("https://b.example/1".to_string()),
9406 title: Some("B one".to_string()),
9407 published: Some("2026-07-11T00:00:00Z".to_string()),
9408 content_html: Some("<p>secret B body</p>".to_string()),
9409 ..Default::default()
9410 }],
9411 0,
9412 )
9413 .await
9414 .unwrap();
9415 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9417 .await
9418 .unwrap();
9419 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
9422 .await
9423 .unwrap();
9424 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
9425 .await
9426 .unwrap()[0]
9427 .id;
9428 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
9429 .await
9430 .unwrap();
9431
9432 let cookie = session_cookie(&state, did_a, None);
9433 let app = router(state.clone());
9434
9435 let get_b = app
9437 .clone()
9438 .oneshot(
9439 Request::builder()
9440 .method("GET")
9441 .uri(format!("/entries/{b_entry_id}"))
9442 .header(header::COOKIE, cookie.clone())
9443 .body(Body::empty())
9444 .unwrap(),
9445 )
9446 .await
9447 .unwrap();
9448 assert_eq!(
9449 get_b.status(),
9450 StatusCode::NOT_FOUND,
9451 "A must not read B's entry during a PDS outage"
9452 );
9453
9454 let read_b = app
9456 .oneshot(
9457 Request::builder()
9458 .method("POST")
9459 .uri(format!("/entries/{b_entry_id}/read"))
9460 .header(header::COOKIE, cookie)
9461 .header("content-type", "application/x-www-form-urlencoded")
9462 .body(Body::from("read=true"))
9463 .unwrap(),
9464 )
9465 .await
9466 .unwrap();
9467 assert_eq!(
9468 read_b.status(),
9469 StatusCode::NOT_FOUND,
9470 "A must not mark B's entry read during a PDS outage"
9471 );
9472
9473 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9475 .bind(did_a)
9476 .fetch_all(&state.db)
9477 .await
9478 .unwrap();
9479 assert_eq!(
9480 a_feed_ids,
9481 vec![feed_a],
9482 "outage fallback must not add feeds A never subscribed to"
9483 );
9484 let es_count: i64 =
9486 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9487 .bind(did_a)
9488 .bind(b_entry_id)
9489 .fetch_one(&state.db)
9490 .await
9491 .unwrap();
9492 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9493 }
9494
9495 #[tokio::test]
9509 async fn a_logout_with_no_session_counts_as_success() {
9510 let did = "did:plc:aaaa";
9511 let state = test_state(&[]).await;
9512 assert!(
9513 state.oauth.is_some(),
9514 "meaningless without an oauth runtime; the revoke arm would be skipped",
9515 );
9516
9517 revoke_everywhere(&state, did).await;
9518 let rows = state.metrics.snapshot();
9519 let find = |b: crate::metrics::Backend| {
9520 rows.iter()
9521 .find(|r| r.op == "oauth_revoke" && r.backend == b)
9522 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9523 };
9524
9525 let rust = find(crate::metrics::Backend::Rust);
9527 assert_eq!(
9528 rust.stats.err_count, 0,
9529 "NoSession was counted as a failure; logout is idempotent",
9530 );
9531 assert_eq!(rust.stats.ok_count, 1);
9532
9533 let sidecar = find(crate::metrics::Backend::Sidecar);
9537 assert_eq!(
9538 sidecar.stats.err_count, 1,
9539 "a failed sidecar revoke was not counted",
9540 );
9541 }
9542
9543 #[tokio::test]
9553 async fn a_failed_rust_revoke_counts_as_an_error() {
9554 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9555 let state = test_state(&[]).await;
9556 let runtime = state.oauth.as_deref().expect("oauth runtime");
9557 crate::oauth::store::put_session(
9558 &state.db,
9559 &runtime.codec,
9560 &crate::oauth::store::OAuthSession {
9561 sub: did.into(),
9562 issuer: "https://auth.invalid".into(),
9563 aud: "https://pds.invalid".into(),
9564 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9565 .to_jwk_json()
9566 .unwrap(),
9567 access_token: "at".into(),
9568 refresh_token: "rt".into(),
9569 token_type: "DPoP".into(),
9570 granted_scope: "atproto".into(),
9571 expires_at: Some(crate::store::now_unix() + 3600),
9572 },
9573 )
9574 .await
9575 .unwrap();
9576
9577 revoke_everywhere(&state, did).await;
9578
9579 let rows = state.metrics.snapshot();
9580 let rust = rows
9581 .iter()
9582 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9583 .expect("no rust oauth_revoke row");
9584 assert_eq!(
9585 rust.stats.err_count, 1,
9586 "an unreachable PDS must count as a revocation failure",
9587 );
9588 assert_eq!(rust.stats.ok_count, 0);
9589 }
9590
9591 #[test]
9607 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9608 for hostile in [
9609 "javascript:alert(1)",
9610 "JavaScript:alert(1)",
9611 " javascript:alert(1)",
9612 "data:text/html;base64,PHNjcmlwdD4=",
9613 "vbscript:msgbox(1)",
9614 "file:///etc/passwd",
9615 "//evil.example/path",
9619 ] {
9620 let link = SafeLink::external(hostile);
9621 assert!(
9622 link.is_empty(),
9623 "{hostile:?} produced a non-empty href: {link}",
9624 );
9625 assert!(
9626 !link.to_string().to_ascii_lowercase().contains("script"),
9627 "{hostile:?} leaked into the rendered link",
9628 );
9629 }
9630
9631 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9634 let link = SafeLink::external(good);
9635 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9636 assert_eq!(link.to_string(), good);
9637 }
9638 }
9639
9640 #[tokio::test]
9655 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9656 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9657 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9658 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9659 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9660
9661 let resp = router(state)
9662 .oneshot(
9663 Request::builder()
9664 .uri("/?view=starred")
9665 .body(Body::empty())
9666 .unwrap(),
9667 )
9668 .await
9669 .unwrap();
9670 assert_eq!(resp.status(), StatusCode::OK);
9671 let body = String::from_utf8(
9672 axum::body::to_bytes(resp.into_body(), usize::MAX)
9673 .await
9674 .unwrap()
9675 .to_vec(),
9676 )
9677 .unwrap();
9678
9679 assert!(
9682 !body.to_ascii_lowercase().contains("javascript:"),
9683 "the hostile scheme reached the rendered page",
9684 );
9685 assert!(
9688 body.contains("unusable link"),
9689 "the row was dropped instead of rendering without an anchor",
9690 );
9691 }
9692
9693 #[tokio::test]
9710 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9711 let did = "did:plc:readerhref";
9712 let state = test_state(&[]).await;
9713 store::grant_access(&state.db, did, None, "test", None)
9714 .await
9715 .unwrap();
9716 let feed = store::upsert_feed(
9717 &state.db,
9718 &store::NewFeed {
9719 url: "https://href.example/feed.xml".to_string(),
9720 title: Some("Href".to_string()),
9721 ..Default::default()
9722 },
9723 )
9724 .await
9725 .unwrap();
9726 store::insert_entries(
9728 &state.db,
9729 feed,
9730 &[
9731 store::NewEntry {
9732 guid: "hostile-1".to_string(),
9733 url: Some("javascript:alert(1)".to_string()),
9734 title: Some("Hostile entry".to_string()),
9735 published: Some("2026-07-11T00:00:00Z".to_string()),
9736 ..Default::default()
9737 },
9738 store::NewEntry {
9739 guid: "benign-1".to_string(),
9740 url: Some("https://href.example/post".to_string()),
9741 title: Some("Benign entry".to_string()),
9742 published: Some("2026-07-10T00:00:00Z".to_string()),
9743 ..Default::default()
9744 },
9745 ],
9746 0,
9747 )
9748 .await
9749 .unwrap();
9750 store::replace_sub_refs(&state.db, did, &[feed])
9751 .await
9752 .unwrap();
9753 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9754 let id_of = |guid: &str| {
9755 rows.iter()
9756 .find(|r| r.guid == guid)
9757 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9758 .id
9759 };
9760
9761 let cookie = session_cookie(&state, did, None);
9762 let app = router(state.clone());
9763
9764 let render = |id: i64| {
9765 let app = app.clone();
9766 let cookie = cookie.clone();
9767 async move {
9768 let resp = app
9769 .oneshot(
9770 Request::builder()
9771 .method("GET")
9772 .uri(format!("/entries/{id}"))
9773 .header(header::COOKIE, cookie)
9774 .body(Body::empty())
9775 .unwrap(),
9776 )
9777 .await
9778 .unwrap();
9779 assert_eq!(resp.status(), StatusCode::OK);
9780 String::from_utf8(
9781 axum::body::to_bytes(resp.into_body(), usize::MAX)
9782 .await
9783 .unwrap()
9784 .to_vec(),
9785 )
9786 .unwrap()
9787 }
9788 };
9789
9790 let hostile = render(id_of("hostile-1")).await;
9791 assert!(
9794 hostile.contains("Hostile entry"),
9795 "the reader did not render the entry: {hostile}",
9796 );
9797 assert!(
9798 !hostile.to_ascii_lowercase().contains("javascript:"),
9799 "the hostile scheme reached the reader page: {hostile}",
9800 );
9801 assert!(
9805 !hostile.contains("actionbar-open"),
9806 "the action bar rendered an open-original link for a refused URL: {hostile}",
9807 );
9808 assert!(
9809 !hostile.contains("Original \u{2197}"),
9810 "the byline rendered an original link for a refused URL: {hostile}",
9811 );
9812
9813 let benign = render(id_of("benign-1")).await;
9816 assert!(
9817 benign.contains("Benign entry"),
9818 "the reader did not render the benign entry: {benign}",
9819 );
9820 assert_eq!(
9824 benign
9825 .matches(r#"href="https://href.example/post""#)
9826 .count(),
9827 2,
9828 "entry.html has two `href`s for the entry URL — the byline link and \
9829 the action-bar button — and this render produced a different \
9830 number: {benign}",
9831 );
9832 assert!(
9833 benign.contains("actionbar-open"),
9834 "a legitimate entry lost its open-original button: {benign}",
9835 );
9836 assert!(
9837 benign.contains("Original \u{2197}"),
9838 "a legitimate entry lost its byline link: {benign}",
9839 );
9840 }
9841
9842 #[tokio::test]
9862 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9863 let did_a = "did:plc:aaaa";
9864 let state = test_state(&[]).await;
9865 store::grant_access(&state.db, did_a, None, "test", None)
9866 .await
9867 .unwrap();
9868
9869 let feed_a = store::upsert_feed(
9870 &state.db,
9871 &store::NewFeed {
9872 url: "https://a.example/feed.xml".to_string(),
9873 title: Some("A".to_string()),
9874 ..Default::default()
9875 },
9876 )
9877 .await
9878 .unwrap();
9879 let _feed_b = store::upsert_feed(
9880 &state.db,
9881 &store::NewFeed {
9882 url: "https://b.example/feed.xml".to_string(),
9883 title: Some("B".to_string()),
9884 ..Default::default()
9885 },
9886 )
9887 .await
9888 .unwrap();
9889 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9892 .await
9893 .unwrap();
9894
9895 assert!(
9900 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9901 "this test is only meaningful on the outage path; the repo answered",
9902 );
9903
9904 let resolved = resolve_subscriptions(&state, did_a).await;
9905
9906 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9907 assert_eq!(
9908 urls,
9909 vec!["https://a.example/feed.xml"],
9910 "the outage fallback must return the caller's OWN subscriptions only; \
9911 any other feed here is cross-tenant read access granted by an outage",
9912 );
9913 }
9914
9915 async fn test_state_with_caps(
9918 did: &str,
9919 max_subs_per_did: i64,
9920 max_feeds_global: i64,
9921 ) -> AppState {
9922 let db = store::init_url("sqlite::memory:").await.unwrap();
9923 let config = Config {
9924 cookie_secret: "test-cookie-secret-000".to_string(),
9925 beta_cap: 100,
9926 max_subs_per_did,
9927 max_feeds_global,
9928 ..Config::default()
9929 };
9930 store::grant_access(&db, did, None, "test", None)
9931 .await
9932 .unwrap();
9933 AppState::new(config, db).unwrap()
9934 }
9935
9936 fn opml_with_feeds(n: usize) -> String {
9938 let mut outlines = String::new();
9939 for i in 0..n {
9940 outlines.push_str(&format!(
9941 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9942 ));
9943 }
9944 format!(
9945 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9946 )
9947 }
9948
9949 #[tokio::test]
9954 async fn opml_import_enforces_global_feeds_ceiling() {
9955 let did = "did:plc:importer";
9956 let state = test_state_with_caps(did, 0, 3).await;
9958 let cookie = session_cookie(&state, did, None);
9959 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9960 let app = router(state.clone());
9961
9962 let resp = app
9963 .oneshot(
9964 Request::builder()
9965 .method("POST")
9966 .uri("/opml")
9967 .header(header::COOKIE, cookie)
9968 .header("content-type", ct)
9969 .body(Body::from(body))
9970 .unwrap(),
9971 )
9972 .await
9973 .unwrap();
9974 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9975
9976 let feeds = store::count_feeds(&state.db).await.unwrap();
9977 assert!(
9978 feeds <= 3,
9979 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9980 );
9981 }
9982
9983 async fn import_against_strict_pds(
9986 did: &str,
9987 n: usize,
9988 fail_call: Option<usize>,
9989 ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9990 let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9991 let state = test_state_with_sidecar(&[did], &sidecar).await;
9992 let cookie = session_cookie(&state, did, None);
9993 let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9994 let resp = router(state)
9995 .oneshot(
9996 Request::builder()
9997 .method("POST")
9998 .uri("/opml")
9999 .header(header::COOKIE, cookie)
10000 .header("content-type", ct)
10001 .body(Body::from(body))
10002 .unwrap(),
10003 )
10004 .await
10005 .unwrap();
10006 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10007 let loc = resp.headers()[header::LOCATION].to_str().unwrap();
10008 let flash = url::Url::parse(&format!("http://x{loc}"))
10009 .unwrap()
10010 .query_pairs()
10011 .find(|(k, _)| k == "flash")
10012 .map(|(_, v)| v.into_owned())
10013 .unwrap_or_default();
10014 (flash, log)
10015 }
10016
10017 #[tokio::test]
10021 async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
10022 let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
10023 assert_eq!(flash, "Imported 450 feeds", "{flash}");
10024 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
10025 }
10026
10027 #[tokio::test]
10031 async fn opml_import_that_part_lands_reports_what_landed() {
10032 let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
10033 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
10034 assert!(
10035 flash.contains("200 of 450"),
10036 "the landed count is not reported: {flash}"
10037 );
10038 assert!(
10039 !flash.contains("nothing was imported"),
10040 "200 feeds landed and the reader was told none did: {flash}"
10041 );
10042 }
10043
10044 #[tokio::test]
10047 async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
10048 let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
10049 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
10050 assert!(flash.contains("nothing was imported"), "{flash}");
10051 }
10052
10053 #[tokio::test]
10062 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
10063 let did = "did:plc:typoist";
10064 let state = test_state_with_caps(did, 0, 0).await;
10065 let cookie = session_cookie(&state, did, None);
10066 for input in [
10067 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
10068 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10069 ] {
10070 let resp = router(state.clone())
10071 .oneshot(
10072 Request::builder()
10073 .method("POST")
10074 .uri("/subscriptions")
10075 .header(header::COOKIE, cookie.clone())
10076 .header("content-type", "application/x-www-form-urlencoded")
10077 .body(Body::from(format!("url={input}")))
10078 .unwrap(),
10079 )
10080 .await
10081 .unwrap();
10082 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10083 let loc = resp
10084 .headers()
10085 .get(header::LOCATION)
10086 .unwrap()
10087 .to_str()
10088 .unwrap();
10089 assert!(
10090 loc.contains("kind%20of%20feed"),
10091 "expected the unsupported-feed flash for {input}, got {loc}"
10092 );
10093 assert!(
10094 !loc.contains("Private"),
10095 "a storability refusal was reported as a privacy one for {input}: {loc}"
10096 );
10097 }
10098 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10099 }
10100
10101 #[tokio::test]
10108 async fn opml_import_reports_entries_this_instance_cannot_store() {
10109 let did = "did:plc:renamer4";
10110 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10111 let state = test_state_with_sidecar(&[did], &sidecar).await;
10112 assert!(!state.config.standard_site);
10113 let opml = format!(
10114 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10115 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10116 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10117 </body></opml>"
10118 );
10119 let (ct, body) = opml_multipart(opml.as_bytes());
10120 let cookie = session_cookie(&state, did, None);
10121 let resp = router(state.clone())
10122 .oneshot(
10123 Request::builder()
10124 .method("POST")
10125 .uri("/opml")
10126 .header(header::COOKIE, cookie)
10127 .header("content-type", ct)
10128 .body(Body::from(body))
10129 .unwrap(),
10130 )
10131 .await
10132 .unwrap();
10133 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10134 let loc = resp
10135 .headers()
10136 .get(header::LOCATION)
10137 .unwrap()
10138 .to_str()
10139 .unwrap();
10140 assert!(
10141 loc.contains("Imported%201%20feed"),
10142 "unexpected flash: {loc}"
10143 );
10144 assert!(
10145 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
10146 "the dropped entry was not reported: {loc}"
10147 );
10148 assert!(
10150 !loc.contains("site.standard.publication"),
10151 "the URI was echoed: {loc}"
10152 );
10153 }
10154
10155 #[tokio::test]
10158 async fn opml_import_enforces_per_did_cap() {
10159 let did = "did:plc:capped";
10160 let state = test_state_with_caps(did, 2, 0).await;
10162 let existing_a = store::upsert_feed(
10163 &state.db,
10164 &store::NewFeed {
10165 url: "https://have-a.example/feed.xml".to_string(),
10166 ..Default::default()
10167 },
10168 )
10169 .await
10170 .unwrap();
10171 let existing_b = store::upsert_feed(
10172 &state.db,
10173 &store::NewFeed {
10174 url: "https://have-b.example/feed.xml".to_string(),
10175 ..Default::default()
10176 },
10177 )
10178 .await
10179 .unwrap();
10180 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
10181 .await
10182 .unwrap();
10183 let before = store::count_feeds(&state.db).await.unwrap();
10184
10185 let cookie = session_cookie(&state, did, None);
10186 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10187 let app = router(state.clone());
10188 let resp = app
10189 .oneshot(
10190 Request::builder()
10191 .method("POST")
10192 .uri("/opml")
10193 .header(header::COOKIE, cookie)
10194 .header("content-type", ct)
10195 .body(Body::from(body))
10196 .unwrap(),
10197 )
10198 .await
10199 .unwrap();
10200 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10201 let after = store::count_feeds(&state.db).await.unwrap();
10203 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
10204 }
10205
10206 #[tokio::test]
10209 async fn single_add_enforces_per_did_cap() {
10210 let did = "did:plc:subcapped";
10211 let state = test_state_with_caps(did, 1, 0).await;
10212 let f = store::upsert_feed(
10213 &state.db,
10214 &store::NewFeed {
10215 url: "https://have.example/feed.xml".to_string(),
10216 ..Default::default()
10217 },
10218 )
10219 .await
10220 .unwrap();
10221 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
10222 let cookie = session_cookie(&state, did, None);
10223 let app = router(state.clone());
10224 let resp = app
10225 .oneshot(
10226 Request::builder()
10227 .method("POST")
10228 .uri("/subscriptions")
10229 .header(header::COOKIE, cookie)
10230 .header("content-type", "application/x-www-form-urlencoded")
10231 .body(Body::from("url=https://another.example/feed.xml"))
10232 .unwrap(),
10233 )
10234 .await
10235 .unwrap();
10236 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10237 let loc = resp
10238 .headers()
10239 .get(header::LOCATION)
10240 .unwrap()
10241 .to_str()
10242 .unwrap();
10243 assert!(
10244 loc.contains("Subscription%20limit%20reached"),
10245 "expected sub-limit flash, got {loc}"
10246 );
10247 }
10248
10249 #[tokio::test]
10258 async fn the_reader_index_pages_instead_of_rendering_everything() {
10259 let did = "did:plc:pager";
10260 let state = test_state(&[]).await;
10261 store::grant_access(&state.db, did, None, "test", None)
10262 .await
10263 .unwrap();
10264 let feed = store::upsert_feed(
10265 &state.db,
10266 &store::NewFeed {
10267 url: "https://pager.example/feed.xml".to_string(),
10268 title: Some("Pager".to_string()),
10269 ..Default::default()
10270 },
10271 )
10272 .await
10273 .unwrap();
10274 let total = 250_usize;
10275 let entries: Vec<store::NewEntry> = (0..total)
10276 .map(|i| store::NewEntry {
10277 guid: format!("p-{i:04}"),
10278 url: Some(format!("https://pager.example/{i}")),
10279 title: Some(format!("Article {i:04}")),
10280 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
10281 content_html: Some("x".repeat(4_000)),
10282 ..Default::default()
10283 })
10284 .collect();
10285 store::insert_entries(&state.db, feed, &entries, 0)
10286 .await
10287 .unwrap();
10288 store::replace_sub_refs(&state.db, did, &[feed])
10289 .await
10290 .unwrap();
10291
10292 let cookie = session_cookie(&state, did, None);
10293 let app = router(state.clone());
10294 let get = |uri: &str| {
10295 let app = app.clone();
10296 let cookie = cookie.clone();
10297 let uri = uri.to_string();
10298 async move {
10299 let resp = app
10300 .oneshot(
10301 Request::builder()
10302 .uri(uri)
10303 .header(header::COOKIE, cookie)
10304 .body(Body::empty())
10305 .unwrap(),
10306 )
10307 .await
10308 .unwrap();
10309 assert_eq!(resp.status(), StatusCode::OK);
10310 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
10311 .await
10312 .unwrap();
10313 String::from_utf8(bytes.to_vec()).unwrap()
10314 }
10315 };
10316
10317 let page1 = get("/").await;
10318 let rows1 = page1.matches("<li class=\"entry").count();
10322 assert!(
10323 rows1 <= ENTRIES_PER_PAGE as usize,
10324 "page 1 rendered {rows1} entry links; the list is unbounded"
10325 );
10326 assert!(
10327 rows1 > 0,
10328 "page 1 rendered nothing at all: the page bound swallowed the list"
10329 );
10330 assert!(
10333 page1.contains("250 entries"),
10334 "heading must report the full total, not the page"
10335 );
10336 assert!(
10337 page1.contains("page=2"),
10338 "no way to reach the rest of the list: {}",
10339 &page1[..page1.len().min(400)]
10340 );
10341 assert!(
10343 !page1.contains(&"x".repeat(4_000)),
10344 "the list response carried an article body"
10345 );
10346
10347 let page2 = get("/?page=2").await;
10348 assert!(
10349 page2.matches("<li class=\"entry").count() > 0,
10350 "page 2 rendered no rows at all"
10351 );
10352 assert!(
10353 page2.contains("page=1") || page2.contains("Newer"),
10354 "page 2 offers no way back"
10355 );
10356 let first_title = (0..total)
10358 .map(|i| format!("Article {i:04}"))
10359 .find(|t| page1.contains(t))
10360 .expect("page 1 shows at least one titled article");
10361 assert!(
10362 !page2.contains(&first_title),
10363 "{first_title} appears on both pages"
10364 );
10365
10366 let past_end = get("/?page=999").await;
10372 assert!(
10373 past_end.matches("<li class=\"entry").count() > 0,
10374 "an out-of-range page rendered nothing and offered no way back"
10375 );
10376 assert!(
10377 past_end.contains("page=2"),
10378 "the clamped page offers no pager"
10379 );
10380 }
10381
10382 #[tokio::test]
10389 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
10390 let did = "did:plc:reader";
10391 let state = test_state(&[]).await;
10392 store::grant_access(&state.db, did, None, "test", None)
10393 .await
10394 .unwrap();
10395 let feed = store::upsert_feed(
10396 &state.db,
10397 &store::NewFeed {
10398 url: "https://reader.example/feed.xml".to_string(),
10399 title: Some("Reader".to_string()),
10400 ..Default::default()
10401 },
10402 )
10403 .await
10404 .unwrap();
10405 store::insert_entries(
10406 &state.db,
10407 feed,
10408 &[store::NewEntry {
10409 guid: "r-1".to_string(),
10410 url: Some("https://reader.example/1".to_string()),
10411 title: Some("Article".to_string()),
10412 published: Some("2026-07-11T00:00:00Z".to_string()),
10413 content_html: Some("<p>body</p>".to_string()),
10414 ..Default::default()
10415 }],
10416 0,
10417 )
10418 .await
10419 .unwrap();
10420 store::replace_sub_refs(&state.db, did, &[feed])
10421 .await
10422 .unwrap();
10423 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10424
10425 let cookie = session_cookie(&state, did, None);
10426 let app = router(state.clone());
10427
10428 let resp = app
10430 .clone()
10431 .oneshot(
10432 Request::builder()
10433 .method("POST")
10434 .uri(format!("/entries/{entry_id}/read"))
10435 .header(header::COOKIE, cookie.clone())
10436 .header("HX-Request", "true")
10437 .header("X-FR-Reader", "1")
10438 .header("content-type", "application/x-www-form-urlencoded")
10439 .body(Body::from("read=true"))
10440 .unwrap(),
10441 )
10442 .await
10443 .unwrap();
10444 assert_eq!(resp.status(), StatusCode::OK);
10445 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10446 .await
10447 .unwrap();
10448 let html = String::from_utf8(bytes.to_vec()).unwrap();
10449 assert!(
10450 html.contains("hx-swap-oob=\"outerHTML\""),
10451 "reader response must be an OOB swap: {html}"
10452 );
10453 assert!(
10454 html.contains(r#"id="entry-actionbar""#),
10455 "reader response must be the action-bar fragment: {html}"
10456 );
10457 assert!(
10460 html.contains(r#"aria-pressed="true""#),
10461 "read button must show pressed after marking read: {html}"
10462 );
10463 assert!(
10464 html.contains(r#"name="read" value="false""#),
10465 "hidden read value must flip to false so a second tap reverses: {html}"
10466 );
10467
10468 let resp2 = app
10471 .oneshot(
10472 Request::builder()
10473 .method("POST")
10474 .uri(format!("/entries/{entry_id}/read"))
10475 .header(header::COOKIE, cookie)
10476 .header("HX-Request", "true")
10477 .header("X-FR-Reader", "1")
10478 .header("content-type", "application/x-www-form-urlencoded")
10479 .body(Body::from("read=false"))
10480 .unwrap(),
10481 )
10482 .await
10483 .unwrap();
10484 assert_eq!(resp2.status(), StatusCode::OK);
10485 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10486 .await
10487 .unwrap();
10488 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10489 assert!(
10490 html2.contains(r#"aria-pressed="false""#),
10491 "read button must show un-pressed after reversing: {html2}"
10492 );
10493 assert!(
10494 html2.contains(r#"name="read" value="true""#),
10495 "hidden read value must flip back to true: {html2}"
10496 );
10497 }
10498
10499 #[tokio::test]
10502 async fn list_mark_read_returns_row_not_oob_actionbar() {
10503 let did = "did:plc:listv";
10504 let state = test_state(&[]).await;
10505 store::grant_access(&state.db, did, None, "test", None)
10506 .await
10507 .unwrap();
10508 let feed = store::upsert_feed(
10509 &state.db,
10510 &store::NewFeed {
10511 url: "https://list.example/feed.xml".to_string(),
10512 title: Some("List".to_string()),
10513 ..Default::default()
10514 },
10515 )
10516 .await
10517 .unwrap();
10518 store::insert_entries(
10519 &state.db,
10520 feed,
10521 &[store::NewEntry {
10522 guid: "l-1".to_string(),
10523 url: Some("https://list.example/1".to_string()),
10524 title: Some("Article".to_string()),
10525 published: Some("2026-07-11T00:00:00Z".to_string()),
10526 ..Default::default()
10527 }],
10528 0,
10529 )
10530 .await
10531 .unwrap();
10532 store::replace_sub_refs(&state.db, did, &[feed])
10533 .await
10534 .unwrap();
10535 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10536
10537 let cookie = session_cookie(&state, did, None);
10538 let app = router(state.clone());
10539
10540 let resp = app
10541 .oneshot(
10542 Request::builder()
10543 .method("POST")
10544 .uri(format!("/entries/{entry_id}/read"))
10545 .header(header::COOKIE, cookie)
10546 .header("HX-Request", "true")
10547 .header("content-type", "application/x-www-form-urlencoded")
10548 .body(Body::from("read=true"))
10549 .unwrap(),
10550 )
10551 .await
10552 .unwrap();
10553 assert_eq!(resp.status(), StatusCode::OK);
10554 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10555 .await
10556 .unwrap();
10557 let html = String::from_utf8(bytes.to_vec()).unwrap();
10558 assert!(
10559 !html.contains("hx-swap-oob"),
10560 "list-view response must NOT be an OOB swap: {html}"
10561 );
10562 assert!(
10567 html.contains(&format!("/entries/{entry_id}")),
10568 "the response is not the row for this entry: {html}",
10569 );
10570 assert!(
10571 html.contains("Article"),
10572 "the row rendered without its title: {html}",
10573 );
10574 assert!(
10591 html.contains("is-read"),
10592 "the row came back without the read state it was just given: {html}",
10593 );
10594 }
10595
10596 #[tokio::test]
10621 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10622 let did = "did:plc:autodiscovered";
10623 let state = test_state_with_caps(did, 0, 0).await;
10626
10627 let page = r#"<!doctype html><html><head><title>Blog</title>
10628 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10629 </head><body>hi</body></html>"#;
10630 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10631 let port: u16 = base
10632 .trim_end_matches('/')
10633 .rsplit(':')
10634 .next()
10635 .unwrap()
10636 .parse()
10637 .unwrap();
10638 crate::net::test_host_override(
10639 "autodiscover-ftp.test",
10640 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10641 );
10642
10643 let cookie = session_cookie(&state, did, None);
10644 let resp = router(state.clone())
10645 .oneshot(
10646 Request::builder()
10647 .method("POST")
10648 .uri("/subscriptions")
10649 .header(header::COOKIE, cookie)
10650 .header("content-type", "application/x-www-form-urlencoded")
10651 .body(Body::from(format!(
10652 "url=http://autodiscover-ftp.test:{port}/"
10653 )))
10654 .unwrap(),
10655 )
10656 .await
10657 .unwrap();
10658 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10659 let loc = resp
10660 .headers()
10661 .get(header::LOCATION)
10662 .unwrap()
10663 .to_str()
10664 .unwrap();
10665 assert_ne!(loc, "/login", "the test never reached the add path");
10666 assert_ne!(loc, "/", "the subscribe succeeded");
10667
10668 assert_eq!(
10669 store::count_feeds(&state.db).await.unwrap(),
10670 0,
10671 "a non-http(s) URL from autodiscovery was stored"
10672 );
10673 assert_eq!(
10674 store::count_subscriptions_for_did(&state.db, did)
10675 .await
10676 .unwrap(),
10677 0
10678 );
10679 }
10680
10681 #[tokio::test]
10686 async fn rename_to_new_url_refused_at_global_feeds_cap() {
10687 let did = "did:plc:renamer4";
10688 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10689 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10691 store::upsert_feed(
10692 &state.db,
10693 &store::NewFeed {
10694 url: "https://existing.example/feed.xml".to_string(),
10695 ..Default::default()
10696 },
10697 )
10698 .await
10699 .unwrap();
10700 let before = store::count_feeds(&state.db).await.unwrap();
10701 assert_eq!(before, 1);
10702
10703 let cookie = session_cookie(&state, did, None);
10704 let resp = router(state.clone())
10705 .oneshot(
10706 Request::builder()
10707 .method("POST")
10708 .uri("/subscriptions/rk-keep/rename")
10709 .header(header::COOKIE, cookie)
10710 .header("content-type", "application/x-www-form-urlencoded")
10711 .body(Body::from(
10713 "url=https://brand-new.example/feed.xml&title=Renamed",
10714 ))
10715 .unwrap(),
10716 )
10717 .await
10718 .unwrap();
10719 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10720 let loc = resp
10721 .headers()
10722 .get(header::LOCATION)
10723 .unwrap()
10724 .to_str()
10725 .unwrap();
10726 assert!(
10727 loc.contains("feed%20capacity"),
10728 "expected the feed-capacity flash, got {loc}"
10729 );
10730 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10732 assert!(
10733 puts.lock().unwrap().is_empty(),
10734 "a refused repoint reached the PDS"
10735 );
10736 }
10737
10738 #[tokio::test]
10745 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10746 let did = "did:plc:renamer4";
10747 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10748 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10749 store::upsert_feed(
10750 &state.db,
10751 &store::NewFeed {
10752 url: "https://existing.example/feed.xml".to_string(),
10753 ..Default::default()
10754 },
10755 )
10756 .await
10757 .unwrap();
10758 let before = store::count_feeds(&state.db).await.unwrap();
10759
10760 let cookie = session_cookie(&state, did, None);
10761 let resp = router(state.clone())
10762 .oneshot(
10763 Request::builder()
10764 .method("POST")
10765 .uri("/subscriptions/rk-keep/rename")
10766 .header(header::COOKIE, cookie)
10767 .header("content-type", "application/x-www-form-urlencoded")
10768 .body(Body::from(
10769 "url=https://existing.example/feed.xml&title=Retitled",
10770 ))
10771 .unwrap(),
10772 )
10773 .await
10774 .unwrap();
10775 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10776 let loc = resp
10777 .headers()
10778 .get(header::LOCATION)
10779 .unwrap()
10780 .to_str()
10781 .unwrap();
10782 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10783 assert_eq!(
10784 puts.lock().unwrap().len(),
10785 1,
10786 "the repoint did not reach the PDS"
10787 );
10788 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10789 }
10790
10791 #[tokio::test]
10793 async fn rename_with_blank_url_writes_nothing() {
10794 let did = "did:plc:renamer3";
10795 let state = test_state_with_caps(did, 0, 0).await;
10796 let before = store::count_feeds(&state.db).await.unwrap();
10797 assert_eq!(before, 0);
10798
10799 let cookie = session_cookie(&state, did, None);
10800 let app = router(state.clone());
10801 let resp = app
10802 .oneshot(
10803 Request::builder()
10804 .method("POST")
10805 .uri("/subscriptions/rkey123/rename")
10806 .header(header::COOKIE, cookie)
10807 .header("content-type", "application/x-www-form-urlencoded")
10808 .body(Body::from("url=%20%20&title=Nope"))
10810 .unwrap(),
10811 )
10812 .await
10813 .unwrap();
10814 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10815 assert_eq!(
10816 resp.headers()
10817 .get(header::LOCATION)
10818 .unwrap()
10819 .to_str()
10820 .unwrap(),
10821 "/",
10822 );
10823 assert_eq!(
10825 store::count_feeds(&state.db).await.unwrap(),
10826 0,
10827 "blank-URL rename wrote a junk feeds row"
10828 );
10829 }
10830
10831 async fn spawn_rename_sidecar(
10840 existing: serde_json::Value,
10841 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10842 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10843 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10844 let addr = listener.local_addr().unwrap();
10845 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10846 let sink = puts.clone();
10847 tokio::spawn(async move {
10848 loop {
10849 let Ok((mut sock, _)) = listener.accept().await else {
10850 break;
10851 };
10852 let mut raw: Vec<u8> = Vec::new();
10853 let mut chunk = [0u8; 4096];
10854 let body_text = loop {
10855 let Ok(n) = sock.read(&mut chunk).await else {
10856 break String::new();
10857 };
10858 if n == 0 {
10859 break String::from_utf8_lossy(&raw).to_string();
10860 }
10861 raw.extend_from_slice(&chunk[..n]);
10862 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10863 continue;
10864 };
10865 let (head, body) = raw.split_at(split + 4);
10866 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10867 let (k, v) = l.split_once(':')?;
10868 k.eq_ignore_ascii_case("content-length")
10869 .then(|| v.trim().parse::<usize>().ok())?
10870 });
10871 if want.is_none_or(|want| body.len() >= want) {
10872 break String::from_utf8_lossy(body).to_string();
10873 }
10874 };
10875
10876 let is_put = body_text.contains("\"action\":\"put\"");
10878 let data = if is_put {
10879 sink.lock().unwrap().push(body_text.clone());
10880 serde_json::json!({
10881 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10882 "cid": "bafyreiafter"
10883 })
10884 } else {
10885 serde_json::json!({ "records": [existing.clone()] })
10886 };
10887 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10888 let resp = format!(
10889 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10890 body.len(),
10891 body
10892 );
10893 let _ = sock.write_all(resp.as_bytes()).await;
10894 let _ = sock.flush().await;
10895 }
10896 });
10897 (format!("http://{addr}"), puts)
10898 }
10899
10900 fn seeded_subscription() -> serde_json::Value {
10902 serde_json::json!({
10903 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10904 "cid": "bafyreibefore",
10905 "value": {
10906 "$type": "community.lexicon.rss.subscription",
10907 "url": "https://example.com/feed.xml",
10908 "title": "Old title",
10909 "siteUrl": "https://example.com/blog",
10910 "fetchHint": "hourly",
10911 "private": false,
10912 "createdAt": "2024-03-01T00:00:00.000Z"
10913 }
10914 })
10915 }
10916
10917 fn seeded_at_uri_subscription() -> serde_json::Value {
10920 seeded_subscription_with_url(AT_URI_SUB)
10921 }
10922 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10924 serde_json::json!({
10925 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10926 "cid": "bafyreibefore",
10927 "value": {
10928 "$type": "community.lexicon.rss.subscription",
10929 "url": url,
10930 "title": "Old title",
10931 "private": false,
10932 "createdAt": "2024-03-01T00:00:00.000Z"
10933 }
10934 })
10935 }
10936 const AT_URI_SUB: &str =
10937 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10938 const AT_URI_SUB_ENC: &str =
10939 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10940
10941 #[tokio::test]
10950 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10951 let did = "did:plc:renamer5";
10952 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10953 let state = test_state_with_sidecar(&[did], &sidecar).await;
10954 assert!(
10955 !state.config.standard_site,
10956 "the flag must be off for this test"
10957 );
10958 let cookie = session_cookie(&state, did, None);
10959 let resp = router(state.clone())
10960 .oneshot(
10961 Request::builder()
10962 .method("POST")
10963 .uri("/subscriptions/rk-keep/rename")
10964 .header(header::COOKIE, cookie)
10965 .header("content-type", "application/x-www-form-urlencoded")
10966 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10967 .unwrap(),
10968 )
10969 .await
10970 .unwrap();
10971 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10972 let loc = resp
10973 .headers()
10974 .get(header::LOCATION)
10975 .unwrap()
10976 .to_str()
10977 .unwrap();
10978 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10979
10980 let bodies = puts.lock().unwrap().clone();
10981 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10982 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10983 assert_eq!(
10984 sent["record"]["title"], "New title",
10985 "the rename did not apply"
10986 );
10987 assert_eq!(
10988 sent["record"]["url"], AT_URI_SUB,
10989 "the rename changed the URL"
10990 );
10991
10992 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10994 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10995 }
10996
10997 #[tokio::test]
11001 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
11002 let did = "did:plc:renamer4";
11003 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11004 let state = test_state_with_sidecar(&[did], &sidecar).await;
11005 let cookie = session_cookie(&state, did, None);
11006 let resp = router(state.clone())
11007 .oneshot(
11008 Request::builder()
11009 .method("POST")
11010 .uri("/subscriptions/rk-keep/rename")
11011 .header(header::COOKIE, cookie)
11012 .header("content-type", "application/x-www-form-urlencoded")
11013 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11014 .unwrap(),
11015 )
11016 .await
11017 .unwrap();
11018 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11019 let loc = resp
11020 .headers()
11021 .get(header::LOCATION)
11022 .unwrap()
11023 .to_str()
11024 .unwrap();
11025 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
11026 assert!(
11027 !loc.contains("Private"),
11028 "a storability refusal was reported as a privacy one: {loc}"
11029 );
11030 assert!(
11031 puts.lock().unwrap().is_empty(),
11032 "the repoint reached the PDS"
11033 );
11034 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11035 assert_eq!(cached, 0);
11036 }
11037
11038 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
11041 let cookie = session_cookie(state, did, None);
11042 let resp = router(state.clone())
11043 .oneshot(
11044 Request::builder()
11045 .method("POST")
11046 .uri("/subscriptions/rk-keep/rename")
11047 .header(header::COOKIE, cookie)
11048 .header("content-type", "application/x-www-form-urlencoded")
11049 .body(Body::from(format!("url={url_enc}&title=New+title")))
11050 .unwrap(),
11051 )
11052 .await
11053 .unwrap();
11054 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11055 resp.headers()
11056 .get(header::LOCATION)
11057 .unwrap()
11058 .to_str()
11059 .unwrap()
11060 .to_string()
11061 }
11062
11063 #[tokio::test]
11073 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
11074 let did = "did:plc:renamer5";
11075 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
11076 let other_enc =
11077 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
11078 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
11079 let state = test_state_with_sidecar(&[did], &sidecar).await;
11080 let loc = retitle_unchanged(&state, did, other_enc).await;
11081 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11082 let bodies = puts.lock().unwrap().clone();
11083 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11084 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11085 assert_eq!(sent["record"]["title"], "New title");
11086 assert_eq!(sent["record"]["url"], other);
11087 }
11088
11089 #[tokio::test]
11093 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
11094 let did = "did:plc:renamer4";
11095 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11096 let state = test_state_with_sidecar(&[did], &sidecar).await;
11097 let cookie = session_cookie(&state, did, None);
11098 let resp = router(state.clone())
11099 .oneshot(
11100 Request::builder()
11101 .method("POST")
11102 .uri("/subscriptions/rk-keep/rename")
11103 .header(header::COOKIE, cookie)
11104 .header("content-type", "application/x-www-form-urlencoded")
11105 .body(Body::from(
11106 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
11107 ))
11108 .unwrap(),
11109 )
11110 .await
11111 .unwrap();
11112 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11113 let loc = resp
11114 .headers()
11115 .get(header::LOCATION)
11116 .unwrap()
11117 .to_str()
11118 .unwrap();
11119 assert!(
11120 loc.contains("Private"),
11121 "the private repoint was not refused: {loc}"
11122 );
11123 assert!(
11124 puts.lock().unwrap().is_empty(),
11125 "a secret-bearing URL reached the PDS"
11126 );
11127 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
11130 assert!(store::get_feed_by_url(&state.db, leaked)
11131 .await
11132 .unwrap()
11133 .is_none());
11134 }
11135
11136 #[tokio::test]
11142 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
11143 let did = "did:plc:renamer5";
11144 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11145 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11147 store::upsert_feed(
11148 &state.db,
11149 &store::NewFeed {
11150 url: "https://filler.example/feed.xml".to_string(),
11151 ..Default::default()
11152 },
11153 )
11154 .await
11155 .unwrap();
11156 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11157 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11158 assert_eq!(
11159 puts.lock().unwrap().len(),
11160 1,
11161 "the retitle did not reach the PDS"
11162 );
11163 assert_eq!(
11164 store::count_feeds(&state.db).await.unwrap(),
11165 1,
11166 "a row was inserted"
11167 );
11168 }
11169
11170 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
11172 let cookie = session_cookie(state, did, None);
11173 let resp = router(state.clone())
11174 .oneshot(
11175 Request::builder()
11176 .method("POST")
11177 .uri("/subscriptions")
11178 .header(header::COOKIE, cookie)
11179 .header("content-type", "application/x-www-form-urlencoded")
11180 .body(Body::from(format!("url={url_enc}")))
11181 .unwrap(),
11182 )
11183 .await
11184 .unwrap();
11185 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11186 resp.headers()
11187 .get(header::LOCATION)
11188 .unwrap()
11189 .to_str()
11190 .unwrap()
11191 .to_string()
11192 }
11193
11194 async fn serve_resolver(did: &str) -> String {
11196 let base = crate::net::tests::serve_body(
11197 serde_json::json!({ "did": did }).to_string().into_bytes(),
11198 )
11199 .await;
11200 let port: u16 = base
11201 .trim_end_matches('/')
11202 .rsplit(':')
11203 .next()
11204 .unwrap()
11205 .parse()
11206 .unwrap();
11207 let host = format!("resolver-{port}.test");
11208 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11209 format!("http://{host}:{port}")
11210 }
11211
11212 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
11213 let mut config = (*state.config).clone();
11214 f(&mut config);
11215 state.config = std::sync::Arc::new(config);
11216 state
11217 }
11218
11219 #[tokio::test]
11224 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
11225 let did = "did:plc:renamer5";
11226 let (sidecar, log) = spawn_logging_sidecar().await;
11227 let state = with_config(
11228 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11229 |c| {
11230 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11231 },
11232 );
11233 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
11234 assert_eq!(loc, "/", "the paste was refused: {loc}");
11235 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
11236 .await
11237 .unwrap()
11238 .expect("no feed row");
11239 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
11240 let sent = log.lock().unwrap().join("\n");
11241 assert!(
11242 sent.contains(AT_URI_SUB),
11243 "the subscription was not written to the PDS: {sent}"
11244 );
11245 }
11246
11247 #[tokio::test]
11251 async fn a0_subscribing_from_the_form_delivers_entries() {
11252 let did = "did:plc:renamer5";
11253 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11254 let site = AT_URI_SUB;
11255 let (plc, _) = crate::standard_site::tests::serve_repo(
11256 author,
11257 vec![
11258 (
11259 lexicon::nsid::STANDARD_PUBLICATION,
11260 "3lab2c4d5e6f7g8h",
11261 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
11262 ),
11263 (
11264 lexicon::nsid::STANDARD_DOCUMENT,
11265 "3l2a0frmaaa2a",
11266 serde_json::json!({ "title": "From the form", "path": "/f",
11267 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
11268 ),
11269 ],
11270 )
11271 .await;
11272 let (sidecar, _log) = spawn_logging_sidecar().await;
11273 let state = with_config(
11274 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11275 |c| {
11276 c.oauth.plc_directory = plc;
11277 },
11278 );
11279 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
11280 let row = store::get_feed_by_url(&state.db, site)
11281 .await
11282 .unwrap()
11283 .unwrap();
11284 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
11285 .bind(row.id)
11286 .fetch_all(&state.db)
11287 .await
11288 .unwrap();
11289 assert_eq!(
11290 titles,
11291 vec!["From the form".to_string()],
11292 "the first poll stored nothing"
11293 );
11294 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
11295 }
11296
11297 #[tokio::test]
11300 async fn a_handle_form_paste_is_stored_by_its_did() {
11301 let did = "did:plc:renamer5";
11302 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11303 let (sidecar, _log) = spawn_logging_sidecar().await;
11304 let resolver = serve_resolver(author).await;
11305 let state = with_config(
11306 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11307 |c| {
11308 c.resolver_base = resolver;
11309 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11310 },
11311 );
11312 let loc = subscribe(
11313 &state,
11314 did,
11315 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11316 )
11317 .await;
11318 assert_eq!(loc, "/", "the paste was refused: {loc}");
11319 assert!(
11320 store::get_feed_by_url(&state.db, AT_URI_SUB)
11321 .await
11322 .unwrap()
11323 .is_some(),
11324 "not stored by its DID"
11325 );
11326 assert_eq!(
11327 store::count_feeds(&state.db).await.unwrap(),
11328 1,
11329 "the handle form was stored too"
11330 );
11331 }
11332
11333 async fn serve_counting_resolver(
11335 did: &str,
11336 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
11337 let (base, hits) = crate::net::tests::serve_body_counted(
11338 serde_json::json!({ "did": did }).to_string().into_bytes(),
11339 )
11340 .await;
11341 let port: u16 = base
11342 .trim_end_matches('/')
11343 .rsplit(':')
11344 .next()
11345 .unwrap()
11346 .parse()
11347 .unwrap();
11348 let host = format!("counting-resolver-{port}.test");
11349 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11350 (format!("http://{host}:{port}"), hits)
11351 }
11352
11353 #[tokio::test]
11357 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
11358 let did = "did:plc:renamer5";
11359 let (sidecar, _log) = spawn_logging_sidecar().await;
11360 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11361 let state = with_config(
11362 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11363 |c| {
11364 c.resolver_base = resolver;
11365 c.max_subs_per_did = 1;
11366 },
11367 );
11368 let feed_id = store::upsert_feed(
11369 &state.db,
11370 &store::NewFeed {
11371 url: "https://already.example/feed.xml".into(),
11372 ..Default::default()
11373 },
11374 )
11375 .await
11376 .unwrap();
11377 store::replace_sub_refs(&state.db, did, &[feed_id])
11378 .await
11379 .unwrap();
11380 let loc = subscribe(
11381 &state,
11382 did,
11383 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11384 )
11385 .await;
11386 assert!(
11387 loc.contains("Subscription%20limit"),
11388 "expected the cap flash: {loc}"
11389 );
11390 assert_eq!(
11391 hits.load(std::sync::atomic::Ordering::SeqCst),
11392 0,
11393 "an over-cap paste resolved a handle"
11394 );
11395 }
11396
11397 #[tokio::test]
11401 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
11402 let did = "did:plc:renamer5";
11403 let (sidecar, _log) = spawn_logging_sidecar().await;
11404 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11405 let state = with_config(
11406 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11407 |c| {
11408 c.resolver_base = resolver;
11409 },
11410 );
11411 for authority in [
11412 "did%3Aplc%3ATOOSHORT",
11413 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
11414 "bad%0Ahandle.example",
11415 ] {
11416 let loc = subscribe(
11417 &state,
11418 did,
11419 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
11420 )
11421 .await;
11422 assert!(
11423 loc.contains("kind%20of%20feed"),
11424 "{authority}: expected the unsupported flash: {loc}"
11425 );
11426 }
11427 assert_eq!(
11428 hits.load(std::sync::atomic::Ordering::SeqCst),
11429 0,
11430 "a malformed authority reached the resolver"
11431 );
11432 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11433 }
11434
11435 #[tokio::test]
11437 async fn an_unresolvable_handle_paste_is_refused() {
11438 let did = "did:plc:renamer5";
11439 let (sidecar, _log) = spawn_logging_sidecar().await;
11440 let state = with_config(
11441 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11442 |c| {
11443 c.resolver_base = "http://resolver.nowhere.invalid".into();
11444 },
11445 );
11446 let loc = subscribe(
11447 &state,
11448 did,
11449 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11450 )
11451 .await;
11452 assert!(
11453 loc.contains("resolve%20the%20handle"),
11454 "expected the unresolvable-handle flash: {loc}"
11455 );
11456 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11457 }
11458
11459 #[tokio::test]
11461 async fn a_non_publication_at_uri_paste_is_refused() {
11462 let did = "did:plc:renamer5";
11463 let (sidecar, _log) = spawn_logging_sidecar().await;
11464 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11465 let loc = subscribe(
11466 &state,
11467 did,
11468 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
11469 )
11470 .await;
11471 assert!(
11472 loc.contains("kind%20of%20feed"),
11473 "expected the unsupported flash: {loc}"
11474 );
11475 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11476 }
11477
11478 #[tokio::test]
11481 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11482 let did = "did:plc:renamer5";
11483 let (sidecar, _log) = spawn_logging_sidecar().await;
11484 let state = with_config(
11485 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11486 |c| {
11487 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11488 },
11489 );
11490 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11491 assert_eq!(loc, "/", "the paste was refused: {loc}");
11492 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11493 .await
11494 .unwrap()
11495 .is_some());
11496 }
11497
11498 #[tokio::test]
11501 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11502 let did = "did:plc:renamer5";
11503 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11504 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11505 let opml = format!(
11506 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11507 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11508 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11509 </body></opml>"
11510 );
11511 let (ct, body) = opml_multipart(opml.as_bytes());
11512 let cookie = session_cookie(&state, did, None);
11513 let resp = router(state.clone())
11514 .oneshot(
11515 Request::builder()
11516 .method("POST")
11517 .uri("/opml")
11518 .header(header::COOKIE, cookie)
11519 .header("content-type", ct)
11520 .body(Body::from(body))
11521 .unwrap(),
11522 )
11523 .await
11524 .unwrap();
11525 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11526 let loc = resp
11527 .headers()
11528 .get(header::LOCATION)
11529 .unwrap()
11530 .to_str()
11531 .unwrap();
11532 assert!(
11533 loc.contains("Imported%202%20feeds"),
11534 "unexpected flash: {loc}"
11535 );
11536 assert!(
11537 !loc.contains("skipped"),
11538 "the at:// entry was skipped with the flag on: {loc}"
11539 );
11540 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11541 assert!(
11542 stored.is_some(),
11543 "the at:// entry was not stored with the flag on"
11544 );
11545 }
11546
11547 #[tokio::test]
11557 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11558 let did = "did:plc:renamer5";
11559 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11560 let tokened_enc =
11561 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11562 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11563 let state = test_state_with_sidecar(&[did], &sidecar).await;
11564 let loc = retitle_unchanged(&state, did, tokened_enc).await;
11565 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11566 assert_eq!(
11567 puts.lock().unwrap().len(),
11568 1,
11569 "the retitle did not reach the PDS"
11570 );
11571 assert!(
11572 store::get_feed_by_url(&state.db, tokened)
11573 .await
11574 .unwrap()
11575 .is_none(),
11576 "a secret-bearing URL was written to the shared cache by a retitle"
11577 );
11578 }
11579
11580 #[tokio::test]
11585 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11586 let did = "did:plc:renamer4";
11587 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11588 let state = test_state_with_sidecar(&[did], &sidecar).await;
11589 let cookie = session_cookie(&state, did, None);
11590 let resp = router(state.clone())
11591 .oneshot(
11592 Request::builder()
11593 .method("POST")
11594 .uri("/subscriptions/rk-keep/rename")
11595 .header(header::COOKIE, cookie)
11596 .header("content-type", "application/x-www-form-urlencoded")
11597 .body(Body::from(
11598 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11599 ))
11600 .unwrap(),
11601 )
11602 .await
11603 .unwrap();
11604 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11605 let loc = resp
11606 .headers()
11607 .get(header::LOCATION)
11608 .unwrap()
11609 .to_str()
11610 .unwrap();
11611 assert!(
11612 loc.contains("kind%20of%20feed"),
11613 "expected the unsupported flash: {loc}"
11614 );
11615 assert!(
11616 !loc.contains("Private"),
11617 "a typo was reported as a paid feed: {loc}"
11618 );
11619 assert!(puts.lock().unwrap().is_empty());
11620 }
11621
11622 #[tokio::test]
11623 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11624 let did = "did:plc:renamer4";
11625 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11626 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11627 store::upsert_feed(
11628 &state.db,
11629 &store::NewFeed {
11630 url: "https://filler.example/feed.xml".to_string(),
11631 ..Default::default()
11632 },
11633 )
11634 .await
11635 .unwrap();
11636 let cookie = session_cookie(&state, did, None);
11637 let resp = router(state.clone())
11638 .oneshot(
11639 Request::builder()
11640 .method("POST")
11641 .uri("/subscriptions/rk-keep/rename")
11642 .header(header::COOKIE, cookie)
11643 .header("content-type", "application/x-www-form-urlencoded")
11644 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11645 .unwrap(),
11646 )
11647 .await
11648 .unwrap();
11649 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11650 let loc = resp
11651 .headers()
11652 .get(header::LOCATION)
11653 .unwrap()
11654 .to_str()
11655 .unwrap();
11656 assert!(
11657 loc.contains("kind%20of%20feed"),
11658 "expected the unsupported flash: {loc}"
11659 );
11660 assert!(
11661 !loc.contains("capacity"),
11662 "an unacceptable URL was reported as a capacity problem: {loc}"
11663 );
11664 assert!(puts.lock().unwrap().is_empty());
11665 }
11666
11667 #[tokio::test]
11672 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11673 let did = "did:plc:renamer5";
11674 let padded = format!("{AT_URI_SUB} ");
11675 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11676 let state = test_state_with_sidecar(&[did], &sidecar).await;
11677 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11679 assert_eq!(
11680 loc, "/",
11681 "the retitle was treated as a repoint and refused: {loc}"
11682 );
11683 let bodies = puts.lock().unwrap().clone();
11684 assert_eq!(bodies.len(), 1);
11685 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11686 assert_eq!(
11687 sent["record"]["url"], AT_URI_SUB,
11688 "the padding was not normalised away"
11689 );
11690 }
11691
11692 #[tokio::test]
11698 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11699 let did = "did:plc:renamer5";
11700 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11701 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11702 store::upsert_feed(
11703 &state.db,
11704 &store::NewFeed {
11705 url: "https://filler.example/feed.xml".to_string(),
11706 ..Default::default()
11707 },
11708 )
11709 .await
11710 .unwrap();
11711 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11712 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11713 assert_eq!(puts.lock().unwrap().len(), 1);
11714 assert_eq!(
11715 store::count_feeds(&state.db).await.unwrap(),
11716 1,
11717 "a retitle inserted a cache row past the ceiling"
11718 );
11719 }
11720
11721 #[tokio::test]
11728 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11729 let did = "did:plc:typoist";
11730 let state = test_state_with_caps(did, 0, 0).await;
11731 let cookie = session_cookie(&state, did, None);
11732 let resp = router(state.clone())
11733 .oneshot(
11734 Request::builder()
11735 .method("POST")
11736 .uri("/subscriptions")
11737 .header(header::COOKIE, cookie)
11738 .header("content-type", "application/x-www-form-urlencoded")
11739 .body(Body::from(
11740 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11741 ))
11742 .unwrap(),
11743 )
11744 .await
11745 .unwrap();
11746 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11747 let loc = resp
11748 .headers()
11749 .get(header::LOCATION)
11750 .unwrap()
11751 .to_str()
11752 .unwrap();
11753 assert!(
11754 loc.contains("kind%20of%20feed"),
11755 "expected the unsupported flash: {loc}"
11756 );
11757 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11758 }
11759
11760 #[derive(Default)]
11766 struct SwapRepo {
11767 value: serde_json::Value,
11769 version: u32,
11771 puts: Vec<serde_json::Value>,
11773 concurrent: Option<serde_json::Value>,
11776 refuse_every_swap: bool,
11778 fail_puts: Option<(u16, &'static str)>,
11780 collection: Option<&'static str>,
11782 missing: bool,
11784 fail_list: bool,
11786 }
11787
11788 impl SwapRepo {
11789 fn cid(&self) -> String {
11790 format!("bafyreiversion{}", self.version)
11791 }
11792
11793 fn nsid(&self) -> &'static str {
11794 self.collection
11795 .unwrap_or(crate::lexicon::nsid::SUBSCRIPTION)
11796 }
11797
11798 fn page(&self) -> serde_json::Value {
11799 if self.missing {
11800 return serde_json::json!({ "records": [] });
11801 }
11802 serde_json::json!({ "records": [{
11803 "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
11804 "cid": self.cid(),
11805 "value": self.value,
11806 }] })
11807 }
11808
11809 fn put(&mut self, body: &serde_json::Value) -> Result<serde_json::Value, (u16, String)> {
11811 self.puts.push(body.clone());
11812 if let Some(theirs) = self.concurrent.take() {
11813 self.value = theirs;
11814 self.version += 1;
11815 }
11816 if let Some((status, error)) = self.fail_puts {
11817 return Err((status, error.to_string()));
11818 }
11819 if let Some(swap) = body.get("swapRecord").and_then(|v| v.as_str()) {
11820 if self.refuse_every_swap || swap != self.cid() {
11821 return Err((400, "InvalidSwap".to_string()));
11822 }
11823 }
11824 self.value = body["record"].clone();
11825 self.version += 1;
11826 Ok(serde_json::json!({
11827 "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
11828 "cid": self.cid(),
11829 }))
11830 }
11831 }
11832
11833 const RACE_DID: &str = "did:plc:racer149";
11834
11835 async fn serve_swap_repo(repo: std::sync::Arc<std::sync::Mutex<SwapRepo>>) -> (String, String) {
11839 use axum::response::IntoResponse as _;
11840 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11841 let addr = listener.local_addr().unwrap();
11842 let host = format!("pds-{}.race.test", addr.port());
11843 crate::net::test_host_override(&host, addr);
11844 let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
11845 let repo = std::sync::Arc::clone(&repo);
11846 async move {
11847 let (parts, body) = req.into_parts();
11848 let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
11849 let body: serde_json::Value =
11850 serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
11851 let reply = |status: u16, body: serde_json::Value| {
11852 (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
11853 };
11854 let mut repo = repo.lock().unwrap();
11855 match (parts.uri.path(), body["action"].as_str()) {
11856 ("/internal/repo", Some("list")) if repo.fail_list => reply(
11857 502,
11858 serde_json::json!({
11859 "ok": false, "error": "UpstreamFailure", "message": "down", "status": 502,
11860 }),
11861 ),
11862 ("/internal/repo", Some("list")) => {
11863 reply(200, serde_json::json!({ "ok": true, "data": repo.page() }))
11864 }
11865 ("/internal/repo", Some("put")) => match repo.put(&body) {
11866 Ok(data) => reply(200, serde_json::json!({ "ok": true, "data": data })),
11867 Err((status, error)) => reply(
11868 status,
11869 serde_json::json!({
11870 "ok": false, "error": error, "message": "refused", "status": status,
11871 }),
11872 ),
11873 },
11874 ("/xrpc/com.atproto.repo.listRecords", _) if repo.fail_list => reply(
11875 502,
11876 serde_json::json!({ "error": "UpstreamFailure", "message": "down" }),
11877 ),
11878 ("/xrpc/com.atproto.repo.listRecords", _) => reply(200, repo.page()),
11879 ("/xrpc/com.atproto.repo.putRecord", _) => match repo.put(&body) {
11880 Ok(data) => reply(200, data),
11881 Err((status, error)) => reply(
11882 status,
11883 serde_json::json!({ "error": error, "message": "refused" }),
11884 ),
11885 },
11886 other => panic!("unexpected request {other:?}"),
11887 }
11888 }
11889 });
11890 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
11891 (
11892 format!("http://{addr}"),
11893 format!("http://{host}:{}", addr.port()),
11894 )
11895 }
11896
11897 async fn race_state(
11901 backend: crate::metrics::Backend,
11902 repo: &std::sync::Arc<std::sync::Mutex<SwapRepo>>,
11903 ) -> AppState {
11904 let (sidecar, aud) = serve_swap_repo(std::sync::Arc::clone(repo)).await;
11905 let db = store::init_url("sqlite::memory:").await.unwrap();
11906 store::ensure_seed(&db, &[RACE_DID.to_string()])
11907 .await
11908 .unwrap();
11909 let mut config = Config {
11910 allowed_dids: vec![RACE_DID.to_string()],
11911 cookie_secret: "test-cookie-secret-000".to_string(),
11912 beta_cap: 3,
11913 repo_backend: backend,
11914 oauth: crate::config::OauthConfig {
11915 key_path: std::env::temp_dir().join(format!(
11917 "fr-race-oauth-key-{}-{:p}.json",
11918 std::process::id(),
11919 &db as *const _
11920 )),
11921 encryption_key: Some("a".repeat(43)),
11922 ..crate::config::OauthConfig::default()
11923 },
11924 ..Config::default()
11925 };
11926 config.sidecar.public_url = sidecar.clone();
11927 config.sidecar.internal_url = sidecar;
11928 let state = AppState::new(config, db).unwrap();
11929 if backend == crate::metrics::Backend::Rust {
11930 let runtime = state.oauth.as_deref().expect("oauth runtime");
11931 crate::oauth::store::put_session(
11932 &state.db,
11933 &runtime.codec,
11934 &crate::oauth::store::OAuthSession {
11935 sub: RACE_DID.into(),
11936 issuer: "https://auth.invalid".into(),
11937 aud,
11938 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
11939 .to_jwk_json()
11940 .unwrap(),
11941 access_token: "at".into(),
11942 refresh_token: "rt".into(),
11943 token_type: "DPoP".into(),
11944 granted_scope: "atproto".into(),
11945 expires_at: Some(store::now_unix() + 3600),
11946 },
11947 )
11948 .await
11949 .unwrap();
11950 }
11951 state
11952 }
11953
11954 fn race_seed() -> serde_json::Value {
11956 seeded_subscription()["value"].clone()
11957 }
11958
11959 async fn post_race_rename(state: &AppState) -> String {
11962 post_race_rename_body(
11963 state,
11964 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11965 )
11966 .await
11967 }
11968
11969 async fn post_race_rename_body(state: &AppState, body: &str) -> String {
11971 let cookie = session_cookie(state, RACE_DID, None);
11972 let resp = router(state.clone())
11973 .oneshot(
11974 Request::builder()
11975 .method("POST")
11976 .uri("/subscriptions/rk-keep/rename")
11977 .header(header::COOKIE, cookie)
11978 .header("content-type", "application/x-www-form-urlencoded")
11979 .body(Body::from(body.to_string()))
11980 .unwrap(),
11981 )
11982 .await
11983 .unwrap();
11984 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11985 resp.headers()
11986 .get(header::LOCATION)
11987 .unwrap()
11988 .to_str()
11989 .unwrap()
11990 .to_string()
11991 }
11992
11993 const RACE_BACKENDS: [crate::metrics::Backend; 2] = [
11994 crate::metrics::Backend::Sidecar,
11995 crate::metrics::Backend::Rust,
11996 ];
11997
11998 #[tokio::test]
12011 async fn a_rename_that_loses_a_race_keeps_the_concurrent_edit_and_lands() {
12012 for backend in RACE_BACKENDS {
12013 let mut theirs = race_seed();
12014 theirs["siteUrl"] = serde_json::json!("https://elsewhere.example/blog");
12015 theirs["fetchHint"] = serde_json::json!("daily");
12016 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12017 value: race_seed(),
12018 concurrent: Some(theirs),
12019 ..SwapRepo::default()
12020 }));
12021 let state = race_state(backend, &repo).await;
12022
12023 let loc = post_race_rename(&state).await;
12024
12025 let repo = repo.lock().unwrap();
12026 assert_eq!(
12027 loc, "/",
12028 "{backend:?}: a rename that converged was not reported as done"
12029 );
12030 assert_eq!(
12031 repo.puts.len(),
12032 2,
12033 "{backend:?}: expected the refused put and one retry: {:?}",
12034 repo.puts
12035 );
12036 assert_eq!(
12037 repo.puts[0]["swapRecord"], "bafyreiversion0",
12038 "{backend:?}: the first put did not name the CID it read: {}",
12039 repo.puts[0]
12040 );
12041 assert_eq!(
12042 repo.puts[1]["swapRecord"], "bafyreiversion1",
12043 "{backend:?}: the retry did not name the RE-READ CID: {}",
12044 repo.puts[1]
12045 );
12046 let landed = &repo.value;
12047 assert_eq!(landed["title"], "New title", "{backend:?}: {landed}");
12049 assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12050 assert_eq!(
12052 landed["siteUrl"], "https://elsewhere.example/blog",
12053 "{backend:?}: the concurrent edit was lost: {landed}"
12054 );
12055 assert_eq!(
12056 landed["fetchHint"], "daily",
12057 "{backend:?}: the concurrent edit was lost: {landed}"
12058 );
12059 assert_eq!(
12061 landed["createdAt"], "2024-03-01T00:00:00.000Z",
12062 "{backend:?}: {landed}"
12063 );
12064 }
12065 }
12066
12067 #[tokio::test]
12071 async fn a_rename_refused_on_every_swap_reports_the_conflict() {
12072 for backend in RACE_BACKENDS {
12073 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12074 value: race_seed(),
12075 refuse_every_swap: true,
12076 ..SwapRepo::default()
12077 }));
12078 let state = race_state(backend, &repo).await;
12079
12080 let loc = post_race_rename(&state).await;
12081
12082 let repo = repo.lock().unwrap();
12083 assert_ne!(loc, "/", "{backend:?}: a refused rename reported success");
12084 assert!(
12085 loc.contains("changed%20elsewhere"),
12086 "{backend:?}: expected the conflict flash, got {loc}"
12087 );
12088 assert!(
12089 (1..=2).contains(&repo.puts.len()),
12090 "{backend:?}: expected at most two put attempts, got {}",
12091 repo.puts.len()
12092 );
12093 assert_eq!(
12094 repo.value,
12095 race_seed(),
12096 "{backend:?}: the record changed though every put was refused"
12097 );
12098 }
12099 }
12100
12101 #[tokio::test]
12106 async fn a_rename_refused_for_another_reason_is_not_retried() {
12107 for backend in RACE_BACKENDS {
12108 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12109 value: race_seed(),
12110 fail_puts: Some((400, "InvalidRequest")),
12111 ..SwapRepo::default()
12112 }));
12113 let state = race_state(backend, &repo).await;
12114
12115 let loc = post_race_rename(&state).await;
12116
12117 let repo = repo.lock().unwrap();
12118 assert_eq!(
12119 repo.puts.len(),
12120 1,
12121 "{backend:?}: a non-swap refusal was retried"
12122 );
12123 assert!(
12124 loc.contains("Could%20not%20save"),
12125 "{backend:?}: expected the save-failed flash, got {loc}"
12126 );
12127 assert!(
12128 !loc.contains("changed%20elsewhere"),
12129 "{backend:?}: a non-swap refusal was reported as a conflict: {loc}"
12130 );
12131 }
12132 }
12133
12134 const SEEN_SEED: &str = "seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12138 &seen_title=Old+title&seen_folder=";
12139
12140 #[tokio::test]
12149 async fn a_retry_keeps_a_concurrent_repoint_the_reader_did_not_make() {
12150 for backend in RACE_BACKENDS {
12151 for with_seen in [true, false] {
12152 let mut theirs = race_seed();
12153 theirs["url"] = serde_json::json!("https://moved.example/feed.xml");
12154 theirs["siteUrl"] = serde_json::json!("https://moved.example/");
12155 theirs["fetchHint"] = serde_json::json!("daily");
12156 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12157 value: race_seed(),
12158 concurrent: Some(theirs),
12159 ..SwapRepo::default()
12160 }));
12161 let state = race_state(backend, &repo).await;
12162 let mut body =
12163 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title".to_string();
12164 if with_seen {
12165 body.push_str(
12166 "&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml&seen_title=Old+title",
12167 );
12168 }
12169
12170 let loc = post_race_rename_body(&state, &body).await;
12171
12172 let repo = repo.lock().unwrap();
12173 let ctx = format!("{backend:?} seen={with_seen}");
12174 assert_eq!(loc, "/", "{ctx}: the rename did not land: {loc}");
12175 assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12176 let landed = &repo.value;
12177 assert_eq!(landed["title"], "New title", "{ctx}: {landed}");
12178 assert_eq!(
12179 landed["url"], "https://moved.example/feed.xml",
12180 "{ctx}: the retry repointed the record back to the stale URL: {landed}"
12181 );
12182 assert_eq!(
12183 landed["siteUrl"], "https://moved.example/",
12184 "{ctx}: {landed}"
12185 );
12186 assert_eq!(landed["fetchHint"], "daily", "{ctx}: {landed}");
12187 }
12188 }
12189 }
12190
12191 #[tokio::test]
12195 async fn a_retry_keeps_a_concurrent_retitle_when_the_reader_only_moved_it() {
12196 for backend in RACE_BACKENDS {
12197 let mut theirs = race_seed();
12198 theirs["title"] = serde_json::json!("Their title");
12199 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12200 value: race_seed(),
12201 concurrent: Some(theirs),
12202 ..SwapRepo::default()
12203 }));
12204 let state = race_state(backend, &repo).await;
12205
12206 let loc = post_race_rename_body(
12207 &state,
12208 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Old+title&folder=Tech&{SEEN_SEED}"),
12209 )
12210 .await;
12211
12212 let repo = repo.lock().unwrap();
12213 assert_eq!(loc, "/", "{backend:?}: {loc}");
12214 let landed = &repo.value;
12215 assert_eq!(
12216 landed["title"], "Their title",
12217 "{backend:?}: the reader's untouched title overwrote the other client's: {landed}"
12218 );
12219 assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12220 }
12221 }
12222
12223 #[tokio::test]
12227 async fn both_retitling_is_a_conflict_that_writes_nothing() {
12228 for backend in RACE_BACKENDS {
12229 let mut theirs = race_seed();
12230 theirs["title"] = serde_json::json!("Their title");
12231 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12232 value: race_seed(),
12233 concurrent: Some(theirs.clone()),
12234 ..SwapRepo::default()
12235 }));
12236 let state = race_state(backend, &repo).await;
12237
12238 let loc = post_race_rename_body(
12239 &state,
12240 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12241 )
12242 .await;
12243
12244 let repo = repo.lock().unwrap();
12245 assert!(
12246 loc.contains("changed%20elsewhere"),
12247 "{backend:?}: expected the conflict flash, got {loc}"
12248 );
12249 assert_eq!(
12250 repo.puts.len(),
12251 1,
12252 "{backend:?}: a conflicting retry was written: {:?}",
12253 repo.puts
12254 );
12255 assert_eq!(
12256 repo.value, theirs,
12257 "{backend:?}: their title was overwritten"
12258 );
12259 }
12260 }
12261
12262 #[tokio::test]
12269 async fn a_repoint_before_the_first_read_is_kept_when_the_reader_only_retitled() {
12270 for backend in RACE_BACKENDS {
12271 let mut moved = race_seed();
12272 moved["url"] = serde_json::json!("https://moved.example/feed.xml");
12273 moved["siteUrl"] = serde_json::json!("https://moved.example/");
12274 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12275 value: moved,
12276 ..SwapRepo::default()
12277 }));
12278 let state = race_state(backend, &repo).await;
12279
12280 let loc = post_race_rename_body(
12281 &state,
12282 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12283 )
12284 .await;
12285
12286 let repo = repo.lock().unwrap();
12287 assert_eq!(loc, "/", "{backend:?}: {loc}");
12288 assert_eq!(repo.puts.len(), 1, "{backend:?}");
12289 let landed = &repo.value;
12290 assert_eq!(
12291 landed["url"], "https://moved.example/feed.xml",
12292 "{backend:?}: the stale hidden url repointed the record: {landed}"
12293 );
12294 assert_eq!(landed["siteUrl"], "https://moved.example/", "{backend:?}");
12295 assert_eq!(landed["title"], "New title", "{backend:?}");
12296 }
12297 }
12298
12299 #[tokio::test]
12305 async fn a_rename_that_did_not_land_leaves_the_cache_alone() {
12306 const NEW_URL: &str = "https://other.example/feed.xml";
12307 const OLD_URL: &str = "https://example.com/feed.xml";
12308 for (refuse_every_swap, fail_puts, lands) in [
12310 (true, None, false),
12311 (false, Some((400, "InvalidRequest")), false),
12312 (false, Some((502, "UpstreamFailure")), false),
12313 (false, None, true),
12314 ] {
12315 for backend in RACE_BACKENDS {
12316 let ctx = format!("{backend:?} refuse={refuse_every_swap} fail={fail_puts:?}");
12317 for repoint in [false, true] {
12318 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12319 value: race_seed(),
12320 refuse_every_swap,
12321 fail_puts,
12322 ..SwapRepo::default()
12323 }));
12324 let state = race_state(backend, &repo).await;
12325 store::upsert_feed(
12326 &state.db,
12327 &store::NewFeed {
12328 url: OLD_URL.to_string(),
12329 title: Some("Cached title".to_string()),
12330 ..Default::default()
12331 },
12332 )
12333 .await
12334 .unwrap();
12335 let url = if repoint { NEW_URL } else { OLD_URL };
12336 let body = format!("url={}&title=New+title&{SEEN_SEED}", qenc(url));
12337
12338 let loc = post_race_rename_body(&state, &body).await;
12339
12340 let new_row = store::get_feed_by_url(&state.db, NEW_URL).await.unwrap();
12341 let old_row = store::get_feed_by_url(&state.db, OLD_URL)
12342 .await
12343 .unwrap()
12344 .expect("the old row");
12345 let ctx = format!("{ctx} repoint={repoint} -> {loc}");
12346 if lands {
12347 assert_eq!(loc, "/", "{ctx}");
12348 if repoint {
12349 assert!(
12350 new_row.is_some(),
12351 "{ctx}: a landed repoint got no cache row"
12352 );
12353 } else {
12354 assert_eq!(old_row.title.as_deref(), Some("New title"), "{ctx}");
12355 }
12356 } else {
12357 assert_ne!(loc, "/", "{ctx}");
12358 assert!(
12359 new_row.is_none(),
12360 "{ctx}: a repoint that did not land left a feeds row for its URL"
12361 );
12362 assert_eq!(
12363 old_row.title.as_deref(),
12364 Some("Cached title"),
12365 "{ctx}: a rename that did not land changed the cached title"
12366 );
12367 }
12368 }
12369 }
12370 }
12371 }
12372
12373 #[tokio::test]
12380 async fn a_rename_from_a_page_without_a_folder_select_keeps_the_folder() {
12381 for backend in RACE_BACKENDS {
12382 for raced in [false, true] {
12383 let mut seed = race_seed();
12384 seed["folder"] = serde_json::json!("at://did:plc:racer149/folder/kept");
12385 let concurrent = raced.then(|| {
12386 let mut theirs = seed.clone();
12387 theirs["folder"] = serde_json::json!("at://did:plc:racer149/folder/theirs");
12388 theirs
12389 });
12390 let want_folder = concurrent
12391 .as_ref()
12392 .map_or(seed["folder"].clone(), |t| t["folder"].clone());
12393 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12394 value: seed,
12395 concurrent,
12396 ..SwapRepo::default()
12397 }));
12398 let state = race_state(backend, &repo).await;
12399
12400 let loc = post_race_rename_body(
12402 &state,
12403 "url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12404 &seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12405 &seen_title=Old+title&title=New+title",
12406 )
12407 .await;
12408
12409 let repo = repo.lock().unwrap();
12410 let ctx = format!("{backend:?} raced={raced}");
12411 assert_eq!(loc, "/", "{ctx}: {loc}");
12412 assert_eq!(repo.value["title"], "New title", "{ctx}");
12413 assert_eq!(
12414 repo.value["folder"], want_folder,
12415 "{ctx}: a page that never showed a folder changed it: {}",
12416 repo.value
12417 );
12418 }
12419 }
12420 }
12421
12422 #[tokio::test]
12427 async fn a_double_submitted_rename_reports_success_and_writes_once() {
12428 for backend in RACE_BACKENDS {
12429 let mut first = race_seed();
12432 first["title"] = serde_json::json!("New title");
12433 first["folder"] = serde_json::json!("Tech");
12434 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12435 value: race_seed(),
12436 concurrent: Some(first.clone()),
12437 ..SwapRepo::default()
12438 }));
12439 let state = race_state(backend, &repo).await;
12440
12441 let loc = post_race_rename_body(
12442 &state,
12443 &format!(
12444 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech&{SEEN_SEED}"
12445 ),
12446 )
12447 .await;
12448
12449 let repo = repo.lock().unwrap();
12450 assert_eq!(
12451 loc, "/",
12452 "{backend:?}: a save that landed was reported as a conflict: {loc}"
12453 );
12454 assert_eq!(
12455 repo.puts.len(),
12456 1,
12457 "{backend:?}: only the refused put; the re-read has nothing left to write: {:?}",
12458 repo.puts
12459 );
12460 assert_eq!(repo.value, first, "{backend:?}");
12461 }
12462 }
12463
12464 fn folder_seed() -> serde_json::Value {
12470 serde_json::json!({
12471 "$type": crate::lexicon::nsid::FOLDER,
12472 "name": "Old name",
12473 "position": 3,
12474 "createdAt": "2024-01-01T00:00:00.000Z",
12475 "color": "#abc",
12476 })
12477 }
12478
12479 fn folder_repo(value: serde_json::Value) -> SwapRepo {
12481 SwapRepo {
12482 value,
12483 collection: Some(crate::lexicon::nsid::FOLDER),
12484 ..SwapRepo::default()
12485 }
12486 }
12487
12488 async fn post_folder_rename(state: &AppState, body: &str) -> String {
12490 let cookie = session_cookie(state, RACE_DID, None);
12491 let resp = router(state.clone())
12492 .oneshot(
12493 Request::builder()
12494 .method("POST")
12495 .uri("/folders/rk-keep/rename")
12496 .header(header::COOKIE, cookie)
12497 .header("content-type", "application/x-www-form-urlencoded")
12498 .body(Body::from(body.to_string()))
12499 .unwrap(),
12500 )
12501 .await
12502 .unwrap();
12503 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12504 resp.headers()
12505 .get(header::LOCATION)
12506 .unwrap()
12507 .to_str()
12508 .unwrap()
12509 .to_string()
12510 }
12511
12512 fn renamed(mut value: serde_json::Value, name: &str) -> serde_json::Value {
12514 value["name"] = serde_json::json!(name);
12515 value
12516 }
12517
12518 #[tokio::test]
12524 async fn renaming_a_folder_changes_only_its_name() {
12525 for backend in RACE_BACKENDS {
12526 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(folder_seed())));
12527 let state = race_state(backend, &repo).await;
12528
12529 let loc = post_folder_rename(&state, "name=New+name").await;
12530
12531 let repo = repo.lock().unwrap();
12532 assert_eq!(
12533 loc, "/",
12534 "{backend:?}: a landed rename was not reported as done"
12535 );
12536 assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12537 assert_eq!(
12538 repo.puts[0]["record"],
12539 renamed(folder_seed(), "New name"),
12540 "{backend:?}: the put did not keep the record whole: {}",
12541 repo.puts[0]
12542 );
12543 assert_eq!(
12544 repo.puts[0]["collection"],
12545 crate::lexicon::nsid::FOLDER,
12546 "{backend:?}"
12547 );
12548 assert_eq!(
12549 repo.puts[0]["swapRecord"], "bafyreiversion0",
12550 "{backend:?}: the put did not name the CID it read: {}",
12551 repo.puts[0]
12552 );
12553 }
12554 }
12555
12556 #[tokio::test]
12561 async fn a_folder_rename_that_loses_a_race_keeps_the_concurrent_edit() {
12562 for backend in RACE_BACKENDS {
12563 for with_seen in [true, false] {
12564 let mut theirs = folder_seed();
12565 theirs["position"] = serde_json::json!(7);
12566 theirs["icon"] = serde_json::json!("star");
12567 let mut fake = folder_repo(folder_seed());
12568 fake.concurrent = Some(theirs.clone());
12569 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12570 let state = race_state(backend, &repo).await;
12571
12572 let body = if with_seen {
12573 "name=New+name&seen_name=Old+name"
12574 } else {
12575 "name=New+name"
12576 };
12577 let loc = post_folder_rename(&state, body).await;
12578
12579 let repo = repo.lock().unwrap();
12580 let ctx = format!("{backend:?} with_seen={with_seen}");
12581 assert_eq!(
12582 loc, "/",
12583 "{ctx}: a converged rename was not reported as done"
12584 );
12585 assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12586 assert_eq!(repo.puts[0]["swapRecord"], "bafyreiversion0", "{ctx}");
12587 assert_eq!(
12588 repo.puts[1]["swapRecord"], "bafyreiversion1",
12589 "{ctx}: the retry did not name the RE-READ CID"
12590 );
12591 assert_eq!(
12592 repo.value,
12593 renamed(theirs, "New name"),
12594 "{ctx}: the concurrent edit was lost"
12595 );
12596 }
12597 }
12598 }
12599
12600 #[tokio::test]
12605 async fn both_renaming_a_folder_differently_is_a_conflict() {
12606 for backend in RACE_BACKENDS {
12607 for body in ["name=New+name&seen_name=Old+name", "name=New+name"] {
12608 let theirs = renamed(folder_seed(), "Their name");
12609 let mut fake = folder_repo(folder_seed());
12610 fake.concurrent = Some(theirs.clone());
12611 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12612 let state = race_state(backend, &repo).await;
12613
12614 let loc = post_folder_rename(&state, body).await;
12615
12616 let repo = repo.lock().unwrap();
12617 assert!(
12618 loc.contains("changed%20elsewhere"),
12619 "{backend:?} {body}: expected the conflict flash, got {loc}"
12620 );
12621 assert_eq!(
12622 repo.puts.len(),
12623 1,
12624 "{backend:?} {body}: only the refused put: {:?}",
12625 repo.puts
12626 );
12627 assert_eq!(
12628 repo.value, theirs,
12629 "{backend:?} {body}: the other client's name was overwritten"
12630 );
12631 }
12632 }
12633 }
12634
12635 #[tokio::test]
12638 async fn both_renaming_a_folder_the_same_is_success_without_a_write() {
12639 for backend in RACE_BACKENDS {
12640 let theirs = renamed(folder_seed(), "New name");
12641 let mut fake = folder_repo(folder_seed());
12642 fake.concurrent = Some(theirs.clone());
12643 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12644 let state = race_state(backend, &repo).await;
12645
12646 let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
12647
12648 let repo = repo.lock().unwrap();
12649 assert_eq!(
12650 loc, "/",
12651 "{backend:?}: agreement reported as a failure: {loc}"
12652 );
12653 assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12654 assert_eq!(repo.value, theirs, "{backend:?}");
12655 }
12656 }
12657
12658 #[tokio::test]
12661 async fn a_folder_rename_refused_on_every_swap_reports_the_conflict() {
12662 for backend in RACE_BACKENDS {
12663 let mut fake = folder_repo(folder_seed());
12664 fake.refuse_every_swap = true;
12665 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12666 let state = race_state(backend, &repo).await;
12667
12668 let loc = post_folder_rename(&state, "name=New+name").await;
12669
12670 let repo = repo.lock().unwrap();
12671 assert!(
12672 loc.contains("changed%20elsewhere"),
12673 "{backend:?}: expected the conflict flash, got {loc}"
12674 );
12675 assert_eq!(repo.puts.len(), 2, "{backend:?}: one try and one retry");
12676 assert_eq!(repo.value, folder_seed(), "{backend:?}");
12677 }
12678 }
12679
12680 #[tokio::test]
12683 async fn a_failed_folder_rename_shows_an_error() {
12684 for backend in RACE_BACKENDS {
12685 let mut fake = folder_repo(folder_seed());
12686 fake.fail_puts = Some((502, "UpstreamFailure"));
12687 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12688 let state = race_state(backend, &repo).await;
12689
12690 let loc = post_folder_rename(&state, "name=New+name").await;
12691
12692 let repo = repo.lock().unwrap();
12693 assert_ne!(loc, "/", "{backend:?}: a failed rename reported success");
12694 assert!(
12695 loc.contains("Could%20not%20save"),
12696 "{backend:?}: expected the save-failed flash, got {loc}"
12697 );
12698 assert!(!loc.contains("changed%20elsewhere"), "{backend:?}: {loc}");
12699 assert_eq!(
12700 repo.puts.len(),
12701 1,
12702 "{backend:?}: a non-swap failure was retried"
12703 );
12704 }
12705 }
12706
12707 #[tokio::test]
12710 async fn renaming_a_folder_that_no_longer_exists_writes_nothing() {
12711 for backend in RACE_BACKENDS {
12712 let mut fake = folder_repo(folder_seed());
12713 fake.missing = true;
12714 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12715 let state = race_state(backend, &repo).await;
12716
12717 let loc = post_folder_rename(&state, "name=New+name").await;
12718
12719 let repo = repo.lock().unwrap();
12720 assert!(
12721 loc.contains("no%20longer%20exists"),
12722 "{backend:?}: expected the missing-folder flash, got {loc}"
12723 );
12724 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12725 }
12726 }
12727
12728 #[tokio::test]
12731 async fn a_folder_rename_whose_read_fails_writes_nothing() {
12732 for backend in RACE_BACKENDS {
12733 let mut fake = folder_repo(folder_seed());
12734 fake.fail_list = true;
12735 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12736 let state = race_state(backend, &repo).await;
12737
12738 let loc = post_folder_rename(&state, "name=New+name").await;
12739
12740 let repo = repo.lock().unwrap();
12741 assert!(
12742 loc.contains("Could%20not%20reach"),
12743 "{backend:?}: expected the read-failed flash, got {loc}"
12744 );
12745 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12746 }
12747 }
12748
12749 #[tokio::test]
12754 async fn a_rename_elsewhere_after_page_load_is_a_conflict_with_seen_name() {
12755 for backend in RACE_BACKENDS {
12756 let theirs = renamed(folder_seed(), "Their name");
12757 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
12758 let state = race_state(backend, &repo).await;
12759
12760 let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
12761
12762 let repo = repo.lock().unwrap();
12763 assert!(
12764 loc.contains("changed%20elsewhere"),
12765 "{backend:?}: expected the conflict flash, got {loc}"
12766 );
12767 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12768 assert_eq!(repo.value, theirs, "{backend:?}");
12769 }
12770 }
12771
12772 #[tokio::test]
12775 async fn an_unchanged_folder_name_writes_nothing() {
12776 for backend in RACE_BACKENDS {
12777 let theirs = renamed(folder_seed(), "Their name");
12778 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
12779 let state = race_state(backend, &repo).await;
12780
12781 let loc = post_folder_rename(&state, "name=Old+name&seen_name=Old+name").await;
12782
12783 let repo = repo.lock().unwrap();
12784 assert_eq!(loc, "/", "{backend:?}");
12785 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12786 assert_eq!(repo.value, theirs, "{backend:?}");
12787 }
12788 }
12789
12790 #[test]
12792 fn merge_folder_rename_is_a_three_way_merge_on_the_name() {
12793 let base = Folder::new("Old", "2024-01-01T00:00:00.000Z");
12794 let with = |name: &str| {
12795 let mut f = base.clone();
12796 f.name = name.to_string();
12797 f.position = Some(3);
12798 f.extra
12799 .insert("color".to_string(), serde_json::json!("#abc"));
12800 f
12801 };
12802 assert_eq!(
12804 merge_folder_rename("Old", Some("Old"), &base, with("Other")),
12805 Ok(FolderMerge::Unchanged)
12806 );
12807 assert_eq!(
12809 merge_folder_rename("New", Some("Old"), &base, with("Old")),
12810 Ok(FolderMerge::Write(with("New")))
12811 );
12812 assert_eq!(
12814 merge_folder_rename("New", Some("Old"), &base, with("New")),
12815 Ok(FolderMerge::AlreadySaved)
12816 );
12817 assert_eq!(
12819 merge_folder_rename("New", Some("Old"), &base, with("Other")),
12820 Err(RenameConflict("name"))
12821 );
12822 assert_eq!(
12824 merge_folder_rename("New", None, &with("Other"), with("Other")),
12825 Ok(FolderMerge::Write(with("New")))
12826 );
12827 assert_eq!(
12828 merge_folder_rename("New", None, &base, with("Other")),
12829 Err(RenameConflict("name"))
12830 );
12831 assert_eq!(
12833 merge_folder_rename(" Old ", Some("Old "), &base, with("Other")),
12834 Ok(FolderMerge::Unchanged)
12835 );
12836 assert_eq!(
12837 merge_folder_rename("New ", Some("Old"), &base, with(" Old ")),
12838 Ok(FolderMerge::Write(with("New")))
12839 );
12840 }
12841
12842 #[test]
12847 fn the_same_change_on_both_sides_is_not_a_conflict() {
12848 let base = merge_base();
12849 let url = "https://a.example/feed.xml";
12850 let new_url = "https://c.example/feed.xml";
12851
12852 let mut fresh = base.clone();
12854 fresh.title = Some("New".to_string());
12855 let merged = merge_rename(&merge_form(url, "New", Some("at://f/old")), &base, fresh)
12856 .expect("same title is no conflict");
12857 assert!(merged.already_saved, "nothing left to write");
12858
12859 let mut fresh = base.clone();
12861 fresh.folder = Some("at://f/new".to_string());
12862 let merged = merge_rename(&merge_form(url, "Mine", Some("at://f/new")), &base, fresh)
12863 .expect("same folder is no conflict");
12864 assert!(!merged.already_saved, "the title is still to write");
12865 assert_eq!(merged.sub.title.as_deref(), Some("Mine"));
12866 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/new"));
12867
12868 let mut fresh = base.clone();
12871 fresh.url = new_url.to_string();
12872 fresh.site_url = Some("https://c.example/".to_string());
12873 let merged = merge_rename(
12874 &merge_form(new_url, "Old", Some("at://f/old")),
12875 &base,
12876 fresh,
12877 )
12878 .expect("same url is no conflict");
12879 assert!(merged.already_saved);
12880 assert!(!merged.repoint);
12881 assert_eq!(merged.sub.site_url.as_deref(), Some("https://c.example/"));
12882
12883 let mut fresh = base.clone();
12885 fresh.site_url = Some("https://same.example/".to_string());
12886 let mut form = merge_form(url, "Old", Some("at://f/old"));
12887 form.site_url = Some("https://same.example/".to_string());
12888 let merged = merge_rename(&form, &base, fresh).expect("same siteUrl is no conflict");
12889 assert!(merged.already_saved);
12890
12891 let merged = merge_rename(
12894 &merge_form(url, "Old", Some("at://f/old")),
12895 &base,
12896 base.clone(),
12897 )
12898 .unwrap();
12899 assert!(!merged.already_saved);
12900 }
12901
12902 fn merge_form(url: &str, title: &str, folder: Option<&str>) -> RenameSubForm {
12903 RenameSubForm {
12904 url: url.to_string(),
12905 title: Some(title.to_string()),
12906 site_url: None,
12907 folder: folder.map(str::to_string),
12908 seen_url: None,
12909 seen_title: None,
12910 seen_folder: None,
12911 }
12912 }
12913
12914 fn merge_base() -> Subscription {
12915 let mut s = Subscription::new("https://a.example/feed.xml", "2024-03-01T00:00:00.000Z");
12916 s.title = Some("Old".to_string());
12917 s.folder = Some("at://f/old".to_string());
12918 s.site_url = Some("https://a.example/".to_string());
12919 s
12920 }
12921
12922 #[test]
12926 fn merge_rename_is_a_three_way_merge_per_field() {
12927 let base = merge_base();
12928 let url = "https://a.example/feed.xml";
12929
12930 let mut theirs = base.clone();
12932 theirs.folder = Some("at://f/theirs".to_string());
12933 assert_eq!(
12934 merge_rename(
12935 &merge_form(url, "Old", Some("at://f/mine")),
12936 &base,
12937 theirs.clone()
12938 ),
12939 Err(RenameConflict("folder"))
12940 );
12941 let merged = merge_rename(
12942 &merge_form(url, "Old", Some("at://f/mine")),
12943 &base,
12944 base.clone(),
12945 )
12946 .unwrap();
12947 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/mine"));
12948 assert!(!merged.repoint);
12949 let merged =
12951 merge_rename(&merge_form(url, "Old", Some("at://f/old")), &base, theirs).unwrap();
12952 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/theirs"));
12953
12954 let mut moved = base.clone();
12956 moved.url = "https://b.example/feed.xml".to_string();
12957 assert_eq!(
12958 merge_rename(
12959 &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
12960 &base,
12961 moved
12962 ),
12963 Err(RenameConflict("url"))
12964 );
12965
12966 let mut resited = base.clone();
12968 resited.site_url = Some("https://theirs.example/".to_string());
12969 let mut form = merge_form(url, "Old", Some("at://f/old"));
12970 form.site_url = Some("https://mine.example/".to_string());
12971 assert_eq!(
12972 merge_rename(&form, &base, resited),
12973 Err(RenameConflict("siteUrl"))
12974 );
12975
12976 let merged = merge_rename(
12978 &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
12979 &base,
12980 base.clone(),
12981 )
12982 .unwrap();
12983 assert!(merged.repoint);
12984 assert_eq!(merged.sub.url, "https://c.example/feed.xml");
12985 assert_eq!(merged.sub.site_url, None);
12986
12987 let mut untitled = base.clone();
12990 untitled.title = None;
12991 let mut form = merge_form(url, "A display fallback", Some("at://f/old"));
12992 form.seen_title = Some("A display fallback".to_string());
12993 let merged = merge_rename(&form, &untitled, untitled.clone()).unwrap();
12994 assert_eq!(
12995 merged.sub.title, None,
12996 "an untouched display title was written"
12997 );
12998 }
12999
13000 #[tokio::test]
13022 async fn renaming_preserves_the_fields_the_form_never_carries() {
13023 let did = "did:plc:renamer4";
13024 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13025 let state = test_state_with_sidecar(&[did], &sidecar).await;
13026 let cookie = session_cookie(&state, did, None);
13027
13028 let resp = router(state.clone())
13029 .oneshot(
13030 Request::builder()
13031 .method("POST")
13032 .uri("/subscriptions/rk-keep/rename")
13033 .header(header::COOKIE, cookie)
13034 .header("content-type", "application/x-www-form-urlencoded")
13035 .body(Body::from(
13037 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
13038 ))
13039 .unwrap(),
13040 )
13041 .await
13042 .unwrap();
13043 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13044
13045 let bodies = puts.lock().unwrap().clone();
13046 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13047 let body = &bodies[0];
13048 assert!(
13050 body.contains("community.lexicon.rss.subscription"),
13051 "captured no usable put body: {body:?}"
13052 );
13053
13054 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
13055 let record = &sent["record"];
13056
13057 assert_eq!(record["title"], "New title", "the rename did not apply");
13059 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
13060
13061 assert_eq!(
13063 record["createdAt"], "2024-03-01T00:00:00.000Z",
13064 "the rename reset createdAt — the reader's subscribe time is gone \
13065 from their own repo, and nothing told them"
13066 );
13067 assert_eq!(
13068 record["siteUrl"], "https://example.com/blog",
13069 "the rename erased siteUrl"
13070 );
13071 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
13072 assert_eq!(record["private"], false, "the rename erased private");
13073 }
13074
13075 #[tokio::test]
13084 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
13085 let did = "did:plc:renamer4";
13086 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13087 let state = test_state_with_sidecar(&[did], &sidecar).await;
13088 let cookie = session_cookie(&state, did, None);
13089
13090 let resp = router(state.clone())
13091 .oneshot(
13092 Request::builder()
13093 .method("POST")
13094 .uri("/subscriptions/rk-keep/rename")
13095 .header(header::COOKIE, cookie)
13096 .header("content-type", "application/x-www-form-urlencoded")
13097 .body(Body::from(
13099 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
13100 ))
13101 .unwrap(),
13102 )
13103 .await
13104 .unwrap();
13105 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13106
13107 let bodies = puts.lock().unwrap().clone();
13108 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13109 assert!(
13110 bodies[0].contains("community.lexicon.rss.subscription"),
13111 "captured no usable put body: {:?}",
13112 bodies[0]
13113 );
13114 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13115 let record = &sent["record"];
13116
13117 assert_eq!(record["url"], "https://other.example/feed.xml");
13118 assert!(
13120 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
13121 "the old feed's site link followed the subscription to a new feed: {record}"
13122 );
13123 assert!(
13124 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
13125 "the old feed's fetch hint followed the subscription to a new feed: {record}"
13126 );
13127 assert_eq!(
13129 record["createdAt"], "2024-03-01T00:00:00.000Z",
13130 "a repoint is still not a new subscription; createdAt must not move"
13131 );
13132 assert_eq!(record["private"], false, "the repoint erased private");
13133 }
13134
13135 #[tokio::test]
13147 async fn renaming_an_unknown_rkey_writes_nothing() {
13148 let did = "did:plc:renamer4";
13149 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13151 let state = test_state_with_sidecar(&[did], &sidecar).await;
13152 let cookie = session_cookie(&state, did, None);
13153
13154 let resp = router(state.clone())
13155 .oneshot(
13156 Request::builder()
13157 .method("POST")
13158 .uri("/subscriptions/rk-does-not-exist/rename")
13160 .header(header::COOKIE, cookie)
13161 .header("content-type", "application/x-www-form-urlencoded")
13162 .body(Body::from(
13163 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
13164 ))
13165 .unwrap(),
13166 )
13167 .await
13168 .unwrap();
13169
13170 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13171 let loc = resp
13172 .headers()
13173 .get(header::LOCATION)
13174 .unwrap()
13175 .to_str()
13176 .unwrap();
13177 assert!(
13178 loc.contains("flash="),
13179 "an unknown rkey redirected as though the rename had worked: {loc}"
13180 );
13181 assert!(
13182 puts.lock().unwrap().is_empty(),
13183 "a rename against an unknown rkey wrote a record — putRecord would \
13184 CREATE it, dated today: {:?}",
13185 puts.lock().unwrap()
13186 );
13187 }
13188
13189 #[tokio::test]
13201 async fn a_client_supplied_site_url_reaches_the_record() {
13202 let did = "did:plc:renamer4";
13203 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13204 let state = test_state_with_sidecar(&[did], &sidecar).await;
13205 let cookie = session_cookie(&state, did, None);
13206
13207 let resp = router(state.clone())
13208 .oneshot(
13209 Request::builder()
13210 .method("POST")
13211 .uri("/subscriptions/rk-keep/rename")
13212 .header(header::COOKIE, cookie)
13213 .header("content-type", "application/x-www-form-urlencoded")
13214 .body(Body::from(
13217 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
13218 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
13219 ))
13220 .unwrap(),
13221 )
13222 .await
13223 .unwrap();
13224 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13225
13226 let bodies = puts.lock().unwrap().clone();
13227 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13228 assert!(
13229 bodies[0].contains("community.lexicon.rss.subscription"),
13230 "captured no usable put body: {:?}",
13231 bodies[0]
13232 );
13233 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13234 assert_eq!(
13235 sent["record"]["siteUrl"], "https://typed.example/site",
13236 "the client's siteUrl was dropped; the seeded record's survived instead"
13237 );
13238 }
13239
13240 #[tokio::test]
13248 async fn a_rename_whose_read_fails_writes_nothing() {
13249 let did = "did:plc:renamer5";
13250 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13252 let dead = format!("http://{}", listener.local_addr().unwrap());
13253 drop(listener);
13254
13255 let state = test_state_with_sidecar(&[did], &dead).await;
13256 let cookie = session_cookie(&state, did, None);
13257 let before = store::count_feeds(&state.db).await.unwrap();
13258
13259 let resp = router(state.clone())
13260 .oneshot(
13261 Request::builder()
13262 .method("POST")
13263 .uri("/subscriptions/rk-keep/rename")
13264 .header(header::COOKIE, cookie)
13265 .header("content-type", "application/x-www-form-urlencoded")
13266 .body(Body::from(
13267 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
13268 ))
13269 .unwrap(),
13270 )
13271 .await
13272 .unwrap();
13273
13274 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13275 let loc = resp
13276 .headers()
13277 .get(header::LOCATION)
13278 .unwrap()
13279 .to_str()
13280 .unwrap();
13281 assert!(
13282 loc.contains("flash="),
13283 "a failed read redirected as though the rename had worked: {loc}"
13284 );
13285 assert_eq!(
13286 store::count_feeds(&state.db).await.unwrap(),
13287 before,
13288 "a rename that could not read the record still wrote to the cache"
13289 );
13290 }
13291
13292 #[test]
13298 fn manage_rename_row_preselects_current_folder() {
13299 let nav = Nav {
13300 handle: "@reader.example".to_string(),
13301 avatar: "RE".to_string(),
13302 view: "unread".to_string(),
13303 scope_qs: String::new(),
13304 folders: Vec::new(),
13305 loose_feeds: Vec::new(),
13306 manage_active: true,
13307 };
13308 let folder_options = vec![
13309 FolderOption {
13310 uri: "at://did:plc:x/app.folder/work".to_string(),
13311 name: "Work".to_string(),
13312 },
13313 FolderOption {
13314 uri: "at://did:plc:x/app.folder/fun".to_string(),
13315 name: "Fun".to_string(),
13316 },
13317 ];
13318 let foldered = FeedView {
13321 rkey: "sub-foldered".to_string(),
13322 url: "https://work.example/feed.xml".to_string(),
13323 title: "Work Feed".to_string(),
13324 unread: 0,
13325 selected: false,
13326 folder: Some("at://did:plc:x/app.folder/work".to_string()),
13327 };
13328 let loose = FeedView {
13329 rkey: "sub-loose".to_string(),
13330 url: "https://loose.example/feed.xml".to_string(),
13331 title: "Loose Feed".to_string(),
13332 unread: 0,
13333 selected: false,
13334 folder: None,
13335 };
13336 let tmpl = ManageTemplate {
13337 card: Card::private(&Config::default()),
13338 version: VERSION,
13339 repo_url: REPO_URL,
13340 kofi_url: KOFI_URL,
13341 flash: String::new(),
13342 alert: String::new(),
13343 nav,
13344 folder_options,
13345 folders: vec![FolderView {
13346 rkey: "folder-work".to_string(),
13347 uri: "at://did:plc:x/app.folder/work".to_string(),
13348 name: "Work".to_string(),
13349 feeds: vec![foldered],
13350 selected: false,
13351 }],
13352 loose_feeds: vec![loose],
13353 standard_site: false,
13354 };
13355 let html = tmpl.render().unwrap();
13356
13357 assert!(
13359 html.contains(
13360 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
13361 ),
13362 "foldered feed must pre-select its current folder: {html}"
13363 );
13364 assert!(
13367 html.contains(r#"<option value="" selected>No folder</option>"#),
13368 "loose feed must pre-select 'No folder': {html}"
13369 );
13370
13371 for want in [
13374 r#"<input type="hidden" name="seen_url" value="https://work.example/feed.xml" />"#,
13375 r#"<input type="hidden" name="seen_title" value="Work Feed" />"#,
13376 r#"<input type="hidden" name="seen_folder" value="at://did:plc:x/app.folder/work" />"#,
13377 r#"<input type="hidden" name="seen_folder" value="" />"#,
13378 r#"<input type="hidden" name="seen_name" value="Work" />"#,
13380 ] {
13381 assert!(html.contains(want), "missing {want}: {html}");
13382 }
13383 }
13384
13385 #[tokio::test]
13391 async fn the_public_stats_page_exposes_no_user_data() {
13392 let state = test_state(&[]).await;
13393 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
13394 .await
13395 .unwrap();
13396
13397 let resp = router(state)
13398 .oneshot(
13399 Request::builder()
13400 .uri("/stats")
13401 .body(Body::empty())
13402 .unwrap(),
13403 )
13404 .await
13405 .unwrap();
13406 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
13407
13408 let body = String::from_utf8(
13409 axum::body::to_bytes(resp.into_body(), usize::MAX)
13410 .await
13411 .unwrap()
13412 .to_vec(),
13413 )
13414 .unwrap();
13415
13416 assert!(
13422 !body.contains("did:"),
13423 "the public stats page leaked an identifier"
13424 );
13425 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
13426 assert!(
13427 !body.contains(admin_only),
13428 "the public page is showing the admin metrics column {admin_only:?}"
13429 );
13430 }
13431 assert!(body.contains("Feeds tracked"));
13433 assert!(body.contains("Waiting to be polled"));
13434 }
13435
13436 #[tokio::test]
13444 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
13445 let state = test_state(&[]).await;
13446 for (url, errors) in [
13448 ("https://ok.example/f.xml", 0),
13449 ("https://flaky.example/f.xml", 2),
13450 ("https://dead.example/f.xml", 9),
13451 ] {
13452 store::upsert_feed(
13453 &state.db,
13454 &store::NewFeed {
13455 url: url.to_string(),
13456 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13459 ..Default::default()
13460 },
13461 )
13462 .await
13463 .unwrap();
13464 for _ in 0..errors {
13465 store::bump_feed_errors(
13466 &state.db,
13467 url,
13468 feed::FailureKind::Fetch,
13469 "connection refused",
13470 )
13471 .await
13472 .unwrap();
13473 }
13474 }
13475
13476 let render_stats = |state: AppState| async move {
13477 let resp = router(state)
13478 .oneshot(
13479 Request::builder()
13480 .uri("/stats")
13481 .body(Body::empty())
13482 .unwrap(),
13483 )
13484 .await
13485 .unwrap();
13486 assert_eq!(resp.status(), StatusCode::OK);
13487 String::from_utf8(
13488 axum::body::to_bytes(resp.into_body(), usize::MAX)
13489 .await
13490 .unwrap()
13491 .to_vec(),
13492 )
13493 .unwrap()
13494 };
13495
13496 state.runtime_health.set_schedulers_enabled(true);
13503 state
13504 .runtime_health
13505 .poll_tick_completed(crate::store::now_unix());
13506
13507 let body = render_stats(state.clone()).await;
13508 assert!(
13509 body.contains("Failing"),
13510 "backoff is still invisible on the public page"
13511 );
13512 assert!(
13516 body.contains("2, 1 badly"),
13517 "expected '2, 1 badly' in the failing row; got:\n{}",
13518 body.split("Failing")
13519 .nth(1)
13520 .unwrap_or("")
13521 .chars()
13522 .take(300)
13523 .collect::<String>()
13524 );
13525 assert!(
13533 !body.contains("the poller is not running")
13534 && !body.contains("the cache is at its size limit")
13535 && !body.contains("has not completed a round"),
13536 "expected the running state; the page reported a stopped one",
13537 );
13538
13539 state.runtime_health.set_watermark(true);
13543 let paused = render_stats(state.clone()).await;
13544 assert!(
13549 paused.contains("the cache is at its size limit"),
13550 "a watermark pause is still invisible on the public page"
13551 );
13552
13553 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
13555 assert!(
13556 !paused.contains(leak),
13557 "the public page leaked {leak:?} while reporting failures"
13558 );
13559 }
13560 }
13561
13562 #[tokio::test]
13574 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
13575 let admin = "did:plc:adminseed";
13576 let state = test_state(&[admin]).await;
13585 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
13586 .await
13587 .unwrap();
13588 let url = "https://broken.example/f.xml";
13589 store::upsert_feed(
13590 &state.db,
13591 &store::NewFeed {
13592 url: url.to_string(),
13593 ..Default::default()
13594 },
13595 )
13596 .await
13597 .unwrap();
13598 store::bump_feed_errors(
13599 &state.db,
13600 url,
13601 feed::FailureKind::Fetch,
13602 "SENTINEL_ADMIN_ONLY",
13603 )
13604 .await
13605 .unwrap();
13606
13607 let get = |state: AppState, cookie: Option<String>| async move {
13608 let mut req = Request::builder().uri("/admin/metrics");
13609 if let Some(c) = cookie {
13610 req = req.header(header::COOKIE, c);
13611 }
13612 let resp = router(state)
13613 .oneshot(req.body(Body::empty()).unwrap())
13614 .await
13615 .unwrap();
13616 let status = resp.status();
13617 let body = String::from_utf8(
13618 axum::body::to_bytes(resp.into_body(), usize::MAX)
13619 .await
13620 .unwrap()
13621 .to_vec(),
13622 )
13623 .unwrap();
13624 (status, body)
13625 };
13626
13627 let (status, body) = get(state.clone(), None).await;
13629 assert_eq!(status, StatusCode::UNAUTHORIZED);
13630 assert!(
13631 !body.contains("SENTINEL_ADMIN_ONLY"),
13632 "leaked to anonymous: {body}"
13633 );
13634
13635 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
13637 let (status, body) = get(state.clone(), Some(ordinary)).await;
13638 assert_eq!(
13639 status,
13640 StatusCode::FORBIDDEN,
13641 "a non-admin session was let in"
13642 );
13643 assert!(
13644 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
13645 "leaked to a non-admin: {body}",
13646 );
13647
13648 let admin_cookie = session_cookie(&state, admin, None);
13651 let (status, body) = get(state, Some(admin_cookie)).await;
13652 assert_eq!(status, StatusCode::OK);
13653 assert!(
13654 body.contains("SENTINEL_ADMIN_ONLY"),
13655 "admin cannot see it: {body}"
13656 );
13657 }
13658
13659 #[tokio::test]
13676 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
13677 let admin = "did:plc:adminseed";
13678 let state = test_state(&[admin]).await;
13679 let url = "https://broken.example/f.xml";
13680 store::upsert_feed(
13681 &state.db,
13682 &store::NewFeed {
13683 url: url.to_string(),
13684 ..Default::default()
13685 },
13686 )
13687 .await
13688 .unwrap();
13689 store::bump_feed_errors(
13690 &state.db,
13691 url,
13692 feed::FailureKind::Fetch,
13693 "SENTINEL_REDIRECT_NO_LOCATION",
13694 )
13695 .await
13696 .unwrap();
13697
13698 let cookie = session_cookie(&state, admin, None);
13699 let resp = router(state.clone())
13700 .oneshot(
13701 Request::builder()
13702 .uri("/admin/metrics")
13703 .header(header::COOKIE, cookie)
13704 .body(Body::empty())
13705 .unwrap(),
13706 )
13707 .await
13708 .unwrap();
13709 assert_eq!(resp.status(), StatusCode::OK);
13710 let admin_body = String::from_utf8(
13711 axum::body::to_bytes(resp.into_body(), usize::MAX)
13712 .await
13713 .unwrap()
13714 .to_vec(),
13715 )
13716 .unwrap();
13717 assert!(
13718 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
13719 "the admin page does not carry the failure detail: {admin_body}",
13720 );
13721 assert!(
13722 admin_body.contains("broken.example"),
13723 "the admin page does not name the failing feed: {admin_body}",
13724 );
13725
13726 let resp = router(state)
13728 .oneshot(
13729 Request::builder()
13730 .uri("/stats")
13731 .body(Body::empty())
13732 .unwrap(),
13733 )
13734 .await
13735 .unwrap();
13736 let public = String::from_utf8(
13737 axum::body::to_bytes(resp.into_body(), usize::MAX)
13738 .await
13739 .unwrap()
13740 .to_vec(),
13741 )
13742 .unwrap();
13743 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
13744 assert!(
13745 !public.contains(secret),
13746 "{secret:?} reached the PUBLIC stats page: {public}",
13747 );
13748 }
13749 }
13750
13751 #[tokio::test]
13764 async fn a_successful_direct_poll_clears_a_stale_failure() {
13765 let state = test_state(&[]).await;
13766 let url = "https://recovered.example/f.xml";
13767 store::upsert_feed(
13768 &state.db,
13769 &store::NewFeed {
13770 url: url.to_string(),
13771 ..Default::default()
13772 },
13773 )
13774 .await
13775 .unwrap();
13776 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
13777 .await
13778 .unwrap();
13779 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
13781 .bind(url)
13782 .execute(&state.db)
13783 .await
13784 .unwrap();
13785
13786 feed::settle_poll(
13788 &state.db,
13789 url,
13790 &feed::PollOutcome::NotModified,
13791 state.config.poll_interval,
13792 )
13793 .await;
13794
13795 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
13796 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
13797 )
13798 .bind(url)
13799 .fetch_one(&state.db)
13800 .await
13801 .unwrap();
13802 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
13803 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
13804 let next = row.2.expect("next_poll was cleared to NULL");
13808 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
13812 let delta = parsed
13813 .signed_duration_since(chrono::Utc::now())
13814 .num_seconds();
13815 let cadence = state.config.poll_interval.as_secs() as i64;
13816 assert!(
13817 (cadence - 60..=cadence + 60).contains(&delta),
13818 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
13819 );
13820 }
13821
13822 #[tokio::test]
13828 async fn a_failing_direct_poll_is_recorded() {
13829 let state = test_state(&[]).await;
13830 let url = "https://born-broken.example/f.xml";
13831 store::upsert_feed(
13832 &state.db,
13833 &store::NewFeed {
13834 url: url.to_string(),
13835 ..Default::default()
13836 },
13837 )
13838 .await
13839 .unwrap();
13840
13841 feed::settle_poll(
13842 &state.db,
13843 url,
13844 &feed::PollOutcome::Failed {
13845 backoff: std::time::Duration::from_secs(300),
13846 kind: feed::FailureKind::Parse,
13847 detail: "SENTINEL_BORN_BROKEN".to_string(),
13848 },
13849 state.config.poll_interval,
13850 )
13851 .await;
13852
13853 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
13854 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
13855 )
13856 .bind(url)
13857 .fetch_one(&state.db)
13858 .await
13859 .unwrap();
13860 assert_eq!(row.0, 1, "a failed first poll was not counted");
13861 assert_eq!(
13862 row.1.as_deref(),
13863 Some("parse"),
13864 "its cause was not recorded"
13865 );
13866 let next = row.2.expect("a failed direct poll left next_poll NULL");
13870 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
13871 let delta = parsed
13872 .signed_duration_since(chrono::Utc::now())
13873 .num_seconds();
13874 assert!(
13875 (240..=360).contains(&delta),
13876 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
13877 );
13878 }
13879
13880 #[tokio::test]
13892 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
13893 let state = test_state(&[]).await;
13894 for url in [
13896 "https://legacy1.example/f.xml",
13897 "https://legacy2.example/f.xml",
13898 ] {
13899 store::upsert_feed(
13900 &state.db,
13901 &store::NewFeed {
13902 url: url.to_string(),
13903 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13904 ..Default::default()
13905 },
13906 )
13907 .await
13908 .unwrap();
13909 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
13910 .bind(url)
13911 .execute(&state.db)
13912 .await
13913 .unwrap();
13914 }
13915 store::upsert_feed(
13917 &state.db,
13918 &store::NewFeed {
13919 url: "https://known.example/f.xml".to_string(),
13920 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13921 ..Default::default()
13922 },
13923 )
13924 .await
13925 .unwrap();
13926 store::bump_feed_errors(
13927 &state.db,
13928 "https://known.example/f.xml",
13929 feed::FailureKind::Status,
13930 "SENTINEL",
13931 )
13932 .await
13933 .unwrap();
13934
13935 let now = chrono::Utc::now();
13936 let health = store::poll_health(
13937 &state.db,
13938 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13939 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13940 )
13941 .await
13942 .unwrap();
13943 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
13944 assert_eq!(
13945 counted, health.in_backoff,
13946 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
13947 health.in_backoff, health.failure_kinds,
13948 );
13949 assert!(
13950 health
13951 .failure_kinds
13952 .iter()
13953 .any(|(k, n)| k == "unknown" && *n == 2),
13954 "no unknown bucket for the legacy rows: {:?}",
13955 health.failure_kinds,
13956 );
13957 }
13958
13959 #[tokio::test]
13964 async fn the_failure_breakdown_is_ordered_by_count() {
13965 let state = test_state(&[]).await;
13966 for (url, kind, n) in [
13967 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
13968 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
13969 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
13970 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
13971 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
13972 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
13973 ] {
13974 store::upsert_feed(
13975 &state.db,
13976 &store::NewFeed {
13977 url: url.to_string(),
13978 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13979 ..Default::default()
13980 },
13981 )
13982 .await
13983 .unwrap();
13984 for _ in 0..n {
13985 store::bump_feed_errors(&state.db, url, kind, "d")
13986 .await
13987 .unwrap();
13988 }
13989 }
13990 let now = chrono::Utc::now();
13991 let health = store::poll_health(
13992 &state.db,
13993 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13994 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13995 )
13996 .await
13997 .unwrap();
13998 let labels: Vec<&str> = health
13999 .failure_kinds
14000 .iter()
14001 .map(|(k, _)| k.as_str())
14002 .collect();
14003 assert_eq!(
14004 labels,
14005 ["fetch", "status", "parse"],
14006 "not ordered by count, descending: {:?}",
14007 health.failure_kinds,
14008 );
14009 }
14010
14011 #[tokio::test]
14023 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
14024 let state = test_state(&[]).await;
14025 for (url, kind, detail, errors) in [
14026 (
14032 "https://a.example/f.xml",
14033 feed::FailureKind::Fetch,
14034 "SENTINEL_CONNREFUSED",
14035 3,
14036 ),
14037 (
14038 "https://b.example/f.xml",
14039 feed::FailureKind::Fetch,
14040 "SENTINEL_DNSFAIL",
14041 2,
14042 ),
14043 (
14044 "https://c.example/f.xml",
14045 feed::FailureKind::Status,
14046 "SENTINEL_404",
14047 1,
14048 ),
14049 (
14050 "https://d.example/f.xml",
14051 feed::FailureKind::Parse,
14052 "SENTINEL_UNPARSEABLE",
14053 1,
14054 ),
14055 ] {
14056 store::upsert_feed(
14057 &state.db,
14058 &store::NewFeed {
14059 url: url.to_string(),
14060 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14061 ..Default::default()
14062 },
14063 )
14064 .await
14065 .unwrap();
14066 for _ in 0..errors {
14067 store::bump_feed_errors(&state.db, url, kind, detail)
14068 .await
14069 .unwrap();
14070 }
14071 }
14072
14073 let resp = router(state.clone())
14074 .oneshot(
14075 Request::builder()
14076 .uri("/stats")
14077 .body(Body::empty())
14078 .unwrap(),
14079 )
14080 .await
14081 .unwrap();
14082 assert_eq!(resp.status(), StatusCode::OK);
14083 let body = String::from_utf8(
14084 axum::body::to_bytes(resp.into_body(), usize::MAX)
14085 .await
14086 .unwrap()
14087 .to_vec(),
14088 )
14089 .unwrap();
14090
14091 assert!(
14093 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
14094 "the cause histogram did not render: {body}",
14095 );
14096
14097 for secret in [
14100 "a.example",
14101 "b.example",
14102 "c.example",
14103 "d.example",
14104 "SENTINEL_CONNREFUSED",
14105 "SENTINEL_DNSFAIL",
14106 "SENTINEL_404",
14107 "SENTINEL_UNPARSEABLE",
14108 ] {
14109 assert!(
14110 !body.contains(secret),
14111 "{secret:?} reached the PUBLIC stats page: {body}",
14112 );
14113 }
14114 }
14115
14116 #[tokio::test]
14119 async fn health_checks_the_database_and_reports_the_loops() {
14120 let state = test_state(&[]).await;
14121 let body_of = |state: AppState| async move {
14122 let resp = router(state)
14123 .oneshot(
14124 Request::builder()
14125 .uri("/health")
14126 .body(Body::empty())
14127 .unwrap(),
14128 )
14129 .await
14130 .unwrap();
14131 let status = resp.status();
14132 let body = String::from_utf8(
14133 axum::body::to_bytes(resp.into_body(), usize::MAX)
14134 .await
14135 .unwrap()
14136 .to_vec(),
14137 )
14138 .unwrap();
14139 (status, body)
14140 };
14141
14142 state
14145 .runtime_health
14146 .set_started_at(chrono::Utc::now().timestamp());
14147
14148 let (status, body) = body_of(state.clone()).await;
14149 assert_eq!(status, StatusCode::OK);
14150 assert!(
14151 body.contains("db: ok"),
14152 "health did not probe the DB: {body}"
14153 );
14154 assert!(
14155 body.contains("uptime:"),
14156 "no uptime — the first thing anyone asks about a container that may \
14157 be restarting: {body}"
14158 );
14159 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
14160 assert!(body.contains("polling-paused: no"), "{body}");
14161 assert!(body.contains("backend:"), "{body}");
14162 assert!(body.contains("oauth-runtime:"), "{body}");
14163
14164 state.runtime_health.set_watermark(true);
14169 state.runtime_health.set_schedulers_enabled(true);
14170 let (status, body) = body_of(state.clone()).await;
14171 assert_eq!(
14172 status,
14173 StatusCode::OK,
14174 "a watermark pause must not fail the liveness check: {body}"
14175 );
14176 assert!(body.contains("polling-paused: yes"), "{body}");
14177 assert!(
14180 body.contains("poller: not-yet-ticked"),
14181 "a never-ticked poller must say so: {body}"
14182 );
14183
14184 let stale_after = health_tick_stale_secs(configured_poll_tick());
14186 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
14187 state.runtime_health.poll_tick_completed(long_ago);
14188 let (status, body) = body_of(state.clone()).await;
14189 assert_eq!(
14190 status,
14191 StatusCode::OK,
14192 "a stale poller must not 503: {body}"
14193 );
14194 assert!(body.contains("poller: stale"), "{body}");
14195
14196 state.runtime_health.poll_tick_completed(0); state
14204 .runtime_health
14205 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
14206 let (status, body) = body_of(state.clone()).await;
14207 assert_eq!(status, StatusCode::OK);
14208 assert!(
14209 body.contains("poller: stale never-ticked"),
14210 "a poller that never ticked long after boot still reads as benign: {body}"
14211 );
14212
14213 state.db.close().await;
14216 let (status, body) = body_of(state.clone()).await;
14217 assert_eq!(
14218 status,
14219 StatusCode::SERVICE_UNAVAILABLE,
14220 "an unreachable database must fail the check: {body}"
14221 );
14222 assert!(body.starts_with("FAIL"), "{body}");
14223 assert!(
14227 !body.contains("PoolClosed") && !body.contains("sqlx"),
14228 "health leaked the raw database error to an unauthenticated caller: {body}"
14229 );
14230 }
14231
14232 #[test]
14238 fn the_stale_threshold_follows_the_poll_tick() {
14239 assert_eq!(
14242 health_tick_stale_secs(Duration::from_secs(60)),
14243 HEALTH_TICK_STALE_FLOOR_SECS
14244 );
14245 let slow = Duration::from_secs(30 * 60);
14248 assert!(
14249 health_tick_stale_secs(slow) > slow.as_secs() as i64,
14250 "a 30-minute tick must not be stale after one interval"
14251 );
14252 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
14253 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
14255 }
14256
14257 #[tokio::test]
14263 async fn stats_does_not_call_a_stopped_poller_running() {
14264 let state = test_state(&[]).await;
14265 let render = |state: AppState| async move {
14266 let resp = router(state)
14267 .oneshot(
14268 Request::builder()
14269 .uri("/stats")
14270 .body(Body::empty())
14271 .unwrap(),
14272 )
14273 .await
14274 .unwrap();
14275 assert_eq!(resp.status(), StatusCode::OK);
14276 String::from_utf8(
14277 axum::body::to_bytes(resp.into_body(), usize::MAX)
14278 .await
14279 .unwrap()
14280 .to_vec(),
14281 )
14282 .unwrap()
14283 };
14284
14285 let body = render(state.clone()).await;
14287 assert!(
14288 body.contains("the poller is not running on this instance"),
14289 "a disabled poller renders as healthy"
14290 );
14291
14292 state.runtime_health.set_schedulers_enabled(true);
14294 let body = render(state.clone()).await;
14295 assert!(
14296 body.contains("no poll has finished since this instance booted"),
14297 "a poller that has not ticked renders as healthy"
14298 );
14299
14300 state
14302 .runtime_health
14303 .poll_tick_completed(chrono::Utc::now().timestamp());
14304 let body = render(state.clone()).await;
14305 assert!(
14306 body.contains("running"),
14307 "a healthy poller must read as running"
14308 );
14309
14310 state.runtime_health.set_watermark(true);
14312 let body = render(state.clone()).await;
14313 assert!(
14314 body.contains("the cache is at its size limit"),
14315 "a watermark pause is hidden once the poller is ticking"
14316 );
14317 }
14318
14319 #[tokio::test]
14330 async fn health_reports_an_unmeasured_database_without_failing() {
14331 use crate::runtime_health::DbProbe;
14332 let state = test_state(&[]).await;
14333
14334 let held = state
14337 .runtime_health
14338 .begin_db_probe()
14339 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
14340
14341 let resp = router(state.clone())
14342 .oneshot(
14343 Request::builder()
14344 .uri("/health")
14345 .body(Body::empty())
14346 .unwrap(),
14347 )
14348 .await
14349 .unwrap();
14350 let status = resp.status();
14351 let body = String::from_utf8(
14352 axum::body::to_bytes(resp.into_body(), usize::MAX)
14353 .await
14354 .unwrap()
14355 .to_vec(),
14356 )
14357 .unwrap();
14358 drop(held);
14359
14360 assert_eq!(
14361 status,
14362 StatusCode::OK,
14363 "an unmeasured database failed the check, which an unauthenticated \
14364 caller can cause on demand: {body}"
14365 );
14366 assert!(
14367 body.contains("db: unknown"),
14368 "the unmeasured state must still be REPORTED: {body}"
14369 );
14370 assert!(!body.starts_with("FAIL"), "{body}");
14371 assert!(
14376 !body.starts_with("ok"),
14377 "the unmeasured state is indistinguishable from healthy to a \
14378 body-matching monitor: {body}"
14379 );
14380 assert!(body.starts_with("unknown"), "{body}");
14381
14382 let held = state
14393 .runtime_health
14394 .begin_db_probe()
14395 .unwrap_or_else(|_| panic!("claim"));
14396 state
14397 .runtime_health
14398 .record_for_test(DbProbe::Failed("unavailable".to_string()));
14399 let resp = router(state.clone())
14400 .oneshot(
14401 Request::builder()
14402 .uri("/health")
14403 .body(Body::empty())
14404 .unwrap(),
14405 )
14406 .await
14407 .unwrap();
14408 let status = resp.status();
14409 let body = String::from_utf8(
14410 axum::body::to_bytes(resp.into_body(), usize::MAX)
14411 .await
14412 .unwrap()
14413 .to_vec(),
14414 )
14415 .unwrap();
14416 drop(held);
14417 assert_eq!(
14418 status,
14419 StatusCode::SERVICE_UNAVAILABLE,
14420 "a BORROWED failure verdict must fail the check, not just a freshly \
14421 measured one: {body}"
14422 );
14423 assert!(body.starts_with("FAIL"), "{body}");
14424
14425 state.db.close().await;
14426 let resp = router(state.clone())
14427 .oneshot(
14428 Request::builder()
14429 .uri("/health")
14430 .body(Body::empty())
14431 .unwrap(),
14432 )
14433 .await
14434 .unwrap();
14435 assert_eq!(
14436 resp.status(),
14437 StatusCode::SERVICE_UNAVAILABLE,
14438 "a measured database failure must still fail the check"
14439 );
14440 }
14441
14442 #[tokio::test]
14451 async fn an_abandoned_request_still_records_its_probe() {
14452 use crate::runtime_health::DbProbe;
14453 let state = test_state(&[]).await;
14454 let rh = state.runtime_health.clone();
14455
14456 let app = router(state.clone());
14458 let fut = app.oneshot(
14459 Request::builder()
14460 .uri("/health")
14461 .body(Body::empty())
14462 .unwrap(),
14463 );
14464 let handle = tokio::spawn(fut);
14465 handle.abort();
14466 let _ = handle.await;
14467
14468 for _ in 0..50 {
14471 if rh.begin_db_probe().is_ok() {
14472 break;
14473 }
14474 tokio::time::sleep(Duration::from_millis(20)).await;
14475 }
14476 let resp = router(state.clone())
14477 .oneshot(
14478 Request::builder()
14479 .uri("/health")
14480 .body(Body::empty())
14481 .unwrap(),
14482 )
14483 .await
14484 .unwrap();
14485 let body = String::from_utf8(
14486 axum::body::to_bytes(resp.into_body(), usize::MAX)
14487 .await
14488 .unwrap()
14489 .to_vec(),
14490 )
14491 .unwrap();
14492 assert!(
14493 body.contains("db: ok"),
14494 "after an abandoned request the next caller still reads an \
14495 unmeasured database — the probe was cancelled with it: {body}"
14496 );
14497 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
14499 }
14500
14501 #[tokio::test]
14508 async fn the_health_probe_opens_a_real_table() {
14509 use sqlx::Row;
14510 let state = test_state(&[]).await;
14511 let opcodes = |sql: &'static str| {
14513 let db = state.db.clone();
14514 async move {
14515 sqlx::query(sql)
14516 .fetch_all(&db)
14517 .await
14518 .unwrap()
14519 .into_iter()
14520 .map(|r| r.get::<String, _>("opcode"))
14521 .collect::<Vec<String>>()
14522 }
14523 };
14524
14525 let explain: &'static str =
14528 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
14529 let probe = opcodes(explain).await;
14530 assert!(
14532 health_db_probe(&state.db).await.is_ok(),
14533 "the probe does not run against the real schema",
14534 );
14535 assert!(
14536 probe.iter().any(|op| op == "OpenRead"),
14537 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
14538 );
14539 let bare = opcodes("EXPLAIN SELECT 1").await;
14541 assert!(
14542 !bare.iter().any(|op| op == "OpenRead"),
14543 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
14544 );
14545 }
14546
14547 #[test]
14550 fn an_instance_that_has_never_polled_says_so() {
14551 assert_eq!(humanise_ago(None), "never");
14552 assert_eq!(humanise_ago(Some(0)), "0s ago");
14553 assert_eq!(humanise_ago(Some(59)), "59s ago");
14554 assert_eq!(humanise_ago(Some(60)), "1m ago");
14555 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
14556 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
14557 }
14558
14559 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
14562 use tokio::io::{AsyncReadExt, AsyncWriteExt};
14563 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14564 let addr = listener.local_addr().unwrap();
14565 let (url, title) = (saved_url.to_string(), saved_title.to_string());
14566 tokio::spawn(async move {
14567 loop {
14568 let Ok((mut sock, _)) = listener.accept().await else {
14569 break;
14570 };
14571 let mut buf = vec![0u8; 8192];
14572 let Ok(n) = sock.read(&mut buf).await else {
14573 continue;
14574 };
14575 let req = String::from_utf8_lossy(&buf[..n]).to_string();
14576 let wants_saved = req.contains("community.lexicon.rss.saved");
14577 let records = if wants_saved {
14578 serde_json::json!([{
14579 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
14580 "cid": "bafy",
14581 "value": {
14582 "$type": "community.lexicon.rss.saved",
14583 "url": url,
14584 "title": title,
14585 "createdAt": "2026-01-01T00:00:00Z"
14586 }
14587 }])
14588 } else {
14589 serde_json::json!([])
14590 };
14591 let body = serde_json::json!({
14592 "ok": true, "data": { "records": records }
14593 })
14594 .to_string();
14595 let resp = format!(
14596 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
14597 body.len(), body
14598 );
14599 let _ = sock.write_all(resp.as_bytes()).await;
14600 let _ = sock.flush().await;
14601 }
14602 });
14603 format!("http://{addr}")
14604 }
14605
14606 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
14609 let feed = subscribed_feed.to_string();
14610 use tokio::io::{AsyncReadExt, AsyncWriteExt};
14611 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14612 let addr = listener.local_addr().unwrap();
14613 tokio::spawn(async move {
14614 loop {
14615 let Ok((mut sock, _)) = listener.accept().await else {
14616 break;
14617 };
14618 let mut buf = vec![0u8; 8192];
14619 let Ok(read) = sock.read(&mut buf).await else {
14620 continue;
14621 };
14622 let req = String::from_utf8_lossy(&buf[..read]).to_string();
14623 let records = if req.contains("community.lexicon.rss.saved") {
14624 serde_json::Value::Array(
14625 (0..n)
14626 .map(|i| {
14627 serde_json::json!({
14628 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
14629 "cid": "bafy",
14630 "value": {
14631 "$type": "community.lexicon.rss.saved",
14632 "url": format!("https://elsewhere.example/{i}"),
14633 "title": format!("Elsewhere {i}"),
14634 "createdAt": "2026-01-01T00:00:00Z"
14635 }
14636 })
14637 })
14638 .collect(),
14639 )
14640 } else if req.contains("community.lexicon.rss.subscription") {
14641 serde_json::json!([{
14646 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
14647 "cid": "bafy",
14648 "value": {
14649 "$type": "community.lexicon.rss.subscription",
14650 "url": feed,
14651 "createdAt": "2026-01-01T00:00:00Z"
14652 }
14653 }])
14654 } else {
14655 serde_json::json!([])
14656 };
14657 let body =
14658 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
14659 let resp = format!(
14660 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
14661 body.len(), body
14662 );
14663 let _ = sock.write_all(resp.as_bytes()).await;
14664 let _ = sock.flush().await;
14665 }
14666 });
14667 format!("http://{addr}")
14668 }
14669
14670 #[tokio::test]
14678 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
14679 let did = "did:plc:pagerloop";
14680 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
14681 let state = test_state_with_sidecar(&[], &sidecar).await;
14682 store::grant_access(&state.db, did, None, "test", None)
14683 .await
14684 .unwrap();
14685 let feed = store::upsert_feed(
14686 &state.db,
14687 &store::NewFeed {
14688 url: "https://loop.example/feed.xml".to_string(),
14689 title: Some("Loop".to_string()),
14690 ..Default::default()
14691 },
14692 )
14693 .await
14694 .unwrap();
14695 let entries: Vec<store::NewEntry> = (0..250)
14698 .map(|i| store::NewEntry {
14699 guid: format!("s-{i:04}"),
14700 url: Some(format!("https://loop.example/{i}")),
14701 title: Some(format!("Starred {i:04}")),
14702 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
14703 ..Default::default()
14704 })
14705 .collect();
14706 store::insert_entries(&state.db, feed, &entries, 0)
14707 .await
14708 .unwrap();
14709 store::replace_sub_refs(&state.db, did, &[feed])
14710 .await
14711 .unwrap();
14712 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
14713 .await
14714 .unwrap()
14715 {
14716 store::mark_starred(&state.db, did, row.id, true)
14717 .await
14718 .unwrap();
14719 }
14720
14721 let cookie = session_cookie(&state, did, None);
14722 let app = router(state.clone());
14723 let get = |uri: &str| {
14724 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
14725 async move {
14726 let resp = app
14727 .oneshot(
14728 Request::builder()
14729 .uri(uri)
14730 .header(header::COOKIE, cookie)
14731 .body(Body::empty())
14732 .unwrap(),
14733 )
14734 .await
14735 .unwrap();
14736 assert_eq!(resp.status(), StatusCode::OK);
14737 String::from_utf8(
14738 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
14739 .await
14740 .unwrap()
14741 .to_vec(),
14742 )
14743 .unwrap()
14744 }
14745 };
14746
14747 let p3 = get("/?view=starred&page=3").await;
14752 assert!(
14753 p3.contains("Page 3 of 4"),
14754 "the pager and the clamp disagree on the total: {}",
14755 p3.split("pager-pos")
14756 .nth(1)
14757 .unwrap_or("")
14758 .chars()
14759 .take(120)
14760 .collect::<String>()
14761 );
14762 assert!(
14765 p3.contains("Elsewhere 0"),
14766 "page 3 should start the uncached run"
14767 );
14768 assert_eq!(
14769 p3.matches("<li class=\"entry").count(),
14770 ENTRIES_PER_PAGE as usize,
14771 "the boundary page is not full"
14772 );
14773
14774 {
14783 let body = &p3;
14784 assert!(
14785 body.contains("330 entries"),
14786 "the heading must count the whole sequence: {}",
14787 body.split("content-count")
14788 .nth(1)
14789 .unwrap_or("")
14790 .chars()
14791 .take(120)
14792 .collect::<String>()
14793 );
14794 assert!(
14795 body.contains("(80 saved elsewhere)"),
14796 "the heading must say how many of the total the cache cannot show, \
14797 as a whole-list figure and not a per-page one: {}",
14798 body.split("content-count")
14799 .nth(1)
14800 .unwrap_or("")
14801 .chars()
14802 .take(120)
14803 .collect::<String>()
14804 );
14805 assert!(
14806 !body.contains("plus 50") && !body.contains("plus 80"),
14807 "the heading is adding the uncached rows to a total that already \
14808 includes them"
14809 );
14810 }
14811
14812 let p4 = get("/?view=starred&page=4").await;
14813 assert!(
14814 p4.contains("Page 4 of 4"),
14815 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
14816 );
14817 assert_eq!(
14818 p4.matches("<li class=\"entry").count(),
14819 30,
14820 "page 4 should hold the remaining 30 uncached records"
14821 );
14822 assert!(
14823 p4.contains("Elsewhere 79"),
14824 "the LAST saved record is unreachable — it can only be removed from here"
14825 );
14826
14827 assert!(
14829 !p4.contains("Elsewhere 0"),
14830 "an uncached record was rendered on more than one page"
14831 );
14832 let first = get("/?view=starred").await;
14835 assert!(
14836 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
14837 "the heading changed between pages; it describes the list, not the page"
14838 );
14839 assert!(
14840 !first.contains("Elsewhere "),
14841 "uncached saved records leaked onto the first page"
14842 );
14843 }
14844
14845 #[tokio::test]
14852 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
14853 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
14854 let sidecar =
14855 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
14856 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
14857 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
14858
14859 let resp = router(state)
14860 .oneshot(
14861 Request::builder()
14862 .uri("/?view=starred")
14863 .body(Body::empty())
14864 .unwrap(),
14865 )
14866 .await
14867 .unwrap();
14868 assert_eq!(resp.status(), StatusCode::OK);
14869 let body = String::from_utf8(
14870 axum::body::to_bytes(resp.into_body(), usize::MAX)
14871 .await
14872 .unwrap()
14873 .to_vec(),
14874 )
14875 .unwrap();
14876
14877 assert!(
14878 body.contains("Starred elsewhere"),
14879 "the saved record was not rendered at all"
14880 );
14881 assert!(
14882 body.contains("entry-uncached"),
14883 "it was not marked as uncached, so it looks like a normal entry"
14884 );
14885 assert!(
14886 body.contains("https://elsewhere.example/article"),
14887 "the row must link straight to the article"
14888 );
14889 assert!(
14890 !body.contains("/entries/0/"),
14891 "an uncached row must not offer entry actions against a nonexistent id"
14892 );
14893 }
14894
14895 #[test]
14903 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
14904 for hostile in [
14905 "日本語日本語日本",
14906 "é",
14907 "",
14908 "2026",
14909 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
14910 ] {
14911 let out = display_date(Some(hostile));
14912 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
14913 }
14914 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
14915 assert_eq!(display_date(None), "");
14916 }
14917
14918 #[test]
14921 fn the_unsave_route_is_rate_limited() {
14922 use axum::http::Method;
14923 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
14924 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
14926 }
14927
14928 #[tokio::test]
14937 async fn health_reports_a_broken_database() {
14938 let state = test_state(&[]).await;
14939 assert!(
14941 health_db_probe(&state.db).await.is_ok(),
14942 "the fixture was not healthy to begin with",
14943 );
14944
14945 sqlx::query("DROP TABLE feeds")
14946 .execute(&state.db)
14947 .await
14948 .unwrap();
14949
14950 assert!(
14951 health_db_probe(&state.db).await.is_err(),
14952 "the probe reported success against a database missing the table it \
14953 claims to read; `SELECT 1` would do exactly this",
14954 );
14955
14956 let resp = router(state)
14957 .oneshot(
14958 Request::builder()
14959 .uri("/health")
14960 .body(Body::empty())
14961 .unwrap(),
14962 )
14963 .await
14964 .unwrap();
14965 let body = String::from_utf8(
14966 axum::body::to_bytes(resp.into_body(), usize::MAX)
14967 .await
14968 .unwrap()
14969 .to_vec(),
14970 )
14971 .unwrap();
14972 assert!(
14974 body.starts_with("FAIL"),
14975 "/health did not report FAIL for a broken database: {body}",
14976 );
14977 assert!(
14978 !body.contains("db: ok"),
14979 "/health still called the database ok: {body}",
14980 );
14981 }
14982
14983 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
14988 use tokio::io::{AsyncReadExt, AsyncWriteExt};
14989 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14990 let addr = listener.local_addr().unwrap();
14991 tokio::spawn(async move {
14992 loop {
14993 let Ok((mut sock, _)) = listener.accept().await else {
14994 break;
14995 };
14996 let mut buf = vec![0u8; 8192];
14997 let Ok(n) = sock.read(&mut buf).await else {
14998 continue;
14999 };
15000 let req = String::from_utf8_lossy(&buf[..n]).to_string();
15001 let wants = |c: &str| req.contains(c);
15002 if fail_on.is_some_and(wants) {
15003 let body = r#"{"ok":false,"error":"ShortList"}"#;
15004 let resp = format!(
15005 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15006 body.len(),
15007 body
15008 );
15009 let _ = sock.write_all(resp.as_bytes()).await;
15010 let _ = sock.flush().await;
15011 continue;
15012 }
15013 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
15014 serde_json::json!([{
15015 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
15016 "cid": "bafy",
15017 "value": {
15018 "$type": crate::lexicon::nsid::SUBSCRIPTION,
15019 "url": "https://kept.example/feed.xml",
15020 "title": "Kept",
15021 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15026 "createdAt": "2026-01-01T00:00:00Z"
15027 }
15028 }])
15029 } else if wants(crate::lexicon::nsid::FOLDER) {
15030 serde_json::json!([{
15031 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15032 "cid": "bafy",
15033 "value": {
15034 "$type": crate::lexicon::nsid::FOLDER,
15035 "name": "Kept folder",
15036 "createdAt": "2026-01-01T00:00:00Z"
15037 }
15038 }])
15039 } else {
15040 serde_json::json!([])
15041 };
15042 let body =
15043 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15044 let resp = format!(
15045 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15046 body.len(),
15047 body
15048 );
15049 let _ = sock.write_all(resp.as_bytes()).await;
15050 let _ = sock.flush().await;
15051 }
15052 });
15053 format!("http://{addr}")
15054 }
15055
15056 async fn spawn_malformed_sidecar() -> String {
15059 use tokio::io::{AsyncReadExt, AsyncWriteExt};
15060 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15061 let addr = listener.local_addr().unwrap();
15062 tokio::spawn(async move {
15063 loop {
15064 let Ok((mut sock, _)) = listener.accept().await else {
15065 break;
15066 };
15067 let mut buf = vec![0u8; 8192];
15068 let _ = sock.read(&mut buf).await;
15069 let body = serde_json::json!({ "ok": true, "data": { "records": [
15070 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
15071 { "cid": "bafy", "value": {} },
15072 ]}})
15073 .to_string();
15074 let resp = format!(
15075 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15076 body.len(),
15077 body
15078 );
15079 let _ = sock.write_all(resp.as_bytes()).await;
15080 let _ = sock.flush().await;
15081 }
15082 });
15083 format!("http://{addr}")
15084 }
15085
15086 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
15087 let cookie = session_cookie(&state, did, None);
15088 let resp = router(state)
15089 .oneshot(
15090 Request::builder()
15091 .uri(uri)
15092 .header(header::COOKIE, cookie)
15093 .body(Body::empty())
15094 .unwrap(),
15095 )
15096 .await
15097 .unwrap();
15098 let status = resp.status();
15099 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15100 .await
15101 .unwrap();
15102 (status, String::from_utf8_lossy(&body).to_string())
15103 }
15104
15105 #[tokio::test]
15111 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
15112 let did = "did:plc:displayer";
15113 let state = test_state(&[did]).await;
15114 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
15115 let feed_id = store::upsert_feed(
15116 &state.db,
15117 &store::NewFeed {
15118 url: url.into(),
15119 title: Some("Quiet Journal".into()),
15120 ..Default::default()
15121 },
15122 )
15123 .await
15124 .unwrap();
15125 store::replace_sub_refs(&state.db, did, &[feed_id])
15126 .await
15127 .unwrap();
15128 store::insert_entries(
15129 &state.db,
15130 feed_id,
15131 &[store::NewEntry {
15132 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
15133 .into(),
15134 url: Some("https://quiet.example/no-summary".into()),
15135 title: Some("A title-only article".into()),
15136 published: Some("2026-07-11T00:00:00Z".into()),
15137 content_html: None,
15138 ..Default::default()
15139 }],
15140 0,
15141 )
15142 .await
15143 .unwrap();
15144 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
15145 assert_eq!(status, StatusCode::OK);
15146 assert!(
15147 list.contains("A title-only article"),
15148 "the entry is missing from the list"
15149 );
15150
15151 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
15152 .bind(feed_id)
15153 .fetch_one(&state.db)
15154 .await
15155 .unwrap();
15156 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
15157 assert_eq!(
15158 status,
15159 StatusCode::OK,
15160 "the article page failed for an entry with no body"
15161 );
15162 assert!(page.contains("A title-only article"));
15163 assert!(
15164 page.contains("https://quiet.example/no-summary"),
15165 "no link to the original"
15166 );
15167 assert!(
15168 page.contains(r#"<time datetime=""#),
15169 "no date on the article page"
15170 );
15171 }
15172
15173 #[tokio::test]
15178 async fn a_malformed_subscription_record_raises_an_alert() {
15179 let did = "did:plc:alerted";
15180 for page in ["/", "/manage"] {
15181 let sidecar = spawn_malformed_sidecar().await;
15182 let state = test_state_with_sidecar(&[did], &sidecar).await;
15183 let (status, body) = page_body(state, did, page).await;
15184 assert_eq!(status, StatusCode::OK, "{page} did not render");
15185 assert!(
15186 body.contains(r#"role="alert""#) && body.contains("could not be read"),
15187 "{page} rendered no alert for a refused subscription list"
15188 );
15189 assert!(
15190 body.contains("1 record(s) in your subscription list"),
15191 "{page} gave the generic alert, not the malformed-record one"
15192 );
15193 }
15194 }
15195
15196 #[tokio::test]
15198 async fn a_healthy_subscription_listing_raises_no_alert() {
15199 let did = "did:plc:exporter";
15200 let sidecar = spawn_export_sidecar(None).await;
15201 let state = test_state_with_sidecar(&[did], &sidecar).await;
15202 let (status, body) = page_body(state, did, "/").await;
15203 assert_eq!(status, StatusCode::OK);
15204 assert!(
15205 !body.contains("could not be read"),
15206 "a healthy listing raised an alert"
15207 );
15208 }
15209
15210 async fn export_opml_response(
15212 fail_on: Option<&'static str>,
15213 ) -> (StatusCode, HeaderMap, String) {
15214 let did = "did:plc:exporter";
15215 let sidecar = spawn_export_sidecar(fail_on).await;
15216 let state = test_state_with_sidecar(&[did], &sidecar).await;
15217 let cookie = session_cookie(&state, did, None);
15218 let resp = router(state)
15219 .oneshot(
15220 Request::builder()
15221 .uri("/opml/export")
15222 .header(header::COOKIE, cookie)
15223 .body(Body::empty())
15224 .unwrap(),
15225 )
15226 .await
15227 .unwrap();
15228 let status = resp.status();
15229 let headers = resp.headers().clone();
15230 let body = String::from_utf8_lossy(
15231 &axum::body::to_bytes(resp.into_body(), usize::MAX)
15232 .await
15233 .unwrap(),
15234 )
15235 .to_string();
15236 (status, headers, body)
15237 }
15238
15239 #[tokio::test]
15252 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
15253 let (status, headers, body) =
15254 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
15255
15256 assert_ne!(
15257 status,
15258 StatusCode::OK,
15259 "a failed subscription walk answered 200: {body}",
15260 );
15261 assert!(
15262 !headers.contains_key(header::CONTENT_DISPOSITION),
15263 "a failed subscription walk still offered a download: {headers:?}",
15264 );
15265 assert!(
15266 !body.contains("<opml"),
15267 "a failed subscription walk still served an OPML document: {body}",
15268 );
15269 }
15270
15271 #[tokio::test]
15275 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
15276 let (status, headers, body) =
15277 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
15278
15279 assert_ne!(
15280 status,
15281 StatusCode::OK,
15282 "a failed folder walk answered 200: {body}",
15283 );
15284 assert!(
15285 !headers.contains_key(header::CONTENT_DISPOSITION),
15286 "a failed folder walk still offered a download: {headers:?}",
15287 );
15288 assert!(
15289 !body.contains("<opml"),
15290 "a failed folder walk still served an OPML document: {body}",
15291 );
15292 }
15293
15294 #[tokio::test]
15297 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
15298 let (status, headers, body) = export_opml_response(None).await;
15299
15300 assert_eq!(
15301 status,
15302 StatusCode::OK,
15303 "a healthy export did not answer 200"
15304 );
15305 assert_eq!(
15306 headers
15307 .get(header::CONTENT_DISPOSITION)
15308 .and_then(|v| v.to_str().ok()),
15309 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
15310 "a healthy export did not offer the download",
15311 );
15312 assert!(
15313 body.contains("https://kept.example/feed.xml"),
15314 "the exported OPML lost the subscription: {body}",
15315 );
15316 assert!(
15317 body.contains("Kept folder"),
15318 "the exported OPML lost the folder: {body}",
15319 );
15320 }
15321}