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::sanitized_html::{BodyRender, BodyRenderer};
84use crate::{feed, store, AppState, Session, VERSION};
85
86#[path = "opml.rs"]
91mod opml;
92
93const SESSION_COOKIE: &str = "fr_session";
95
96const INVITE_COOKIE: &str = "fr_invite";
104
105const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
113
114const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
116
117const INVITE_TTL_SECS: i64 = 1800;
120
121const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
124
125const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
127
128const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
130
131const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
147 script-src 'self'; \
148 style-src 'self' 'unsafe-inline'; \
149 img-src 'self' https: data:; \
150 font-src 'self'; \
151 connect-src 'self'; \
152 form-action 'self'; \
153 base-uri 'self'; \
154 frame-ancestors 'none'; \
155 object-src 'none'";
156
157#[derive(Clone, Debug)]
164struct CurrentUser {
165 did: String,
166 handle: Option<String>,
167 sid: Option<String>,
170}
171
172async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
183 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
184 if let Some(session) = state.sessions.get(&sid) {
185 if store::has_beta_access(&state.db, &session.did)
186 .await
187 .unwrap_or(false)
188 {
189 return Some(CurrentUser {
190 did: session.did,
191 handle: session.handle,
192 sid: Some(sid),
193 });
194 }
195 state.sessions.remove(&sid);
198 }
199 }
200 if let Some(did) = state.config.dev_did.clone() {
203 if store::has_beta_access(&state.db, &did)
204 .await
205 .unwrap_or(false)
206 {
207 return Some(CurrentUser {
208 did,
209 handle: None,
210 sid: None,
211 });
212 }
213 }
214 None
215}
216
217async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
219 current_session(state, headers).await.map(|u| u.did)
220}
221
222pub fn router(state: AppState) -> Router {
228 let limiter = RateLimiter::shared();
232 let rl_state = RateLimitState {
236 limiter,
237 trusted_header: state.config.trusted_ip_header.clone(),
238 };
239
240 Router::new()
241 .route("/health", get(health))
242 .route("/about", get(about))
243 .route("/standard-site", get(standard_site))
244 .route("/stats", get(stats))
245 .route("/privacy", get(privacy))
246 .route("/terms", get(terms))
247 .route("/manage", get(manage))
248 .route("/", get(index))
249 .route("/entries/{id}", get(entry_view))
250 .route("/entries/{id}/read", post(mark_read))
251 .route("/entries/{id}/star", post(toggle_star))
252 .route("/saved/{rkey}/delete", post(unsave_record))
253 .route("/read-all", post(mark_all_read))
254 .route("/subscriptions", post(add_subscription))
255 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
256 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
257 .route("/folders", post(create_folder))
258 .route("/folders/{rkey}/rename", post(rename_folder))
259 .route("/folders/{rkey}/delete", post(delete_folder))
260 .route(
263 "/opml",
264 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
265 )
266 .route("/opml/export", get(export_opml))
267 .route("/login", get(login_form).post(login_submit))
268 .route(
269 "/beta/redeem",
270 get(beta_redeem_form).post(beta_redeem_submit),
271 )
272 .route("/claim", get(claim))
275 .route("/bot/claims", post(bot_mint_claim))
278 .route("/admin/invites", post(admin_mint_invites))
279 .route("/admin/metrics", get(admin_metrics))
280 .route("/oauth/client-metadata.json", get(oauth_client_metadata))
281 .route("/oauth/jwks.json", get(oauth_jwks))
282 .route("/account/delete", post(account_delete))
283 .route("/oauth/callback", get(oauth_callback))
284 .route("/logout", post(logout))
285 .nest_service("/static", ServeDir::new("static"))
286 .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
290 .layer(middleware::from_fn(cache_control))
295 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
298 .layer(TraceLayer::new_for_http())
299 .layer(static_header_layer(
303 "content-security-policy",
304 CONTENT_SECURITY_POLICY,
305 ))
306 .layer(static_header_layer("x-content-type-options", "nosniff"))
307 .layer(static_header_layer(
308 "referrer-policy",
309 "strict-origin-when-cross-origin",
310 ))
311 .layer(static_header_layer("x-frame-options", "DENY"))
312 .with_state(state)
313}
314
315const OPML_BODY_LIMIT: usize = 1024 * 1024;
330
331#[cfg(test)]
341const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
342
343#[cfg(test)]
348const _: () = assert!(
349 OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
350 "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
351);
352
353fn static_header_layer(
357 name: &'static str,
358 value: &'static str,
359) -> SetResponseHeaderLayer<header::HeaderValue> {
360 SetResponseHeaderLayer::overriding(
361 header::HeaderName::from_static(name),
362 header::HeaderValue::from_static(value),
363 )
364}
365
366fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
386 use axum::http::Method;
387 if method != Method::POST
395 && !(method == Method::GET
396 && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
397 {
398 return false;
399 }
400 match path {
401 "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
406 | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
407 | "/folders" => true,
408 p => {
411 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
412 || p.starts_with("/saved/")
416 || p.starts_with("/subscriptions/")
417 || p.starts_with("/folders/")
418 }
419 }
420}
421
422#[derive(Clone)]
425struct RateLimitState {
426 limiter: RateLimiter,
427 trusted_header: Option<String>,
430}
431
432#[derive(Clone)]
437struct RateLimiter {
438 inner: std::sync::Arc<Mutex<RateLimiterState>>,
439}
440
441struct RateLimiterState {
443 buckets: HashMap<IpAddr, Bucket>,
444 last_sweep: Instant,
445}
446
447struct Bucket {
449 tokens: f64,
450 last: Instant,
451}
452
453const RATE_BURST: f64 = 20.0;
455const RATE_REFILL_PER_SEC: f64 = 1.0;
457const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
459
460const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
469
470const MAX_RATE_BUCKETS: usize = 10_000;
478
479const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
483
484impl RateLimiter {
485 fn shared() -> Self {
487 Self {
488 inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
489 buckets: HashMap::new(),
490 last_sweep: Instant::now(),
491 })),
492 }
493 }
494
495 fn check(&self, ip: IpAddr) -> bool {
498 self.check_at(ip, Instant::now())
499 }
500
501 fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
504 let mut state = match self.inner.lock() {
505 Ok(m) => m,
506 Err(p) => p.into_inner(),
508 };
509
510 if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
512 state
513 .buckets
514 .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
515 state.last_sweep = now;
516 }
517
518 if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
526 let mut by_age: Vec<(IpAddr, Instant)> =
527 state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
528 by_age.sort_unstable_by_key(|(_, last)| *last);
529 for (victim, _) in by_age
530 .into_iter()
531 .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
532 {
533 state.buckets.remove(&victim);
534 }
535 warn!(
536 buckets = state.buckets.len(),
537 "rate-limit bucket cap reached; evicted the least recently seen clients"
538 );
539 }
540
541 let bucket = state.buckets.entry(ip).or_insert(Bucket {
542 tokens: RATE_BURST,
543 last: now,
544 });
545 let elapsed = now.duration_since(bucket.last).as_secs_f64();
546 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
547 bucket.last = now;
548 if bucket.tokens >= 1.0 {
549 bucket.tokens -= 1.0;
550 true
551 } else {
552 false
553 }
554 }
555}
556
557fn client_ip(
578 headers: &HeaderMap,
579 conn: Option<&SocketAddr>,
580 trusted_header: Option<&str>,
581) -> Option<IpAddr> {
582 if let Some(name) = trusted_header {
583 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
584 if let Some(last) = raw.split(',').next_back() {
587 if let Ok(ip) = last.trim().parse::<IpAddr>() {
588 return Some(ip);
589 }
590 }
591 }
592 }
594 conn.map(|s| s.ip())
595}
596
597async fn rate_limit(
602 State(rl): State<RateLimitState>,
603 req: axum::extract::Request,
604 next: Next,
605) -> Response {
606 let path = req.uri().path().to_string();
607 let method = req.method().clone();
608 if is_rate_limited_path(&path, &method) {
609 let conn = req
610 .extensions()
611 .get::<ConnectInfo<SocketAddr>>()
612 .map(|c| c.0);
613 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
614 if let Some(ip) = ip {
620 if !rl.limiter.check(ip) {
621 warn!(%ip, %path, "rate limit exceeded");
622 return (
623 StatusCode::TOO_MANY_REQUESTS,
624 [(header::RETRY_AFTER, "1")],
625 "rate limit exceeded\n",
626 )
627 .into_response();
628 }
629 }
630 }
631 next.run(req).await
632}
633
634async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
645 let path = req.uri().path().to_string();
646 let is_login_landing = path == "/login"
649 && req.method() == axum::http::Method::GET
650 && !req.uri().query().unwrap_or("").contains("handle=");
651 let public = is_login_landing
652 || path == "/about"
653 || path == "/standard-site"
654 || path == "/privacy"
655 || path == "/terms"
656 || path.starts_with("/static/");
657
658 let mut resp = next.run(req).await;
659 if resp.headers().contains_key(header::CACHE_CONTROL) {
660 return resp;
661 }
662 let value = if public {
663 "public, max-age=300"
664 } else {
665 "no-store"
666 };
667 if let Ok(hv) = header::HeaderValue::from_str(value) {
668 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
669 }
670 resp
671}
672
673async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
682 sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
683 .fetch_optional(pool)
684 .await
685}
686
687const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
694
695const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
701
702const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
710
711fn health_tick_stale_secs(tick: Duration) -> i64 {
715 let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
716 tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
717}
718
719fn configured_poll_tick() -> Duration {
723 std::env::var("FEATHERREADER_POLL_TICK_SECS")
724 .ok()
725 .and_then(|v| v.trim().parse::<u64>().ok())
726 .filter(|s| *s > 0)
727 .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
728}
729
730const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
735
736const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
748
749async fn health(State(state): State<AppState>) -> Response {
793 let now = chrono::Utc::now().timestamp();
794 let rh = &state.runtime_health;
795
796 use crate::runtime_health::DbProbe;
797 let db = match rh.begin_db_probe() {
798 Err(borrowed) => borrowed,
801 Ok(probe) => {
802 let pool = state.db.clone();
812 let task = tokio::spawn(async move {
813 let verdict =
823 match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
824 Ok(Ok(_)) => DbProbe::Ok,
825 Ok(Err(err)) => {
830 warn!(%err, "health: database probe failed");
831 DbProbe::Failed("unavailable".to_string())
832 }
833 Err(_) => {
834 warn!(
835 timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
836 "health: database probe timed out (pool exhausted?)"
837 );
838 DbProbe::Failed("timeout".to_string())
839 }
840 };
841 probe.record(verdict.clone());
842 verdict
843 });
844 task.await.unwrap_or(DbProbe::Unknown)
848 }
849 };
850
851 let uptime = rh.uptime_secs(now);
852 let poller = if !rh.schedulers_enabled() {
853 "disabled".to_string()
856 } else {
857 match rh.secs_since_poll_tick(now) {
858 None => match uptime {
861 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
862 format!("stale never-ticked {up}s")
863 }
864 _ => "not-yet-ticked".to_string(),
865 },
866 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
867 format!("stale {secs}s")
868 }
869 Some(secs) => format!("ok {secs}s"),
870 }
871 };
872
873 let mut body = String::new();
882 let status = match &db {
883 DbProbe::Ok => {
884 body.push_str(&format!("ok featherreader/{VERSION}\n"));
885 body.push_str("db: ok\n");
886 StatusCode::OK
887 }
888 DbProbe::Unknown => {
895 body.push_str(&format!("unknown featherreader/{VERSION}\n"));
896 body.push_str("db: unknown (no probe has completed yet)\n");
897 StatusCode::OK
898 }
899 DbProbe::Failed(why) => {
900 body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
901 body.push_str(&format!("db: {why}\n"));
902 StatusCode::SERVICE_UNAVAILABLE
903 }
904 };
905 body.push_str(&format!(
909 "uptime: {}\n",
910 match uptime {
911 Some(secs) => format!("{secs}s"),
912 None => "unknown".to_string(),
913 }
914 ));
915 body.push_str(&format!("poller: {poller}\n"));
916 body.push_str(&format!(
917 "polling-paused: {}\n",
918 if rh.watermark_paused() { "yes" } else { "no" }
919 ));
920 body.push_str(&format!(
927 "backend: {}\n",
928 state.config.repo_backend.as_str()
929 ));
930 body.push_str(&format!(
931 "oauth-runtime: {}\n",
932 if state.oauth.is_some() {
933 "built"
934 } else {
935 "absent"
936 }
937 ));
938
939 let mut resp = (status, body).into_response();
942 if let Ok(hv) = header::HeaderValue::from_str("no-store") {
943 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
944 }
945 resp
946}
947
948async fn about(State(state): State<AppState>) -> Response {
957 let adoption = if state.config.show_adoption {
958 adoption_line(&state).await
959 } else {
960 None
961 };
962 render(&AboutTemplate {
963 card: Card::public(
964 &state.config,
965 "/about",
966 "About — FeatherReader",
967 "What FeatherReader is and isn't: an open-source, atproto-native reader for \
968 RSS feeds and standard.site publications, run as an experiment, free to \
969 self-host under the AGPL.",
970 ),
971 version: VERSION,
972 repo_url: REPO_URL,
973 kofi_url: KOFI_URL,
974 adoption,
975 standard_site: state.config.standard_site,
976 })
977}
978
979async fn standard_site(State(state): State<AppState>) -> Response {
985 render(&StandardSiteTemplate {
986 card: Card::public(
987 &state.config,
988 "/standard-site",
989 "standard.site — FeatherReader",
990 "Read standard.site publications beside your RSS feeds: articles \
991 published as atproto records, followed with the same portable \
992 subscription record.",
993 ),
994 version: VERSION,
995 repo_url: REPO_URL,
996 kofi_url: KOFI_URL,
997 standard_site: state.config.standard_site,
998 releases: RELEASES,
999 })
1000}
1001
1002async fn unsave_record(
1017 State(state): State<AppState>,
1018 headers: HeaderMap,
1019 Path(rkey): Path<String>,
1020) -> Response {
1021 let Some(did) = current_did(&state, &headers).await else {
1022 return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1023 };
1024
1025 let identity = match state.repo().list_saved(&did).await {
1030 Ok(records) => records
1031 .into_iter()
1032 .find(|(k, _)| *k == rkey)
1033 .map(|(_, rec)| (rec.url, rec.entry_id)),
1034 Err(err) => {
1035 warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1036 a local star for the same article may survive");
1037 None
1038 }
1039 };
1040
1041 match state.repo().remove_saved(&did, &rkey).await {
1042 Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1043 Err(err) => {
1044 warn!(%err, %did, %rkey, "could not remove the saved record");
1045 return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1046 }
1047 }
1048
1049 if let Some((url, guid)) = identity {
1053 match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1054 Ok(0) => {}
1055 Ok(n) => {
1056 info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1057 }
1058 Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1059 }
1060 }
1061 if is_htmx(&headers) {
1063 return (StatusCode::OK, "").into_response();
1064 }
1065 Redirect::to("/?view=starred").into_response()
1066}
1067
1068fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1081 if !rh.schedulers_enabled() {
1082 return "off";
1083 }
1084 match rh.secs_since_poll_tick(now_unix) {
1087 None => {
1088 match rh.uptime_secs(now_unix) {
1091 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1092 _ => "starting",
1093 }
1094 }
1095 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1096 _ if rh.watermark_paused() => "paused",
1097 _ => "running",
1098 }
1099}
1100
1101async fn stats(State(state): State<AppState>) -> Response {
1103 let now = chrono::Utc::now();
1104 let health = match store::poll_health(
1105 &state.db,
1106 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1107 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1108 )
1109 .await
1110 {
1111 Ok(health) => health,
1112 Err(err) => {
1113 warn!(%err, "could not compute poll health");
1114 return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1115 }
1116 };
1117
1118 let (deferred, starved_since) = state.sanitize_starvation.snapshot();
1121
1122 let polled_pct = if health.feeds_tracked == 0 {
1123 100
1124 } else {
1125 health.polled_last_hour * 100 / health.feeds_tracked
1126 };
1127
1128 render(&StatsTemplate {
1129 card: Card::public(
1130 &state.config,
1131 "/stats",
1132 "Stats — FeatherReader",
1133 "Is this instance's poller keeping up? Aggregate feed-polling health — \
1134 counts only; no feed and no reader is named.",
1135 ),
1136 version: VERSION,
1137 repo_url: REPO_URL,
1138 kofi_url: KOFI_URL,
1139 feeds_tracked: health.feeds_tracked,
1140 polled_last_hour: health.polled_last_hour,
1141 polled_pct,
1142 overdue: health.overdue,
1143 last_poll: humanise_ago(health.last_poll_secs_ago),
1144 oldest_poll: if health.never_polled > 0 {
1145 "never".to_string()
1146 } else {
1147 humanise_ago(health.oldest_poll_secs_ago)
1148 },
1149 never_polled: health.never_polled,
1150 poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1151 in_backoff: health.in_backoff,
1160 badly_broken: health.badly_broken,
1161 failure_kinds: health.failure_kinds,
1162 fetching: fetching_state(&state.runtime_health, now.timestamp()),
1163 deferred_no_permit: deferred,
1164 starved_since: starved_since.map(|since| humanise_ago(Some(now.timestamp() - since))),
1165 })
1166}
1167
1168fn humanise_ago(secs: Option<i64>) -> String {
1173 let Some(secs) = secs else {
1174 return "never".to_string();
1175 };
1176 match secs {
1177 s if s < 60 => format!("{s}s ago"),
1178 s if s < 3600 => format!("{}m ago", s / 60),
1179 s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1180 }
1181}
1182
1183async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1191 match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1192 Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1194 repos: stat.value,
1195 truncated: stat.truncated,
1196 observed_on: stat
1197 .observed_at
1198 .split('T')
1199 .next()
1200 .unwrap_or_default()
1201 .to_string(),
1202 }),
1203 Ok(_) => None,
1204 Err(err) => {
1205 warn!(%err, "about: adoption stat read failed; omitting the line");
1206 None
1207 }
1208 }
1209}
1210
1211async fn privacy(State(state): State<AppState>) -> Response {
1215 render(&PrivacyTemplate {
1216 card: Card::public(
1217 &state.config,
1218 "/privacy",
1219 "Privacy — FeatherReader",
1220 "No account and no tracking: your subscriptions and reading state live in \
1221 your own PDS. What this server caches, for how long, and how the session \
1222 token is handled.",
1223 ),
1224 version: VERSION,
1225 repo_url: REPO_URL,
1226 kofi_url: KOFI_URL,
1227 })
1228}
1229
1230async fn terms(State(state): State<AppState>) -> Response {
1234 render(&TermsTemplate {
1235 card: Card::public(
1236 &state.config,
1237 "/terms",
1238 "Terms — FeatherReader",
1239 "The terms of use: an experimental service offered as-is with no warranty, \
1240 what acceptable use means here, and the AGPL self-host note.",
1241 ),
1242 version: VERSION,
1243 repo_url: REPO_URL,
1244 kofi_url: KOFI_URL,
1245 })
1246}
1247
1248struct FeedView {
1255 rkey: String,
1257 url: String,
1259 title: String,
1260 unread: i64,
1261 selected: bool,
1263 folder: Option<String>,
1268}
1269
1270struct FolderView {
1272 rkey: String,
1274 uri: String,
1276 name: String,
1277 feeds: Vec<FeedView>,
1278 selected: bool,
1280}
1281
1282struct EntryRow {
1284 id: i64,
1285 title: String,
1286 feed_title: String,
1287 published: String,
1288 read: bool,
1289 starred: bool,
1290 link: SafeLink,
1293 cached: bool,
1301 rkey: String,
1303}
1304
1305struct FolderOption {
1307 uri: String,
1308 name: String,
1309}
1310
1311struct Nav {
1316 handle: String,
1318 avatar: String,
1320 view: String,
1322 scope_qs: String,
1325 folders: Vec<FolderView>,
1328 loose_feeds: Vec<FeedView>,
1329 manage_active: bool,
1331}
1332
1333pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1342
1343const SITE_TITLE: &str = "FeatherReader — read, quietly";
1350
1351const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1354standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1355password, no tracking.";
1356
1357const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1362
1363#[derive(Debug, Clone)]
1374pub(crate) struct Card {
1375 pub title: String,
1377 pub description: String,
1380 pub url: String,
1382 pub image: String,
1384 pub private: bool,
1388}
1389
1390impl Card {
1391 fn public(
1393 config: &Config,
1394 path: &str,
1395 title: impl Into<String>,
1396 description: impl Into<String>,
1397 ) -> Self {
1398 let origin = config.public_url.trim_end_matches('/');
1399 Card {
1400 title: title.into(),
1401 description: description.into(),
1402 url: format!("{origin}{path}"),
1403 image: format!("{origin}{SHARE_IMAGE_PATH}"),
1404 private: false,
1405 }
1406 }
1407
1408 fn site(config: &Config) -> Self {
1410 Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1411 }
1412
1413 fn private(config: &Config) -> Self {
1417 Card {
1418 private: true,
1419 ..Card::site(config)
1420 }
1421 }
1422}
1423
1424#[derive(Template)]
1426#[template(path = "index.html")]
1427struct IndexTemplate {
1428 card: Card,
1430 version: &'static str,
1431 repo_url: &'static str,
1432 kofi_url: &'static str,
1433 flash: String,
1434 alert: String,
1437 nav: Nav,
1439 entries: Vec<EntryRow>,
1441 heading: String,
1443 feed_scope: Option<String>,
1445 total: i64,
1453 uncached_total: i64,
1461 page: i64,
1463 page_count: i64,
1465 prev_href: Option<String>,
1467 next_href: Option<String>,
1469}
1470
1471#[derive(Template)]
1473#[template(path = "manage.html")]
1474struct ManageTemplate {
1475 card: Card,
1477 version: &'static str,
1478 repo_url: &'static str,
1479 kofi_url: &'static str,
1480 flash: String,
1481 alert: String,
1483 nav: Nav,
1484 folder_options: Vec<FolderOption>,
1486 folders: Vec<FolderView>,
1488 loose_feeds: Vec<FeedView>,
1489 standard_site: bool,
1495}
1496
1497struct AdoptionLine {
1502 repos: i64,
1504 truncated: bool,
1506 observed_on: String,
1508}
1509
1510#[derive(Template)]
1513#[template(path = "about.html")]
1514struct AboutTemplate {
1515 card: Card,
1517 version: &'static str,
1518 repo_url: &'static str,
1519 kofi_url: &'static str,
1520 adoption: Option<AdoptionLine>,
1521 standard_site: bool,
1524}
1525
1526#[derive(Template)]
1530#[template(path = "standard_site.html")]
1531struct StandardSiteTemplate {
1532 card: Card,
1534 version: &'static str,
1535 repo_url: &'static str,
1536 kofi_url: &'static str,
1537 standard_site: bool,
1540 releases: &'static [Release],
1542}
1543
1544pub(crate) struct Release {
1549 pub(crate) version: &'static str,
1551 pub(crate) date: &'static str,
1553 pub(crate) summary: &'static str,
1555}
1556
1557impl Release {
1558 pub(crate) fn url(&self) -> String {
1560 format!("{REPO_URL}/releases/tag/v{}", self.version)
1561 }
1562
1563 pub(crate) fn changelog_url(&self) -> String {
1567 format!(
1568 "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1569 self.version.replace('.', ""),
1570 self.date
1571 )
1572 }
1573}
1574
1575pub(crate) const RELEASES: &[Release] = &[
1580 Release {
1581 version: "0.4.8",
1582 date: "2026-10-08",
1583 summary: "A misbehaving server can no longer drop your subscriptions by \
1584 cutting a list short, and a small publication that shares a \
1585 repo with big ones is read in full.",
1586 },
1587 Release {
1588 version: "0.4.7",
1589 date: "2026-10-07",
1590 summary: "A feed can no longer stall the reader with an article that is \
1591 slow to clean up, and every stored article is cleaned again \
1592 as it is shown.",
1593 },
1594 Release {
1595 version: "0.4.6",
1596 date: "2026-10-06",
1597 summary: "Renaming a subscription or a folder no longer overwrites \
1598 what another app changed at the same moment, and a folder \
1599 rename keeps everything but the name.",
1600 },
1601 Release {
1602 version: "0.4.5",
1603 date: "2026-10-06",
1604 summary: "An operator teardown now signs every user out at their own \
1605 server before deleting anything, and the session-writing \
1606 code is hardened against the races that work exposed.",
1607 },
1608 Release {
1609 version: "0.4.4",
1610 date: "2026-10-05",
1611 summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1612 links unchanged and real RSS bylines, and the address guard \
1613 refuses the reserved ranges it missed.",
1614 },
1615 Release {
1616 version: "0.4.3",
1617 date: "2026-10-05",
1618 summary: "Two write-path fixes for any PDS: large OPML imports and \
1619 read-state syncs are sent in calls the PDS accepts, and a \
1620 read-state sync that disagreed with the PDS recovers instead \
1621 of failing every round.",
1622 },
1623 Release {
1624 version: "0.4.2",
1625 date: "2026-10-04",
1626 summary: "A public standard.site feature page with this list of recent \
1627 releases, and link cards: a posted feather-reader.com link \
1628 now unfurls with a description and an image.",
1629 },
1630 Release {
1631 version: "0.4.1",
1632 date: "2026-10-04",
1633 summary: "The public pages explain standard.site publications, and the \
1634 subscribe form can submit the DID form of a publication URI, \
1635 which browsers refused in 0.4.0.",
1636 },
1637 Release {
1638 version: "0.4.0",
1639 date: "2026-10-03",
1640 summary: "standard.site support: publications are read from their \
1641 authors' atproto repos as subscriptions, beside RSS, on their \
1642 own polling loop. Every stored field from a feed or a \
1643 publication now has a size bound.",
1644 },
1645];
1646
1647#[derive(Template)]
1659#[template(path = "stats.html")]
1660struct StatsTemplate {
1661 card: Card,
1663 version: &'static str,
1664 repo_url: &'static str,
1665 kofi_url: &'static str,
1666 feeds_tracked: i64,
1667 polled_last_hour: i64,
1668 polled_pct: i64,
1669 overdue: i64,
1670 last_poll: String,
1671 oldest_poll: String,
1672 never_polled: i64,
1673 poll_interval_mins: i64,
1674 in_backoff: i64,
1676 badly_broken: i64,
1680 failure_kinds: Vec<(String, i64)>,
1682 fetching: &'static str,
1686 deferred_no_permit: u64,
1691 starved_since: Option<String>,
1692}
1693
1694#[derive(Template)]
1698#[template(path = "privacy.html")]
1699struct PrivacyTemplate {
1700 card: Card,
1702 version: &'static str,
1703 repo_url: &'static str,
1704 kofi_url: &'static str,
1705}
1706
1707#[derive(Template)]
1710#[template(path = "terms.html")]
1711struct TermsTemplate {
1712 card: Card,
1714 version: &'static str,
1715 repo_url: &'static str,
1716 kofi_url: &'static str,
1717}
1718
1719#[derive(Template)]
1722#[template(path = "landing.html")]
1723struct LandingTemplate {
1724 card: Card,
1726 version: &'static str,
1727 repo_url: &'static str,
1728 crates_url: &'static str,
1729 kofi_url: &'static str,
1730 standard_site: bool,
1733 releases: &'static [Release],
1735}
1736
1737#[derive(Template)]
1739#[template(path = "entry.html")]
1740struct EntryTemplate {
1741 card: Card,
1743 version: &'static str,
1744 repo_url: &'static str,
1745 kofi_url: &'static str,
1746 nav: Nav,
1747 id: i64,
1748 title: String,
1749 feed_title: String,
1750 author: Option<String>,
1751 published: String,
1752 url: Option<SafeLink>,
1762 content_html: Option<BodyRender>,
1774 read: bool,
1775 starred: bool,
1776 back_qs: String,
1778 prev_id: Option<i64>,
1780 next_id: Option<i64>,
1781 oob: bool,
1783}
1784
1785#[derive(Template)]
1787#[template(path = "entry_row.html")]
1788struct EntryRowTemplate {
1789 e: EntryRow,
1790}
1791
1792#[derive(Template)]
1797#[template(path = "entry_actionbar.html")]
1798struct EntryActionBarTemplate {
1799 id: i64,
1800 read: bool,
1801 starred: bool,
1802 oob: bool,
1804}
1805
1806#[derive(Template)]
1808#[template(path = "login.html")]
1809struct LoginTemplate {
1810 card: Card,
1812 repo_url: &'static str,
1813 error: String,
1814 flash: String,
1817}
1818
1819#[derive(Template)]
1821#[template(path = "beta_redeem.html")]
1822struct BetaRedeemTemplate {
1823 card: Card,
1825 repo_url: &'static str,
1826 error: String,
1827 capacity_full: bool,
1830}
1831
1832fn render<T: Template>(tmpl: &T) -> Response {
1839 match tmpl.render() {
1840 Ok(body) => Html(body).into_response(),
1841 Err(err) => {
1842 warn!(%err, "template render failed");
1843 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1844 }
1845 }
1846}
1847
1848struct WebError {
1853 err: anyhow::Error,
1854 status: StatusCode,
1855}
1856
1857impl<E: Into<anyhow::Error>> From<E> for WebError {
1858 fn from(err: E) -> Self {
1859 WebError {
1860 err: err.into(),
1861 status: StatusCode::INTERNAL_SERVER_ERROR,
1862 }
1863 }
1864}
1865
1866impl WebError {
1867 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1869 WebError {
1870 err: err.into(),
1871 status,
1872 }
1873 }
1874}
1875
1876impl IntoResponse for WebError {
1877 fn into_response(self) -> Response {
1878 warn!(error = %self.err, status = %self.status, "request failed");
1879 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1880 "internal error"
1881 } else {
1882 self.status.canonical_reason().unwrap_or("error")
1883 };
1884 (self.status, body).into_response()
1885 }
1886}
1887
1888fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1893 let status = err.status();
1894 WebError::with_status(err, status)
1895}
1896
1897fn display_title(title: Option<&str>, url: &str) -> String {
1900 if let Some(t) = title {
1901 let t = t.trim();
1902 if !t.is_empty() {
1903 return t.to_string();
1904 }
1905 }
1906 url::Url::parse(url)
1907 .ok()
1908 .and_then(|u| u.host_str().map(str::to_string))
1909 .unwrap_or_else(|| url.to_string())
1910}
1911
1912fn display_handle(handle: Option<&str>, did: &str) -> String {
1915 match handle {
1916 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1917 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1918 }
1919}
1920
1921fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1923 let source = handle
1924 .map(|h| h.trim().trim_start_matches('@'))
1925 .filter(|h| !h.is_empty())
1926 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1927 let letters: String = source
1928 .chars()
1929 .filter(|c| c.is_alphanumeric())
1930 .take(2)
1931 .collect::<String>()
1932 .to_lowercase();
1933 if letters.is_empty() {
1934 "fr".to_string()
1935 } else {
1936 letters
1937 }
1938}
1939
1940fn display_date(published: Option<&str>) -> String {
1943 match published {
1952 Some(p) => p.chars().take(10).collect(),
1953 None => String::new(),
1954 }
1955}
1956
1957fn qenc(s: &str) -> String {
1961 let mut out = String::with_capacity(s.len() * 3);
1962 for b in s.bytes() {
1963 match b {
1964 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1965 out.push(b as char)
1966 }
1967 _ => out.push_str(&format!("%{b:02X}")),
1968 }
1969 }
1970 out
1971}
1972
1973#[derive(Debug, Deserialize, Default)]
1979struct IndexQuery {
1980 #[serde(default)]
1982 feed: Option<String>,
1983 #[serde(default)]
1985 folder: Option<String>,
1986 #[serde(default)]
1988 view: Option<String>,
1989 #[serde(default)]
1991 page: Option<u32>,
1992 #[serde(default)]
1994 flash: Option<String>,
1995}
1996
1997const ENTRIES_PER_PAGE: i64 = 100;
2005
2006fn page_count_for(total: i64) -> i64 {
2009 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
2010}
2011
2012const PREV_NEXT_MAX: i64 = 5_000;
2019
2020const STARRED_IDENTITY_MAX: i64 = 20_000;
2028
2029const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2043
2044struct ResolvedSub {
2047 rkey: String,
2048 sub: Subscription,
2049 feed: Option<store::Feed>,
2050}
2051
2052async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2056 resolve_subscriptions_noting(state, did).await.0
2057}
2058
2059fn subscriptions_alert(err: &anyhow::Error) -> String {
2066 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2067 Some(m) => format!(
2068 "{} record(s) in your subscription list could not be read, so it was not \
2069 refreshed. Showing your last-known subscriptions; nothing was removed.",
2070 m.count
2071 ),
2072 None => "Your subscription list could not be read from your PDS just now. \
2073 Showing your last-known subscriptions."
2074 .to_string(),
2075 }
2076}
2077
2078async fn resolve_subscriptions_noting(
2081 state: &AppState,
2082 did: &str,
2083) -> (Vec<ResolvedSub>, Option<String>) {
2084 let pool = &state.db;
2085 let subs = match state.repo().list_subscriptions_sorted(did).await {
2086 Ok(s) => s,
2087 Err(err) => {
2088 let alert = subscriptions_alert(&err);
2089 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2090 return (cached_subscriptions(pool, did).await, Some(alert));
2091 }
2092 };
2093
2094 let mut out = Vec::with_capacity(subs.len());
2110 for (rkey, sub) in subs {
2111 let feed = match store::get_feed_by_url(pool, &sub.url).await {
2112 Ok(Some(f)) => Some(f),
2113 Ok(None) => {
2114 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2125 || feed::classify_feed_privacy(&sub.url).is_private()
2126 {
2127 warn!(
2128 %did,
2129 "skipping cache row for a subscription URL that is private or not http(s)"
2130 );
2131 out.push(ResolvedSub {
2132 rkey,
2133 sub,
2134 feed: None,
2135 });
2136 continue;
2137 }
2138 if let Err(err) = store::upsert_feed(
2146 pool,
2147 &store::NewFeed {
2148 url: sub.url.clone(),
2149 title: sub.title.clone(),
2150 site_url: sub.site_url.clone(),
2151 ..Default::default()
2152 },
2153 )
2154 .await
2155 {
2156 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2157 it will not be polled");
2158 }
2159 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2160 }
2161 Err(err) => {
2162 warn!(%err, url = %sub.url, "get_feed_by_url failed");
2163 None
2164 }
2165 };
2166 out.push(ResolvedSub { rkey, sub, feed });
2167 }
2168 let refused = match shrink_is_corroborated(state, did, &out).await {
2175 Shrink::Apply => None,
2176 Shrink::Disagreed => Some(
2177 "Your subscription list came back much shorter than before and did not \
2178 read the same twice, so it was not applied. Showing your last-known \
2179 subscriptions; nothing was removed."
2180 .to_string(),
2181 ),
2182 Shrink::Unreadable(err) => Some(subscriptions_alert(&err)),
2185 };
2186 if let Some(alert) = refused {
2187 return (cached_subscriptions(pool, did).await, Some(alert));
2188 }
2189 sync_sub_refs(pool, did, &out).await;
2193 (out, None)
2194}
2195
2196const SUB_REF_SHRINK_CORROBORATE: usize = 3;
2207
2208enum Shrink {
2210 Apply,
2212 Disagreed,
2214 Unreadable(anyhow::Error),
2217}
2218
2219async fn shrink_is_corroborated(state: &AppState, did: &str, resolved: &[ResolvedSub]) -> Shrink {
2228 let current = match store::subscribed_feed_ids(&state.db, did).await {
2229 Ok(ids) => ids,
2230 Err(err) => {
2231 warn!(%err, %did, "could not read sub_ref to check for a mass drop");
2234 return Shrink::Apply;
2235 }
2236 };
2237 let kept: std::collections::HashSet<i64> = resolved
2238 .iter()
2239 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2240 .collect();
2241 let dropped = current.iter().filter(|id| !kept.contains(id)).count();
2242 if dropped < SUB_REF_SHRINK_CORROBORATE {
2243 return Shrink::Apply;
2244 }
2245 let first: std::collections::HashSet<&str> =
2246 resolved.iter().map(|s| s.sub.url.as_str()).collect();
2247 match state.repo().list_subscriptions_sorted(did).await {
2248 Ok(again) => {
2249 let second: std::collections::HashSet<&str> =
2250 again.iter().map(|(_, s)| s.url.as_str()).collect();
2251 if first == second {
2252 return Shrink::Apply;
2253 }
2254 warn!(
2255 %did,
2256 current = current.len(),
2257 dropped,
2258 first = first.len(),
2259 second = second.len(),
2260 "subscription list shrank sharply and did not read the same twice; \
2261 leaving sub_ref untouched"
2262 );
2263 Shrink::Disagreed
2264 }
2265 Err(err) => {
2266 warn!(
2267 %err,
2268 %did,
2269 current = current.len(),
2270 dropped,
2271 "subscription list shrank sharply and could not be read a second time; \
2272 leaving sub_ref untouched"
2273 );
2274 Shrink::Unreadable(err)
2275 }
2276 }
2277}
2278
2279async fn cached_subscriptions(pool: &store::Pool, did: &str) -> Vec<ResolvedSub> {
2283 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2297 warn!(%err, %did, "the PDS listing could not be applied AND the local \
2298 subscription projection could not be read; rendering an \
2299 EMPTY feed list, which is not the same as having none");
2300 Vec::new()
2301 });
2302 feeds
2303 .into_iter()
2304 .map(|f| ResolvedSub {
2305 rkey: String::new(),
2306 sub: Subscription::new(f.url.clone(), now_rfc3339()),
2307 feed: Some(f),
2308 })
2309 .collect()
2310}
2311
2312async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2316 let feed_ids: Vec<i64> = subs
2317 .iter()
2318 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2319 .collect();
2320 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2321 warn!(%err, %did, "failed to sync sub_ref projection");
2322 }
2323}
2324
2325async fn index(
2328 State(state): State<AppState>,
2329 headers: HeaderMap,
2330 Query(q): Query<IndexQuery>,
2331) -> Result<Response, WebError> {
2332 let user = match current_session(&state, &headers).await {
2333 Some(u) => u,
2334 None => {
2337 return Ok(render(&LandingTemplate {
2338 card: Card::site(&state.config),
2339 version: VERSION,
2340 repo_url: REPO_URL,
2341 crates_url: CRATES_URL,
2342 kofi_url: KOFI_URL,
2343 standard_site: state.config.standard_site,
2344 releases: RELEASES,
2345 }))
2346 }
2347 };
2348 let did = user.did.clone();
2349 let pool = &state.db;
2350
2351 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2352
2353 let view = match q.view.as_deref() {
2355 Some("all") => "all",
2356 Some("starred") => "starred",
2357 _ => "unread",
2358 }
2359 .to_string();
2360 let list_view = list_view_of(q.view.as_deref());
2361
2362 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2364 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2369
2370 let feed_title_by_id = |id: i64| -> String {
2371 subs.iter()
2372 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2373 .map(|s| {
2374 display_title(
2375 s.sub
2376 .title
2377 .as_deref()
2378 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2379 &s.sub.url,
2380 )
2381 })
2382 .unwrap_or_default()
2383 };
2384
2385 let mut uncached: Vec<EntryRow> = Vec::new();
2398 if view == "starred" {
2399 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2428 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2429 Ok(store::StarredIdentities::Truncated) => {
2434 warn!(
2435 %did,
2436 cap = STARRED_IDENTITY_MAX,
2437 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2438 rather than rendering record-deleting buttons for cached articles"
2439 );
2440 None
2441 }
2442 Err(err) => {
2443 warn!(%err, %did, "cached-starred identity lookup failed; \
2444 suppressing uncached saved rows this render");
2445 None
2446 }
2447 };
2448 let identities_ok = identities.is_some();
2455 let identities = identities.unwrap_or_default();
2456 let cached_urls: std::collections::HashSet<&str> = identities
2457 .iter()
2458 .filter_map(|(url, _)| url.as_deref())
2459 .collect();
2460 let cached_guids: std::collections::HashSet<&str> =
2461 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2462
2463 let mut uncached_dropped = 0usize;
2476 match state.repo().list_saved_sorted(&did).await {
2477 Ok(saved) if identities_ok => {
2478 for (rkey, item) in saved {
2479 let known = cached_urls.contains(item.url.as_str())
2480 || item
2481 .entry_id
2482 .as_deref()
2483 .is_some_and(|g| cached_guids.contains(g));
2484 if known {
2485 continue;
2486 }
2487 if let Some(urls) = &scope_urls {
2491 match item.feed_url.as_deref() {
2492 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2493 _ => continue,
2497 }
2498 }
2499 let link = SafeLink::external(&item.url);
2525 if link.is_empty() {
2526 warn!(
2527 %did, %rkey,
2528 "a saved record has an unusable URL; rendering it without a link \
2529 so it can still be removed"
2530 );
2531 }
2532
2533 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2547 uncached_dropped += 1;
2548 continue;
2549 }
2550 if let Some(feed_url) = item.feed_url.as_deref() {
2551 if subs.iter().any(|s| s.sub.url == feed_url) {
2552 let stale_before = (chrono::Utc::now()
2556 - chrono::Duration::from_std(state.config.poll_interval)
2557 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2558 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2559 if let Err(err) =
2560 store::mark_feed_due(pool, feed_url, &stale_before).await
2561 {
2562 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2563 }
2564 }
2565 }
2566 uncached.push(EntryRow {
2567 id: 0,
2568 title: item
2569 .title
2570 .clone()
2571 .filter(|t| !t.trim().is_empty())
2572 .unwrap_or_else(|| {
2580 if link.is_empty() {
2581 format!("Saved item {rkey}")
2582 } else {
2583 item.url.clone()
2584 }
2585 }),
2586 feed_title: item.feed_url.clone().unwrap_or_default(),
2587 published: display_date(Some(&item.created_at)),
2588 read: false,
2589 starred: true,
2590 link,
2594 cached: false,
2595 rkey,
2596 });
2597 }
2598 }
2599 Ok(_) => {}
2601 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2602 }
2603 if uncached_dropped > 0 {
2604 warn!(
2605 %did,
2606 dropped = uncached_dropped,
2607 cap = MAX_UNCACHED_SAVED_ROWS,
2608 "more saved records than this instance will hold in one response; the \
2609 rest are not reachable from here"
2610 );
2611 }
2612 }
2613
2614 let total_cached =
2630 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2631 let uncached_len = uncached.len();
2632 let total = total_cached + uncached_len as i64;
2633 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2638 let offset = (page - 1) * ENTRIES_PER_PAGE;
2639 let source = store::list_entries(
2642 pool,
2643 &did,
2644 list_view,
2645 scope_ids.as_deref(),
2646 ENTRIES_PER_PAGE,
2647 offset,
2648 )
2649 .await?;
2650 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2670 let cached_here = cached_allotment.min(source.len());
2671 let source = if uncached_len == 0 {
2676 &source[..]
2677 } else {
2678 &source[..cached_here]
2679 };
2680 let uncached_page: Vec<EntryRow> = {
2681 let skip = (offset - total_cached).max(0) as usize;
2682 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2683 uncached.into_iter().skip(skip).take(take).collect()
2684 };
2685 let uncached_total = uncached_len as i64;
2688
2689 let entry_scope_qs = {
2691 let mut parts = Vec::new();
2692 if let Some(f) = q.feed.as_deref() {
2693 parts.push(format!("feed={}", qenc(f)));
2694 }
2695 if let Some(f) = q.folder.as_deref() {
2696 parts.push(format!("folder={}", qenc(f)));
2697 }
2698 if view != "unread" {
2699 parts.push(format!("view={}", qenc(&view)));
2700 }
2701 parts.join("&")
2702 };
2703 let entries: Vec<EntryRow> = source
2704 .iter()
2705 .map(|e| EntryRow {
2706 id: e.id,
2707 title: e
2708 .title
2709 .clone()
2710 .filter(|t| !t.trim().is_empty())
2711 .unwrap_or_else(|| "(untitled)".to_string()),
2712 feed_title: feed_title_by_id(e.feed_id),
2713 published: display_date(e.published.as_deref()),
2714 read: e.read,
2719 starred: e.starred,
2720 link: SafeLink::entry(e.id, &entry_scope_qs),
2721 cached: true,
2722 rkey: String::new(),
2723 })
2724 .collect();
2725
2726 let mut entries = entries;
2728 entries.extend(uncached_page);
2729 let entries = entries;
2730
2731 let selected_feed = q.feed.as_deref();
2732 let selected_folder = q.folder.as_deref();
2733
2734 let (folder_views, loose_feeds, _folder_options) =
2736 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2737
2738 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2740 let name = subs
2741 .iter()
2742 .find(|s| s.sub.url == feed_url)
2743 .map(|s| {
2744 display_title(
2745 s.sub
2746 .title
2747 .as_deref()
2748 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2749 &s.sub.url,
2750 )
2751 })
2752 .unwrap_or_else(|| display_title(None, feed_url));
2753 (name, format!("feed={}", qenc(feed_url)))
2754 } else if let Some(folder_uri) = selected_folder {
2755 let name = folder_views
2756 .iter()
2757 .find(|f| f.uri == folder_uri)
2758 .map(|f| f.name.clone())
2759 .unwrap_or_else(|| "Folder".to_string());
2760 (name, format!("folder={}", qenc(folder_uri)))
2761 } else {
2762 let h = match view.as_str() {
2763 "all" => "All",
2764 "starred" => "Starred",
2765 _ => "Unread",
2766 };
2767 (h.to_string(), String::new())
2768 };
2769
2770 let feed_scope = selected_feed.map(str::to_string);
2771 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2772
2773 let page_href = |n: i64| -> String {
2777 let mut parts = Vec::new();
2778 if !entry_scope_qs.is_empty() {
2779 parts.push(entry_scope_qs.clone());
2780 }
2781 if n > 1 {
2782 parts.push(format!("page={n}"));
2783 }
2784 if parts.is_empty() {
2785 "/".to_string()
2786 } else {
2787 format!("/?{}", parts.join("&"))
2788 }
2789 };
2790 let prev_href = (page > 1).then(|| page_href(page - 1));
2791 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2792
2793 let tmpl = IndexTemplate {
2794 card: Card::private(&state.config),
2795 version: VERSION,
2796 repo_url: REPO_URL,
2797 kofi_url: KOFI_URL,
2798 flash: q.flash.unwrap_or_default(),
2799 alert: alert.unwrap_or_default(),
2800 nav,
2801 entries,
2802 heading,
2803 feed_scope,
2804 total,
2805 uncached_total,
2808 page,
2809 page_count: page_count_for(total),
2810 prev_href,
2811 next_href,
2812 };
2813 Ok(render(&tmpl))
2814}
2815
2816#[derive(Debug, Deserialize, Default)]
2818struct ManageQuery {
2819 #[serde(default)]
2820 flash: Option<String>,
2821}
2822
2823async fn manage(
2828 State(state): State<AppState>,
2829 headers: HeaderMap,
2830 Query(q): Query<ManageQuery>,
2831) -> Result<Response, WebError> {
2832 let user = match current_session(&state, &headers).await {
2833 Some(u) => u,
2834 None => return Ok(Redirect::to("/login").into_response()),
2835 };
2836 let did = user.did.clone();
2837
2838 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2839 let (folder_views, loose_feeds, folder_options) =
2840 build_sidebar(&state, &did, &subs, None, None).await;
2841
2842 let nav = build_nav(
2844 &user,
2845 "unread",
2846 String::new(),
2847 folder_views.iter().map(clone_folder_view).collect(),
2848 loose_feeds.iter().map(clone_feed_view).collect(),
2849 true,
2850 );
2851
2852 let tmpl = ManageTemplate {
2853 card: Card::private(&state.config),
2854 version: VERSION,
2855 repo_url: REPO_URL,
2856 kofi_url: KOFI_URL,
2857 flash: q.flash.unwrap_or_default(),
2858 alert: alert.unwrap_or_default(),
2859 nav,
2860 folder_options,
2861 folders: folder_views,
2862 loose_feeds,
2863 standard_site: state.config.standard_site,
2864 };
2865 Ok(render(&tmpl))
2866}
2867
2868fn clone_feed_view(f: &FeedView) -> FeedView {
2871 FeedView {
2872 rkey: f.rkey.clone(),
2873 url: f.url.clone(),
2874 title: f.title.clone(),
2875 unread: f.unread,
2876 selected: f.selected,
2877 folder: f.folder.clone(),
2878 }
2879}
2880
2881fn clone_folder_view(f: &FolderView) -> FolderView {
2882 FolderView {
2883 rkey: f.rkey.clone(),
2884 uri: f.uri.clone(),
2885 name: f.name.clone(),
2886 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2887 selected: f.selected,
2888 }
2889}
2890
2891fn scope_urls_for(
2896 subs: &[ResolvedSub],
2897 feed: Option<&str>,
2898 folder: Option<&str>,
2899) -> Option<Vec<String>> {
2900 if let Some(feed_url) = feed {
2901 Some(vec![feed_url.to_string()])
2902 } else {
2903 folder.map(|folder_uri| {
2904 subs.iter()
2905 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2906 .map(|s| s.sub.url.clone())
2907 .collect()
2908 })
2909 }
2910}
2911
2912fn folder_uri(did: &str, rkey: &str) -> String {
2914 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2915}
2916
2917async fn build_sidebar(
2921 state: &AppState,
2922 did: &str,
2923 subs: &[ResolvedSub],
2924 selected_feed: Option<&str>,
2925 selected_folder: Option<&str>,
2926) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2927 let pool = &state.db;
2928 let unread_counts = store::unread_counts_by_feed(pool, did)
2933 .await
2934 .unwrap_or_else(|err| {
2935 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2936 Default::default()
2937 });
2938 let folders = state
2939 .repo()
2940 .list_folders_sorted(did)
2941 .await
2942 .unwrap_or_default();
2943
2944 let unread_count = |feed_id: Option<i64>| -> i64 {
2945 feed_id
2946 .and_then(|id| unread_counts.get(&id).copied())
2947 .unwrap_or(0)
2948 };
2949 let mk_feed_view = |s: &ResolvedSub| FeedView {
2950 rkey: s.rkey.clone(),
2951 url: s.sub.url.clone(),
2952 title: display_title(
2953 s.sub
2954 .title
2955 .as_deref()
2956 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2957 &s.sub.url,
2958 ),
2959 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2960 selected: selected_feed == Some(s.sub.url.as_str()),
2961 folder: s.sub.folder.clone(),
2962 };
2963
2964 let mut folder_views = Vec::with_capacity(folders.len());
2965 for (rkey, folder) in &folders {
2966 let uri = folder_uri(did, rkey);
2967 let feeds: Vec<FeedView> = subs
2968 .iter()
2969 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2970 .map(mk_feed_view)
2971 .collect();
2972 folder_views.push(FolderView {
2973 rkey: rkey.clone(),
2974 uri: uri.clone(),
2975 name: folder.name.clone(),
2976 feeds,
2977 selected: selected_folder == Some(uri.as_str()),
2978 });
2979 }
2980
2981 let known_uris: std::collections::HashSet<String> =
2982 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2983 let loose_feeds: Vec<FeedView> = subs
2984 .iter()
2985 .filter(|s| {
2986 s.sub
2987 .folder
2988 .as_deref()
2989 .map(|f| !known_uris.contains(f))
2990 .unwrap_or(true)
2991 })
2992 .map(mk_feed_view)
2993 .collect();
2994
2995 let folder_options: Vec<FolderOption> = folders
2996 .iter()
2997 .map(|(rkey, folder)| FolderOption {
2998 name: folder.name.clone(),
2999 uri: folder_uri(did, rkey),
3000 })
3001 .collect();
3002
3003 (folder_views, loose_feeds, folder_options)
3004}
3005
3006fn build_nav(
3008 user: &CurrentUser,
3009 view: &str,
3010 scope_qs: String,
3011 folders: Vec<FolderView>,
3012 loose_feeds: Vec<FeedView>,
3013 manage_active: bool,
3014) -> Nav {
3015 Nav {
3016 handle: display_handle(user.handle.as_deref(), &user.did),
3017 avatar: avatar_initials(user.handle.as_deref(), &user.did),
3018 view: view.to_string(),
3019 scope_qs,
3020 folders,
3021 loose_feeds,
3022 manage_active,
3023 }
3024}
3025
3026#[derive(Debug, Deserialize, Default)]
3033struct EntryQuery {
3034 #[serde(default)]
3035 feed: Option<String>,
3036 #[serde(default)]
3037 folder: Option<String>,
3038 #[serde(default)]
3039 view: Option<String>,
3040}
3041
3042async fn entry_view(
3045 State(state): State<AppState>,
3046 headers: HeaderMap,
3047 Path(id): Path<i64>,
3048 Query(q): Query<EntryQuery>,
3049) -> Result<Response, WebError> {
3050 let user = match current_session(&state, &headers).await {
3051 Some(u) => u,
3052 None => return Ok(Redirect::to("/login").into_response()),
3053 };
3054 let did = user.did.clone();
3055 let pool = &state.db;
3056
3057 let subs = resolve_subscriptions(&state, &did).await;
3061
3062 let entry = match get_entry_by_id(pool, &did, id).await? {
3063 Some(e) => e,
3064 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3065 };
3066
3067 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
3068
3069 let read = entry_is_read(pool, &did, id).await?;
3070 let starred = entry_is_starred(pool, &did, id).await?;
3071
3072 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
3075
3076 let back_qs = scope_query(&q);
3077
3078 let content_html = match entry.content_html.clone() {
3082 Some(raw) => Some(BodyRenderer::shared().render(raw).await?),
3083 None => None,
3084 };
3085
3086 let (folder_views, loose_feeds, _) =
3087 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
3088 let nav_view = match q.view.as_deref() {
3089 Some("all") => "all",
3090 Some("starred") => "starred",
3091 _ => "unread",
3092 };
3093 let nav = build_nav(
3094 &user,
3095 nav_view,
3096 back_qs.clone(),
3097 folder_views,
3098 loose_feeds,
3099 false,
3100 );
3101
3102 let tmpl = EntryTemplate {
3103 card: Card::private(&state.config),
3104 version: VERSION,
3105 repo_url: REPO_URL,
3106 kofi_url: KOFI_URL,
3107 nav,
3108 id: entry.id,
3109 title: entry
3110 .title
3111 .clone()
3112 .filter(|t| !t.trim().is_empty())
3113 .unwrap_or_else(|| "(untitled)".to_string()),
3114 feed_title,
3115 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
3116 published: display_date(entry.published.as_deref()),
3117 url: entry.url.as_deref().and_then(SafeLink::external_opt),
3118 content_html,
3119 read,
3120 starred,
3121 back_qs,
3122 prev_id,
3123 next_id,
3124 oob: false,
3125 };
3126 Ok(render(&tmpl))
3127}
3128
3129async fn neighbors_in_scope(
3132 state: &AppState,
3133 did: &str,
3134 q: &EntryQuery,
3135 current: i64,
3136) -> (Option<i64>, Option<i64>) {
3137 let idx_q = IndexQuery {
3138 feed: q.feed.clone(),
3139 folder: q.folder.clone(),
3140 view: q.view.clone(),
3141 page: None,
3143 flash: None,
3144 };
3145 let ids = list_entry_ids(state, did, &idx_q).await;
3146 let pos = ids.iter().position(|&x| x == current);
3147 match pos {
3148 Some(p) => {
3149 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
3150 let next = ids.get(p + 1).copied();
3151 (prev, next)
3152 }
3153 None => (None, None),
3154 }
3155}
3156
3157async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
3160 let pool = &state.db;
3161 let subs = resolve_subscriptions(state, did).await;
3162
3163 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3164
3165 store::list_entry_ids(
3171 pool,
3172 did,
3173 list_view_of(q.view.as_deref()),
3174 scoped_feed_ids(&subs, &scope_urls).as_deref(),
3175 PREV_NEXT_MAX,
3176 )
3177 .await
3178 .unwrap_or_else(|err| {
3179 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3180 Vec::new()
3181 })
3182}
3183
3184fn list_view_of(view: Option<&str>) -> store::ListView {
3187 match view {
3188 Some("all") => store::ListView::All,
3189 Some("starred") => store::ListView::Starred,
3190 _ => store::ListView::Unread,
3191 }
3192}
3193
3194fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3200 let urls = scope_urls.as_ref()?;
3201 Some(
3202 subs.iter()
3203 .filter(|s| urls.contains(&s.sub.url))
3204 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3205 .collect(),
3206 )
3207}
3208
3209fn scope_query(q: &EntryQuery) -> String {
3211 let mut parts = Vec::new();
3212 if let Some(f) = q.feed.as_deref() {
3213 parts.push(format!("feed={}", qenc(f)));
3214 }
3215 if let Some(f) = q.folder.as_deref() {
3216 parts.push(format!("folder={}", qenc(f)));
3217 }
3218 if let Some(v) = q.view.as_deref() {
3219 if v != "unread" {
3220 parts.push(format!("view={}", qenc(v)));
3221 }
3222 }
3223 parts.join("&")
3224}
3225
3226#[derive(Debug, Deserialize)]
3232struct ReadForm {
3233 #[serde(default)]
3234 read: Option<String>,
3235}
3236
3237async fn mark_read(
3239 State(state): State<AppState>,
3240 Path(id): Path<i64>,
3241 headers: HeaderMap,
3242 Form(form): Form<ReadForm>,
3243) -> Result<Response, WebError> {
3244 let did = match current_did(&state, &headers).await {
3245 Some(d) => d,
3246 None => return Ok(Redirect::to("/login").into_response()),
3247 };
3248 let pool = &state.db;
3249
3250 let read = matches!(
3251 form.read.as_deref(),
3252 Some("true") | Some("1") | Some("on") | None
3253 );
3254
3255 resolve_subscriptions(&state, &did).await;
3260 if !store::mark_read(pool, &did, id, read).await? {
3261 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3262 }
3263
3264 if !is_htmx(&headers) {
3265 return Ok(Redirect::to("/").into_response());
3266 }
3267
3268 if is_reader_request(&headers) {
3272 let starred = entry_is_starred(pool, &did, id).await?;
3273 return Ok(render(&EntryActionBarTemplate {
3274 id,
3275 read,
3276 starred,
3277 oob: true,
3278 }));
3279 }
3280
3281 let row = build_entry_row(pool, &did, id, Some(read)).await?;
3282 match row {
3283 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3284 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3285 }
3286}
3287
3288#[derive(Debug, Deserialize)]
3294struct StarForm {
3295 #[serde(default)]
3296 starred: Option<String>,
3297}
3298
3299async fn toggle_star(
3305 State(state): State<AppState>,
3306 Path(id): Path<i64>,
3307 headers: HeaderMap,
3308 Form(form): Form<StarForm>,
3309) -> Result<Response, WebError> {
3310 let did = match current_did(&state, &headers).await {
3311 Some(d) => d,
3312 None => return Ok(Redirect::to("/login").into_response()),
3313 };
3314 let pool = &state.db;
3315
3316 let starred = matches!(
3317 form.starred.as_deref(),
3318 Some("true") | Some("1") | Some("on") | None
3319 );
3320
3321 resolve_subscriptions(&state, &did).await;
3325 if !store::mark_starred(pool, &did, id, starred).await? {
3326 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3327 }
3328
3329 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3332 let entry_url = entry.url.clone().unwrap_or_default();
3333 if !entry_url.is_empty() {
3334 if starred {
3335 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3336 saved.title = entry.title.clone();
3337 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3338 saved.entry_id = Some(entry.guid.clone());
3339 match state.repo().add_saved(&did, &saved).await {
3340 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3341 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3342 }
3343 } else {
3344 match state.repo().list_saved(&did).await {
3346 Ok(records) => {
3347 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3348 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3349 warn!(%err, %did, %rkey, "PDS saved delete failed");
3350 }
3351 }
3352 }
3353 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3354 }
3355 }
3356 }
3357 }
3358
3359 if !is_htmx(&headers) {
3360 return Ok(Redirect::to("/").into_response());
3361 }
3362
3363 if is_reader_request(&headers) {
3365 let read = entry_is_read(pool, &did, id).await?;
3366 return Ok(render(&EntryActionBarTemplate {
3367 id,
3368 read,
3369 starred,
3370 oob: true,
3371 }));
3372 }
3373
3374 let row = build_entry_row(pool, &did, id, None).await?;
3375 match row {
3376 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3377 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3378 }
3379}
3380
3381async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3383 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3384 .bind(feed_id)
3385 .fetch_optional(pool)
3386 .await
3387 .ok()
3388 .flatten()
3389}
3390
3391#[derive(Debug, Deserialize, Default)]
3398struct ReadAllQuery {
3399 #[serde(default)]
3400 feed: Option<String>,
3401}
3402
3403async fn mark_all_read(
3406 State(state): State<AppState>,
3407 headers: HeaderMap,
3408 Query(q): Query<ReadAllQuery>,
3409) -> Result<Response, WebError> {
3410 let did = match current_did(&state, &headers).await {
3411 Some(d) => d,
3412 None => return Ok(Redirect::to("/login").into_response()),
3413 };
3414 let pool = &state.db;
3415
3416 resolve_subscriptions(&state, &did).await;
3419
3420 if let Some(feed_url) = q.feed.as_deref() {
3421 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3422 store::mark_feed_read(pool, &did, feed.id, true).await?;
3423 }
3424 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3425 }
3426
3427 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3432 store::mark_feed_read(pool, &did, feed_id, true).await?;
3433 }
3434 Ok(Redirect::to("/").into_response())
3435}
3436
3437const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3447 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3448
3449const EXPORT_INCOMPLETE_REFUSAL: &str =
3456 "Could not read your subscriptions in full, so nothing was exported. Your \
3457 feeds are unchanged — try again, and if it keeps failing the list may be \
3458 larger than this reader can page through.";
3459
3460const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3466 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3467 feeds for now — private-feed support arrives when atproto's private data \
3468 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3469
3470#[derive(Debug, Deserialize)]
3472struct SubscribeForm {
3473 url: String,
3474 #[serde(default)]
3476 folder: Option<String>,
3477}
3478
3479async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3489 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3490 let canonical = format!(
3491 "{}{}",
3492 crate::atproto::AT_URI_PREFIX,
3493 &input[crate::atproto::AT_URI_PREFIX.len()..]
3494 );
3495 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3496 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3497 return Err(unsupported());
3498 }
3499 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3500 uri.authority.clone()
3501 } else {
3502 let handle =
3503 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3508 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3509 .await
3510 .map_err(|err| {
3511 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3512 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3513 })?
3514 };
3515 let url = format!(
3516 "{}{did}/{}/{}",
3517 crate::atproto::AT_URI_PREFIX,
3518 uri.collection,
3519 uri.rkey
3520 );
3521 if !feed::is_storable_feed_url(&url, true) {
3522 return Err(unsupported());
3523 }
3524 Ok(url)
3525}
3526
3527async fn add_subscription(
3529 State(state): State<AppState>,
3530 headers: HeaderMap,
3531 Form(form): Form<SubscribeForm>,
3532) -> Result<Response, WebError> {
3533 let did = match current_did(&state, &headers).await {
3534 Some(d) => d,
3535 None => return Ok(Redirect::to("/login").into_response()),
3536 };
3537 let pool = &state.db;
3538 let input = form.url.trim().to_string();
3539 if input.is_empty() {
3540 return Ok(Redirect::to("/").into_response());
3541 }
3542
3543 let cap = state.config.max_subs_per_did;
3547 if cap > 0 {
3548 match store::count_subscriptions_for_did(pool, &did).await {
3549 Ok(n) if n >= cap => {
3550 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3551 return Ok(Redirect::to(&format!(
3552 "/?flash={}",
3553 qenc(&format!(
3554 "Subscription limit reached ({cap}). Remove a feed before adding another."
3555 ))
3556 ))
3557 .into_response());
3558 }
3559 Ok(_) => {}
3560 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3561 }
3562 }
3563
3564 let is_at_uri = input
3569 .get(..crate::atproto::AT_URI_PREFIX.len())
3570 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3571 let publication_url = if is_at_uri {
3572 if !state.config.standard_site {
3573 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3574 return Ok(
3575 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3576 .into_response(),
3577 );
3578 }
3579 match publication_url_from_paste(&state, &input).await {
3580 Ok(url) => Some(url),
3581 Err(flash) => {
3582 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3583 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3584 }
3585 }
3586 } else {
3587 None
3588 };
3589
3590 if let feed::FeedPrivacy::Private(reason) =
3591 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3592 {
3593 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3594 return Ok(
3595 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3596 );
3597 }
3598
3599 let resolved = match publication_url {
3600 Some(url) => Ok(url),
3601 None => resolve_feed_url(&state.config, &input).await,
3602 };
3603 let feed_url = match resolved {
3604 Ok(u) => u,
3605 Err(err) => {
3606 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3607 return Ok(Redirect::to(&format!(
3608 "/?flash={}",
3609 qenc("Couldn't find a feed at that URL")
3610 ))
3611 .into_response());
3612 }
3613 };
3614
3615 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3619 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3620 return Ok(
3621 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3622 );
3623 }
3624
3625 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3630 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3631 return Ok(
3632 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3633 .into_response(),
3634 );
3635 }
3636
3637 let feeds_cap = state.config.max_feeds_global;
3641 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3642 match store::count_feeds(pool).await {
3643 Ok(n) if n >= feeds_cap => {
3644 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3645 return Ok(Redirect::to(&format!(
3646 "/?flash={}",
3647 qenc(
3648 "This instance is at its feed capacity right now. Please try again later."
3649 )
3650 ))
3651 .into_response());
3652 }
3653 Ok(_) => {}
3654 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3655 }
3656 }
3657
3658 store::upsert_feed(
3659 pool,
3660 &store::NewFeed {
3661 url: feed_url.clone(),
3662 ..Default::default()
3663 },
3664 )
3665 .await?;
3666
3667 if let Ok(client) = feed::build_client() {
3668 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3669 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3670 Ok(outcome) => {
3671 info!(feed = %feed_url, ?outcome, "polled new subscription");
3672 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3676 }
3677 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3678 }
3679 }
3680 }
3681
3682 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3683 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3684 sub.title = feed_row.title.clone();
3685 sub.site_url = feed_row.site_url.clone();
3686 }
3687 sub.folder = form
3688 .folder
3689 .map(|f| f.trim().to_string())
3690 .filter(|f| !f.is_empty());
3691
3692 match state.repo().add_subscription(&did, &sub).await {
3693 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3694 Err(err) => {
3695 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3696 }
3697 }
3698
3699 Ok(Redirect::to("/").into_response())
3700}
3701
3702async fn delete_subscription(
3704 State(state): State<AppState>,
3705 headers: HeaderMap,
3706 Path(rkey): Path<String>,
3707) -> Result<Response, WebError> {
3708 let did = match current_did(&state, &headers).await {
3709 Some(d) => d,
3710 None => return Ok(Redirect::to("/login").into_response()),
3711 };
3712 match state.repo().remove_subscription(&did, &rkey).await {
3713 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3714 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3715 }
3716 Ok(Redirect::to("/").into_response())
3717}
3718
3719#[derive(Debug, Deserialize)]
3721struct RenameSubForm {
3722 url: String,
3723 #[serde(default)]
3724 title: Option<String>,
3725 #[serde(default)]
3726 site_url: Option<String>,
3727 #[serde(default)]
3728 folder: Option<String>,
3729 #[serde(default)]
3735 seen_url: Option<String>,
3736 #[serde(default)]
3739 seen_title: Option<String>,
3740 #[serde(default)]
3744 seen_folder: Option<String>,
3745}
3746
3747async fn rename_subscription(
3750 State(state): State<AppState>,
3751 headers: HeaderMap,
3752 Path(rkey): Path<String>,
3753 Form(form): Form<RenameSubForm>,
3754) -> Result<Response, WebError> {
3755 let did = match current_did(&state, &headers).await {
3756 Some(d) => d,
3757 None => return Ok(Redirect::to("/login").into_response()),
3758 };
3759 let feed_url = form.url.trim().to_string();
3760
3761 if feed_url.is_empty() {
3765 return Ok(Redirect::to("/").into_response());
3766 }
3767
3768 let mut base: Option<Subscription> = None;
3784 for attempt in 1..=RENAME_ATTEMPTS {
3785 match rename_subscription_once(&state, &did, &rkey, &form, &mut base).await? {
3786 RenameAttempt::Done(resp) => return Ok(resp),
3787 RenameAttempt::Raced => {
3788 info!(%did, %rkey, attempt, "subscription changed between read and write; re-reading");
3789 }
3790 }
3791 }
3792 warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused rename: the subscription kept changing elsewhere");
3793 Ok(rename_conflict_response())
3794}
3795
3796fn rename_conflict_response() -> Response {
3799 Redirect::to(&format!(
3800 "/?flash={}",
3801 qenc(
3802 "This subscription was changed elsewhere while you were editing it — \
3803 nothing was renamed or moved. Reload and try again."
3804 )
3805 ))
3806 .into_response()
3807}
3808
3809fn form_value(v: Option<&str>) -> Option<String> {
3812 v.map(str::trim)
3813 .filter(|t| !t.is_empty())
3814 .map(str::to_string)
3815}
3816
3817#[derive(Debug, PartialEq, Eq)]
3819struct MergedRename {
3820 sub: Subscription,
3822 repoint: bool,
3824 already_saved: bool,
3827}
3828
3829#[derive(Debug, PartialEq, Eq)]
3831struct RenameConflict(&'static str);
3832
3833fn merge_rename(
3866 form: &RenameSubForm,
3867 base: &Subscription,
3868 fresh: Subscription,
3869) -> Result<MergedRename, RenameConflict> {
3870 let mut sub = fresh;
3871 let mut edited = 0;
3876 let mut to_write = 0;
3877
3878 let posted_url = form.url.trim();
3879 let seen_url = form.seen_url.as_deref().unwrap_or(&base.url).trim();
3880 let mut repoint = false;
3881 if posted_url != seen_url {
3882 edited += 1;
3883 if sub.url.trim() == posted_url {
3884 } else if sub.url.trim() != base.url.trim() {
3887 return Err(RenameConflict("url"));
3888 } else {
3889 to_write += 1;
3890 repoint = true;
3891 }
3892 }
3893 sub.url = if repoint { posted_url } else { sub.url.trim() }.to_string();
3895
3896 let posted_title = form_value(form.title.as_deref());
3897 let seen_title = match form.seen_title.as_deref() {
3898 Some(seen) => form_value(Some(seen)),
3899 None => base.title.clone(),
3900 };
3901 if posted_title != seen_title {
3902 edited += 1;
3903 if sub.title == posted_title {
3904 } else if sub.title != base.title {
3906 return Err(RenameConflict("title"));
3907 } else {
3908 to_write += 1;
3909 sub.title = posted_title;
3910 }
3911 }
3912
3913 if form.folder.is_some() || form.seen_folder.is_some() {
3921 let posted_folder = form_value(form.folder.as_deref());
3922 let seen_folder = match form.seen_folder.as_deref() {
3923 Some(seen) => form_value(Some(seen)),
3924 None => base.folder.clone(),
3925 };
3926 if posted_folder != seen_folder {
3927 edited += 1;
3928 if sub.folder == posted_folder {
3929 } else if sub.folder != base.folder {
3931 return Err(RenameConflict("folder"));
3932 } else {
3933 to_write += 1;
3934 sub.folder = posted_folder;
3935 }
3936 }
3937 }
3938
3939 match form_value(form.site_url.as_deref()) {
3948 Some(site) if Some(&site) != base.site_url.as_ref() => {
3949 edited += 1;
3950 if sub.site_url.as_ref() == Some(&site) {
3951 } else if sub.site_url != base.site_url {
3953 return Err(RenameConflict("siteUrl"));
3954 } else {
3955 to_write += 1;
3956 sub.site_url = Some(site);
3957 }
3958 }
3959 Some(_) => {}
3960 None if repoint => sub.site_url = None,
3961 None => {}
3962 }
3963 if repoint {
3964 sub.fetch_hint = None;
3965 }
3966 Ok(MergedRename {
3967 sub,
3968 repoint,
3969 already_saved: edited > 0 && to_write == 0,
3972 })
3973}
3974
3975const RENAME_ATTEMPTS: u32 = 2;
3979
3980enum RenameAttempt {
3982 Done(Response),
3985 Raced,
3988}
3989
3990async fn rename_subscription_once(
3997 state: &AppState,
3998 did: &str,
3999 rkey: &str,
4000 form: &RenameSubForm,
4001 base: &mut Option<Subscription>,
4002) -> Result<RenameAttempt, WebError> {
4003 use RenameAttempt::Done;
4004
4005 let found = match state.repo().list_subscriptions_with_cids(did).await {
4035 Ok(subs) => subs
4036 .into_iter()
4037 .find(|(k, _, _)| k == rkey)
4038 .map(|(_, cid, s)| (cid, s)),
4039 Err(err) => {
4040 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
4041 return Ok(Done(
4042 Redirect::to(&format!(
4043 "/?flash={}",
4044 qenc("Could not reach your PDS — nothing was renamed or moved.")
4045 ))
4046 .into_response(),
4047 ));
4048 }
4049 };
4050 let Some((read_cid, fresh)) = found else {
4051 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
4055 return Ok(Done(
4056 Redirect::to(&format!(
4057 "/?flash={}",
4058 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
4059 ))
4060 .into_response(),
4061 ));
4062 };
4063
4064 let base = base.get_or_insert_with(|| fresh.clone());
4086 let MergedRename {
4087 sub,
4088 repoint: url_changed,
4089 already_saved,
4090 } = match merge_rename(form, base, fresh) {
4091 Ok(merged) => merged,
4092 Err(RenameConflict(field)) => {
4093 warn!(%did, %rkey, field, "refused rename: the reader and another client both changed the same field");
4094 return Ok(Done(rename_conflict_response()));
4095 }
4096 };
4097 if already_saved {
4101 info!(%did, %rkey, "rename already in the record; nothing to write");
4102 return Ok(Done(Redirect::to("/").into_response()));
4103 }
4104 let feed_url = sub.url.clone();
4105
4106 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
4119 if url_changed && !storable {
4120 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
4121 return Ok(Done(
4122 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
4123 .into_response(),
4124 ));
4125 }
4126
4127 if url_changed {
4133 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
4134 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
4135 return Ok(Done(
4136 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
4137 ));
4138 }
4139 }
4140
4141 let feeds_cap = state.config.max_feeds_global;
4146 if url_changed
4147 && feeds_cap > 0
4148 && store::get_feed_by_url(&state.db, &feed_url)
4149 .await?
4150 .is_none()
4151 {
4152 match store::count_feeds(&state.db).await {
4153 Ok(n) if n >= feeds_cap => {
4154 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
4155 return Ok(Done(
4156 Redirect::to(&format!(
4157 "/?flash={}",
4158 qenc(
4159 "This instance is at its feed capacity right now. Please try again later."
4160 )
4161 ))
4162 .into_response(),
4163 ));
4164 }
4165 Ok(_) => {}
4166 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
4167 }
4168 }
4169
4170 if read_cid.is_none() {
4182 warn!(%did, %rkey, "the PDS listed this subscription without a CID; renaming without a compare-and-swap");
4183 }
4184 let res = match state
4185 .repo()
4186 .update_subscription(did, rkey, &sub, read_cid.as_deref())
4187 .await
4188 {
4189 Ok(res) => res,
4190 Err(err) if crate::atproto::is_invalid_swap(&err) => return Ok(RenameAttempt::Raced),
4193 Err(err) => {
4194 warn!(%err, %did, %rkey, "PDS subscription update failed");
4195 return Ok(Done(
4196 Redirect::to(&format!(
4197 "/?flash={}",
4198 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
4199 ))
4200 .into_response(),
4201 ));
4202 }
4203 };
4204 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
4205
4206 let cache_write = storable
4232 && (url_changed
4233 || match store::get_feed_by_url(&state.db, &sub.url).await {
4234 Ok(row) => row.is_some(),
4235 Err(err) => {
4236 warn!(%err, %did, url = %sub.url, "could not look up the cached feed row after a rename");
4237 false
4238 }
4239 });
4240 if !cache_write {
4241 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
4242 } else if let Err(err) = store::upsert_feed(
4243 &state.db,
4244 &store::NewFeed {
4245 url: sub.url.clone(),
4246 title: sub.title.clone(),
4247 site_url: sub.site_url.clone(),
4248 ..Default::default()
4249 },
4250 )
4251 .await
4252 {
4253 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
4256 }
4257
4258 Ok(Done(Redirect::to("/").into_response()))
4259}
4260
4261#[derive(Debug, Deserialize)]
4267struct FolderForm {
4268 name: String,
4269}
4270
4271async fn create_folder(
4273 State(state): State<AppState>,
4274 headers: HeaderMap,
4275 Form(form): Form<FolderForm>,
4276) -> Result<Response, WebError> {
4277 let did = match current_did(&state, &headers).await {
4278 Some(d) => d,
4279 None => return Ok(Redirect::to("/login").into_response()),
4280 };
4281 let name = form.name.trim();
4282 if name.is_empty() {
4283 return Ok(Redirect::to("/").into_response());
4284 }
4285 let folder = Folder::new(name.to_string(), now_rfc3339());
4286 match state.repo().add_folder(&did, &folder).await {
4287 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
4288 Err(err) => warn!(%err, %did, "PDS folder create failed"),
4289 }
4290 Ok(Redirect::to("/").into_response())
4291}
4292
4293#[derive(Debug, Deserialize)]
4295struct RenameFolderForm {
4296 name: String,
4297 #[serde(default)]
4302 seen_name: Option<String>,
4303}
4304
4305async fn rename_folder(
4318 State(state): State<AppState>,
4319 headers: HeaderMap,
4320 Path(rkey): Path<String>,
4321 Form(form): Form<RenameFolderForm>,
4322) -> Result<Response, WebError> {
4323 let did = match current_did(&state, &headers).await {
4324 Some(d) => d,
4325 None => return Ok(Redirect::to("/login").into_response()),
4326 };
4327 if form.name.trim().is_empty() {
4328 return Ok(Redirect::to("/").into_response());
4329 }
4330 let mut base: Option<Folder> = None;
4331 for attempt in 1..=RENAME_ATTEMPTS {
4332 match rename_folder_once(&state, &did, &rkey, &form, &mut base).await {
4333 RenameAttempt::Done(resp) => return Ok(resp),
4334 RenameAttempt::Raced => {
4335 info!(%did, %rkey, attempt, "folder changed between read and write; re-reading");
4336 }
4337 }
4338 }
4339 warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused folder rename: the folder kept changing elsewhere");
4340 Ok(folder_flash(FOLDER_RENAME_CONFLICT))
4341}
4342
4343const FOLDER_RENAME_CONFLICT: &str = "This folder was changed elsewhere while you were renaming \
4345 it — it was not renamed. Reload and try again.";
4346
4347fn folder_flash(message: &str) -> Response {
4349 Redirect::to(&format!("/?flash={}", qenc(message))).into_response()
4350}
4351
4352#[derive(Debug, PartialEq, Eq)]
4354enum FolderMerge {
4355 Write(Folder),
4357 AlreadySaved,
4360 Unchanged,
4362}
4363
4364fn merge_folder_rename(
4380 posted: &str,
4381 seen: Option<&str>,
4382 base: &Folder,
4383 fresh: Folder,
4384) -> Result<FolderMerge, RenameConflict> {
4385 let posted = posted.trim();
4386 let ancestor = seen.unwrap_or(&base.name).trim();
4387 if posted == ancestor {
4388 return Ok(FolderMerge::Unchanged);
4389 }
4390 let current = fresh.name.trim();
4391 if current == posted {
4392 return Ok(FolderMerge::AlreadySaved);
4393 }
4394 if current != ancestor {
4395 return Err(RenameConflict("name"));
4396 }
4397 let mut folder = fresh;
4398 folder.name = posted.to_string();
4399 Ok(FolderMerge::Write(folder))
4400}
4401
4402async fn rename_folder_once(
4406 state: &AppState,
4407 did: &str,
4408 rkey: &str,
4409 form: &RenameFolderForm,
4410 base: &mut Option<Folder>,
4411) -> RenameAttempt {
4412 use RenameAttempt::Done;
4413
4414 let found = match state.repo().list_folders_with_cids(did).await {
4419 Ok(folders) => folders
4420 .into_iter()
4421 .find(|(k, _, _)| k == rkey)
4422 .map(|(_, cid, f)| (cid, f)),
4423 Err(err) => {
4424 warn!(%err, %did, %rkey, "could not read the folder before renaming it");
4425 return Done(folder_flash(
4426 "Could not reach your PDS — the folder was not renamed.",
4427 ));
4428 }
4429 };
4430 let Some((read_cid, fresh)) = found else {
4431 warn!(%did, %rkey, "refused folder rename: no such folder in the repo");
4434 return Done(folder_flash(
4435 "That folder no longer exists — it may have been deleted elsewhere. \
4436 Nothing was renamed.",
4437 ));
4438 };
4439
4440 let base = base.get_or_insert_with(|| fresh.clone());
4441 let folder = match merge_folder_rename(&form.name, form.seen_name.as_deref(), base, fresh) {
4442 Ok(FolderMerge::Write(folder)) => folder,
4443 Ok(FolderMerge::AlreadySaved) => {
4444 info!(%did, %rkey, "folder already has this name; nothing to write");
4445 return Done(Redirect::to("/").into_response());
4446 }
4447 Ok(FolderMerge::Unchanged) => return Done(Redirect::to("/").into_response()),
4448 Err(RenameConflict(field)) => {
4449 warn!(%did, %rkey, field, "refused folder rename: the reader and another client both renamed it");
4450 return Done(folder_flash(FOLDER_RENAME_CONFLICT));
4451 }
4452 };
4453
4454 if read_cid.is_none() {
4455 warn!(%did, %rkey, "the PDS listed this folder without a CID; renaming without a compare-and-swap");
4456 }
4457 match state
4458 .repo()
4459 .rename_folder(did, rkey, &folder, read_cid.as_deref())
4460 .await
4461 {
4462 Ok(res) => {
4463 info!(%did, %rkey, uri = %res.uri, "renamed folder");
4464 Done(Redirect::to("/").into_response())
4465 }
4466 Err(err) if crate::atproto::is_invalid_swap(&err) => RenameAttempt::Raced,
4467 Err(err) => {
4468 warn!(%err, %did, %rkey, "PDS folder rename failed");
4469 Done(folder_flash(
4470 "Could not save that change to your PDS — the folder was not renamed.",
4471 ))
4472 }
4473 }
4474}
4475
4476async fn delete_folder(
4479 State(state): State<AppState>,
4480 headers: HeaderMap,
4481 Path(rkey): Path<String>,
4482) -> Result<Response, WebError> {
4483 let did = match current_did(&state, &headers).await {
4484 Some(d) => d,
4485 None => return Ok(Redirect::to("/login").into_response()),
4486 };
4487 match state.repo().remove_folder(&did, &rkey).await {
4488 Ok(()) => info!(%did, %rkey, "deleted folder record"),
4489 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
4490 }
4491 Ok(Redirect::to("/").into_response())
4492}
4493
4494async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
4498 let parsed =
4499 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
4500
4501 let client = feed::build_client()?;
4502 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
4506 let final_url = resp.url().clone();
4507 let content_type = resp
4508 .headers()
4509 .get(axum::http::header::CONTENT_TYPE)
4510 .and_then(|v| v.to_str().ok())
4511 .unwrap_or("")
4512 .to_ascii_lowercase();
4513 let raw = crate::net::read_capped(resp).await?;
4516 let body = String::from_utf8_lossy(&raw).into_owned();
4517
4518 let looks_like_feed = content_type.contains("xml")
4519 || content_type.contains("rss")
4520 || content_type.contains("atom")
4521 || content_type.contains("application/feed+json")
4522 || {
4523 let head = body.trim_start();
4524 head.starts_with("<?xml")
4525 || head.starts_with("<rss")
4526 || head.starts_with("<feed")
4527 || head.contains("<rss")
4528 || head.contains("<feed")
4529 };
4530 if looks_like_feed {
4531 return Ok(final_url.to_string());
4532 }
4533
4534 match feed::discover_feed(&body, Some(&final_url)) {
4535 Some(u) => Ok(u.to_string()),
4536 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
4537 }
4538}
4539
4540#[derive(Debug, Deserialize, Default)]
4546struct LoginQuery {
4547 #[serde(default)]
4548 handle: Option<String>,
4549 #[serde(default)]
4550 error: Option<String>,
4551 #[serde(default)]
4552 flash: Option<String>,
4553}
4554
4555async fn login_form(
4563 State(state): State<AppState>,
4564 headers: HeaderMap,
4565 Query(q): Query<LoginQuery>,
4566) -> Response {
4567 if let Some(handle) = q
4568 .handle
4569 .map(|h| h.trim().to_string())
4570 .filter(|h| !h.is_empty())
4571 {
4572 if !may_start_oauth(&state, &headers, &handle).await {
4573 return Redirect::to("/beta/redeem").into_response();
4574 }
4575 return start_oauth(&state, &handle).await;
4576 }
4577 render(&LoginTemplate {
4578 card: login_card(&state.config),
4579 repo_url: REPO_URL,
4580 error: q.error.unwrap_or_default(),
4581 flash: q.flash.unwrap_or_default(),
4582 })
4583}
4584
4585async fn login_submit(
4588 State(state): State<AppState>,
4589 headers: HeaderMap,
4590 Form(form): Form<LoginForm>,
4591) -> Response {
4592 let handle = form.handle.trim();
4593 if handle.is_empty() {
4594 return login_error(&state, "Enter your atproto handle.");
4595 }
4596 if !may_start_oauth(&state, &headers, handle).await {
4597 return Redirect::to("/beta/redeem").into_response();
4598 }
4599 start_oauth(&state, handle).await
4600}
4601
4602async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4620 may_start_oauth_with(state, headers, handle, |h| async move {
4624 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4625 .await
4626 .ok()
4627 })
4628 .await
4629}
4630
4631async fn may_start_oauth_with<F, Fut>(
4637 state: &AppState,
4638 headers: &HeaderMap,
4639 handle: &str,
4640 resolve: F,
4641) -> bool
4642where
4643 F: FnOnce(String) -> Fut,
4644 Fut: std::future::Future<Output = Option<String>>,
4645{
4646 if let Some(did) = current_did(state, headers).await {
4648 if store::has_beta_access(&state.db, &did)
4649 .await
4650 .unwrap_or(false)
4651 {
4652 return true;
4653 }
4654 }
4655 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4657 return true;
4658 }
4659 match resolve(handle.to_string()).await {
4663 Some(did) => store::has_beta_access(&state.db, &did)
4664 .await
4665 .unwrap_or(false),
4666 None => {
4667 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4668 false
4669 }
4670 }
4671}
4672
4673async fn start_oauth(state: &AppState, handle: &str) -> Response {
4695 match state.config.repo_backend {
4696 crate::metrics::Backend::Sidecar => {
4697 let url = state.sidecar.login_url(handle, None);
4698 info!(%handle, "redirecting to OAuth sidecar login");
4699 Redirect::to(&url).into_response()
4700 }
4701 crate::metrics::Backend::Rust => {
4702 let Some(runtime) = state.oauth.as_deref() else {
4703 warn!("the rust backend is live but its OAuth runtime is absent");
4704 return login_error(state, "Login is not available right now.");
4705 };
4706 match crate::oauth::login::start(
4707 runtime,
4708 &state.http,
4709 &state.db,
4710 handle,
4711 crate::store::now_unix(),
4712 )
4713 .await
4714 {
4715 Ok(started) => {
4716 info!(%handle, "pushed authorization request; redirecting to the PDS");
4717 let mut resp = Redirect::to(&started.authorize_url).into_response();
4718 set_cookie(
4719 &mut resp,
4720 &cookie::sign_value(
4721 OAUTH_BINDING_COOKIE,
4722 &started.binding_token,
4723 &state.config.cookie_secret,
4724 OAUTH_BINDING_MAX_AGE_SECS,
4725 ),
4726 );
4727 resp
4728 }
4729 Err(err) => {
4730 warn!(%err, %handle, "could not start the OAuth login");
4733 login_error(state, "Could not start login for that handle.")
4734 }
4735 }
4736 }
4737 }
4738}
4739
4740fn clear_binding_cookie(resp: &mut Response) {
4744 set_cookie(
4745 resp,
4746 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4747 );
4748}
4749
4750#[derive(Debug, Deserialize)]
4752struct LoginForm {
4753 handle: String,
4754}
4755
4756#[derive(Debug, Deserialize, Default)]
4765struct CallbackQuery {
4766 #[serde(default)]
4768 session_id: Option<String>,
4769 #[serde(default)]
4771 code: Option<String>,
4772 #[serde(default)]
4773 state: Option<String>,
4774 #[serde(default)]
4775 iss: Option<String>,
4776 #[serde(default)]
4779 response: Option<String>,
4780 #[serde(default)]
4781 error: Option<String>,
4782 #[serde(default)]
4783 error_description: Option<String>,
4784}
4785
4786async fn oauth_callback(
4793 State(state): State<AppState>,
4794 headers: HeaderMap,
4795 Query(q): Query<CallbackQuery>,
4796) -> Response {
4797 let sidecar_shape =
4827 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4828 let sidecar_handoff = sidecar_shape
4829 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4830 if let Some(err) = q.error.clone() {
4831 let slug = crate::oauth::flow::known_error_slug(&err);
4847 warn!(
4848 error = slug,
4849 desc_len = q.error_description.as_deref().map_or(0, str::len),
4850 "OAuth callback returned an error"
4851 );
4852 if sidecar_handoff || state.oauth.is_none() {
4853 return login_error(&state, &format!("Login failed: {slug}"));
4854 }
4855 }
4858
4859 let session = if sidecar_handoff {
4862 let session_id = q.session_id.clone().unwrap_or_default();
4863 match state.sidecar.resolve_session(&session_id).await {
4864 Ok(Some(s)) => s,
4865 Ok(None) => {
4866 warn!("OAuth callback session_id did not resolve (expired/unknown)");
4867 return login_error(&state, "Login session expired — please try again.");
4868 }
4869 Err(err) => {
4870 warn!(%err, "failed to resolve OAuth session via the sidecar");
4871 return login_error(&state, "Login failed talking to the auth service.");
4872 }
4873 }
4874 } else {
4875 let Some(runtime) = state.oauth.as_deref() else {
4876 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4877 return login_error(&state, "Login failed: this login could not be completed.");
4878 };
4879 let params = crate::oauth::flow::CallbackParams {
4880 code: q.code.clone(),
4881 state: q.state.clone(),
4882 iss: q.iss.clone(),
4883 error: q.error.clone(),
4887 error_description: q.error_description.clone(),
4888 response: q.response.clone(),
4889 };
4890 let binding =
4891 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4892 match crate::oauth::login::complete(
4893 runtime,
4894 &state.http,
4895 &state.db,
4896 ¶ms,
4897 binding.as_deref(),
4898 crate::store::now_unix(),
4899 )
4900 .await
4901 {
4902 Ok(done) => crate::atproto::SidecarSession {
4903 did: done.did,
4904 handle: done.handle,
4905 },
4906 Err(err) => {
4907 warn!(%err, "could not complete the OAuth callback");
4910 let mut resp = login_error(&state, "Login failed — please try again.");
4911 clear_binding_cookie(&mut resp);
4912 return resp;
4913 }
4914 }
4915 };
4916
4917 let mut clear_invite = false;
4920 if !store::has_beta_access(&state.db, &session.did)
4921 .await
4922 .unwrap_or(false)
4923 {
4924 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4926 Some(c) => c,
4927 None => {
4928 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4929 return Redirect::to("/beta/redeem").into_response();
4930 }
4931 };
4932 match store::redeem_code(
4933 &state.db,
4934 &code,
4935 &session.did,
4936 session.handle.as_deref(),
4937 state.config.beta_cap,
4938 )
4939 .await
4940 {
4941 Ok(Ok(())) => {
4942 clear_invite = true;
4943 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4944 }
4945 Ok(Err(policy)) => {
4946 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4947 let mut resp = redeem_bounce(&state, &policy).into_response();
4948 clear_invite_cookie(&mut resp);
4950 return resp;
4951 }
4952 Err(err) => {
4953 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4954 return login_error(&state, "Login failed while confirming your invite.");
4955 }
4956 }
4957 }
4958
4959 let sid = state.sessions.create(Session {
4962 did: session.did.clone(),
4963 handle: session.handle.clone(),
4964 });
4965 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4966 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4967
4968 let mut resp = Redirect::to("/").into_response();
4969 set_cookie(&mut resp, &cookie);
4970 clear_binding_cookie(&mut resp);
4971 if clear_invite {
4972 clear_invite_cookie(&mut resp);
4973 }
4974 resp
4975}
4976
4977const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4995
4996async fn flush_before_revoke(state: &AppState, did: &str) {
5009 match tokio::time::timeout(
5010 SIGN_OUT_FLUSH_BUDGET,
5011 crate::readstate::flush_did(state, did),
5012 )
5013 .await
5014 {
5015 Ok(Ok(())) => {}
5016 Ok(Err(err)) => {
5017 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
5018 }
5019 Err(_) => warn!(
5020 %did,
5021 budget = ?SIGN_OUT_FLUSH_BUDGET,
5022 "sign-out: final read-state flush timed out; it will park until next sign-in"
5023 ),
5024 }
5025}
5026
5027async fn revoke_everywhere(state: &AppState, did: &str) {
5028 let sidecar_started = std::time::Instant::now();
5038 let sidecar_ok = match state.sidecar.revoke_session(did).await {
5039 Ok(res) => {
5040 info!(%did, revoked = res.revoked, "sidecar session revoked");
5041 true
5042 }
5043 Err(err) => {
5044 warn!(%did, %err, "sidecar revoke failed; continuing");
5045 false
5046 }
5047 };
5048 state.metrics.record(
5049 crate::metrics::Backend::Sidecar,
5050 "oauth_revoke",
5051 sidecar_started.elapsed().as_micros() as u64,
5052 sidecar_ok,
5053 );
5054
5055 if let Some(runtime) = state.oauth.as_deref() {
5056 let revoke_started = std::time::Instant::now();
5057 let outcome = crate::oauth::revoke::sign_out_discovering(
5058 runtime,
5059 &state.http,
5060 &state.db,
5061 did,
5062 crate::store::now_unix(),
5063 )
5064 .await;
5065 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
5076 state.metrics.record(
5077 crate::metrics::Backend::Rust,
5078 "oauth_revoke",
5079 revoke_started.elapsed().as_micros() as u64,
5080 revoke_ok,
5081 );
5082 match outcome {
5083 crate::oauth::revoke::Revocation::Revoked => {
5084 info!(%did, "rust OAuth session revoked at the PDS")
5085 }
5086 crate::oauth::revoke::Revocation::NoSession => {}
5087 crate::oauth::revoke::Revocation::Failed(reason) => {
5088 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
5089 }
5090 }
5091 }
5092}
5093
5094async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
5104 if let Some(user) = current_session(&state, &headers).await {
5105 if let Some(sid) = user.sid {
5108 state.sessions.remove(&sid);
5109 flush_before_revoke(&state, &user.did).await;
5111 revoke_everywhere(&state, &user.did).await;
5112 }
5113 }
5114 let mut resp = Redirect::to("/login").into_response();
5115 set_cookie(
5116 &mut resp,
5117 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5118 );
5119 resp
5120}
5121
5122#[derive(Debug, Deserialize)]
5125struct DeleteAccountForm {
5126 #[serde(default)]
5127 confirm: String,
5128}
5129
5130const DELETE_CONFIRM_PHRASE: &str = "DELETE";
5132
5133async fn account_delete(
5149 State(state): State<AppState>,
5150 headers: HeaderMap,
5151 Form(form): Form<DeleteAccountForm>,
5152) -> Result<Response, WebError> {
5153 let user = match current_session(&state, &headers).await {
5154 Some(u) => u,
5155 None => return Ok(Redirect::to("/login").into_response()),
5156 };
5157 let did = user.did.clone();
5158
5159 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
5161 return Ok(Redirect::to(&format!(
5162 "/manage?flash={}",
5163 qenc("Type DELETE to confirm — nothing was deleted.")
5164 ))
5165 .into_response());
5166 }
5167
5168 let counts = store::purge_did_data(&state.db, &did).await?;
5170 info!(
5171 %did,
5172 total = counts.total(),
5173 entry_state = counts.entry_state,
5174 read_cursor = counts.read_cursor,
5175 sub_ref = counts.sub_ref,
5176 beta_access = counts.beta_access,
5177 invite_codes = counts.invite_codes,
5178 "account/delete: local rows purged"
5179 );
5180
5181 revoke_everywhere(&state, &did).await;
5184
5185 if let Some(sid) = user.sid {
5187 state.sessions.remove(&sid);
5188 }
5189 let mut resp = Redirect::to(&format!(
5190 "/login?flash={}",
5191 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
5192 ))
5193 .into_response();
5194 set_cookie(
5195 &mut resp,
5196 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5197 );
5198 Ok(resp)
5199}
5200
5201fn login_card(config: &Config) -> Card {
5203 Card::public(
5204 config,
5205 "/login",
5206 "Sign in — FeatherReader",
5207 "Sign in to FeatherReader with your atproto handle. You approve access on \
5208 your own server — no signup, no password.",
5209 )
5210}
5211
5212fn login_error(state: &AppState, msg: &str) -> Response {
5214 render(&LoginTemplate {
5215 card: login_card(&state.config),
5216 repo_url: REPO_URL,
5217 error: msg.to_string(),
5218 flash: String::new(),
5219 })
5220}
5221
5222#[derive(Debug, Deserialize)]
5228struct RedeemForm {
5229 code: String,
5230}
5231
5232async fn beta_redeem_form(State(state): State<AppState>) -> Response {
5235 let full = store::count_beta_access(&state.db)
5236 .await
5237 .map(|n| n >= state.config.beta_cap)
5238 .unwrap_or(false);
5239 render(&BetaRedeemTemplate {
5240 card: redeem_card(&state.config),
5241 repo_url: REPO_URL,
5242 error: String::new(),
5243 capacity_full: full,
5244 })
5245}
5246
5247async fn beta_redeem_submit(
5257 State(state): State<AppState>,
5258 Form(form): Form<RedeemForm>,
5259) -> Response {
5260 let code = form.code.trim().to_uppercase();
5261 if code.is_empty() {
5262 return render(&BetaRedeemTemplate {
5263 card: redeem_card(&state.config),
5264 repo_url: REPO_URL,
5265 error: "Enter your invite code.".to_string(),
5266 capacity_full: false,
5267 });
5268 }
5269
5270 match preflight_code(&state, &code).await {
5271 Ok(()) => {
5272 let cookie = sign_invite(&code, &state.config.cookie_secret);
5273 let mut resp = Redirect::to("/login").into_response();
5274 set_cookie(&mut resp, &cookie);
5275 info!("invite code preflight OK; reserving intent + redirecting to /login");
5276 resp
5277 }
5278 Err(policy) => {
5279 warn!(?policy, "invite code preflight rejected");
5280 redeem_bounce(&state, &policy)
5281 }
5282 }
5283}
5284
5285async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
5291 let count = match store::count_beta_access(&state.db).await {
5299 Ok(n) => n,
5300 Err(err) => {
5301 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
5302 return Err(store::RedeemError::CapacityFull);
5303 }
5304 };
5305 if count >= state.config.beta_cap {
5306 return Err(store::RedeemError::CapacityFull);
5307 }
5308 let row = sqlx::query_as::<_, (String, i64)>(
5310 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
5311 )
5312 .bind(code)
5313 .fetch_optional(&state.db)
5314 .await
5315 .ok()
5316 .flatten();
5317 let (status, expires_at) = match row {
5318 Some(r) => r,
5319 None => return Err(store::RedeemError::NotFound),
5320 };
5321 let now = chrono::Utc::now().timestamp();
5322 match status.as_str() {
5323 "active" if expires_at >= now => Ok(()),
5324 "active" => Err(store::RedeemError::Expired),
5325 "expired" => Err(store::RedeemError::Expired),
5326 _ => Err(store::RedeemError::AlreadyRedeemed),
5328 }
5329}
5330
5331fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
5334 use store::RedeemError::*;
5335 let (msg, capacity_full) = match policy {
5336 NotFound => ("That invite code isn't valid.", false),
5337 Expired => ("That invite code has expired.", false),
5338 AlreadyRedeemed => ("That invite code has already been used.", false),
5339 CapacityFull => ("", true),
5340 };
5341 render(&BetaRedeemTemplate {
5342 card: redeem_card(&state.config),
5343 repo_url: REPO_URL,
5344 error: msg.to_string(),
5345 capacity_full,
5346 })
5347}
5348
5349fn redeem_card(config: &Config) -> Card {
5352 Card::public(
5353 config,
5354 "/beta/redeem",
5355 "Redeem an invite — FeatherReader",
5356 "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
5357 in with your atproto handle.",
5358 )
5359}
5360
5361#[derive(Debug, Deserialize, Default)]
5363struct MintQuery {
5364 #[serde(default)]
5365 n: Option<u32>,
5366}
5367
5368async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
5381 let Some(runtime) = state.oauth.as_deref() else {
5382 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
5384 };
5385 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
5386}
5387
5388async fn oauth_jwks(State(state): State<AppState>) -> Response {
5395 let Some(runtime) = state.oauth.as_deref() else {
5396 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
5397 };
5398 match runtime.client_key.as_ref() {
5399 Some(key) => match key.jwks_document() {
5400 Ok(doc) => axum::Json(doc).into_response(),
5401 Err(err) => {
5402 warn!(%err, "could not render the client JWKS");
5403 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
5404 }
5405 },
5406 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
5407 }
5408}
5409
5410const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
5412
5413async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
5422 let did = match current_did(&state, &headers).await {
5423 Some(d) => d,
5424 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5425 };
5426 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5427 warn!(%did, "admin metrics denied: not an admin-seed DID");
5428 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5429 }
5430
5431 if let Err(err) =
5436 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
5437 {
5438 warn!(%err, "could not flush repo timings before rendering");
5439 }
5440 let rows = match crate::metrics::persisted_rows(&state.db).await {
5441 Ok(rows) => rows,
5442 Err(err) => {
5443 warn!(%err, "could not read persisted repo timings");
5444 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
5445 }
5446 };
5447
5448 let parked = match crate::store::parked_readstate_dids(&state.db).await {
5454 Ok(n) => n.to_string(),
5455 Err(err) => {
5456 warn!(%err, "could not count parked read-state DIDs");
5457 "unknown".to_string()
5458 }
5459 };
5460 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
5467 Ok(f) => f,
5468 Err(err) => {
5469 warn!(%err, "could not list failing feeds");
5470 Vec::new()
5471 }
5472 };
5473 let mut failing_block = String::new();
5474 if !failing.is_empty() {
5475 failing_block.push_str("\nfailing feeds (worst first)\n");
5476 for f in &failing {
5477 failing_block.push_str(&format!(
5478 " {:>4}x {:<8} {}\n {}\n",
5479 f.consecutive_errors,
5480 f.kind.as_deref().unwrap_or("unknown"),
5481 f.url,
5482 f.detail.as_deref().unwrap_or("(no detail recorded)"),
5483 ));
5484 }
5485 }
5486
5487 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
5492 Ok(n) => n,
5493 Err(err) => {
5494 warn!(%err, "could not count unpollable feeds");
5495 -1
5496 }
5497 };
5498 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
5499
5500 let body = format!(
5501 "live backend: {}\nparked read-state DIDs: {}\n\
5502 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
5503 state.config.repo_backend.as_str(),
5504 parked,
5505 cached,
5506 state.config.max_feeds_global,
5507 unpollable,
5508 crate::metrics::render(&rows),
5509 failing_block,
5510 );
5511 (StatusCode::OK, body).into_response()
5512}
5513
5514async fn admin_mint_invites(
5518 State(state): State<AppState>,
5519 headers: HeaderMap,
5520 Query(q): Query<MintQuery>,
5521) -> Response {
5522 let did = match current_did(&state, &headers).await {
5525 Some(d) => d,
5526 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5527 };
5528 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5529 warn!(%did, "admin mint denied: not an admin-seed DID");
5530 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5531 }
5532
5533 let n = q.n.unwrap_or(1).clamp(1, 100);
5534 let mut codes = Vec::with_capacity(n as usize);
5535 for _ in 0..n {
5536 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
5537 Ok(code) => codes.push(code),
5538 Err(err) => {
5539 warn!(%err, %did, "admin mint_code failed");
5540 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5541 }
5542 }
5543 }
5544 info!(%did, count = codes.len(), "admin minted invite codes");
5545 let mut body = codes.join("\n");
5546 body.push('\n');
5547 (StatusCode::OK, body).into_response()
5548}
5549
5550#[derive(Debug, Deserialize)]
5556struct ClaimQuery {
5557 t: Option<String>,
5559}
5560
5561async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
5580 let token = match q.t {
5581 Some(t) if !t.is_empty() => t,
5582 _ => {
5583 warn!("claim link with no token");
5584 return redeem_bounce(&state, &store::RedeemError::NotFound);
5585 }
5586 };
5587
5588 let code = match claim_token_code(&token, &state.config.cookie_secret) {
5591 Some(c) => c,
5592 None => {
5593 warn!("claim token invalid (bad signature / malformed)");
5594 return redeem_bounce(&state, &store::RedeemError::NotFound);
5595 }
5596 };
5597
5598 match preflight_code(&state, &code).await {
5602 Ok(()) => {
5603 let cookie = sign_invite(&code, &state.config.cookie_secret);
5604 let mut resp = Redirect::to("/login").into_response();
5605 set_cookie(&mut resp, &cookie);
5606 info!("claim token preflight OK; reserving intent + redirecting to /login");
5607 resp
5608 }
5609 Err(policy) => {
5610 warn!(?policy, "claim token preflight rejected");
5611 redeem_bounce(&state, &policy)
5612 }
5613 }
5614}
5615
5616#[derive(Debug, Default, Deserialize)]
5623struct BotClaimRequest {
5624 #[serde(default)]
5627 did: Option<String>,
5628 #[serde(default)]
5630 #[allow(dead_code)]
5631 handle: Option<String>,
5632}
5633
5634#[derive(Debug, serde::Serialize)]
5636struct BotClaimResponse {
5637 status: &'static str,
5643 code: String,
5647 token: String,
5650 url: String,
5653}
5654
5655async fn bot_mint_claim(
5683 State(state): State<AppState>,
5684 headers: HeaderMap,
5685 body: axum::body::Bytes,
5686) -> Response {
5687 let bot_secret = match state.config.bot_secret.as_deref() {
5689 Some(s) => s,
5690 None => {
5691 warn!(
5692 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5693 );
5694 return (
5695 StatusCode::SERVICE_UNAVAILABLE,
5696 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5697 )
5698 .into_response();
5699 }
5700 };
5701
5702 let presented = headers
5704 .get("x-bot-secret")
5705 .and_then(|v| v.to_str().ok())
5706 .unwrap_or("");
5707 if !bot_secret_matches(presented, bot_secret) {
5708 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5709 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5710 }
5711
5712 let req: BotClaimRequest = if body.is_empty() {
5715 BotClaimRequest::default()
5716 } else {
5717 match serde_json::from_slice(&body) {
5718 Ok(r) => r,
5719 Err(err) => {
5720 warn!(%err, "POST /bot/claims: bad JSON body");
5721 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5722 }
5723 }
5724 };
5725 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5726
5727 if let Some(did) = follower_did {
5729 match store::has_beta_access(&state.db, did).await {
5731 Ok(true) => {
5732 info!("bot mint: DID already holds beta access; already_seated");
5733 return bot_claim_json(BotClaimResponse {
5734 status: "already_seated",
5735 code: String::new(),
5736 token: String::new(),
5737 url: String::new(),
5738 });
5739 }
5740 Ok(false) => {}
5741 Err(err) => {
5742 warn!(%err, "bot mint: has_beta_access failed");
5744 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5745 }
5746 }
5747 match store::find_active_code_for_did(&state.db, did).await {
5750 Ok(Some(code)) => {
5751 info!("bot mint: existing outstanding claim for DID; returning same code");
5752 let token = sign_claim_token(&code, &state.config.cookie_secret);
5753 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5754 return bot_claim_json(BotClaimResponse {
5755 status: "existing",
5756 code,
5757 token,
5758 url,
5759 });
5760 }
5761 Ok(None) => {}
5762 Err(err) => {
5763 warn!(%err, "bot mint: find_active_code_for_did failed");
5764 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5765 }
5766 }
5767 }
5768
5769 let granted = match store::count_beta_access(&state.db).await {
5772 Ok(n) => n,
5773 Err(err) => {
5774 warn!(%err, "bot mint: count_beta_access failed; failing closed");
5775 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5776 }
5777 };
5778 let outstanding = match store::count_active_codes(&state.db).await {
5779 Ok(n) => n,
5780 Err(err) => {
5781 warn!(%err, "bot mint: count_active_codes failed; failing closed");
5782 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5783 }
5784 };
5785 if granted + outstanding >= state.config.beta_cap {
5786 info!(
5787 granted,
5788 outstanding,
5789 cap = state.config.beta_cap,
5790 "bot mint refused: at capacity"
5791 );
5792 return (
5793 StatusCode::CONFLICT,
5794 [(header::CONTENT_TYPE, "application/json")],
5795 "{\"error\":\"full\"}\n",
5796 )
5797 .into_response();
5798 }
5799
5800 let bot_did = state
5803 .config
5804 .admin_seed_dids()
5805 .first()
5806 .cloned()
5807 .unwrap_or_else(|| "did:bot:featherreader".to_string());
5808 let minted = match follower_did {
5809 Some(did) => {
5810 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5811 }
5812 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5813 };
5814 let code = match minted {
5815 Ok(c) => c,
5816 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5823 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5824 Ok(Some(code)) => {
5825 info!("bot mint: lost the mint race; returning the concurrently-minted code");
5826 let token = sign_claim_token(&code, &state.config.cookie_secret);
5827 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5828 return bot_claim_json(BotClaimResponse {
5829 status: "existing",
5830 code,
5831 token,
5832 url,
5833 });
5834 }
5835 Ok(None) => {
5839 warn!("bot mint: conflict but no active code found on recovery");
5840 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5841 }
5842 Err(err) => {
5843 warn!(%err, "bot mint: recovery lookup after conflict failed");
5844 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5845 }
5846 }
5847 }
5848 Err(err) => {
5849 warn!(%err, "bot mint_code failed");
5850 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5851 }
5852 };
5853 let token = sign_claim_token(&code, &state.config.cookie_secret);
5854 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5855 info!("bot minted a claim code + token");
5856
5857 bot_claim_json(BotClaimResponse {
5858 status: "minted",
5859 code,
5860 token,
5861 url,
5862 })
5863}
5864
5865fn bot_claim_json(resp: BotClaimResponse) -> Response {
5868 match serde_json::to_string(&resp) {
5869 Ok(body) => (
5870 StatusCode::OK,
5871 [(header::CONTENT_TYPE, "application/json")],
5872 body,
5873 )
5874 .into_response(),
5875 Err(err) => {
5876 warn!(%err, "serializing bot claim response failed");
5877 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5878 }
5879 }
5880}
5881
5882fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5887 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5888}
5889
5890fn sign_invite(code: &str, secret: &str) -> String {
5899 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5900}
5901
5902fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5907 cookie::verify_value(headers, INVITE_COOKIE, secret)
5908}
5909
5910const CLAIM_TOKEN_LABEL: &str = "claim-token";
5914
5915fn sign_claim_token(code: &str, secret: &str) -> String {
5927 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5928}
5929
5930fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5935 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5936}
5937
5938fn clear_invite_cookie(resp: &mut Response) {
5941 set_cookie(
5942 resp,
5943 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5944 );
5945}
5946
5947async fn import_opml(
5960 State(state): State<AppState>,
5961 headers: HeaderMap,
5962 mut multipart: Multipart,
5963) -> Result<Response, WebError> {
5964 let did = match current_did(&state, &headers).await {
5965 Some(d) => d,
5966 None => return Ok(Redirect::to("/login").into_response()),
5967 };
5968 let pool = &state.db;
5969
5970 let mut opml_text = String::new();
5976 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5977 let name = field.name().unwrap_or("").to_string();
5978 if name == "opml" || name == "file" {
5979 let bytes = field.bytes().await.map_err(multipart_response)?;
5980 if !bytes.is_empty() {
5981 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5982 if name == "file" {
5983 break;
5984 }
5985 }
5986 }
5987 }
5988
5989 let feeds =
5994 match opml::parse_opml(&opml_text) {
5995 Ok(feeds) => feeds,
5996 Err(err) => {
5997 warn!(%err, %did, "OPML import could not parse the uploaded file");
5998 return Ok(Redirect::to(&format!(
5999 "/?flash={}",
6000 qenc("That file could not be read as OPML. Export it again from your other reader?")
6001 ))
6002 .into_response());
6003 }
6004 };
6005 if feeds.is_empty() {
6006 info!(%did, "OPML import found no feeds");
6007 return Ok(
6008 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
6009 .into_response(),
6010 );
6011 }
6012
6013 let now = now_rfc3339();
6016 let mut folder_uris: std::collections::HashMap<String, String> =
6017 std::collections::HashMap::new();
6018 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
6020 for (rkey, folder) in existing {
6021 folder_uris
6022 .entry(folder.name.clone())
6023 .or_insert_with(|| folder_uri(&did, &rkey));
6024 }
6025 }
6026 let mut wanted_folders: Vec<String> = feeds
6027 .iter()
6028 .filter_map(|f| f.folder.clone())
6029 .filter(|n| !n.is_empty())
6030 .collect();
6031 wanted_folders.sort();
6032 wanted_folders.dedup();
6033 for name in wanted_folders {
6034 if folder_uris.contains_key(&name) {
6035 continue;
6036 }
6037 let folder = Folder::new(name.clone(), now.clone());
6038 match state.repo().add_folder(&did, &folder).await {
6039 Ok(rkey) => {
6040 folder_uris.insert(name, folder_uri(&did, &rkey));
6041 }
6042 Err(err) => warn!(%err, %did, "OPML folder create failed"),
6043 }
6044 }
6045
6046 let sub_cap = state.config.max_subs_per_did;
6055 let mut headroom: Option<i64> = if sub_cap > 0 {
6056 let existing = store::count_subscriptions_for_did(pool, &did)
6057 .await
6058 .unwrap_or(0);
6059 Some((sub_cap - existing).max(0))
6060 } else {
6061 None
6062 };
6063 let mut trimmed_over_cap: usize = 0;
6064
6065 let feeds_cap = state.config.max_feeds_global;
6072 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
6073 let existing = store::count_feeds(pool).await.unwrap_or(0);
6074 Some((feeds_cap - existing).max(0))
6075 } else {
6076 None
6077 };
6078 let mut trimmed_over_global: usize = 0;
6079
6080 let mut subs = Vec::with_capacity(feeds.len());
6081 let mut skipped_private: Vec<String> = Vec::new();
6082 let mut uncached: usize = 0;
6085 let mut skipped_unsupported: usize = 0;
6091 for f in &feeds {
6092 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
6100 info!(
6101 %did,
6102 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
6103 );
6104 skipped_unsupported += 1;
6105 continue;
6106 }
6107 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
6108 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
6109 let label = f
6111 .title
6112 .clone()
6113 .filter(|t| !t.trim().is_empty())
6114 .unwrap_or_else(|| private_feed_label(&f.feed_url));
6115 skipped_private.push(label);
6116 continue;
6117 }
6118
6119 if let Some(h) = headroom.as_mut() {
6122 if *h <= 0 {
6123 trimmed_over_cap += 1;
6124 continue;
6125 }
6126 }
6127
6128 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
6133 Ok(existing) => existing.is_none(),
6134 Err(err) => {
6137 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
6138 false
6139 }
6140 };
6141 if is_new {
6142 if let Some(g) = global_headroom.as_mut() {
6143 if *g <= 0 {
6144 trimmed_over_global += 1;
6145 continue;
6146 }
6147 *g -= 1;
6148 }
6149 }
6150
6151 if let Some(h) = headroom.as_mut() {
6154 *h -= 1;
6155 }
6156
6157 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
6158 sub.title = f.title.clone();
6159 sub.site_url = f.site_url.clone();
6160 sub.folder = f
6161 .folder
6162 .as_ref()
6163 .and_then(|name| folder_uris.get(name).cloned());
6164 subs.push(sub);
6165 if let Err(err) = store::upsert_feed(
6171 pool,
6172 &store::NewFeed {
6173 url: f.feed_url.clone(),
6174 title: f.title.clone(),
6175 site_url: f.site_url.clone(),
6176 ..Default::default()
6177 },
6178 )
6179 .await
6180 {
6181 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
6182 it will not be polled");
6183 uncached += 1;
6184 }
6185 }
6186
6187 let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
6204 Ok(rkeys) => {
6205 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
6206 rkeys.len()
6207 }
6208 Err(err) => {
6209 let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
6210 warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
6211 landed
6212 }
6213 };
6214 if landed == 0 && !subs.is_empty() {
6215 return Ok(Redirect::to(&format!(
6216 "/?flash={}",
6217 qenc(
6218 "Could not save those subscriptions to your PDS, so nothing was imported. \
6219 Try again in a moment."
6220 )
6221 ))
6222 .into_response());
6223 }
6224
6225 let mut flash = if landed < subs.len() {
6227 format!(
6228 "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
6229 other {} may not have been saved. Importing the same file again would add the first \
6230 {landed} a second time",
6231 subs.len(),
6232 subs.len() - landed
6233 )
6234 } else {
6235 format!("Imported {} feeds", subs.len())
6236 };
6237 if uncached > 0 {
6238 flash.push_str(&format!(
6239 ". {uncached} of them could not be cached locally and may not update until the next import."
6240 ));
6241 }
6242 if trimmed_over_cap > 0 {
6243 flash.push_str(&format!(
6244 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
6245 ));
6246 }
6247 if trimmed_over_global > 0 {
6248 flash.push_str(&format!(
6249 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
6250 ));
6251 }
6252 if !skipped_private.is_empty() {
6253 flash.push_str(&format!(
6254 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
6255 skipped_private.len(),
6256 skipped_private.join(", ")
6257 ));
6258 }
6259 if skipped_unsupported > 0 {
6260 flash.push_str(&format!(
6263 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
6264 ));
6265 }
6266 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
6267}
6268
6269fn private_feed_label(url: &str) -> String {
6272 url::Url::parse(url)
6273 .ok()
6274 .and_then(|u| u.host_str().map(str::to_string))
6275 .unwrap_or_else(|| "a private feed".to_string())
6276}
6277
6278async fn export_opml(
6280 State(state): State<AppState>,
6281 headers: HeaderMap,
6282) -> Result<Response, WebError> {
6283 let did = match current_did(&state, &headers).await {
6284 Some(d) => d,
6285 None => return Ok(Redirect::to("/login").into_response()),
6286 };
6287
6288 let subs = match state.repo().list_subscriptions_sorted(&did).await {
6295 Ok(subs) => subs,
6296 Err(err) => {
6297 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
6298 return Ok(Redirect::to(&format!(
6299 "/manage?flash={}",
6300 qenc(EXPORT_INCOMPLETE_REFUSAL)
6301 ))
6302 .into_response());
6303 }
6304 };
6305 let folders = match state.repo().list_folders_sorted(&did).await {
6306 Ok(folders) => folders,
6307 Err(err) => {
6308 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
6309 return Ok(Redirect::to(&format!(
6310 "/manage?flash={}",
6311 qenc(EXPORT_INCOMPLETE_REFUSAL)
6312 ))
6313 .into_response());
6314 }
6315 };
6316 let folder_pairs: Vec<(String, Folder)> = folders
6319 .into_iter()
6320 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
6321 .collect();
6322
6323 let body = opml::to_opml(&subs, &folder_pairs);
6324 let mut resp = (StatusCode::OK, body).into_response();
6325 resp.headers_mut().insert(
6326 header::CONTENT_TYPE,
6327 "text/x-opml; charset=utf-8".parse().unwrap(),
6328 );
6329 resp.headers_mut().insert(
6330 header::CONTENT_DISPOSITION,
6331 "attachment; filename=\"featherreader-subscriptions.opml\""
6332 .parse()
6333 .unwrap(),
6334 );
6335 Ok(resp)
6336}
6337
6338fn set_cookie(resp: &mut Response, cookie: &str) {
6344 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
6345 resp.headers_mut()
6346 .append(axum::http::header::SET_COOKIE, value);
6347 }
6348}
6349
6350fn is_htmx(headers: &HeaderMap) -> bool {
6352 headers
6353 .get("HX-Request")
6354 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
6355}
6356
6357fn is_reader_request(headers: &HeaderMap) -> bool {
6363 headers
6364 .get("X-FR-Reader")
6365 .is_some_and(|v| v.as_bytes() == b"1")
6366}
6367
6368mod cookie {
6373 use super::{HeaderMap, SESSION_COOKIE};
6374
6375 pub fn sign_session(sid: &str, secret: &str) -> String {
6377 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
6378 }
6379
6380 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
6382 verify_value(headers, SESSION_COOKIE, secret)
6383 }
6384
6385 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
6391 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
6392 msg.extend_from_slice(name.as_bytes());
6393 msg.push(0);
6394 msg.extend_from_slice(value.as_bytes());
6395 msg
6396 }
6397
6398 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
6404 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
6405 let b64 = b64url_encode(value.as_bytes());
6406 format!(
6407 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
6408 )
6409 }
6410
6411 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
6414 let raw = cookie_value(headers, name)?;
6415 let (b64, sig) = raw.split_once('.')?;
6416 let bytes = b64url_decode(b64)?;
6417 let value = String::from_utf8(bytes).ok()?;
6418 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
6419 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6420 Some(value)
6421 } else {
6422 None
6423 }
6424 }
6425
6426 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
6432 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
6433 let b64 = b64url_encode(value.as_bytes());
6434 format!("{b64}.{sig}")
6435 }
6436
6437 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
6440 let (b64, sig) = token.split_once('.')?;
6441 let bytes = b64url_decode(b64)?;
6442 let value = String::from_utf8(bytes).ok()?;
6443 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
6444 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6445 Some(value)
6446 } else {
6447 None
6448 }
6449 }
6450
6451 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
6453 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
6454 for part in header.split(';') {
6455 let part = part.trim();
6456 if let Some((k, v)) = part.split_once('=') {
6457 if k == name {
6458 return Some(v.to_string());
6459 }
6460 }
6461 }
6462 None
6463 }
6464
6465 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6469 if a.len() != b.len() {
6470 return false;
6471 }
6472 let mut diff = 0u8;
6473 for (x, y) in a.iter().zip(b.iter()) {
6474 diff |= x ^ y;
6475 }
6476 diff == 0
6477 }
6478
6479 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6482
6483 fn b64url_encode(input: &[u8]) -> String {
6484 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
6485 for chunk in input.chunks(3) {
6486 let b = [
6487 chunk[0],
6488 *chunk.get(1).unwrap_or(&0),
6489 *chunk.get(2).unwrap_or(&0),
6490 ];
6491 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
6492 out.push(B64[((n >> 18) & 63) as usize] as char);
6493 out.push(B64[((n >> 12) & 63) as usize] as char);
6494 if chunk.len() > 1 {
6495 out.push(B64[((n >> 6) & 63) as usize] as char);
6496 }
6497 if chunk.len() > 2 {
6498 out.push(B64[(n & 63) as usize] as char);
6499 }
6500 }
6501 out
6502 }
6503
6504 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
6505 fn val(c: u8) -> Option<u32> {
6506 match c {
6507 b'A'..=b'Z' => Some((c - b'A') as u32),
6508 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6509 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6510 b'-' => Some(62),
6511 b'_' => Some(63),
6512 _ => None,
6513 }
6514 }
6515 let bytes = input.as_bytes();
6516 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
6517 for chunk in bytes.chunks(4) {
6518 let mut n = 0u32;
6519 let mut valid = 0;
6520 for (i, &c) in chunk.iter().enumerate() {
6521 n |= val(c)? << (18 - 6 * i);
6522 valid += 1;
6523 }
6524 out.push((n >> 16) as u8);
6525 if valid > 2 {
6526 out.push((n >> 8) as u8);
6527 }
6528 if valid > 3 {
6529 out.push(n as u8);
6530 }
6531 }
6532 Some(out)
6533 }
6534
6535 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
6539 const BLOCK: usize = 64;
6540 let mut k = [0u8; BLOCK];
6541 if key.len() > BLOCK {
6542 let d = sha256(key);
6543 k[..32].copy_from_slice(&d);
6544 } else {
6545 k[..key.len()].copy_from_slice(key);
6546 }
6547 let mut ipad = [0x36u8; BLOCK];
6548 let mut opad = [0x5cu8; BLOCK];
6549 for i in 0..BLOCK {
6550 ipad[i] ^= k[i];
6551 opad[i] ^= k[i];
6552 }
6553 let mut inner = Vec::with_capacity(BLOCK + msg.len());
6554 inner.extend_from_slice(&ipad);
6555 inner.extend_from_slice(msg);
6556 let inner_hash = sha256(&inner);
6557 let mut outer = Vec::with_capacity(BLOCK + 32);
6558 outer.extend_from_slice(&opad);
6559 outer.extend_from_slice(&inner_hash);
6560 let mac = sha256(&outer);
6561 let mut hex = String::with_capacity(64);
6562 for b in mac {
6563 hex.push_str(&format!("{b:02x}"));
6564 }
6565 hex
6566 }
6567
6568 fn sha256(data: &[u8]) -> [u8; 32] {
6570 const K: [u32; 64] = [
6571 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
6572 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
6573 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
6574 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
6575 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
6576 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
6577 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
6578 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
6579 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
6580 0xc67178f2,
6581 ];
6582 let mut h: [u32; 8] = [
6583 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
6584 0x5be0cd19,
6585 ];
6586
6587 let bit_len = (data.len() as u64) * 8;
6588 let mut msg = data.to_vec();
6589 msg.push(0x80);
6590 while msg.len() % 64 != 56 {
6591 msg.push(0);
6592 }
6593 msg.extend_from_slice(&bit_len.to_be_bytes());
6594
6595 for block in msg.chunks(64) {
6596 let mut w = [0u32; 64];
6597 for i in 0..16 {
6598 w[i] = u32::from_be_bytes([
6599 block[i * 4],
6600 block[i * 4 + 1],
6601 block[i * 4 + 2],
6602 block[i * 4 + 3],
6603 ]);
6604 }
6605 for i in 16..64 {
6606 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
6607 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
6608 w[i] = w[i - 16]
6609 .wrapping_add(s0)
6610 .wrapping_add(w[i - 7])
6611 .wrapping_add(s1);
6612 }
6613 let mut a = h;
6614 for i in 0..64 {
6615 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6616 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6617 let t1 = a[7]
6618 .wrapping_add(s1)
6619 .wrapping_add(ch)
6620 .wrapping_add(K[i])
6621 .wrapping_add(w[i]);
6622 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6623 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6624 let t2 = s0.wrapping_add(maj);
6625 a[7] = a[6];
6626 a[6] = a[5];
6627 a[5] = a[4];
6628 a[4] = a[3].wrapping_add(t1);
6629 a[3] = a[2];
6630 a[2] = a[1];
6631 a[1] = a[0];
6632 a[0] = t1.wrapping_add(t2);
6633 }
6634 for i in 0..8 {
6635 h[i] = h[i].wrapping_add(a[i]);
6636 }
6637 }
6638
6639 let mut out = [0u8; 32];
6640 for (i, word) in h.iter().enumerate() {
6641 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6642 }
6643 out
6644 }
6645
6646 #[cfg(test)]
6647 mod tests {
6648 use super::*;
6649
6650 #[test]
6651 fn sha256_known_vector() {
6652 let d = sha256(b"abc");
6653 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6654 assert_eq!(
6655 hex,
6656 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6657 );
6658 }
6659
6660 #[test]
6661 fn hmac_known_vector() {
6662 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6663 assert_eq!(
6664 mac,
6665 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6666 );
6667 }
6668
6669 #[test]
6670 fn sign_verify_round_trips() {
6671 let secret = "test-secret";
6672 let sid = "9f2c-opaque-session-id";
6673 let cookie = sign_session(sid, secret);
6674 let pair = cookie.split(';').next().unwrap().to_string();
6675 let mut headers = HeaderMap::new();
6676 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6677 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6678 assert!(verify_session(&headers, "other-secret").is_none());
6680 }
6681
6682 #[test]
6683 fn forged_and_tampered_cookies_are_rejected() {
6684 let secret = "test-secret";
6685
6686 let forged = format!(
6689 "{SESSION_COOKIE}={}.{}",
6690 b64url_encode(b"attacker-chosen-sid"),
6691 "deadbeef".repeat(8) );
6693 let mut headers = HeaderMap::new();
6694 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6695 assert!(verify_session(&headers, secret).is_none());
6696
6697 let cookie = sign_session("real-sid", secret);
6700 let pair = cookie.split(';').next().unwrap();
6701 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6702 let tampered = format!(
6703 "{SESSION_COOKIE}={}.{}",
6704 b64url_encode(b"different-sid"),
6705 sig
6706 );
6707 let mut headers2 = HeaderMap::new();
6708 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6709 assert!(verify_session(&headers2, secret).is_none());
6710 }
6711
6712 #[test]
6713 fn b64url_round_trips() {
6714 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6715 let enc = b64url_encode(s.as_bytes());
6716 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6717 }
6718 }
6719 }
6720}
6721
6722async fn get_entry_by_id(
6738 pool: &store::Pool,
6739 did: &str,
6740 id: i64,
6741) -> anyhow::Result<Option<store::Entry>> {
6742 let entry = sqlx::query_as::<_, store::Entry>(
6743 r#"
6744 SELECT e.* FROM entries e
6745 WHERE e.id = ?2
6746 AND EXISTS (
6747 SELECT 1 FROM sub_ref sr
6748 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6749 )
6750 "#,
6751 )
6752 .bind(did)
6753 .bind(id)
6754 .fetch_optional(pool)
6755 .await?;
6756 Ok(entry)
6757}
6758
6759async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6761 let read: Option<bool> =
6762 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6763 .bind(did)
6764 .bind(entry_id)
6765 .fetch_optional(pool)
6766 .await?
6767 .flatten();
6768 Ok(read.unwrap_or(false))
6769}
6770
6771async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6773 let starred: Option<bool> =
6774 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6775 .bind(did)
6776 .bind(entry_id)
6777 .fetch_optional(pool)
6778 .await?
6779 .flatten();
6780 Ok(starred.unwrap_or(false))
6781}
6782
6783async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6785 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6786 .bind(feed_id)
6787 .fetch_optional(pool)
6788 .await
6789 {
6790 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6791 _ => String::new(),
6792 }
6793}
6794
6795async fn build_entry_row(
6798 pool: &store::Pool,
6799 did: &str,
6800 id: i64,
6801 read: Option<bool>,
6802) -> anyhow::Result<Option<EntryRow>> {
6803 let entry = match get_entry_by_id(pool, did, id).await? {
6804 Some(e) => e,
6805 None => return Ok(None),
6806 };
6807 let read = match read {
6808 Some(r) => r,
6809 None => entry_is_read(pool, did, id).await?,
6810 };
6811 let starred = entry_is_starred(pool, did, id).await?;
6812 Ok(Some(EntryRow {
6813 id: entry.id,
6814 title: entry
6815 .title
6816 .clone()
6817 .filter(|t| !t.trim().is_empty())
6818 .unwrap_or_else(|| "(untitled)".to_string()),
6819 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6820 published: display_date(entry.published.as_deref()),
6821 read,
6822 starred,
6823 link: SafeLink::entry(id, ""),
6824 cached: true,
6825 rkey: String::new(),
6826 }))
6827}
6828
6829fn now_rfc3339() -> String {
6831 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6832}
6833
6834#[cfg(test)]
6835mod tests {
6836 use super::*;
6837
6838 #[test]
6839 fn qenc_encodes_reserved() {
6840 assert_eq!(qenc("a b"), "a%20b");
6841 assert_eq!(
6842 qenc("https://example.com/feed.xml"),
6843 "https%3A%2F%2Fexample.com%2Ffeed.xml"
6844 );
6845 assert_eq!(
6846 qenc("at://did:plc:x/c/r"),
6847 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6848 );
6849 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6851 }
6852
6853 #[test]
6854 fn folder_uri_shape() {
6855 assert_eq!(
6856 folder_uri("did:plc:abc", "3kfolder"),
6857 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6858 );
6859 }
6860
6861 #[test]
6864 fn private_feeds_are_classified_private_across_providers() {
6865 for url in [
6869 "https://author.substack.com/feed/private/deadbeefcafe1234",
6870 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6871 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6872 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6873 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6874 "https://user:pass@example.com/feed",
6875 ] {
6876 assert!(
6877 feed::classify_feed_privacy(url).is_private(),
6878 "expected private: {url}"
6879 );
6880 }
6881 }
6882
6883 #[test]
6884 fn public_feeds_stay_public() {
6885 for url in [
6886 "https://author.substack.com/feed",
6887 "https://wordpress.example.com/feed/",
6888 "https://example.com/rss.xml",
6889 "https://example.org/atom.xml",
6890 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6892 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6893 ] {
6894 assert!(
6895 !feed::classify_feed_privacy(url).is_private(),
6896 "expected public: {url}"
6897 );
6898 }
6899 }
6900
6901 #[test]
6902 fn private_feed_label_is_public_safe_host_only() {
6903 let label =
6905 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6906 assert_eq!(label, "author.substack.com");
6907 assert!(!label.contains("deadbeefcafe1234token"));
6908 assert!(!label.contains("/private/"));
6909 assert_eq!(private_feed_label("not a url"), "a private feed");
6911 }
6912
6913 #[test]
6914 fn refusal_message_promises_nothing_stored() {
6915 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6916 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6917 }
6918
6919 #[test]
6920 fn scope_query_preserves_context() {
6921 let q = EntryQuery {
6922 feed: Some("https://example.com/feed.xml".to_string()),
6923 folder: None,
6924 view: Some("all".to_string()),
6925 };
6926 let s = scope_query(&q);
6927 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6928 assert!(s.contains("view=all"));
6929
6930 let q2 = EntryQuery {
6932 feed: None,
6933 folder: None,
6934 view: Some("unread".to_string()),
6935 };
6936 assert_eq!(scope_query(&q2), "");
6937 }
6938
6939 use axum::body::Body;
6942 use axum::http::Request;
6943 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6949 let db = store::init_url("sqlite::memory:").await.unwrap();
6950 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6951 store::ensure_seed(&db, &dids).await.unwrap();
6952 let config = Config {
6953 allowed_dids: dids,
6954 cookie_secret: "test-cookie-secret-000".to_string(),
6955 beta_cap: 3,
6956 ..Config::default()
6957 };
6958 AppState::new(config, db).unwrap()
6959 }
6960
6961 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6964 let sid = state.sessions.create(Session {
6965 did: did.to_string(),
6966 handle: handle.map(str::to_string),
6967 });
6968 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6969 sc.split(';').next().unwrap().to_string()
6970 }
6971
6972 #[test]
6976 fn the_rate_limit_map_is_bounded() {
6977 let rl = RateLimiter::shared();
6978 let now = Instant::now();
6979 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6980 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6983 rl.check_at(ip, now + Duration::from_millis(i as u64));
6984 }
6985 let len = rl.inner.lock().unwrap().buckets.len();
6986 assert!(
6987 len <= MAX_RATE_BUCKETS,
6988 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6989 );
6990 }
6991
6992 #[test]
6999 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
7000 let rl = RateLimiter::shared();
7001 let base = Instant::now();
7002 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
7003 let at = |n: u64| base + Duration::from_nanos(n);
7008
7009 for i in 0..(RATE_BURST as u64) {
7011 assert!(rl.check_at(attacker, at(i)));
7012 }
7013 assert!(
7014 !rl.check_at(attacker, at(RATE_BURST as u64)),
7015 "burst was not exhausted; the rest of this test proves nothing"
7016 );
7017
7018 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
7021 let t = at(100 + i as u64 * 2);
7022 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
7023 rl.check_at(ip, t);
7024 assert!(
7025 !rl.check_at(attacker, t),
7026 "the attacker got a token back after evictions at i={i}"
7027 );
7028 }
7029 }
7030
7031 #[test]
7034 fn the_idle_sweep_does_not_run_on_every_request() {
7035 let rl = RateLimiter::shared();
7036 let start = Instant::now();
7037 let a: IpAddr = "198.51.100.1".parse().unwrap();
7038 let b: IpAddr = "198.51.100.2".parse().unwrap();
7039
7040 rl.check_at(a, start);
7041 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
7044 assert!(
7045 !rl.inner.lock().unwrap().buckets.contains_key(&a),
7046 "an idle bucket survived a sweep that was due"
7047 );
7048
7049 let before = rl.inner.lock().unwrap().last_sweep;
7052 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
7053 assert_eq!(
7054 rl.inner.lock().unwrap().last_sweep,
7055 before,
7056 "the sweep ran again within the interval"
7057 );
7058 }
7059
7060 #[test]
7061 fn rate_limited_paths_match_expected() {
7062 use axum::http::Method;
7063 assert!(is_rate_limited_path("/login", &Method::GET));
7064 assert!(is_rate_limited_path("/login", &Method::POST));
7065 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
7066 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
7067 assert!(is_rate_limited_path("/opml", &Method::POST));
7068 assert!(is_rate_limited_path("/read-all", &Method::POST));
7069 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
7070 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
7071 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
7072 assert!(!is_rate_limited_path("/", &Method::GET));
7074 assert!(!is_rate_limited_path("/about", &Method::GET));
7075 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
7076 assert!(!is_rate_limited_path("/login", &Method::HEAD));
7077 }
7078
7079 #[test]
7080 fn rate_limiter_allows_burst_then_429s() {
7081 let rl = RateLimiter::shared();
7082 let ip: IpAddr = "203.0.113.7".parse().unwrap();
7083 for _ in 0..(RATE_BURST as usize) {
7085 assert!(rl.check(ip));
7086 }
7087 assert!(!rl.check(ip));
7089 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
7091 assert!(rl.check(ip2));
7092 }
7093
7094 #[test]
7095 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
7096 let mut h = HeaderMap::new();
7100 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
7101 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
7102 assert_eq!(
7103 client_ip(&h, Some(&sock), None),
7104 Some("203.0.113.55".parse().unwrap()),
7105 "spoofed XFF must not override the socket peer"
7106 );
7107 }
7108
7109 #[test]
7110 fn client_ip_uses_trusted_header_last_hop() {
7111 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
7116
7117 let mut h = HeaderMap::new();
7118 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
7119 assert_eq!(
7120 client_ip(&h, Some(&sock), Some("fly-client-ip")),
7121 Some("198.51.100.9".parse().unwrap())
7122 );
7123
7124 let mut h2 = HeaderMap::new();
7126 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
7127 assert_eq!(
7128 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
7129 Some("198.51.100.9".parse().unwrap()),
7130 "must take the right-most (trusted) hop, not the forged left-most"
7131 );
7132
7133 let h3 = HeaderMap::new();
7135 assert_eq!(
7136 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
7137 Some("10.0.0.1".parse().unwrap())
7138 );
7139 }
7140
7141 #[test]
7142 fn invite_cookie_round_trips_and_rejects_tamper() {
7143 let secret = "test-cookie-secret-000";
7144 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
7145 let pair = sc.split(';').next().unwrap();
7146 let mut h = HeaderMap::new();
7147 h.insert(header::COOKIE, pair.parse().unwrap());
7148 assert_eq!(
7149 invite_cookie_code(&h, secret).as_deref(),
7150 Some("FEATHER-ABCDWXYZ")
7151 );
7152 assert!(invite_cookie_code(&h, "other").is_none());
7154 }
7155
7156 #[tokio::test]
7157 async fn preflight_valid_expired_and_full() {
7158 let state = test_state(&["did:plc:admin"]).await;
7159 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7161 .await
7162 .unwrap();
7163 assert!(preflight_code(&state, &code).await.is_ok());
7164
7165 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
7169 .await
7170 .unwrap();
7171 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
7172 .bind(chrono::Utc::now().timestamp() - 3600)
7173 .bind(&expired)
7174 .execute(&state.db)
7175 .await
7176 .unwrap();
7177 assert_eq!(
7178 preflight_code(&state, &expired).await,
7179 Err(store::RedeemError::Expired)
7180 );
7181
7182 assert_eq!(
7184 preflight_code(&state, "FEATHER-NOPENOPE").await,
7185 Err(store::RedeemError::NotFound)
7186 );
7187
7188 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7191 .await
7192 .unwrap();
7193 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7194 .await
7195 .unwrap();
7196 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7197 assert_eq!(
7198 preflight_code(&state, &code).await,
7199 Err(store::RedeemError::CapacityFull)
7200 );
7201 }
7202
7203 async fn bot_state(bot_secret: &str) -> AppState {
7207 let db = store::init_url("sqlite::memory:").await.unwrap();
7208 store::ensure_seed(&db, &["did:plc:admin".to_string()])
7209 .await
7210 .unwrap();
7211 let config = Config {
7212 allowed_dids: vec!["did:plc:admin".to_string()],
7213 cookie_secret: "test-cookie-secret-000".to_string(),
7214 beta_cap: 3,
7215 bot_secret: Some(bot_secret.to_string()),
7216 public_url: "https://feather-reader.com".to_string(),
7217 ..Config::default()
7218 };
7219 AppState::new(config, db).unwrap()
7220 }
7221
7222 #[test]
7223 fn claim_token_round_trips_and_rejects_tamper() {
7224 let secret = "test-cookie-secret-000";
7225 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
7226 assert!(!token.contains(';'));
7228 assert_eq!(
7229 claim_token_code(&token, secret).as_deref(),
7230 Some("FEATHER-ABCDWXYZ")
7231 );
7232 assert!(claim_token_code(&token, "other").is_none());
7234 let mut bad = token.clone();
7236 bad.push('x');
7237 assert!(claim_token_code(&bad, secret).is_none());
7238 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
7244 assert_eq!(
7245 test_b64url_decode(b64).as_deref(),
7246 Some("FEATHER-ABCDWXYZ".as_bytes()),
7247 "the code half of the token is plain base64url, decodable by anyone"
7248 );
7249 }
7250
7251 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
7254 fn val(c: u8) -> Option<u32> {
7255 match c {
7256 b'A'..=b'Z' => Some((c - b'A') as u32),
7257 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
7258 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
7259 b'-' => Some(62),
7260 b'_' => Some(63),
7261 _ => None,
7262 }
7263 }
7264 let mut out = Vec::with_capacity(input.len() / 4 * 3);
7265 for chunk in input.as_bytes().chunks(4) {
7266 let mut n = 0u32;
7267 let mut bits = 0;
7268 for &c in chunk {
7269 n = (n << 6) | val(c)?;
7270 bits += 6;
7271 }
7272 let bytes = bits / 8;
7273 n <<= 24 - bits;
7274 for i in 0..bytes {
7275 out.push((n >> (16 - i * 8)) as u8);
7276 }
7277 }
7278 Some(out)
7279 }
7280
7281 #[tokio::test]
7282 async fn bot_mint_then_claim_grants_a_seat() {
7283 let state = bot_state("bot-secret-abcdef").await;
7284 let app = router(state.clone());
7285
7286 let resp = app
7288 .clone()
7289 .oneshot(
7290 Request::builder()
7291 .method("POST")
7292 .uri("/bot/claims")
7293 .header("x-bot-secret", "bot-secret-abcdef")
7294 .body(Body::empty())
7295 .unwrap(),
7296 )
7297 .await
7298 .unwrap();
7299 assert_eq!(resp.status(), StatusCode::OK);
7300 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7301 .await
7302 .unwrap();
7303 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7304 let token = json["token"].as_str().unwrap().to_string();
7305 let url = json["url"].as_str().unwrap();
7306 assert!(url.starts_with("https://feather-reader.com/claim?t="));
7307 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
7309 assert!(!url.contains("FEATHER-"));
7310
7311 let resp = app
7313 .clone()
7314 .oneshot(
7315 Request::builder()
7316 .method("GET")
7317 .uri(format!("/claim?t={}", qenc(&token)))
7318 .body(Body::empty())
7319 .unwrap(),
7320 )
7321 .await
7322 .unwrap();
7323 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7324 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7325 let set_cookie = resp
7326 .headers()
7327 .get(header::SET_COOKIE)
7328 .unwrap()
7329 .to_str()
7330 .unwrap();
7331 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
7332
7333 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
7336 let out = store::redeem_code(
7337 &state.db,
7338 &code,
7339 "did:plc:follower",
7340 None,
7341 state.config.beta_cap,
7342 )
7343 .await
7344 .unwrap();
7345 assert_eq!(out, Ok(()));
7346 assert!(store::has_beta_access(&state.db, "did:plc:follower")
7347 .await
7348 .unwrap());
7349 }
7350
7351 #[tokio::test]
7352 async fn claim_with_invalid_token_bounces() {
7353 let state = bot_state("bot-secret-abcdef").await;
7354 let app = router(state);
7355 let resp = app
7356 .oneshot(
7357 Request::builder()
7358 .method("GET")
7359 .uri("/claim?t=not-a-real-token")
7360 .body(Body::empty())
7361 .unwrap(),
7362 )
7363 .await
7364 .unwrap();
7365 assert_eq!(resp.status(), StatusCode::OK);
7367 }
7368
7369 #[tokio::test]
7370 async fn claim_with_used_token_is_refused() {
7371 let state = bot_state("bot-secret-abcdef").await;
7372 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7374 .await
7375 .unwrap();
7376 let token = sign_claim_token(&code, &state.config.cookie_secret);
7377 store::redeem_code(
7378 &state.db,
7379 &code,
7380 "did:plc:someone",
7381 None,
7382 state.config.beta_cap,
7383 )
7384 .await
7385 .unwrap()
7386 .unwrap();
7387 let app = router(state);
7388 let resp = app
7389 .oneshot(
7390 Request::builder()
7391 .method("GET")
7392 .uri(format!("/claim?t={}", qenc(&token)))
7393 .body(Body::empty())
7394 .unwrap(),
7395 )
7396 .await
7397 .unwrap();
7398 assert_eq!(resp.status(), StatusCode::OK);
7400 assert!(resp.headers().get(header::SET_COOKIE).is_none());
7401 }
7402
7403 #[tokio::test]
7404 async fn bot_claims_rejects_bad_and_missing_secret() {
7405 let state = bot_state("bot-secret-abcdef").await;
7406 let app = router(state);
7407 let resp = app
7409 .clone()
7410 .oneshot(
7411 Request::builder()
7412 .method("POST")
7413 .uri("/bot/claims")
7414 .header("x-bot-secret", "wrong")
7415 .body(Body::empty())
7416 .unwrap(),
7417 )
7418 .await
7419 .unwrap();
7420 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7421 let resp = app
7423 .oneshot(
7424 Request::builder()
7425 .method("POST")
7426 .uri("/bot/claims")
7427 .body(Body::empty())
7428 .unwrap(),
7429 )
7430 .await
7431 .unwrap();
7432 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7433 }
7434
7435 #[tokio::test]
7436 async fn bot_claims_disabled_when_secret_unset() {
7437 let state = test_state(&["did:plc:admin"]).await;
7439 let app = router(state);
7440 let resp = app
7441 .oneshot(
7442 Request::builder()
7443 .method("POST")
7444 .uri("/bot/claims")
7445 .header("x-bot-secret", "anything")
7446 .body(Body::empty())
7447 .unwrap(),
7448 )
7449 .await
7450 .unwrap();
7451 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7452 }
7453
7454 #[tokio::test]
7455 async fn bot_claims_refuses_at_capacity() {
7456 let state = bot_state("bot-secret-abcdef").await;
7457 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7459 .await
7460 .unwrap();
7461 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7462 .await
7463 .unwrap();
7464 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7465 let app = router(state);
7466 let resp = app
7467 .oneshot(
7468 Request::builder()
7469 .method("POST")
7470 .uri("/bot/claims")
7471 .header("x-bot-secret", "bot-secret-abcdef")
7472 .body(Body::empty())
7473 .unwrap(),
7474 )
7475 .await
7476 .unwrap();
7477 assert_eq!(resp.status(), StatusCode::CONFLICT);
7478 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7479 .await
7480 .unwrap();
7481 assert!(String::from_utf8_lossy(&bytes).contains("full"));
7482 }
7483
7484 #[tokio::test]
7485 async fn bot_claims_counts_outstanding_codes_against_cap() {
7486 let state = bot_state("bot-secret-abcdef").await;
7487 store::mint_code(&state.db, "did:plc:admin", 3600)
7489 .await
7490 .unwrap();
7491 store::mint_code(&state.db, "did:plc:admin", 3600)
7492 .await
7493 .unwrap();
7494 let app = router(state);
7495 let resp = app
7496 .oneshot(
7497 Request::builder()
7498 .method("POST")
7499 .uri("/bot/claims")
7500 .header("x-bot-secret", "bot-secret-abcdef")
7501 .body(Body::empty())
7502 .unwrap(),
7503 )
7504 .await
7505 .unwrap();
7506 assert_eq!(resp.status(), StatusCode::CONFLICT);
7508 }
7509
7510 async fn post_bot_claim_for(
7512 app: &axum::Router,
7513 secret: &str,
7514 did: &str,
7515 ) -> (StatusCode, serde_json::Value) {
7516 let resp = app
7517 .clone()
7518 .oneshot(
7519 Request::builder()
7520 .method("POST")
7521 .uri("/bot/claims")
7522 .header("x-bot-secret", secret)
7523 .header("content-type", "application/json")
7524 .body(Body::from(format!(
7525 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
7526 )))
7527 .unwrap(),
7528 )
7529 .await
7530 .unwrap();
7531 let status = resp.status();
7532 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7533 .await
7534 .unwrap();
7535 let json = if bytes.is_empty() {
7536 serde_json::Value::Null
7537 } else {
7538 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
7539 };
7540 (status, json)
7541 }
7542
7543 #[tokio::test]
7544 async fn bot_claims_returns_already_seated_for_a_member() {
7545 let state = bot_state("bot-secret-abcdef").await;
7549 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
7550 .await
7551 .unwrap();
7552 let app = router(state.clone());
7553 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
7554 assert_eq!(status, StatusCode::OK);
7555 assert_eq!(json["status"], "already_seated");
7556 assert_eq!(json["code"], "");
7557 assert_eq!(json["url"], "");
7558 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
7560 .await
7561 .unwrap()
7562 .is_none());
7563 }
7564
7565 #[tokio::test]
7566 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
7567 let state = bot_state("bot-secret-abcdef").await;
7571 let app = router(state.clone());
7572
7573 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7574 assert_eq!(s1, StatusCode::OK);
7575 assert_eq!(j1["status"], "minted");
7576 let code1 = j1["code"].as_str().unwrap().to_string();
7577
7578 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7579 assert_eq!(s2, StatusCode::OK);
7580 assert_eq!(j2["status"], "existing");
7581 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
7582 assert_eq!(j2["url"], j1["url"], "same url returned");
7583
7584 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
7586 }
7587
7588 #[tokio::test]
7589 async fn bot_claims_records_intended_did_at_mint() {
7590 let state = bot_state("bot-secret-abcdef").await;
7592 let app = router(state.clone());
7593 let (status, json) =
7594 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
7595 assert_eq!(status, StatusCode::OK);
7596 let code = json["code"].as_str().unwrap();
7597 assert_eq!(
7598 store::find_active_code_for_did(&state.db, "did:plc:follower2")
7599 .await
7600 .unwrap()
7601 .as_deref(),
7602 Some(code)
7603 );
7604 }
7605
7606 #[tokio::test]
7607 async fn bot_claims_concurrent_same_did_never_double_mints() {
7608 let state = bot_state("bot-secret-abcdef").await;
7615 let app = router(state.clone());
7616
7617 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7618 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7619 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7620
7621 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7622 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7623
7624 assert_eq!(
7626 store::count_active_codes(&state.db).await.unwrap(),
7627 1,
7628 "concurrent mints must not create two active codes"
7629 );
7630
7631 let ca = ja["code"].as_str().unwrap_or("");
7633 let cb = jb["code"].as_str().unwrap_or("");
7634 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7635 assert_eq!(ca, cb, "both callers must get the one minted code");
7636 for st in [&ja["status"], &jb["status"]] {
7639 let s = st.as_str().unwrap_or("");
7640 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7641 }
7642 }
7643
7644 #[tokio::test]
7645 async fn bot_claims_rejects_malformed_json_body() {
7646 let state = bot_state("bot-secret-abcdef").await;
7647 let app = router(state);
7648 let resp = app
7649 .oneshot(
7650 Request::builder()
7651 .method("POST")
7652 .uri("/bot/claims")
7653 .header("x-bot-secret", "bot-secret-abcdef")
7654 .header("content-type", "application/json")
7655 .body(Body::from("{not json"))
7656 .unwrap(),
7657 )
7658 .await
7659 .unwrap();
7660 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7661 }
7662
7663 #[tokio::test]
7664 async fn favicon_ico_served_at_root() {
7665 let state = test_state(&[]).await;
7668 let app = router(state);
7669 let resp = app
7670 .oneshot(
7671 Request::builder()
7672 .uri("/favicon.ico")
7673 .body(Body::empty())
7674 .unwrap(),
7675 )
7676 .await
7677 .unwrap();
7678 assert_eq!(resp.status(), StatusCode::OK);
7679 let ct = resp
7680 .headers()
7681 .get(header::CONTENT_TYPE)
7682 .unwrap()
7683 .to_str()
7684 .unwrap();
7685 assert!(
7686 ct.contains("icon") || ct.starts_with("image/"),
7687 "content-type = {ct}"
7688 );
7689 }
7690
7691 #[tokio::test]
7692 async fn login_without_invite_redirects_to_beta_redeem() {
7693 let state = test_state(&[]).await;
7695 let app = router(state);
7696 let resp = app
7697 .oneshot(
7698 Request::builder()
7699 .method("POST")
7700 .uri("/login")
7701 .header("content-type", "application/x-www-form-urlencoded")
7702 .body(Body::from("handle=alice.bsky.social"))
7703 .unwrap(),
7704 )
7705 .await
7706 .unwrap();
7707 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7708 assert_eq!(
7709 resp.headers().get(header::LOCATION).unwrap(),
7710 "/beta/redeem"
7711 );
7712 }
7713
7714 #[tokio::test]
7715 async fn login_with_valid_invite_cookie_starts_oauth() {
7716 let state = test_state(&[]).await;
7717 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7718 let cookie = cookie.split(';').next().unwrap().to_string();
7719 let app = router(state);
7720 let resp = app
7721 .oneshot(
7722 Request::builder()
7723 .method("POST")
7724 .uri("/login")
7725 .header("content-type", "application/x-www-form-urlencoded")
7726 .header(header::COOKIE, cookie)
7727 .body(Body::from("handle=alice.bsky.social"))
7728 .unwrap(),
7729 )
7730 .await
7731 .unwrap();
7732 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7734 let loc = resp
7735 .headers()
7736 .get(header::LOCATION)
7737 .unwrap()
7738 .to_str()
7739 .unwrap();
7740 assert!(loc.contains("/login"), "loc = {loc}");
7741 assert_ne!(loc, "/beta/redeem");
7742 }
7743
7744 async fn resolver_never(_handle: String) -> Option<String> {
7747 None
7748 }
7749
7750 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7752 move |_handle| std::future::ready(Some(did.to_string()))
7753 }
7754
7755 #[tokio::test]
7759 async fn may_start_oauth_honors_seat_via_resolved_handle() {
7760 let state = test_state(&["did:plc:admin"]).await;
7763 let headers = HeaderMap::new();
7764 assert!(
7765 may_start_oauth_with(
7766 &state,
7767 &headers,
7768 "admin.example",
7769 resolver_to("did:plc:admin")
7770 )
7771 .await,
7772 "a handle resolving to a seated DID must pass the gate"
7773 );
7774 }
7775
7776 #[tokio::test]
7780 async fn may_start_oauth_bounces_non_member_handle() {
7781 let state = test_state(&["did:plc:admin"]).await;
7782 let headers = HeaderMap::new();
7783 assert!(
7784 !may_start_oauth_with(
7785 &state,
7786 &headers,
7787 "rando.example",
7788 resolver_to("did:plc:rando")
7789 )
7790 .await,
7791 "a resolved DID with no seat must be bounced"
7792 );
7793 }
7794
7795 #[tokio::test]
7798 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7799 let state = test_state(&["did:plc:admin"]).await;
7800 let headers = HeaderMap::new();
7801 assert!(
7802 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7803 "an unresolvable handle must fail closed"
7804 );
7805 }
7806
7807 #[tokio::test]
7811 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7812 let state = test_state(&[]).await;
7813 let did = "did:plc:member";
7814 store::grant_access(&state.db, did, Some("member.example"), "test", None)
7815 .await
7816 .unwrap();
7817 let cookie = session_cookie(&state, did, Some("member.example"));
7818 let mut headers = HeaderMap::new();
7819 headers.insert(header::COOKIE, cookie.parse().unwrap());
7820 assert!(
7821 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7822 "a seated session cookie must pass without resolution"
7823 );
7824 }
7825
7826 #[tokio::test]
7828 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7829 let state = test_state(&[]).await;
7830 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7831 let cookie = cookie.split(';').next().unwrap().to_string();
7832 let mut headers = HeaderMap::new();
7833 headers.insert(header::COOKIE, cookie.parse().unwrap());
7834 assert!(
7835 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7836 "a valid invite cookie must pass without resolution"
7837 );
7838 }
7839
7840 #[tokio::test]
7841 async fn admin_mint_requires_admin_seed_did() {
7842 let state = test_state(&["did:plc:admin"]).await;
7843 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7845 .await
7846 .unwrap();
7847 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7848 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7850 let app = router(state);
7851
7852 let forbidden = app
7853 .clone()
7854 .oneshot(
7855 Request::builder()
7856 .method("POST")
7857 .uri("/admin/invites?n=2")
7858 .header(header::COOKIE, rando_cookie)
7859 .body(Body::empty())
7860 .unwrap(),
7861 )
7862 .await
7863 .unwrap();
7864 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7865
7866 let ok = app
7867 .oneshot(
7868 Request::builder()
7869 .method("POST")
7870 .uri("/admin/invites?n=2")
7871 .header(header::COOKIE, admin_cookie)
7872 .body(Body::empty())
7873 .unwrap(),
7874 )
7875 .await
7876 .unwrap();
7877 assert_eq!(ok.status(), StatusCode::OK);
7878 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7879 .await
7880 .unwrap();
7881 let body = String::from_utf8(bytes.to_vec()).unwrap();
7882 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7883 assert_eq!(minted.len(), 2);
7884 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7885 }
7886
7887 #[tokio::test]
7888 async fn admin_mint_unauthenticated_is_401() {
7889 let state = test_state(&["did:plc:admin"]).await;
7890 let app = router(state);
7891 let resp = app
7892 .oneshot(
7893 Request::builder()
7894 .method("POST")
7895 .uri("/admin/invites")
7896 .body(Body::empty())
7897 .unwrap(),
7898 )
7899 .await
7900 .unwrap();
7901 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7902 }
7903
7904 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7907 let db = store::init_url("sqlite::memory:").await.unwrap();
7908 store::record_network_stat(
7909 &db,
7910 &store::NetworkStat {
7911 key: store::ADOPTION_STAT_KEY.to_string(),
7912 source: "https://relay1.us-west.bsky.network".to_string(),
7913 value: repos,
7914 truncated,
7915 observed_at: "2026-08-13T04:05:06Z".to_string(),
7916 },
7917 )
7918 .await
7919 .unwrap();
7920 let config = Config {
7921 cookie_secret: "test-cookie-secret-000".to_string(),
7922 show_adoption: true,
7923 ..Config::default()
7924 };
7925 AppState::new(config, db).unwrap()
7926 }
7927
7928 async fn about_body(state: AppState) -> String {
7929 let resp = router(state)
7930 .oneshot(
7931 Request::builder()
7932 .uri("/about")
7933 .body(Body::empty())
7934 .unwrap(),
7935 )
7936 .await
7937 .unwrap();
7938 assert_eq!(resp.status(), StatusCode::OK);
7939 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7940 .await
7941 .unwrap();
7942 String::from_utf8(bytes.to_vec()).unwrap()
7943 }
7944
7945 #[tokio::test]
7947 async fn about_omits_adoption_line_by_default() {
7948 let state = test_state(&[]).await;
7949 assert!(!state.config.show_adoption);
7950 let body = about_body(state).await;
7951 assert!(
7952 !body.contains("atproto network"),
7953 "the adoption line must not render by default"
7954 );
7955 }
7956
7957 #[tokio::test]
7958 async fn about_renders_adoption_line_when_enabled() {
7959 let body = about_body(adoption_state(7_318, false).await).await;
7967 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7970 assert!(
7971 flat.contains("7318 accounts on the atproto network hold"),
7972 "the count did not render in its own sentence: {flat}",
7973 );
7974 assert!(
7975 body.contains("accounts on the atproto network hold"),
7976 "{body}"
7977 );
7978 assert!(
7979 body.contains("2026-08-13"),
7980 "the observation date must render"
7981 );
7982 assert!(
7983 body.contains("lower bound"),
7984 "the non-archival caveat must ride along with the number"
7985 );
7986 assert!(
7987 !body.contains("At least"),
7988 "an untruncated count is exact-ish"
7989 );
7990 }
7991
7992 #[tokio::test]
7994 async fn about_adoption_line_is_singular_at_one() {
7995 let body = about_body(adoption_state(1, false).await).await;
7996 assert!(
7997 body.contains("account on the atproto network holds"),
7998 "{body}"
7999 );
8000 }
8001
8002 #[tokio::test]
8004 async fn about_adoption_line_says_at_least_when_truncated() {
8005 let body = about_body(adoption_state(25_000, true).await).await;
8006 assert!(body.contains("At least"), "{body}");
8007 }
8008
8009 #[tokio::test]
8011 async fn about_omits_line_when_enabled_with_no_observation() {
8012 let db = store::init_url("sqlite::memory:").await.unwrap();
8013 let config = Config {
8014 cookie_secret: "test-cookie-secret-000".to_string(),
8015 show_adoption: true,
8016 ..Config::default()
8017 };
8018 let body = about_body(AppState::new(config, db).unwrap()).await;
8019 assert!(!body.contains("atproto network"));
8020 }
8021
8022 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
8033 let db = store::init_url("sqlite::memory:").await.unwrap();
8034 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
8035 let config = Config {
8036 allowed_dids: vec![did.to_string()],
8037 cookie_secret: "test-cookie-secret-000".to_string(),
8038 beta_cap: 3,
8039 standard_site,
8040 ..Config::default()
8041 };
8042 AppState::new(config, db).unwrap()
8043 }
8044
8045 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
8047 let cookie = session_cookie(&state, did, Some("reader.example"));
8048 let resp = router(state)
8049 .oneshot(
8050 Request::builder()
8051 .uri(path)
8052 .header(header::COOKIE, cookie)
8053 .body(Body::empty())
8054 .unwrap(),
8055 )
8056 .await
8057 .unwrap();
8058 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8059 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
8060 .await
8061 .unwrap();
8062 String::from_utf8(bytes.to_vec()).unwrap()
8063 }
8064
8065 async fn public_body(state: AppState, path: &str) -> String {
8067 let resp = router(state)
8068 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8069 .await
8070 .unwrap();
8071 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8072 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
8073 .await
8074 .unwrap();
8075 String::from_utf8(bytes.to_vec()).unwrap()
8076 }
8077
8078 fn feed_url_input(body: &str) -> &str {
8080 let start = body
8081 .find("id=\"feed-url\"")
8082 .and_then(|i| body[..i].rfind("<input"))
8083 .expect("the subscribe form's URL input renders");
8084 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
8085 &body[start..end]
8086 }
8087
8088 #[tokio::test]
8091 async fn manage_hints_at_publications_when_the_flag_is_on() {
8092 let did = "did:plc:reader";
8093 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8094 assert!(
8095 body.contains("at://did:plc:…/site.standard.publication/…"),
8096 "the DID form must be shown: {body}"
8097 );
8098 assert!(
8099 body.contains("at://alice.example.com/site.standard.publication/…"),
8100 "the handle form must be shown: {body}"
8101 );
8102 }
8103
8104 #[tokio::test]
8110 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
8111 let did = "did:plc:reader";
8112 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8113 let input = feed_url_input(&body);
8114 assert!(
8115 input.contains("type=\"text\""),
8116 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
8117 );
8118 assert!(
8119 input.contains("inputmode=\"url\""),
8120 "the URL keyboard is still wanted: {input}"
8121 );
8122 }
8123
8124 #[tokio::test]
8130 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
8131 let did = "did:plc:reader";
8132 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8133 let input = feed_url_input(&body);
8134 assert!(
8135 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
8136 "the text input must keep a scheme check: {input}"
8137 );
8138 }
8139
8140 #[tokio::test]
8143 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
8144 let did = "did:plc:reader";
8145 let state = standard_site_state(false, did).await;
8146 assert!(!state.config.standard_site);
8147 let page = signed_in_body(state, "/manage", did).await;
8148 let body = &page[page.find("</head>").expect("a <head>")..];
8153 assert!(
8154 !body.contains("site.standard.publication"),
8155 "a refused form must not be advertised: {body}"
8156 );
8157 let above_footer = body
8162 .split("<footer")
8163 .next()
8164 .expect("split yields at least one piece");
8165 assert!(
8166 above_footer.contains("id=\"feed-url\""),
8167 "the form must be above the footer: {body}"
8168 );
8169 assert!(
8170 !above_footer.contains("standard.site"),
8171 "a refused form must not be advertised: {body}"
8172 );
8173 assert!(
8174 feed_url_input(body).contains("type=\"url\""),
8175 "with the flag off the input is unchanged"
8176 );
8177 }
8178
8179 #[tokio::test]
8182 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
8183 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
8184 assert!(body.contains("standard.site"), "{body}");
8185 assert!(
8186 body.contains("at://did:plc:…/site.standard.publication/…"),
8187 "the landing page must show the DID form: {body}"
8188 );
8189 assert!(
8190 body.contains("at://alice.example.com/site.standard.publication/…"),
8191 "the landing page must show the handle form: {body}"
8192 );
8193 }
8194
8195 #[tokio::test]
8199 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
8200 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
8201 assert!(body.contains("standard.site"), "{body}");
8202 assert!(
8203 !body.contains("at://did:plc:…/site.standard.publication/…"),
8204 "no paste instructions with the flag off: {body}"
8205 );
8206 assert!(
8207 !body.contains("at://alice.example.com/site.standard.publication/…"),
8208 "no paste instructions with the flag off: {body}"
8209 );
8210 assert!(
8211 body.contains("isn't accepting new publication subscriptions"),
8212 "the page must say the form is closed here: {body}"
8213 );
8214 }
8215
8216 #[tokio::test]
8218 async fn about_describes_publications_and_how_to_subscribe_when_on() {
8219 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
8220 assert!(body.contains("site.standard.publication"), "{body}");
8221 assert!(body.contains("site.standard.document"), "{body}");
8222 assert!(
8223 body.contains("at://did:plc:…/site.standard.publication/…"),
8224 "{body}"
8225 );
8226 assert!(
8227 body.contains("at://alice.example.com/site.standard.publication/…"),
8228 "{body}"
8229 );
8230 }
8231
8232 #[tokio::test]
8234 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
8235 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
8236 assert!(body.contains("site.standard.publication"), "{body}");
8237 assert!(
8238 !body.contains("at://did:plc:…/site.standard.publication/…"),
8239 "no paste instructions with the flag off: {body}"
8240 );
8241 assert!(
8242 !body.contains("at://alice.example.com/site.standard.publication/…"),
8243 "no paste instructions with the flag off: {body}"
8244 );
8245 assert!(
8246 body.contains("isn't accepting new publication subscriptions"),
8247 "{body}"
8248 );
8249 }
8250
8251 #[tokio::test]
8259 async fn standard_site_page_renders_signed_out() {
8260 let body = public_body(test_state(&[]).await, "/standard-site").await;
8261 assert!(body.contains("site.standard.publication"), "{body}");
8262 assert!(body.contains("site.standard.document"), "{body}");
8263 assert!(
8264 body.contains("<title>standard.site — FeatherReader</title>"),
8265 "{body}"
8266 );
8267 }
8268
8269 #[tokio::test]
8272 async fn standard_site_page_tells_how_to_subscribe_when_on() {
8273 let body = public_body(
8274 standard_site_state(true, "did:plc:x").await,
8275 "/standard-site",
8276 )
8277 .await;
8278 assert!(
8279 body.contains("at://did:plc:…/site.standard.publication/…"),
8280 "the DID form must be shown: {body}"
8281 );
8282 assert!(
8283 body.contains("at://alice.example.com/site.standard.publication/…"),
8284 "the handle form must be shown: {body}"
8285 );
8286 assert!(
8287 body.contains("resolved to its DID"),
8288 "the handle resolution must be stated: {body}"
8289 );
8290 assert!(
8291 !body.contains("isn't accepting new publication subscriptions"),
8292 "{body}"
8293 );
8294 }
8295
8296 #[tokio::test]
8300 async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
8301 let state = standard_site_state(false, "did:plc:x").await;
8302 assert!(!state.config.standard_site);
8303 let body = public_body(state, "/standard-site").await;
8304 assert!(body.contains("site.standard.publication"), "{body}");
8305 assert!(
8306 !body.contains("at://did:plc:…/site.standard.publication/…"),
8307 "no paste instructions with the flag off: {body}"
8308 );
8309 assert!(
8310 !body.contains("at://alice.example.com/site.standard.publication/…"),
8311 "no paste instructions with the flag off: {body}"
8312 );
8313 assert!(
8314 body.contains("isn't accepting new publication subscriptions"),
8315 "the page must say the form is closed here: {body}"
8316 );
8317 assert!(
8318 body.contains("already follows are still read"),
8319 "stored publications are polled whatever the flag says: {body}"
8320 );
8321 }
8322
8323 #[tokio::test]
8326 async fn releases_callout_links_the_release_pages() {
8327 for path in ["/standard-site", "/"] {
8328 let body = public_body(test_state(&[]).await, path).await;
8329 for tag in ["v0.4.1", "v0.4.0"] {
8330 let href = format!(
8331 "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
8332 );
8333 assert!(body.contains(&href), "{path} must link {tag}: {body}");
8334 }
8335 assert!(
8336 body.contains(
8337 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
8338 ),
8339 "{path} must link the changelog: {body}"
8340 );
8341 }
8342 }
8343
8344 #[tokio::test]
8348 async fn landing_about_and_footer_link_the_standard_site_page() {
8349 for path in ["/", "/about", "/privacy"] {
8350 let body = public_body(test_state(&[]).await, path).await;
8351 assert!(
8352 body.contains("href=\"/standard-site\""),
8353 "{path} must link the feature page: {body}"
8354 );
8355 }
8356 }
8357
8358 #[test]
8362 fn releases_are_newest_first_and_link_the_tag_and_changelog() {
8363 assert!(!RELEASES.is_empty());
8364 let parse = |v: &str| -> Vec<u32> {
8365 v.split('.')
8366 .map(|p| p.parse::<u32>().expect("a numeric version part"))
8367 .collect()
8368 };
8369 for pair in RELEASES.windows(2) {
8370 assert!(
8371 parse(pair[0].version) > parse(pair[1].version),
8372 "{} must come before {}",
8373 pair[0].version,
8374 pair[1].version
8375 );
8376 }
8377 for r in RELEASES {
8378 assert_eq!(parse(r.version).len(), 3, "{}", r.version);
8379 assert!(
8380 chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
8381 "{} is not YYYY-MM-DD",
8382 r.date
8383 );
8384 assert!(!r.summary.trim().is_empty());
8385 assert!(!r.summary.contains('<'), "the summary is plain text");
8386 assert_eq!(
8387 r.url(),
8388 format!(
8389 "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
8390 r.version
8391 )
8392 );
8393 }
8394 let latest = &RELEASES[0];
8397 assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
8398 assert_eq!(
8399 latest.changelog_url(),
8400 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#048--2026-10-08"
8401 );
8402 }
8403
8404 #[tokio::test]
8406 async fn standard_site_page_is_publicly_cacheable() {
8407 let resp = router(test_state(&[]).await)
8408 .oneshot(
8409 Request::builder()
8410 .uri("/standard-site")
8411 .body(Body::empty())
8412 .unwrap(),
8413 )
8414 .await
8415 .unwrap();
8416 assert_eq!(resp.status(), StatusCode::OK);
8417 assert_eq!(
8418 resp.headers().get(header::CACHE_CONTROL).unwrap(),
8419 "public, max-age=300"
8420 );
8421 }
8422
8423 #[tokio::test]
8424 async fn cache_control_public_on_about_no_store_on_authed() {
8425 let state = test_state(&["did:plc:admin"]).await;
8426 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
8427 let app = router(state);
8428
8429 let about = app
8431 .clone()
8432 .oneshot(
8433 Request::builder()
8434 .uri("/about")
8435 .body(Body::empty())
8436 .unwrap(),
8437 )
8438 .await
8439 .unwrap();
8440 assert_eq!(
8441 about.headers().get(header::CACHE_CONTROL).unwrap(),
8442 "public, max-age=300"
8443 );
8444 assert_eq!(
8450 about.headers()["content-security-policy"],
8451 EXPECTED_CSP,
8452 "the CSP is not the policy the router promises"
8453 );
8454 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
8455
8456 for path in ["/privacy", "/terms"] {
8458 let resp = app
8459 .clone()
8460 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8461 .await
8462 .unwrap();
8463 assert_eq!(resp.status(), StatusCode::OK);
8464 assert_eq!(
8465 resp.headers().get(header::CACHE_CONTROL).unwrap(),
8466 "public, max-age=300",
8467 "{path} should be publicly cacheable"
8468 );
8469 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
8471 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
8472 }
8473
8474 let login = app
8476 .clone()
8477 .oneshot(
8478 Request::builder()
8479 .uri("/login")
8480 .body(Body::empty())
8481 .unwrap(),
8482 )
8483 .await
8484 .unwrap();
8485 assert_eq!(
8486 login.headers().get(header::CACHE_CONTROL).unwrap(),
8487 "public, max-age=300"
8488 );
8489
8490 let home = app
8492 .oneshot(
8493 Request::builder()
8494 .uri("/")
8495 .header(header::COOKIE, admin_cookie)
8496 .body(Body::empty())
8497 .unwrap(),
8498 )
8499 .await
8500 .unwrap();
8501 assert_eq!(
8502 home.headers().get(header::CACHE_CONTROL).unwrap(),
8503 "no-store"
8504 );
8505 }
8506
8507 fn head(body: &str) -> &str {
8516 let end = body.find("</head>").expect("a <head>");
8517 &body[..end]
8518 }
8519
8520 fn meta(head: &str, attr: &str) -> Option<String> {
8523 let tag_start = head.find(attr)?;
8524 let rest = &head[tag_start..];
8525 let tag_end = rest.find('>')?;
8526 let tag = &rest[..tag_end];
8527 let content = tag.find("content=\"")? + "content=\"".len();
8528 let close = tag[content..].find('"')?;
8529 Some(tag[content..content + close].to_string())
8530 }
8531
8532 async fn production_origin_state() -> AppState {
8536 let db = store::init_url("sqlite::memory:").await.unwrap();
8537 store::ensure_seed(&db, &["did:plc:admin".to_string()])
8538 .await
8539 .unwrap();
8540 let config = Config {
8541 allowed_dids: vec!["did:plc:admin".to_string()],
8542 cookie_secret: "test-cookie-secret-000".to_string(),
8543 beta_cap: 3,
8544 public_url: "https://feather-reader.com".to_string(),
8545 ..Config::default()
8546 };
8547 AppState::new(config, db).unwrap()
8548 }
8549
8550 #[tokio::test]
8553 async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
8554 let landing = public_body(production_origin_state().await, "/").await;
8555 let about = public_body(production_origin_state().await, "/about").await;
8556 let (lh, ah) = (head(&landing), head(&about));
8557
8558 assert_eq!(
8559 meta(lh, "property=\"og:title\"").as_deref(),
8560 Some("FeatherReader — read, quietly"),
8561 "{lh}"
8562 );
8563 assert_eq!(
8564 meta(ah, "property=\"og:title\"").as_deref(),
8565 Some("About — FeatherReader"),
8566 "{ah}"
8567 );
8568 for (h, path) in [(lh, "/"), (ah, "/about")] {
8569 let url = format!("https://feather-reader.com{path}");
8570 assert_eq!(
8571 meta(h, "property=\"og:url\"").as_deref(),
8572 Some(url.as_str())
8573 );
8574 assert!(
8575 h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
8576 "{path} must carry a canonical link: {h}"
8577 );
8578 let image = meta(h, "property=\"og:image\"").unwrap_or_default();
8579 assert!(
8580 image.starts_with("https://feather-reader.com/static/"),
8581 "{path}: og:image must be absolute on the public origin, got {image:?}"
8582 );
8583 assert_eq!(
8584 meta(h, "name=\"twitter:card\"").as_deref(),
8585 Some("summary_large_image")
8586 );
8587 assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
8588 assert_eq!(
8589 meta(h, "property=\"og:site_name\"").as_deref(),
8590 Some("FeatherReader")
8591 );
8592 let description = meta(h, "property=\"og:description\"").unwrap_or_default();
8593 assert!(!description.is_empty(), "{path}: og:description is empty");
8594 assert_eq!(
8595 meta(h, "name=\"description\"").as_deref(),
8596 Some(description.as_str()),
8597 "{path}: the meta description and og:description must agree"
8598 );
8599 }
8600 assert_ne!(
8601 meta(lh, "property=\"og:description\""),
8602 meta(ah, "property=\"og:description\""),
8603 "the landing page and /about must not share a description"
8604 );
8605 }
8606
8607 #[tokio::test]
8609 async fn card_urls_follow_the_configured_public_url() {
8610 let db = store::init_url("sqlite::memory:").await.unwrap();
8611 store::ensure_seed(&db, &[]).await.unwrap();
8612 let config = Config {
8613 cookie_secret: "test-cookie-secret-000".to_string(),
8614 public_url: "https://reader.example.org".to_string(),
8615 ..Config::default()
8616 };
8617 let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8618 let h = head(&body);
8619 assert_eq!(
8620 meta(h, "property=\"og:url\"").as_deref(),
8621 Some("https://reader.example.org/privacy")
8622 );
8623 assert_eq!(
8624 meta(h, "property=\"og:image\"").as_deref(),
8625 Some("https://reader.example.org/static/social-card.png")
8626 );
8627 }
8628
8629 #[tokio::test]
8632 async fn public_pages_each_carry_their_own_description() {
8633 let paths = [
8634 "/",
8635 "/about",
8636 "/privacy",
8637 "/terms",
8638 "/stats",
8639 "/standard-site",
8640 "/login",
8641 "/beta/redeem",
8642 ];
8643 let mut seen = std::collections::HashSet::new();
8644 for path in paths {
8645 let body = public_body(production_origin_state().await, path).await;
8646 let h = head(&body);
8647 let description = meta(h, "name=\"description\"").unwrap_or_default();
8648 assert!(!description.is_empty(), "{path} has no description: {h}");
8649 assert!(
8650 seen.insert(description.clone()),
8651 "{path} repeats another page's description: {description:?}"
8652 );
8653 assert_eq!(
8654 meta(h, "property=\"og:url\"").as_deref(),
8655 Some(format!("https://feather-reader.com{path}").as_str()),
8656 "{path}"
8657 );
8658 assert!(
8659 !h.contains("name=\"robots\""),
8660 "{path} is public and must not be noindex: {h}"
8661 );
8662 }
8663 }
8664
8665 #[tokio::test]
8668 async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8669 let landing = public_body(production_origin_state().await, "/").await;
8670 let h = head(&landing);
8671 let image = meta(h, "property=\"og:image\"").unwrap();
8672 let path = image.strip_prefix("https://feather-reader.com").unwrap();
8673 let width: u32 = meta(h, "property=\"og:image:width\"")
8674 .unwrap()
8675 .parse()
8676 .unwrap();
8677 let height: u32 = meta(h, "property=\"og:image:height\"")
8678 .unwrap()
8679 .parse()
8680 .unwrap();
8681 assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8682 assert_eq!(
8683 meta(h, "property=\"og:image:type\"").as_deref(),
8684 Some("image/png")
8685 );
8686 assert!(
8687 !meta(h, "property=\"og:image:alt\"")
8688 .unwrap_or_default()
8689 .is_empty(),
8690 "the image needs alt text"
8691 );
8692
8693 let resp = router(production_origin_state().await)
8694 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8695 .await
8696 .unwrap();
8697 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8698 assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8699 assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8700 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8701 .await
8702 .expect("the image is under 1 MB");
8703 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8704 let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8706 assert_eq!(
8707 (be(16), be(20)),
8708 (width, height),
8709 "the PNG's own dimensions must match the tags"
8710 );
8711 }
8712
8713 #[tokio::test]
8716 async fn private_pages_keep_user_data_out_of_the_card() {
8717 for path in ["/", "/manage"] {
8718 let state = production_origin_state().await;
8719 let body = signed_in_body(state, path, "did:plc:admin").await;
8720 let h = head(&body);
8721 assert!(
8722 h.contains("<meta name=\"robots\" content=\"noindex\""),
8723 "{path}: a private view must be noindex: {h}"
8724 );
8725 assert_eq!(
8726 meta(h, "property=\"og:title\"").as_deref(),
8727 Some("FeatherReader — read, quietly"),
8728 "{path}: the card of a private view is the site's generic one"
8729 );
8730 assert_eq!(
8731 meta(h, "property=\"og:url\"").as_deref(),
8732 Some("https://feather-reader.com/"),
8733 "{path}: og:url of a private view is the front door, not the private path"
8734 );
8735 for private in ["reader.example", "did:plc:admin"] {
8736 assert!(
8737 !h.contains(private),
8738 "{path}: {private:?} must not reach <head>: {h}"
8739 );
8740 }
8741 }
8742 }
8743
8744 #[tokio::test]
8745 async fn beta_redeem_page_renders() {
8746 let state = test_state(&[]).await;
8747 let app = router(state);
8748 let resp = app
8749 .oneshot(
8750 Request::builder()
8751 .uri("/beta/redeem")
8752 .body(Body::empty())
8753 .unwrap(),
8754 )
8755 .await
8756 .unwrap();
8757 assert_eq!(resp.status(), StatusCode::OK);
8758 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8759 .await
8760 .unwrap();
8761 let html = String::from_utf8(bytes.to_vec()).unwrap();
8762 assert!(html.contains("Invite code"));
8763 assert!(html.contains("/beta/redeem"));
8764 }
8765
8766 #[tokio::test]
8767 async fn rate_limit_returns_429_after_burst() {
8768 let db = store::init_url("sqlite::memory:").await.unwrap();
8771 store::ensure_seed(&db, &[]).await.unwrap();
8772 let config = Config {
8773 cookie_secret: "test-cookie-secret-000".to_string(),
8774 beta_cap: 3,
8775 trusted_ip_header: Some("cf-connecting-ip".to_string()),
8776 ..Config::default()
8777 };
8778 let state = AppState::new(config, db).unwrap();
8779 let app = router(state);
8780 let mut saw_429 = false;
8784 for _ in 0..(RATE_BURST as usize + 5) {
8785 let resp = app
8786 .clone()
8787 .oneshot(
8788 Request::builder()
8789 .method("POST")
8790 .uri("/beta/redeem")
8791 .header("content-type", "application/x-www-form-urlencoded")
8792 .header("cf-connecting-ip", "203.0.113.200")
8793 .body(Body::from("code=FEATHER-NOPENOPE"))
8794 .unwrap(),
8795 )
8796 .await
8797 .unwrap();
8798 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8799 saw_429 = true;
8800 break;
8801 }
8802 }
8803 assert!(saw_429, "expected a 429 after exhausting the burst");
8804 }
8805
8806 #[tokio::test]
8819 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8820 let state = test_state(&[]).await;
8821 assert!(
8822 state.config.trusted_ip_header.is_none(),
8823 "no proxy header is trusted here"
8824 );
8825 let app = router(state);
8826 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8827 let mut saw_429 = false;
8828 for i in 0..(RATE_BURST as usize + 5) {
8829 let forged = format!("10.9.8.{}", i % 250);
8830 let resp = app
8831 .clone()
8832 .oneshot(
8833 Request::builder()
8834 .method("POST")
8835 .uri("/beta/redeem")
8836 .header("content-type", "application/x-www-form-urlencoded")
8837 .header("x-forwarded-for", forged)
8838 .extension(axum::extract::ConnectInfo(peer))
8839 .body(Body::from("code=FEATHER-NOPENOPE"))
8840 .unwrap(),
8841 )
8842 .await
8843 .unwrap();
8844 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8845 saw_429 = true;
8846 break;
8847 }
8848 }
8849 assert!(
8850 saw_429,
8851 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8852 );
8853 }
8854
8855 #[tokio::test]
8864 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8865 let did = "did:plc:privateadder";
8866 let state = test_state_with_caps(did, 0, 0).await;
8867 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8868 let port: u16 = base
8869 .trim_end_matches('/')
8870 .rsplit(':')
8871 .next()
8872 .unwrap()
8873 .parse()
8874 .unwrap();
8875 crate::net::test_host_override(
8876 "private-add.test",
8877 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8878 );
8879 let cookie = session_cookie(&state, did, None);
8880 let resp = router(state.clone())
8881 .oneshot(
8882 Request::builder()
8883 .method("POST")
8884 .uri("/subscriptions")
8885 .header(header::COOKIE, cookie)
8886 .header("content-type", "application/x-www-form-urlencoded")
8887 .body(Body::from(format!(
8888 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8889 )))
8890 .unwrap(),
8891 )
8892 .await
8893 .unwrap();
8894 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8895 let loc = resp
8896 .headers()
8897 .get(header::LOCATION)
8898 .unwrap()
8899 .to_str()
8900 .unwrap();
8901 assert!(loc.contains("Private"), "not refused as private: {loc}");
8902 assert_eq!(
8903 hits.load(std::sync::atomic::Ordering::SeqCst),
8904 0,
8905 "the private feed was FETCHED before being refused"
8906 );
8907 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8908 }
8909
8910 #[tokio::test]
8915 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8916 let did = "did:plc:renamer4";
8917 let (sidecar, bodies) = spawn_logging_sidecar().await;
8918 let state = test_state_with_sidecar(&[did], &sidecar).await;
8919 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8920 let opml = format!(
8921 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8922 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8923 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8924 </body></opml>"
8925 );
8926 let (ct, body) = opml_multipart(opml.as_bytes());
8927 let cookie = session_cookie(&state, did, None);
8928 let resp = router(state.clone())
8929 .oneshot(
8930 Request::builder()
8931 .method("POST")
8932 .uri("/opml")
8933 .header(header::COOKIE, cookie)
8934 .header("content-type", ct)
8935 .body(Body::from(body))
8936 .unwrap(),
8937 )
8938 .await
8939 .unwrap();
8940 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8941 let loc = resp
8942 .headers()
8943 .get(header::LOCATION)
8944 .unwrap()
8945 .to_str()
8946 .unwrap();
8947 assert!(
8948 loc.contains("skipped%20as%20private"),
8949 "not reported as skipped: {loc}"
8950 );
8951 assert!(store::get_feed_by_url(&state.db, tokened)
8952 .await
8953 .unwrap()
8954 .is_none());
8955 let sent = bodies.lock().unwrap().join("\n");
8956 assert!(
8957 sent.contains("public.example"),
8958 "the public feed was not written: {sent}"
8959 );
8960 assert!(
8961 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8962 "the secret was PUBLISHED to the PDS: {sent}"
8963 );
8964 }
8965
8966 #[tokio::test]
8970 async fn get_login_without_a_seat_is_refused() {
8971 let state = test_state(&[]).await;
8972 let resp = router(state)
8973 .oneshot(
8974 Request::builder()
8975 .method("GET")
8976 .uri("/login?handle=alice.bsky.social")
8977 .body(Body::empty())
8978 .unwrap(),
8979 )
8980 .await
8981 .unwrap();
8982 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8983 assert_eq!(
8984 resp.headers().get(header::LOCATION).unwrap(),
8985 "/beta/redeem"
8986 );
8987 }
8988
8989 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8993 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8994 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8995 let addr = listener.local_addr().unwrap();
8996 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8997 let sink = log.clone();
8998 tokio::spawn(async move {
8999 loop {
9000 let Ok((mut sock, _)) = listener.accept().await else {
9001 break;
9002 };
9003 let mut raw: Vec<u8> = Vec::new();
9004 let mut chunk = [0u8; 4096];
9005 let text = loop {
9006 let Ok(n) = sock.read(&mut chunk).await else {
9007 break String::new();
9008 };
9009 if n == 0 {
9010 break String::from_utf8_lossy(&raw).to_string();
9011 }
9012 raw.extend_from_slice(&chunk[..n]);
9013 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9014 continue;
9015 };
9016 let (head, body) = raw.split_at(split + 4);
9017 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9018 let (k, v) = l.split_once(':')?;
9019 k.eq_ignore_ascii_case("content-length")
9020 .then(|| v.trim().parse::<usize>().ok())?
9021 });
9022 if want.is_none_or(|w| body.len() >= w) {
9023 break String::from_utf8_lossy(&raw).to_string();
9024 }
9025 };
9026 let path = text
9027 .lines()
9028 .next()
9029 .and_then(|l| l.split_whitespace().nth(1))
9030 .unwrap_or("")
9031 .to_string();
9032 let body_text = text
9033 .split_once("\r\n\r\n")
9034 .map(|(_, b)| b)
9035 .unwrap_or("")
9036 .to_string();
9037 sink.lock().unwrap().push(format!("{path} {body_text}"));
9038 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();
9039 let resp = format!(
9040 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9041 body.len(),
9042 body
9043 );
9044 let _ = sock.write_all(resp.as_bytes()).await;
9045 let _ = sock.flush().await;
9046 }
9047 });
9048 (format!("http://{addr}"), log)
9049 }
9050
9051 #[tokio::test]
9057 async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
9058 use crate::readstate::tests as rs;
9059 for backend in [
9060 crate::metrics::Backend::Sidecar,
9061 crate::metrics::Backend::Rust,
9062 ] {
9063 let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
9064 let state = rs::state_on(backend, &fake).await;
9065 for i in 0..250 {
9066 rs::mark_read(&state, i, "1").await;
9067 }
9068 fake.lock().unwrap().drop_call = Some(2);
9069
9070 flush_before_revoke(&state, rs::DID).await;
9071
9072 let order = rs::send_order(250);
9073 let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
9074 for &i in landed {
9075 let c = rs::cursor(&state, i).await;
9076 assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
9077 }
9078 for &i in rest {
9079 let c = rs::cursor(&state, i).await;
9080 assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
9081 }
9082 assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
9083 }
9084 }
9085
9086 #[tokio::test]
9095 async fn signing_out_flushes_before_it_revokes_through_the_route() {
9096 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9097 let (sidecar, log) = spawn_logging_sidecar().await;
9098 let state = test_state_with_sidecar(&[did], &sidecar).await;
9099 crate::store::upsert_cursor(
9100 &state.db,
9101 &crate::store::ReadCursor {
9102 did: did.to_string(),
9103 feed_url: "https://example.com/feed.xml".into(),
9104 read_through: None,
9105 read_ids: "[\"1\"]".into(),
9106 unread_ids: "[]".into(),
9107 dirty: true,
9108 pds_created: false,
9109 updated_at: "2026-09-13T21:22:40Z".into(),
9110 },
9111 )
9112 .await
9113 .unwrap();
9114 let cookie = session_cookie(&state, did, None);
9115 let resp = router(state.clone())
9116 .oneshot(
9117 Request::builder()
9118 .method("POST")
9119 .uri("/logout")
9120 .header(header::COOKIE, cookie)
9121 .body(Body::empty())
9122 .unwrap(),
9123 )
9124 .await
9125 .unwrap();
9126 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9127
9128 let entries = log.lock().unwrap().clone();
9129 let flush = entries
9130 .iter()
9131 .position(|e| e.starts_with("/internal/repo "));
9132 let revoke = entries
9133 .iter()
9134 .position(|e| e.starts_with("/internal/revoke "));
9135 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
9136 assert!(
9137 flush.is_some(),
9138 "sign-out did not attempt a flush before revoking: {entries:?}"
9139 );
9140 assert!(
9141 flush < revoke,
9142 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
9143 );
9144 }
9145
9146 const EXPECTED_CSP: &str = "default-src 'self'; \
9150 script-src 'self'; \
9151 style-src 'self' 'unsafe-inline'; \
9152 img-src 'self' https: data:; \
9153 font-src 'self'; \
9154 connect-src 'self'; \
9155 form-action 'self'; \
9156 base-uri 'self'; \
9157 frame-ancestors 'none'; \
9158 object-src 'none'";
9159
9160 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
9163 let boundary = "----featherreadertestboundary";
9164 let mut body = Vec::new();
9165 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
9166 body.extend_from_slice(
9167 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
9168 );
9169 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
9170 body.extend_from_slice(payload);
9171 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
9172 (format!("multipart/form-data; boundary={boundary}"), body)
9173 }
9174
9175 #[tokio::test]
9176 async fn opml_import_oversize_upload_returns_413() {
9177 let state = test_state(&["did:plc:admin"]).await;
9178 let cookie = session_cookie(&state, "did:plc:admin", None);
9179 let app = router(state);
9180
9181 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
9183 let (content_type, body) = opml_multipart(&payload);
9184
9185 let resp = app
9186 .oneshot(
9187 Request::builder()
9188 .method("POST")
9189 .uri("/opml")
9190 .header("content-type", content_type)
9191 .header(header::COOKIE, cookie)
9192 .body(Body::from(body))
9193 .unwrap(),
9194 )
9195 .await
9196 .unwrap();
9197 assert_eq!(
9198 resp.status(),
9199 StatusCode::PAYLOAD_TOO_LARGE,
9200 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
9201 );
9202 }
9203
9204 #[tokio::test]
9215 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
9216 let state = test_state(&["did:plc:admin"]).await;
9217 let cookie = session_cookie(&state, "did:plc:admin", None);
9218 let app = router(state);
9219
9220 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
9222 let (content_type, body) = opml_multipart(&payload);
9223
9224 let resp = app
9225 .oneshot(
9226 Request::builder()
9227 .method("POST")
9228 .uri("/opml")
9229 .header("content-type", content_type)
9230 .header(header::COOKIE, cookie)
9231 .body(Body::from(body))
9232 .unwrap(),
9233 )
9234 .await
9235 .unwrap();
9236 assert_eq!(
9237 resp.status(),
9238 StatusCode::PAYLOAD_TOO_LARGE,
9239 "a payload over the route's cap but under the framework's was accepted — \
9240 the route's own DefaultBodyLimit layer is not doing anything"
9241 );
9242 }
9243
9244 #[tokio::test]
9245 async fn opml_import_under_limit_upload_is_accepted() {
9246 let state = test_state(&["did:plc:admin"]).await;
9247 let cookie = session_cookie(&state, "did:plc:admin", None);
9248 let db = state.db.clone();
9249 let app = router(state);
9250
9251 let opml = br#"<?xml version="1.0"?>
9254<opml version="2.0"><body>
9255 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
9256</body></opml>"#;
9257 let (content_type, body) = opml_multipart(opml);
9258
9259 let resp = app
9260 .oneshot(
9261 Request::builder()
9262 .method("POST")
9263 .uri("/opml")
9264 .header("content-type", content_type)
9265 .header(header::COOKIE, cookie)
9266 .body(Body::from(body))
9267 .unwrap(),
9268 )
9269 .await
9270 .unwrap();
9271 assert_eq!(
9278 resp.status(),
9279 StatusCode::SEE_OTHER,
9280 "an under-cap OPML upload was not accepted (status {})",
9281 resp.status(),
9282 );
9283 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
9287 .bind("https://example.com/feed.xml")
9288 .fetch_one(&db)
9289 .await
9290 .unwrap();
9291 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
9292 let location = resp
9293 .headers()
9294 .get(header::LOCATION)
9295 .and_then(|v| v.to_str().ok())
9296 .unwrap_or_default()
9297 .to_string();
9298 assert!(
9299 !location.starts_with("/login"),
9300 "the import bounced to login instead of being accepted: {location}",
9301 );
9302 }
9303
9304 #[tokio::test]
9305 async fn opml_import_logged_out_redirects_to_login() {
9306 let state = test_state(&["did:plc:admin"]).await;
9309 let app = router(state);
9310
9311 let opml = b"<opml version=\"2.0\"><body></body></opml>";
9312 let (content_type, body) = opml_multipart(opml);
9313
9314 let resp = app
9315 .oneshot(
9316 Request::builder()
9317 .method("POST")
9318 .uri("/opml")
9319 .header("content-type", content_type)
9320 .body(Body::from(body))
9321 .unwrap(),
9322 )
9323 .await
9324 .unwrap();
9325 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9326 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
9327 }
9328
9329 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
9336 use tokio::io::{AsyncReadExt, AsyncWriteExt};
9337 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9338 let addr = listener.local_addr().unwrap();
9339 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
9340 tokio::spawn(async move {
9341 let (mut sock, _) = listener.accept().await.unwrap();
9342 let mut buf = vec![0u8; 4096];
9343 let n = sock.read(&mut buf).await.unwrap();
9344 let req = String::from_utf8_lossy(&buf[..n]).to_string();
9345 let did = req
9347 .split("\r\n\r\n")
9348 .nth(1)
9349 .and_then(|body| {
9350 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
9351 v.get("did")?.as_str().map(str::to_string)
9352 })
9353 .unwrap_or_default();
9354 let is_revoke = req.starts_with("POST /internal/revoke");
9355 let body = serde_json::json!({
9356 "ok": true, "did": did, "revoked": true, "hadSession": true
9357 })
9358 .to_string();
9359 let resp = format!(
9360 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9361 body.len(),
9362 body
9363 );
9364 sock.write_all(resp.as_bytes()).await.unwrap();
9365 sock.flush().await.unwrap();
9366 let _ = tx.send(if is_revoke { did } else { String::new() });
9367 });
9368 (format!("http://{addr}"), rx)
9369 }
9370
9371 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
9373 let defaults = Config::default();
9374 test_state_with_sidecar_and(
9375 allowed,
9376 sidecar_url,
9377 defaults.standard_site,
9378 defaults.max_feeds_global,
9379 )
9380 .await
9381 }
9382
9383 async fn test_state_with_sidecar_and(
9386 allowed: &[&str],
9387 sidecar_url: &str,
9388 standard_site: bool,
9389 max_feeds_global: i64,
9390 ) -> AppState {
9391 let db = store::init_url("sqlite::memory:").await.unwrap();
9392 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
9393 store::ensure_seed(&db, &dids).await.unwrap();
9394 let mut config = Config {
9395 allowed_dids: dids,
9396 cookie_secret: "test-cookie-secret-000".to_string(),
9397 beta_cap: 3,
9398 standard_site,
9399 max_feeds_global,
9400 ..Config::default()
9401 };
9402 config.sidecar.public_url = sidecar_url.to_string();
9403 config.sidecar.internal_url = sidecar_url.to_string();
9404 AppState::new(config, db).unwrap()
9405 }
9406
9407 #[tokio::test]
9410 async fn account_delete_purges_rows_and_triggers_revoke() {
9411 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
9412 let did = "did:plc:leaver";
9413 let state = test_state_with_sidecar(&[], &sidecar_url).await;
9414
9415 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
9417 .await
9418 .unwrap();
9419 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
9420 store::mint_code(&state.db, did, 3600).await.unwrap();
9421 assert!(store::has_beta_access(&state.db, did).await.unwrap());
9422
9423 let cookie = session_cookie(&state, did, Some("leaver.example"));
9424 let app = router(state.clone());
9425
9426 let resp = app
9427 .oneshot(
9428 Request::builder()
9429 .method("POST")
9430 .uri("/account/delete")
9431 .header(header::COOKIE, cookie)
9432 .header("content-type", "application/x-www-form-urlencoded")
9433 .body(Body::from("confirm=DELETE"))
9434 .unwrap(),
9435 )
9436 .await
9437 .unwrap();
9438
9439 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9441 assert!(resp
9442 .headers()
9443 .get(header::LOCATION)
9444 .unwrap()
9445 .to_str()
9446 .unwrap()
9447 .starts_with("/login"));
9448 let set_cookie = resp
9449 .headers()
9450 .get(header::SET_COOKIE)
9451 .unwrap()
9452 .to_str()
9453 .unwrap();
9454 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
9455
9456 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
9463 .await
9464 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
9465 .unwrap();
9466 assert_eq!(
9467 revoked_did, did,
9468 "sidecar revoke must fire for the caller DID"
9469 );
9470
9471 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
9473 let codes: i64 =
9474 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
9475 .bind(did)
9476 .fetch_one(&state.db)
9477 .await
9478 .unwrap();
9479 assert_eq!(codes, 0);
9480 }
9481
9482 #[tokio::test]
9485 async fn account_delete_without_confirm_is_a_noop() {
9486 let did = "did:plc:staying";
9487 let state = test_state(&[]).await;
9488 store::grant_access(&state.db, did, None, "test", None)
9489 .await
9490 .unwrap();
9491 let cookie = session_cookie(&state, did, None);
9492 let app = router(state.clone());
9493
9494 let resp = app
9495 .oneshot(
9496 Request::builder()
9497 .method("POST")
9498 .uri("/account/delete")
9499 .header(header::COOKIE, cookie)
9500 .header("content-type", "application/x-www-form-urlencoded")
9501 .body(Body::from("confirm=nope"))
9502 .unwrap(),
9503 )
9504 .await
9505 .unwrap();
9506
9507 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9508 assert!(resp
9509 .headers()
9510 .get(header::LOCATION)
9511 .unwrap()
9512 .to_str()
9513 .unwrap()
9514 .starts_with("/manage"));
9515 assert!(store::has_beta_access(&state.db, did).await.unwrap());
9517 }
9518
9519 #[tokio::test]
9526 async fn pds_outage_does_not_widen_cross_did_access() {
9527 let did_a = "did:plc:aaaa";
9528 let state = test_state(&[]).await;
9529 store::grant_access(&state.db, did_a, None, "test", None)
9530 .await
9531 .unwrap();
9532
9533 let feed_a = store::upsert_feed(
9536 &state.db,
9537 &store::NewFeed {
9538 url: "https://a.example/feed.xml".to_string(),
9539 title: Some("A".to_string()),
9540 ..Default::default()
9541 },
9542 )
9543 .await
9544 .unwrap();
9545 let feed_b = store::upsert_feed(
9546 &state.db,
9547 &store::NewFeed {
9548 url: "https://b.example/feed.xml".to_string(),
9549 title: Some("B".to_string()),
9550 ..Default::default()
9551 },
9552 )
9553 .await
9554 .unwrap();
9555 store::insert_entries(
9556 &state.db,
9557 feed_b,
9558 &[store::NewEntry {
9559 guid: "b-1".to_string(),
9560 url: Some("https://b.example/1".to_string()),
9561 title: Some("B one".to_string()),
9562 published: Some("2026-07-11T00:00:00Z".to_string()),
9563 content_html: Some("<p>secret B body</p>".to_string()),
9564 ..Default::default()
9565 }],
9566 0,
9567 )
9568 .await
9569 .unwrap();
9570 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9572 .await
9573 .unwrap();
9574 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
9577 .await
9578 .unwrap();
9579 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
9580 .await
9581 .unwrap()[0]
9582 .id;
9583 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
9584 .await
9585 .unwrap();
9586
9587 let cookie = session_cookie(&state, did_a, None);
9588 let app = router(state.clone());
9589
9590 let get_b = app
9592 .clone()
9593 .oneshot(
9594 Request::builder()
9595 .method("GET")
9596 .uri(format!("/entries/{b_entry_id}"))
9597 .header(header::COOKIE, cookie.clone())
9598 .body(Body::empty())
9599 .unwrap(),
9600 )
9601 .await
9602 .unwrap();
9603 assert_eq!(
9604 get_b.status(),
9605 StatusCode::NOT_FOUND,
9606 "A must not read B's entry during a PDS outage"
9607 );
9608
9609 let read_b = app
9611 .oneshot(
9612 Request::builder()
9613 .method("POST")
9614 .uri(format!("/entries/{b_entry_id}/read"))
9615 .header(header::COOKIE, cookie)
9616 .header("content-type", "application/x-www-form-urlencoded")
9617 .body(Body::from("read=true"))
9618 .unwrap(),
9619 )
9620 .await
9621 .unwrap();
9622 assert_eq!(
9623 read_b.status(),
9624 StatusCode::NOT_FOUND,
9625 "A must not mark B's entry read during a PDS outage"
9626 );
9627
9628 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9630 .bind(did_a)
9631 .fetch_all(&state.db)
9632 .await
9633 .unwrap();
9634 assert_eq!(
9635 a_feed_ids,
9636 vec![feed_a],
9637 "outage fallback must not add feeds A never subscribed to"
9638 );
9639 let es_count: i64 =
9641 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9642 .bind(did_a)
9643 .bind(b_entry_id)
9644 .fetch_one(&state.db)
9645 .await
9646 .unwrap();
9647 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9648 }
9649
9650 #[tokio::test]
9664 async fn a_logout_with_no_session_counts_as_success() {
9665 let did = "did:plc:aaaa";
9666 let state = test_state(&[]).await;
9667 assert!(
9668 state.oauth.is_some(),
9669 "meaningless without an oauth runtime; the revoke arm would be skipped",
9670 );
9671
9672 revoke_everywhere(&state, did).await;
9673 let rows = state.metrics.snapshot();
9674 let find = |b: crate::metrics::Backend| {
9675 rows.iter()
9676 .find(|r| r.op == "oauth_revoke" && r.backend == b)
9677 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9678 };
9679
9680 let rust = find(crate::metrics::Backend::Rust);
9682 assert_eq!(
9683 rust.stats.err_count, 0,
9684 "NoSession was counted as a failure; logout is idempotent",
9685 );
9686 assert_eq!(rust.stats.ok_count, 1);
9687
9688 let sidecar = find(crate::metrics::Backend::Sidecar);
9692 assert_eq!(
9693 sidecar.stats.err_count, 1,
9694 "a failed sidecar revoke was not counted",
9695 );
9696 }
9697
9698 #[tokio::test]
9708 async fn a_failed_rust_revoke_counts_as_an_error() {
9709 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9710 let state = test_state(&[]).await;
9711 let runtime = state.oauth.as_deref().expect("oauth runtime");
9712 crate::oauth::store::put_session(
9713 &state.db,
9714 &runtime.codec,
9715 &crate::oauth::store::OAuthSession {
9716 sub: did.into(),
9717 issuer: "https://auth.invalid".into(),
9718 aud: "https://pds.invalid".into(),
9719 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9720 .to_jwk_json()
9721 .unwrap(),
9722 access_token: "at".into(),
9723 refresh_token: "rt".into(),
9724 token_type: "DPoP".into(),
9725 granted_scope: "atproto".into(),
9726 expires_at: Some(crate::store::now_unix() + 3600),
9727 },
9728 )
9729 .await
9730 .unwrap();
9731
9732 revoke_everywhere(&state, did).await;
9733
9734 let rows = state.metrics.snapshot();
9735 let rust = rows
9736 .iter()
9737 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9738 .expect("no rust oauth_revoke row");
9739 assert_eq!(
9740 rust.stats.err_count, 1,
9741 "an unreachable PDS must count as a revocation failure",
9742 );
9743 assert_eq!(rust.stats.ok_count, 0);
9744 }
9745
9746 #[test]
9762 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9763 for hostile in [
9764 "javascript:alert(1)",
9765 "JavaScript:alert(1)",
9766 " javascript:alert(1)",
9767 "data:text/html;base64,PHNjcmlwdD4=",
9768 "vbscript:msgbox(1)",
9769 "file:///etc/passwd",
9770 "//evil.example/path",
9774 ] {
9775 let link = SafeLink::external(hostile);
9776 assert!(
9777 link.is_empty(),
9778 "{hostile:?} produced a non-empty href: {link}",
9779 );
9780 assert!(
9781 !link.to_string().to_ascii_lowercase().contains("script"),
9782 "{hostile:?} leaked into the rendered link",
9783 );
9784 }
9785
9786 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9789 let link = SafeLink::external(good);
9790 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9791 assert_eq!(link.to_string(), good);
9792 }
9793 }
9794
9795 #[tokio::test]
9810 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9811 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9812 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9813 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9814 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9815
9816 let resp = router(state)
9817 .oneshot(
9818 Request::builder()
9819 .uri("/?view=starred")
9820 .body(Body::empty())
9821 .unwrap(),
9822 )
9823 .await
9824 .unwrap();
9825 assert_eq!(resp.status(), StatusCode::OK);
9826 let body = String::from_utf8(
9827 axum::body::to_bytes(resp.into_body(), usize::MAX)
9828 .await
9829 .unwrap()
9830 .to_vec(),
9831 )
9832 .unwrap();
9833
9834 assert!(
9837 !body.to_ascii_lowercase().contains("javascript:"),
9838 "the hostile scheme reached the rendered page",
9839 );
9840 assert!(
9843 body.contains("unusable link"),
9844 "the row was dropped instead of rendering without an anchor",
9845 );
9846 }
9847
9848 #[tokio::test]
9865 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9866 let did = "did:plc:readerhref";
9867 let state = test_state(&[]).await;
9868 store::grant_access(&state.db, did, None, "test", None)
9869 .await
9870 .unwrap();
9871 let feed = store::upsert_feed(
9872 &state.db,
9873 &store::NewFeed {
9874 url: "https://href.example/feed.xml".to_string(),
9875 title: Some("Href".to_string()),
9876 ..Default::default()
9877 },
9878 )
9879 .await
9880 .unwrap();
9881 store::insert_entries(
9883 &state.db,
9884 feed,
9885 &[
9886 store::NewEntry {
9887 guid: "hostile-1".to_string(),
9888 url: Some("javascript:alert(1)".to_string()),
9889 title: Some("Hostile entry".to_string()),
9890 published: Some("2026-07-11T00:00:00Z".to_string()),
9891 ..Default::default()
9892 },
9893 store::NewEntry {
9894 guid: "benign-1".to_string(),
9895 url: Some("https://href.example/post".to_string()),
9896 title: Some("Benign entry".to_string()),
9897 published: Some("2026-07-10T00:00:00Z".to_string()),
9898 ..Default::default()
9899 },
9900 ],
9901 0,
9902 )
9903 .await
9904 .unwrap();
9905 store::replace_sub_refs(&state.db, did, &[feed])
9906 .await
9907 .unwrap();
9908 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9909 let id_of = |guid: &str| {
9910 rows.iter()
9911 .find(|r| r.guid == guid)
9912 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9913 .id
9914 };
9915
9916 let cookie = session_cookie(&state, did, None);
9917 let app = router(state.clone());
9918
9919 let render = |id: i64| {
9920 let app = app.clone();
9921 let cookie = cookie.clone();
9922 async move {
9923 let resp = app
9924 .oneshot(
9925 Request::builder()
9926 .method("GET")
9927 .uri(format!("/entries/{id}"))
9928 .header(header::COOKIE, cookie)
9929 .body(Body::empty())
9930 .unwrap(),
9931 )
9932 .await
9933 .unwrap();
9934 assert_eq!(resp.status(), StatusCode::OK);
9935 String::from_utf8(
9936 axum::body::to_bytes(resp.into_body(), usize::MAX)
9937 .await
9938 .unwrap()
9939 .to_vec(),
9940 )
9941 .unwrap()
9942 }
9943 };
9944
9945 let hostile = render(id_of("hostile-1")).await;
9946 assert!(
9949 hostile.contains("Hostile entry"),
9950 "the reader did not render the entry: {hostile}",
9951 );
9952 assert!(
9953 !hostile.to_ascii_lowercase().contains("javascript:"),
9954 "the hostile scheme reached the reader page: {hostile}",
9955 );
9956 assert!(
9960 !hostile.contains("actionbar-open"),
9961 "the action bar rendered an open-original link for a refused URL: {hostile}",
9962 );
9963 assert!(
9964 !hostile.contains("Original \u{2197}"),
9965 "the byline rendered an original link for a refused URL: {hostile}",
9966 );
9967
9968 let benign = render(id_of("benign-1")).await;
9971 assert!(
9972 benign.contains("Benign entry"),
9973 "the reader did not render the benign entry: {benign}",
9974 );
9975 assert_eq!(
9979 benign
9980 .matches(r#"href="https://href.example/post""#)
9981 .count(),
9982 2,
9983 "entry.html has two `href`s for the entry URL — the byline link and \
9984 the action-bar button — and this render produced a different \
9985 number: {benign}",
9986 );
9987 assert!(
9988 benign.contains("actionbar-open"),
9989 "a legitimate entry lost its open-original button: {benign}",
9990 );
9991 assert!(
9992 benign.contains("Original \u{2197}"),
9993 "a legitimate entry lost its byline link: {benign}",
9994 );
9995 }
9996
9997 #[tokio::test]
10010 async fn a_hostile_stored_body_renders_inert_on_the_reader_page() {
10011 let did = "did:plc:readerbody";
10012 let state = test_state(&[]).await;
10013 store::grant_access(&state.db, did, None, "test", None)
10014 .await
10015 .unwrap();
10016 let feed = store::upsert_feed(
10017 &state.db,
10018 &store::NewFeed {
10019 url: "https://body.example/feed.xml".to_string(),
10020 title: Some("Body".to_string()),
10021 ..Default::default()
10022 },
10023 )
10024 .await
10025 .unwrap();
10026 let hostile_body = concat!(
10028 "<p>kept <b>bold</b></p>",
10029 r#"<img src="x" onerror="alert(2)">"#,
10030 r#"<a href="javascript:alert(3)">click</a>"#,
10031 r#"<p onclick="alert(4)" style="color:red">tail</p>"#,
10032 "<script>alert(1)</script>",
10033 r#"<iframe src="https://evil.example/"></iframe>"#,
10034 );
10035 let clean_body = concat!(
10037 "<h2>Heading</h2>",
10038 r#"<p>Text with <a href="https://body.example/x" rel="noopener noreferrer">a link</a>, "#,
10039 "<em>emphasis</em> & an entity, <angle> brackets.</p>",
10040 "<pre><code>if a < b && c { }</code></pre>",
10041 r#"<ul><li>one</li><li>two</li></ul><img src="https://body.example/i.png" alt="i">"#,
10042 );
10043 store::insert_entries(
10044 &state.db,
10045 feed,
10046 &[
10047 store::NewEntry {
10048 guid: "hostile-body".to_string(),
10049 title: Some("Hostile body".to_string()),
10050 published: Some("2026-07-11T00:00:00Z".to_string()),
10051 content_html: Some(hostile_body.to_string()),
10052 ..Default::default()
10053 },
10054 store::NewEntry {
10055 guid: "clean-body".to_string(),
10056 title: Some("Clean body".to_string()),
10057 published: Some("2026-07-10T00:00:00Z".to_string()),
10058 content_html: Some(clean_body.to_string()),
10059 ..Default::default()
10060 },
10061 store::NewEntry {
10064 guid: "oversize-body".to_string(),
10065 title: Some("Oversize body".to_string()),
10066 published: Some("2026-07-09T00:00:00Z".to_string()),
10067 content_html: Some(format!(
10068 "<p>{}OVERSIZE</p>",
10069 "a".repeat(crate::sanitized_html::MAX_RENDER_HTML_BYTES)
10070 )),
10071 ..Default::default()
10072 },
10073 ],
10074 0,
10075 )
10076 .await
10077 .unwrap();
10078 store::replace_sub_refs(&state.db, did, &[feed])
10079 .await
10080 .unwrap();
10081 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
10082 let id_of = |guid: &str| {
10083 rows.iter()
10084 .find(|r| r.guid == guid)
10085 .unwrap_or_else(|| panic!("{guid} was not inserted"))
10086 .id
10087 };
10088
10089 let cookie = session_cookie(&state, did, None);
10090 let app = router(state.clone());
10091 let prose = |id: i64| {
10094 let app = app.clone();
10095 let cookie = cookie.clone();
10096 async move {
10097 let resp = app
10098 .oneshot(
10099 Request::builder()
10100 .method("GET")
10101 .uri(format!("/entries/{id}"))
10102 .header(header::COOKIE, cookie)
10103 .body(Body::empty())
10104 .unwrap(),
10105 )
10106 .await
10107 .unwrap();
10108 assert_eq!(resp.status(), StatusCode::OK);
10109 let page = String::from_utf8(
10110 axum::body::to_bytes(resp.into_body(), usize::MAX)
10111 .await
10112 .unwrap()
10113 .to_vec(),
10114 )
10115 .unwrap();
10116 let start = page
10117 .find(r#"<div class="prose">"#)
10118 .unwrap_or_else(|| panic!("no prose block: {page}"));
10119 let end = page[start..]
10120 .find("</article>")
10121 .map(|e| start + e)
10122 .unwrap_or_else(|| panic!("no </article>: {page}"));
10123 page[start..end].to_string()
10124 }
10125 };
10126
10127 let hostile = prose(id_of("hostile-body")).await;
10128 let lower = hostile.to_ascii_lowercase();
10129 for needle in [
10130 "<script",
10131 "alert(1)",
10132 "onerror",
10133 "javascript:",
10134 "<iframe",
10135 "onclick",
10136 ] {
10137 assert!(
10138 !lower.contains(needle),
10139 "`{needle}` from a stored body reached the reader page: {hostile}",
10140 );
10141 }
10142 assert!(
10144 hostile.contains("<p>kept <b>bold</b></p>"),
10145 "the benign markup did not render as markup: {hostile}",
10146 );
10147 assert!(
10148 hostile.contains(r#"<img src="x">"#)
10149 && hostile.contains(r#"<a rel="noopener noreferrer">click</a>"#),
10150 "the sanitizer's output did not reach the page: {hostile}",
10151 );
10152
10153 let clean = prose(id_of("clean-body")).await;
10154 assert!(
10155 clean.contains(clean_body),
10156 "an already-clean stored body did not render byte-identically: {clean}",
10157 );
10158 assert!(
10159 !clean.contains("body-too-large")
10160 && !clean.contains("body-unavailable")
10161 && !clean.contains("body-too-slow"),
10162 "a whole, ordinary body was shown as refused: {clean}",
10163 );
10164
10165 let over = prose(id_of("oversize-body")).await;
10168 assert!(
10169 !over.contains("OVERSIZE") && !over.contains(&"a".repeat(64)),
10170 "an over-size stored body was rendered: {}…",
10171 &over[..over.len().min(300)],
10172 );
10173 assert!(
10174 over.contains("body-too-large"),
10175 "an over-size body did not say so: {over}",
10176 );
10177 }
10178
10179 #[test]
10188 fn the_reader_template_renders_each_body_outcome() {
10189 let config = Config::default();
10190 let user = CurrentUser {
10191 did: "did:plc:bodyoutcomes".to_string(),
10192 handle: None,
10193 sid: None,
10194 };
10195 let page = |content_html: Option<BodyRender>| {
10196 EntryTemplate {
10197 card: Card::private(&config),
10198 version: VERSION,
10199 repo_url: REPO_URL,
10200 kofi_url: KOFI_URL,
10201 nav: build_nav(&user, "unread", String::new(), vec![], vec![], false),
10202 id: 1,
10203 title: "T".to_string(),
10204 feed_title: "F".to_string(),
10205 author: None,
10206 published: String::new(),
10207 url: SafeLink::external_opt("https://orig.example/a"),
10208 content_html,
10209 read: false,
10210 starred: false,
10211 back_qs: String::new(),
10212 prev_id: None,
10213 next_id: None,
10214 oob: false,
10215 }
10216 .render()
10217 .unwrap()
10218 };
10219
10220 let html = page(Some(BodyRender::Html(
10221 crate::sanitized_html::SanitizedHtml::clean("<p>body <b>here</b></p>"),
10222 )));
10223 assert!(html.contains("<p>body <b>here</b></p>"), "{html}");
10224 assert!(
10225 !html.contains("body-too-large")
10226 && !html.contains("body-unavailable")
10227 && !html.contains("body-too-slow")
10228 );
10229
10230 let too_large = page(Some(BodyRender::TooLarge));
10231 assert!(too_large.contains("body-too-large"), "{too_large}");
10232 assert!(too_large.contains("too large to display"), "{too_large}");
10233 assert!(!too_large.contains("body-unavailable"));
10234
10235 let unavailable = page(Some(BodyRender::Unavailable));
10236 assert!(unavailable.contains("body-unavailable"), "{unavailable}");
10237 assert!(
10238 unavailable.contains("temporarily unavailable"),
10239 "{unavailable}"
10240 );
10241 assert!(!unavailable.contains("body-too-large"));
10242
10243 let too_slow = page(Some(BodyRender::TooSlow));
10244 assert!(too_slow.contains("body-too-slow"), "{too_slow}");
10245 assert!(too_slow.contains("too complex to display"), "{too_slow}");
10246 assert!(
10247 !too_slow.contains("body-too-large") && !too_slow.contains("body-unavailable"),
10248 "{too_slow}"
10249 );
10250
10251 let none = page(None);
10252 assert!(none.contains("has no stored content"), "{none}");
10253 }
10254
10255 #[tokio::test]
10275 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
10276 let did_a = "did:plc:aaaa";
10277 let state = test_state(&[]).await;
10278 store::grant_access(&state.db, did_a, None, "test", None)
10279 .await
10280 .unwrap();
10281
10282 let feed_a = store::upsert_feed(
10283 &state.db,
10284 &store::NewFeed {
10285 url: "https://a.example/feed.xml".to_string(),
10286 title: Some("A".to_string()),
10287 ..Default::default()
10288 },
10289 )
10290 .await
10291 .unwrap();
10292 let _feed_b = store::upsert_feed(
10293 &state.db,
10294 &store::NewFeed {
10295 url: "https://b.example/feed.xml".to_string(),
10296 title: Some("B".to_string()),
10297 ..Default::default()
10298 },
10299 )
10300 .await
10301 .unwrap();
10302 store::replace_sub_refs(&state.db, did_a, &[feed_a])
10305 .await
10306 .unwrap();
10307
10308 assert!(
10313 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
10314 "this test is only meaningful on the outage path; the repo answered",
10315 );
10316
10317 let resolved = resolve_subscriptions(&state, did_a).await;
10318
10319 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
10320 assert_eq!(
10321 urls,
10322 vec!["https://a.example/feed.xml"],
10323 "the outage fallback must return the caller's OWN subscriptions only; \
10324 any other feed here is cross-tenant read access granted by an outage",
10325 );
10326 }
10327
10328 const MALFORMED_LISTING: &str = "<malformed>";
10335
10336 async fn spawn_listing_sidecar(
10337 listings: Vec<Vec<String>>,
10338 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10339 use tokio::io::{AsyncReadExt, AsyncWriteExt};
10340 let served = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10341 let counter = served.clone();
10342 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10343 let addr = listener.local_addr().unwrap();
10344 tokio::spawn(async move {
10345 loop {
10346 let Ok((mut sock, _)) = listener.accept().await else {
10347 break;
10348 };
10349 let mut req = Vec::new();
10351 let mut buf = [0u8; 4096];
10352 while let Ok(n) = sock.read(&mut buf).await {
10353 if n == 0 {
10354 break;
10355 }
10356 req.extend_from_slice(&buf[..n]);
10357 let text = String::from_utf8_lossy(&req).to_string();
10358 let Some(head_end) = text.find("\r\n\r\n") else {
10359 continue;
10360 };
10361 let len = text[..head_end]
10362 .lines()
10363 .find_map(|l| {
10364 let (k, v) = l.split_once(':')?;
10365 k.eq_ignore_ascii_case("content-length")
10366 .then(|| v.trim().parse::<usize>().ok())
10367 .flatten()
10368 })
10369 .unwrap_or(0);
10370 if req.len() >= head_end + 4 + len {
10371 break;
10372 }
10373 }
10374 let req = String::from_utf8_lossy(&req).to_string();
10375 let records = if req.contains("subscription") {
10376 let i = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10377 let urls = &listings[i.min(listings.len() - 1)];
10378 urls.iter()
10379 .enumerate()
10380 .map(|(j, url)| {
10381 if url == MALFORMED_LISTING {
10384 return serde_json::json!({ "uri": 7 });
10385 }
10386 serde_json::json!({
10387 "uri": format!("at://did:plc:x/{}/3lab{j}", lexicon::nsid::SUBSCRIPTION),
10388 "cid": "bafy",
10389 "value": {
10390 "$type": lexicon::nsid::SUBSCRIPTION,
10391 "url": url,
10392 "createdAt": "2026-01-01T00:00:00Z"
10393 }
10394 })
10395 })
10396 .collect::<Vec<_>>()
10397 } else {
10398 Vec::new()
10399 };
10400 let body =
10401 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
10402 let resp = format!(
10403 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10404 body.len(),
10405 body
10406 );
10407 let _ = sock.write_all(resp.as_bytes()).await;
10408 let _ = sock.flush().await;
10409 }
10410 });
10411 (format!("http://{addr}"), served)
10412 }
10413
10414 fn shrink_feed_urls(n: usize) -> Vec<String> {
10415 (0..n)
10416 .map(|i| format!("https://shrink{i}.example/feed.xml"))
10417 .collect()
10418 }
10419
10420 async fn shrink_state(
10424 did: &str,
10425 subscribed: usize,
10426 listings: Vec<Vec<String>>,
10427 ) -> (
10428 AppState,
10429 std::sync::Arc<std::sync::atomic::AtomicUsize>,
10430 Vec<i64>,
10431 ) {
10432 let (url, served) = spawn_listing_sidecar(listings).await;
10433 let state = test_state_with_sidecar(&[did], &url).await;
10434 let mut ids = Vec::new();
10435 for u in shrink_feed_urls(5) {
10436 ids.push(
10437 store::upsert_feed(
10438 &state.db,
10439 &store::NewFeed {
10440 url: u,
10441 ..Default::default()
10442 },
10443 )
10444 .await
10445 .unwrap(),
10446 );
10447 }
10448 store::replace_sub_refs(&state.db, did, &ids[..subscribed])
10449 .await
10450 .unwrap();
10451 (state, served, ids)
10452 }
10453
10454 async fn sub_ref_sorted(state: &AppState, did: &str) -> Vec<i64> {
10455 let mut ids = store::subscribed_feed_ids(&state.db, did).await.unwrap();
10456 ids.sort();
10457 ids
10458 }
10459
10460 #[tokio::test]
10466 async fn a_big_shrink_that_does_not_read_the_same_twice_is_not_applied() {
10467 let did = "did:plc:shrinker";
10468 let all = shrink_feed_urls(5);
10469 let (state, served, ids) = shrink_state(did, 5, vec![all[..2].to_vec(), all.clone()]).await;
10473
10474 let (subs, alert) = resolve_subscriptions_noting(&state, did).await;
10475
10476 assert_eq!(
10477 sub_ref_sorted(&state, did).await,
10478 ids,
10479 "a mass drop that did not read the same twice was applied to sub_ref"
10480 );
10481 let alert = alert.expect("a refused shrink must say why the list is stale");
10482 assert!(
10483 alert.contains("did not read the same twice"),
10484 "wrong alert: {alert}"
10485 );
10486 assert_eq!(subs.len(), 5, "the last-known list was not the one shown");
10487 assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2);
10488 }
10489
10490 #[tokio::test]
10496 async fn a_big_shrink_whose_second_read_fails_names_the_failure() {
10497 let did = "did:plc:shrinker";
10498 let all = shrink_feed_urls(5);
10499 let (state, served, ids) = shrink_state(
10500 did,
10501 5,
10502 vec![all[..1].to_vec(), vec![MALFORMED_LISTING.to_string()]],
10503 )
10504 .await;
10505
10506 let (subs, alert) = resolve_subscriptions_noting(&state, did).await;
10507
10508 assert_eq!(sub_ref_sorted(&state, did).await, ids);
10509 let alert = alert.expect("a refused shrink must say why the list is stale");
10510 assert!(
10511 alert.contains("1 record(s) in your subscription list could not be read"),
10512 "the failed second read was reported as a disagreement: {alert}"
10513 );
10514 assert_eq!(subs.len(), 5);
10515 assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2);
10516 }
10517
10518 #[tokio::test]
10522 async fn a_big_shrink_that_reads_the_same_twice_is_applied() {
10523 let did = "did:plc:shrinker";
10524 let all = shrink_feed_urls(5);
10525 let (state, served, ids) =
10526 shrink_state(did, 5, vec![all[..1].to_vec(), all[..1].to_vec()]).await;
10527
10528 let (_, alert) = resolve_subscriptions_noting(&state, did).await;
10529
10530 assert_eq!(alert, None);
10531 assert_eq!(
10532 sub_ref_sorted(&state, did).await,
10533 vec![ids[0]],
10534 "a corroborated mass drop was not applied"
10535 );
10536 assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2);
10537 }
10538
10539 #[tokio::test]
10542 async fn a_small_shrink_is_applied_on_one_read() {
10543 let did = "did:plc:shrinker";
10544 let all = shrink_feed_urls(5);
10545 let (state, served, ids) = shrink_state(did, 5, vec![all[..3].to_vec()]).await;
10546
10547 let (_, alert) = resolve_subscriptions_noting(&state, did).await;
10548
10549 assert_eq!(alert, None);
10550 assert_eq!(sub_ref_sorted(&state, did).await, ids[..3].to_vec());
10551 assert_eq!(
10552 served.load(std::sync::atomic::Ordering::SeqCst),
10553 1,
10554 "a shrink below the threshold paid for a second listing"
10555 );
10556 }
10557
10558 #[tokio::test]
10560 async fn the_first_resolve_is_never_corroborated() {
10561 let did = "did:plc:shrinker";
10562 let all = shrink_feed_urls(5);
10563 let (state, served, ids) = shrink_state(did, 0, vec![all.clone()]).await;
10564
10565 let (_, alert) = resolve_subscriptions_noting(&state, did).await;
10566
10567 assert_eq!(alert, None);
10568 assert_eq!(sub_ref_sorted(&state, did).await, ids);
10569 assert_eq!(
10570 served.load(std::sync::atomic::Ordering::SeqCst),
10571 1,
10572 "a first-ever resolve paid for a second listing"
10573 );
10574 }
10575
10576 async fn test_state_with_caps(
10579 did: &str,
10580 max_subs_per_did: i64,
10581 max_feeds_global: i64,
10582 ) -> AppState {
10583 let db = store::init_url("sqlite::memory:").await.unwrap();
10584 let config = Config {
10585 cookie_secret: "test-cookie-secret-000".to_string(),
10586 beta_cap: 100,
10587 max_subs_per_did,
10588 max_feeds_global,
10589 ..Config::default()
10590 };
10591 store::grant_access(&db, did, None, "test", None)
10592 .await
10593 .unwrap();
10594 AppState::new(config, db).unwrap()
10595 }
10596
10597 fn opml_with_feeds(n: usize) -> String {
10599 let mut outlines = String::new();
10600 for i in 0..n {
10601 outlines.push_str(&format!(
10602 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
10603 ));
10604 }
10605 format!(
10606 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
10607 )
10608 }
10609
10610 #[tokio::test]
10615 async fn opml_import_enforces_global_feeds_ceiling() {
10616 let did = "did:plc:importer";
10617 let state = test_state_with_caps(did, 0, 3).await;
10619 let cookie = session_cookie(&state, did, None);
10620 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10621 let app = router(state.clone());
10622
10623 let resp = app
10624 .oneshot(
10625 Request::builder()
10626 .method("POST")
10627 .uri("/opml")
10628 .header(header::COOKIE, cookie)
10629 .header("content-type", ct)
10630 .body(Body::from(body))
10631 .unwrap(),
10632 )
10633 .await
10634 .unwrap();
10635 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10636
10637 let feeds = store::count_feeds(&state.db).await.unwrap();
10638 assert!(
10639 feeds <= 3,
10640 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
10641 );
10642 }
10643
10644 async fn import_against_strict_pds(
10647 did: &str,
10648 n: usize,
10649 fail_call: Option<usize>,
10650 ) -> (String, crate::atproto::tests::ApplyWritesLog) {
10651 let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
10652 let state = test_state_with_sidecar(&[did], &sidecar).await;
10653 let cookie = session_cookie(&state, did, None);
10654 let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
10655 let resp = router(state)
10656 .oneshot(
10657 Request::builder()
10658 .method("POST")
10659 .uri("/opml")
10660 .header(header::COOKIE, cookie)
10661 .header("content-type", ct)
10662 .body(Body::from(body))
10663 .unwrap(),
10664 )
10665 .await
10666 .unwrap();
10667 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10668 let loc = resp.headers()[header::LOCATION].to_str().unwrap();
10669 let flash = url::Url::parse(&format!("http://x{loc}"))
10670 .unwrap()
10671 .query_pairs()
10672 .find(|(k, _)| k == "flash")
10673 .map(|(_, v)| v.into_owned())
10674 .unwrap_or_default();
10675 (flash, log)
10676 }
10677
10678 #[tokio::test]
10682 async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
10683 let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
10684 assert_eq!(flash, "Imported 450 feeds", "{flash}");
10685 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
10686 }
10687
10688 #[tokio::test]
10692 async fn opml_import_that_part_lands_reports_what_landed() {
10693 let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
10694 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
10695 assert!(
10696 flash.contains("200 of 450"),
10697 "the landed count is not reported: {flash}"
10698 );
10699 assert!(
10700 !flash.contains("nothing was imported"),
10701 "200 feeds landed and the reader was told none did: {flash}"
10702 );
10703 }
10704
10705 #[tokio::test]
10708 async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
10709 let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
10710 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
10711 assert!(flash.contains("nothing was imported"), "{flash}");
10712 }
10713
10714 #[tokio::test]
10723 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
10724 let did = "did:plc:typoist";
10725 let state = test_state_with_caps(did, 0, 0).await;
10726 let cookie = session_cookie(&state, did, None);
10727 for input in [
10728 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
10729 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10730 ] {
10731 let resp = router(state.clone())
10732 .oneshot(
10733 Request::builder()
10734 .method("POST")
10735 .uri("/subscriptions")
10736 .header(header::COOKIE, cookie.clone())
10737 .header("content-type", "application/x-www-form-urlencoded")
10738 .body(Body::from(format!("url={input}")))
10739 .unwrap(),
10740 )
10741 .await
10742 .unwrap();
10743 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10744 let loc = resp
10745 .headers()
10746 .get(header::LOCATION)
10747 .unwrap()
10748 .to_str()
10749 .unwrap();
10750 assert!(
10751 loc.contains("kind%20of%20feed"),
10752 "expected the unsupported-feed flash for {input}, got {loc}"
10753 );
10754 assert!(
10755 !loc.contains("Private"),
10756 "a storability refusal was reported as a privacy one for {input}: {loc}"
10757 );
10758 }
10759 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10760 }
10761
10762 #[tokio::test]
10769 async fn opml_import_reports_entries_this_instance_cannot_store() {
10770 let did = "did:plc:renamer4";
10771 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10772 let state = test_state_with_sidecar(&[did], &sidecar).await;
10773 assert!(!state.config.standard_site);
10774 let opml = format!(
10775 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10776 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10777 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10778 </body></opml>"
10779 );
10780 let (ct, body) = opml_multipart(opml.as_bytes());
10781 let cookie = session_cookie(&state, did, None);
10782 let resp = router(state.clone())
10783 .oneshot(
10784 Request::builder()
10785 .method("POST")
10786 .uri("/opml")
10787 .header(header::COOKIE, cookie)
10788 .header("content-type", ct)
10789 .body(Body::from(body))
10790 .unwrap(),
10791 )
10792 .await
10793 .unwrap();
10794 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10795 let loc = resp
10796 .headers()
10797 .get(header::LOCATION)
10798 .unwrap()
10799 .to_str()
10800 .unwrap();
10801 assert!(
10802 loc.contains("Imported%201%20feed"),
10803 "unexpected flash: {loc}"
10804 );
10805 assert!(
10806 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
10807 "the dropped entry was not reported: {loc}"
10808 );
10809 assert!(
10811 !loc.contains("site.standard.publication"),
10812 "the URI was echoed: {loc}"
10813 );
10814 }
10815
10816 #[tokio::test]
10819 async fn opml_import_enforces_per_did_cap() {
10820 let did = "did:plc:capped";
10821 let state = test_state_with_caps(did, 2, 0).await;
10823 let existing_a = store::upsert_feed(
10824 &state.db,
10825 &store::NewFeed {
10826 url: "https://have-a.example/feed.xml".to_string(),
10827 ..Default::default()
10828 },
10829 )
10830 .await
10831 .unwrap();
10832 let existing_b = store::upsert_feed(
10833 &state.db,
10834 &store::NewFeed {
10835 url: "https://have-b.example/feed.xml".to_string(),
10836 ..Default::default()
10837 },
10838 )
10839 .await
10840 .unwrap();
10841 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
10842 .await
10843 .unwrap();
10844 let before = store::count_feeds(&state.db).await.unwrap();
10845
10846 let cookie = session_cookie(&state, did, None);
10847 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10848 let app = router(state.clone());
10849 let resp = app
10850 .oneshot(
10851 Request::builder()
10852 .method("POST")
10853 .uri("/opml")
10854 .header(header::COOKIE, cookie)
10855 .header("content-type", ct)
10856 .body(Body::from(body))
10857 .unwrap(),
10858 )
10859 .await
10860 .unwrap();
10861 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10862 let after = store::count_feeds(&state.db).await.unwrap();
10864 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
10865 }
10866
10867 #[tokio::test]
10870 async fn single_add_enforces_per_did_cap() {
10871 let did = "did:plc:subcapped";
10872 let state = test_state_with_caps(did, 1, 0).await;
10873 let f = store::upsert_feed(
10874 &state.db,
10875 &store::NewFeed {
10876 url: "https://have.example/feed.xml".to_string(),
10877 ..Default::default()
10878 },
10879 )
10880 .await
10881 .unwrap();
10882 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
10883 let cookie = session_cookie(&state, did, None);
10884 let app = router(state.clone());
10885 let resp = app
10886 .oneshot(
10887 Request::builder()
10888 .method("POST")
10889 .uri("/subscriptions")
10890 .header(header::COOKIE, cookie)
10891 .header("content-type", "application/x-www-form-urlencoded")
10892 .body(Body::from("url=https://another.example/feed.xml"))
10893 .unwrap(),
10894 )
10895 .await
10896 .unwrap();
10897 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10898 let loc = resp
10899 .headers()
10900 .get(header::LOCATION)
10901 .unwrap()
10902 .to_str()
10903 .unwrap();
10904 assert!(
10905 loc.contains("Subscription%20limit%20reached"),
10906 "expected sub-limit flash, got {loc}"
10907 );
10908 }
10909
10910 #[tokio::test]
10919 async fn the_reader_index_pages_instead_of_rendering_everything() {
10920 let did = "did:plc:pager";
10921 let state = test_state(&[]).await;
10922 store::grant_access(&state.db, did, None, "test", None)
10923 .await
10924 .unwrap();
10925 let feed = store::upsert_feed(
10926 &state.db,
10927 &store::NewFeed {
10928 url: "https://pager.example/feed.xml".to_string(),
10929 title: Some("Pager".to_string()),
10930 ..Default::default()
10931 },
10932 )
10933 .await
10934 .unwrap();
10935 let total = 250_usize;
10936 let entries: Vec<store::NewEntry> = (0..total)
10937 .map(|i| store::NewEntry {
10938 guid: format!("p-{i:04}"),
10939 url: Some(format!("https://pager.example/{i}")),
10940 title: Some(format!("Article {i:04}")),
10941 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
10942 content_html: Some("x".repeat(4_000)),
10943 ..Default::default()
10944 })
10945 .collect();
10946 store::insert_entries(&state.db, feed, &entries, 0)
10947 .await
10948 .unwrap();
10949 store::replace_sub_refs(&state.db, did, &[feed])
10950 .await
10951 .unwrap();
10952
10953 let cookie = session_cookie(&state, did, None);
10954 let app = router(state.clone());
10955 let get = |uri: &str| {
10956 let app = app.clone();
10957 let cookie = cookie.clone();
10958 let uri = uri.to_string();
10959 async move {
10960 let resp = app
10961 .oneshot(
10962 Request::builder()
10963 .uri(uri)
10964 .header(header::COOKIE, cookie)
10965 .body(Body::empty())
10966 .unwrap(),
10967 )
10968 .await
10969 .unwrap();
10970 assert_eq!(resp.status(), StatusCode::OK);
10971 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
10972 .await
10973 .unwrap();
10974 String::from_utf8(bytes.to_vec()).unwrap()
10975 }
10976 };
10977
10978 let page1 = get("/").await;
10979 let rows1 = page1.matches("<li class=\"entry").count();
10983 assert!(
10984 rows1 <= ENTRIES_PER_PAGE as usize,
10985 "page 1 rendered {rows1} entry links; the list is unbounded"
10986 );
10987 assert!(
10988 rows1 > 0,
10989 "page 1 rendered nothing at all: the page bound swallowed the list"
10990 );
10991 assert!(
10994 page1.contains("250 entries"),
10995 "heading must report the full total, not the page"
10996 );
10997 assert!(
10998 page1.contains("page=2"),
10999 "no way to reach the rest of the list: {}",
11000 &page1[..page1.len().min(400)]
11001 );
11002 assert!(
11004 !page1.contains(&"x".repeat(4_000)),
11005 "the list response carried an article body"
11006 );
11007
11008 let page2 = get("/?page=2").await;
11009 assert!(
11010 page2.matches("<li class=\"entry").count() > 0,
11011 "page 2 rendered no rows at all"
11012 );
11013 assert!(
11014 page2.contains("page=1") || page2.contains("Newer"),
11015 "page 2 offers no way back"
11016 );
11017 let first_title = (0..total)
11019 .map(|i| format!("Article {i:04}"))
11020 .find(|t| page1.contains(t))
11021 .expect("page 1 shows at least one titled article");
11022 assert!(
11023 !page2.contains(&first_title),
11024 "{first_title} appears on both pages"
11025 );
11026
11027 let past_end = get("/?page=999").await;
11033 assert!(
11034 past_end.matches("<li class=\"entry").count() > 0,
11035 "an out-of-range page rendered nothing and offered no way back"
11036 );
11037 assert!(
11038 past_end.contains("page=2"),
11039 "the clamped page offers no pager"
11040 );
11041 }
11042
11043 #[tokio::test]
11050 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
11051 let did = "did:plc:reader";
11052 let state = test_state(&[]).await;
11053 store::grant_access(&state.db, did, None, "test", None)
11054 .await
11055 .unwrap();
11056 let feed = store::upsert_feed(
11057 &state.db,
11058 &store::NewFeed {
11059 url: "https://reader.example/feed.xml".to_string(),
11060 title: Some("Reader".to_string()),
11061 ..Default::default()
11062 },
11063 )
11064 .await
11065 .unwrap();
11066 store::insert_entries(
11067 &state.db,
11068 feed,
11069 &[store::NewEntry {
11070 guid: "r-1".to_string(),
11071 url: Some("https://reader.example/1".to_string()),
11072 title: Some("Article".to_string()),
11073 published: Some("2026-07-11T00:00:00Z".to_string()),
11074 content_html: Some("<p>body</p>".to_string()),
11075 ..Default::default()
11076 }],
11077 0,
11078 )
11079 .await
11080 .unwrap();
11081 store::replace_sub_refs(&state.db, did, &[feed])
11082 .await
11083 .unwrap();
11084 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
11085
11086 let cookie = session_cookie(&state, did, None);
11087 let app = router(state.clone());
11088
11089 let resp = app
11091 .clone()
11092 .oneshot(
11093 Request::builder()
11094 .method("POST")
11095 .uri(format!("/entries/{entry_id}/read"))
11096 .header(header::COOKIE, cookie.clone())
11097 .header("HX-Request", "true")
11098 .header("X-FR-Reader", "1")
11099 .header("content-type", "application/x-www-form-urlencoded")
11100 .body(Body::from("read=true"))
11101 .unwrap(),
11102 )
11103 .await
11104 .unwrap();
11105 assert_eq!(resp.status(), StatusCode::OK);
11106 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
11107 .await
11108 .unwrap();
11109 let html = String::from_utf8(bytes.to_vec()).unwrap();
11110 assert!(
11111 html.contains("hx-swap-oob=\"outerHTML\""),
11112 "reader response must be an OOB swap: {html}"
11113 );
11114 assert!(
11115 html.contains(r#"id="entry-actionbar""#),
11116 "reader response must be the action-bar fragment: {html}"
11117 );
11118 assert!(
11121 html.contains(r#"aria-pressed="true""#),
11122 "read button must show pressed after marking read: {html}"
11123 );
11124 assert!(
11125 html.contains(r#"name="read" value="false""#),
11126 "hidden read value must flip to false so a second tap reverses: {html}"
11127 );
11128
11129 let resp2 = app
11132 .oneshot(
11133 Request::builder()
11134 .method("POST")
11135 .uri(format!("/entries/{entry_id}/read"))
11136 .header(header::COOKIE, cookie)
11137 .header("HX-Request", "true")
11138 .header("X-FR-Reader", "1")
11139 .header("content-type", "application/x-www-form-urlencoded")
11140 .body(Body::from("read=false"))
11141 .unwrap(),
11142 )
11143 .await
11144 .unwrap();
11145 assert_eq!(resp2.status(), StatusCode::OK);
11146 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
11147 .await
11148 .unwrap();
11149 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
11150 assert!(
11151 html2.contains(r#"aria-pressed="false""#),
11152 "read button must show un-pressed after reversing: {html2}"
11153 );
11154 assert!(
11155 html2.contains(r#"name="read" value="true""#),
11156 "hidden read value must flip back to true: {html2}"
11157 );
11158 }
11159
11160 #[tokio::test]
11163 async fn list_mark_read_returns_row_not_oob_actionbar() {
11164 let did = "did:plc:listv";
11165 let state = test_state(&[]).await;
11166 store::grant_access(&state.db, did, None, "test", None)
11167 .await
11168 .unwrap();
11169 let feed = store::upsert_feed(
11170 &state.db,
11171 &store::NewFeed {
11172 url: "https://list.example/feed.xml".to_string(),
11173 title: Some("List".to_string()),
11174 ..Default::default()
11175 },
11176 )
11177 .await
11178 .unwrap();
11179 store::insert_entries(
11180 &state.db,
11181 feed,
11182 &[store::NewEntry {
11183 guid: "l-1".to_string(),
11184 url: Some("https://list.example/1".to_string()),
11185 title: Some("Article".to_string()),
11186 published: Some("2026-07-11T00:00:00Z".to_string()),
11187 ..Default::default()
11188 }],
11189 0,
11190 )
11191 .await
11192 .unwrap();
11193 store::replace_sub_refs(&state.db, did, &[feed])
11194 .await
11195 .unwrap();
11196 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
11197
11198 let cookie = session_cookie(&state, did, None);
11199 let app = router(state.clone());
11200
11201 let resp = app
11202 .oneshot(
11203 Request::builder()
11204 .method("POST")
11205 .uri(format!("/entries/{entry_id}/read"))
11206 .header(header::COOKIE, cookie)
11207 .header("HX-Request", "true")
11208 .header("content-type", "application/x-www-form-urlencoded")
11209 .body(Body::from("read=true"))
11210 .unwrap(),
11211 )
11212 .await
11213 .unwrap();
11214 assert_eq!(resp.status(), StatusCode::OK);
11215 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
11216 .await
11217 .unwrap();
11218 let html = String::from_utf8(bytes.to_vec()).unwrap();
11219 assert!(
11220 !html.contains("hx-swap-oob"),
11221 "list-view response must NOT be an OOB swap: {html}"
11222 );
11223 assert!(
11228 html.contains(&format!("/entries/{entry_id}")),
11229 "the response is not the row for this entry: {html}",
11230 );
11231 assert!(
11232 html.contains("Article"),
11233 "the row rendered without its title: {html}",
11234 );
11235 assert!(
11252 html.contains("is-read"),
11253 "the row came back without the read state it was just given: {html}",
11254 );
11255 }
11256
11257 #[tokio::test]
11282 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
11283 let did = "did:plc:autodiscovered";
11284 let state = test_state_with_caps(did, 0, 0).await;
11287
11288 let page = r#"<!doctype html><html><head><title>Blog</title>
11289 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
11290 </head><body>hi</body></html>"#;
11291 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
11292 let port: u16 = base
11293 .trim_end_matches('/')
11294 .rsplit(':')
11295 .next()
11296 .unwrap()
11297 .parse()
11298 .unwrap();
11299 crate::net::test_host_override(
11300 "autodiscover-ftp.test",
11301 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
11302 );
11303
11304 let cookie = session_cookie(&state, did, None);
11305 let resp = router(state.clone())
11306 .oneshot(
11307 Request::builder()
11308 .method("POST")
11309 .uri("/subscriptions")
11310 .header(header::COOKIE, cookie)
11311 .header("content-type", "application/x-www-form-urlencoded")
11312 .body(Body::from(format!(
11313 "url=http://autodiscover-ftp.test:{port}/"
11314 )))
11315 .unwrap(),
11316 )
11317 .await
11318 .unwrap();
11319 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11320 let loc = resp
11321 .headers()
11322 .get(header::LOCATION)
11323 .unwrap()
11324 .to_str()
11325 .unwrap();
11326 assert_ne!(loc, "/login", "the test never reached the add path");
11327 assert_ne!(loc, "/", "the subscribe succeeded");
11328
11329 assert_eq!(
11330 store::count_feeds(&state.db).await.unwrap(),
11331 0,
11332 "a non-http(s) URL from autodiscovery was stored"
11333 );
11334 assert_eq!(
11335 store::count_subscriptions_for_did(&state.db, did)
11336 .await
11337 .unwrap(),
11338 0
11339 );
11340 }
11341
11342 #[tokio::test]
11347 async fn rename_to_new_url_refused_at_global_feeds_cap() {
11348 let did = "did:plc:renamer4";
11349 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11350 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11352 store::upsert_feed(
11353 &state.db,
11354 &store::NewFeed {
11355 url: "https://existing.example/feed.xml".to_string(),
11356 ..Default::default()
11357 },
11358 )
11359 .await
11360 .unwrap();
11361 let before = store::count_feeds(&state.db).await.unwrap();
11362 assert_eq!(before, 1);
11363
11364 let cookie = session_cookie(&state, did, None);
11365 let resp = router(state.clone())
11366 .oneshot(
11367 Request::builder()
11368 .method("POST")
11369 .uri("/subscriptions/rk-keep/rename")
11370 .header(header::COOKIE, cookie)
11371 .header("content-type", "application/x-www-form-urlencoded")
11372 .body(Body::from(
11374 "url=https://brand-new.example/feed.xml&title=Renamed",
11375 ))
11376 .unwrap(),
11377 )
11378 .await
11379 .unwrap();
11380 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11381 let loc = resp
11382 .headers()
11383 .get(header::LOCATION)
11384 .unwrap()
11385 .to_str()
11386 .unwrap();
11387 assert!(
11388 loc.contains("feed%20capacity"),
11389 "expected the feed-capacity flash, got {loc}"
11390 );
11391 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11393 assert!(
11394 puts.lock().unwrap().is_empty(),
11395 "a refused repoint reached the PDS"
11396 );
11397 }
11398
11399 #[tokio::test]
11406 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
11407 let did = "did:plc:renamer4";
11408 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11409 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11410 store::upsert_feed(
11411 &state.db,
11412 &store::NewFeed {
11413 url: "https://existing.example/feed.xml".to_string(),
11414 ..Default::default()
11415 },
11416 )
11417 .await
11418 .unwrap();
11419 let before = store::count_feeds(&state.db).await.unwrap();
11420
11421 let cookie = session_cookie(&state, did, None);
11422 let resp = router(state.clone())
11423 .oneshot(
11424 Request::builder()
11425 .method("POST")
11426 .uri("/subscriptions/rk-keep/rename")
11427 .header(header::COOKIE, cookie)
11428 .header("content-type", "application/x-www-form-urlencoded")
11429 .body(Body::from(
11430 "url=https://existing.example/feed.xml&title=Retitled",
11431 ))
11432 .unwrap(),
11433 )
11434 .await
11435 .unwrap();
11436 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11437 let loc = resp
11438 .headers()
11439 .get(header::LOCATION)
11440 .unwrap()
11441 .to_str()
11442 .unwrap();
11443 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
11444 assert_eq!(
11445 puts.lock().unwrap().len(),
11446 1,
11447 "the repoint did not reach the PDS"
11448 );
11449 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11450 }
11451
11452 #[tokio::test]
11454 async fn rename_with_blank_url_writes_nothing() {
11455 let did = "did:plc:renamer3";
11456 let state = test_state_with_caps(did, 0, 0).await;
11457 let before = store::count_feeds(&state.db).await.unwrap();
11458 assert_eq!(before, 0);
11459
11460 let cookie = session_cookie(&state, did, None);
11461 let app = router(state.clone());
11462 let resp = app
11463 .oneshot(
11464 Request::builder()
11465 .method("POST")
11466 .uri("/subscriptions/rkey123/rename")
11467 .header(header::COOKIE, cookie)
11468 .header("content-type", "application/x-www-form-urlencoded")
11469 .body(Body::from("url=%20%20&title=Nope"))
11471 .unwrap(),
11472 )
11473 .await
11474 .unwrap();
11475 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11476 assert_eq!(
11477 resp.headers()
11478 .get(header::LOCATION)
11479 .unwrap()
11480 .to_str()
11481 .unwrap(),
11482 "/",
11483 );
11484 assert_eq!(
11486 store::count_feeds(&state.db).await.unwrap(),
11487 0,
11488 "blank-URL rename wrote a junk feeds row"
11489 );
11490 }
11491
11492 async fn spawn_rename_sidecar(
11501 existing: serde_json::Value,
11502 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
11503 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11504 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11505 let addr = listener.local_addr().unwrap();
11506 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
11507 let sink = puts.clone();
11508 tokio::spawn(async move {
11509 loop {
11510 let Ok((mut sock, _)) = listener.accept().await else {
11511 break;
11512 };
11513 let mut raw: Vec<u8> = Vec::new();
11514 let mut chunk = [0u8; 4096];
11515 let body_text = loop {
11516 let Ok(n) = sock.read(&mut chunk).await else {
11517 break String::new();
11518 };
11519 if n == 0 {
11520 break String::from_utf8_lossy(&raw).to_string();
11521 }
11522 raw.extend_from_slice(&chunk[..n]);
11523 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
11524 continue;
11525 };
11526 let (head, body) = raw.split_at(split + 4);
11527 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
11528 let (k, v) = l.split_once(':')?;
11529 k.eq_ignore_ascii_case("content-length")
11530 .then(|| v.trim().parse::<usize>().ok())?
11531 });
11532 if want.is_none_or(|want| body.len() >= want) {
11533 break String::from_utf8_lossy(body).to_string();
11534 }
11535 };
11536
11537 let is_put = body_text.contains("\"action\":\"put\"");
11539 let data = if is_put {
11540 sink.lock().unwrap().push(body_text.clone());
11541 serde_json::json!({
11542 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
11543 "cid": "bafyreiafter"
11544 })
11545 } else {
11546 serde_json::json!({ "records": [existing.clone()] })
11547 };
11548 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
11549 let resp = format!(
11550 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11551 body.len(),
11552 body
11553 );
11554 let _ = sock.write_all(resp.as_bytes()).await;
11555 let _ = sock.flush().await;
11556 }
11557 });
11558 (format!("http://{addr}"), puts)
11559 }
11560
11561 fn seeded_subscription() -> serde_json::Value {
11563 serde_json::json!({
11564 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
11565 "cid": "bafyreibefore",
11566 "value": {
11567 "$type": "community.lexicon.rss.subscription",
11568 "url": "https://example.com/feed.xml",
11569 "title": "Old title",
11570 "siteUrl": "https://example.com/blog",
11571 "fetchHint": "hourly",
11572 "private": false,
11573 "createdAt": "2024-03-01T00:00:00.000Z"
11574 }
11575 })
11576 }
11577
11578 fn seeded_at_uri_subscription() -> serde_json::Value {
11581 seeded_subscription_with_url(AT_URI_SUB)
11582 }
11583 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
11585 serde_json::json!({
11586 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
11587 "cid": "bafyreibefore",
11588 "value": {
11589 "$type": "community.lexicon.rss.subscription",
11590 "url": url,
11591 "title": "Old title",
11592 "private": false,
11593 "createdAt": "2024-03-01T00:00:00.000Z"
11594 }
11595 })
11596 }
11597 const AT_URI_SUB: &str =
11598 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
11599 const AT_URI_SUB_ENC: &str =
11600 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
11601
11602 #[tokio::test]
11611 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
11612 let did = "did:plc:renamer5";
11613 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11614 let state = test_state_with_sidecar(&[did], &sidecar).await;
11615 assert!(
11616 !state.config.standard_site,
11617 "the flag must be off for this test"
11618 );
11619 let cookie = session_cookie(&state, did, None);
11620 let resp = router(state.clone())
11621 .oneshot(
11622 Request::builder()
11623 .method("POST")
11624 .uri("/subscriptions/rk-keep/rename")
11625 .header(header::COOKIE, cookie)
11626 .header("content-type", "application/x-www-form-urlencoded")
11627 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
11628 .unwrap(),
11629 )
11630 .await
11631 .unwrap();
11632 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11633 let loc = resp
11634 .headers()
11635 .get(header::LOCATION)
11636 .unwrap()
11637 .to_str()
11638 .unwrap();
11639 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11640
11641 let bodies = puts.lock().unwrap().clone();
11642 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11643 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11644 assert_eq!(
11645 sent["record"]["title"], "New title",
11646 "the rename did not apply"
11647 );
11648 assert_eq!(
11649 sent["record"]["url"], AT_URI_SUB,
11650 "the rename changed the URL"
11651 );
11652
11653 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11655 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
11656 }
11657
11658 #[tokio::test]
11662 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
11663 let did = "did:plc:renamer4";
11664 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11665 let state = test_state_with_sidecar(&[did], &sidecar).await;
11666 let cookie = session_cookie(&state, did, None);
11667 let resp = router(state.clone())
11668 .oneshot(
11669 Request::builder()
11670 .method("POST")
11671 .uri("/subscriptions/rk-keep/rename")
11672 .header(header::COOKIE, cookie)
11673 .header("content-type", "application/x-www-form-urlencoded")
11674 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11675 .unwrap(),
11676 )
11677 .await
11678 .unwrap();
11679 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11680 let loc = resp
11681 .headers()
11682 .get(header::LOCATION)
11683 .unwrap()
11684 .to_str()
11685 .unwrap();
11686 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
11687 assert!(
11688 !loc.contains("Private"),
11689 "a storability refusal was reported as a privacy one: {loc}"
11690 );
11691 assert!(
11692 puts.lock().unwrap().is_empty(),
11693 "the repoint reached the PDS"
11694 );
11695 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11696 assert_eq!(cached, 0);
11697 }
11698
11699 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
11702 let cookie = session_cookie(state, did, None);
11703 let resp = router(state.clone())
11704 .oneshot(
11705 Request::builder()
11706 .method("POST")
11707 .uri("/subscriptions/rk-keep/rename")
11708 .header(header::COOKIE, cookie)
11709 .header("content-type", "application/x-www-form-urlencoded")
11710 .body(Body::from(format!("url={url_enc}&title=New+title")))
11711 .unwrap(),
11712 )
11713 .await
11714 .unwrap();
11715 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11716 resp.headers()
11717 .get(header::LOCATION)
11718 .unwrap()
11719 .to_str()
11720 .unwrap()
11721 .to_string()
11722 }
11723
11724 #[tokio::test]
11734 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
11735 let did = "did:plc:renamer5";
11736 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
11737 let other_enc =
11738 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
11739 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
11740 let state = test_state_with_sidecar(&[did], &sidecar).await;
11741 let loc = retitle_unchanged(&state, did, other_enc).await;
11742 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11743 let bodies = puts.lock().unwrap().clone();
11744 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11745 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11746 assert_eq!(sent["record"]["title"], "New title");
11747 assert_eq!(sent["record"]["url"], other);
11748 }
11749
11750 #[tokio::test]
11754 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
11755 let did = "did:plc:renamer4";
11756 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11757 let state = test_state_with_sidecar(&[did], &sidecar).await;
11758 let cookie = session_cookie(&state, did, None);
11759 let resp = router(state.clone())
11760 .oneshot(
11761 Request::builder()
11762 .method("POST")
11763 .uri("/subscriptions/rk-keep/rename")
11764 .header(header::COOKIE, cookie)
11765 .header("content-type", "application/x-www-form-urlencoded")
11766 .body(Body::from(
11767 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
11768 ))
11769 .unwrap(),
11770 )
11771 .await
11772 .unwrap();
11773 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11774 let loc = resp
11775 .headers()
11776 .get(header::LOCATION)
11777 .unwrap()
11778 .to_str()
11779 .unwrap();
11780 assert!(
11781 loc.contains("Private"),
11782 "the private repoint was not refused: {loc}"
11783 );
11784 assert!(
11785 puts.lock().unwrap().is_empty(),
11786 "a secret-bearing URL reached the PDS"
11787 );
11788 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
11791 assert!(store::get_feed_by_url(&state.db, leaked)
11792 .await
11793 .unwrap()
11794 .is_none());
11795 }
11796
11797 #[tokio::test]
11803 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
11804 let did = "did:plc:renamer5";
11805 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11806 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11808 store::upsert_feed(
11809 &state.db,
11810 &store::NewFeed {
11811 url: "https://filler.example/feed.xml".to_string(),
11812 ..Default::default()
11813 },
11814 )
11815 .await
11816 .unwrap();
11817 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11818 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11819 assert_eq!(
11820 puts.lock().unwrap().len(),
11821 1,
11822 "the retitle did not reach the PDS"
11823 );
11824 assert_eq!(
11825 store::count_feeds(&state.db).await.unwrap(),
11826 1,
11827 "a row was inserted"
11828 );
11829 }
11830
11831 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
11833 let cookie = session_cookie(state, did, None);
11834 let resp = router(state.clone())
11835 .oneshot(
11836 Request::builder()
11837 .method("POST")
11838 .uri("/subscriptions")
11839 .header(header::COOKIE, cookie)
11840 .header("content-type", "application/x-www-form-urlencoded")
11841 .body(Body::from(format!("url={url_enc}")))
11842 .unwrap(),
11843 )
11844 .await
11845 .unwrap();
11846 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11847 resp.headers()
11848 .get(header::LOCATION)
11849 .unwrap()
11850 .to_str()
11851 .unwrap()
11852 .to_string()
11853 }
11854
11855 async fn serve_resolver(did: &str) -> String {
11857 let base = crate::net::tests::serve_body(
11858 serde_json::json!({ "did": did }).to_string().into_bytes(),
11859 )
11860 .await;
11861 let port: u16 = base
11862 .trim_end_matches('/')
11863 .rsplit(':')
11864 .next()
11865 .unwrap()
11866 .parse()
11867 .unwrap();
11868 let host = format!("resolver-{port}.test");
11869 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11870 format!("http://{host}:{port}")
11871 }
11872
11873 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
11874 let mut config = (*state.config).clone();
11875 f(&mut config);
11876 state.config = std::sync::Arc::new(config);
11877 state
11878 }
11879
11880 #[tokio::test]
11885 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
11886 let did = "did:plc:renamer5";
11887 let (sidecar, log) = spawn_logging_sidecar().await;
11888 let state = with_config(
11889 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11890 |c| {
11891 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11892 },
11893 );
11894 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
11895 assert_eq!(loc, "/", "the paste was refused: {loc}");
11896 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
11897 .await
11898 .unwrap()
11899 .expect("no feed row");
11900 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
11901 let sent = log.lock().unwrap().join("\n");
11902 assert!(
11903 sent.contains(AT_URI_SUB),
11904 "the subscription was not written to the PDS: {sent}"
11905 );
11906 }
11907
11908 #[tokio::test]
11912 async fn a0_subscribing_from_the_form_delivers_entries() {
11913 let did = "did:plc:renamer5";
11914 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11915 let site = AT_URI_SUB;
11916 let (plc, _) = crate::standard_site::tests::serve_repo(
11917 author,
11918 vec![
11919 (
11920 lexicon::nsid::STANDARD_PUBLICATION,
11921 "3lab2c4d5e6f7g8h",
11922 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
11923 ),
11924 (
11925 lexicon::nsid::STANDARD_DOCUMENT,
11926 "3l2a0frmaaa2a",
11927 serde_json::json!({ "title": "From the form", "path": "/f",
11928 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
11929 ),
11930 ],
11931 )
11932 .await;
11933 let (sidecar, _log) = spawn_logging_sidecar().await;
11934 let state = with_config(
11935 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11936 |c| {
11937 c.oauth.plc_directory = plc;
11938 },
11939 );
11940 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
11941 let row = store::get_feed_by_url(&state.db, site)
11942 .await
11943 .unwrap()
11944 .unwrap();
11945 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
11946 .bind(row.id)
11947 .fetch_all(&state.db)
11948 .await
11949 .unwrap();
11950 assert_eq!(
11951 titles,
11952 vec!["From the form".to_string()],
11953 "the first poll stored nothing"
11954 );
11955 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
11956 }
11957
11958 #[tokio::test]
11961 async fn a_handle_form_paste_is_stored_by_its_did() {
11962 let did = "did:plc:renamer5";
11963 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11964 let (sidecar, _log) = spawn_logging_sidecar().await;
11965 let resolver = serve_resolver(author).await;
11966 let state = with_config(
11967 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11968 |c| {
11969 c.resolver_base = resolver;
11970 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11971 },
11972 );
11973 let loc = subscribe(
11974 &state,
11975 did,
11976 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11977 )
11978 .await;
11979 assert_eq!(loc, "/", "the paste was refused: {loc}");
11980 assert!(
11981 store::get_feed_by_url(&state.db, AT_URI_SUB)
11982 .await
11983 .unwrap()
11984 .is_some(),
11985 "not stored by its DID"
11986 );
11987 assert_eq!(
11988 store::count_feeds(&state.db).await.unwrap(),
11989 1,
11990 "the handle form was stored too"
11991 );
11992 }
11993
11994 async fn serve_counting_resolver(
11996 did: &str,
11997 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
11998 let (base, hits) = crate::net::tests::serve_body_counted(
11999 serde_json::json!({ "did": did }).to_string().into_bytes(),
12000 )
12001 .await;
12002 let port: u16 = base
12003 .trim_end_matches('/')
12004 .rsplit(':')
12005 .next()
12006 .unwrap()
12007 .parse()
12008 .unwrap();
12009 let host = format!("counting-resolver-{port}.test");
12010 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
12011 (format!("http://{host}:{port}"), hits)
12012 }
12013
12014 #[tokio::test]
12018 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
12019 let did = "did:plc:renamer5";
12020 let (sidecar, _log) = spawn_logging_sidecar().await;
12021 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
12022 let state = with_config(
12023 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12024 |c| {
12025 c.resolver_base = resolver;
12026 c.max_subs_per_did = 1;
12027 },
12028 );
12029 let feed_id = store::upsert_feed(
12030 &state.db,
12031 &store::NewFeed {
12032 url: "https://already.example/feed.xml".into(),
12033 ..Default::default()
12034 },
12035 )
12036 .await
12037 .unwrap();
12038 store::replace_sub_refs(&state.db, did, &[feed_id])
12039 .await
12040 .unwrap();
12041 let loc = subscribe(
12042 &state,
12043 did,
12044 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12045 )
12046 .await;
12047 assert!(
12048 loc.contains("Subscription%20limit"),
12049 "expected the cap flash: {loc}"
12050 );
12051 assert_eq!(
12052 hits.load(std::sync::atomic::Ordering::SeqCst),
12053 0,
12054 "an over-cap paste resolved a handle"
12055 );
12056 }
12057
12058 #[tokio::test]
12062 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
12063 let did = "did:plc:renamer5";
12064 let (sidecar, _log) = spawn_logging_sidecar().await;
12065 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
12066 let state = with_config(
12067 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12068 |c| {
12069 c.resolver_base = resolver;
12070 },
12071 );
12072 for authority in [
12073 "did%3Aplc%3ATOOSHORT",
12074 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
12075 "bad%0Ahandle.example",
12076 ] {
12077 let loc = subscribe(
12078 &state,
12079 did,
12080 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
12081 )
12082 .await;
12083 assert!(
12084 loc.contains("kind%20of%20feed"),
12085 "{authority}: expected the unsupported flash: {loc}"
12086 );
12087 }
12088 assert_eq!(
12089 hits.load(std::sync::atomic::Ordering::SeqCst),
12090 0,
12091 "a malformed authority reached the resolver"
12092 );
12093 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
12094 }
12095
12096 #[tokio::test]
12098 async fn an_unresolvable_handle_paste_is_refused() {
12099 let did = "did:plc:renamer5";
12100 let (sidecar, _log) = spawn_logging_sidecar().await;
12101 let state = with_config(
12102 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12103 |c| {
12104 c.resolver_base = "http://resolver.nowhere.invalid".into();
12105 },
12106 );
12107 let loc = subscribe(
12108 &state,
12109 did,
12110 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12111 )
12112 .await;
12113 assert!(
12114 loc.contains("resolve%20the%20handle"),
12115 "expected the unresolvable-handle flash: {loc}"
12116 );
12117 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
12118 }
12119
12120 #[tokio::test]
12122 async fn a_non_publication_at_uri_paste_is_refused() {
12123 let did = "did:plc:renamer5";
12124 let (sidecar, _log) = spawn_logging_sidecar().await;
12125 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
12126 let loc = subscribe(
12127 &state,
12128 did,
12129 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
12130 )
12131 .await;
12132 assert!(
12133 loc.contains("kind%20of%20feed"),
12134 "expected the unsupported flash: {loc}"
12135 );
12136 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
12137 }
12138
12139 #[tokio::test]
12142 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
12143 let did = "did:plc:renamer5";
12144 let (sidecar, _log) = spawn_logging_sidecar().await;
12145 let state = with_config(
12146 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12147 |c| {
12148 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
12149 },
12150 );
12151 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
12152 assert_eq!(loc, "/", "the paste was refused: {loc}");
12153 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
12154 .await
12155 .unwrap()
12156 .is_some());
12157 }
12158
12159 #[tokio::test]
12162 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
12163 let did = "did:plc:renamer5";
12164 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
12165 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
12166 let opml = format!(
12167 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
12168 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
12169 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
12170 </body></opml>"
12171 );
12172 let (ct, body) = opml_multipart(opml.as_bytes());
12173 let cookie = session_cookie(&state, did, None);
12174 let resp = router(state.clone())
12175 .oneshot(
12176 Request::builder()
12177 .method("POST")
12178 .uri("/opml")
12179 .header(header::COOKIE, cookie)
12180 .header("content-type", ct)
12181 .body(Body::from(body))
12182 .unwrap(),
12183 )
12184 .await
12185 .unwrap();
12186 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12187 let loc = resp
12188 .headers()
12189 .get(header::LOCATION)
12190 .unwrap()
12191 .to_str()
12192 .unwrap();
12193 assert!(
12194 loc.contains("Imported%202%20feeds"),
12195 "unexpected flash: {loc}"
12196 );
12197 assert!(
12198 !loc.contains("skipped"),
12199 "the at:// entry was skipped with the flag on: {loc}"
12200 );
12201 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
12202 assert!(
12203 stored.is_some(),
12204 "the at:// entry was not stored with the flag on"
12205 );
12206 }
12207
12208 #[tokio::test]
12218 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
12219 let did = "did:plc:renamer5";
12220 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
12221 let tokened_enc =
12222 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
12223 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
12224 let state = test_state_with_sidecar(&[did], &sidecar).await;
12225 let loc = retitle_unchanged(&state, did, tokened_enc).await;
12226 assert_eq!(loc, "/", "the retitle was refused: {loc}");
12227 assert_eq!(
12228 puts.lock().unwrap().len(),
12229 1,
12230 "the retitle did not reach the PDS"
12231 );
12232 assert!(
12233 store::get_feed_by_url(&state.db, tokened)
12234 .await
12235 .unwrap()
12236 .is_none(),
12237 "a secret-bearing URL was written to the shared cache by a retitle"
12238 );
12239 }
12240
12241 #[tokio::test]
12246 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
12247 let did = "did:plc:renamer4";
12248 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
12249 let state = test_state_with_sidecar(&[did], &sidecar).await;
12250 let cookie = session_cookie(&state, did, None);
12251 let resp = router(state.clone())
12252 .oneshot(
12253 Request::builder()
12254 .method("POST")
12255 .uri("/subscriptions/rk-keep/rename")
12256 .header(header::COOKIE, cookie)
12257 .header("content-type", "application/x-www-form-urlencoded")
12258 .body(Body::from(
12259 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
12260 ))
12261 .unwrap(),
12262 )
12263 .await
12264 .unwrap();
12265 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12266 let loc = resp
12267 .headers()
12268 .get(header::LOCATION)
12269 .unwrap()
12270 .to_str()
12271 .unwrap();
12272 assert!(
12273 loc.contains("kind%20of%20feed"),
12274 "expected the unsupported flash: {loc}"
12275 );
12276 assert!(
12277 !loc.contains("Private"),
12278 "a typo was reported as a paid feed: {loc}"
12279 );
12280 assert!(puts.lock().unwrap().is_empty());
12281 }
12282
12283 #[tokio::test]
12284 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
12285 let did = "did:plc:renamer4";
12286 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
12287 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
12288 store::upsert_feed(
12289 &state.db,
12290 &store::NewFeed {
12291 url: "https://filler.example/feed.xml".to_string(),
12292 ..Default::default()
12293 },
12294 )
12295 .await
12296 .unwrap();
12297 let cookie = session_cookie(&state, did, None);
12298 let resp = router(state.clone())
12299 .oneshot(
12300 Request::builder()
12301 .method("POST")
12302 .uri("/subscriptions/rk-keep/rename")
12303 .header(header::COOKIE, cookie)
12304 .header("content-type", "application/x-www-form-urlencoded")
12305 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
12306 .unwrap(),
12307 )
12308 .await
12309 .unwrap();
12310 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12311 let loc = resp
12312 .headers()
12313 .get(header::LOCATION)
12314 .unwrap()
12315 .to_str()
12316 .unwrap();
12317 assert!(
12318 loc.contains("kind%20of%20feed"),
12319 "expected the unsupported flash: {loc}"
12320 );
12321 assert!(
12322 !loc.contains("capacity"),
12323 "an unacceptable URL was reported as a capacity problem: {loc}"
12324 );
12325 assert!(puts.lock().unwrap().is_empty());
12326 }
12327
12328 #[tokio::test]
12333 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
12334 let did = "did:plc:renamer5";
12335 let padded = format!("{AT_URI_SUB} ");
12336 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
12337 let state = test_state_with_sidecar(&[did], &sidecar).await;
12338 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
12340 assert_eq!(
12341 loc, "/",
12342 "the retitle was treated as a repoint and refused: {loc}"
12343 );
12344 let bodies = puts.lock().unwrap().clone();
12345 assert_eq!(bodies.len(), 1);
12346 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
12347 assert_eq!(
12348 sent["record"]["url"], AT_URI_SUB,
12349 "the padding was not normalised away"
12350 );
12351 }
12352
12353 #[tokio::test]
12359 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
12360 let did = "did:plc:renamer5";
12361 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
12362 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
12363 store::upsert_feed(
12364 &state.db,
12365 &store::NewFeed {
12366 url: "https://filler.example/feed.xml".to_string(),
12367 ..Default::default()
12368 },
12369 )
12370 .await
12371 .unwrap();
12372 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
12373 assert_eq!(loc, "/", "the retitle was refused: {loc}");
12374 assert_eq!(puts.lock().unwrap().len(), 1);
12375 assert_eq!(
12376 store::count_feeds(&state.db).await.unwrap(),
12377 1,
12378 "a retitle inserted a cache row past the ceiling"
12379 );
12380 }
12381
12382 #[tokio::test]
12389 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
12390 let did = "did:plc:typoist";
12391 let state = test_state_with_caps(did, 0, 0).await;
12392 let cookie = session_cookie(&state, did, None);
12393 let resp = router(state.clone())
12394 .oneshot(
12395 Request::builder()
12396 .method("POST")
12397 .uri("/subscriptions")
12398 .header(header::COOKIE, cookie)
12399 .header("content-type", "application/x-www-form-urlencoded")
12400 .body(Body::from(
12401 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12402 ))
12403 .unwrap(),
12404 )
12405 .await
12406 .unwrap();
12407 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12408 let loc = resp
12409 .headers()
12410 .get(header::LOCATION)
12411 .unwrap()
12412 .to_str()
12413 .unwrap();
12414 assert!(
12415 loc.contains("kind%20of%20feed"),
12416 "expected the unsupported flash: {loc}"
12417 );
12418 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
12419 }
12420
12421 #[derive(Default)]
12427 struct SwapRepo {
12428 value: serde_json::Value,
12430 version: u32,
12432 puts: Vec<serde_json::Value>,
12434 concurrent: Option<serde_json::Value>,
12437 refuse_every_swap: bool,
12439 fail_puts: Option<(u16, &'static str)>,
12441 collection: Option<&'static str>,
12443 missing: bool,
12445 fail_list: bool,
12447 }
12448
12449 impl SwapRepo {
12450 fn cid(&self) -> String {
12451 format!("bafyreiversion{}", self.version)
12452 }
12453
12454 fn nsid(&self) -> &'static str {
12455 self.collection
12456 .unwrap_or(crate::lexicon::nsid::SUBSCRIPTION)
12457 }
12458
12459 fn page(&self) -> serde_json::Value {
12460 if self.missing {
12461 return serde_json::json!({ "records": [] });
12462 }
12463 serde_json::json!({ "records": [{
12464 "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12465 "cid": self.cid(),
12466 "value": self.value,
12467 }] })
12468 }
12469
12470 fn put(&mut self, body: &serde_json::Value) -> Result<serde_json::Value, (u16, String)> {
12472 self.puts.push(body.clone());
12473 if let Some(theirs) = self.concurrent.take() {
12474 self.value = theirs;
12475 self.version += 1;
12476 }
12477 if let Some((status, error)) = self.fail_puts {
12478 return Err((status, error.to_string()));
12479 }
12480 if let Some(swap) = body.get("swapRecord").and_then(|v| v.as_str()) {
12481 if self.refuse_every_swap || swap != self.cid() {
12482 return Err((400, "InvalidSwap".to_string()));
12483 }
12484 }
12485 self.value = body["record"].clone();
12486 self.version += 1;
12487 Ok(serde_json::json!({
12488 "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12489 "cid": self.cid(),
12490 }))
12491 }
12492 }
12493
12494 const RACE_DID: &str = "did:plc:racer149";
12495
12496 async fn serve_swap_repo(repo: std::sync::Arc<std::sync::Mutex<SwapRepo>>) -> (String, String) {
12500 use axum::response::IntoResponse as _;
12501 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12502 let addr = listener.local_addr().unwrap();
12503 let host = format!("pds-{}.race.test", addr.port());
12504 crate::net::test_host_override(&host, addr);
12505 let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
12506 let repo = std::sync::Arc::clone(&repo);
12507 async move {
12508 let (parts, body) = req.into_parts();
12509 let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
12510 let body: serde_json::Value =
12511 serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
12512 let reply = |status: u16, body: serde_json::Value| {
12513 (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
12514 };
12515 let mut repo = repo.lock().unwrap();
12516 match (parts.uri.path(), body["action"].as_str()) {
12517 ("/internal/repo", Some("list")) if repo.fail_list => reply(
12518 502,
12519 serde_json::json!({
12520 "ok": false, "error": "UpstreamFailure", "message": "down", "status": 502,
12521 }),
12522 ),
12523 ("/internal/repo", Some("list")) => {
12524 reply(200, serde_json::json!({ "ok": true, "data": repo.page() }))
12525 }
12526 ("/internal/repo", Some("put")) => match repo.put(&body) {
12527 Ok(data) => reply(200, serde_json::json!({ "ok": true, "data": data })),
12528 Err((status, error)) => reply(
12529 status,
12530 serde_json::json!({
12531 "ok": false, "error": error, "message": "refused", "status": status,
12532 }),
12533 ),
12534 },
12535 ("/xrpc/com.atproto.repo.listRecords", _) if repo.fail_list => reply(
12536 502,
12537 serde_json::json!({ "error": "UpstreamFailure", "message": "down" }),
12538 ),
12539 ("/xrpc/com.atproto.repo.listRecords", _) => reply(200, repo.page()),
12540 ("/xrpc/com.atproto.repo.putRecord", _) => match repo.put(&body) {
12541 Ok(data) => reply(200, data),
12542 Err((status, error)) => reply(
12543 status,
12544 serde_json::json!({ "error": error, "message": "refused" }),
12545 ),
12546 },
12547 other => panic!("unexpected request {other:?}"),
12548 }
12549 }
12550 });
12551 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
12552 (
12553 format!("http://{addr}"),
12554 format!("http://{host}:{}", addr.port()),
12555 )
12556 }
12557
12558 async fn race_state(
12562 backend: crate::metrics::Backend,
12563 repo: &std::sync::Arc<std::sync::Mutex<SwapRepo>>,
12564 ) -> AppState {
12565 let (sidecar, aud) = serve_swap_repo(std::sync::Arc::clone(repo)).await;
12566 let db = store::init_url("sqlite::memory:").await.unwrap();
12567 store::ensure_seed(&db, &[RACE_DID.to_string()])
12568 .await
12569 .unwrap();
12570 let mut config = Config {
12571 allowed_dids: vec![RACE_DID.to_string()],
12572 cookie_secret: "test-cookie-secret-000".to_string(),
12573 beta_cap: 3,
12574 repo_backend: backend,
12575 oauth: crate::config::OauthConfig {
12576 key_path: std::env::temp_dir().join(format!(
12578 "fr-race-oauth-key-{}-{:p}.json",
12579 std::process::id(),
12580 &db as *const _
12581 )),
12582 encryption_key: Some("a".repeat(43)),
12583 ..crate::config::OauthConfig::default()
12584 },
12585 ..Config::default()
12586 };
12587 config.sidecar.public_url = sidecar.clone();
12588 config.sidecar.internal_url = sidecar;
12589 let state = AppState::new(config, db).unwrap();
12590 if backend == crate::metrics::Backend::Rust {
12591 let runtime = state.oauth.as_deref().expect("oauth runtime");
12592 crate::oauth::store::put_session(
12593 &state.db,
12594 &runtime.codec,
12595 &crate::oauth::store::OAuthSession {
12596 sub: RACE_DID.into(),
12597 issuer: "https://auth.invalid".into(),
12598 aud,
12599 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
12600 .to_jwk_json()
12601 .unwrap(),
12602 access_token: "at".into(),
12603 refresh_token: "rt".into(),
12604 token_type: "DPoP".into(),
12605 granted_scope: "atproto".into(),
12606 expires_at: Some(store::now_unix() + 3600),
12607 },
12608 )
12609 .await
12610 .unwrap();
12611 }
12612 state
12613 }
12614
12615 fn race_seed() -> serde_json::Value {
12617 seeded_subscription()["value"].clone()
12618 }
12619
12620 async fn post_race_rename(state: &AppState) -> String {
12623 post_race_rename_body(
12624 state,
12625 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
12626 )
12627 .await
12628 }
12629
12630 async fn post_race_rename_body(state: &AppState, body: &str) -> String {
12632 let cookie = session_cookie(state, RACE_DID, None);
12633 let resp = router(state.clone())
12634 .oneshot(
12635 Request::builder()
12636 .method("POST")
12637 .uri("/subscriptions/rk-keep/rename")
12638 .header(header::COOKIE, cookie)
12639 .header("content-type", "application/x-www-form-urlencoded")
12640 .body(Body::from(body.to_string()))
12641 .unwrap(),
12642 )
12643 .await
12644 .unwrap();
12645 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12646 resp.headers()
12647 .get(header::LOCATION)
12648 .unwrap()
12649 .to_str()
12650 .unwrap()
12651 .to_string()
12652 }
12653
12654 const RACE_BACKENDS: [crate::metrics::Backend; 2] = [
12655 crate::metrics::Backend::Sidecar,
12656 crate::metrics::Backend::Rust,
12657 ];
12658
12659 #[tokio::test]
12672 async fn a_rename_that_loses_a_race_keeps_the_concurrent_edit_and_lands() {
12673 for backend in RACE_BACKENDS {
12674 let mut theirs = race_seed();
12675 theirs["siteUrl"] = serde_json::json!("https://elsewhere.example/blog");
12676 theirs["fetchHint"] = serde_json::json!("daily");
12677 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12678 value: race_seed(),
12679 concurrent: Some(theirs),
12680 ..SwapRepo::default()
12681 }));
12682 let state = race_state(backend, &repo).await;
12683
12684 let loc = post_race_rename(&state).await;
12685
12686 let repo = repo.lock().unwrap();
12687 assert_eq!(
12688 loc, "/",
12689 "{backend:?}: a rename that converged was not reported as done"
12690 );
12691 assert_eq!(
12692 repo.puts.len(),
12693 2,
12694 "{backend:?}: expected the refused put and one retry: {:?}",
12695 repo.puts
12696 );
12697 assert_eq!(
12698 repo.puts[0]["swapRecord"], "bafyreiversion0",
12699 "{backend:?}: the first put did not name the CID it read: {}",
12700 repo.puts[0]
12701 );
12702 assert_eq!(
12703 repo.puts[1]["swapRecord"], "bafyreiversion1",
12704 "{backend:?}: the retry did not name the RE-READ CID: {}",
12705 repo.puts[1]
12706 );
12707 let landed = &repo.value;
12708 assert_eq!(landed["title"], "New title", "{backend:?}: {landed}");
12710 assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12711 assert_eq!(
12713 landed["siteUrl"], "https://elsewhere.example/blog",
12714 "{backend:?}: the concurrent edit was lost: {landed}"
12715 );
12716 assert_eq!(
12717 landed["fetchHint"], "daily",
12718 "{backend:?}: the concurrent edit was lost: {landed}"
12719 );
12720 assert_eq!(
12722 landed["createdAt"], "2024-03-01T00:00:00.000Z",
12723 "{backend:?}: {landed}"
12724 );
12725 }
12726 }
12727
12728 #[tokio::test]
12732 async fn a_rename_refused_on_every_swap_reports_the_conflict() {
12733 for backend in RACE_BACKENDS {
12734 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12735 value: race_seed(),
12736 refuse_every_swap: true,
12737 ..SwapRepo::default()
12738 }));
12739 let state = race_state(backend, &repo).await;
12740
12741 let loc = post_race_rename(&state).await;
12742
12743 let repo = repo.lock().unwrap();
12744 assert_ne!(loc, "/", "{backend:?}: a refused rename reported success");
12745 assert!(
12746 loc.contains("changed%20elsewhere"),
12747 "{backend:?}: expected the conflict flash, got {loc}"
12748 );
12749 assert!(
12750 (1..=2).contains(&repo.puts.len()),
12751 "{backend:?}: expected at most two put attempts, got {}",
12752 repo.puts.len()
12753 );
12754 assert_eq!(
12755 repo.value,
12756 race_seed(),
12757 "{backend:?}: the record changed though every put was refused"
12758 );
12759 }
12760 }
12761
12762 #[tokio::test]
12767 async fn a_rename_refused_for_another_reason_is_not_retried() {
12768 for backend in RACE_BACKENDS {
12769 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12770 value: race_seed(),
12771 fail_puts: Some((400, "InvalidRequest")),
12772 ..SwapRepo::default()
12773 }));
12774 let state = race_state(backend, &repo).await;
12775
12776 let loc = post_race_rename(&state).await;
12777
12778 let repo = repo.lock().unwrap();
12779 assert_eq!(
12780 repo.puts.len(),
12781 1,
12782 "{backend:?}: a non-swap refusal was retried"
12783 );
12784 assert!(
12785 loc.contains("Could%20not%20save"),
12786 "{backend:?}: expected the save-failed flash, got {loc}"
12787 );
12788 assert!(
12789 !loc.contains("changed%20elsewhere"),
12790 "{backend:?}: a non-swap refusal was reported as a conflict: {loc}"
12791 );
12792 }
12793 }
12794
12795 const SEEN_SEED: &str = "seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12799 &seen_title=Old+title&seen_folder=";
12800
12801 #[tokio::test]
12810 async fn a_retry_keeps_a_concurrent_repoint_the_reader_did_not_make() {
12811 for backend in RACE_BACKENDS {
12812 for with_seen in [true, false] {
12813 let mut theirs = race_seed();
12814 theirs["url"] = serde_json::json!("https://moved.example/feed.xml");
12815 theirs["siteUrl"] = serde_json::json!("https://moved.example/");
12816 theirs["fetchHint"] = serde_json::json!("daily");
12817 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12818 value: race_seed(),
12819 concurrent: Some(theirs),
12820 ..SwapRepo::default()
12821 }));
12822 let state = race_state(backend, &repo).await;
12823 let mut body =
12824 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title".to_string();
12825 if with_seen {
12826 body.push_str(
12827 "&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml&seen_title=Old+title",
12828 );
12829 }
12830
12831 let loc = post_race_rename_body(&state, &body).await;
12832
12833 let repo = repo.lock().unwrap();
12834 let ctx = format!("{backend:?} seen={with_seen}");
12835 assert_eq!(loc, "/", "{ctx}: the rename did not land: {loc}");
12836 assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12837 let landed = &repo.value;
12838 assert_eq!(landed["title"], "New title", "{ctx}: {landed}");
12839 assert_eq!(
12840 landed["url"], "https://moved.example/feed.xml",
12841 "{ctx}: the retry repointed the record back to the stale URL: {landed}"
12842 );
12843 assert_eq!(
12844 landed["siteUrl"], "https://moved.example/",
12845 "{ctx}: {landed}"
12846 );
12847 assert_eq!(landed["fetchHint"], "daily", "{ctx}: {landed}");
12848 }
12849 }
12850 }
12851
12852 #[tokio::test]
12856 async fn a_retry_keeps_a_concurrent_retitle_when_the_reader_only_moved_it() {
12857 for backend in RACE_BACKENDS {
12858 let mut theirs = race_seed();
12859 theirs["title"] = serde_json::json!("Their title");
12860 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12861 value: race_seed(),
12862 concurrent: Some(theirs),
12863 ..SwapRepo::default()
12864 }));
12865 let state = race_state(backend, &repo).await;
12866
12867 let loc = post_race_rename_body(
12868 &state,
12869 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Old+title&folder=Tech&{SEEN_SEED}"),
12870 )
12871 .await;
12872
12873 let repo = repo.lock().unwrap();
12874 assert_eq!(loc, "/", "{backend:?}: {loc}");
12875 let landed = &repo.value;
12876 assert_eq!(
12877 landed["title"], "Their title",
12878 "{backend:?}: the reader's untouched title overwrote the other client's: {landed}"
12879 );
12880 assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12881 }
12882 }
12883
12884 #[tokio::test]
12888 async fn both_retitling_is_a_conflict_that_writes_nothing() {
12889 for backend in RACE_BACKENDS {
12890 let mut theirs = race_seed();
12891 theirs["title"] = serde_json::json!("Their title");
12892 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12893 value: race_seed(),
12894 concurrent: Some(theirs.clone()),
12895 ..SwapRepo::default()
12896 }));
12897 let state = race_state(backend, &repo).await;
12898
12899 let loc = post_race_rename_body(
12900 &state,
12901 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12902 )
12903 .await;
12904
12905 let repo = repo.lock().unwrap();
12906 assert!(
12907 loc.contains("changed%20elsewhere"),
12908 "{backend:?}: expected the conflict flash, got {loc}"
12909 );
12910 assert_eq!(
12911 repo.puts.len(),
12912 1,
12913 "{backend:?}: a conflicting retry was written: {:?}",
12914 repo.puts
12915 );
12916 assert_eq!(
12917 repo.value, theirs,
12918 "{backend:?}: their title was overwritten"
12919 );
12920 }
12921 }
12922
12923 #[tokio::test]
12930 async fn a_repoint_before_the_first_read_is_kept_when_the_reader_only_retitled() {
12931 for backend in RACE_BACKENDS {
12932 let mut moved = race_seed();
12933 moved["url"] = serde_json::json!("https://moved.example/feed.xml");
12934 moved["siteUrl"] = serde_json::json!("https://moved.example/");
12935 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12936 value: moved,
12937 ..SwapRepo::default()
12938 }));
12939 let state = race_state(backend, &repo).await;
12940
12941 let loc = post_race_rename_body(
12942 &state,
12943 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12944 )
12945 .await;
12946
12947 let repo = repo.lock().unwrap();
12948 assert_eq!(loc, "/", "{backend:?}: {loc}");
12949 assert_eq!(repo.puts.len(), 1, "{backend:?}");
12950 let landed = &repo.value;
12951 assert_eq!(
12952 landed["url"], "https://moved.example/feed.xml",
12953 "{backend:?}: the stale hidden url repointed the record: {landed}"
12954 );
12955 assert_eq!(landed["siteUrl"], "https://moved.example/", "{backend:?}");
12956 assert_eq!(landed["title"], "New title", "{backend:?}");
12957 }
12958 }
12959
12960 #[tokio::test]
12966 async fn a_rename_that_did_not_land_leaves_the_cache_alone() {
12967 const NEW_URL: &str = "https://other.example/feed.xml";
12968 const OLD_URL: &str = "https://example.com/feed.xml";
12969 for (refuse_every_swap, fail_puts, lands) in [
12971 (true, None, false),
12972 (false, Some((400, "InvalidRequest")), false),
12973 (false, Some((502, "UpstreamFailure")), false),
12974 (false, None, true),
12975 ] {
12976 for backend in RACE_BACKENDS {
12977 let ctx = format!("{backend:?} refuse={refuse_every_swap} fail={fail_puts:?}");
12978 for repoint in [false, true] {
12979 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12980 value: race_seed(),
12981 refuse_every_swap,
12982 fail_puts,
12983 ..SwapRepo::default()
12984 }));
12985 let state = race_state(backend, &repo).await;
12986 store::upsert_feed(
12987 &state.db,
12988 &store::NewFeed {
12989 url: OLD_URL.to_string(),
12990 title: Some("Cached title".to_string()),
12991 ..Default::default()
12992 },
12993 )
12994 .await
12995 .unwrap();
12996 let url = if repoint { NEW_URL } else { OLD_URL };
12997 let body = format!("url={}&title=New+title&{SEEN_SEED}", qenc(url));
12998
12999 let loc = post_race_rename_body(&state, &body).await;
13000
13001 let new_row = store::get_feed_by_url(&state.db, NEW_URL).await.unwrap();
13002 let old_row = store::get_feed_by_url(&state.db, OLD_URL)
13003 .await
13004 .unwrap()
13005 .expect("the old row");
13006 let ctx = format!("{ctx} repoint={repoint} -> {loc}");
13007 if lands {
13008 assert_eq!(loc, "/", "{ctx}");
13009 if repoint {
13010 assert!(
13011 new_row.is_some(),
13012 "{ctx}: a landed repoint got no cache row"
13013 );
13014 } else {
13015 assert_eq!(old_row.title.as_deref(), Some("New title"), "{ctx}");
13016 }
13017 } else {
13018 assert_ne!(loc, "/", "{ctx}");
13019 assert!(
13020 new_row.is_none(),
13021 "{ctx}: a repoint that did not land left a feeds row for its URL"
13022 );
13023 assert_eq!(
13024 old_row.title.as_deref(),
13025 Some("Cached title"),
13026 "{ctx}: a rename that did not land changed the cached title"
13027 );
13028 }
13029 }
13030 }
13031 }
13032 }
13033
13034 #[tokio::test]
13041 async fn a_rename_from_a_page_without_a_folder_select_keeps_the_folder() {
13042 for backend in RACE_BACKENDS {
13043 for raced in [false, true] {
13044 let mut seed = race_seed();
13045 seed["folder"] = serde_json::json!("at://did:plc:racer149/folder/kept");
13046 let concurrent = raced.then(|| {
13047 let mut theirs = seed.clone();
13048 theirs["folder"] = serde_json::json!("at://did:plc:racer149/folder/theirs");
13049 theirs
13050 });
13051 let want_folder = concurrent
13052 .as_ref()
13053 .map_or(seed["folder"].clone(), |t| t["folder"].clone());
13054 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
13055 value: seed,
13056 concurrent,
13057 ..SwapRepo::default()
13058 }));
13059 let state = race_state(backend, &repo).await;
13060
13061 let loc = post_race_rename_body(
13063 &state,
13064 "url=https%3A%2F%2Fexample.com%2Ffeed.xml\
13065 &seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
13066 &seen_title=Old+title&title=New+title",
13067 )
13068 .await;
13069
13070 let repo = repo.lock().unwrap();
13071 let ctx = format!("{backend:?} raced={raced}");
13072 assert_eq!(loc, "/", "{ctx}: {loc}");
13073 assert_eq!(repo.value["title"], "New title", "{ctx}");
13074 assert_eq!(
13075 repo.value["folder"], want_folder,
13076 "{ctx}: a page that never showed a folder changed it: {}",
13077 repo.value
13078 );
13079 }
13080 }
13081 }
13082
13083 #[tokio::test]
13088 async fn a_double_submitted_rename_reports_success_and_writes_once() {
13089 for backend in RACE_BACKENDS {
13090 let mut first = race_seed();
13093 first["title"] = serde_json::json!("New title");
13094 first["folder"] = serde_json::json!("Tech");
13095 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
13096 value: race_seed(),
13097 concurrent: Some(first.clone()),
13098 ..SwapRepo::default()
13099 }));
13100 let state = race_state(backend, &repo).await;
13101
13102 let loc = post_race_rename_body(
13103 &state,
13104 &format!(
13105 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech&{SEEN_SEED}"
13106 ),
13107 )
13108 .await;
13109
13110 let repo = repo.lock().unwrap();
13111 assert_eq!(
13112 loc, "/",
13113 "{backend:?}: a save that landed was reported as a conflict: {loc}"
13114 );
13115 assert_eq!(
13116 repo.puts.len(),
13117 1,
13118 "{backend:?}: only the refused put; the re-read has nothing left to write: {:?}",
13119 repo.puts
13120 );
13121 assert_eq!(repo.value, first, "{backend:?}");
13122 }
13123 }
13124
13125 fn folder_seed() -> serde_json::Value {
13131 serde_json::json!({
13132 "$type": crate::lexicon::nsid::FOLDER,
13133 "name": "Old name",
13134 "position": 3,
13135 "createdAt": "2024-01-01T00:00:00.000Z",
13136 "color": "#abc",
13137 })
13138 }
13139
13140 fn folder_repo(value: serde_json::Value) -> SwapRepo {
13142 SwapRepo {
13143 value,
13144 collection: Some(crate::lexicon::nsid::FOLDER),
13145 ..SwapRepo::default()
13146 }
13147 }
13148
13149 async fn post_folder_rename(state: &AppState, body: &str) -> String {
13151 let cookie = session_cookie(state, RACE_DID, None);
13152 let resp = router(state.clone())
13153 .oneshot(
13154 Request::builder()
13155 .method("POST")
13156 .uri("/folders/rk-keep/rename")
13157 .header(header::COOKIE, cookie)
13158 .header("content-type", "application/x-www-form-urlencoded")
13159 .body(Body::from(body.to_string()))
13160 .unwrap(),
13161 )
13162 .await
13163 .unwrap();
13164 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13165 resp.headers()
13166 .get(header::LOCATION)
13167 .unwrap()
13168 .to_str()
13169 .unwrap()
13170 .to_string()
13171 }
13172
13173 fn renamed(mut value: serde_json::Value, name: &str) -> serde_json::Value {
13175 value["name"] = serde_json::json!(name);
13176 value
13177 }
13178
13179 #[tokio::test]
13185 async fn renaming_a_folder_changes_only_its_name() {
13186 for backend in RACE_BACKENDS {
13187 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(folder_seed())));
13188 let state = race_state(backend, &repo).await;
13189
13190 let loc = post_folder_rename(&state, "name=New+name").await;
13191
13192 let repo = repo.lock().unwrap();
13193 assert_eq!(
13194 loc, "/",
13195 "{backend:?}: a landed rename was not reported as done"
13196 );
13197 assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
13198 assert_eq!(
13199 repo.puts[0]["record"],
13200 renamed(folder_seed(), "New name"),
13201 "{backend:?}: the put did not keep the record whole: {}",
13202 repo.puts[0]
13203 );
13204 assert_eq!(
13205 repo.puts[0]["collection"],
13206 crate::lexicon::nsid::FOLDER,
13207 "{backend:?}"
13208 );
13209 assert_eq!(
13210 repo.puts[0]["swapRecord"], "bafyreiversion0",
13211 "{backend:?}: the put did not name the CID it read: {}",
13212 repo.puts[0]
13213 );
13214 }
13215 }
13216
13217 #[tokio::test]
13222 async fn a_folder_rename_that_loses_a_race_keeps_the_concurrent_edit() {
13223 for backend in RACE_BACKENDS {
13224 for with_seen in [true, false] {
13225 let mut theirs = folder_seed();
13226 theirs["position"] = serde_json::json!(7);
13227 theirs["icon"] = serde_json::json!("star");
13228 let mut fake = folder_repo(folder_seed());
13229 fake.concurrent = Some(theirs.clone());
13230 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13231 let state = race_state(backend, &repo).await;
13232
13233 let body = if with_seen {
13234 "name=New+name&seen_name=Old+name"
13235 } else {
13236 "name=New+name"
13237 };
13238 let loc = post_folder_rename(&state, body).await;
13239
13240 let repo = repo.lock().unwrap();
13241 let ctx = format!("{backend:?} with_seen={with_seen}");
13242 assert_eq!(
13243 loc, "/",
13244 "{ctx}: a converged rename was not reported as done"
13245 );
13246 assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
13247 assert_eq!(repo.puts[0]["swapRecord"], "bafyreiversion0", "{ctx}");
13248 assert_eq!(
13249 repo.puts[1]["swapRecord"], "bafyreiversion1",
13250 "{ctx}: the retry did not name the RE-READ CID"
13251 );
13252 assert_eq!(
13253 repo.value,
13254 renamed(theirs, "New name"),
13255 "{ctx}: the concurrent edit was lost"
13256 );
13257 }
13258 }
13259 }
13260
13261 #[tokio::test]
13266 async fn both_renaming_a_folder_differently_is_a_conflict() {
13267 for backend in RACE_BACKENDS {
13268 for body in ["name=New+name&seen_name=Old+name", "name=New+name"] {
13269 let theirs = renamed(folder_seed(), "Their name");
13270 let mut fake = folder_repo(folder_seed());
13271 fake.concurrent = Some(theirs.clone());
13272 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13273 let state = race_state(backend, &repo).await;
13274
13275 let loc = post_folder_rename(&state, body).await;
13276
13277 let repo = repo.lock().unwrap();
13278 assert!(
13279 loc.contains("changed%20elsewhere"),
13280 "{backend:?} {body}: expected the conflict flash, got {loc}"
13281 );
13282 assert_eq!(
13283 repo.puts.len(),
13284 1,
13285 "{backend:?} {body}: only the refused put: {:?}",
13286 repo.puts
13287 );
13288 assert_eq!(
13289 repo.value, theirs,
13290 "{backend:?} {body}: the other client's name was overwritten"
13291 );
13292 }
13293 }
13294 }
13295
13296 #[tokio::test]
13299 async fn both_renaming_a_folder_the_same_is_success_without_a_write() {
13300 for backend in RACE_BACKENDS {
13301 let theirs = renamed(folder_seed(), "New name");
13302 let mut fake = folder_repo(folder_seed());
13303 fake.concurrent = Some(theirs.clone());
13304 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13305 let state = race_state(backend, &repo).await;
13306
13307 let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
13308
13309 let repo = repo.lock().unwrap();
13310 assert_eq!(
13311 loc, "/",
13312 "{backend:?}: agreement reported as a failure: {loc}"
13313 );
13314 assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
13315 assert_eq!(repo.value, theirs, "{backend:?}");
13316 }
13317 }
13318
13319 #[tokio::test]
13322 async fn a_folder_rename_refused_on_every_swap_reports_the_conflict() {
13323 for backend in RACE_BACKENDS {
13324 let mut fake = folder_repo(folder_seed());
13325 fake.refuse_every_swap = true;
13326 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13327 let state = race_state(backend, &repo).await;
13328
13329 let loc = post_folder_rename(&state, "name=New+name").await;
13330
13331 let repo = repo.lock().unwrap();
13332 assert!(
13333 loc.contains("changed%20elsewhere"),
13334 "{backend:?}: expected the conflict flash, got {loc}"
13335 );
13336 assert_eq!(repo.puts.len(), 2, "{backend:?}: one try and one retry");
13337 assert_eq!(repo.value, folder_seed(), "{backend:?}");
13338 }
13339 }
13340
13341 #[tokio::test]
13344 async fn a_failed_folder_rename_shows_an_error() {
13345 for backend in RACE_BACKENDS {
13346 let mut fake = folder_repo(folder_seed());
13347 fake.fail_puts = Some((502, "UpstreamFailure"));
13348 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13349 let state = race_state(backend, &repo).await;
13350
13351 let loc = post_folder_rename(&state, "name=New+name").await;
13352
13353 let repo = repo.lock().unwrap();
13354 assert_ne!(loc, "/", "{backend:?}: a failed rename reported success");
13355 assert!(
13356 loc.contains("Could%20not%20save"),
13357 "{backend:?}: expected the save-failed flash, got {loc}"
13358 );
13359 assert!(!loc.contains("changed%20elsewhere"), "{backend:?}: {loc}");
13360 assert_eq!(
13361 repo.puts.len(),
13362 1,
13363 "{backend:?}: a non-swap failure was retried"
13364 );
13365 }
13366 }
13367
13368 #[tokio::test]
13371 async fn renaming_a_folder_that_no_longer_exists_writes_nothing() {
13372 for backend in RACE_BACKENDS {
13373 let mut fake = folder_repo(folder_seed());
13374 fake.missing = true;
13375 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13376 let state = race_state(backend, &repo).await;
13377
13378 let loc = post_folder_rename(&state, "name=New+name").await;
13379
13380 let repo = repo.lock().unwrap();
13381 assert!(
13382 loc.contains("no%20longer%20exists"),
13383 "{backend:?}: expected the missing-folder flash, got {loc}"
13384 );
13385 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13386 }
13387 }
13388
13389 #[tokio::test]
13392 async fn a_folder_rename_whose_read_fails_writes_nothing() {
13393 for backend in RACE_BACKENDS {
13394 let mut fake = folder_repo(folder_seed());
13395 fake.fail_list = true;
13396 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13397 let state = race_state(backend, &repo).await;
13398
13399 let loc = post_folder_rename(&state, "name=New+name").await;
13400
13401 let repo = repo.lock().unwrap();
13402 assert!(
13403 loc.contains("Could%20not%20reach"),
13404 "{backend:?}: expected the read-failed flash, got {loc}"
13405 );
13406 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13407 }
13408 }
13409
13410 #[tokio::test]
13415 async fn a_rename_elsewhere_after_page_load_is_a_conflict_with_seen_name() {
13416 for backend in RACE_BACKENDS {
13417 let theirs = renamed(folder_seed(), "Their name");
13418 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13419 let state = race_state(backend, &repo).await;
13420
13421 let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
13422
13423 let repo = repo.lock().unwrap();
13424 assert!(
13425 loc.contains("changed%20elsewhere"),
13426 "{backend:?}: expected the conflict flash, got {loc}"
13427 );
13428 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13429 assert_eq!(repo.value, theirs, "{backend:?}");
13430 }
13431 }
13432
13433 #[tokio::test]
13436 async fn an_unchanged_folder_name_writes_nothing() {
13437 for backend in RACE_BACKENDS {
13438 let theirs = renamed(folder_seed(), "Their name");
13439 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13440 let state = race_state(backend, &repo).await;
13441
13442 let loc = post_folder_rename(&state, "name=Old+name&seen_name=Old+name").await;
13443
13444 let repo = repo.lock().unwrap();
13445 assert_eq!(loc, "/", "{backend:?}");
13446 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13447 assert_eq!(repo.value, theirs, "{backend:?}");
13448 }
13449 }
13450
13451 #[test]
13453 fn merge_folder_rename_is_a_three_way_merge_on_the_name() {
13454 let base = Folder::new("Old", "2024-01-01T00:00:00.000Z");
13455 let with = |name: &str| {
13456 let mut f = base.clone();
13457 f.name = name.to_string();
13458 f.position = Some(3);
13459 f.extra
13460 .insert("color".to_string(), serde_json::json!("#abc"));
13461 f
13462 };
13463 assert_eq!(
13465 merge_folder_rename("Old", Some("Old"), &base, with("Other")),
13466 Ok(FolderMerge::Unchanged)
13467 );
13468 assert_eq!(
13470 merge_folder_rename("New", Some("Old"), &base, with("Old")),
13471 Ok(FolderMerge::Write(with("New")))
13472 );
13473 assert_eq!(
13475 merge_folder_rename("New", Some("Old"), &base, with("New")),
13476 Ok(FolderMerge::AlreadySaved)
13477 );
13478 assert_eq!(
13480 merge_folder_rename("New", Some("Old"), &base, with("Other")),
13481 Err(RenameConflict("name"))
13482 );
13483 assert_eq!(
13485 merge_folder_rename("New", None, &with("Other"), with("Other")),
13486 Ok(FolderMerge::Write(with("New")))
13487 );
13488 assert_eq!(
13489 merge_folder_rename("New", None, &base, with("Other")),
13490 Err(RenameConflict("name"))
13491 );
13492 assert_eq!(
13494 merge_folder_rename(" Old ", Some("Old "), &base, with("Other")),
13495 Ok(FolderMerge::Unchanged)
13496 );
13497 assert_eq!(
13498 merge_folder_rename("New ", Some("Old"), &base, with(" Old ")),
13499 Ok(FolderMerge::Write(with("New")))
13500 );
13501 }
13502
13503 #[test]
13508 fn the_same_change_on_both_sides_is_not_a_conflict() {
13509 let base = merge_base();
13510 let url = "https://a.example/feed.xml";
13511 let new_url = "https://c.example/feed.xml";
13512
13513 let mut fresh = base.clone();
13515 fresh.title = Some("New".to_string());
13516 let merged = merge_rename(&merge_form(url, "New", Some("at://f/old")), &base, fresh)
13517 .expect("same title is no conflict");
13518 assert!(merged.already_saved, "nothing left to write");
13519
13520 let mut fresh = base.clone();
13522 fresh.folder = Some("at://f/new".to_string());
13523 let merged = merge_rename(&merge_form(url, "Mine", Some("at://f/new")), &base, fresh)
13524 .expect("same folder is no conflict");
13525 assert!(!merged.already_saved, "the title is still to write");
13526 assert_eq!(merged.sub.title.as_deref(), Some("Mine"));
13527 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/new"));
13528
13529 let mut fresh = base.clone();
13532 fresh.url = new_url.to_string();
13533 fresh.site_url = Some("https://c.example/".to_string());
13534 let merged = merge_rename(
13535 &merge_form(new_url, "Old", Some("at://f/old")),
13536 &base,
13537 fresh,
13538 )
13539 .expect("same url is no conflict");
13540 assert!(merged.already_saved);
13541 assert!(!merged.repoint);
13542 assert_eq!(merged.sub.site_url.as_deref(), Some("https://c.example/"));
13543
13544 let mut fresh = base.clone();
13546 fresh.site_url = Some("https://same.example/".to_string());
13547 let mut form = merge_form(url, "Old", Some("at://f/old"));
13548 form.site_url = Some("https://same.example/".to_string());
13549 let merged = merge_rename(&form, &base, fresh).expect("same siteUrl is no conflict");
13550 assert!(merged.already_saved);
13551
13552 let merged = merge_rename(
13555 &merge_form(url, "Old", Some("at://f/old")),
13556 &base,
13557 base.clone(),
13558 )
13559 .unwrap();
13560 assert!(!merged.already_saved);
13561 }
13562
13563 fn merge_form(url: &str, title: &str, folder: Option<&str>) -> RenameSubForm {
13564 RenameSubForm {
13565 url: url.to_string(),
13566 title: Some(title.to_string()),
13567 site_url: None,
13568 folder: folder.map(str::to_string),
13569 seen_url: None,
13570 seen_title: None,
13571 seen_folder: None,
13572 }
13573 }
13574
13575 fn merge_base() -> Subscription {
13576 let mut s = Subscription::new("https://a.example/feed.xml", "2024-03-01T00:00:00.000Z");
13577 s.title = Some("Old".to_string());
13578 s.folder = Some("at://f/old".to_string());
13579 s.site_url = Some("https://a.example/".to_string());
13580 s
13581 }
13582
13583 #[test]
13587 fn merge_rename_is_a_three_way_merge_per_field() {
13588 let base = merge_base();
13589 let url = "https://a.example/feed.xml";
13590
13591 let mut theirs = base.clone();
13593 theirs.folder = Some("at://f/theirs".to_string());
13594 assert_eq!(
13595 merge_rename(
13596 &merge_form(url, "Old", Some("at://f/mine")),
13597 &base,
13598 theirs.clone()
13599 ),
13600 Err(RenameConflict("folder"))
13601 );
13602 let merged = merge_rename(
13603 &merge_form(url, "Old", Some("at://f/mine")),
13604 &base,
13605 base.clone(),
13606 )
13607 .unwrap();
13608 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/mine"));
13609 assert!(!merged.repoint);
13610 let merged =
13612 merge_rename(&merge_form(url, "Old", Some("at://f/old")), &base, theirs).unwrap();
13613 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/theirs"));
13614
13615 let mut moved = base.clone();
13617 moved.url = "https://b.example/feed.xml".to_string();
13618 assert_eq!(
13619 merge_rename(
13620 &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13621 &base,
13622 moved
13623 ),
13624 Err(RenameConflict("url"))
13625 );
13626
13627 let mut resited = base.clone();
13629 resited.site_url = Some("https://theirs.example/".to_string());
13630 let mut form = merge_form(url, "Old", Some("at://f/old"));
13631 form.site_url = Some("https://mine.example/".to_string());
13632 assert_eq!(
13633 merge_rename(&form, &base, resited),
13634 Err(RenameConflict("siteUrl"))
13635 );
13636
13637 let merged = merge_rename(
13639 &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13640 &base,
13641 base.clone(),
13642 )
13643 .unwrap();
13644 assert!(merged.repoint);
13645 assert_eq!(merged.sub.url, "https://c.example/feed.xml");
13646 assert_eq!(merged.sub.site_url, None);
13647
13648 let mut untitled = base.clone();
13651 untitled.title = None;
13652 let mut form = merge_form(url, "A display fallback", Some("at://f/old"));
13653 form.seen_title = Some("A display fallback".to_string());
13654 let merged = merge_rename(&form, &untitled, untitled.clone()).unwrap();
13655 assert_eq!(
13656 merged.sub.title, None,
13657 "an untouched display title was written"
13658 );
13659 }
13660
13661 #[tokio::test]
13683 async fn renaming_preserves_the_fields_the_form_never_carries() {
13684 let did = "did:plc:renamer4";
13685 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13686 let state = test_state_with_sidecar(&[did], &sidecar).await;
13687 let cookie = session_cookie(&state, did, None);
13688
13689 let resp = router(state.clone())
13690 .oneshot(
13691 Request::builder()
13692 .method("POST")
13693 .uri("/subscriptions/rk-keep/rename")
13694 .header(header::COOKIE, cookie)
13695 .header("content-type", "application/x-www-form-urlencoded")
13696 .body(Body::from(
13698 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
13699 ))
13700 .unwrap(),
13701 )
13702 .await
13703 .unwrap();
13704 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13705
13706 let bodies = puts.lock().unwrap().clone();
13707 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13708 let body = &bodies[0];
13709 assert!(
13711 body.contains("community.lexicon.rss.subscription"),
13712 "captured no usable put body: {body:?}"
13713 );
13714
13715 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
13716 let record = &sent["record"];
13717
13718 assert_eq!(record["title"], "New title", "the rename did not apply");
13720 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
13721
13722 assert_eq!(
13724 record["createdAt"], "2024-03-01T00:00:00.000Z",
13725 "the rename reset createdAt — the reader's subscribe time is gone \
13726 from their own repo, and nothing told them"
13727 );
13728 assert_eq!(
13729 record["siteUrl"], "https://example.com/blog",
13730 "the rename erased siteUrl"
13731 );
13732 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
13733 assert_eq!(record["private"], false, "the rename erased private");
13734 }
13735
13736 #[tokio::test]
13745 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
13746 let did = "did:plc:renamer4";
13747 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13748 let state = test_state_with_sidecar(&[did], &sidecar).await;
13749 let cookie = session_cookie(&state, did, None);
13750
13751 let resp = router(state.clone())
13752 .oneshot(
13753 Request::builder()
13754 .method("POST")
13755 .uri("/subscriptions/rk-keep/rename")
13756 .header(header::COOKIE, cookie)
13757 .header("content-type", "application/x-www-form-urlencoded")
13758 .body(Body::from(
13760 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
13761 ))
13762 .unwrap(),
13763 )
13764 .await
13765 .unwrap();
13766 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13767
13768 let bodies = puts.lock().unwrap().clone();
13769 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13770 assert!(
13771 bodies[0].contains("community.lexicon.rss.subscription"),
13772 "captured no usable put body: {:?}",
13773 bodies[0]
13774 );
13775 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13776 let record = &sent["record"];
13777
13778 assert_eq!(record["url"], "https://other.example/feed.xml");
13779 assert!(
13781 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
13782 "the old feed's site link followed the subscription to a new feed: {record}"
13783 );
13784 assert!(
13785 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
13786 "the old feed's fetch hint followed the subscription to a new feed: {record}"
13787 );
13788 assert_eq!(
13790 record["createdAt"], "2024-03-01T00:00:00.000Z",
13791 "a repoint is still not a new subscription; createdAt must not move"
13792 );
13793 assert_eq!(record["private"], false, "the repoint erased private");
13794 }
13795
13796 #[tokio::test]
13808 async fn renaming_an_unknown_rkey_writes_nothing() {
13809 let did = "did:plc:renamer4";
13810 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13812 let state = test_state_with_sidecar(&[did], &sidecar).await;
13813 let cookie = session_cookie(&state, did, None);
13814
13815 let resp = router(state.clone())
13816 .oneshot(
13817 Request::builder()
13818 .method("POST")
13819 .uri("/subscriptions/rk-does-not-exist/rename")
13821 .header(header::COOKIE, cookie)
13822 .header("content-type", "application/x-www-form-urlencoded")
13823 .body(Body::from(
13824 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
13825 ))
13826 .unwrap(),
13827 )
13828 .await
13829 .unwrap();
13830
13831 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13832 let loc = resp
13833 .headers()
13834 .get(header::LOCATION)
13835 .unwrap()
13836 .to_str()
13837 .unwrap();
13838 assert!(
13839 loc.contains("flash="),
13840 "an unknown rkey redirected as though the rename had worked: {loc}"
13841 );
13842 assert!(
13843 puts.lock().unwrap().is_empty(),
13844 "a rename against an unknown rkey wrote a record — putRecord would \
13845 CREATE it, dated today: {:?}",
13846 puts.lock().unwrap()
13847 );
13848 }
13849
13850 #[tokio::test]
13862 async fn a_client_supplied_site_url_reaches_the_record() {
13863 let did = "did:plc:renamer4";
13864 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13865 let state = test_state_with_sidecar(&[did], &sidecar).await;
13866 let cookie = session_cookie(&state, did, None);
13867
13868 let resp = router(state.clone())
13869 .oneshot(
13870 Request::builder()
13871 .method("POST")
13872 .uri("/subscriptions/rk-keep/rename")
13873 .header(header::COOKIE, cookie)
13874 .header("content-type", "application/x-www-form-urlencoded")
13875 .body(Body::from(
13878 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
13879 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
13880 ))
13881 .unwrap(),
13882 )
13883 .await
13884 .unwrap();
13885 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13886
13887 let bodies = puts.lock().unwrap().clone();
13888 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13889 assert!(
13890 bodies[0].contains("community.lexicon.rss.subscription"),
13891 "captured no usable put body: {:?}",
13892 bodies[0]
13893 );
13894 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13895 assert_eq!(
13896 sent["record"]["siteUrl"], "https://typed.example/site",
13897 "the client's siteUrl was dropped; the seeded record's survived instead"
13898 );
13899 }
13900
13901 #[tokio::test]
13909 async fn a_rename_whose_read_fails_writes_nothing() {
13910 let did = "did:plc:renamer5";
13911 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13913 let dead = format!("http://{}", listener.local_addr().unwrap());
13914 drop(listener);
13915
13916 let state = test_state_with_sidecar(&[did], &dead).await;
13917 let cookie = session_cookie(&state, did, None);
13918 let before = store::count_feeds(&state.db).await.unwrap();
13919
13920 let resp = router(state.clone())
13921 .oneshot(
13922 Request::builder()
13923 .method("POST")
13924 .uri("/subscriptions/rk-keep/rename")
13925 .header(header::COOKIE, cookie)
13926 .header("content-type", "application/x-www-form-urlencoded")
13927 .body(Body::from(
13928 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
13929 ))
13930 .unwrap(),
13931 )
13932 .await
13933 .unwrap();
13934
13935 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13936 let loc = resp
13937 .headers()
13938 .get(header::LOCATION)
13939 .unwrap()
13940 .to_str()
13941 .unwrap();
13942 assert!(
13943 loc.contains("flash="),
13944 "a failed read redirected as though the rename had worked: {loc}"
13945 );
13946 assert_eq!(
13947 store::count_feeds(&state.db).await.unwrap(),
13948 before,
13949 "a rename that could not read the record still wrote to the cache"
13950 );
13951 }
13952
13953 #[test]
13959 fn manage_rename_row_preselects_current_folder() {
13960 let nav = Nav {
13961 handle: "@reader.example".to_string(),
13962 avatar: "RE".to_string(),
13963 view: "unread".to_string(),
13964 scope_qs: String::new(),
13965 folders: Vec::new(),
13966 loose_feeds: Vec::new(),
13967 manage_active: true,
13968 };
13969 let folder_options = vec![
13970 FolderOption {
13971 uri: "at://did:plc:x/app.folder/work".to_string(),
13972 name: "Work".to_string(),
13973 },
13974 FolderOption {
13975 uri: "at://did:plc:x/app.folder/fun".to_string(),
13976 name: "Fun".to_string(),
13977 },
13978 ];
13979 let foldered = FeedView {
13982 rkey: "sub-foldered".to_string(),
13983 url: "https://work.example/feed.xml".to_string(),
13984 title: "Work Feed".to_string(),
13985 unread: 0,
13986 selected: false,
13987 folder: Some("at://did:plc:x/app.folder/work".to_string()),
13988 };
13989 let loose = FeedView {
13990 rkey: "sub-loose".to_string(),
13991 url: "https://loose.example/feed.xml".to_string(),
13992 title: "Loose Feed".to_string(),
13993 unread: 0,
13994 selected: false,
13995 folder: None,
13996 };
13997 let tmpl = ManageTemplate {
13998 card: Card::private(&Config::default()),
13999 version: VERSION,
14000 repo_url: REPO_URL,
14001 kofi_url: KOFI_URL,
14002 flash: String::new(),
14003 alert: String::new(),
14004 nav,
14005 folder_options,
14006 folders: vec![FolderView {
14007 rkey: "folder-work".to_string(),
14008 uri: "at://did:plc:x/app.folder/work".to_string(),
14009 name: "Work".to_string(),
14010 feeds: vec![foldered],
14011 selected: false,
14012 }],
14013 loose_feeds: vec![loose],
14014 standard_site: false,
14015 };
14016 let html = tmpl.render().unwrap();
14017
14018 assert!(
14020 html.contains(
14021 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
14022 ),
14023 "foldered feed must pre-select its current folder: {html}"
14024 );
14025 assert!(
14028 html.contains(r#"<option value="" selected>No folder</option>"#),
14029 "loose feed must pre-select 'No folder': {html}"
14030 );
14031
14032 for want in [
14035 r#"<input type="hidden" name="seen_url" value="https://work.example/feed.xml" />"#,
14036 r#"<input type="hidden" name="seen_title" value="Work Feed" />"#,
14037 r#"<input type="hidden" name="seen_folder" value="at://did:plc:x/app.folder/work" />"#,
14038 r#"<input type="hidden" name="seen_folder" value="" />"#,
14039 r#"<input type="hidden" name="seen_name" value="Work" />"#,
14041 ] {
14042 assert!(html.contains(want), "missing {want}: {html}");
14043 }
14044 }
14045
14046 #[tokio::test]
14052 async fn the_public_stats_page_exposes_no_user_data() {
14053 let state = test_state(&[]).await;
14054 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
14055 .await
14056 .unwrap();
14057
14058 let resp = router(state)
14059 .oneshot(
14060 Request::builder()
14061 .uri("/stats")
14062 .body(Body::empty())
14063 .unwrap(),
14064 )
14065 .await
14066 .unwrap();
14067 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
14068
14069 let body = String::from_utf8(
14070 axum::body::to_bytes(resp.into_body(), usize::MAX)
14071 .await
14072 .unwrap()
14073 .to_vec(),
14074 )
14075 .unwrap();
14076
14077 assert!(
14083 !body.contains("did:"),
14084 "the public stats page leaked an identifier"
14085 );
14086 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
14087 assert!(
14088 !body.contains(admin_only),
14089 "the public page is showing the admin metrics column {admin_only:?}"
14090 );
14091 }
14092 assert!(body.contains("Feeds tracked"));
14094 assert!(body.contains("Waiting to be polled"));
14095 }
14096
14097 #[tokio::test]
14105 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
14106 let state = test_state(&[]).await;
14107 for (url, errors) in [
14109 ("https://ok.example/f.xml", 0),
14110 ("https://flaky.example/f.xml", 2),
14111 ("https://dead.example/f.xml", 9),
14112 ] {
14113 store::upsert_feed(
14114 &state.db,
14115 &store::NewFeed {
14116 url: url.to_string(),
14117 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14120 ..Default::default()
14121 },
14122 )
14123 .await
14124 .unwrap();
14125 for _ in 0..errors {
14126 store::bump_feed_errors(
14127 &state.db,
14128 url,
14129 feed::FailureKind::Fetch,
14130 "connection refused",
14131 )
14132 .await
14133 .unwrap();
14134 }
14135 }
14136
14137 let render_stats = |state: AppState| async move {
14138 let resp = router(state)
14139 .oneshot(
14140 Request::builder()
14141 .uri("/stats")
14142 .body(Body::empty())
14143 .unwrap(),
14144 )
14145 .await
14146 .unwrap();
14147 assert_eq!(resp.status(), StatusCode::OK);
14148 String::from_utf8(
14149 axum::body::to_bytes(resp.into_body(), usize::MAX)
14150 .await
14151 .unwrap()
14152 .to_vec(),
14153 )
14154 .unwrap()
14155 };
14156
14157 state.runtime_health.set_schedulers_enabled(true);
14164 state
14165 .runtime_health
14166 .poll_tick_completed(crate::store::now_unix());
14167
14168 let body = render_stats(state.clone()).await;
14169 assert!(
14170 body.contains("Failing"),
14171 "backoff is still invisible on the public page"
14172 );
14173 assert!(
14177 body.contains("2, 1 badly"),
14178 "expected '2, 1 badly' in the failing row; got:\n{}",
14179 body.split("Failing")
14180 .nth(1)
14181 .unwrap_or("")
14182 .chars()
14183 .take(300)
14184 .collect::<String>()
14185 );
14186 assert!(
14194 !body.contains("the poller is not running")
14195 && !body.contains("the cache is at its size limit")
14196 && !body.contains("has not completed a round"),
14197 "expected the running state; the page reported a stopped one",
14198 );
14199
14200 state.runtime_health.set_watermark(true);
14204 let paused = render_stats(state.clone()).await;
14205 assert!(
14210 paused.contains("the cache is at its size limit"),
14211 "a watermark pause is still invisible on the public page"
14212 );
14213
14214 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
14216 assert!(
14217 !paused.contains(leak),
14218 "the public page leaked {leak:?} while reporting failures"
14219 );
14220 }
14221 }
14222
14223 #[tokio::test]
14235 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
14236 let admin = "did:plc:adminseed";
14237 let state = test_state(&[admin]).await;
14246 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
14247 .await
14248 .unwrap();
14249 let url = "https://broken.example/f.xml";
14250 store::upsert_feed(
14251 &state.db,
14252 &store::NewFeed {
14253 url: url.to_string(),
14254 ..Default::default()
14255 },
14256 )
14257 .await
14258 .unwrap();
14259 store::bump_feed_errors(
14260 &state.db,
14261 url,
14262 feed::FailureKind::Fetch,
14263 "SENTINEL_ADMIN_ONLY",
14264 )
14265 .await
14266 .unwrap();
14267
14268 let get = |state: AppState, cookie: Option<String>| async move {
14269 let mut req = Request::builder().uri("/admin/metrics");
14270 if let Some(c) = cookie {
14271 req = req.header(header::COOKIE, c);
14272 }
14273 let resp = router(state)
14274 .oneshot(req.body(Body::empty()).unwrap())
14275 .await
14276 .unwrap();
14277 let status = resp.status();
14278 let body = String::from_utf8(
14279 axum::body::to_bytes(resp.into_body(), usize::MAX)
14280 .await
14281 .unwrap()
14282 .to_vec(),
14283 )
14284 .unwrap();
14285 (status, body)
14286 };
14287
14288 let (status, body) = get(state.clone(), None).await;
14290 assert_eq!(status, StatusCode::UNAUTHORIZED);
14291 assert!(
14292 !body.contains("SENTINEL_ADMIN_ONLY"),
14293 "leaked to anonymous: {body}"
14294 );
14295
14296 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
14298 let (status, body) = get(state.clone(), Some(ordinary)).await;
14299 assert_eq!(
14300 status,
14301 StatusCode::FORBIDDEN,
14302 "a non-admin session was let in"
14303 );
14304 assert!(
14305 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
14306 "leaked to a non-admin: {body}",
14307 );
14308
14309 let admin_cookie = session_cookie(&state, admin, None);
14312 let (status, body) = get(state, Some(admin_cookie)).await;
14313 assert_eq!(status, StatusCode::OK);
14314 assert!(
14315 body.contains("SENTINEL_ADMIN_ONLY"),
14316 "admin cannot see it: {body}"
14317 );
14318 }
14319
14320 #[tokio::test]
14337 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
14338 let admin = "did:plc:adminseed";
14339 let state = test_state(&[admin]).await;
14340 let url = "https://broken.example/f.xml";
14341 store::upsert_feed(
14342 &state.db,
14343 &store::NewFeed {
14344 url: url.to_string(),
14345 ..Default::default()
14346 },
14347 )
14348 .await
14349 .unwrap();
14350 store::bump_feed_errors(
14351 &state.db,
14352 url,
14353 feed::FailureKind::Fetch,
14354 "SENTINEL_REDIRECT_NO_LOCATION",
14355 )
14356 .await
14357 .unwrap();
14358
14359 let cookie = session_cookie(&state, admin, None);
14360 let resp = router(state.clone())
14361 .oneshot(
14362 Request::builder()
14363 .uri("/admin/metrics")
14364 .header(header::COOKIE, cookie)
14365 .body(Body::empty())
14366 .unwrap(),
14367 )
14368 .await
14369 .unwrap();
14370 assert_eq!(resp.status(), StatusCode::OK);
14371 let admin_body = String::from_utf8(
14372 axum::body::to_bytes(resp.into_body(), usize::MAX)
14373 .await
14374 .unwrap()
14375 .to_vec(),
14376 )
14377 .unwrap();
14378 assert!(
14379 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
14380 "the admin page does not carry the failure detail: {admin_body}",
14381 );
14382 assert!(
14383 admin_body.contains("broken.example"),
14384 "the admin page does not name the failing feed: {admin_body}",
14385 );
14386
14387 let resp = router(state)
14389 .oneshot(
14390 Request::builder()
14391 .uri("/stats")
14392 .body(Body::empty())
14393 .unwrap(),
14394 )
14395 .await
14396 .unwrap();
14397 let public = String::from_utf8(
14398 axum::body::to_bytes(resp.into_body(), usize::MAX)
14399 .await
14400 .unwrap()
14401 .to_vec(),
14402 )
14403 .unwrap();
14404 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
14405 assert!(
14406 !public.contains(secret),
14407 "{secret:?} reached the PUBLIC stats page: {public}",
14408 );
14409 }
14410 }
14411
14412 #[tokio::test]
14425 async fn a_successful_direct_poll_clears_a_stale_failure() {
14426 let state = test_state(&[]).await;
14427 let url = "https://recovered.example/f.xml";
14428 store::upsert_feed(
14429 &state.db,
14430 &store::NewFeed {
14431 url: url.to_string(),
14432 ..Default::default()
14433 },
14434 )
14435 .await
14436 .unwrap();
14437 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
14438 .await
14439 .unwrap();
14440 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
14442 .bind(url)
14443 .execute(&state.db)
14444 .await
14445 .unwrap();
14446
14447 feed::settle_poll(
14449 &state.db,
14450 url,
14451 &feed::PollOutcome::NotModified,
14452 state.config.poll_interval,
14453 )
14454 .await;
14455
14456 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14457 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14458 )
14459 .bind(url)
14460 .fetch_one(&state.db)
14461 .await
14462 .unwrap();
14463 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
14464 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
14465 let next = row.2.expect("next_poll was cleared to NULL");
14469 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14473 let delta = parsed
14474 .signed_duration_since(chrono::Utc::now())
14475 .num_seconds();
14476 let cadence = state.config.poll_interval.as_secs() as i64;
14477 assert!(
14478 (cadence - 60..=cadence + 60).contains(&delta),
14479 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
14480 );
14481 }
14482
14483 #[tokio::test]
14489 async fn a_failing_direct_poll_is_recorded() {
14490 let state = test_state(&[]).await;
14491 let url = "https://born-broken.example/f.xml";
14492 store::upsert_feed(
14493 &state.db,
14494 &store::NewFeed {
14495 url: url.to_string(),
14496 ..Default::default()
14497 },
14498 )
14499 .await
14500 .unwrap();
14501
14502 feed::settle_poll(
14503 &state.db,
14504 url,
14505 &feed::PollOutcome::Failed {
14506 backoff: std::time::Duration::from_secs(300),
14507 kind: feed::FailureKind::Parse,
14508 detail: "SENTINEL_BORN_BROKEN".to_string(),
14509 },
14510 state.config.poll_interval,
14511 )
14512 .await;
14513
14514 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14515 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14516 )
14517 .bind(url)
14518 .fetch_one(&state.db)
14519 .await
14520 .unwrap();
14521 assert_eq!(row.0, 1, "a failed first poll was not counted");
14522 assert_eq!(
14523 row.1.as_deref(),
14524 Some("parse"),
14525 "its cause was not recorded"
14526 );
14527 let next = row.2.expect("a failed direct poll left next_poll NULL");
14531 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14532 let delta = parsed
14533 .signed_duration_since(chrono::Utc::now())
14534 .num_seconds();
14535 assert!(
14536 (240..=360).contains(&delta),
14537 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
14538 );
14539 }
14540
14541 #[tokio::test]
14553 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
14554 let state = test_state(&[]).await;
14555 for url in [
14557 "https://legacy1.example/f.xml",
14558 "https://legacy2.example/f.xml",
14559 ] {
14560 store::upsert_feed(
14561 &state.db,
14562 &store::NewFeed {
14563 url: url.to_string(),
14564 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14565 ..Default::default()
14566 },
14567 )
14568 .await
14569 .unwrap();
14570 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
14571 .bind(url)
14572 .execute(&state.db)
14573 .await
14574 .unwrap();
14575 }
14576 store::upsert_feed(
14578 &state.db,
14579 &store::NewFeed {
14580 url: "https://known.example/f.xml".to_string(),
14581 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14582 ..Default::default()
14583 },
14584 )
14585 .await
14586 .unwrap();
14587 store::bump_feed_errors(
14588 &state.db,
14589 "https://known.example/f.xml",
14590 feed::FailureKind::Status,
14591 "SENTINEL",
14592 )
14593 .await
14594 .unwrap();
14595
14596 let now = chrono::Utc::now();
14597 let health = store::poll_health(
14598 &state.db,
14599 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14600 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14601 )
14602 .await
14603 .unwrap();
14604 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
14605 assert_eq!(
14606 counted, health.in_backoff,
14607 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
14608 health.in_backoff, health.failure_kinds,
14609 );
14610 assert!(
14611 health
14612 .failure_kinds
14613 .iter()
14614 .any(|(k, n)| k == "unknown" && *n == 2),
14615 "no unknown bucket for the legacy rows: {:?}",
14616 health.failure_kinds,
14617 );
14618 }
14619
14620 #[tokio::test]
14625 async fn the_failure_breakdown_is_ordered_by_count() {
14626 let state = test_state(&[]).await;
14627 for (url, kind, n) in [
14628 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
14629 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
14630 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
14631 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
14632 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
14633 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
14634 ] {
14635 store::upsert_feed(
14636 &state.db,
14637 &store::NewFeed {
14638 url: url.to_string(),
14639 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14640 ..Default::default()
14641 },
14642 )
14643 .await
14644 .unwrap();
14645 for _ in 0..n {
14646 store::bump_feed_errors(&state.db, url, kind, "d")
14647 .await
14648 .unwrap();
14649 }
14650 }
14651 let now = chrono::Utc::now();
14652 let health = store::poll_health(
14653 &state.db,
14654 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14655 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14656 )
14657 .await
14658 .unwrap();
14659 let labels: Vec<&str> = health
14660 .failure_kinds
14661 .iter()
14662 .map(|(k, _)| k.as_str())
14663 .collect();
14664 assert_eq!(
14665 labels,
14666 ["fetch", "status", "parse"],
14667 "not ordered by count, descending: {:?}",
14668 health.failure_kinds,
14669 );
14670 }
14671
14672 #[tokio::test]
14684 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
14685 let state = test_state(&[]).await;
14686 for (url, kind, detail, errors) in [
14687 (
14693 "https://a.example/f.xml",
14694 feed::FailureKind::Fetch,
14695 "SENTINEL_CONNREFUSED",
14696 3,
14697 ),
14698 (
14699 "https://b.example/f.xml",
14700 feed::FailureKind::Fetch,
14701 "SENTINEL_DNSFAIL",
14702 2,
14703 ),
14704 (
14705 "https://c.example/f.xml",
14706 feed::FailureKind::Status,
14707 "SENTINEL_404",
14708 1,
14709 ),
14710 (
14711 "https://d.example/f.xml",
14712 feed::FailureKind::Parse,
14713 "SENTINEL_UNPARSEABLE",
14714 1,
14715 ),
14716 ] {
14717 store::upsert_feed(
14718 &state.db,
14719 &store::NewFeed {
14720 url: url.to_string(),
14721 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14722 ..Default::default()
14723 },
14724 )
14725 .await
14726 .unwrap();
14727 for _ in 0..errors {
14728 store::bump_feed_errors(&state.db, url, kind, detail)
14729 .await
14730 .unwrap();
14731 }
14732 }
14733
14734 let resp = router(state.clone())
14735 .oneshot(
14736 Request::builder()
14737 .uri("/stats")
14738 .body(Body::empty())
14739 .unwrap(),
14740 )
14741 .await
14742 .unwrap();
14743 assert_eq!(resp.status(), StatusCode::OK);
14744 let body = String::from_utf8(
14745 axum::body::to_bytes(resp.into_body(), usize::MAX)
14746 .await
14747 .unwrap()
14748 .to_vec(),
14749 )
14750 .unwrap();
14751
14752 assert!(
14754 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
14755 "the cause histogram did not render: {body}",
14756 );
14757
14758 for secret in [
14761 "a.example",
14762 "b.example",
14763 "c.example",
14764 "d.example",
14765 "SENTINEL_CONNREFUSED",
14766 "SENTINEL_DNSFAIL",
14767 "SENTINEL_404",
14768 "SENTINEL_UNPARSEABLE",
14769 ] {
14770 assert!(
14771 !body.contains(secret),
14772 "{secret:?} reached the PUBLIC stats page: {body}",
14773 );
14774 }
14775 }
14776
14777 #[tokio::test]
14780 async fn health_checks_the_database_and_reports_the_loops() {
14781 let state = test_state(&[]).await;
14782 let body_of = |state: AppState| async move {
14783 let resp = router(state)
14784 .oneshot(
14785 Request::builder()
14786 .uri("/health")
14787 .body(Body::empty())
14788 .unwrap(),
14789 )
14790 .await
14791 .unwrap();
14792 let status = resp.status();
14793 let body = String::from_utf8(
14794 axum::body::to_bytes(resp.into_body(), usize::MAX)
14795 .await
14796 .unwrap()
14797 .to_vec(),
14798 )
14799 .unwrap();
14800 (status, body)
14801 };
14802
14803 state
14806 .runtime_health
14807 .set_started_at(chrono::Utc::now().timestamp());
14808
14809 let (status, body) = body_of(state.clone()).await;
14810 assert_eq!(status, StatusCode::OK);
14811 assert!(
14812 body.contains("db: ok"),
14813 "health did not probe the DB: {body}"
14814 );
14815 assert!(
14816 body.contains("uptime:"),
14817 "no uptime — the first thing anyone asks about a container that may \
14818 be restarting: {body}"
14819 );
14820 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
14821 assert!(body.contains("polling-paused: no"), "{body}");
14822 assert!(body.contains("backend:"), "{body}");
14823 assert!(body.contains("oauth-runtime:"), "{body}");
14824
14825 state.runtime_health.set_watermark(true);
14830 state.runtime_health.set_schedulers_enabled(true);
14831 let (status, body) = body_of(state.clone()).await;
14832 assert_eq!(
14833 status,
14834 StatusCode::OK,
14835 "a watermark pause must not fail the liveness check: {body}"
14836 );
14837 assert!(body.contains("polling-paused: yes"), "{body}");
14838 assert!(
14841 body.contains("poller: not-yet-ticked"),
14842 "a never-ticked poller must say so: {body}"
14843 );
14844
14845 let stale_after = health_tick_stale_secs(configured_poll_tick());
14847 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
14848 state.runtime_health.poll_tick_completed(long_ago);
14849 let (status, body) = body_of(state.clone()).await;
14850 assert_eq!(
14851 status,
14852 StatusCode::OK,
14853 "a stale poller must not 503: {body}"
14854 );
14855 assert!(body.contains("poller: stale"), "{body}");
14856
14857 state.runtime_health.poll_tick_completed(0); state
14865 .runtime_health
14866 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
14867 let (status, body) = body_of(state.clone()).await;
14868 assert_eq!(status, StatusCode::OK);
14869 assert!(
14870 body.contains("poller: stale never-ticked"),
14871 "a poller that never ticked long after boot still reads as benign: {body}"
14872 );
14873
14874 state.db.close().await;
14877 let (status, body) = body_of(state.clone()).await;
14878 assert_eq!(
14879 status,
14880 StatusCode::SERVICE_UNAVAILABLE,
14881 "an unreachable database must fail the check: {body}"
14882 );
14883 assert!(body.starts_with("FAIL"), "{body}");
14884 assert!(
14888 !body.contains("PoolClosed") && !body.contains("sqlx"),
14889 "health leaked the raw database error to an unauthenticated caller: {body}"
14890 );
14891 }
14892
14893 #[test]
14899 fn the_stale_threshold_follows_the_poll_tick() {
14900 assert_eq!(
14903 health_tick_stale_secs(Duration::from_secs(60)),
14904 HEALTH_TICK_STALE_FLOOR_SECS
14905 );
14906 let slow = Duration::from_secs(30 * 60);
14909 assert!(
14910 health_tick_stale_secs(slow) > slow.as_secs() as i64,
14911 "a 30-minute tick must not be stale after one interval"
14912 );
14913 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
14914 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
14916 }
14917
14918 #[tokio::test]
14924 async fn stats_does_not_call_a_stopped_poller_running() {
14925 let state = test_state(&[]).await;
14926 let render = |state: AppState| async move {
14927 let resp = router(state)
14928 .oneshot(
14929 Request::builder()
14930 .uri("/stats")
14931 .body(Body::empty())
14932 .unwrap(),
14933 )
14934 .await
14935 .unwrap();
14936 assert_eq!(resp.status(), StatusCode::OK);
14937 String::from_utf8(
14938 axum::body::to_bytes(resp.into_body(), usize::MAX)
14939 .await
14940 .unwrap()
14941 .to_vec(),
14942 )
14943 .unwrap()
14944 };
14945
14946 let body = render(state.clone()).await;
14948 assert!(
14949 body.contains("the poller is not running on this instance"),
14950 "a disabled poller renders as healthy"
14951 );
14952
14953 state.runtime_health.set_schedulers_enabled(true);
14955 let body = render(state.clone()).await;
14956 assert!(
14957 body.contains("no poll has finished since this instance booted"),
14958 "a poller that has not ticked renders as healthy"
14959 );
14960
14961 state
14963 .runtime_health
14964 .poll_tick_completed(chrono::Utc::now().timestamp());
14965 let body = render(state.clone()).await;
14966 assert!(
14967 body.contains("running"),
14968 "a healthy poller must read as running"
14969 );
14970
14971 state.runtime_health.set_watermark(true);
14973 let body = render(state.clone()).await;
14974 assert!(
14975 body.contains("the cache is at its size limit"),
14976 "a watermark pause is hidden once the poller is ticking"
14977 );
14978 }
14979
14980 #[tokio::test]
14987 async fn stats_shows_ingest_starved_of_sanitize_permits() {
14988 let mut state = test_state(&[]).await;
14989 let starvation: &'static crate::feed::Starvation =
14990 Box::leak(Box::new(crate::feed::Starvation::new()));
14991 state.sanitize_starvation = starvation;
14992 let render = |state: AppState| async move {
14993 let resp = router(state)
14994 .oneshot(
14995 Request::builder()
14996 .uri("/stats")
14997 .body(Body::empty())
14998 .unwrap(),
14999 )
15000 .await
15001 .unwrap();
15002 assert_eq!(resp.status(), StatusCode::OK);
15003 String::from_utf8(
15004 axum::body::to_bytes(resp.into_body(), usize::MAX)
15005 .await
15006 .unwrap()
15007 .to_vec(),
15008 )
15009 .unwrap()
15010 };
15011
15012 let body = render(state.clone()).await;
15013 assert!(
15014 !body.contains("no sanitize capacity"),
15015 "the row shows on an instance that never deferred"
15016 );
15017
15018 let ten_min_ago = chrono::Utc::now().timestamp() - 600;
15019 starvation.record_no_permit(ten_min_ago);
15020 starvation.record_no_permit(ten_min_ago + 1);
15021 starvation.record_no_permit(ten_min_ago + 2);
15022 let body = render(state.clone()).await;
15023 assert!(body.contains("3 polls deferred since boot"), "{body}");
15024 assert!(
15025 body.contains(
15026 "<strong>stalled</strong> — no feed body has been sanitized since 10m ago"
15027 ),
15028 "a starved instance does not read as stalled"
15029 );
15030
15031 starvation.record_permit();
15032 let body = render(state).await;
15033 assert!(body.contains("3 polls deferred since boot"));
15034 assert!(
15035 !body.contains("<strong>stalled</strong> — no feed body"),
15036 "a permit came free but /stats still reads stalled"
15037 );
15038 }
15039
15040 #[tokio::test]
15051 async fn health_reports_an_unmeasured_database_without_failing() {
15052 use crate::runtime_health::DbProbe;
15053 let state = test_state(&[]).await;
15054
15055 let held = state
15058 .runtime_health
15059 .begin_db_probe()
15060 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
15061
15062 let resp = router(state.clone())
15063 .oneshot(
15064 Request::builder()
15065 .uri("/health")
15066 .body(Body::empty())
15067 .unwrap(),
15068 )
15069 .await
15070 .unwrap();
15071 let status = resp.status();
15072 let body = String::from_utf8(
15073 axum::body::to_bytes(resp.into_body(), usize::MAX)
15074 .await
15075 .unwrap()
15076 .to_vec(),
15077 )
15078 .unwrap();
15079 drop(held);
15080
15081 assert_eq!(
15082 status,
15083 StatusCode::OK,
15084 "an unmeasured database failed the check, which an unauthenticated \
15085 caller can cause on demand: {body}"
15086 );
15087 assert!(
15088 body.contains("db: unknown"),
15089 "the unmeasured state must still be REPORTED: {body}"
15090 );
15091 assert!(!body.starts_with("FAIL"), "{body}");
15092 assert!(
15097 !body.starts_with("ok"),
15098 "the unmeasured state is indistinguishable from healthy to a \
15099 body-matching monitor: {body}"
15100 );
15101 assert!(body.starts_with("unknown"), "{body}");
15102
15103 let held = state
15114 .runtime_health
15115 .begin_db_probe()
15116 .unwrap_or_else(|_| panic!("claim"));
15117 state
15118 .runtime_health
15119 .record_for_test(DbProbe::Failed("unavailable".to_string()));
15120 let resp = router(state.clone())
15121 .oneshot(
15122 Request::builder()
15123 .uri("/health")
15124 .body(Body::empty())
15125 .unwrap(),
15126 )
15127 .await
15128 .unwrap();
15129 let status = resp.status();
15130 let body = String::from_utf8(
15131 axum::body::to_bytes(resp.into_body(), usize::MAX)
15132 .await
15133 .unwrap()
15134 .to_vec(),
15135 )
15136 .unwrap();
15137 drop(held);
15138 assert_eq!(
15139 status,
15140 StatusCode::SERVICE_UNAVAILABLE,
15141 "a BORROWED failure verdict must fail the check, not just a freshly \
15142 measured one: {body}"
15143 );
15144 assert!(body.starts_with("FAIL"), "{body}");
15145
15146 state.db.close().await;
15147 let resp = router(state.clone())
15148 .oneshot(
15149 Request::builder()
15150 .uri("/health")
15151 .body(Body::empty())
15152 .unwrap(),
15153 )
15154 .await
15155 .unwrap();
15156 assert_eq!(
15157 resp.status(),
15158 StatusCode::SERVICE_UNAVAILABLE,
15159 "a measured database failure must still fail the check"
15160 );
15161 }
15162
15163 #[tokio::test]
15172 async fn an_abandoned_request_still_records_its_probe() {
15173 use crate::runtime_health::DbProbe;
15174 let state = test_state(&[]).await;
15175 let rh = state.runtime_health.clone();
15176
15177 let app = router(state.clone());
15179 let fut = app.oneshot(
15180 Request::builder()
15181 .uri("/health")
15182 .body(Body::empty())
15183 .unwrap(),
15184 );
15185 let handle = tokio::spawn(fut);
15186 handle.abort();
15187 let _ = handle.await;
15188
15189 for _ in 0..50 {
15192 if rh.begin_db_probe().is_ok() {
15193 break;
15194 }
15195 tokio::time::sleep(Duration::from_millis(20)).await;
15196 }
15197 let resp = router(state.clone())
15198 .oneshot(
15199 Request::builder()
15200 .uri("/health")
15201 .body(Body::empty())
15202 .unwrap(),
15203 )
15204 .await
15205 .unwrap();
15206 let body = String::from_utf8(
15207 axum::body::to_bytes(resp.into_body(), usize::MAX)
15208 .await
15209 .unwrap()
15210 .to_vec(),
15211 )
15212 .unwrap();
15213 assert!(
15214 body.contains("db: ok"),
15215 "after an abandoned request the next caller still reads an \
15216 unmeasured database — the probe was cancelled with it: {body}"
15217 );
15218 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
15220 }
15221
15222 #[tokio::test]
15229 async fn the_health_probe_opens_a_real_table() {
15230 use sqlx::Row;
15231 let state = test_state(&[]).await;
15232 let opcodes = |sql: &'static str| {
15234 let db = state.db.clone();
15235 async move {
15236 sqlx::query(sql)
15237 .fetch_all(&db)
15238 .await
15239 .unwrap()
15240 .into_iter()
15241 .map(|r| r.get::<String, _>("opcode"))
15242 .collect::<Vec<String>>()
15243 }
15244 };
15245
15246 let explain: &'static str =
15249 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
15250 let probe = opcodes(explain).await;
15251 assert!(
15253 health_db_probe(&state.db).await.is_ok(),
15254 "the probe does not run against the real schema",
15255 );
15256 assert!(
15257 probe.iter().any(|op| op == "OpenRead"),
15258 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
15259 );
15260 let bare = opcodes("EXPLAIN SELECT 1").await;
15262 assert!(
15263 !bare.iter().any(|op| op == "OpenRead"),
15264 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
15265 );
15266 }
15267
15268 #[test]
15271 fn an_instance_that_has_never_polled_says_so() {
15272 assert_eq!(humanise_ago(None), "never");
15273 assert_eq!(humanise_ago(Some(0)), "0s ago");
15274 assert_eq!(humanise_ago(Some(59)), "59s ago");
15275 assert_eq!(humanise_ago(Some(60)), "1m ago");
15276 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
15277 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
15278 }
15279
15280 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
15283 use tokio::io::{AsyncReadExt, AsyncWriteExt};
15284 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15285 let addr = listener.local_addr().unwrap();
15286 let (url, title) = (saved_url.to_string(), saved_title.to_string());
15287 tokio::spawn(async move {
15288 loop {
15289 let Ok((mut sock, _)) = listener.accept().await else {
15290 break;
15291 };
15292 let mut buf = vec![0u8; 8192];
15293 let Ok(n) = sock.read(&mut buf).await else {
15294 continue;
15295 };
15296 let req = String::from_utf8_lossy(&buf[..n]).to_string();
15297 let wants_saved = req.contains("community.lexicon.rss.saved");
15298 let records = if wants_saved {
15299 serde_json::json!([{
15300 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
15301 "cid": "bafy",
15302 "value": {
15303 "$type": "community.lexicon.rss.saved",
15304 "url": url,
15305 "title": title,
15306 "createdAt": "2026-01-01T00:00:00Z"
15307 }
15308 }])
15309 } else {
15310 serde_json::json!([])
15311 };
15312 let body = serde_json::json!({
15313 "ok": true, "data": { "records": records }
15314 })
15315 .to_string();
15316 let resp = format!(
15317 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15318 body.len(), body
15319 );
15320 let _ = sock.write_all(resp.as_bytes()).await;
15321 let _ = sock.flush().await;
15322 }
15323 });
15324 format!("http://{addr}")
15325 }
15326
15327 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
15330 let feed = subscribed_feed.to_string();
15331 use tokio::io::{AsyncReadExt, AsyncWriteExt};
15332 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15333 let addr = listener.local_addr().unwrap();
15334 tokio::spawn(async move {
15335 loop {
15336 let Ok((mut sock, _)) = listener.accept().await else {
15337 break;
15338 };
15339 let mut buf = vec![0u8; 8192];
15340 let Ok(read) = sock.read(&mut buf).await else {
15341 continue;
15342 };
15343 let req = String::from_utf8_lossy(&buf[..read]).to_string();
15344 let records = if req.contains("community.lexicon.rss.saved") {
15345 serde_json::Value::Array(
15346 (0..n)
15347 .map(|i| {
15348 serde_json::json!({
15349 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
15350 "cid": "bafy",
15351 "value": {
15352 "$type": "community.lexicon.rss.saved",
15353 "url": format!("https://elsewhere.example/{i}"),
15354 "title": format!("Elsewhere {i}"),
15355 "createdAt": "2026-01-01T00:00:00Z"
15356 }
15357 })
15358 })
15359 .collect(),
15360 )
15361 } else if req.contains("community.lexicon.rss.subscription") {
15362 serde_json::json!([{
15367 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
15368 "cid": "bafy",
15369 "value": {
15370 "$type": "community.lexicon.rss.subscription",
15371 "url": feed,
15372 "createdAt": "2026-01-01T00:00:00Z"
15373 }
15374 }])
15375 } else {
15376 serde_json::json!([])
15377 };
15378 let body =
15379 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15380 let resp = format!(
15381 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15382 body.len(), body
15383 );
15384 let _ = sock.write_all(resp.as_bytes()).await;
15385 let _ = sock.flush().await;
15386 }
15387 });
15388 format!("http://{addr}")
15389 }
15390
15391 #[tokio::test]
15399 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
15400 let did = "did:plc:pagerloop";
15401 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
15402 let state = test_state_with_sidecar(&[], &sidecar).await;
15403 store::grant_access(&state.db, did, None, "test", None)
15404 .await
15405 .unwrap();
15406 let feed = store::upsert_feed(
15407 &state.db,
15408 &store::NewFeed {
15409 url: "https://loop.example/feed.xml".to_string(),
15410 title: Some("Loop".to_string()),
15411 ..Default::default()
15412 },
15413 )
15414 .await
15415 .unwrap();
15416 let entries: Vec<store::NewEntry> = (0..250)
15419 .map(|i| store::NewEntry {
15420 guid: format!("s-{i:04}"),
15421 url: Some(format!("https://loop.example/{i}")),
15422 title: Some(format!("Starred {i:04}")),
15423 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
15424 ..Default::default()
15425 })
15426 .collect();
15427 store::insert_entries(&state.db, feed, &entries, 0)
15428 .await
15429 .unwrap();
15430 store::replace_sub_refs(&state.db, did, &[feed])
15431 .await
15432 .unwrap();
15433 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
15434 .await
15435 .unwrap()
15436 {
15437 store::mark_starred(&state.db, did, row.id, true)
15438 .await
15439 .unwrap();
15440 }
15441
15442 let cookie = session_cookie(&state, did, None);
15443 let app = router(state.clone());
15444 let get = |uri: &str| {
15445 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
15446 async move {
15447 let resp = app
15448 .oneshot(
15449 Request::builder()
15450 .uri(uri)
15451 .header(header::COOKIE, cookie)
15452 .body(Body::empty())
15453 .unwrap(),
15454 )
15455 .await
15456 .unwrap();
15457 assert_eq!(resp.status(), StatusCode::OK);
15458 String::from_utf8(
15459 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
15460 .await
15461 .unwrap()
15462 .to_vec(),
15463 )
15464 .unwrap()
15465 }
15466 };
15467
15468 let p3 = get("/?view=starred&page=3").await;
15473 assert!(
15474 p3.contains("Page 3 of 4"),
15475 "the pager and the clamp disagree on the total: {}",
15476 p3.split("pager-pos")
15477 .nth(1)
15478 .unwrap_or("")
15479 .chars()
15480 .take(120)
15481 .collect::<String>()
15482 );
15483 assert!(
15486 p3.contains("Elsewhere 0"),
15487 "page 3 should start the uncached run"
15488 );
15489 assert_eq!(
15490 p3.matches("<li class=\"entry").count(),
15491 ENTRIES_PER_PAGE as usize,
15492 "the boundary page is not full"
15493 );
15494
15495 {
15504 let body = &p3;
15505 assert!(
15506 body.contains("330 entries"),
15507 "the heading must count the whole sequence: {}",
15508 body.split("content-count")
15509 .nth(1)
15510 .unwrap_or("")
15511 .chars()
15512 .take(120)
15513 .collect::<String>()
15514 );
15515 assert!(
15516 body.contains("(80 saved elsewhere)"),
15517 "the heading must say how many of the total the cache cannot show, \
15518 as a whole-list figure and not a per-page one: {}",
15519 body.split("content-count")
15520 .nth(1)
15521 .unwrap_or("")
15522 .chars()
15523 .take(120)
15524 .collect::<String>()
15525 );
15526 assert!(
15527 !body.contains("plus 50") && !body.contains("plus 80"),
15528 "the heading is adding the uncached rows to a total that already \
15529 includes them"
15530 );
15531 }
15532
15533 let p4 = get("/?view=starred&page=4").await;
15534 assert!(
15535 p4.contains("Page 4 of 4"),
15536 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
15537 );
15538 assert_eq!(
15539 p4.matches("<li class=\"entry").count(),
15540 30,
15541 "page 4 should hold the remaining 30 uncached records"
15542 );
15543 assert!(
15544 p4.contains("Elsewhere 79"),
15545 "the LAST saved record is unreachable — it can only be removed from here"
15546 );
15547
15548 assert!(
15550 !p4.contains("Elsewhere 0"),
15551 "an uncached record was rendered on more than one page"
15552 );
15553 let first = get("/?view=starred").await;
15556 assert!(
15557 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
15558 "the heading changed between pages; it describes the list, not the page"
15559 );
15560 assert!(
15561 !first.contains("Elsewhere "),
15562 "uncached saved records leaked onto the first page"
15563 );
15564 }
15565
15566 #[tokio::test]
15573 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
15574 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
15575 let sidecar =
15576 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
15577 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
15578 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
15579
15580 let resp = router(state)
15581 .oneshot(
15582 Request::builder()
15583 .uri("/?view=starred")
15584 .body(Body::empty())
15585 .unwrap(),
15586 )
15587 .await
15588 .unwrap();
15589 assert_eq!(resp.status(), StatusCode::OK);
15590 let body = String::from_utf8(
15591 axum::body::to_bytes(resp.into_body(), usize::MAX)
15592 .await
15593 .unwrap()
15594 .to_vec(),
15595 )
15596 .unwrap();
15597
15598 assert!(
15599 body.contains("Starred elsewhere"),
15600 "the saved record was not rendered at all"
15601 );
15602 assert!(
15603 body.contains("entry-uncached"),
15604 "it was not marked as uncached, so it looks like a normal entry"
15605 );
15606 assert!(
15607 body.contains("https://elsewhere.example/article"),
15608 "the row must link straight to the article"
15609 );
15610 assert!(
15611 !body.contains("/entries/0/"),
15612 "an uncached row must not offer entry actions against a nonexistent id"
15613 );
15614 }
15615
15616 #[test]
15624 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
15625 for hostile in [
15626 "日本語日本語日本",
15627 "é",
15628 "",
15629 "2026",
15630 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
15631 ] {
15632 let out = display_date(Some(hostile));
15633 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
15634 }
15635 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
15636 assert_eq!(display_date(None), "");
15637 }
15638
15639 #[test]
15642 fn the_unsave_route_is_rate_limited() {
15643 use axum::http::Method;
15644 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
15645 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
15647 }
15648
15649 #[tokio::test]
15658 async fn health_reports_a_broken_database() {
15659 let state = test_state(&[]).await;
15660 assert!(
15662 health_db_probe(&state.db).await.is_ok(),
15663 "the fixture was not healthy to begin with",
15664 );
15665
15666 sqlx::query("DROP TABLE feeds")
15667 .execute(&state.db)
15668 .await
15669 .unwrap();
15670
15671 assert!(
15672 health_db_probe(&state.db).await.is_err(),
15673 "the probe reported success against a database missing the table it \
15674 claims to read; `SELECT 1` would do exactly this",
15675 );
15676
15677 let resp = router(state)
15678 .oneshot(
15679 Request::builder()
15680 .uri("/health")
15681 .body(Body::empty())
15682 .unwrap(),
15683 )
15684 .await
15685 .unwrap();
15686 let body = String::from_utf8(
15687 axum::body::to_bytes(resp.into_body(), usize::MAX)
15688 .await
15689 .unwrap()
15690 .to_vec(),
15691 )
15692 .unwrap();
15693 assert!(
15695 body.starts_with("FAIL"),
15696 "/health did not report FAIL for a broken database: {body}",
15697 );
15698 assert!(
15699 !body.contains("db: ok"),
15700 "/health still called the database ok: {body}",
15701 );
15702 }
15703
15704 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
15709 use tokio::io::{AsyncReadExt, AsyncWriteExt};
15710 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15711 let addr = listener.local_addr().unwrap();
15712 tokio::spawn(async move {
15713 loop {
15714 let Ok((mut sock, _)) = listener.accept().await else {
15715 break;
15716 };
15717 let mut buf = vec![0u8; 8192];
15718 let Ok(n) = sock.read(&mut buf).await else {
15719 continue;
15720 };
15721 let req = String::from_utf8_lossy(&buf[..n]).to_string();
15722 let wants = |c: &str| req.contains(c);
15723 if fail_on.is_some_and(wants) {
15724 let body = r#"{"ok":false,"error":"ShortList"}"#;
15725 let resp = format!(
15726 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15727 body.len(),
15728 body
15729 );
15730 let _ = sock.write_all(resp.as_bytes()).await;
15731 let _ = sock.flush().await;
15732 continue;
15733 }
15734 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
15735 serde_json::json!([{
15736 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
15737 "cid": "bafy",
15738 "value": {
15739 "$type": crate::lexicon::nsid::SUBSCRIPTION,
15740 "url": "https://kept.example/feed.xml",
15741 "title": "Kept",
15742 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15747 "createdAt": "2026-01-01T00:00:00Z"
15748 }
15749 }])
15750 } else if wants(crate::lexicon::nsid::FOLDER) {
15751 serde_json::json!([{
15752 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15753 "cid": "bafy",
15754 "value": {
15755 "$type": crate::lexicon::nsid::FOLDER,
15756 "name": "Kept folder",
15757 "createdAt": "2026-01-01T00:00:00Z"
15758 }
15759 }])
15760 } else {
15761 serde_json::json!([])
15762 };
15763 let body =
15764 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15765 let resp = format!(
15766 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15767 body.len(),
15768 body
15769 );
15770 let _ = sock.write_all(resp.as_bytes()).await;
15771 let _ = sock.flush().await;
15772 }
15773 });
15774 format!("http://{addr}")
15775 }
15776
15777 async fn spawn_malformed_sidecar() -> String {
15780 use tokio::io::{AsyncReadExt, AsyncWriteExt};
15781 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15782 let addr = listener.local_addr().unwrap();
15783 tokio::spawn(async move {
15784 loop {
15785 let Ok((mut sock, _)) = listener.accept().await else {
15786 break;
15787 };
15788 let mut buf = vec![0u8; 8192];
15789 let _ = sock.read(&mut buf).await;
15790 let body = serde_json::json!({ "ok": true, "data": { "records": [
15791 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
15792 { "cid": "bafy", "value": {} },
15793 ]}})
15794 .to_string();
15795 let resp = format!(
15796 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15797 body.len(),
15798 body
15799 );
15800 let _ = sock.write_all(resp.as_bytes()).await;
15801 let _ = sock.flush().await;
15802 }
15803 });
15804 format!("http://{addr}")
15805 }
15806
15807 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
15808 let cookie = session_cookie(&state, did, None);
15809 let resp = router(state)
15810 .oneshot(
15811 Request::builder()
15812 .uri(uri)
15813 .header(header::COOKIE, cookie)
15814 .body(Body::empty())
15815 .unwrap(),
15816 )
15817 .await
15818 .unwrap();
15819 let status = resp.status();
15820 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15821 .await
15822 .unwrap();
15823 (status, String::from_utf8_lossy(&body).to_string())
15824 }
15825
15826 #[tokio::test]
15832 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
15833 let did = "did:plc:displayer";
15834 let state = test_state(&[did]).await;
15835 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
15836 let feed_id = store::upsert_feed(
15837 &state.db,
15838 &store::NewFeed {
15839 url: url.into(),
15840 title: Some("Quiet Journal".into()),
15841 ..Default::default()
15842 },
15843 )
15844 .await
15845 .unwrap();
15846 store::replace_sub_refs(&state.db, did, &[feed_id])
15847 .await
15848 .unwrap();
15849 store::insert_entries(
15850 &state.db,
15851 feed_id,
15852 &[store::NewEntry {
15853 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
15854 .into(),
15855 url: Some("https://quiet.example/no-summary".into()),
15856 title: Some("A title-only article".into()),
15857 published: Some("2026-07-11T00:00:00Z".into()),
15858 content_html: None,
15859 ..Default::default()
15860 }],
15861 0,
15862 )
15863 .await
15864 .unwrap();
15865 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
15866 assert_eq!(status, StatusCode::OK);
15867 assert!(
15868 list.contains("A title-only article"),
15869 "the entry is missing from the list"
15870 );
15871
15872 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
15873 .bind(feed_id)
15874 .fetch_one(&state.db)
15875 .await
15876 .unwrap();
15877 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
15878 assert_eq!(
15879 status,
15880 StatusCode::OK,
15881 "the article page failed for an entry with no body"
15882 );
15883 assert!(page.contains("A title-only article"));
15884 assert!(
15885 page.contains("https://quiet.example/no-summary"),
15886 "no link to the original"
15887 );
15888 assert!(
15889 page.contains(r#"<time datetime=""#),
15890 "no date on the article page"
15891 );
15892 }
15893
15894 #[tokio::test]
15899 async fn a_malformed_subscription_record_raises_an_alert() {
15900 let did = "did:plc:alerted";
15901 for page in ["/", "/manage"] {
15902 let sidecar = spawn_malformed_sidecar().await;
15903 let state = test_state_with_sidecar(&[did], &sidecar).await;
15904 let (status, body) = page_body(state, did, page).await;
15905 assert_eq!(status, StatusCode::OK, "{page} did not render");
15906 assert!(
15907 body.contains(r#"role="alert""#) && body.contains("could not be read"),
15908 "{page} rendered no alert for a refused subscription list"
15909 );
15910 assert!(
15911 body.contains("1 record(s) in your subscription list"),
15912 "{page} gave the generic alert, not the malformed-record one"
15913 );
15914 }
15915 }
15916
15917 #[tokio::test]
15919 async fn a_healthy_subscription_listing_raises_no_alert() {
15920 let did = "did:plc:exporter";
15921 let sidecar = spawn_export_sidecar(None).await;
15922 let state = test_state_with_sidecar(&[did], &sidecar).await;
15923 let (status, body) = page_body(state, did, "/").await;
15924 assert_eq!(status, StatusCode::OK);
15925 assert!(
15926 !body.contains("could not be read"),
15927 "a healthy listing raised an alert"
15928 );
15929 }
15930
15931 async fn export_opml_response(
15933 fail_on: Option<&'static str>,
15934 ) -> (StatusCode, HeaderMap, String) {
15935 let did = "did:plc:exporter";
15936 let sidecar = spawn_export_sidecar(fail_on).await;
15937 let state = test_state_with_sidecar(&[did], &sidecar).await;
15938 let cookie = session_cookie(&state, did, None);
15939 let resp = router(state)
15940 .oneshot(
15941 Request::builder()
15942 .uri("/opml/export")
15943 .header(header::COOKIE, cookie)
15944 .body(Body::empty())
15945 .unwrap(),
15946 )
15947 .await
15948 .unwrap();
15949 let status = resp.status();
15950 let headers = resp.headers().clone();
15951 let body = String::from_utf8_lossy(
15952 &axum::body::to_bytes(resp.into_body(), usize::MAX)
15953 .await
15954 .unwrap(),
15955 )
15956 .to_string();
15957 (status, headers, body)
15958 }
15959
15960 #[tokio::test]
15973 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
15974 let (status, headers, body) =
15975 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
15976
15977 assert_ne!(
15978 status,
15979 StatusCode::OK,
15980 "a failed subscription walk answered 200: {body}",
15981 );
15982 assert!(
15983 !headers.contains_key(header::CONTENT_DISPOSITION),
15984 "a failed subscription walk still offered a download: {headers:?}",
15985 );
15986 assert!(
15987 !body.contains("<opml"),
15988 "a failed subscription walk still served an OPML document: {body}",
15989 );
15990 }
15991
15992 #[tokio::test]
15996 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
15997 let (status, headers, body) =
15998 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
15999
16000 assert_ne!(
16001 status,
16002 StatusCode::OK,
16003 "a failed folder walk answered 200: {body}",
16004 );
16005 assert!(
16006 !headers.contains_key(header::CONTENT_DISPOSITION),
16007 "a failed folder walk still offered a download: {headers:?}",
16008 );
16009 assert!(
16010 !body.contains("<opml"),
16011 "a failed folder walk still served an OPML document: {body}",
16012 );
16013 }
16014
16015 #[tokio::test]
16018 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
16019 let (status, headers, body) = export_opml_response(None).await;
16020
16021 assert_eq!(
16022 status,
16023 StatusCode::OK,
16024 "a healthy export did not answer 200"
16025 );
16026 assert_eq!(
16027 headers
16028 .get(header::CONTENT_DISPOSITION)
16029 .and_then(|v| v.to_str().ok()),
16030 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
16031 "a healthy export did not offer the download",
16032 );
16033 assert!(
16034 body.contains("https://kept.example/feed.xml"),
16035 "the exported OPML lost the subscription: {body}",
16036 );
16037 assert!(
16038 body.contains("Kept folder"),
16039 "the exported OPML lost the folder: {body}",
16040 );
16041 }
16042}