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.7",
1582 date: "2026-10-07",
1583 summary: "A feed can no longer stall the reader with an article that is \
1584 slow to clean up, and every stored article is cleaned again \
1585 as it is shown.",
1586 },
1587 Release {
1588 version: "0.4.6",
1589 date: "2026-10-06",
1590 summary: "Renaming a subscription or a folder no longer overwrites \
1591 what another app changed at the same moment, and a folder \
1592 rename keeps everything but the name.",
1593 },
1594 Release {
1595 version: "0.4.5",
1596 date: "2026-10-06",
1597 summary: "An operator teardown now signs every user out at their own \
1598 server before deleting anything, and the session-writing \
1599 code is hardened against the races that work exposed.",
1600 },
1601 Release {
1602 version: "0.4.4",
1603 date: "2026-10-05",
1604 summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1605 links unchanged and real RSS bylines, and the address guard \
1606 refuses the reserved ranges it missed.",
1607 },
1608 Release {
1609 version: "0.4.3",
1610 date: "2026-10-05",
1611 summary: "Two write-path fixes for any PDS: large OPML imports and \
1612 read-state syncs are sent in calls the PDS accepts, and a \
1613 read-state sync that disagreed with the PDS recovers instead \
1614 of failing every round.",
1615 },
1616 Release {
1617 version: "0.4.2",
1618 date: "2026-10-04",
1619 summary: "A public standard.site feature page with this list of recent \
1620 releases, and link cards: a posted feather-reader.com link \
1621 now unfurls with a description and an image.",
1622 },
1623 Release {
1624 version: "0.4.1",
1625 date: "2026-10-04",
1626 summary: "The public pages explain standard.site publications, and the \
1627 subscribe form can submit the DID form of a publication URI, \
1628 which browsers refused in 0.4.0.",
1629 },
1630 Release {
1631 version: "0.4.0",
1632 date: "2026-10-03",
1633 summary: "standard.site support: publications are read from their \
1634 authors' atproto repos as subscriptions, beside RSS, on their \
1635 own polling loop. Every stored field from a feed or a \
1636 publication now has a size bound.",
1637 },
1638];
1639
1640#[derive(Template)]
1652#[template(path = "stats.html")]
1653struct StatsTemplate {
1654 card: Card,
1656 version: &'static str,
1657 repo_url: &'static str,
1658 kofi_url: &'static str,
1659 feeds_tracked: i64,
1660 polled_last_hour: i64,
1661 polled_pct: i64,
1662 overdue: i64,
1663 last_poll: String,
1664 oldest_poll: String,
1665 never_polled: i64,
1666 poll_interval_mins: i64,
1667 in_backoff: i64,
1669 badly_broken: i64,
1673 failure_kinds: Vec<(String, i64)>,
1675 fetching: &'static str,
1679 deferred_no_permit: u64,
1684 starved_since: Option<String>,
1685}
1686
1687#[derive(Template)]
1691#[template(path = "privacy.html")]
1692struct PrivacyTemplate {
1693 card: Card,
1695 version: &'static str,
1696 repo_url: &'static str,
1697 kofi_url: &'static str,
1698}
1699
1700#[derive(Template)]
1703#[template(path = "terms.html")]
1704struct TermsTemplate {
1705 card: Card,
1707 version: &'static str,
1708 repo_url: &'static str,
1709 kofi_url: &'static str,
1710}
1711
1712#[derive(Template)]
1715#[template(path = "landing.html")]
1716struct LandingTemplate {
1717 card: Card,
1719 version: &'static str,
1720 repo_url: &'static str,
1721 crates_url: &'static str,
1722 kofi_url: &'static str,
1723 standard_site: bool,
1726 releases: &'static [Release],
1728}
1729
1730#[derive(Template)]
1732#[template(path = "entry.html")]
1733struct EntryTemplate {
1734 card: Card,
1736 version: &'static str,
1737 repo_url: &'static str,
1738 kofi_url: &'static str,
1739 nav: Nav,
1740 id: i64,
1741 title: String,
1742 feed_title: String,
1743 author: Option<String>,
1744 published: String,
1745 url: Option<SafeLink>,
1755 content_html: Option<BodyRender>,
1767 read: bool,
1768 starred: bool,
1769 back_qs: String,
1771 prev_id: Option<i64>,
1773 next_id: Option<i64>,
1774 oob: bool,
1776}
1777
1778#[derive(Template)]
1780#[template(path = "entry_row.html")]
1781struct EntryRowTemplate {
1782 e: EntryRow,
1783}
1784
1785#[derive(Template)]
1790#[template(path = "entry_actionbar.html")]
1791struct EntryActionBarTemplate {
1792 id: i64,
1793 read: bool,
1794 starred: bool,
1795 oob: bool,
1797}
1798
1799#[derive(Template)]
1801#[template(path = "login.html")]
1802struct LoginTemplate {
1803 card: Card,
1805 repo_url: &'static str,
1806 error: String,
1807 flash: String,
1810}
1811
1812#[derive(Template)]
1814#[template(path = "beta_redeem.html")]
1815struct BetaRedeemTemplate {
1816 card: Card,
1818 repo_url: &'static str,
1819 error: String,
1820 capacity_full: bool,
1823}
1824
1825fn render<T: Template>(tmpl: &T) -> Response {
1832 match tmpl.render() {
1833 Ok(body) => Html(body).into_response(),
1834 Err(err) => {
1835 warn!(%err, "template render failed");
1836 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1837 }
1838 }
1839}
1840
1841struct WebError {
1846 err: anyhow::Error,
1847 status: StatusCode,
1848}
1849
1850impl<E: Into<anyhow::Error>> From<E> for WebError {
1851 fn from(err: E) -> Self {
1852 WebError {
1853 err: err.into(),
1854 status: StatusCode::INTERNAL_SERVER_ERROR,
1855 }
1856 }
1857}
1858
1859impl WebError {
1860 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1862 WebError {
1863 err: err.into(),
1864 status,
1865 }
1866 }
1867}
1868
1869impl IntoResponse for WebError {
1870 fn into_response(self) -> Response {
1871 warn!(error = %self.err, status = %self.status, "request failed");
1872 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1873 "internal error"
1874 } else {
1875 self.status.canonical_reason().unwrap_or("error")
1876 };
1877 (self.status, body).into_response()
1878 }
1879}
1880
1881fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1886 let status = err.status();
1887 WebError::with_status(err, status)
1888}
1889
1890fn display_title(title: Option<&str>, url: &str) -> String {
1893 if let Some(t) = title {
1894 let t = t.trim();
1895 if !t.is_empty() {
1896 return t.to_string();
1897 }
1898 }
1899 url::Url::parse(url)
1900 .ok()
1901 .and_then(|u| u.host_str().map(str::to_string))
1902 .unwrap_or_else(|| url.to_string())
1903}
1904
1905fn display_handle(handle: Option<&str>, did: &str) -> String {
1908 match handle {
1909 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1910 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1911 }
1912}
1913
1914fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1916 let source = handle
1917 .map(|h| h.trim().trim_start_matches('@'))
1918 .filter(|h| !h.is_empty())
1919 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1920 let letters: String = source
1921 .chars()
1922 .filter(|c| c.is_alphanumeric())
1923 .take(2)
1924 .collect::<String>()
1925 .to_lowercase();
1926 if letters.is_empty() {
1927 "fr".to_string()
1928 } else {
1929 letters
1930 }
1931}
1932
1933fn display_date(published: Option<&str>) -> String {
1936 match published {
1945 Some(p) => p.chars().take(10).collect(),
1946 None => String::new(),
1947 }
1948}
1949
1950fn qenc(s: &str) -> String {
1954 let mut out = String::with_capacity(s.len() * 3);
1955 for b in s.bytes() {
1956 match b {
1957 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1958 out.push(b as char)
1959 }
1960 _ => out.push_str(&format!("%{b:02X}")),
1961 }
1962 }
1963 out
1964}
1965
1966#[derive(Debug, Deserialize, Default)]
1972struct IndexQuery {
1973 #[serde(default)]
1975 feed: Option<String>,
1976 #[serde(default)]
1978 folder: Option<String>,
1979 #[serde(default)]
1981 view: Option<String>,
1982 #[serde(default)]
1984 page: Option<u32>,
1985 #[serde(default)]
1987 flash: Option<String>,
1988}
1989
1990const ENTRIES_PER_PAGE: i64 = 100;
1998
1999fn page_count_for(total: i64) -> i64 {
2002 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
2003}
2004
2005const PREV_NEXT_MAX: i64 = 5_000;
2012
2013const STARRED_IDENTITY_MAX: i64 = 20_000;
2021
2022const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2036
2037struct ResolvedSub {
2040 rkey: String,
2041 sub: Subscription,
2042 feed: Option<store::Feed>,
2043}
2044
2045async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2049 resolve_subscriptions_noting(state, did).await.0
2050}
2051
2052fn subscriptions_alert(err: &anyhow::Error) -> String {
2059 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2060 Some(m) => format!(
2061 "{} record(s) in your subscription list could not be read, so it was not \
2062 refreshed. Showing your last-known subscriptions; nothing was removed.",
2063 m.count
2064 ),
2065 None => "Your subscription list could not be read from your PDS just now. \
2066 Showing your last-known subscriptions."
2067 .to_string(),
2068 }
2069}
2070
2071async fn resolve_subscriptions_noting(
2074 state: &AppState,
2075 did: &str,
2076) -> (Vec<ResolvedSub>, Option<String>) {
2077 let pool = &state.db;
2078 let subs = match state.repo().list_subscriptions_sorted(did).await {
2079 Ok(s) => s,
2080 Err(err) => {
2081 let alert = subscriptions_alert(&err);
2082 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2083 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2096 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2097 projection could not be read; rendering an EMPTY \
2098 feed list, which is not the same as having none");
2099 Vec::new()
2100 });
2101 let cached = feeds
2102 .into_iter()
2103 .map(|f| ResolvedSub {
2104 rkey: String::new(),
2105 sub: Subscription::new(f.url.clone(), now_rfc3339()),
2106 feed: Some(f),
2107 })
2108 .collect();
2109 return (cached, Some(alert));
2110 }
2111 };
2112
2113 let mut out = Vec::with_capacity(subs.len());
2129 for (rkey, sub) in subs {
2130 let feed = match store::get_feed_by_url(pool, &sub.url).await {
2131 Ok(Some(f)) => Some(f),
2132 Ok(None) => {
2133 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2144 || feed::classify_feed_privacy(&sub.url).is_private()
2145 {
2146 warn!(
2147 %did,
2148 "skipping cache row for a subscription URL that is private or not http(s)"
2149 );
2150 out.push(ResolvedSub {
2151 rkey,
2152 sub,
2153 feed: None,
2154 });
2155 continue;
2156 }
2157 if let Err(err) = store::upsert_feed(
2165 pool,
2166 &store::NewFeed {
2167 url: sub.url.clone(),
2168 title: sub.title.clone(),
2169 site_url: sub.site_url.clone(),
2170 ..Default::default()
2171 },
2172 )
2173 .await
2174 {
2175 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2176 it will not be polled");
2177 }
2178 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2179 }
2180 Err(err) => {
2181 warn!(%err, url = %sub.url, "get_feed_by_url failed");
2182 None
2183 }
2184 };
2185 out.push(ResolvedSub { rkey, sub, feed });
2186 }
2187 sync_sub_refs(pool, did, &out).await;
2191 (out, None)
2192}
2193
2194async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2198 let feed_ids: Vec<i64> = subs
2199 .iter()
2200 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2201 .collect();
2202 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2203 warn!(%err, %did, "failed to sync sub_ref projection");
2204 }
2205}
2206
2207async fn index(
2210 State(state): State<AppState>,
2211 headers: HeaderMap,
2212 Query(q): Query<IndexQuery>,
2213) -> Result<Response, WebError> {
2214 let user = match current_session(&state, &headers).await {
2215 Some(u) => u,
2216 None => {
2219 return Ok(render(&LandingTemplate {
2220 card: Card::site(&state.config),
2221 version: VERSION,
2222 repo_url: REPO_URL,
2223 crates_url: CRATES_URL,
2224 kofi_url: KOFI_URL,
2225 standard_site: state.config.standard_site,
2226 releases: RELEASES,
2227 }))
2228 }
2229 };
2230 let did = user.did.clone();
2231 let pool = &state.db;
2232
2233 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2234
2235 let view = match q.view.as_deref() {
2237 Some("all") => "all",
2238 Some("starred") => "starred",
2239 _ => "unread",
2240 }
2241 .to_string();
2242 let list_view = list_view_of(q.view.as_deref());
2243
2244 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2246 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2251
2252 let feed_title_by_id = |id: i64| -> String {
2253 subs.iter()
2254 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2255 .map(|s| {
2256 display_title(
2257 s.sub
2258 .title
2259 .as_deref()
2260 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2261 &s.sub.url,
2262 )
2263 })
2264 .unwrap_or_default()
2265 };
2266
2267 let mut uncached: Vec<EntryRow> = Vec::new();
2280 if view == "starred" {
2281 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2310 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2311 Ok(store::StarredIdentities::Truncated) => {
2316 warn!(
2317 %did,
2318 cap = STARRED_IDENTITY_MAX,
2319 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2320 rather than rendering record-deleting buttons for cached articles"
2321 );
2322 None
2323 }
2324 Err(err) => {
2325 warn!(%err, %did, "cached-starred identity lookup failed; \
2326 suppressing uncached saved rows this render");
2327 None
2328 }
2329 };
2330 let identities_ok = identities.is_some();
2337 let identities = identities.unwrap_or_default();
2338 let cached_urls: std::collections::HashSet<&str> = identities
2339 .iter()
2340 .filter_map(|(url, _)| url.as_deref())
2341 .collect();
2342 let cached_guids: std::collections::HashSet<&str> =
2343 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2344
2345 let mut uncached_dropped = 0usize;
2358 match state.repo().list_saved_sorted(&did).await {
2359 Ok(saved) if identities_ok => {
2360 for (rkey, item) in saved {
2361 let known = cached_urls.contains(item.url.as_str())
2362 || item
2363 .entry_id
2364 .as_deref()
2365 .is_some_and(|g| cached_guids.contains(g));
2366 if known {
2367 continue;
2368 }
2369 if let Some(urls) = &scope_urls {
2373 match item.feed_url.as_deref() {
2374 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2375 _ => continue,
2379 }
2380 }
2381 let link = SafeLink::external(&item.url);
2407 if link.is_empty() {
2408 warn!(
2409 %did, %rkey,
2410 "a saved record has an unusable URL; rendering it without a link \
2411 so it can still be removed"
2412 );
2413 }
2414
2415 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2429 uncached_dropped += 1;
2430 continue;
2431 }
2432 if let Some(feed_url) = item.feed_url.as_deref() {
2433 if subs.iter().any(|s| s.sub.url == feed_url) {
2434 let stale_before = (chrono::Utc::now()
2438 - chrono::Duration::from_std(state.config.poll_interval)
2439 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2440 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2441 if let Err(err) =
2442 store::mark_feed_due(pool, feed_url, &stale_before).await
2443 {
2444 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2445 }
2446 }
2447 }
2448 uncached.push(EntryRow {
2449 id: 0,
2450 title: item
2451 .title
2452 .clone()
2453 .filter(|t| !t.trim().is_empty())
2454 .unwrap_or_else(|| {
2462 if link.is_empty() {
2463 format!("Saved item {rkey}")
2464 } else {
2465 item.url.clone()
2466 }
2467 }),
2468 feed_title: item.feed_url.clone().unwrap_or_default(),
2469 published: display_date(Some(&item.created_at)),
2470 read: false,
2471 starred: true,
2472 link,
2476 cached: false,
2477 rkey,
2478 });
2479 }
2480 }
2481 Ok(_) => {}
2483 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2484 }
2485 if uncached_dropped > 0 {
2486 warn!(
2487 %did,
2488 dropped = uncached_dropped,
2489 cap = MAX_UNCACHED_SAVED_ROWS,
2490 "more saved records than this instance will hold in one response; the \
2491 rest are not reachable from here"
2492 );
2493 }
2494 }
2495
2496 let total_cached =
2512 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2513 let uncached_len = uncached.len();
2514 let total = total_cached + uncached_len as i64;
2515 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2520 let offset = (page - 1) * ENTRIES_PER_PAGE;
2521 let source = store::list_entries(
2524 pool,
2525 &did,
2526 list_view,
2527 scope_ids.as_deref(),
2528 ENTRIES_PER_PAGE,
2529 offset,
2530 )
2531 .await?;
2532 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2552 let cached_here = cached_allotment.min(source.len());
2553 let source = if uncached_len == 0 {
2558 &source[..]
2559 } else {
2560 &source[..cached_here]
2561 };
2562 let uncached_page: Vec<EntryRow> = {
2563 let skip = (offset - total_cached).max(0) as usize;
2564 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2565 uncached.into_iter().skip(skip).take(take).collect()
2566 };
2567 let uncached_total = uncached_len as i64;
2570
2571 let entry_scope_qs = {
2573 let mut parts = Vec::new();
2574 if let Some(f) = q.feed.as_deref() {
2575 parts.push(format!("feed={}", qenc(f)));
2576 }
2577 if let Some(f) = q.folder.as_deref() {
2578 parts.push(format!("folder={}", qenc(f)));
2579 }
2580 if view != "unread" {
2581 parts.push(format!("view={}", qenc(&view)));
2582 }
2583 parts.join("&")
2584 };
2585 let entries: Vec<EntryRow> = source
2586 .iter()
2587 .map(|e| EntryRow {
2588 id: e.id,
2589 title: e
2590 .title
2591 .clone()
2592 .filter(|t| !t.trim().is_empty())
2593 .unwrap_or_else(|| "(untitled)".to_string()),
2594 feed_title: feed_title_by_id(e.feed_id),
2595 published: display_date(e.published.as_deref()),
2596 read: e.read,
2601 starred: e.starred,
2602 link: SafeLink::entry(e.id, &entry_scope_qs),
2603 cached: true,
2604 rkey: String::new(),
2605 })
2606 .collect();
2607
2608 let mut entries = entries;
2610 entries.extend(uncached_page);
2611 let entries = entries;
2612
2613 let selected_feed = q.feed.as_deref();
2614 let selected_folder = q.folder.as_deref();
2615
2616 let (folder_views, loose_feeds, _folder_options) =
2618 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2619
2620 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2622 let name = subs
2623 .iter()
2624 .find(|s| s.sub.url == feed_url)
2625 .map(|s| {
2626 display_title(
2627 s.sub
2628 .title
2629 .as_deref()
2630 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2631 &s.sub.url,
2632 )
2633 })
2634 .unwrap_or_else(|| display_title(None, feed_url));
2635 (name, format!("feed={}", qenc(feed_url)))
2636 } else if let Some(folder_uri) = selected_folder {
2637 let name = folder_views
2638 .iter()
2639 .find(|f| f.uri == folder_uri)
2640 .map(|f| f.name.clone())
2641 .unwrap_or_else(|| "Folder".to_string());
2642 (name, format!("folder={}", qenc(folder_uri)))
2643 } else {
2644 let h = match view.as_str() {
2645 "all" => "All",
2646 "starred" => "Starred",
2647 _ => "Unread",
2648 };
2649 (h.to_string(), String::new())
2650 };
2651
2652 let feed_scope = selected_feed.map(str::to_string);
2653 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2654
2655 let page_href = |n: i64| -> String {
2659 let mut parts = Vec::new();
2660 if !entry_scope_qs.is_empty() {
2661 parts.push(entry_scope_qs.clone());
2662 }
2663 if n > 1 {
2664 parts.push(format!("page={n}"));
2665 }
2666 if parts.is_empty() {
2667 "/".to_string()
2668 } else {
2669 format!("/?{}", parts.join("&"))
2670 }
2671 };
2672 let prev_href = (page > 1).then(|| page_href(page - 1));
2673 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2674
2675 let tmpl = IndexTemplate {
2676 card: Card::private(&state.config),
2677 version: VERSION,
2678 repo_url: REPO_URL,
2679 kofi_url: KOFI_URL,
2680 flash: q.flash.unwrap_or_default(),
2681 alert: alert.unwrap_or_default(),
2682 nav,
2683 entries,
2684 heading,
2685 feed_scope,
2686 total,
2687 uncached_total,
2690 page,
2691 page_count: page_count_for(total),
2692 prev_href,
2693 next_href,
2694 };
2695 Ok(render(&tmpl))
2696}
2697
2698#[derive(Debug, Deserialize, Default)]
2700struct ManageQuery {
2701 #[serde(default)]
2702 flash: Option<String>,
2703}
2704
2705async fn manage(
2710 State(state): State<AppState>,
2711 headers: HeaderMap,
2712 Query(q): Query<ManageQuery>,
2713) -> Result<Response, WebError> {
2714 let user = match current_session(&state, &headers).await {
2715 Some(u) => u,
2716 None => return Ok(Redirect::to("/login").into_response()),
2717 };
2718 let did = user.did.clone();
2719
2720 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2721 let (folder_views, loose_feeds, folder_options) =
2722 build_sidebar(&state, &did, &subs, None, None).await;
2723
2724 let nav = build_nav(
2726 &user,
2727 "unread",
2728 String::new(),
2729 folder_views.iter().map(clone_folder_view).collect(),
2730 loose_feeds.iter().map(clone_feed_view).collect(),
2731 true,
2732 );
2733
2734 let tmpl = ManageTemplate {
2735 card: Card::private(&state.config),
2736 version: VERSION,
2737 repo_url: REPO_URL,
2738 kofi_url: KOFI_URL,
2739 flash: q.flash.unwrap_or_default(),
2740 alert: alert.unwrap_or_default(),
2741 nav,
2742 folder_options,
2743 folders: folder_views,
2744 loose_feeds,
2745 standard_site: state.config.standard_site,
2746 };
2747 Ok(render(&tmpl))
2748}
2749
2750fn clone_feed_view(f: &FeedView) -> FeedView {
2753 FeedView {
2754 rkey: f.rkey.clone(),
2755 url: f.url.clone(),
2756 title: f.title.clone(),
2757 unread: f.unread,
2758 selected: f.selected,
2759 folder: f.folder.clone(),
2760 }
2761}
2762
2763fn clone_folder_view(f: &FolderView) -> FolderView {
2764 FolderView {
2765 rkey: f.rkey.clone(),
2766 uri: f.uri.clone(),
2767 name: f.name.clone(),
2768 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2769 selected: f.selected,
2770 }
2771}
2772
2773fn scope_urls_for(
2778 subs: &[ResolvedSub],
2779 feed: Option<&str>,
2780 folder: Option<&str>,
2781) -> Option<Vec<String>> {
2782 if let Some(feed_url) = feed {
2783 Some(vec![feed_url.to_string()])
2784 } else {
2785 folder.map(|folder_uri| {
2786 subs.iter()
2787 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2788 .map(|s| s.sub.url.clone())
2789 .collect()
2790 })
2791 }
2792}
2793
2794fn folder_uri(did: &str, rkey: &str) -> String {
2796 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2797}
2798
2799async fn build_sidebar(
2803 state: &AppState,
2804 did: &str,
2805 subs: &[ResolvedSub],
2806 selected_feed: Option<&str>,
2807 selected_folder: Option<&str>,
2808) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2809 let pool = &state.db;
2810 let unread_counts = store::unread_counts_by_feed(pool, did)
2815 .await
2816 .unwrap_or_else(|err| {
2817 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2818 Default::default()
2819 });
2820 let folders = state
2821 .repo()
2822 .list_folders_sorted(did)
2823 .await
2824 .unwrap_or_default();
2825
2826 let unread_count = |feed_id: Option<i64>| -> i64 {
2827 feed_id
2828 .and_then(|id| unread_counts.get(&id).copied())
2829 .unwrap_or(0)
2830 };
2831 let mk_feed_view = |s: &ResolvedSub| FeedView {
2832 rkey: s.rkey.clone(),
2833 url: s.sub.url.clone(),
2834 title: display_title(
2835 s.sub
2836 .title
2837 .as_deref()
2838 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2839 &s.sub.url,
2840 ),
2841 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2842 selected: selected_feed == Some(s.sub.url.as_str()),
2843 folder: s.sub.folder.clone(),
2844 };
2845
2846 let mut folder_views = Vec::with_capacity(folders.len());
2847 for (rkey, folder) in &folders {
2848 let uri = folder_uri(did, rkey);
2849 let feeds: Vec<FeedView> = subs
2850 .iter()
2851 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2852 .map(mk_feed_view)
2853 .collect();
2854 folder_views.push(FolderView {
2855 rkey: rkey.clone(),
2856 uri: uri.clone(),
2857 name: folder.name.clone(),
2858 feeds,
2859 selected: selected_folder == Some(uri.as_str()),
2860 });
2861 }
2862
2863 let known_uris: std::collections::HashSet<String> =
2864 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2865 let loose_feeds: Vec<FeedView> = subs
2866 .iter()
2867 .filter(|s| {
2868 s.sub
2869 .folder
2870 .as_deref()
2871 .map(|f| !known_uris.contains(f))
2872 .unwrap_or(true)
2873 })
2874 .map(mk_feed_view)
2875 .collect();
2876
2877 let folder_options: Vec<FolderOption> = folders
2878 .iter()
2879 .map(|(rkey, folder)| FolderOption {
2880 name: folder.name.clone(),
2881 uri: folder_uri(did, rkey),
2882 })
2883 .collect();
2884
2885 (folder_views, loose_feeds, folder_options)
2886}
2887
2888fn build_nav(
2890 user: &CurrentUser,
2891 view: &str,
2892 scope_qs: String,
2893 folders: Vec<FolderView>,
2894 loose_feeds: Vec<FeedView>,
2895 manage_active: bool,
2896) -> Nav {
2897 Nav {
2898 handle: display_handle(user.handle.as_deref(), &user.did),
2899 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2900 view: view.to_string(),
2901 scope_qs,
2902 folders,
2903 loose_feeds,
2904 manage_active,
2905 }
2906}
2907
2908#[derive(Debug, Deserialize, Default)]
2915struct EntryQuery {
2916 #[serde(default)]
2917 feed: Option<String>,
2918 #[serde(default)]
2919 folder: Option<String>,
2920 #[serde(default)]
2921 view: Option<String>,
2922}
2923
2924async fn entry_view(
2927 State(state): State<AppState>,
2928 headers: HeaderMap,
2929 Path(id): Path<i64>,
2930 Query(q): Query<EntryQuery>,
2931) -> Result<Response, WebError> {
2932 let user = match current_session(&state, &headers).await {
2933 Some(u) => u,
2934 None => return Ok(Redirect::to("/login").into_response()),
2935 };
2936 let did = user.did.clone();
2937 let pool = &state.db;
2938
2939 let subs = resolve_subscriptions(&state, &did).await;
2943
2944 let entry = match get_entry_by_id(pool, &did, id).await? {
2945 Some(e) => e,
2946 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2947 };
2948
2949 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2950
2951 let read = entry_is_read(pool, &did, id).await?;
2952 let starred = entry_is_starred(pool, &did, id).await?;
2953
2954 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2957
2958 let back_qs = scope_query(&q);
2959
2960 let content_html = match entry.content_html.clone() {
2964 Some(raw) => Some(BodyRenderer::shared().render(raw).await?),
2965 None => None,
2966 };
2967
2968 let (folder_views, loose_feeds, _) =
2969 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2970 let nav_view = match q.view.as_deref() {
2971 Some("all") => "all",
2972 Some("starred") => "starred",
2973 _ => "unread",
2974 };
2975 let nav = build_nav(
2976 &user,
2977 nav_view,
2978 back_qs.clone(),
2979 folder_views,
2980 loose_feeds,
2981 false,
2982 );
2983
2984 let tmpl = EntryTemplate {
2985 card: Card::private(&state.config),
2986 version: VERSION,
2987 repo_url: REPO_URL,
2988 kofi_url: KOFI_URL,
2989 nav,
2990 id: entry.id,
2991 title: entry
2992 .title
2993 .clone()
2994 .filter(|t| !t.trim().is_empty())
2995 .unwrap_or_else(|| "(untitled)".to_string()),
2996 feed_title,
2997 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2998 published: display_date(entry.published.as_deref()),
2999 url: entry.url.as_deref().and_then(SafeLink::external_opt),
3000 content_html,
3001 read,
3002 starred,
3003 back_qs,
3004 prev_id,
3005 next_id,
3006 oob: false,
3007 };
3008 Ok(render(&tmpl))
3009}
3010
3011async fn neighbors_in_scope(
3014 state: &AppState,
3015 did: &str,
3016 q: &EntryQuery,
3017 current: i64,
3018) -> (Option<i64>, Option<i64>) {
3019 let idx_q = IndexQuery {
3020 feed: q.feed.clone(),
3021 folder: q.folder.clone(),
3022 view: q.view.clone(),
3023 page: None,
3025 flash: None,
3026 };
3027 let ids = list_entry_ids(state, did, &idx_q).await;
3028 let pos = ids.iter().position(|&x| x == current);
3029 match pos {
3030 Some(p) => {
3031 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
3032 let next = ids.get(p + 1).copied();
3033 (prev, next)
3034 }
3035 None => (None, None),
3036 }
3037}
3038
3039async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
3042 let pool = &state.db;
3043 let subs = resolve_subscriptions(state, did).await;
3044
3045 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3046
3047 store::list_entry_ids(
3053 pool,
3054 did,
3055 list_view_of(q.view.as_deref()),
3056 scoped_feed_ids(&subs, &scope_urls).as_deref(),
3057 PREV_NEXT_MAX,
3058 )
3059 .await
3060 .unwrap_or_else(|err| {
3061 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3062 Vec::new()
3063 })
3064}
3065
3066fn list_view_of(view: Option<&str>) -> store::ListView {
3069 match view {
3070 Some("all") => store::ListView::All,
3071 Some("starred") => store::ListView::Starred,
3072 _ => store::ListView::Unread,
3073 }
3074}
3075
3076fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3082 let urls = scope_urls.as_ref()?;
3083 Some(
3084 subs.iter()
3085 .filter(|s| urls.contains(&s.sub.url))
3086 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3087 .collect(),
3088 )
3089}
3090
3091fn scope_query(q: &EntryQuery) -> String {
3093 let mut parts = Vec::new();
3094 if let Some(f) = q.feed.as_deref() {
3095 parts.push(format!("feed={}", qenc(f)));
3096 }
3097 if let Some(f) = q.folder.as_deref() {
3098 parts.push(format!("folder={}", qenc(f)));
3099 }
3100 if let Some(v) = q.view.as_deref() {
3101 if v != "unread" {
3102 parts.push(format!("view={}", qenc(v)));
3103 }
3104 }
3105 parts.join("&")
3106}
3107
3108#[derive(Debug, Deserialize)]
3114struct ReadForm {
3115 #[serde(default)]
3116 read: Option<String>,
3117}
3118
3119async fn mark_read(
3121 State(state): State<AppState>,
3122 Path(id): Path<i64>,
3123 headers: HeaderMap,
3124 Form(form): Form<ReadForm>,
3125) -> Result<Response, WebError> {
3126 let did = match current_did(&state, &headers).await {
3127 Some(d) => d,
3128 None => return Ok(Redirect::to("/login").into_response()),
3129 };
3130 let pool = &state.db;
3131
3132 let read = matches!(
3133 form.read.as_deref(),
3134 Some("true") | Some("1") | Some("on") | None
3135 );
3136
3137 resolve_subscriptions(&state, &did).await;
3142 if !store::mark_read(pool, &did, id, read).await? {
3143 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3144 }
3145
3146 if !is_htmx(&headers) {
3147 return Ok(Redirect::to("/").into_response());
3148 }
3149
3150 if is_reader_request(&headers) {
3154 let starred = entry_is_starred(pool, &did, id).await?;
3155 return Ok(render(&EntryActionBarTemplate {
3156 id,
3157 read,
3158 starred,
3159 oob: true,
3160 }));
3161 }
3162
3163 let row = build_entry_row(pool, &did, id, Some(read)).await?;
3164 match row {
3165 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3166 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3167 }
3168}
3169
3170#[derive(Debug, Deserialize)]
3176struct StarForm {
3177 #[serde(default)]
3178 starred: Option<String>,
3179}
3180
3181async fn toggle_star(
3187 State(state): State<AppState>,
3188 Path(id): Path<i64>,
3189 headers: HeaderMap,
3190 Form(form): Form<StarForm>,
3191) -> Result<Response, WebError> {
3192 let did = match current_did(&state, &headers).await {
3193 Some(d) => d,
3194 None => return Ok(Redirect::to("/login").into_response()),
3195 };
3196 let pool = &state.db;
3197
3198 let starred = matches!(
3199 form.starred.as_deref(),
3200 Some("true") | Some("1") | Some("on") | None
3201 );
3202
3203 resolve_subscriptions(&state, &did).await;
3207 if !store::mark_starred(pool, &did, id, starred).await? {
3208 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3209 }
3210
3211 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3214 let entry_url = entry.url.clone().unwrap_or_default();
3215 if !entry_url.is_empty() {
3216 if starred {
3217 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3218 saved.title = entry.title.clone();
3219 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3220 saved.entry_id = Some(entry.guid.clone());
3221 match state.repo().add_saved(&did, &saved).await {
3222 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3223 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3224 }
3225 } else {
3226 match state.repo().list_saved(&did).await {
3228 Ok(records) => {
3229 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3230 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3231 warn!(%err, %did, %rkey, "PDS saved delete failed");
3232 }
3233 }
3234 }
3235 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3236 }
3237 }
3238 }
3239 }
3240
3241 if !is_htmx(&headers) {
3242 return Ok(Redirect::to("/").into_response());
3243 }
3244
3245 if is_reader_request(&headers) {
3247 let read = entry_is_read(pool, &did, id).await?;
3248 return Ok(render(&EntryActionBarTemplate {
3249 id,
3250 read,
3251 starred,
3252 oob: true,
3253 }));
3254 }
3255
3256 let row = build_entry_row(pool, &did, id, None).await?;
3257 match row {
3258 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3259 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3260 }
3261}
3262
3263async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3265 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3266 .bind(feed_id)
3267 .fetch_optional(pool)
3268 .await
3269 .ok()
3270 .flatten()
3271}
3272
3273#[derive(Debug, Deserialize, Default)]
3280struct ReadAllQuery {
3281 #[serde(default)]
3282 feed: Option<String>,
3283}
3284
3285async fn mark_all_read(
3288 State(state): State<AppState>,
3289 headers: HeaderMap,
3290 Query(q): Query<ReadAllQuery>,
3291) -> Result<Response, WebError> {
3292 let did = match current_did(&state, &headers).await {
3293 Some(d) => d,
3294 None => return Ok(Redirect::to("/login").into_response()),
3295 };
3296 let pool = &state.db;
3297
3298 resolve_subscriptions(&state, &did).await;
3301
3302 if let Some(feed_url) = q.feed.as_deref() {
3303 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3304 store::mark_feed_read(pool, &did, feed.id, true).await?;
3305 }
3306 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3307 }
3308
3309 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3314 store::mark_feed_read(pool, &did, feed_id, true).await?;
3315 }
3316 Ok(Redirect::to("/").into_response())
3317}
3318
3319const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3329 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3330
3331const EXPORT_INCOMPLETE_REFUSAL: &str =
3338 "Could not read your subscriptions in full, so nothing was exported. Your \
3339 feeds are unchanged — try again, and if it keeps failing the list may be \
3340 larger than this reader can page through.";
3341
3342const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3348 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3349 feeds for now — private-feed support arrives when atproto's private data \
3350 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3351
3352#[derive(Debug, Deserialize)]
3354struct SubscribeForm {
3355 url: String,
3356 #[serde(default)]
3358 folder: Option<String>,
3359}
3360
3361async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3371 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3372 let canonical = format!(
3373 "{}{}",
3374 crate::atproto::AT_URI_PREFIX,
3375 &input[crate::atproto::AT_URI_PREFIX.len()..]
3376 );
3377 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3378 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3379 return Err(unsupported());
3380 }
3381 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3382 uri.authority.clone()
3383 } else {
3384 let handle =
3385 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3390 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3391 .await
3392 .map_err(|err| {
3393 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3394 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3395 })?
3396 };
3397 let url = format!(
3398 "{}{did}/{}/{}",
3399 crate::atproto::AT_URI_PREFIX,
3400 uri.collection,
3401 uri.rkey
3402 );
3403 if !feed::is_storable_feed_url(&url, true) {
3404 return Err(unsupported());
3405 }
3406 Ok(url)
3407}
3408
3409async fn add_subscription(
3411 State(state): State<AppState>,
3412 headers: HeaderMap,
3413 Form(form): Form<SubscribeForm>,
3414) -> Result<Response, WebError> {
3415 let did = match current_did(&state, &headers).await {
3416 Some(d) => d,
3417 None => return Ok(Redirect::to("/login").into_response()),
3418 };
3419 let pool = &state.db;
3420 let input = form.url.trim().to_string();
3421 if input.is_empty() {
3422 return Ok(Redirect::to("/").into_response());
3423 }
3424
3425 let cap = state.config.max_subs_per_did;
3429 if cap > 0 {
3430 match store::count_subscriptions_for_did(pool, &did).await {
3431 Ok(n) if n >= cap => {
3432 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3433 return Ok(Redirect::to(&format!(
3434 "/?flash={}",
3435 qenc(&format!(
3436 "Subscription limit reached ({cap}). Remove a feed before adding another."
3437 ))
3438 ))
3439 .into_response());
3440 }
3441 Ok(_) => {}
3442 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3443 }
3444 }
3445
3446 let is_at_uri = input
3451 .get(..crate::atproto::AT_URI_PREFIX.len())
3452 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3453 let publication_url = if is_at_uri {
3454 if !state.config.standard_site {
3455 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3456 return Ok(
3457 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3458 .into_response(),
3459 );
3460 }
3461 match publication_url_from_paste(&state, &input).await {
3462 Ok(url) => Some(url),
3463 Err(flash) => {
3464 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3465 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3466 }
3467 }
3468 } else {
3469 None
3470 };
3471
3472 if let feed::FeedPrivacy::Private(reason) =
3473 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3474 {
3475 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3476 return Ok(
3477 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3478 );
3479 }
3480
3481 let resolved = match publication_url {
3482 Some(url) => Ok(url),
3483 None => resolve_feed_url(&state.config, &input).await,
3484 };
3485 let feed_url = match resolved {
3486 Ok(u) => u,
3487 Err(err) => {
3488 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3489 return Ok(Redirect::to(&format!(
3490 "/?flash={}",
3491 qenc("Couldn't find a feed at that URL")
3492 ))
3493 .into_response());
3494 }
3495 };
3496
3497 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3501 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3502 return Ok(
3503 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3504 );
3505 }
3506
3507 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3512 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3513 return Ok(
3514 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3515 .into_response(),
3516 );
3517 }
3518
3519 let feeds_cap = state.config.max_feeds_global;
3523 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3524 match store::count_feeds(pool).await {
3525 Ok(n) if n >= feeds_cap => {
3526 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3527 return Ok(Redirect::to(&format!(
3528 "/?flash={}",
3529 qenc(
3530 "This instance is at its feed capacity right now. Please try again later."
3531 )
3532 ))
3533 .into_response());
3534 }
3535 Ok(_) => {}
3536 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3537 }
3538 }
3539
3540 store::upsert_feed(
3541 pool,
3542 &store::NewFeed {
3543 url: feed_url.clone(),
3544 ..Default::default()
3545 },
3546 )
3547 .await?;
3548
3549 if let Ok(client) = feed::build_client() {
3550 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3551 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3552 Ok(outcome) => {
3553 info!(feed = %feed_url, ?outcome, "polled new subscription");
3554 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3558 }
3559 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3560 }
3561 }
3562 }
3563
3564 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3565 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3566 sub.title = feed_row.title.clone();
3567 sub.site_url = feed_row.site_url.clone();
3568 }
3569 sub.folder = form
3570 .folder
3571 .map(|f| f.trim().to_string())
3572 .filter(|f| !f.is_empty());
3573
3574 match state.repo().add_subscription(&did, &sub).await {
3575 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3576 Err(err) => {
3577 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3578 }
3579 }
3580
3581 Ok(Redirect::to("/").into_response())
3582}
3583
3584async fn delete_subscription(
3586 State(state): State<AppState>,
3587 headers: HeaderMap,
3588 Path(rkey): Path<String>,
3589) -> Result<Response, WebError> {
3590 let did = match current_did(&state, &headers).await {
3591 Some(d) => d,
3592 None => return Ok(Redirect::to("/login").into_response()),
3593 };
3594 match state.repo().remove_subscription(&did, &rkey).await {
3595 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3596 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3597 }
3598 Ok(Redirect::to("/").into_response())
3599}
3600
3601#[derive(Debug, Deserialize)]
3603struct RenameSubForm {
3604 url: String,
3605 #[serde(default)]
3606 title: Option<String>,
3607 #[serde(default)]
3608 site_url: Option<String>,
3609 #[serde(default)]
3610 folder: Option<String>,
3611 #[serde(default)]
3617 seen_url: Option<String>,
3618 #[serde(default)]
3621 seen_title: Option<String>,
3622 #[serde(default)]
3626 seen_folder: Option<String>,
3627}
3628
3629async fn rename_subscription(
3632 State(state): State<AppState>,
3633 headers: HeaderMap,
3634 Path(rkey): Path<String>,
3635 Form(form): Form<RenameSubForm>,
3636) -> Result<Response, WebError> {
3637 let did = match current_did(&state, &headers).await {
3638 Some(d) => d,
3639 None => return Ok(Redirect::to("/login").into_response()),
3640 };
3641 let feed_url = form.url.trim().to_string();
3642
3643 if feed_url.is_empty() {
3647 return Ok(Redirect::to("/").into_response());
3648 }
3649
3650 let mut base: Option<Subscription> = None;
3666 for attempt in 1..=RENAME_ATTEMPTS {
3667 match rename_subscription_once(&state, &did, &rkey, &form, &mut base).await? {
3668 RenameAttempt::Done(resp) => return Ok(resp),
3669 RenameAttempt::Raced => {
3670 info!(%did, %rkey, attempt, "subscription changed between read and write; re-reading");
3671 }
3672 }
3673 }
3674 warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused rename: the subscription kept changing elsewhere");
3675 Ok(rename_conflict_response())
3676}
3677
3678fn rename_conflict_response() -> Response {
3681 Redirect::to(&format!(
3682 "/?flash={}",
3683 qenc(
3684 "This subscription was changed elsewhere while you were editing it — \
3685 nothing was renamed or moved. Reload and try again."
3686 )
3687 ))
3688 .into_response()
3689}
3690
3691fn form_value(v: Option<&str>) -> Option<String> {
3694 v.map(str::trim)
3695 .filter(|t| !t.is_empty())
3696 .map(str::to_string)
3697}
3698
3699#[derive(Debug, PartialEq, Eq)]
3701struct MergedRename {
3702 sub: Subscription,
3704 repoint: bool,
3706 already_saved: bool,
3709}
3710
3711#[derive(Debug, PartialEq, Eq)]
3713struct RenameConflict(&'static str);
3714
3715fn merge_rename(
3748 form: &RenameSubForm,
3749 base: &Subscription,
3750 fresh: Subscription,
3751) -> Result<MergedRename, RenameConflict> {
3752 let mut sub = fresh;
3753 let mut edited = 0;
3758 let mut to_write = 0;
3759
3760 let posted_url = form.url.trim();
3761 let seen_url = form.seen_url.as_deref().unwrap_or(&base.url).trim();
3762 let mut repoint = false;
3763 if posted_url != seen_url {
3764 edited += 1;
3765 if sub.url.trim() == posted_url {
3766 } else if sub.url.trim() != base.url.trim() {
3769 return Err(RenameConflict("url"));
3770 } else {
3771 to_write += 1;
3772 repoint = true;
3773 }
3774 }
3775 sub.url = if repoint { posted_url } else { sub.url.trim() }.to_string();
3777
3778 let posted_title = form_value(form.title.as_deref());
3779 let seen_title = match form.seen_title.as_deref() {
3780 Some(seen) => form_value(Some(seen)),
3781 None => base.title.clone(),
3782 };
3783 if posted_title != seen_title {
3784 edited += 1;
3785 if sub.title == posted_title {
3786 } else if sub.title != base.title {
3788 return Err(RenameConflict("title"));
3789 } else {
3790 to_write += 1;
3791 sub.title = posted_title;
3792 }
3793 }
3794
3795 if form.folder.is_some() || form.seen_folder.is_some() {
3803 let posted_folder = form_value(form.folder.as_deref());
3804 let seen_folder = match form.seen_folder.as_deref() {
3805 Some(seen) => form_value(Some(seen)),
3806 None => base.folder.clone(),
3807 };
3808 if posted_folder != seen_folder {
3809 edited += 1;
3810 if sub.folder == posted_folder {
3811 } else if sub.folder != base.folder {
3813 return Err(RenameConflict("folder"));
3814 } else {
3815 to_write += 1;
3816 sub.folder = posted_folder;
3817 }
3818 }
3819 }
3820
3821 match form_value(form.site_url.as_deref()) {
3830 Some(site) if Some(&site) != base.site_url.as_ref() => {
3831 edited += 1;
3832 if sub.site_url.as_ref() == Some(&site) {
3833 } else if sub.site_url != base.site_url {
3835 return Err(RenameConflict("siteUrl"));
3836 } else {
3837 to_write += 1;
3838 sub.site_url = Some(site);
3839 }
3840 }
3841 Some(_) => {}
3842 None if repoint => sub.site_url = None,
3843 None => {}
3844 }
3845 if repoint {
3846 sub.fetch_hint = None;
3847 }
3848 Ok(MergedRename {
3849 sub,
3850 repoint,
3851 already_saved: edited > 0 && to_write == 0,
3854 })
3855}
3856
3857const RENAME_ATTEMPTS: u32 = 2;
3861
3862enum RenameAttempt {
3864 Done(Response),
3867 Raced,
3870}
3871
3872async fn rename_subscription_once(
3879 state: &AppState,
3880 did: &str,
3881 rkey: &str,
3882 form: &RenameSubForm,
3883 base: &mut Option<Subscription>,
3884) -> Result<RenameAttempt, WebError> {
3885 use RenameAttempt::Done;
3886
3887 let found = match state.repo().list_subscriptions_with_cids(did).await {
3917 Ok(subs) => subs
3918 .into_iter()
3919 .find(|(k, _, _)| k == rkey)
3920 .map(|(_, cid, s)| (cid, s)),
3921 Err(err) => {
3922 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3923 return Ok(Done(
3924 Redirect::to(&format!(
3925 "/?flash={}",
3926 qenc("Could not reach your PDS — nothing was renamed or moved.")
3927 ))
3928 .into_response(),
3929 ));
3930 }
3931 };
3932 let Some((read_cid, fresh)) = found else {
3933 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3937 return Ok(Done(
3938 Redirect::to(&format!(
3939 "/?flash={}",
3940 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3941 ))
3942 .into_response(),
3943 ));
3944 };
3945
3946 let base = base.get_or_insert_with(|| fresh.clone());
3968 let MergedRename {
3969 sub,
3970 repoint: url_changed,
3971 already_saved,
3972 } = match merge_rename(form, base, fresh) {
3973 Ok(merged) => merged,
3974 Err(RenameConflict(field)) => {
3975 warn!(%did, %rkey, field, "refused rename: the reader and another client both changed the same field");
3976 return Ok(Done(rename_conflict_response()));
3977 }
3978 };
3979 if already_saved {
3983 info!(%did, %rkey, "rename already in the record; nothing to write");
3984 return Ok(Done(Redirect::to("/").into_response()));
3985 }
3986 let feed_url = sub.url.clone();
3987
3988 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
4001 if url_changed && !storable {
4002 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
4003 return Ok(Done(
4004 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
4005 .into_response(),
4006 ));
4007 }
4008
4009 if url_changed {
4015 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
4016 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
4017 return Ok(Done(
4018 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
4019 ));
4020 }
4021 }
4022
4023 let feeds_cap = state.config.max_feeds_global;
4028 if url_changed
4029 && feeds_cap > 0
4030 && store::get_feed_by_url(&state.db, &feed_url)
4031 .await?
4032 .is_none()
4033 {
4034 match store::count_feeds(&state.db).await {
4035 Ok(n) if n >= feeds_cap => {
4036 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
4037 return Ok(Done(
4038 Redirect::to(&format!(
4039 "/?flash={}",
4040 qenc(
4041 "This instance is at its feed capacity right now. Please try again later."
4042 )
4043 ))
4044 .into_response(),
4045 ));
4046 }
4047 Ok(_) => {}
4048 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
4049 }
4050 }
4051
4052 if read_cid.is_none() {
4064 warn!(%did, %rkey, "the PDS listed this subscription without a CID; renaming without a compare-and-swap");
4065 }
4066 let res = match state
4067 .repo()
4068 .update_subscription(did, rkey, &sub, read_cid.as_deref())
4069 .await
4070 {
4071 Ok(res) => res,
4072 Err(err) if crate::atproto::is_invalid_swap(&err) => return Ok(RenameAttempt::Raced),
4075 Err(err) => {
4076 warn!(%err, %did, %rkey, "PDS subscription update failed");
4077 return Ok(Done(
4078 Redirect::to(&format!(
4079 "/?flash={}",
4080 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
4081 ))
4082 .into_response(),
4083 ));
4084 }
4085 };
4086 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
4087
4088 let cache_write = storable
4114 && (url_changed
4115 || match store::get_feed_by_url(&state.db, &sub.url).await {
4116 Ok(row) => row.is_some(),
4117 Err(err) => {
4118 warn!(%err, %did, url = %sub.url, "could not look up the cached feed row after a rename");
4119 false
4120 }
4121 });
4122 if !cache_write {
4123 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
4124 } else if let Err(err) = store::upsert_feed(
4125 &state.db,
4126 &store::NewFeed {
4127 url: sub.url.clone(),
4128 title: sub.title.clone(),
4129 site_url: sub.site_url.clone(),
4130 ..Default::default()
4131 },
4132 )
4133 .await
4134 {
4135 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
4138 }
4139
4140 Ok(Done(Redirect::to("/").into_response()))
4141}
4142
4143#[derive(Debug, Deserialize)]
4149struct FolderForm {
4150 name: String,
4151}
4152
4153async fn create_folder(
4155 State(state): State<AppState>,
4156 headers: HeaderMap,
4157 Form(form): Form<FolderForm>,
4158) -> Result<Response, WebError> {
4159 let did = match current_did(&state, &headers).await {
4160 Some(d) => d,
4161 None => return Ok(Redirect::to("/login").into_response()),
4162 };
4163 let name = form.name.trim();
4164 if name.is_empty() {
4165 return Ok(Redirect::to("/").into_response());
4166 }
4167 let folder = Folder::new(name.to_string(), now_rfc3339());
4168 match state.repo().add_folder(&did, &folder).await {
4169 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
4170 Err(err) => warn!(%err, %did, "PDS folder create failed"),
4171 }
4172 Ok(Redirect::to("/").into_response())
4173}
4174
4175#[derive(Debug, Deserialize)]
4177struct RenameFolderForm {
4178 name: String,
4179 #[serde(default)]
4184 seen_name: Option<String>,
4185}
4186
4187async fn rename_folder(
4200 State(state): State<AppState>,
4201 headers: HeaderMap,
4202 Path(rkey): Path<String>,
4203 Form(form): Form<RenameFolderForm>,
4204) -> Result<Response, WebError> {
4205 let did = match current_did(&state, &headers).await {
4206 Some(d) => d,
4207 None => return Ok(Redirect::to("/login").into_response()),
4208 };
4209 if form.name.trim().is_empty() {
4210 return Ok(Redirect::to("/").into_response());
4211 }
4212 let mut base: Option<Folder> = None;
4213 for attempt in 1..=RENAME_ATTEMPTS {
4214 match rename_folder_once(&state, &did, &rkey, &form, &mut base).await {
4215 RenameAttempt::Done(resp) => return Ok(resp),
4216 RenameAttempt::Raced => {
4217 info!(%did, %rkey, attempt, "folder changed between read and write; re-reading");
4218 }
4219 }
4220 }
4221 warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused folder rename: the folder kept changing elsewhere");
4222 Ok(folder_flash(FOLDER_RENAME_CONFLICT))
4223}
4224
4225const FOLDER_RENAME_CONFLICT: &str = "This folder was changed elsewhere while you were renaming \
4227 it — it was not renamed. Reload and try again.";
4228
4229fn folder_flash(message: &str) -> Response {
4231 Redirect::to(&format!("/?flash={}", qenc(message))).into_response()
4232}
4233
4234#[derive(Debug, PartialEq, Eq)]
4236enum FolderMerge {
4237 Write(Folder),
4239 AlreadySaved,
4242 Unchanged,
4244}
4245
4246fn merge_folder_rename(
4262 posted: &str,
4263 seen: Option<&str>,
4264 base: &Folder,
4265 fresh: Folder,
4266) -> Result<FolderMerge, RenameConflict> {
4267 let posted = posted.trim();
4268 let ancestor = seen.unwrap_or(&base.name).trim();
4269 if posted == ancestor {
4270 return Ok(FolderMerge::Unchanged);
4271 }
4272 let current = fresh.name.trim();
4273 if current == posted {
4274 return Ok(FolderMerge::AlreadySaved);
4275 }
4276 if current != ancestor {
4277 return Err(RenameConflict("name"));
4278 }
4279 let mut folder = fresh;
4280 folder.name = posted.to_string();
4281 Ok(FolderMerge::Write(folder))
4282}
4283
4284async fn rename_folder_once(
4288 state: &AppState,
4289 did: &str,
4290 rkey: &str,
4291 form: &RenameFolderForm,
4292 base: &mut Option<Folder>,
4293) -> RenameAttempt {
4294 use RenameAttempt::Done;
4295
4296 let found = match state.repo().list_folders_with_cids(did).await {
4301 Ok(folders) => folders
4302 .into_iter()
4303 .find(|(k, _, _)| k == rkey)
4304 .map(|(_, cid, f)| (cid, f)),
4305 Err(err) => {
4306 warn!(%err, %did, %rkey, "could not read the folder before renaming it");
4307 return Done(folder_flash(
4308 "Could not reach your PDS — the folder was not renamed.",
4309 ));
4310 }
4311 };
4312 let Some((read_cid, fresh)) = found else {
4313 warn!(%did, %rkey, "refused folder rename: no such folder in the repo");
4316 return Done(folder_flash(
4317 "That folder no longer exists — it may have been deleted elsewhere. \
4318 Nothing was renamed.",
4319 ));
4320 };
4321
4322 let base = base.get_or_insert_with(|| fresh.clone());
4323 let folder = match merge_folder_rename(&form.name, form.seen_name.as_deref(), base, fresh) {
4324 Ok(FolderMerge::Write(folder)) => folder,
4325 Ok(FolderMerge::AlreadySaved) => {
4326 info!(%did, %rkey, "folder already has this name; nothing to write");
4327 return Done(Redirect::to("/").into_response());
4328 }
4329 Ok(FolderMerge::Unchanged) => return Done(Redirect::to("/").into_response()),
4330 Err(RenameConflict(field)) => {
4331 warn!(%did, %rkey, field, "refused folder rename: the reader and another client both renamed it");
4332 return Done(folder_flash(FOLDER_RENAME_CONFLICT));
4333 }
4334 };
4335
4336 if read_cid.is_none() {
4337 warn!(%did, %rkey, "the PDS listed this folder without a CID; renaming without a compare-and-swap");
4338 }
4339 match state
4340 .repo()
4341 .rename_folder(did, rkey, &folder, read_cid.as_deref())
4342 .await
4343 {
4344 Ok(res) => {
4345 info!(%did, %rkey, uri = %res.uri, "renamed folder");
4346 Done(Redirect::to("/").into_response())
4347 }
4348 Err(err) if crate::atproto::is_invalid_swap(&err) => RenameAttempt::Raced,
4349 Err(err) => {
4350 warn!(%err, %did, %rkey, "PDS folder rename failed");
4351 Done(folder_flash(
4352 "Could not save that change to your PDS — the folder was not renamed.",
4353 ))
4354 }
4355 }
4356}
4357
4358async fn delete_folder(
4361 State(state): State<AppState>,
4362 headers: HeaderMap,
4363 Path(rkey): Path<String>,
4364) -> Result<Response, WebError> {
4365 let did = match current_did(&state, &headers).await {
4366 Some(d) => d,
4367 None => return Ok(Redirect::to("/login").into_response()),
4368 };
4369 match state.repo().remove_folder(&did, &rkey).await {
4370 Ok(()) => info!(%did, %rkey, "deleted folder record"),
4371 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
4372 }
4373 Ok(Redirect::to("/").into_response())
4374}
4375
4376async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
4380 let parsed =
4381 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
4382
4383 let client = feed::build_client()?;
4384 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
4388 let final_url = resp.url().clone();
4389 let content_type = resp
4390 .headers()
4391 .get(axum::http::header::CONTENT_TYPE)
4392 .and_then(|v| v.to_str().ok())
4393 .unwrap_or("")
4394 .to_ascii_lowercase();
4395 let raw = crate::net::read_capped(resp).await?;
4398 let body = String::from_utf8_lossy(&raw).into_owned();
4399
4400 let looks_like_feed = content_type.contains("xml")
4401 || content_type.contains("rss")
4402 || content_type.contains("atom")
4403 || content_type.contains("application/feed+json")
4404 || {
4405 let head = body.trim_start();
4406 head.starts_with("<?xml")
4407 || head.starts_with("<rss")
4408 || head.starts_with("<feed")
4409 || head.contains("<rss")
4410 || head.contains("<feed")
4411 };
4412 if looks_like_feed {
4413 return Ok(final_url.to_string());
4414 }
4415
4416 match feed::discover_feed(&body, Some(&final_url)) {
4417 Some(u) => Ok(u.to_string()),
4418 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
4419 }
4420}
4421
4422#[derive(Debug, Deserialize, Default)]
4428struct LoginQuery {
4429 #[serde(default)]
4430 handle: Option<String>,
4431 #[serde(default)]
4432 error: Option<String>,
4433 #[serde(default)]
4434 flash: Option<String>,
4435}
4436
4437async fn login_form(
4445 State(state): State<AppState>,
4446 headers: HeaderMap,
4447 Query(q): Query<LoginQuery>,
4448) -> Response {
4449 if let Some(handle) = q
4450 .handle
4451 .map(|h| h.trim().to_string())
4452 .filter(|h| !h.is_empty())
4453 {
4454 if !may_start_oauth(&state, &headers, &handle).await {
4455 return Redirect::to("/beta/redeem").into_response();
4456 }
4457 return start_oauth(&state, &handle).await;
4458 }
4459 render(&LoginTemplate {
4460 card: login_card(&state.config),
4461 repo_url: REPO_URL,
4462 error: q.error.unwrap_or_default(),
4463 flash: q.flash.unwrap_or_default(),
4464 })
4465}
4466
4467async fn login_submit(
4470 State(state): State<AppState>,
4471 headers: HeaderMap,
4472 Form(form): Form<LoginForm>,
4473) -> Response {
4474 let handle = form.handle.trim();
4475 if handle.is_empty() {
4476 return login_error(&state, "Enter your atproto handle.");
4477 }
4478 if !may_start_oauth(&state, &headers, handle).await {
4479 return Redirect::to("/beta/redeem").into_response();
4480 }
4481 start_oauth(&state, handle).await
4482}
4483
4484async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4502 may_start_oauth_with(state, headers, handle, |h| async move {
4506 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4507 .await
4508 .ok()
4509 })
4510 .await
4511}
4512
4513async fn may_start_oauth_with<F, Fut>(
4519 state: &AppState,
4520 headers: &HeaderMap,
4521 handle: &str,
4522 resolve: F,
4523) -> bool
4524where
4525 F: FnOnce(String) -> Fut,
4526 Fut: std::future::Future<Output = Option<String>>,
4527{
4528 if let Some(did) = current_did(state, headers).await {
4530 if store::has_beta_access(&state.db, &did)
4531 .await
4532 .unwrap_or(false)
4533 {
4534 return true;
4535 }
4536 }
4537 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4539 return true;
4540 }
4541 match resolve(handle.to_string()).await {
4545 Some(did) => store::has_beta_access(&state.db, &did)
4546 .await
4547 .unwrap_or(false),
4548 None => {
4549 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4550 false
4551 }
4552 }
4553}
4554
4555async fn start_oauth(state: &AppState, handle: &str) -> Response {
4577 match state.config.repo_backend {
4578 crate::metrics::Backend::Sidecar => {
4579 let url = state.sidecar.login_url(handle, None);
4580 info!(%handle, "redirecting to OAuth sidecar login");
4581 Redirect::to(&url).into_response()
4582 }
4583 crate::metrics::Backend::Rust => {
4584 let Some(runtime) = state.oauth.as_deref() else {
4585 warn!("the rust backend is live but its OAuth runtime is absent");
4586 return login_error(state, "Login is not available right now.");
4587 };
4588 match crate::oauth::login::start(
4589 runtime,
4590 &state.http,
4591 &state.db,
4592 handle,
4593 crate::store::now_unix(),
4594 )
4595 .await
4596 {
4597 Ok(started) => {
4598 info!(%handle, "pushed authorization request; redirecting to the PDS");
4599 let mut resp = Redirect::to(&started.authorize_url).into_response();
4600 set_cookie(
4601 &mut resp,
4602 &cookie::sign_value(
4603 OAUTH_BINDING_COOKIE,
4604 &started.binding_token,
4605 &state.config.cookie_secret,
4606 OAUTH_BINDING_MAX_AGE_SECS,
4607 ),
4608 );
4609 resp
4610 }
4611 Err(err) => {
4612 warn!(%err, %handle, "could not start the OAuth login");
4615 login_error(state, "Could not start login for that handle.")
4616 }
4617 }
4618 }
4619 }
4620}
4621
4622fn clear_binding_cookie(resp: &mut Response) {
4626 set_cookie(
4627 resp,
4628 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4629 );
4630}
4631
4632#[derive(Debug, Deserialize)]
4634struct LoginForm {
4635 handle: String,
4636}
4637
4638#[derive(Debug, Deserialize, Default)]
4647struct CallbackQuery {
4648 #[serde(default)]
4650 session_id: Option<String>,
4651 #[serde(default)]
4653 code: Option<String>,
4654 #[serde(default)]
4655 state: Option<String>,
4656 #[serde(default)]
4657 iss: Option<String>,
4658 #[serde(default)]
4661 response: Option<String>,
4662 #[serde(default)]
4663 error: Option<String>,
4664 #[serde(default)]
4665 error_description: Option<String>,
4666}
4667
4668async fn oauth_callback(
4675 State(state): State<AppState>,
4676 headers: HeaderMap,
4677 Query(q): Query<CallbackQuery>,
4678) -> Response {
4679 let sidecar_shape =
4709 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4710 let sidecar_handoff = sidecar_shape
4711 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4712 if let Some(err) = q.error.clone() {
4713 let slug = crate::oauth::flow::known_error_slug(&err);
4729 warn!(
4730 error = slug,
4731 desc_len = q.error_description.as_deref().map_or(0, str::len),
4732 "OAuth callback returned an error"
4733 );
4734 if sidecar_handoff || state.oauth.is_none() {
4735 return login_error(&state, &format!("Login failed: {slug}"));
4736 }
4737 }
4740
4741 let session = if sidecar_handoff {
4744 let session_id = q.session_id.clone().unwrap_or_default();
4745 match state.sidecar.resolve_session(&session_id).await {
4746 Ok(Some(s)) => s,
4747 Ok(None) => {
4748 warn!("OAuth callback session_id did not resolve (expired/unknown)");
4749 return login_error(&state, "Login session expired — please try again.");
4750 }
4751 Err(err) => {
4752 warn!(%err, "failed to resolve OAuth session via the sidecar");
4753 return login_error(&state, "Login failed talking to the auth service.");
4754 }
4755 }
4756 } else {
4757 let Some(runtime) = state.oauth.as_deref() else {
4758 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4759 return login_error(&state, "Login failed: this login could not be completed.");
4760 };
4761 let params = crate::oauth::flow::CallbackParams {
4762 code: q.code.clone(),
4763 state: q.state.clone(),
4764 iss: q.iss.clone(),
4765 error: q.error.clone(),
4769 error_description: q.error_description.clone(),
4770 response: q.response.clone(),
4771 };
4772 let binding =
4773 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4774 match crate::oauth::login::complete(
4775 runtime,
4776 &state.http,
4777 &state.db,
4778 ¶ms,
4779 binding.as_deref(),
4780 crate::store::now_unix(),
4781 )
4782 .await
4783 {
4784 Ok(done) => crate::atproto::SidecarSession {
4785 did: done.did,
4786 handle: done.handle,
4787 },
4788 Err(err) => {
4789 warn!(%err, "could not complete the OAuth callback");
4792 let mut resp = login_error(&state, "Login failed — please try again.");
4793 clear_binding_cookie(&mut resp);
4794 return resp;
4795 }
4796 }
4797 };
4798
4799 let mut clear_invite = false;
4802 if !store::has_beta_access(&state.db, &session.did)
4803 .await
4804 .unwrap_or(false)
4805 {
4806 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4808 Some(c) => c,
4809 None => {
4810 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4811 return Redirect::to("/beta/redeem").into_response();
4812 }
4813 };
4814 match store::redeem_code(
4815 &state.db,
4816 &code,
4817 &session.did,
4818 session.handle.as_deref(),
4819 state.config.beta_cap,
4820 )
4821 .await
4822 {
4823 Ok(Ok(())) => {
4824 clear_invite = true;
4825 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4826 }
4827 Ok(Err(policy)) => {
4828 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4829 let mut resp = redeem_bounce(&state, &policy).into_response();
4830 clear_invite_cookie(&mut resp);
4832 return resp;
4833 }
4834 Err(err) => {
4835 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4836 return login_error(&state, "Login failed while confirming your invite.");
4837 }
4838 }
4839 }
4840
4841 let sid = state.sessions.create(Session {
4844 did: session.did.clone(),
4845 handle: session.handle.clone(),
4846 });
4847 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4848 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4849
4850 let mut resp = Redirect::to("/").into_response();
4851 set_cookie(&mut resp, &cookie);
4852 clear_binding_cookie(&mut resp);
4853 if clear_invite {
4854 clear_invite_cookie(&mut resp);
4855 }
4856 resp
4857}
4858
4859const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4877
4878async fn flush_before_revoke(state: &AppState, did: &str) {
4891 match tokio::time::timeout(
4892 SIGN_OUT_FLUSH_BUDGET,
4893 crate::readstate::flush_did(state, did),
4894 )
4895 .await
4896 {
4897 Ok(Ok(())) => {}
4898 Ok(Err(err)) => {
4899 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4900 }
4901 Err(_) => warn!(
4902 %did,
4903 budget = ?SIGN_OUT_FLUSH_BUDGET,
4904 "sign-out: final read-state flush timed out; it will park until next sign-in"
4905 ),
4906 }
4907}
4908
4909async fn revoke_everywhere(state: &AppState, did: &str) {
4910 let sidecar_started = std::time::Instant::now();
4920 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4921 Ok(res) => {
4922 info!(%did, revoked = res.revoked, "sidecar session revoked");
4923 true
4924 }
4925 Err(err) => {
4926 warn!(%did, %err, "sidecar revoke failed; continuing");
4927 false
4928 }
4929 };
4930 state.metrics.record(
4931 crate::metrics::Backend::Sidecar,
4932 "oauth_revoke",
4933 sidecar_started.elapsed().as_micros() as u64,
4934 sidecar_ok,
4935 );
4936
4937 if let Some(runtime) = state.oauth.as_deref() {
4938 let revoke_started = std::time::Instant::now();
4939 let outcome = crate::oauth::revoke::sign_out_discovering(
4940 runtime,
4941 &state.http,
4942 &state.db,
4943 did,
4944 crate::store::now_unix(),
4945 )
4946 .await;
4947 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4958 state.metrics.record(
4959 crate::metrics::Backend::Rust,
4960 "oauth_revoke",
4961 revoke_started.elapsed().as_micros() as u64,
4962 revoke_ok,
4963 );
4964 match outcome {
4965 crate::oauth::revoke::Revocation::Revoked => {
4966 info!(%did, "rust OAuth session revoked at the PDS")
4967 }
4968 crate::oauth::revoke::Revocation::NoSession => {}
4969 crate::oauth::revoke::Revocation::Failed(reason) => {
4970 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4971 }
4972 }
4973 }
4974}
4975
4976async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4986 if let Some(user) = current_session(&state, &headers).await {
4987 if let Some(sid) = user.sid {
4990 state.sessions.remove(&sid);
4991 flush_before_revoke(&state, &user.did).await;
4993 revoke_everywhere(&state, &user.did).await;
4994 }
4995 }
4996 let mut resp = Redirect::to("/login").into_response();
4997 set_cookie(
4998 &mut resp,
4999 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5000 );
5001 resp
5002}
5003
5004#[derive(Debug, Deserialize)]
5007struct DeleteAccountForm {
5008 #[serde(default)]
5009 confirm: String,
5010}
5011
5012const DELETE_CONFIRM_PHRASE: &str = "DELETE";
5014
5015async fn account_delete(
5031 State(state): State<AppState>,
5032 headers: HeaderMap,
5033 Form(form): Form<DeleteAccountForm>,
5034) -> Result<Response, WebError> {
5035 let user = match current_session(&state, &headers).await {
5036 Some(u) => u,
5037 None => return Ok(Redirect::to("/login").into_response()),
5038 };
5039 let did = user.did.clone();
5040
5041 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
5043 return Ok(Redirect::to(&format!(
5044 "/manage?flash={}",
5045 qenc("Type DELETE to confirm — nothing was deleted.")
5046 ))
5047 .into_response());
5048 }
5049
5050 let counts = store::purge_did_data(&state.db, &did).await?;
5052 info!(
5053 %did,
5054 total = counts.total(),
5055 entry_state = counts.entry_state,
5056 read_cursor = counts.read_cursor,
5057 sub_ref = counts.sub_ref,
5058 beta_access = counts.beta_access,
5059 invite_codes = counts.invite_codes,
5060 "account/delete: local rows purged"
5061 );
5062
5063 revoke_everywhere(&state, &did).await;
5066
5067 if let Some(sid) = user.sid {
5069 state.sessions.remove(&sid);
5070 }
5071 let mut resp = Redirect::to(&format!(
5072 "/login?flash={}",
5073 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
5074 ))
5075 .into_response();
5076 set_cookie(
5077 &mut resp,
5078 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5079 );
5080 Ok(resp)
5081}
5082
5083fn login_card(config: &Config) -> Card {
5085 Card::public(
5086 config,
5087 "/login",
5088 "Sign in — FeatherReader",
5089 "Sign in to FeatherReader with your atproto handle. You approve access on \
5090 your own server — no signup, no password.",
5091 )
5092}
5093
5094fn login_error(state: &AppState, msg: &str) -> Response {
5096 render(&LoginTemplate {
5097 card: login_card(&state.config),
5098 repo_url: REPO_URL,
5099 error: msg.to_string(),
5100 flash: String::new(),
5101 })
5102}
5103
5104#[derive(Debug, Deserialize)]
5110struct RedeemForm {
5111 code: String,
5112}
5113
5114async fn beta_redeem_form(State(state): State<AppState>) -> Response {
5117 let full = store::count_beta_access(&state.db)
5118 .await
5119 .map(|n| n >= state.config.beta_cap)
5120 .unwrap_or(false);
5121 render(&BetaRedeemTemplate {
5122 card: redeem_card(&state.config),
5123 repo_url: REPO_URL,
5124 error: String::new(),
5125 capacity_full: full,
5126 })
5127}
5128
5129async fn beta_redeem_submit(
5139 State(state): State<AppState>,
5140 Form(form): Form<RedeemForm>,
5141) -> Response {
5142 let code = form.code.trim().to_uppercase();
5143 if code.is_empty() {
5144 return render(&BetaRedeemTemplate {
5145 card: redeem_card(&state.config),
5146 repo_url: REPO_URL,
5147 error: "Enter your invite code.".to_string(),
5148 capacity_full: false,
5149 });
5150 }
5151
5152 match preflight_code(&state, &code).await {
5153 Ok(()) => {
5154 let cookie = sign_invite(&code, &state.config.cookie_secret);
5155 let mut resp = Redirect::to("/login").into_response();
5156 set_cookie(&mut resp, &cookie);
5157 info!("invite code preflight OK; reserving intent + redirecting to /login");
5158 resp
5159 }
5160 Err(policy) => {
5161 warn!(?policy, "invite code preflight rejected");
5162 redeem_bounce(&state, &policy)
5163 }
5164 }
5165}
5166
5167async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
5173 let count = match store::count_beta_access(&state.db).await {
5181 Ok(n) => n,
5182 Err(err) => {
5183 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
5184 return Err(store::RedeemError::CapacityFull);
5185 }
5186 };
5187 if count >= state.config.beta_cap {
5188 return Err(store::RedeemError::CapacityFull);
5189 }
5190 let row = sqlx::query_as::<_, (String, i64)>(
5192 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
5193 )
5194 .bind(code)
5195 .fetch_optional(&state.db)
5196 .await
5197 .ok()
5198 .flatten();
5199 let (status, expires_at) = match row {
5200 Some(r) => r,
5201 None => return Err(store::RedeemError::NotFound),
5202 };
5203 let now = chrono::Utc::now().timestamp();
5204 match status.as_str() {
5205 "active" if expires_at >= now => Ok(()),
5206 "active" => Err(store::RedeemError::Expired),
5207 "expired" => Err(store::RedeemError::Expired),
5208 _ => Err(store::RedeemError::AlreadyRedeemed),
5210 }
5211}
5212
5213fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
5216 use store::RedeemError::*;
5217 let (msg, capacity_full) = match policy {
5218 NotFound => ("That invite code isn't valid.", false),
5219 Expired => ("That invite code has expired.", false),
5220 AlreadyRedeemed => ("That invite code has already been used.", false),
5221 CapacityFull => ("", true),
5222 };
5223 render(&BetaRedeemTemplate {
5224 card: redeem_card(&state.config),
5225 repo_url: REPO_URL,
5226 error: msg.to_string(),
5227 capacity_full,
5228 })
5229}
5230
5231fn redeem_card(config: &Config) -> Card {
5234 Card::public(
5235 config,
5236 "/beta/redeem",
5237 "Redeem an invite — FeatherReader",
5238 "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
5239 in with your atproto handle.",
5240 )
5241}
5242
5243#[derive(Debug, Deserialize, Default)]
5245struct MintQuery {
5246 #[serde(default)]
5247 n: Option<u32>,
5248}
5249
5250async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
5263 let Some(runtime) = state.oauth.as_deref() else {
5264 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
5266 };
5267 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
5268}
5269
5270async fn oauth_jwks(State(state): State<AppState>) -> Response {
5277 let Some(runtime) = state.oauth.as_deref() else {
5278 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
5279 };
5280 match runtime.client_key.as_ref() {
5281 Some(key) => match key.jwks_document() {
5282 Ok(doc) => axum::Json(doc).into_response(),
5283 Err(err) => {
5284 warn!(%err, "could not render the client JWKS");
5285 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
5286 }
5287 },
5288 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
5289 }
5290}
5291
5292const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
5294
5295async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
5304 let did = match current_did(&state, &headers).await {
5305 Some(d) => d,
5306 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5307 };
5308 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5309 warn!(%did, "admin metrics denied: not an admin-seed DID");
5310 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5311 }
5312
5313 if let Err(err) =
5318 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
5319 {
5320 warn!(%err, "could not flush repo timings before rendering");
5321 }
5322 let rows = match crate::metrics::persisted_rows(&state.db).await {
5323 Ok(rows) => rows,
5324 Err(err) => {
5325 warn!(%err, "could not read persisted repo timings");
5326 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
5327 }
5328 };
5329
5330 let parked = match crate::store::parked_readstate_dids(&state.db).await {
5336 Ok(n) => n.to_string(),
5337 Err(err) => {
5338 warn!(%err, "could not count parked read-state DIDs");
5339 "unknown".to_string()
5340 }
5341 };
5342 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
5349 Ok(f) => f,
5350 Err(err) => {
5351 warn!(%err, "could not list failing feeds");
5352 Vec::new()
5353 }
5354 };
5355 let mut failing_block = String::new();
5356 if !failing.is_empty() {
5357 failing_block.push_str("\nfailing feeds (worst first)\n");
5358 for f in &failing {
5359 failing_block.push_str(&format!(
5360 " {:>4}x {:<8} {}\n {}\n",
5361 f.consecutive_errors,
5362 f.kind.as_deref().unwrap_or("unknown"),
5363 f.url,
5364 f.detail.as_deref().unwrap_or("(no detail recorded)"),
5365 ));
5366 }
5367 }
5368
5369 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
5374 Ok(n) => n,
5375 Err(err) => {
5376 warn!(%err, "could not count unpollable feeds");
5377 -1
5378 }
5379 };
5380 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
5381
5382 let body = format!(
5383 "live backend: {}\nparked read-state DIDs: {}\n\
5384 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
5385 state.config.repo_backend.as_str(),
5386 parked,
5387 cached,
5388 state.config.max_feeds_global,
5389 unpollable,
5390 crate::metrics::render(&rows),
5391 failing_block,
5392 );
5393 (StatusCode::OK, body).into_response()
5394}
5395
5396async fn admin_mint_invites(
5400 State(state): State<AppState>,
5401 headers: HeaderMap,
5402 Query(q): Query<MintQuery>,
5403) -> Response {
5404 let did = match current_did(&state, &headers).await {
5407 Some(d) => d,
5408 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5409 };
5410 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5411 warn!(%did, "admin mint denied: not an admin-seed DID");
5412 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5413 }
5414
5415 let n = q.n.unwrap_or(1).clamp(1, 100);
5416 let mut codes = Vec::with_capacity(n as usize);
5417 for _ in 0..n {
5418 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
5419 Ok(code) => codes.push(code),
5420 Err(err) => {
5421 warn!(%err, %did, "admin mint_code failed");
5422 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5423 }
5424 }
5425 }
5426 info!(%did, count = codes.len(), "admin minted invite codes");
5427 let mut body = codes.join("\n");
5428 body.push('\n');
5429 (StatusCode::OK, body).into_response()
5430}
5431
5432#[derive(Debug, Deserialize)]
5438struct ClaimQuery {
5439 t: Option<String>,
5441}
5442
5443async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
5462 let token = match q.t {
5463 Some(t) if !t.is_empty() => t,
5464 _ => {
5465 warn!("claim link with no token");
5466 return redeem_bounce(&state, &store::RedeemError::NotFound);
5467 }
5468 };
5469
5470 let code = match claim_token_code(&token, &state.config.cookie_secret) {
5473 Some(c) => c,
5474 None => {
5475 warn!("claim token invalid (bad signature / malformed)");
5476 return redeem_bounce(&state, &store::RedeemError::NotFound);
5477 }
5478 };
5479
5480 match preflight_code(&state, &code).await {
5484 Ok(()) => {
5485 let cookie = sign_invite(&code, &state.config.cookie_secret);
5486 let mut resp = Redirect::to("/login").into_response();
5487 set_cookie(&mut resp, &cookie);
5488 info!("claim token preflight OK; reserving intent + redirecting to /login");
5489 resp
5490 }
5491 Err(policy) => {
5492 warn!(?policy, "claim token preflight rejected");
5493 redeem_bounce(&state, &policy)
5494 }
5495 }
5496}
5497
5498#[derive(Debug, Default, Deserialize)]
5505struct BotClaimRequest {
5506 #[serde(default)]
5509 did: Option<String>,
5510 #[serde(default)]
5512 #[allow(dead_code)]
5513 handle: Option<String>,
5514}
5515
5516#[derive(Debug, serde::Serialize)]
5518struct BotClaimResponse {
5519 status: &'static str,
5525 code: String,
5529 token: String,
5532 url: String,
5535}
5536
5537async fn bot_mint_claim(
5565 State(state): State<AppState>,
5566 headers: HeaderMap,
5567 body: axum::body::Bytes,
5568) -> Response {
5569 let bot_secret = match state.config.bot_secret.as_deref() {
5571 Some(s) => s,
5572 None => {
5573 warn!(
5574 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5575 );
5576 return (
5577 StatusCode::SERVICE_UNAVAILABLE,
5578 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5579 )
5580 .into_response();
5581 }
5582 };
5583
5584 let presented = headers
5586 .get("x-bot-secret")
5587 .and_then(|v| v.to_str().ok())
5588 .unwrap_or("");
5589 if !bot_secret_matches(presented, bot_secret) {
5590 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5591 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5592 }
5593
5594 let req: BotClaimRequest = if body.is_empty() {
5597 BotClaimRequest::default()
5598 } else {
5599 match serde_json::from_slice(&body) {
5600 Ok(r) => r,
5601 Err(err) => {
5602 warn!(%err, "POST /bot/claims: bad JSON body");
5603 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5604 }
5605 }
5606 };
5607 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5608
5609 if let Some(did) = follower_did {
5611 match store::has_beta_access(&state.db, did).await {
5613 Ok(true) => {
5614 info!("bot mint: DID already holds beta access; already_seated");
5615 return bot_claim_json(BotClaimResponse {
5616 status: "already_seated",
5617 code: String::new(),
5618 token: String::new(),
5619 url: String::new(),
5620 });
5621 }
5622 Ok(false) => {}
5623 Err(err) => {
5624 warn!(%err, "bot mint: has_beta_access failed");
5626 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5627 }
5628 }
5629 match store::find_active_code_for_did(&state.db, did).await {
5632 Ok(Some(code)) => {
5633 info!("bot mint: existing outstanding claim for DID; returning same code");
5634 let token = sign_claim_token(&code, &state.config.cookie_secret);
5635 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5636 return bot_claim_json(BotClaimResponse {
5637 status: "existing",
5638 code,
5639 token,
5640 url,
5641 });
5642 }
5643 Ok(None) => {}
5644 Err(err) => {
5645 warn!(%err, "bot mint: find_active_code_for_did failed");
5646 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5647 }
5648 }
5649 }
5650
5651 let granted = match store::count_beta_access(&state.db).await {
5654 Ok(n) => n,
5655 Err(err) => {
5656 warn!(%err, "bot mint: count_beta_access failed; failing closed");
5657 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5658 }
5659 };
5660 let outstanding = match store::count_active_codes(&state.db).await {
5661 Ok(n) => n,
5662 Err(err) => {
5663 warn!(%err, "bot mint: count_active_codes failed; failing closed");
5664 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5665 }
5666 };
5667 if granted + outstanding >= state.config.beta_cap {
5668 info!(
5669 granted,
5670 outstanding,
5671 cap = state.config.beta_cap,
5672 "bot mint refused: at capacity"
5673 );
5674 return (
5675 StatusCode::CONFLICT,
5676 [(header::CONTENT_TYPE, "application/json")],
5677 "{\"error\":\"full\"}\n",
5678 )
5679 .into_response();
5680 }
5681
5682 let bot_did = state
5685 .config
5686 .admin_seed_dids()
5687 .first()
5688 .cloned()
5689 .unwrap_or_else(|| "did:bot:featherreader".to_string());
5690 let minted = match follower_did {
5691 Some(did) => {
5692 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5693 }
5694 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5695 };
5696 let code = match minted {
5697 Ok(c) => c,
5698 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5705 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5706 Ok(Some(code)) => {
5707 info!("bot mint: lost the mint race; returning the concurrently-minted code");
5708 let token = sign_claim_token(&code, &state.config.cookie_secret);
5709 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5710 return bot_claim_json(BotClaimResponse {
5711 status: "existing",
5712 code,
5713 token,
5714 url,
5715 });
5716 }
5717 Ok(None) => {
5721 warn!("bot mint: conflict but no active code found on recovery");
5722 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5723 }
5724 Err(err) => {
5725 warn!(%err, "bot mint: recovery lookup after conflict failed");
5726 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5727 }
5728 }
5729 }
5730 Err(err) => {
5731 warn!(%err, "bot mint_code failed");
5732 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5733 }
5734 };
5735 let token = sign_claim_token(&code, &state.config.cookie_secret);
5736 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5737 info!("bot minted a claim code + token");
5738
5739 bot_claim_json(BotClaimResponse {
5740 status: "minted",
5741 code,
5742 token,
5743 url,
5744 })
5745}
5746
5747fn bot_claim_json(resp: BotClaimResponse) -> Response {
5750 match serde_json::to_string(&resp) {
5751 Ok(body) => (
5752 StatusCode::OK,
5753 [(header::CONTENT_TYPE, "application/json")],
5754 body,
5755 )
5756 .into_response(),
5757 Err(err) => {
5758 warn!(%err, "serializing bot claim response failed");
5759 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5760 }
5761 }
5762}
5763
5764fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5769 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5770}
5771
5772fn sign_invite(code: &str, secret: &str) -> String {
5781 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5782}
5783
5784fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5789 cookie::verify_value(headers, INVITE_COOKIE, secret)
5790}
5791
5792const CLAIM_TOKEN_LABEL: &str = "claim-token";
5796
5797fn sign_claim_token(code: &str, secret: &str) -> String {
5809 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5810}
5811
5812fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5817 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5818}
5819
5820fn clear_invite_cookie(resp: &mut Response) {
5823 set_cookie(
5824 resp,
5825 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5826 );
5827}
5828
5829async fn import_opml(
5842 State(state): State<AppState>,
5843 headers: HeaderMap,
5844 mut multipart: Multipart,
5845) -> Result<Response, WebError> {
5846 let did = match current_did(&state, &headers).await {
5847 Some(d) => d,
5848 None => return Ok(Redirect::to("/login").into_response()),
5849 };
5850 let pool = &state.db;
5851
5852 let mut opml_text = String::new();
5858 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5859 let name = field.name().unwrap_or("").to_string();
5860 if name == "opml" || name == "file" {
5861 let bytes = field.bytes().await.map_err(multipart_response)?;
5862 if !bytes.is_empty() {
5863 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5864 if name == "file" {
5865 break;
5866 }
5867 }
5868 }
5869 }
5870
5871 let feeds =
5876 match opml::parse_opml(&opml_text) {
5877 Ok(feeds) => feeds,
5878 Err(err) => {
5879 warn!(%err, %did, "OPML import could not parse the uploaded file");
5880 return Ok(Redirect::to(&format!(
5881 "/?flash={}",
5882 qenc("That file could not be read as OPML. Export it again from your other reader?")
5883 ))
5884 .into_response());
5885 }
5886 };
5887 if feeds.is_empty() {
5888 info!(%did, "OPML import found no feeds");
5889 return Ok(
5890 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5891 .into_response(),
5892 );
5893 }
5894
5895 let now = now_rfc3339();
5898 let mut folder_uris: std::collections::HashMap<String, String> =
5899 std::collections::HashMap::new();
5900 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5902 for (rkey, folder) in existing {
5903 folder_uris
5904 .entry(folder.name.clone())
5905 .or_insert_with(|| folder_uri(&did, &rkey));
5906 }
5907 }
5908 let mut wanted_folders: Vec<String> = feeds
5909 .iter()
5910 .filter_map(|f| f.folder.clone())
5911 .filter(|n| !n.is_empty())
5912 .collect();
5913 wanted_folders.sort();
5914 wanted_folders.dedup();
5915 for name in wanted_folders {
5916 if folder_uris.contains_key(&name) {
5917 continue;
5918 }
5919 let folder = Folder::new(name.clone(), now.clone());
5920 match state.repo().add_folder(&did, &folder).await {
5921 Ok(rkey) => {
5922 folder_uris.insert(name, folder_uri(&did, &rkey));
5923 }
5924 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5925 }
5926 }
5927
5928 let sub_cap = state.config.max_subs_per_did;
5937 let mut headroom: Option<i64> = if sub_cap > 0 {
5938 let existing = store::count_subscriptions_for_did(pool, &did)
5939 .await
5940 .unwrap_or(0);
5941 Some((sub_cap - existing).max(0))
5942 } else {
5943 None
5944 };
5945 let mut trimmed_over_cap: usize = 0;
5946
5947 let feeds_cap = state.config.max_feeds_global;
5954 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5955 let existing = store::count_feeds(pool).await.unwrap_or(0);
5956 Some((feeds_cap - existing).max(0))
5957 } else {
5958 None
5959 };
5960 let mut trimmed_over_global: usize = 0;
5961
5962 let mut subs = Vec::with_capacity(feeds.len());
5963 let mut skipped_private: Vec<String> = Vec::new();
5964 let mut uncached: usize = 0;
5967 let mut skipped_unsupported: usize = 0;
5973 for f in &feeds {
5974 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5982 info!(
5983 %did,
5984 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5985 );
5986 skipped_unsupported += 1;
5987 continue;
5988 }
5989 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5990 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5991 let label = f
5993 .title
5994 .clone()
5995 .filter(|t| !t.trim().is_empty())
5996 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5997 skipped_private.push(label);
5998 continue;
5999 }
6000
6001 if let Some(h) = headroom.as_mut() {
6004 if *h <= 0 {
6005 trimmed_over_cap += 1;
6006 continue;
6007 }
6008 }
6009
6010 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
6015 Ok(existing) => existing.is_none(),
6016 Err(err) => {
6019 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
6020 false
6021 }
6022 };
6023 if is_new {
6024 if let Some(g) = global_headroom.as_mut() {
6025 if *g <= 0 {
6026 trimmed_over_global += 1;
6027 continue;
6028 }
6029 *g -= 1;
6030 }
6031 }
6032
6033 if let Some(h) = headroom.as_mut() {
6036 *h -= 1;
6037 }
6038
6039 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
6040 sub.title = f.title.clone();
6041 sub.site_url = f.site_url.clone();
6042 sub.folder = f
6043 .folder
6044 .as_ref()
6045 .and_then(|name| folder_uris.get(name).cloned());
6046 subs.push(sub);
6047 if let Err(err) = store::upsert_feed(
6053 pool,
6054 &store::NewFeed {
6055 url: f.feed_url.clone(),
6056 title: f.title.clone(),
6057 site_url: f.site_url.clone(),
6058 ..Default::default()
6059 },
6060 )
6061 .await
6062 {
6063 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
6064 it will not be polled");
6065 uncached += 1;
6066 }
6067 }
6068
6069 let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
6086 Ok(rkeys) => {
6087 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
6088 rkeys.len()
6089 }
6090 Err(err) => {
6091 let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
6092 warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
6093 landed
6094 }
6095 };
6096 if landed == 0 && !subs.is_empty() {
6097 return Ok(Redirect::to(&format!(
6098 "/?flash={}",
6099 qenc(
6100 "Could not save those subscriptions to your PDS, so nothing was imported. \
6101 Try again in a moment."
6102 )
6103 ))
6104 .into_response());
6105 }
6106
6107 let mut flash = if landed < subs.len() {
6109 format!(
6110 "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
6111 other {} may not have been saved. Importing the same file again would add the first \
6112 {landed} a second time",
6113 subs.len(),
6114 subs.len() - landed
6115 )
6116 } else {
6117 format!("Imported {} feeds", subs.len())
6118 };
6119 if uncached > 0 {
6120 flash.push_str(&format!(
6121 ". {uncached} of them could not be cached locally and may not update until the next import."
6122 ));
6123 }
6124 if trimmed_over_cap > 0 {
6125 flash.push_str(&format!(
6126 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
6127 ));
6128 }
6129 if trimmed_over_global > 0 {
6130 flash.push_str(&format!(
6131 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
6132 ));
6133 }
6134 if !skipped_private.is_empty() {
6135 flash.push_str(&format!(
6136 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
6137 skipped_private.len(),
6138 skipped_private.join(", ")
6139 ));
6140 }
6141 if skipped_unsupported > 0 {
6142 flash.push_str(&format!(
6145 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
6146 ));
6147 }
6148 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
6149}
6150
6151fn private_feed_label(url: &str) -> String {
6154 url::Url::parse(url)
6155 .ok()
6156 .and_then(|u| u.host_str().map(str::to_string))
6157 .unwrap_or_else(|| "a private feed".to_string())
6158}
6159
6160async fn export_opml(
6162 State(state): State<AppState>,
6163 headers: HeaderMap,
6164) -> Result<Response, WebError> {
6165 let did = match current_did(&state, &headers).await {
6166 Some(d) => d,
6167 None => return Ok(Redirect::to("/login").into_response()),
6168 };
6169
6170 let subs = match state.repo().list_subscriptions_sorted(&did).await {
6177 Ok(subs) => subs,
6178 Err(err) => {
6179 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
6180 return Ok(Redirect::to(&format!(
6181 "/manage?flash={}",
6182 qenc(EXPORT_INCOMPLETE_REFUSAL)
6183 ))
6184 .into_response());
6185 }
6186 };
6187 let folders = match state.repo().list_folders_sorted(&did).await {
6188 Ok(folders) => folders,
6189 Err(err) => {
6190 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
6191 return Ok(Redirect::to(&format!(
6192 "/manage?flash={}",
6193 qenc(EXPORT_INCOMPLETE_REFUSAL)
6194 ))
6195 .into_response());
6196 }
6197 };
6198 let folder_pairs: Vec<(String, Folder)> = folders
6201 .into_iter()
6202 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
6203 .collect();
6204
6205 let body = opml::to_opml(&subs, &folder_pairs);
6206 let mut resp = (StatusCode::OK, body).into_response();
6207 resp.headers_mut().insert(
6208 header::CONTENT_TYPE,
6209 "text/x-opml; charset=utf-8".parse().unwrap(),
6210 );
6211 resp.headers_mut().insert(
6212 header::CONTENT_DISPOSITION,
6213 "attachment; filename=\"featherreader-subscriptions.opml\""
6214 .parse()
6215 .unwrap(),
6216 );
6217 Ok(resp)
6218}
6219
6220fn set_cookie(resp: &mut Response, cookie: &str) {
6226 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
6227 resp.headers_mut()
6228 .append(axum::http::header::SET_COOKIE, value);
6229 }
6230}
6231
6232fn is_htmx(headers: &HeaderMap) -> bool {
6234 headers
6235 .get("HX-Request")
6236 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
6237}
6238
6239fn is_reader_request(headers: &HeaderMap) -> bool {
6245 headers
6246 .get("X-FR-Reader")
6247 .is_some_and(|v| v.as_bytes() == b"1")
6248}
6249
6250mod cookie {
6255 use super::{HeaderMap, SESSION_COOKIE};
6256
6257 pub fn sign_session(sid: &str, secret: &str) -> String {
6259 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
6260 }
6261
6262 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
6264 verify_value(headers, SESSION_COOKIE, secret)
6265 }
6266
6267 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
6273 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
6274 msg.extend_from_slice(name.as_bytes());
6275 msg.push(0);
6276 msg.extend_from_slice(value.as_bytes());
6277 msg
6278 }
6279
6280 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
6286 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
6287 let b64 = b64url_encode(value.as_bytes());
6288 format!(
6289 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
6290 )
6291 }
6292
6293 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
6296 let raw = cookie_value(headers, name)?;
6297 let (b64, sig) = raw.split_once('.')?;
6298 let bytes = b64url_decode(b64)?;
6299 let value = String::from_utf8(bytes).ok()?;
6300 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
6301 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6302 Some(value)
6303 } else {
6304 None
6305 }
6306 }
6307
6308 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
6314 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
6315 let b64 = b64url_encode(value.as_bytes());
6316 format!("{b64}.{sig}")
6317 }
6318
6319 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
6322 let (b64, sig) = token.split_once('.')?;
6323 let bytes = b64url_decode(b64)?;
6324 let value = String::from_utf8(bytes).ok()?;
6325 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
6326 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6327 Some(value)
6328 } else {
6329 None
6330 }
6331 }
6332
6333 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
6335 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
6336 for part in header.split(';') {
6337 let part = part.trim();
6338 if let Some((k, v)) = part.split_once('=') {
6339 if k == name {
6340 return Some(v.to_string());
6341 }
6342 }
6343 }
6344 None
6345 }
6346
6347 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6351 if a.len() != b.len() {
6352 return false;
6353 }
6354 let mut diff = 0u8;
6355 for (x, y) in a.iter().zip(b.iter()) {
6356 diff |= x ^ y;
6357 }
6358 diff == 0
6359 }
6360
6361 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6364
6365 fn b64url_encode(input: &[u8]) -> String {
6366 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
6367 for chunk in input.chunks(3) {
6368 let b = [
6369 chunk[0],
6370 *chunk.get(1).unwrap_or(&0),
6371 *chunk.get(2).unwrap_or(&0),
6372 ];
6373 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
6374 out.push(B64[((n >> 18) & 63) as usize] as char);
6375 out.push(B64[((n >> 12) & 63) as usize] as char);
6376 if chunk.len() > 1 {
6377 out.push(B64[((n >> 6) & 63) as usize] as char);
6378 }
6379 if chunk.len() > 2 {
6380 out.push(B64[(n & 63) as usize] as char);
6381 }
6382 }
6383 out
6384 }
6385
6386 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
6387 fn val(c: u8) -> Option<u32> {
6388 match c {
6389 b'A'..=b'Z' => Some((c - b'A') as u32),
6390 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6391 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6392 b'-' => Some(62),
6393 b'_' => Some(63),
6394 _ => None,
6395 }
6396 }
6397 let bytes = input.as_bytes();
6398 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
6399 for chunk in bytes.chunks(4) {
6400 let mut n = 0u32;
6401 let mut valid = 0;
6402 for (i, &c) in chunk.iter().enumerate() {
6403 n |= val(c)? << (18 - 6 * i);
6404 valid += 1;
6405 }
6406 out.push((n >> 16) as u8);
6407 if valid > 2 {
6408 out.push((n >> 8) as u8);
6409 }
6410 if valid > 3 {
6411 out.push(n as u8);
6412 }
6413 }
6414 Some(out)
6415 }
6416
6417 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
6421 const BLOCK: usize = 64;
6422 let mut k = [0u8; BLOCK];
6423 if key.len() > BLOCK {
6424 let d = sha256(key);
6425 k[..32].copy_from_slice(&d);
6426 } else {
6427 k[..key.len()].copy_from_slice(key);
6428 }
6429 let mut ipad = [0x36u8; BLOCK];
6430 let mut opad = [0x5cu8; BLOCK];
6431 for i in 0..BLOCK {
6432 ipad[i] ^= k[i];
6433 opad[i] ^= k[i];
6434 }
6435 let mut inner = Vec::with_capacity(BLOCK + msg.len());
6436 inner.extend_from_slice(&ipad);
6437 inner.extend_from_slice(msg);
6438 let inner_hash = sha256(&inner);
6439 let mut outer = Vec::with_capacity(BLOCK + 32);
6440 outer.extend_from_slice(&opad);
6441 outer.extend_from_slice(&inner_hash);
6442 let mac = sha256(&outer);
6443 let mut hex = String::with_capacity(64);
6444 for b in mac {
6445 hex.push_str(&format!("{b:02x}"));
6446 }
6447 hex
6448 }
6449
6450 fn sha256(data: &[u8]) -> [u8; 32] {
6452 const K: [u32; 64] = [
6453 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
6454 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
6455 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
6456 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
6457 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
6458 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
6459 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
6460 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
6461 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
6462 0xc67178f2,
6463 ];
6464 let mut h: [u32; 8] = [
6465 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
6466 0x5be0cd19,
6467 ];
6468
6469 let bit_len = (data.len() as u64) * 8;
6470 let mut msg = data.to_vec();
6471 msg.push(0x80);
6472 while msg.len() % 64 != 56 {
6473 msg.push(0);
6474 }
6475 msg.extend_from_slice(&bit_len.to_be_bytes());
6476
6477 for block in msg.chunks(64) {
6478 let mut w = [0u32; 64];
6479 for i in 0..16 {
6480 w[i] = u32::from_be_bytes([
6481 block[i * 4],
6482 block[i * 4 + 1],
6483 block[i * 4 + 2],
6484 block[i * 4 + 3],
6485 ]);
6486 }
6487 for i in 16..64 {
6488 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
6489 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
6490 w[i] = w[i - 16]
6491 .wrapping_add(s0)
6492 .wrapping_add(w[i - 7])
6493 .wrapping_add(s1);
6494 }
6495 let mut a = h;
6496 for i in 0..64 {
6497 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6498 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6499 let t1 = a[7]
6500 .wrapping_add(s1)
6501 .wrapping_add(ch)
6502 .wrapping_add(K[i])
6503 .wrapping_add(w[i]);
6504 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6505 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6506 let t2 = s0.wrapping_add(maj);
6507 a[7] = a[6];
6508 a[6] = a[5];
6509 a[5] = a[4];
6510 a[4] = a[3].wrapping_add(t1);
6511 a[3] = a[2];
6512 a[2] = a[1];
6513 a[1] = a[0];
6514 a[0] = t1.wrapping_add(t2);
6515 }
6516 for i in 0..8 {
6517 h[i] = h[i].wrapping_add(a[i]);
6518 }
6519 }
6520
6521 let mut out = [0u8; 32];
6522 for (i, word) in h.iter().enumerate() {
6523 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6524 }
6525 out
6526 }
6527
6528 #[cfg(test)]
6529 mod tests {
6530 use super::*;
6531
6532 #[test]
6533 fn sha256_known_vector() {
6534 let d = sha256(b"abc");
6535 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6536 assert_eq!(
6537 hex,
6538 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6539 );
6540 }
6541
6542 #[test]
6543 fn hmac_known_vector() {
6544 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6545 assert_eq!(
6546 mac,
6547 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6548 );
6549 }
6550
6551 #[test]
6552 fn sign_verify_round_trips() {
6553 let secret = "test-secret";
6554 let sid = "9f2c-opaque-session-id";
6555 let cookie = sign_session(sid, secret);
6556 let pair = cookie.split(';').next().unwrap().to_string();
6557 let mut headers = HeaderMap::new();
6558 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6559 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6560 assert!(verify_session(&headers, "other-secret").is_none());
6562 }
6563
6564 #[test]
6565 fn forged_and_tampered_cookies_are_rejected() {
6566 let secret = "test-secret";
6567
6568 let forged = format!(
6571 "{SESSION_COOKIE}={}.{}",
6572 b64url_encode(b"attacker-chosen-sid"),
6573 "deadbeef".repeat(8) );
6575 let mut headers = HeaderMap::new();
6576 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6577 assert!(verify_session(&headers, secret).is_none());
6578
6579 let cookie = sign_session("real-sid", secret);
6582 let pair = cookie.split(';').next().unwrap();
6583 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6584 let tampered = format!(
6585 "{SESSION_COOKIE}={}.{}",
6586 b64url_encode(b"different-sid"),
6587 sig
6588 );
6589 let mut headers2 = HeaderMap::new();
6590 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6591 assert!(verify_session(&headers2, secret).is_none());
6592 }
6593
6594 #[test]
6595 fn b64url_round_trips() {
6596 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6597 let enc = b64url_encode(s.as_bytes());
6598 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6599 }
6600 }
6601 }
6602}
6603
6604async fn get_entry_by_id(
6620 pool: &store::Pool,
6621 did: &str,
6622 id: i64,
6623) -> anyhow::Result<Option<store::Entry>> {
6624 let entry = sqlx::query_as::<_, store::Entry>(
6625 r#"
6626 SELECT e.* FROM entries e
6627 WHERE e.id = ?2
6628 AND EXISTS (
6629 SELECT 1 FROM sub_ref sr
6630 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6631 )
6632 "#,
6633 )
6634 .bind(did)
6635 .bind(id)
6636 .fetch_optional(pool)
6637 .await?;
6638 Ok(entry)
6639}
6640
6641async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6643 let read: Option<bool> =
6644 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6645 .bind(did)
6646 .bind(entry_id)
6647 .fetch_optional(pool)
6648 .await?
6649 .flatten();
6650 Ok(read.unwrap_or(false))
6651}
6652
6653async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6655 let starred: Option<bool> =
6656 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6657 .bind(did)
6658 .bind(entry_id)
6659 .fetch_optional(pool)
6660 .await?
6661 .flatten();
6662 Ok(starred.unwrap_or(false))
6663}
6664
6665async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6667 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6668 .bind(feed_id)
6669 .fetch_optional(pool)
6670 .await
6671 {
6672 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6673 _ => String::new(),
6674 }
6675}
6676
6677async fn build_entry_row(
6680 pool: &store::Pool,
6681 did: &str,
6682 id: i64,
6683 read: Option<bool>,
6684) -> anyhow::Result<Option<EntryRow>> {
6685 let entry = match get_entry_by_id(pool, did, id).await? {
6686 Some(e) => e,
6687 None => return Ok(None),
6688 };
6689 let read = match read {
6690 Some(r) => r,
6691 None => entry_is_read(pool, did, id).await?,
6692 };
6693 let starred = entry_is_starred(pool, did, id).await?;
6694 Ok(Some(EntryRow {
6695 id: entry.id,
6696 title: entry
6697 .title
6698 .clone()
6699 .filter(|t| !t.trim().is_empty())
6700 .unwrap_or_else(|| "(untitled)".to_string()),
6701 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6702 published: display_date(entry.published.as_deref()),
6703 read,
6704 starred,
6705 link: SafeLink::entry(id, ""),
6706 cached: true,
6707 rkey: String::new(),
6708 }))
6709}
6710
6711fn now_rfc3339() -> String {
6713 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6714}
6715
6716#[cfg(test)]
6717mod tests {
6718 use super::*;
6719
6720 #[test]
6721 fn qenc_encodes_reserved() {
6722 assert_eq!(qenc("a b"), "a%20b");
6723 assert_eq!(
6724 qenc("https://example.com/feed.xml"),
6725 "https%3A%2F%2Fexample.com%2Ffeed.xml"
6726 );
6727 assert_eq!(
6728 qenc("at://did:plc:x/c/r"),
6729 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6730 );
6731 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6733 }
6734
6735 #[test]
6736 fn folder_uri_shape() {
6737 assert_eq!(
6738 folder_uri("did:plc:abc", "3kfolder"),
6739 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6740 );
6741 }
6742
6743 #[test]
6746 fn private_feeds_are_classified_private_across_providers() {
6747 for url in [
6751 "https://author.substack.com/feed/private/deadbeefcafe1234",
6752 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6753 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6754 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6755 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6756 "https://user:pass@example.com/feed",
6757 ] {
6758 assert!(
6759 feed::classify_feed_privacy(url).is_private(),
6760 "expected private: {url}"
6761 );
6762 }
6763 }
6764
6765 #[test]
6766 fn public_feeds_stay_public() {
6767 for url in [
6768 "https://author.substack.com/feed",
6769 "https://wordpress.example.com/feed/",
6770 "https://example.com/rss.xml",
6771 "https://example.org/atom.xml",
6772 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6774 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6775 ] {
6776 assert!(
6777 !feed::classify_feed_privacy(url).is_private(),
6778 "expected public: {url}"
6779 );
6780 }
6781 }
6782
6783 #[test]
6784 fn private_feed_label_is_public_safe_host_only() {
6785 let label =
6787 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6788 assert_eq!(label, "author.substack.com");
6789 assert!(!label.contains("deadbeefcafe1234token"));
6790 assert!(!label.contains("/private/"));
6791 assert_eq!(private_feed_label("not a url"), "a private feed");
6793 }
6794
6795 #[test]
6796 fn refusal_message_promises_nothing_stored() {
6797 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6798 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6799 }
6800
6801 #[test]
6802 fn scope_query_preserves_context() {
6803 let q = EntryQuery {
6804 feed: Some("https://example.com/feed.xml".to_string()),
6805 folder: None,
6806 view: Some("all".to_string()),
6807 };
6808 let s = scope_query(&q);
6809 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6810 assert!(s.contains("view=all"));
6811
6812 let q2 = EntryQuery {
6814 feed: None,
6815 folder: None,
6816 view: Some("unread".to_string()),
6817 };
6818 assert_eq!(scope_query(&q2), "");
6819 }
6820
6821 use axum::body::Body;
6824 use axum::http::Request;
6825 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6831 let db = store::init_url("sqlite::memory:").await.unwrap();
6832 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6833 store::ensure_seed(&db, &dids).await.unwrap();
6834 let config = Config {
6835 allowed_dids: dids,
6836 cookie_secret: "test-cookie-secret-000".to_string(),
6837 beta_cap: 3,
6838 ..Config::default()
6839 };
6840 AppState::new(config, db).unwrap()
6841 }
6842
6843 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6846 let sid = state.sessions.create(Session {
6847 did: did.to_string(),
6848 handle: handle.map(str::to_string),
6849 });
6850 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6851 sc.split(';').next().unwrap().to_string()
6852 }
6853
6854 #[test]
6858 fn the_rate_limit_map_is_bounded() {
6859 let rl = RateLimiter::shared();
6860 let now = Instant::now();
6861 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6862 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6865 rl.check_at(ip, now + Duration::from_millis(i as u64));
6866 }
6867 let len = rl.inner.lock().unwrap().buckets.len();
6868 assert!(
6869 len <= MAX_RATE_BUCKETS,
6870 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6871 );
6872 }
6873
6874 #[test]
6881 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6882 let rl = RateLimiter::shared();
6883 let base = Instant::now();
6884 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6885 let at = |n: u64| base + Duration::from_nanos(n);
6890
6891 for i in 0..(RATE_BURST as u64) {
6893 assert!(rl.check_at(attacker, at(i)));
6894 }
6895 assert!(
6896 !rl.check_at(attacker, at(RATE_BURST as u64)),
6897 "burst was not exhausted; the rest of this test proves nothing"
6898 );
6899
6900 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6903 let t = at(100 + i as u64 * 2);
6904 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6905 rl.check_at(ip, t);
6906 assert!(
6907 !rl.check_at(attacker, t),
6908 "the attacker got a token back after evictions at i={i}"
6909 );
6910 }
6911 }
6912
6913 #[test]
6916 fn the_idle_sweep_does_not_run_on_every_request() {
6917 let rl = RateLimiter::shared();
6918 let start = Instant::now();
6919 let a: IpAddr = "198.51.100.1".parse().unwrap();
6920 let b: IpAddr = "198.51.100.2".parse().unwrap();
6921
6922 rl.check_at(a, start);
6923 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6926 assert!(
6927 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6928 "an idle bucket survived a sweep that was due"
6929 );
6930
6931 let before = rl.inner.lock().unwrap().last_sweep;
6934 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6935 assert_eq!(
6936 rl.inner.lock().unwrap().last_sweep,
6937 before,
6938 "the sweep ran again within the interval"
6939 );
6940 }
6941
6942 #[test]
6943 fn rate_limited_paths_match_expected() {
6944 use axum::http::Method;
6945 assert!(is_rate_limited_path("/login", &Method::GET));
6946 assert!(is_rate_limited_path("/login", &Method::POST));
6947 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6948 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6949 assert!(is_rate_limited_path("/opml", &Method::POST));
6950 assert!(is_rate_limited_path("/read-all", &Method::POST));
6951 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6952 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6953 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6954 assert!(!is_rate_limited_path("/", &Method::GET));
6956 assert!(!is_rate_limited_path("/about", &Method::GET));
6957 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6958 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6959 }
6960
6961 #[test]
6962 fn rate_limiter_allows_burst_then_429s() {
6963 let rl = RateLimiter::shared();
6964 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6965 for _ in 0..(RATE_BURST as usize) {
6967 assert!(rl.check(ip));
6968 }
6969 assert!(!rl.check(ip));
6971 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6973 assert!(rl.check(ip2));
6974 }
6975
6976 #[test]
6977 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6978 let mut h = HeaderMap::new();
6982 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6983 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6984 assert_eq!(
6985 client_ip(&h, Some(&sock), None),
6986 Some("203.0.113.55".parse().unwrap()),
6987 "spoofed XFF must not override the socket peer"
6988 );
6989 }
6990
6991 #[test]
6992 fn client_ip_uses_trusted_header_last_hop() {
6993 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6998
6999 let mut h = HeaderMap::new();
7000 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
7001 assert_eq!(
7002 client_ip(&h, Some(&sock), Some("fly-client-ip")),
7003 Some("198.51.100.9".parse().unwrap())
7004 );
7005
7006 let mut h2 = HeaderMap::new();
7008 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
7009 assert_eq!(
7010 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
7011 Some("198.51.100.9".parse().unwrap()),
7012 "must take the right-most (trusted) hop, not the forged left-most"
7013 );
7014
7015 let h3 = HeaderMap::new();
7017 assert_eq!(
7018 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
7019 Some("10.0.0.1".parse().unwrap())
7020 );
7021 }
7022
7023 #[test]
7024 fn invite_cookie_round_trips_and_rejects_tamper() {
7025 let secret = "test-cookie-secret-000";
7026 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
7027 let pair = sc.split(';').next().unwrap();
7028 let mut h = HeaderMap::new();
7029 h.insert(header::COOKIE, pair.parse().unwrap());
7030 assert_eq!(
7031 invite_cookie_code(&h, secret).as_deref(),
7032 Some("FEATHER-ABCDWXYZ")
7033 );
7034 assert!(invite_cookie_code(&h, "other").is_none());
7036 }
7037
7038 #[tokio::test]
7039 async fn preflight_valid_expired_and_full() {
7040 let state = test_state(&["did:plc:admin"]).await;
7041 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7043 .await
7044 .unwrap();
7045 assert!(preflight_code(&state, &code).await.is_ok());
7046
7047 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
7051 .await
7052 .unwrap();
7053 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
7054 .bind(chrono::Utc::now().timestamp() - 3600)
7055 .bind(&expired)
7056 .execute(&state.db)
7057 .await
7058 .unwrap();
7059 assert_eq!(
7060 preflight_code(&state, &expired).await,
7061 Err(store::RedeemError::Expired)
7062 );
7063
7064 assert_eq!(
7066 preflight_code(&state, "FEATHER-NOPENOPE").await,
7067 Err(store::RedeemError::NotFound)
7068 );
7069
7070 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7073 .await
7074 .unwrap();
7075 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7076 .await
7077 .unwrap();
7078 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7079 assert_eq!(
7080 preflight_code(&state, &code).await,
7081 Err(store::RedeemError::CapacityFull)
7082 );
7083 }
7084
7085 async fn bot_state(bot_secret: &str) -> AppState {
7089 let db = store::init_url("sqlite::memory:").await.unwrap();
7090 store::ensure_seed(&db, &["did:plc:admin".to_string()])
7091 .await
7092 .unwrap();
7093 let config = Config {
7094 allowed_dids: vec!["did:plc:admin".to_string()],
7095 cookie_secret: "test-cookie-secret-000".to_string(),
7096 beta_cap: 3,
7097 bot_secret: Some(bot_secret.to_string()),
7098 public_url: "https://feather-reader.com".to_string(),
7099 ..Config::default()
7100 };
7101 AppState::new(config, db).unwrap()
7102 }
7103
7104 #[test]
7105 fn claim_token_round_trips_and_rejects_tamper() {
7106 let secret = "test-cookie-secret-000";
7107 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
7108 assert!(!token.contains(';'));
7110 assert_eq!(
7111 claim_token_code(&token, secret).as_deref(),
7112 Some("FEATHER-ABCDWXYZ")
7113 );
7114 assert!(claim_token_code(&token, "other").is_none());
7116 let mut bad = token.clone();
7118 bad.push('x');
7119 assert!(claim_token_code(&bad, secret).is_none());
7120 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
7126 assert_eq!(
7127 test_b64url_decode(b64).as_deref(),
7128 Some("FEATHER-ABCDWXYZ".as_bytes()),
7129 "the code half of the token is plain base64url, decodable by anyone"
7130 );
7131 }
7132
7133 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
7136 fn val(c: u8) -> Option<u32> {
7137 match c {
7138 b'A'..=b'Z' => Some((c - b'A') as u32),
7139 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
7140 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
7141 b'-' => Some(62),
7142 b'_' => Some(63),
7143 _ => None,
7144 }
7145 }
7146 let mut out = Vec::with_capacity(input.len() / 4 * 3);
7147 for chunk in input.as_bytes().chunks(4) {
7148 let mut n = 0u32;
7149 let mut bits = 0;
7150 for &c in chunk {
7151 n = (n << 6) | val(c)?;
7152 bits += 6;
7153 }
7154 let bytes = bits / 8;
7155 n <<= 24 - bits;
7156 for i in 0..bytes {
7157 out.push((n >> (16 - i * 8)) as u8);
7158 }
7159 }
7160 Some(out)
7161 }
7162
7163 #[tokio::test]
7164 async fn bot_mint_then_claim_grants_a_seat() {
7165 let state = bot_state("bot-secret-abcdef").await;
7166 let app = router(state.clone());
7167
7168 let resp = app
7170 .clone()
7171 .oneshot(
7172 Request::builder()
7173 .method("POST")
7174 .uri("/bot/claims")
7175 .header("x-bot-secret", "bot-secret-abcdef")
7176 .body(Body::empty())
7177 .unwrap(),
7178 )
7179 .await
7180 .unwrap();
7181 assert_eq!(resp.status(), StatusCode::OK);
7182 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7183 .await
7184 .unwrap();
7185 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7186 let token = json["token"].as_str().unwrap().to_string();
7187 let url = json["url"].as_str().unwrap();
7188 assert!(url.starts_with("https://feather-reader.com/claim?t="));
7189 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
7191 assert!(!url.contains("FEATHER-"));
7192
7193 let resp = app
7195 .clone()
7196 .oneshot(
7197 Request::builder()
7198 .method("GET")
7199 .uri(format!("/claim?t={}", qenc(&token)))
7200 .body(Body::empty())
7201 .unwrap(),
7202 )
7203 .await
7204 .unwrap();
7205 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7206 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7207 let set_cookie = resp
7208 .headers()
7209 .get(header::SET_COOKIE)
7210 .unwrap()
7211 .to_str()
7212 .unwrap();
7213 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
7214
7215 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
7218 let out = store::redeem_code(
7219 &state.db,
7220 &code,
7221 "did:plc:follower",
7222 None,
7223 state.config.beta_cap,
7224 )
7225 .await
7226 .unwrap();
7227 assert_eq!(out, Ok(()));
7228 assert!(store::has_beta_access(&state.db, "did:plc:follower")
7229 .await
7230 .unwrap());
7231 }
7232
7233 #[tokio::test]
7234 async fn claim_with_invalid_token_bounces() {
7235 let state = bot_state("bot-secret-abcdef").await;
7236 let app = router(state);
7237 let resp = app
7238 .oneshot(
7239 Request::builder()
7240 .method("GET")
7241 .uri("/claim?t=not-a-real-token")
7242 .body(Body::empty())
7243 .unwrap(),
7244 )
7245 .await
7246 .unwrap();
7247 assert_eq!(resp.status(), StatusCode::OK);
7249 }
7250
7251 #[tokio::test]
7252 async fn claim_with_used_token_is_refused() {
7253 let state = bot_state("bot-secret-abcdef").await;
7254 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7256 .await
7257 .unwrap();
7258 let token = sign_claim_token(&code, &state.config.cookie_secret);
7259 store::redeem_code(
7260 &state.db,
7261 &code,
7262 "did:plc:someone",
7263 None,
7264 state.config.beta_cap,
7265 )
7266 .await
7267 .unwrap()
7268 .unwrap();
7269 let app = router(state);
7270 let resp = app
7271 .oneshot(
7272 Request::builder()
7273 .method("GET")
7274 .uri(format!("/claim?t={}", qenc(&token)))
7275 .body(Body::empty())
7276 .unwrap(),
7277 )
7278 .await
7279 .unwrap();
7280 assert_eq!(resp.status(), StatusCode::OK);
7282 assert!(resp.headers().get(header::SET_COOKIE).is_none());
7283 }
7284
7285 #[tokio::test]
7286 async fn bot_claims_rejects_bad_and_missing_secret() {
7287 let state = bot_state("bot-secret-abcdef").await;
7288 let app = router(state);
7289 let resp = app
7291 .clone()
7292 .oneshot(
7293 Request::builder()
7294 .method("POST")
7295 .uri("/bot/claims")
7296 .header("x-bot-secret", "wrong")
7297 .body(Body::empty())
7298 .unwrap(),
7299 )
7300 .await
7301 .unwrap();
7302 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7303 let resp = app
7305 .oneshot(
7306 Request::builder()
7307 .method("POST")
7308 .uri("/bot/claims")
7309 .body(Body::empty())
7310 .unwrap(),
7311 )
7312 .await
7313 .unwrap();
7314 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7315 }
7316
7317 #[tokio::test]
7318 async fn bot_claims_disabled_when_secret_unset() {
7319 let state = test_state(&["did:plc:admin"]).await;
7321 let app = router(state);
7322 let resp = app
7323 .oneshot(
7324 Request::builder()
7325 .method("POST")
7326 .uri("/bot/claims")
7327 .header("x-bot-secret", "anything")
7328 .body(Body::empty())
7329 .unwrap(),
7330 )
7331 .await
7332 .unwrap();
7333 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7334 }
7335
7336 #[tokio::test]
7337 async fn bot_claims_refuses_at_capacity() {
7338 let state = bot_state("bot-secret-abcdef").await;
7339 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7341 .await
7342 .unwrap();
7343 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7344 .await
7345 .unwrap();
7346 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7347 let app = router(state);
7348 let resp = app
7349 .oneshot(
7350 Request::builder()
7351 .method("POST")
7352 .uri("/bot/claims")
7353 .header("x-bot-secret", "bot-secret-abcdef")
7354 .body(Body::empty())
7355 .unwrap(),
7356 )
7357 .await
7358 .unwrap();
7359 assert_eq!(resp.status(), StatusCode::CONFLICT);
7360 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7361 .await
7362 .unwrap();
7363 assert!(String::from_utf8_lossy(&bytes).contains("full"));
7364 }
7365
7366 #[tokio::test]
7367 async fn bot_claims_counts_outstanding_codes_against_cap() {
7368 let state = bot_state("bot-secret-abcdef").await;
7369 store::mint_code(&state.db, "did:plc:admin", 3600)
7371 .await
7372 .unwrap();
7373 store::mint_code(&state.db, "did:plc:admin", 3600)
7374 .await
7375 .unwrap();
7376 let app = router(state);
7377 let resp = app
7378 .oneshot(
7379 Request::builder()
7380 .method("POST")
7381 .uri("/bot/claims")
7382 .header("x-bot-secret", "bot-secret-abcdef")
7383 .body(Body::empty())
7384 .unwrap(),
7385 )
7386 .await
7387 .unwrap();
7388 assert_eq!(resp.status(), StatusCode::CONFLICT);
7390 }
7391
7392 async fn post_bot_claim_for(
7394 app: &axum::Router,
7395 secret: &str,
7396 did: &str,
7397 ) -> (StatusCode, serde_json::Value) {
7398 let resp = app
7399 .clone()
7400 .oneshot(
7401 Request::builder()
7402 .method("POST")
7403 .uri("/bot/claims")
7404 .header("x-bot-secret", secret)
7405 .header("content-type", "application/json")
7406 .body(Body::from(format!(
7407 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
7408 )))
7409 .unwrap(),
7410 )
7411 .await
7412 .unwrap();
7413 let status = resp.status();
7414 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7415 .await
7416 .unwrap();
7417 let json = if bytes.is_empty() {
7418 serde_json::Value::Null
7419 } else {
7420 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
7421 };
7422 (status, json)
7423 }
7424
7425 #[tokio::test]
7426 async fn bot_claims_returns_already_seated_for_a_member() {
7427 let state = bot_state("bot-secret-abcdef").await;
7431 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
7432 .await
7433 .unwrap();
7434 let app = router(state.clone());
7435 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
7436 assert_eq!(status, StatusCode::OK);
7437 assert_eq!(json["status"], "already_seated");
7438 assert_eq!(json["code"], "");
7439 assert_eq!(json["url"], "");
7440 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
7442 .await
7443 .unwrap()
7444 .is_none());
7445 }
7446
7447 #[tokio::test]
7448 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
7449 let state = bot_state("bot-secret-abcdef").await;
7453 let app = router(state.clone());
7454
7455 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7456 assert_eq!(s1, StatusCode::OK);
7457 assert_eq!(j1["status"], "minted");
7458 let code1 = j1["code"].as_str().unwrap().to_string();
7459
7460 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7461 assert_eq!(s2, StatusCode::OK);
7462 assert_eq!(j2["status"], "existing");
7463 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
7464 assert_eq!(j2["url"], j1["url"], "same url returned");
7465
7466 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
7468 }
7469
7470 #[tokio::test]
7471 async fn bot_claims_records_intended_did_at_mint() {
7472 let state = bot_state("bot-secret-abcdef").await;
7474 let app = router(state.clone());
7475 let (status, json) =
7476 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
7477 assert_eq!(status, StatusCode::OK);
7478 let code = json["code"].as_str().unwrap();
7479 assert_eq!(
7480 store::find_active_code_for_did(&state.db, "did:plc:follower2")
7481 .await
7482 .unwrap()
7483 .as_deref(),
7484 Some(code)
7485 );
7486 }
7487
7488 #[tokio::test]
7489 async fn bot_claims_concurrent_same_did_never_double_mints() {
7490 let state = bot_state("bot-secret-abcdef").await;
7497 let app = router(state.clone());
7498
7499 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7500 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7501 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7502
7503 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7504 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7505
7506 assert_eq!(
7508 store::count_active_codes(&state.db).await.unwrap(),
7509 1,
7510 "concurrent mints must not create two active codes"
7511 );
7512
7513 let ca = ja["code"].as_str().unwrap_or("");
7515 let cb = jb["code"].as_str().unwrap_or("");
7516 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7517 assert_eq!(ca, cb, "both callers must get the one minted code");
7518 for st in [&ja["status"], &jb["status"]] {
7521 let s = st.as_str().unwrap_or("");
7522 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7523 }
7524 }
7525
7526 #[tokio::test]
7527 async fn bot_claims_rejects_malformed_json_body() {
7528 let state = bot_state("bot-secret-abcdef").await;
7529 let app = router(state);
7530 let resp = app
7531 .oneshot(
7532 Request::builder()
7533 .method("POST")
7534 .uri("/bot/claims")
7535 .header("x-bot-secret", "bot-secret-abcdef")
7536 .header("content-type", "application/json")
7537 .body(Body::from("{not json"))
7538 .unwrap(),
7539 )
7540 .await
7541 .unwrap();
7542 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7543 }
7544
7545 #[tokio::test]
7546 async fn favicon_ico_served_at_root() {
7547 let state = test_state(&[]).await;
7550 let app = router(state);
7551 let resp = app
7552 .oneshot(
7553 Request::builder()
7554 .uri("/favicon.ico")
7555 .body(Body::empty())
7556 .unwrap(),
7557 )
7558 .await
7559 .unwrap();
7560 assert_eq!(resp.status(), StatusCode::OK);
7561 let ct = resp
7562 .headers()
7563 .get(header::CONTENT_TYPE)
7564 .unwrap()
7565 .to_str()
7566 .unwrap();
7567 assert!(
7568 ct.contains("icon") || ct.starts_with("image/"),
7569 "content-type = {ct}"
7570 );
7571 }
7572
7573 #[tokio::test]
7574 async fn login_without_invite_redirects_to_beta_redeem() {
7575 let state = test_state(&[]).await;
7577 let app = router(state);
7578 let resp = app
7579 .oneshot(
7580 Request::builder()
7581 .method("POST")
7582 .uri("/login")
7583 .header("content-type", "application/x-www-form-urlencoded")
7584 .body(Body::from("handle=alice.bsky.social"))
7585 .unwrap(),
7586 )
7587 .await
7588 .unwrap();
7589 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7590 assert_eq!(
7591 resp.headers().get(header::LOCATION).unwrap(),
7592 "/beta/redeem"
7593 );
7594 }
7595
7596 #[tokio::test]
7597 async fn login_with_valid_invite_cookie_starts_oauth() {
7598 let state = test_state(&[]).await;
7599 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7600 let cookie = cookie.split(';').next().unwrap().to_string();
7601 let app = router(state);
7602 let resp = app
7603 .oneshot(
7604 Request::builder()
7605 .method("POST")
7606 .uri("/login")
7607 .header("content-type", "application/x-www-form-urlencoded")
7608 .header(header::COOKIE, cookie)
7609 .body(Body::from("handle=alice.bsky.social"))
7610 .unwrap(),
7611 )
7612 .await
7613 .unwrap();
7614 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7616 let loc = resp
7617 .headers()
7618 .get(header::LOCATION)
7619 .unwrap()
7620 .to_str()
7621 .unwrap();
7622 assert!(loc.contains("/login"), "loc = {loc}");
7623 assert_ne!(loc, "/beta/redeem");
7624 }
7625
7626 async fn resolver_never(_handle: String) -> Option<String> {
7629 None
7630 }
7631
7632 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7634 move |_handle| std::future::ready(Some(did.to_string()))
7635 }
7636
7637 #[tokio::test]
7641 async fn may_start_oauth_honors_seat_via_resolved_handle() {
7642 let state = test_state(&["did:plc:admin"]).await;
7645 let headers = HeaderMap::new();
7646 assert!(
7647 may_start_oauth_with(
7648 &state,
7649 &headers,
7650 "admin.example",
7651 resolver_to("did:plc:admin")
7652 )
7653 .await,
7654 "a handle resolving to a seated DID must pass the gate"
7655 );
7656 }
7657
7658 #[tokio::test]
7662 async fn may_start_oauth_bounces_non_member_handle() {
7663 let state = test_state(&["did:plc:admin"]).await;
7664 let headers = HeaderMap::new();
7665 assert!(
7666 !may_start_oauth_with(
7667 &state,
7668 &headers,
7669 "rando.example",
7670 resolver_to("did:plc:rando")
7671 )
7672 .await,
7673 "a resolved DID with no seat must be bounced"
7674 );
7675 }
7676
7677 #[tokio::test]
7680 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7681 let state = test_state(&["did:plc:admin"]).await;
7682 let headers = HeaderMap::new();
7683 assert!(
7684 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7685 "an unresolvable handle must fail closed"
7686 );
7687 }
7688
7689 #[tokio::test]
7693 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7694 let state = test_state(&[]).await;
7695 let did = "did:plc:member";
7696 store::grant_access(&state.db, did, Some("member.example"), "test", None)
7697 .await
7698 .unwrap();
7699 let cookie = session_cookie(&state, did, Some("member.example"));
7700 let mut headers = HeaderMap::new();
7701 headers.insert(header::COOKIE, cookie.parse().unwrap());
7702 assert!(
7703 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7704 "a seated session cookie must pass without resolution"
7705 );
7706 }
7707
7708 #[tokio::test]
7710 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7711 let state = test_state(&[]).await;
7712 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7713 let cookie = cookie.split(';').next().unwrap().to_string();
7714 let mut headers = HeaderMap::new();
7715 headers.insert(header::COOKIE, cookie.parse().unwrap());
7716 assert!(
7717 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7718 "a valid invite cookie must pass without resolution"
7719 );
7720 }
7721
7722 #[tokio::test]
7723 async fn admin_mint_requires_admin_seed_did() {
7724 let state = test_state(&["did:plc:admin"]).await;
7725 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7727 .await
7728 .unwrap();
7729 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7730 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7732 let app = router(state);
7733
7734 let forbidden = app
7735 .clone()
7736 .oneshot(
7737 Request::builder()
7738 .method("POST")
7739 .uri("/admin/invites?n=2")
7740 .header(header::COOKIE, rando_cookie)
7741 .body(Body::empty())
7742 .unwrap(),
7743 )
7744 .await
7745 .unwrap();
7746 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7747
7748 let ok = app
7749 .oneshot(
7750 Request::builder()
7751 .method("POST")
7752 .uri("/admin/invites?n=2")
7753 .header(header::COOKIE, admin_cookie)
7754 .body(Body::empty())
7755 .unwrap(),
7756 )
7757 .await
7758 .unwrap();
7759 assert_eq!(ok.status(), StatusCode::OK);
7760 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7761 .await
7762 .unwrap();
7763 let body = String::from_utf8(bytes.to_vec()).unwrap();
7764 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7765 assert_eq!(minted.len(), 2);
7766 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7767 }
7768
7769 #[tokio::test]
7770 async fn admin_mint_unauthenticated_is_401() {
7771 let state = test_state(&["did:plc:admin"]).await;
7772 let app = router(state);
7773 let resp = app
7774 .oneshot(
7775 Request::builder()
7776 .method("POST")
7777 .uri("/admin/invites")
7778 .body(Body::empty())
7779 .unwrap(),
7780 )
7781 .await
7782 .unwrap();
7783 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7784 }
7785
7786 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7789 let db = store::init_url("sqlite::memory:").await.unwrap();
7790 store::record_network_stat(
7791 &db,
7792 &store::NetworkStat {
7793 key: store::ADOPTION_STAT_KEY.to_string(),
7794 source: "https://relay1.us-west.bsky.network".to_string(),
7795 value: repos,
7796 truncated,
7797 observed_at: "2026-08-13T04:05:06Z".to_string(),
7798 },
7799 )
7800 .await
7801 .unwrap();
7802 let config = Config {
7803 cookie_secret: "test-cookie-secret-000".to_string(),
7804 show_adoption: true,
7805 ..Config::default()
7806 };
7807 AppState::new(config, db).unwrap()
7808 }
7809
7810 async fn about_body(state: AppState) -> String {
7811 let resp = router(state)
7812 .oneshot(
7813 Request::builder()
7814 .uri("/about")
7815 .body(Body::empty())
7816 .unwrap(),
7817 )
7818 .await
7819 .unwrap();
7820 assert_eq!(resp.status(), StatusCode::OK);
7821 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7822 .await
7823 .unwrap();
7824 String::from_utf8(bytes.to_vec()).unwrap()
7825 }
7826
7827 #[tokio::test]
7829 async fn about_omits_adoption_line_by_default() {
7830 let state = test_state(&[]).await;
7831 assert!(!state.config.show_adoption);
7832 let body = about_body(state).await;
7833 assert!(
7834 !body.contains("atproto network"),
7835 "the adoption line must not render by default"
7836 );
7837 }
7838
7839 #[tokio::test]
7840 async fn about_renders_adoption_line_when_enabled() {
7841 let body = about_body(adoption_state(7_318, false).await).await;
7849 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7852 assert!(
7853 flat.contains("7318 accounts on the atproto network hold"),
7854 "the count did not render in its own sentence: {flat}",
7855 );
7856 assert!(
7857 body.contains("accounts on the atproto network hold"),
7858 "{body}"
7859 );
7860 assert!(
7861 body.contains("2026-08-13"),
7862 "the observation date must render"
7863 );
7864 assert!(
7865 body.contains("lower bound"),
7866 "the non-archival caveat must ride along with the number"
7867 );
7868 assert!(
7869 !body.contains("At least"),
7870 "an untruncated count is exact-ish"
7871 );
7872 }
7873
7874 #[tokio::test]
7876 async fn about_adoption_line_is_singular_at_one() {
7877 let body = about_body(adoption_state(1, false).await).await;
7878 assert!(
7879 body.contains("account on the atproto network holds"),
7880 "{body}"
7881 );
7882 }
7883
7884 #[tokio::test]
7886 async fn about_adoption_line_says_at_least_when_truncated() {
7887 let body = about_body(adoption_state(25_000, true).await).await;
7888 assert!(body.contains("At least"), "{body}");
7889 }
7890
7891 #[tokio::test]
7893 async fn about_omits_line_when_enabled_with_no_observation() {
7894 let db = store::init_url("sqlite::memory:").await.unwrap();
7895 let config = Config {
7896 cookie_secret: "test-cookie-secret-000".to_string(),
7897 show_adoption: true,
7898 ..Config::default()
7899 };
7900 let body = about_body(AppState::new(config, db).unwrap()).await;
7901 assert!(!body.contains("atproto network"));
7902 }
7903
7904 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7915 let db = store::init_url("sqlite::memory:").await.unwrap();
7916 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7917 let config = Config {
7918 allowed_dids: vec![did.to_string()],
7919 cookie_secret: "test-cookie-secret-000".to_string(),
7920 beta_cap: 3,
7921 standard_site,
7922 ..Config::default()
7923 };
7924 AppState::new(config, db).unwrap()
7925 }
7926
7927 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7929 let cookie = session_cookie(&state, did, Some("reader.example"));
7930 let resp = router(state)
7931 .oneshot(
7932 Request::builder()
7933 .uri(path)
7934 .header(header::COOKIE, cookie)
7935 .body(Body::empty())
7936 .unwrap(),
7937 )
7938 .await
7939 .unwrap();
7940 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7941 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7942 .await
7943 .unwrap();
7944 String::from_utf8(bytes.to_vec()).unwrap()
7945 }
7946
7947 async fn public_body(state: AppState, path: &str) -> String {
7949 let resp = router(state)
7950 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7951 .await
7952 .unwrap();
7953 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7954 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7955 .await
7956 .unwrap();
7957 String::from_utf8(bytes.to_vec()).unwrap()
7958 }
7959
7960 fn feed_url_input(body: &str) -> &str {
7962 let start = body
7963 .find("id=\"feed-url\"")
7964 .and_then(|i| body[..i].rfind("<input"))
7965 .expect("the subscribe form's URL input renders");
7966 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7967 &body[start..end]
7968 }
7969
7970 #[tokio::test]
7973 async fn manage_hints_at_publications_when_the_flag_is_on() {
7974 let did = "did:plc:reader";
7975 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7976 assert!(
7977 body.contains("at://did:plc:…/site.standard.publication/…"),
7978 "the DID form must be shown: {body}"
7979 );
7980 assert!(
7981 body.contains("at://alice.example.com/site.standard.publication/…"),
7982 "the handle form must be shown: {body}"
7983 );
7984 }
7985
7986 #[tokio::test]
7992 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7993 let did = "did:plc:reader";
7994 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7995 let input = feed_url_input(&body);
7996 assert!(
7997 input.contains("type=\"text\""),
7998 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7999 );
8000 assert!(
8001 input.contains("inputmode=\"url\""),
8002 "the URL keyboard is still wanted: {input}"
8003 );
8004 }
8005
8006 #[tokio::test]
8012 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
8013 let did = "did:plc:reader";
8014 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8015 let input = feed_url_input(&body);
8016 assert!(
8017 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
8018 "the text input must keep a scheme check: {input}"
8019 );
8020 }
8021
8022 #[tokio::test]
8025 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
8026 let did = "did:plc:reader";
8027 let state = standard_site_state(false, did).await;
8028 assert!(!state.config.standard_site);
8029 let page = signed_in_body(state, "/manage", did).await;
8030 let body = &page[page.find("</head>").expect("a <head>")..];
8035 assert!(
8036 !body.contains("site.standard.publication"),
8037 "a refused form must not be advertised: {body}"
8038 );
8039 let above_footer = body
8044 .split("<footer")
8045 .next()
8046 .expect("split yields at least one piece");
8047 assert!(
8048 above_footer.contains("id=\"feed-url\""),
8049 "the form must be above the footer: {body}"
8050 );
8051 assert!(
8052 !above_footer.contains("standard.site"),
8053 "a refused form must not be advertised: {body}"
8054 );
8055 assert!(
8056 feed_url_input(body).contains("type=\"url\""),
8057 "with the flag off the input is unchanged"
8058 );
8059 }
8060
8061 #[tokio::test]
8064 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
8065 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
8066 assert!(body.contains("standard.site"), "{body}");
8067 assert!(
8068 body.contains("at://did:plc:…/site.standard.publication/…"),
8069 "the landing page must show the DID form: {body}"
8070 );
8071 assert!(
8072 body.contains("at://alice.example.com/site.standard.publication/…"),
8073 "the landing page must show the handle form: {body}"
8074 );
8075 }
8076
8077 #[tokio::test]
8081 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
8082 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
8083 assert!(body.contains("standard.site"), "{body}");
8084 assert!(
8085 !body.contains("at://did:plc:…/site.standard.publication/…"),
8086 "no paste instructions with the flag off: {body}"
8087 );
8088 assert!(
8089 !body.contains("at://alice.example.com/site.standard.publication/…"),
8090 "no paste instructions with the flag off: {body}"
8091 );
8092 assert!(
8093 body.contains("isn't accepting new publication subscriptions"),
8094 "the page must say the form is closed here: {body}"
8095 );
8096 }
8097
8098 #[tokio::test]
8100 async fn about_describes_publications_and_how_to_subscribe_when_on() {
8101 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
8102 assert!(body.contains("site.standard.publication"), "{body}");
8103 assert!(body.contains("site.standard.document"), "{body}");
8104 assert!(
8105 body.contains("at://did:plc:…/site.standard.publication/…"),
8106 "{body}"
8107 );
8108 assert!(
8109 body.contains("at://alice.example.com/site.standard.publication/…"),
8110 "{body}"
8111 );
8112 }
8113
8114 #[tokio::test]
8116 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
8117 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
8118 assert!(body.contains("site.standard.publication"), "{body}");
8119 assert!(
8120 !body.contains("at://did:plc:…/site.standard.publication/…"),
8121 "no paste instructions with the flag off: {body}"
8122 );
8123 assert!(
8124 !body.contains("at://alice.example.com/site.standard.publication/…"),
8125 "no paste instructions with the flag off: {body}"
8126 );
8127 assert!(
8128 body.contains("isn't accepting new publication subscriptions"),
8129 "{body}"
8130 );
8131 }
8132
8133 #[tokio::test]
8141 async fn standard_site_page_renders_signed_out() {
8142 let body = public_body(test_state(&[]).await, "/standard-site").await;
8143 assert!(body.contains("site.standard.publication"), "{body}");
8144 assert!(body.contains("site.standard.document"), "{body}");
8145 assert!(
8146 body.contains("<title>standard.site — FeatherReader</title>"),
8147 "{body}"
8148 );
8149 }
8150
8151 #[tokio::test]
8154 async fn standard_site_page_tells_how_to_subscribe_when_on() {
8155 let body = public_body(
8156 standard_site_state(true, "did:plc:x").await,
8157 "/standard-site",
8158 )
8159 .await;
8160 assert!(
8161 body.contains("at://did:plc:…/site.standard.publication/…"),
8162 "the DID form must be shown: {body}"
8163 );
8164 assert!(
8165 body.contains("at://alice.example.com/site.standard.publication/…"),
8166 "the handle form must be shown: {body}"
8167 );
8168 assert!(
8169 body.contains("resolved to its DID"),
8170 "the handle resolution must be stated: {body}"
8171 );
8172 assert!(
8173 !body.contains("isn't accepting new publication subscriptions"),
8174 "{body}"
8175 );
8176 }
8177
8178 #[tokio::test]
8182 async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
8183 let state = standard_site_state(false, "did:plc:x").await;
8184 assert!(!state.config.standard_site);
8185 let body = public_body(state, "/standard-site").await;
8186 assert!(body.contains("site.standard.publication"), "{body}");
8187 assert!(
8188 !body.contains("at://did:plc:…/site.standard.publication/…"),
8189 "no paste instructions with the flag off: {body}"
8190 );
8191 assert!(
8192 !body.contains("at://alice.example.com/site.standard.publication/…"),
8193 "no paste instructions with the flag off: {body}"
8194 );
8195 assert!(
8196 body.contains("isn't accepting new publication subscriptions"),
8197 "the page must say the form is closed here: {body}"
8198 );
8199 assert!(
8200 body.contains("already follows are still read"),
8201 "stored publications are polled whatever the flag says: {body}"
8202 );
8203 }
8204
8205 #[tokio::test]
8208 async fn releases_callout_links_the_release_pages() {
8209 for path in ["/standard-site", "/"] {
8210 let body = public_body(test_state(&[]).await, path).await;
8211 for tag in ["v0.4.1", "v0.4.0"] {
8212 let href = format!(
8213 "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
8214 );
8215 assert!(body.contains(&href), "{path} must link {tag}: {body}");
8216 }
8217 assert!(
8218 body.contains(
8219 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
8220 ),
8221 "{path} must link the changelog: {body}"
8222 );
8223 }
8224 }
8225
8226 #[tokio::test]
8230 async fn landing_about_and_footer_link_the_standard_site_page() {
8231 for path in ["/", "/about", "/privacy"] {
8232 let body = public_body(test_state(&[]).await, path).await;
8233 assert!(
8234 body.contains("href=\"/standard-site\""),
8235 "{path} must link the feature page: {body}"
8236 );
8237 }
8238 }
8239
8240 #[test]
8244 fn releases_are_newest_first_and_link_the_tag_and_changelog() {
8245 assert!(!RELEASES.is_empty());
8246 let parse = |v: &str| -> Vec<u32> {
8247 v.split('.')
8248 .map(|p| p.parse::<u32>().expect("a numeric version part"))
8249 .collect()
8250 };
8251 for pair in RELEASES.windows(2) {
8252 assert!(
8253 parse(pair[0].version) > parse(pair[1].version),
8254 "{} must come before {}",
8255 pair[0].version,
8256 pair[1].version
8257 );
8258 }
8259 for r in RELEASES {
8260 assert_eq!(parse(r.version).len(), 3, "{}", r.version);
8261 assert!(
8262 chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
8263 "{} is not YYYY-MM-DD",
8264 r.date
8265 );
8266 assert!(!r.summary.trim().is_empty());
8267 assert!(!r.summary.contains('<'), "the summary is plain text");
8268 assert_eq!(
8269 r.url(),
8270 format!(
8271 "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
8272 r.version
8273 )
8274 );
8275 }
8276 let latest = &RELEASES[0];
8279 assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
8280 assert_eq!(
8281 latest.changelog_url(),
8282 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#047--2026-10-07"
8283 );
8284 }
8285
8286 #[tokio::test]
8288 async fn standard_site_page_is_publicly_cacheable() {
8289 let resp = router(test_state(&[]).await)
8290 .oneshot(
8291 Request::builder()
8292 .uri("/standard-site")
8293 .body(Body::empty())
8294 .unwrap(),
8295 )
8296 .await
8297 .unwrap();
8298 assert_eq!(resp.status(), StatusCode::OK);
8299 assert_eq!(
8300 resp.headers().get(header::CACHE_CONTROL).unwrap(),
8301 "public, max-age=300"
8302 );
8303 }
8304
8305 #[tokio::test]
8306 async fn cache_control_public_on_about_no_store_on_authed() {
8307 let state = test_state(&["did:plc:admin"]).await;
8308 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
8309 let app = router(state);
8310
8311 let about = app
8313 .clone()
8314 .oneshot(
8315 Request::builder()
8316 .uri("/about")
8317 .body(Body::empty())
8318 .unwrap(),
8319 )
8320 .await
8321 .unwrap();
8322 assert_eq!(
8323 about.headers().get(header::CACHE_CONTROL).unwrap(),
8324 "public, max-age=300"
8325 );
8326 assert_eq!(
8332 about.headers()["content-security-policy"],
8333 EXPECTED_CSP,
8334 "the CSP is not the policy the router promises"
8335 );
8336 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
8337
8338 for path in ["/privacy", "/terms"] {
8340 let resp = app
8341 .clone()
8342 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8343 .await
8344 .unwrap();
8345 assert_eq!(resp.status(), StatusCode::OK);
8346 assert_eq!(
8347 resp.headers().get(header::CACHE_CONTROL).unwrap(),
8348 "public, max-age=300",
8349 "{path} should be publicly cacheable"
8350 );
8351 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
8353 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
8354 }
8355
8356 let login = app
8358 .clone()
8359 .oneshot(
8360 Request::builder()
8361 .uri("/login")
8362 .body(Body::empty())
8363 .unwrap(),
8364 )
8365 .await
8366 .unwrap();
8367 assert_eq!(
8368 login.headers().get(header::CACHE_CONTROL).unwrap(),
8369 "public, max-age=300"
8370 );
8371
8372 let home = app
8374 .oneshot(
8375 Request::builder()
8376 .uri("/")
8377 .header(header::COOKIE, admin_cookie)
8378 .body(Body::empty())
8379 .unwrap(),
8380 )
8381 .await
8382 .unwrap();
8383 assert_eq!(
8384 home.headers().get(header::CACHE_CONTROL).unwrap(),
8385 "no-store"
8386 );
8387 }
8388
8389 fn head(body: &str) -> &str {
8398 let end = body.find("</head>").expect("a <head>");
8399 &body[..end]
8400 }
8401
8402 fn meta(head: &str, attr: &str) -> Option<String> {
8405 let tag_start = head.find(attr)?;
8406 let rest = &head[tag_start..];
8407 let tag_end = rest.find('>')?;
8408 let tag = &rest[..tag_end];
8409 let content = tag.find("content=\"")? + "content=\"".len();
8410 let close = tag[content..].find('"')?;
8411 Some(tag[content..content + close].to_string())
8412 }
8413
8414 async fn production_origin_state() -> AppState {
8418 let db = store::init_url("sqlite::memory:").await.unwrap();
8419 store::ensure_seed(&db, &["did:plc:admin".to_string()])
8420 .await
8421 .unwrap();
8422 let config = Config {
8423 allowed_dids: vec!["did:plc:admin".to_string()],
8424 cookie_secret: "test-cookie-secret-000".to_string(),
8425 beta_cap: 3,
8426 public_url: "https://feather-reader.com".to_string(),
8427 ..Config::default()
8428 };
8429 AppState::new(config, db).unwrap()
8430 }
8431
8432 #[tokio::test]
8435 async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
8436 let landing = public_body(production_origin_state().await, "/").await;
8437 let about = public_body(production_origin_state().await, "/about").await;
8438 let (lh, ah) = (head(&landing), head(&about));
8439
8440 assert_eq!(
8441 meta(lh, "property=\"og:title\"").as_deref(),
8442 Some("FeatherReader — read, quietly"),
8443 "{lh}"
8444 );
8445 assert_eq!(
8446 meta(ah, "property=\"og:title\"").as_deref(),
8447 Some("About — FeatherReader"),
8448 "{ah}"
8449 );
8450 for (h, path) in [(lh, "/"), (ah, "/about")] {
8451 let url = format!("https://feather-reader.com{path}");
8452 assert_eq!(
8453 meta(h, "property=\"og:url\"").as_deref(),
8454 Some(url.as_str())
8455 );
8456 assert!(
8457 h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
8458 "{path} must carry a canonical link: {h}"
8459 );
8460 let image = meta(h, "property=\"og:image\"").unwrap_or_default();
8461 assert!(
8462 image.starts_with("https://feather-reader.com/static/"),
8463 "{path}: og:image must be absolute on the public origin, got {image:?}"
8464 );
8465 assert_eq!(
8466 meta(h, "name=\"twitter:card\"").as_deref(),
8467 Some("summary_large_image")
8468 );
8469 assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
8470 assert_eq!(
8471 meta(h, "property=\"og:site_name\"").as_deref(),
8472 Some("FeatherReader")
8473 );
8474 let description = meta(h, "property=\"og:description\"").unwrap_or_default();
8475 assert!(!description.is_empty(), "{path}: og:description is empty");
8476 assert_eq!(
8477 meta(h, "name=\"description\"").as_deref(),
8478 Some(description.as_str()),
8479 "{path}: the meta description and og:description must agree"
8480 );
8481 }
8482 assert_ne!(
8483 meta(lh, "property=\"og:description\""),
8484 meta(ah, "property=\"og:description\""),
8485 "the landing page and /about must not share a description"
8486 );
8487 }
8488
8489 #[tokio::test]
8491 async fn card_urls_follow_the_configured_public_url() {
8492 let db = store::init_url("sqlite::memory:").await.unwrap();
8493 store::ensure_seed(&db, &[]).await.unwrap();
8494 let config = Config {
8495 cookie_secret: "test-cookie-secret-000".to_string(),
8496 public_url: "https://reader.example.org".to_string(),
8497 ..Config::default()
8498 };
8499 let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8500 let h = head(&body);
8501 assert_eq!(
8502 meta(h, "property=\"og:url\"").as_deref(),
8503 Some("https://reader.example.org/privacy")
8504 );
8505 assert_eq!(
8506 meta(h, "property=\"og:image\"").as_deref(),
8507 Some("https://reader.example.org/static/social-card.png")
8508 );
8509 }
8510
8511 #[tokio::test]
8514 async fn public_pages_each_carry_their_own_description() {
8515 let paths = [
8516 "/",
8517 "/about",
8518 "/privacy",
8519 "/terms",
8520 "/stats",
8521 "/standard-site",
8522 "/login",
8523 "/beta/redeem",
8524 ];
8525 let mut seen = std::collections::HashSet::new();
8526 for path in paths {
8527 let body = public_body(production_origin_state().await, path).await;
8528 let h = head(&body);
8529 let description = meta(h, "name=\"description\"").unwrap_or_default();
8530 assert!(!description.is_empty(), "{path} has no description: {h}");
8531 assert!(
8532 seen.insert(description.clone()),
8533 "{path} repeats another page's description: {description:?}"
8534 );
8535 assert_eq!(
8536 meta(h, "property=\"og:url\"").as_deref(),
8537 Some(format!("https://feather-reader.com{path}").as_str()),
8538 "{path}"
8539 );
8540 assert!(
8541 !h.contains("name=\"robots\""),
8542 "{path} is public and must not be noindex: {h}"
8543 );
8544 }
8545 }
8546
8547 #[tokio::test]
8550 async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8551 let landing = public_body(production_origin_state().await, "/").await;
8552 let h = head(&landing);
8553 let image = meta(h, "property=\"og:image\"").unwrap();
8554 let path = image.strip_prefix("https://feather-reader.com").unwrap();
8555 let width: u32 = meta(h, "property=\"og:image:width\"")
8556 .unwrap()
8557 .parse()
8558 .unwrap();
8559 let height: u32 = meta(h, "property=\"og:image:height\"")
8560 .unwrap()
8561 .parse()
8562 .unwrap();
8563 assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8564 assert_eq!(
8565 meta(h, "property=\"og:image:type\"").as_deref(),
8566 Some("image/png")
8567 );
8568 assert!(
8569 !meta(h, "property=\"og:image:alt\"")
8570 .unwrap_or_default()
8571 .is_empty(),
8572 "the image needs alt text"
8573 );
8574
8575 let resp = router(production_origin_state().await)
8576 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8577 .await
8578 .unwrap();
8579 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8580 assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8581 assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8582 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8583 .await
8584 .expect("the image is under 1 MB");
8585 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8586 let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8588 assert_eq!(
8589 (be(16), be(20)),
8590 (width, height),
8591 "the PNG's own dimensions must match the tags"
8592 );
8593 }
8594
8595 #[tokio::test]
8598 async fn private_pages_keep_user_data_out_of_the_card() {
8599 for path in ["/", "/manage"] {
8600 let state = production_origin_state().await;
8601 let body = signed_in_body(state, path, "did:plc:admin").await;
8602 let h = head(&body);
8603 assert!(
8604 h.contains("<meta name=\"robots\" content=\"noindex\""),
8605 "{path}: a private view must be noindex: {h}"
8606 );
8607 assert_eq!(
8608 meta(h, "property=\"og:title\"").as_deref(),
8609 Some("FeatherReader — read, quietly"),
8610 "{path}: the card of a private view is the site's generic one"
8611 );
8612 assert_eq!(
8613 meta(h, "property=\"og:url\"").as_deref(),
8614 Some("https://feather-reader.com/"),
8615 "{path}: og:url of a private view is the front door, not the private path"
8616 );
8617 for private in ["reader.example", "did:plc:admin"] {
8618 assert!(
8619 !h.contains(private),
8620 "{path}: {private:?} must not reach <head>: {h}"
8621 );
8622 }
8623 }
8624 }
8625
8626 #[tokio::test]
8627 async fn beta_redeem_page_renders() {
8628 let state = test_state(&[]).await;
8629 let app = router(state);
8630 let resp = app
8631 .oneshot(
8632 Request::builder()
8633 .uri("/beta/redeem")
8634 .body(Body::empty())
8635 .unwrap(),
8636 )
8637 .await
8638 .unwrap();
8639 assert_eq!(resp.status(), StatusCode::OK);
8640 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8641 .await
8642 .unwrap();
8643 let html = String::from_utf8(bytes.to_vec()).unwrap();
8644 assert!(html.contains("Invite code"));
8645 assert!(html.contains("/beta/redeem"));
8646 }
8647
8648 #[tokio::test]
8649 async fn rate_limit_returns_429_after_burst() {
8650 let db = store::init_url("sqlite::memory:").await.unwrap();
8653 store::ensure_seed(&db, &[]).await.unwrap();
8654 let config = Config {
8655 cookie_secret: "test-cookie-secret-000".to_string(),
8656 beta_cap: 3,
8657 trusted_ip_header: Some("cf-connecting-ip".to_string()),
8658 ..Config::default()
8659 };
8660 let state = AppState::new(config, db).unwrap();
8661 let app = router(state);
8662 let mut saw_429 = false;
8666 for _ in 0..(RATE_BURST as usize + 5) {
8667 let resp = app
8668 .clone()
8669 .oneshot(
8670 Request::builder()
8671 .method("POST")
8672 .uri("/beta/redeem")
8673 .header("content-type", "application/x-www-form-urlencoded")
8674 .header("cf-connecting-ip", "203.0.113.200")
8675 .body(Body::from("code=FEATHER-NOPENOPE"))
8676 .unwrap(),
8677 )
8678 .await
8679 .unwrap();
8680 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8681 saw_429 = true;
8682 break;
8683 }
8684 }
8685 assert!(saw_429, "expected a 429 after exhausting the burst");
8686 }
8687
8688 #[tokio::test]
8701 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8702 let state = test_state(&[]).await;
8703 assert!(
8704 state.config.trusted_ip_header.is_none(),
8705 "no proxy header is trusted here"
8706 );
8707 let app = router(state);
8708 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8709 let mut saw_429 = false;
8710 for i in 0..(RATE_BURST as usize + 5) {
8711 let forged = format!("10.9.8.{}", i % 250);
8712 let resp = app
8713 .clone()
8714 .oneshot(
8715 Request::builder()
8716 .method("POST")
8717 .uri("/beta/redeem")
8718 .header("content-type", "application/x-www-form-urlencoded")
8719 .header("x-forwarded-for", forged)
8720 .extension(axum::extract::ConnectInfo(peer))
8721 .body(Body::from("code=FEATHER-NOPENOPE"))
8722 .unwrap(),
8723 )
8724 .await
8725 .unwrap();
8726 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8727 saw_429 = true;
8728 break;
8729 }
8730 }
8731 assert!(
8732 saw_429,
8733 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8734 );
8735 }
8736
8737 #[tokio::test]
8746 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8747 let did = "did:plc:privateadder";
8748 let state = test_state_with_caps(did, 0, 0).await;
8749 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8750 let port: u16 = base
8751 .trim_end_matches('/')
8752 .rsplit(':')
8753 .next()
8754 .unwrap()
8755 .parse()
8756 .unwrap();
8757 crate::net::test_host_override(
8758 "private-add.test",
8759 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8760 );
8761 let cookie = session_cookie(&state, did, None);
8762 let resp = router(state.clone())
8763 .oneshot(
8764 Request::builder()
8765 .method("POST")
8766 .uri("/subscriptions")
8767 .header(header::COOKIE, cookie)
8768 .header("content-type", "application/x-www-form-urlencoded")
8769 .body(Body::from(format!(
8770 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8771 )))
8772 .unwrap(),
8773 )
8774 .await
8775 .unwrap();
8776 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8777 let loc = resp
8778 .headers()
8779 .get(header::LOCATION)
8780 .unwrap()
8781 .to_str()
8782 .unwrap();
8783 assert!(loc.contains("Private"), "not refused as private: {loc}");
8784 assert_eq!(
8785 hits.load(std::sync::atomic::Ordering::SeqCst),
8786 0,
8787 "the private feed was FETCHED before being refused"
8788 );
8789 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8790 }
8791
8792 #[tokio::test]
8797 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8798 let did = "did:plc:renamer4";
8799 let (sidecar, bodies) = spawn_logging_sidecar().await;
8800 let state = test_state_with_sidecar(&[did], &sidecar).await;
8801 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8802 let opml = format!(
8803 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8804 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8805 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8806 </body></opml>"
8807 );
8808 let (ct, body) = opml_multipart(opml.as_bytes());
8809 let cookie = session_cookie(&state, did, None);
8810 let resp = router(state.clone())
8811 .oneshot(
8812 Request::builder()
8813 .method("POST")
8814 .uri("/opml")
8815 .header(header::COOKIE, cookie)
8816 .header("content-type", ct)
8817 .body(Body::from(body))
8818 .unwrap(),
8819 )
8820 .await
8821 .unwrap();
8822 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8823 let loc = resp
8824 .headers()
8825 .get(header::LOCATION)
8826 .unwrap()
8827 .to_str()
8828 .unwrap();
8829 assert!(
8830 loc.contains("skipped%20as%20private"),
8831 "not reported as skipped: {loc}"
8832 );
8833 assert!(store::get_feed_by_url(&state.db, tokened)
8834 .await
8835 .unwrap()
8836 .is_none());
8837 let sent = bodies.lock().unwrap().join("\n");
8838 assert!(
8839 sent.contains("public.example"),
8840 "the public feed was not written: {sent}"
8841 );
8842 assert!(
8843 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8844 "the secret was PUBLISHED to the PDS: {sent}"
8845 );
8846 }
8847
8848 #[tokio::test]
8852 async fn get_login_without_a_seat_is_refused() {
8853 let state = test_state(&[]).await;
8854 let resp = router(state)
8855 .oneshot(
8856 Request::builder()
8857 .method("GET")
8858 .uri("/login?handle=alice.bsky.social")
8859 .body(Body::empty())
8860 .unwrap(),
8861 )
8862 .await
8863 .unwrap();
8864 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8865 assert_eq!(
8866 resp.headers().get(header::LOCATION).unwrap(),
8867 "/beta/redeem"
8868 );
8869 }
8870
8871 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8875 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8876 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8877 let addr = listener.local_addr().unwrap();
8878 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8879 let sink = log.clone();
8880 tokio::spawn(async move {
8881 loop {
8882 let Ok((mut sock, _)) = listener.accept().await else {
8883 break;
8884 };
8885 let mut raw: Vec<u8> = Vec::new();
8886 let mut chunk = [0u8; 4096];
8887 let text = loop {
8888 let Ok(n) = sock.read(&mut chunk).await else {
8889 break String::new();
8890 };
8891 if n == 0 {
8892 break String::from_utf8_lossy(&raw).to_string();
8893 }
8894 raw.extend_from_slice(&chunk[..n]);
8895 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8896 continue;
8897 };
8898 let (head, body) = raw.split_at(split + 4);
8899 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8900 let (k, v) = l.split_once(':')?;
8901 k.eq_ignore_ascii_case("content-length")
8902 .then(|| v.trim().parse::<usize>().ok())?
8903 });
8904 if want.is_none_or(|w| body.len() >= w) {
8905 break String::from_utf8_lossy(&raw).to_string();
8906 }
8907 };
8908 let path = text
8909 .lines()
8910 .next()
8911 .and_then(|l| l.split_whitespace().nth(1))
8912 .unwrap_or("")
8913 .to_string();
8914 let body_text = text
8915 .split_once("\r\n\r\n")
8916 .map(|(_, b)| b)
8917 .unwrap_or("")
8918 .to_string();
8919 sink.lock().unwrap().push(format!("{path} {body_text}"));
8920 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();
8921 let resp = format!(
8922 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8923 body.len(),
8924 body
8925 );
8926 let _ = sock.write_all(resp.as_bytes()).await;
8927 let _ = sock.flush().await;
8928 }
8929 });
8930 (format!("http://{addr}"), log)
8931 }
8932
8933 #[tokio::test]
8939 async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8940 use crate::readstate::tests as rs;
8941 for backend in [
8942 crate::metrics::Backend::Sidecar,
8943 crate::metrics::Backend::Rust,
8944 ] {
8945 let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8946 let state = rs::state_on(backend, &fake).await;
8947 for i in 0..250 {
8948 rs::mark_read(&state, i, "1").await;
8949 }
8950 fake.lock().unwrap().drop_call = Some(2);
8951
8952 flush_before_revoke(&state, rs::DID).await;
8953
8954 let order = rs::send_order(250);
8955 let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8956 for &i in landed {
8957 let c = rs::cursor(&state, i).await;
8958 assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8959 }
8960 for &i in rest {
8961 let c = rs::cursor(&state, i).await;
8962 assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8963 }
8964 assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8965 }
8966 }
8967
8968 #[tokio::test]
8977 async fn signing_out_flushes_before_it_revokes_through_the_route() {
8978 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8979 let (sidecar, log) = spawn_logging_sidecar().await;
8980 let state = test_state_with_sidecar(&[did], &sidecar).await;
8981 crate::store::upsert_cursor(
8982 &state.db,
8983 &crate::store::ReadCursor {
8984 did: did.to_string(),
8985 feed_url: "https://example.com/feed.xml".into(),
8986 read_through: None,
8987 read_ids: "[\"1\"]".into(),
8988 unread_ids: "[]".into(),
8989 dirty: true,
8990 pds_created: false,
8991 updated_at: "2026-09-13T21:22:40Z".into(),
8992 },
8993 )
8994 .await
8995 .unwrap();
8996 let cookie = session_cookie(&state, did, None);
8997 let resp = router(state.clone())
8998 .oneshot(
8999 Request::builder()
9000 .method("POST")
9001 .uri("/logout")
9002 .header(header::COOKIE, cookie)
9003 .body(Body::empty())
9004 .unwrap(),
9005 )
9006 .await
9007 .unwrap();
9008 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9009
9010 let entries = log.lock().unwrap().clone();
9011 let flush = entries
9012 .iter()
9013 .position(|e| e.starts_with("/internal/repo "));
9014 let revoke = entries
9015 .iter()
9016 .position(|e| e.starts_with("/internal/revoke "));
9017 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
9018 assert!(
9019 flush.is_some(),
9020 "sign-out did not attempt a flush before revoking: {entries:?}"
9021 );
9022 assert!(
9023 flush < revoke,
9024 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
9025 );
9026 }
9027
9028 const EXPECTED_CSP: &str = "default-src 'self'; \
9032 script-src 'self'; \
9033 style-src 'self' 'unsafe-inline'; \
9034 img-src 'self' https: data:; \
9035 font-src 'self'; \
9036 connect-src 'self'; \
9037 form-action 'self'; \
9038 base-uri 'self'; \
9039 frame-ancestors 'none'; \
9040 object-src 'none'";
9041
9042 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
9045 let boundary = "----featherreadertestboundary";
9046 let mut body = Vec::new();
9047 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
9048 body.extend_from_slice(
9049 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
9050 );
9051 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
9052 body.extend_from_slice(payload);
9053 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
9054 (format!("multipart/form-data; boundary={boundary}"), body)
9055 }
9056
9057 #[tokio::test]
9058 async fn opml_import_oversize_upload_returns_413() {
9059 let state = test_state(&["did:plc:admin"]).await;
9060 let cookie = session_cookie(&state, "did:plc:admin", None);
9061 let app = router(state);
9062
9063 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
9065 let (content_type, body) = opml_multipart(&payload);
9066
9067 let resp = app
9068 .oneshot(
9069 Request::builder()
9070 .method("POST")
9071 .uri("/opml")
9072 .header("content-type", content_type)
9073 .header(header::COOKIE, cookie)
9074 .body(Body::from(body))
9075 .unwrap(),
9076 )
9077 .await
9078 .unwrap();
9079 assert_eq!(
9080 resp.status(),
9081 StatusCode::PAYLOAD_TOO_LARGE,
9082 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
9083 );
9084 }
9085
9086 #[tokio::test]
9097 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
9098 let state = test_state(&["did:plc:admin"]).await;
9099 let cookie = session_cookie(&state, "did:plc:admin", None);
9100 let app = router(state);
9101
9102 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
9104 let (content_type, body) = opml_multipart(&payload);
9105
9106 let resp = app
9107 .oneshot(
9108 Request::builder()
9109 .method("POST")
9110 .uri("/opml")
9111 .header("content-type", content_type)
9112 .header(header::COOKIE, cookie)
9113 .body(Body::from(body))
9114 .unwrap(),
9115 )
9116 .await
9117 .unwrap();
9118 assert_eq!(
9119 resp.status(),
9120 StatusCode::PAYLOAD_TOO_LARGE,
9121 "a payload over the route's cap but under the framework's was accepted — \
9122 the route's own DefaultBodyLimit layer is not doing anything"
9123 );
9124 }
9125
9126 #[tokio::test]
9127 async fn opml_import_under_limit_upload_is_accepted() {
9128 let state = test_state(&["did:plc:admin"]).await;
9129 let cookie = session_cookie(&state, "did:plc:admin", None);
9130 let db = state.db.clone();
9131 let app = router(state);
9132
9133 let opml = br#"<?xml version="1.0"?>
9136<opml version="2.0"><body>
9137 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
9138</body></opml>"#;
9139 let (content_type, body) = opml_multipart(opml);
9140
9141 let resp = app
9142 .oneshot(
9143 Request::builder()
9144 .method("POST")
9145 .uri("/opml")
9146 .header("content-type", content_type)
9147 .header(header::COOKIE, cookie)
9148 .body(Body::from(body))
9149 .unwrap(),
9150 )
9151 .await
9152 .unwrap();
9153 assert_eq!(
9160 resp.status(),
9161 StatusCode::SEE_OTHER,
9162 "an under-cap OPML upload was not accepted (status {})",
9163 resp.status(),
9164 );
9165 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
9169 .bind("https://example.com/feed.xml")
9170 .fetch_one(&db)
9171 .await
9172 .unwrap();
9173 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
9174 let location = resp
9175 .headers()
9176 .get(header::LOCATION)
9177 .and_then(|v| v.to_str().ok())
9178 .unwrap_or_default()
9179 .to_string();
9180 assert!(
9181 !location.starts_with("/login"),
9182 "the import bounced to login instead of being accepted: {location}",
9183 );
9184 }
9185
9186 #[tokio::test]
9187 async fn opml_import_logged_out_redirects_to_login() {
9188 let state = test_state(&["did:plc:admin"]).await;
9191 let app = router(state);
9192
9193 let opml = b"<opml version=\"2.0\"><body></body></opml>";
9194 let (content_type, body) = opml_multipart(opml);
9195
9196 let resp = app
9197 .oneshot(
9198 Request::builder()
9199 .method("POST")
9200 .uri("/opml")
9201 .header("content-type", content_type)
9202 .body(Body::from(body))
9203 .unwrap(),
9204 )
9205 .await
9206 .unwrap();
9207 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9208 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
9209 }
9210
9211 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
9218 use tokio::io::{AsyncReadExt, AsyncWriteExt};
9219 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9220 let addr = listener.local_addr().unwrap();
9221 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
9222 tokio::spawn(async move {
9223 let (mut sock, _) = listener.accept().await.unwrap();
9224 let mut buf = vec![0u8; 4096];
9225 let n = sock.read(&mut buf).await.unwrap();
9226 let req = String::from_utf8_lossy(&buf[..n]).to_string();
9227 let did = req
9229 .split("\r\n\r\n")
9230 .nth(1)
9231 .and_then(|body| {
9232 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
9233 v.get("did")?.as_str().map(str::to_string)
9234 })
9235 .unwrap_or_default();
9236 let is_revoke = req.starts_with("POST /internal/revoke");
9237 let body = serde_json::json!({
9238 "ok": true, "did": did, "revoked": true, "hadSession": true
9239 })
9240 .to_string();
9241 let resp = format!(
9242 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9243 body.len(),
9244 body
9245 );
9246 sock.write_all(resp.as_bytes()).await.unwrap();
9247 sock.flush().await.unwrap();
9248 let _ = tx.send(if is_revoke { did } else { String::new() });
9249 });
9250 (format!("http://{addr}"), rx)
9251 }
9252
9253 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
9255 let defaults = Config::default();
9256 test_state_with_sidecar_and(
9257 allowed,
9258 sidecar_url,
9259 defaults.standard_site,
9260 defaults.max_feeds_global,
9261 )
9262 .await
9263 }
9264
9265 async fn test_state_with_sidecar_and(
9268 allowed: &[&str],
9269 sidecar_url: &str,
9270 standard_site: bool,
9271 max_feeds_global: i64,
9272 ) -> AppState {
9273 let db = store::init_url("sqlite::memory:").await.unwrap();
9274 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
9275 store::ensure_seed(&db, &dids).await.unwrap();
9276 let mut config = Config {
9277 allowed_dids: dids,
9278 cookie_secret: "test-cookie-secret-000".to_string(),
9279 beta_cap: 3,
9280 standard_site,
9281 max_feeds_global,
9282 ..Config::default()
9283 };
9284 config.sidecar.public_url = sidecar_url.to_string();
9285 config.sidecar.internal_url = sidecar_url.to_string();
9286 AppState::new(config, db).unwrap()
9287 }
9288
9289 #[tokio::test]
9292 async fn account_delete_purges_rows_and_triggers_revoke() {
9293 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
9294 let did = "did:plc:leaver";
9295 let state = test_state_with_sidecar(&[], &sidecar_url).await;
9296
9297 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
9299 .await
9300 .unwrap();
9301 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
9302 store::mint_code(&state.db, did, 3600).await.unwrap();
9303 assert!(store::has_beta_access(&state.db, did).await.unwrap());
9304
9305 let cookie = session_cookie(&state, did, Some("leaver.example"));
9306 let app = router(state.clone());
9307
9308 let resp = app
9309 .oneshot(
9310 Request::builder()
9311 .method("POST")
9312 .uri("/account/delete")
9313 .header(header::COOKIE, cookie)
9314 .header("content-type", "application/x-www-form-urlencoded")
9315 .body(Body::from("confirm=DELETE"))
9316 .unwrap(),
9317 )
9318 .await
9319 .unwrap();
9320
9321 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9323 assert!(resp
9324 .headers()
9325 .get(header::LOCATION)
9326 .unwrap()
9327 .to_str()
9328 .unwrap()
9329 .starts_with("/login"));
9330 let set_cookie = resp
9331 .headers()
9332 .get(header::SET_COOKIE)
9333 .unwrap()
9334 .to_str()
9335 .unwrap();
9336 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
9337
9338 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
9345 .await
9346 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
9347 .unwrap();
9348 assert_eq!(
9349 revoked_did, did,
9350 "sidecar revoke must fire for the caller DID"
9351 );
9352
9353 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
9355 let codes: i64 =
9356 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
9357 .bind(did)
9358 .fetch_one(&state.db)
9359 .await
9360 .unwrap();
9361 assert_eq!(codes, 0);
9362 }
9363
9364 #[tokio::test]
9367 async fn account_delete_without_confirm_is_a_noop() {
9368 let did = "did:plc:staying";
9369 let state = test_state(&[]).await;
9370 store::grant_access(&state.db, did, None, "test", None)
9371 .await
9372 .unwrap();
9373 let cookie = session_cookie(&state, did, None);
9374 let app = router(state.clone());
9375
9376 let resp = app
9377 .oneshot(
9378 Request::builder()
9379 .method("POST")
9380 .uri("/account/delete")
9381 .header(header::COOKIE, cookie)
9382 .header("content-type", "application/x-www-form-urlencoded")
9383 .body(Body::from("confirm=nope"))
9384 .unwrap(),
9385 )
9386 .await
9387 .unwrap();
9388
9389 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9390 assert!(resp
9391 .headers()
9392 .get(header::LOCATION)
9393 .unwrap()
9394 .to_str()
9395 .unwrap()
9396 .starts_with("/manage"));
9397 assert!(store::has_beta_access(&state.db, did).await.unwrap());
9399 }
9400
9401 #[tokio::test]
9408 async fn pds_outage_does_not_widen_cross_did_access() {
9409 let did_a = "did:plc:aaaa";
9410 let state = test_state(&[]).await;
9411 store::grant_access(&state.db, did_a, None, "test", None)
9412 .await
9413 .unwrap();
9414
9415 let feed_a = store::upsert_feed(
9418 &state.db,
9419 &store::NewFeed {
9420 url: "https://a.example/feed.xml".to_string(),
9421 title: Some("A".to_string()),
9422 ..Default::default()
9423 },
9424 )
9425 .await
9426 .unwrap();
9427 let feed_b = store::upsert_feed(
9428 &state.db,
9429 &store::NewFeed {
9430 url: "https://b.example/feed.xml".to_string(),
9431 title: Some("B".to_string()),
9432 ..Default::default()
9433 },
9434 )
9435 .await
9436 .unwrap();
9437 store::insert_entries(
9438 &state.db,
9439 feed_b,
9440 &[store::NewEntry {
9441 guid: "b-1".to_string(),
9442 url: Some("https://b.example/1".to_string()),
9443 title: Some("B one".to_string()),
9444 published: Some("2026-07-11T00:00:00Z".to_string()),
9445 content_html: Some("<p>secret B body</p>".to_string()),
9446 ..Default::default()
9447 }],
9448 0,
9449 )
9450 .await
9451 .unwrap();
9452 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9454 .await
9455 .unwrap();
9456 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
9459 .await
9460 .unwrap();
9461 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
9462 .await
9463 .unwrap()[0]
9464 .id;
9465 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
9466 .await
9467 .unwrap();
9468
9469 let cookie = session_cookie(&state, did_a, None);
9470 let app = router(state.clone());
9471
9472 let get_b = app
9474 .clone()
9475 .oneshot(
9476 Request::builder()
9477 .method("GET")
9478 .uri(format!("/entries/{b_entry_id}"))
9479 .header(header::COOKIE, cookie.clone())
9480 .body(Body::empty())
9481 .unwrap(),
9482 )
9483 .await
9484 .unwrap();
9485 assert_eq!(
9486 get_b.status(),
9487 StatusCode::NOT_FOUND,
9488 "A must not read B's entry during a PDS outage"
9489 );
9490
9491 let read_b = app
9493 .oneshot(
9494 Request::builder()
9495 .method("POST")
9496 .uri(format!("/entries/{b_entry_id}/read"))
9497 .header(header::COOKIE, cookie)
9498 .header("content-type", "application/x-www-form-urlencoded")
9499 .body(Body::from("read=true"))
9500 .unwrap(),
9501 )
9502 .await
9503 .unwrap();
9504 assert_eq!(
9505 read_b.status(),
9506 StatusCode::NOT_FOUND,
9507 "A must not mark B's entry read during a PDS outage"
9508 );
9509
9510 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9512 .bind(did_a)
9513 .fetch_all(&state.db)
9514 .await
9515 .unwrap();
9516 assert_eq!(
9517 a_feed_ids,
9518 vec![feed_a],
9519 "outage fallback must not add feeds A never subscribed to"
9520 );
9521 let es_count: i64 =
9523 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9524 .bind(did_a)
9525 .bind(b_entry_id)
9526 .fetch_one(&state.db)
9527 .await
9528 .unwrap();
9529 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9530 }
9531
9532 #[tokio::test]
9546 async fn a_logout_with_no_session_counts_as_success() {
9547 let did = "did:plc:aaaa";
9548 let state = test_state(&[]).await;
9549 assert!(
9550 state.oauth.is_some(),
9551 "meaningless without an oauth runtime; the revoke arm would be skipped",
9552 );
9553
9554 revoke_everywhere(&state, did).await;
9555 let rows = state.metrics.snapshot();
9556 let find = |b: crate::metrics::Backend| {
9557 rows.iter()
9558 .find(|r| r.op == "oauth_revoke" && r.backend == b)
9559 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9560 };
9561
9562 let rust = find(crate::metrics::Backend::Rust);
9564 assert_eq!(
9565 rust.stats.err_count, 0,
9566 "NoSession was counted as a failure; logout is idempotent",
9567 );
9568 assert_eq!(rust.stats.ok_count, 1);
9569
9570 let sidecar = find(crate::metrics::Backend::Sidecar);
9574 assert_eq!(
9575 sidecar.stats.err_count, 1,
9576 "a failed sidecar revoke was not counted",
9577 );
9578 }
9579
9580 #[tokio::test]
9590 async fn a_failed_rust_revoke_counts_as_an_error() {
9591 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9592 let state = test_state(&[]).await;
9593 let runtime = state.oauth.as_deref().expect("oauth runtime");
9594 crate::oauth::store::put_session(
9595 &state.db,
9596 &runtime.codec,
9597 &crate::oauth::store::OAuthSession {
9598 sub: did.into(),
9599 issuer: "https://auth.invalid".into(),
9600 aud: "https://pds.invalid".into(),
9601 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9602 .to_jwk_json()
9603 .unwrap(),
9604 access_token: "at".into(),
9605 refresh_token: "rt".into(),
9606 token_type: "DPoP".into(),
9607 granted_scope: "atproto".into(),
9608 expires_at: Some(crate::store::now_unix() + 3600),
9609 },
9610 )
9611 .await
9612 .unwrap();
9613
9614 revoke_everywhere(&state, did).await;
9615
9616 let rows = state.metrics.snapshot();
9617 let rust = rows
9618 .iter()
9619 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9620 .expect("no rust oauth_revoke row");
9621 assert_eq!(
9622 rust.stats.err_count, 1,
9623 "an unreachable PDS must count as a revocation failure",
9624 );
9625 assert_eq!(rust.stats.ok_count, 0);
9626 }
9627
9628 #[test]
9644 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9645 for hostile in [
9646 "javascript:alert(1)",
9647 "JavaScript:alert(1)",
9648 " javascript:alert(1)",
9649 "data:text/html;base64,PHNjcmlwdD4=",
9650 "vbscript:msgbox(1)",
9651 "file:///etc/passwd",
9652 "//evil.example/path",
9656 ] {
9657 let link = SafeLink::external(hostile);
9658 assert!(
9659 link.is_empty(),
9660 "{hostile:?} produced a non-empty href: {link}",
9661 );
9662 assert!(
9663 !link.to_string().to_ascii_lowercase().contains("script"),
9664 "{hostile:?} leaked into the rendered link",
9665 );
9666 }
9667
9668 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9671 let link = SafeLink::external(good);
9672 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9673 assert_eq!(link.to_string(), good);
9674 }
9675 }
9676
9677 #[tokio::test]
9692 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9693 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9694 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9695 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9696 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9697
9698 let resp = router(state)
9699 .oneshot(
9700 Request::builder()
9701 .uri("/?view=starred")
9702 .body(Body::empty())
9703 .unwrap(),
9704 )
9705 .await
9706 .unwrap();
9707 assert_eq!(resp.status(), StatusCode::OK);
9708 let body = String::from_utf8(
9709 axum::body::to_bytes(resp.into_body(), usize::MAX)
9710 .await
9711 .unwrap()
9712 .to_vec(),
9713 )
9714 .unwrap();
9715
9716 assert!(
9719 !body.to_ascii_lowercase().contains("javascript:"),
9720 "the hostile scheme reached the rendered page",
9721 );
9722 assert!(
9725 body.contains("unusable link"),
9726 "the row was dropped instead of rendering without an anchor",
9727 );
9728 }
9729
9730 #[tokio::test]
9747 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9748 let did = "did:plc:readerhref";
9749 let state = test_state(&[]).await;
9750 store::grant_access(&state.db, did, None, "test", None)
9751 .await
9752 .unwrap();
9753 let feed = store::upsert_feed(
9754 &state.db,
9755 &store::NewFeed {
9756 url: "https://href.example/feed.xml".to_string(),
9757 title: Some("Href".to_string()),
9758 ..Default::default()
9759 },
9760 )
9761 .await
9762 .unwrap();
9763 store::insert_entries(
9765 &state.db,
9766 feed,
9767 &[
9768 store::NewEntry {
9769 guid: "hostile-1".to_string(),
9770 url: Some("javascript:alert(1)".to_string()),
9771 title: Some("Hostile entry".to_string()),
9772 published: Some("2026-07-11T00:00:00Z".to_string()),
9773 ..Default::default()
9774 },
9775 store::NewEntry {
9776 guid: "benign-1".to_string(),
9777 url: Some("https://href.example/post".to_string()),
9778 title: Some("Benign entry".to_string()),
9779 published: Some("2026-07-10T00:00:00Z".to_string()),
9780 ..Default::default()
9781 },
9782 ],
9783 0,
9784 )
9785 .await
9786 .unwrap();
9787 store::replace_sub_refs(&state.db, did, &[feed])
9788 .await
9789 .unwrap();
9790 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9791 let id_of = |guid: &str| {
9792 rows.iter()
9793 .find(|r| r.guid == guid)
9794 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9795 .id
9796 };
9797
9798 let cookie = session_cookie(&state, did, None);
9799 let app = router(state.clone());
9800
9801 let render = |id: i64| {
9802 let app = app.clone();
9803 let cookie = cookie.clone();
9804 async move {
9805 let resp = app
9806 .oneshot(
9807 Request::builder()
9808 .method("GET")
9809 .uri(format!("/entries/{id}"))
9810 .header(header::COOKIE, cookie)
9811 .body(Body::empty())
9812 .unwrap(),
9813 )
9814 .await
9815 .unwrap();
9816 assert_eq!(resp.status(), StatusCode::OK);
9817 String::from_utf8(
9818 axum::body::to_bytes(resp.into_body(), usize::MAX)
9819 .await
9820 .unwrap()
9821 .to_vec(),
9822 )
9823 .unwrap()
9824 }
9825 };
9826
9827 let hostile = render(id_of("hostile-1")).await;
9828 assert!(
9831 hostile.contains("Hostile entry"),
9832 "the reader did not render the entry: {hostile}",
9833 );
9834 assert!(
9835 !hostile.to_ascii_lowercase().contains("javascript:"),
9836 "the hostile scheme reached the reader page: {hostile}",
9837 );
9838 assert!(
9842 !hostile.contains("actionbar-open"),
9843 "the action bar rendered an open-original link for a refused URL: {hostile}",
9844 );
9845 assert!(
9846 !hostile.contains("Original \u{2197}"),
9847 "the byline rendered an original link for a refused URL: {hostile}",
9848 );
9849
9850 let benign = render(id_of("benign-1")).await;
9853 assert!(
9854 benign.contains("Benign entry"),
9855 "the reader did not render the benign entry: {benign}",
9856 );
9857 assert_eq!(
9861 benign
9862 .matches(r#"href="https://href.example/post""#)
9863 .count(),
9864 2,
9865 "entry.html has two `href`s for the entry URL — the byline link and \
9866 the action-bar button — and this render produced a different \
9867 number: {benign}",
9868 );
9869 assert!(
9870 benign.contains("actionbar-open"),
9871 "a legitimate entry lost its open-original button: {benign}",
9872 );
9873 assert!(
9874 benign.contains("Original \u{2197}"),
9875 "a legitimate entry lost its byline link: {benign}",
9876 );
9877 }
9878
9879 #[tokio::test]
9892 async fn a_hostile_stored_body_renders_inert_on_the_reader_page() {
9893 let did = "did:plc:readerbody";
9894 let state = test_state(&[]).await;
9895 store::grant_access(&state.db, did, None, "test", None)
9896 .await
9897 .unwrap();
9898 let feed = store::upsert_feed(
9899 &state.db,
9900 &store::NewFeed {
9901 url: "https://body.example/feed.xml".to_string(),
9902 title: Some("Body".to_string()),
9903 ..Default::default()
9904 },
9905 )
9906 .await
9907 .unwrap();
9908 let hostile_body = concat!(
9910 "<p>kept <b>bold</b></p>",
9911 r#"<img src="x" onerror="alert(2)">"#,
9912 r#"<a href="javascript:alert(3)">click</a>"#,
9913 r#"<p onclick="alert(4)" style="color:red">tail</p>"#,
9914 "<script>alert(1)</script>",
9915 r#"<iframe src="https://evil.example/"></iframe>"#,
9916 );
9917 let clean_body = concat!(
9919 "<h2>Heading</h2>",
9920 r#"<p>Text with <a href="https://body.example/x" rel="noopener noreferrer">a link</a>, "#,
9921 "<em>emphasis</em> & an entity, <angle> brackets.</p>",
9922 "<pre><code>if a < b && c { }</code></pre>",
9923 r#"<ul><li>one</li><li>two</li></ul><img src="https://body.example/i.png" alt="i">"#,
9924 );
9925 store::insert_entries(
9926 &state.db,
9927 feed,
9928 &[
9929 store::NewEntry {
9930 guid: "hostile-body".to_string(),
9931 title: Some("Hostile body".to_string()),
9932 published: Some("2026-07-11T00:00:00Z".to_string()),
9933 content_html: Some(hostile_body.to_string()),
9934 ..Default::default()
9935 },
9936 store::NewEntry {
9937 guid: "clean-body".to_string(),
9938 title: Some("Clean body".to_string()),
9939 published: Some("2026-07-10T00:00:00Z".to_string()),
9940 content_html: Some(clean_body.to_string()),
9941 ..Default::default()
9942 },
9943 store::NewEntry {
9946 guid: "oversize-body".to_string(),
9947 title: Some("Oversize body".to_string()),
9948 published: Some("2026-07-09T00:00:00Z".to_string()),
9949 content_html: Some(format!(
9950 "<p>{}OVERSIZE</p>",
9951 "a".repeat(crate::sanitized_html::MAX_RENDER_HTML_BYTES)
9952 )),
9953 ..Default::default()
9954 },
9955 ],
9956 0,
9957 )
9958 .await
9959 .unwrap();
9960 store::replace_sub_refs(&state.db, did, &[feed])
9961 .await
9962 .unwrap();
9963 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9964 let id_of = |guid: &str| {
9965 rows.iter()
9966 .find(|r| r.guid == guid)
9967 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9968 .id
9969 };
9970
9971 let cookie = session_cookie(&state, did, None);
9972 let app = router(state.clone());
9973 let prose = |id: i64| {
9976 let app = app.clone();
9977 let cookie = cookie.clone();
9978 async move {
9979 let resp = app
9980 .oneshot(
9981 Request::builder()
9982 .method("GET")
9983 .uri(format!("/entries/{id}"))
9984 .header(header::COOKIE, cookie)
9985 .body(Body::empty())
9986 .unwrap(),
9987 )
9988 .await
9989 .unwrap();
9990 assert_eq!(resp.status(), StatusCode::OK);
9991 let page = String::from_utf8(
9992 axum::body::to_bytes(resp.into_body(), usize::MAX)
9993 .await
9994 .unwrap()
9995 .to_vec(),
9996 )
9997 .unwrap();
9998 let start = page
9999 .find(r#"<div class="prose">"#)
10000 .unwrap_or_else(|| panic!("no prose block: {page}"));
10001 let end = page[start..]
10002 .find("</article>")
10003 .map(|e| start + e)
10004 .unwrap_or_else(|| panic!("no </article>: {page}"));
10005 page[start..end].to_string()
10006 }
10007 };
10008
10009 let hostile = prose(id_of("hostile-body")).await;
10010 let lower = hostile.to_ascii_lowercase();
10011 for needle in [
10012 "<script",
10013 "alert(1)",
10014 "onerror",
10015 "javascript:",
10016 "<iframe",
10017 "onclick",
10018 ] {
10019 assert!(
10020 !lower.contains(needle),
10021 "`{needle}` from a stored body reached the reader page: {hostile}",
10022 );
10023 }
10024 assert!(
10026 hostile.contains("<p>kept <b>bold</b></p>"),
10027 "the benign markup did not render as markup: {hostile}",
10028 );
10029 assert!(
10030 hostile.contains(r#"<img src="x">"#)
10031 && hostile.contains(r#"<a rel="noopener noreferrer">click</a>"#),
10032 "the sanitizer's output did not reach the page: {hostile}",
10033 );
10034
10035 let clean = prose(id_of("clean-body")).await;
10036 assert!(
10037 clean.contains(clean_body),
10038 "an already-clean stored body did not render byte-identically: {clean}",
10039 );
10040 assert!(
10041 !clean.contains("body-too-large")
10042 && !clean.contains("body-unavailable")
10043 && !clean.contains("body-too-slow"),
10044 "a whole, ordinary body was shown as refused: {clean}",
10045 );
10046
10047 let over = prose(id_of("oversize-body")).await;
10050 assert!(
10051 !over.contains("OVERSIZE") && !over.contains(&"a".repeat(64)),
10052 "an over-size stored body was rendered: {}…",
10053 &over[..over.len().min(300)],
10054 );
10055 assert!(
10056 over.contains("body-too-large"),
10057 "an over-size body did not say so: {over}",
10058 );
10059 }
10060
10061 #[test]
10070 fn the_reader_template_renders_each_body_outcome() {
10071 let config = Config::default();
10072 let user = CurrentUser {
10073 did: "did:plc:bodyoutcomes".to_string(),
10074 handle: None,
10075 sid: None,
10076 };
10077 let page = |content_html: Option<BodyRender>| {
10078 EntryTemplate {
10079 card: Card::private(&config),
10080 version: VERSION,
10081 repo_url: REPO_URL,
10082 kofi_url: KOFI_URL,
10083 nav: build_nav(&user, "unread", String::new(), vec![], vec![], false),
10084 id: 1,
10085 title: "T".to_string(),
10086 feed_title: "F".to_string(),
10087 author: None,
10088 published: String::new(),
10089 url: SafeLink::external_opt("https://orig.example/a"),
10090 content_html,
10091 read: false,
10092 starred: false,
10093 back_qs: String::new(),
10094 prev_id: None,
10095 next_id: None,
10096 oob: false,
10097 }
10098 .render()
10099 .unwrap()
10100 };
10101
10102 let html = page(Some(BodyRender::Html(
10103 crate::sanitized_html::SanitizedHtml::clean("<p>body <b>here</b></p>"),
10104 )));
10105 assert!(html.contains("<p>body <b>here</b></p>"), "{html}");
10106 assert!(
10107 !html.contains("body-too-large")
10108 && !html.contains("body-unavailable")
10109 && !html.contains("body-too-slow")
10110 );
10111
10112 let too_large = page(Some(BodyRender::TooLarge));
10113 assert!(too_large.contains("body-too-large"), "{too_large}");
10114 assert!(too_large.contains("too large to display"), "{too_large}");
10115 assert!(!too_large.contains("body-unavailable"));
10116
10117 let unavailable = page(Some(BodyRender::Unavailable));
10118 assert!(unavailable.contains("body-unavailable"), "{unavailable}");
10119 assert!(
10120 unavailable.contains("temporarily unavailable"),
10121 "{unavailable}"
10122 );
10123 assert!(!unavailable.contains("body-too-large"));
10124
10125 let too_slow = page(Some(BodyRender::TooSlow));
10126 assert!(too_slow.contains("body-too-slow"), "{too_slow}");
10127 assert!(too_slow.contains("too complex to display"), "{too_slow}");
10128 assert!(
10129 !too_slow.contains("body-too-large") && !too_slow.contains("body-unavailable"),
10130 "{too_slow}"
10131 );
10132
10133 let none = page(None);
10134 assert!(none.contains("has no stored content"), "{none}");
10135 }
10136
10137 #[tokio::test]
10157 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
10158 let did_a = "did:plc:aaaa";
10159 let state = test_state(&[]).await;
10160 store::grant_access(&state.db, did_a, None, "test", None)
10161 .await
10162 .unwrap();
10163
10164 let feed_a = store::upsert_feed(
10165 &state.db,
10166 &store::NewFeed {
10167 url: "https://a.example/feed.xml".to_string(),
10168 title: Some("A".to_string()),
10169 ..Default::default()
10170 },
10171 )
10172 .await
10173 .unwrap();
10174 let _feed_b = store::upsert_feed(
10175 &state.db,
10176 &store::NewFeed {
10177 url: "https://b.example/feed.xml".to_string(),
10178 title: Some("B".to_string()),
10179 ..Default::default()
10180 },
10181 )
10182 .await
10183 .unwrap();
10184 store::replace_sub_refs(&state.db, did_a, &[feed_a])
10187 .await
10188 .unwrap();
10189
10190 assert!(
10195 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
10196 "this test is only meaningful on the outage path; the repo answered",
10197 );
10198
10199 let resolved = resolve_subscriptions(&state, did_a).await;
10200
10201 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
10202 assert_eq!(
10203 urls,
10204 vec!["https://a.example/feed.xml"],
10205 "the outage fallback must return the caller's OWN subscriptions only; \
10206 any other feed here is cross-tenant read access granted by an outage",
10207 );
10208 }
10209
10210 async fn test_state_with_caps(
10213 did: &str,
10214 max_subs_per_did: i64,
10215 max_feeds_global: i64,
10216 ) -> AppState {
10217 let db = store::init_url("sqlite::memory:").await.unwrap();
10218 let config = Config {
10219 cookie_secret: "test-cookie-secret-000".to_string(),
10220 beta_cap: 100,
10221 max_subs_per_did,
10222 max_feeds_global,
10223 ..Config::default()
10224 };
10225 store::grant_access(&db, did, None, "test", None)
10226 .await
10227 .unwrap();
10228 AppState::new(config, db).unwrap()
10229 }
10230
10231 fn opml_with_feeds(n: usize) -> String {
10233 let mut outlines = String::new();
10234 for i in 0..n {
10235 outlines.push_str(&format!(
10236 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
10237 ));
10238 }
10239 format!(
10240 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
10241 )
10242 }
10243
10244 #[tokio::test]
10249 async fn opml_import_enforces_global_feeds_ceiling() {
10250 let did = "did:plc:importer";
10251 let state = test_state_with_caps(did, 0, 3).await;
10253 let cookie = session_cookie(&state, did, None);
10254 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10255 let app = router(state.clone());
10256
10257 let resp = app
10258 .oneshot(
10259 Request::builder()
10260 .method("POST")
10261 .uri("/opml")
10262 .header(header::COOKIE, cookie)
10263 .header("content-type", ct)
10264 .body(Body::from(body))
10265 .unwrap(),
10266 )
10267 .await
10268 .unwrap();
10269 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10270
10271 let feeds = store::count_feeds(&state.db).await.unwrap();
10272 assert!(
10273 feeds <= 3,
10274 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
10275 );
10276 }
10277
10278 async fn import_against_strict_pds(
10281 did: &str,
10282 n: usize,
10283 fail_call: Option<usize>,
10284 ) -> (String, crate::atproto::tests::ApplyWritesLog) {
10285 let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
10286 let state = test_state_with_sidecar(&[did], &sidecar).await;
10287 let cookie = session_cookie(&state, did, None);
10288 let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
10289 let resp = router(state)
10290 .oneshot(
10291 Request::builder()
10292 .method("POST")
10293 .uri("/opml")
10294 .header(header::COOKIE, cookie)
10295 .header("content-type", ct)
10296 .body(Body::from(body))
10297 .unwrap(),
10298 )
10299 .await
10300 .unwrap();
10301 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10302 let loc = resp.headers()[header::LOCATION].to_str().unwrap();
10303 let flash = url::Url::parse(&format!("http://x{loc}"))
10304 .unwrap()
10305 .query_pairs()
10306 .find(|(k, _)| k == "flash")
10307 .map(|(_, v)| v.into_owned())
10308 .unwrap_or_default();
10309 (flash, log)
10310 }
10311
10312 #[tokio::test]
10316 async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
10317 let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
10318 assert_eq!(flash, "Imported 450 feeds", "{flash}");
10319 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
10320 }
10321
10322 #[tokio::test]
10326 async fn opml_import_that_part_lands_reports_what_landed() {
10327 let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
10328 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
10329 assert!(
10330 flash.contains("200 of 450"),
10331 "the landed count is not reported: {flash}"
10332 );
10333 assert!(
10334 !flash.contains("nothing was imported"),
10335 "200 feeds landed and the reader was told none did: {flash}"
10336 );
10337 }
10338
10339 #[tokio::test]
10342 async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
10343 let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
10344 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
10345 assert!(flash.contains("nothing was imported"), "{flash}");
10346 }
10347
10348 #[tokio::test]
10357 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
10358 let did = "did:plc:typoist";
10359 let state = test_state_with_caps(did, 0, 0).await;
10360 let cookie = session_cookie(&state, did, None);
10361 for input in [
10362 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
10363 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10364 ] {
10365 let resp = router(state.clone())
10366 .oneshot(
10367 Request::builder()
10368 .method("POST")
10369 .uri("/subscriptions")
10370 .header(header::COOKIE, cookie.clone())
10371 .header("content-type", "application/x-www-form-urlencoded")
10372 .body(Body::from(format!("url={input}")))
10373 .unwrap(),
10374 )
10375 .await
10376 .unwrap();
10377 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10378 let loc = resp
10379 .headers()
10380 .get(header::LOCATION)
10381 .unwrap()
10382 .to_str()
10383 .unwrap();
10384 assert!(
10385 loc.contains("kind%20of%20feed"),
10386 "expected the unsupported-feed flash for {input}, got {loc}"
10387 );
10388 assert!(
10389 !loc.contains("Private"),
10390 "a storability refusal was reported as a privacy one for {input}: {loc}"
10391 );
10392 }
10393 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10394 }
10395
10396 #[tokio::test]
10403 async fn opml_import_reports_entries_this_instance_cannot_store() {
10404 let did = "did:plc:renamer4";
10405 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10406 let state = test_state_with_sidecar(&[did], &sidecar).await;
10407 assert!(!state.config.standard_site);
10408 let opml = format!(
10409 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10410 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10411 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10412 </body></opml>"
10413 );
10414 let (ct, body) = opml_multipart(opml.as_bytes());
10415 let cookie = session_cookie(&state, did, None);
10416 let resp = router(state.clone())
10417 .oneshot(
10418 Request::builder()
10419 .method("POST")
10420 .uri("/opml")
10421 .header(header::COOKIE, cookie)
10422 .header("content-type", ct)
10423 .body(Body::from(body))
10424 .unwrap(),
10425 )
10426 .await
10427 .unwrap();
10428 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10429 let loc = resp
10430 .headers()
10431 .get(header::LOCATION)
10432 .unwrap()
10433 .to_str()
10434 .unwrap();
10435 assert!(
10436 loc.contains("Imported%201%20feed"),
10437 "unexpected flash: {loc}"
10438 );
10439 assert!(
10440 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
10441 "the dropped entry was not reported: {loc}"
10442 );
10443 assert!(
10445 !loc.contains("site.standard.publication"),
10446 "the URI was echoed: {loc}"
10447 );
10448 }
10449
10450 #[tokio::test]
10453 async fn opml_import_enforces_per_did_cap() {
10454 let did = "did:plc:capped";
10455 let state = test_state_with_caps(did, 2, 0).await;
10457 let existing_a = store::upsert_feed(
10458 &state.db,
10459 &store::NewFeed {
10460 url: "https://have-a.example/feed.xml".to_string(),
10461 ..Default::default()
10462 },
10463 )
10464 .await
10465 .unwrap();
10466 let existing_b = store::upsert_feed(
10467 &state.db,
10468 &store::NewFeed {
10469 url: "https://have-b.example/feed.xml".to_string(),
10470 ..Default::default()
10471 },
10472 )
10473 .await
10474 .unwrap();
10475 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
10476 .await
10477 .unwrap();
10478 let before = store::count_feeds(&state.db).await.unwrap();
10479
10480 let cookie = session_cookie(&state, did, None);
10481 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10482 let app = router(state.clone());
10483 let resp = app
10484 .oneshot(
10485 Request::builder()
10486 .method("POST")
10487 .uri("/opml")
10488 .header(header::COOKIE, cookie)
10489 .header("content-type", ct)
10490 .body(Body::from(body))
10491 .unwrap(),
10492 )
10493 .await
10494 .unwrap();
10495 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10496 let after = store::count_feeds(&state.db).await.unwrap();
10498 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
10499 }
10500
10501 #[tokio::test]
10504 async fn single_add_enforces_per_did_cap() {
10505 let did = "did:plc:subcapped";
10506 let state = test_state_with_caps(did, 1, 0).await;
10507 let f = store::upsert_feed(
10508 &state.db,
10509 &store::NewFeed {
10510 url: "https://have.example/feed.xml".to_string(),
10511 ..Default::default()
10512 },
10513 )
10514 .await
10515 .unwrap();
10516 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
10517 let cookie = session_cookie(&state, did, None);
10518 let app = router(state.clone());
10519 let resp = app
10520 .oneshot(
10521 Request::builder()
10522 .method("POST")
10523 .uri("/subscriptions")
10524 .header(header::COOKIE, cookie)
10525 .header("content-type", "application/x-www-form-urlencoded")
10526 .body(Body::from("url=https://another.example/feed.xml"))
10527 .unwrap(),
10528 )
10529 .await
10530 .unwrap();
10531 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10532 let loc = resp
10533 .headers()
10534 .get(header::LOCATION)
10535 .unwrap()
10536 .to_str()
10537 .unwrap();
10538 assert!(
10539 loc.contains("Subscription%20limit%20reached"),
10540 "expected sub-limit flash, got {loc}"
10541 );
10542 }
10543
10544 #[tokio::test]
10553 async fn the_reader_index_pages_instead_of_rendering_everything() {
10554 let did = "did:plc:pager";
10555 let state = test_state(&[]).await;
10556 store::grant_access(&state.db, did, None, "test", None)
10557 .await
10558 .unwrap();
10559 let feed = store::upsert_feed(
10560 &state.db,
10561 &store::NewFeed {
10562 url: "https://pager.example/feed.xml".to_string(),
10563 title: Some("Pager".to_string()),
10564 ..Default::default()
10565 },
10566 )
10567 .await
10568 .unwrap();
10569 let total = 250_usize;
10570 let entries: Vec<store::NewEntry> = (0..total)
10571 .map(|i| store::NewEntry {
10572 guid: format!("p-{i:04}"),
10573 url: Some(format!("https://pager.example/{i}")),
10574 title: Some(format!("Article {i:04}")),
10575 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
10576 content_html: Some("x".repeat(4_000)),
10577 ..Default::default()
10578 })
10579 .collect();
10580 store::insert_entries(&state.db, feed, &entries, 0)
10581 .await
10582 .unwrap();
10583 store::replace_sub_refs(&state.db, did, &[feed])
10584 .await
10585 .unwrap();
10586
10587 let cookie = session_cookie(&state, did, None);
10588 let app = router(state.clone());
10589 let get = |uri: &str| {
10590 let app = app.clone();
10591 let cookie = cookie.clone();
10592 let uri = uri.to_string();
10593 async move {
10594 let resp = app
10595 .oneshot(
10596 Request::builder()
10597 .uri(uri)
10598 .header(header::COOKIE, cookie)
10599 .body(Body::empty())
10600 .unwrap(),
10601 )
10602 .await
10603 .unwrap();
10604 assert_eq!(resp.status(), StatusCode::OK);
10605 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
10606 .await
10607 .unwrap();
10608 String::from_utf8(bytes.to_vec()).unwrap()
10609 }
10610 };
10611
10612 let page1 = get("/").await;
10613 let rows1 = page1.matches("<li class=\"entry").count();
10617 assert!(
10618 rows1 <= ENTRIES_PER_PAGE as usize,
10619 "page 1 rendered {rows1} entry links; the list is unbounded"
10620 );
10621 assert!(
10622 rows1 > 0,
10623 "page 1 rendered nothing at all: the page bound swallowed the list"
10624 );
10625 assert!(
10628 page1.contains("250 entries"),
10629 "heading must report the full total, not the page"
10630 );
10631 assert!(
10632 page1.contains("page=2"),
10633 "no way to reach the rest of the list: {}",
10634 &page1[..page1.len().min(400)]
10635 );
10636 assert!(
10638 !page1.contains(&"x".repeat(4_000)),
10639 "the list response carried an article body"
10640 );
10641
10642 let page2 = get("/?page=2").await;
10643 assert!(
10644 page2.matches("<li class=\"entry").count() > 0,
10645 "page 2 rendered no rows at all"
10646 );
10647 assert!(
10648 page2.contains("page=1") || page2.contains("Newer"),
10649 "page 2 offers no way back"
10650 );
10651 let first_title = (0..total)
10653 .map(|i| format!("Article {i:04}"))
10654 .find(|t| page1.contains(t))
10655 .expect("page 1 shows at least one titled article");
10656 assert!(
10657 !page2.contains(&first_title),
10658 "{first_title} appears on both pages"
10659 );
10660
10661 let past_end = get("/?page=999").await;
10667 assert!(
10668 past_end.matches("<li class=\"entry").count() > 0,
10669 "an out-of-range page rendered nothing and offered no way back"
10670 );
10671 assert!(
10672 past_end.contains("page=2"),
10673 "the clamped page offers no pager"
10674 );
10675 }
10676
10677 #[tokio::test]
10684 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
10685 let did = "did:plc:reader";
10686 let state = test_state(&[]).await;
10687 store::grant_access(&state.db, did, None, "test", None)
10688 .await
10689 .unwrap();
10690 let feed = store::upsert_feed(
10691 &state.db,
10692 &store::NewFeed {
10693 url: "https://reader.example/feed.xml".to_string(),
10694 title: Some("Reader".to_string()),
10695 ..Default::default()
10696 },
10697 )
10698 .await
10699 .unwrap();
10700 store::insert_entries(
10701 &state.db,
10702 feed,
10703 &[store::NewEntry {
10704 guid: "r-1".to_string(),
10705 url: Some("https://reader.example/1".to_string()),
10706 title: Some("Article".to_string()),
10707 published: Some("2026-07-11T00:00:00Z".to_string()),
10708 content_html: Some("<p>body</p>".to_string()),
10709 ..Default::default()
10710 }],
10711 0,
10712 )
10713 .await
10714 .unwrap();
10715 store::replace_sub_refs(&state.db, did, &[feed])
10716 .await
10717 .unwrap();
10718 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10719
10720 let cookie = session_cookie(&state, did, None);
10721 let app = router(state.clone());
10722
10723 let resp = app
10725 .clone()
10726 .oneshot(
10727 Request::builder()
10728 .method("POST")
10729 .uri(format!("/entries/{entry_id}/read"))
10730 .header(header::COOKIE, cookie.clone())
10731 .header("HX-Request", "true")
10732 .header("X-FR-Reader", "1")
10733 .header("content-type", "application/x-www-form-urlencoded")
10734 .body(Body::from("read=true"))
10735 .unwrap(),
10736 )
10737 .await
10738 .unwrap();
10739 assert_eq!(resp.status(), StatusCode::OK);
10740 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10741 .await
10742 .unwrap();
10743 let html = String::from_utf8(bytes.to_vec()).unwrap();
10744 assert!(
10745 html.contains("hx-swap-oob=\"outerHTML\""),
10746 "reader response must be an OOB swap: {html}"
10747 );
10748 assert!(
10749 html.contains(r#"id="entry-actionbar""#),
10750 "reader response must be the action-bar fragment: {html}"
10751 );
10752 assert!(
10755 html.contains(r#"aria-pressed="true""#),
10756 "read button must show pressed after marking read: {html}"
10757 );
10758 assert!(
10759 html.contains(r#"name="read" value="false""#),
10760 "hidden read value must flip to false so a second tap reverses: {html}"
10761 );
10762
10763 let resp2 = app
10766 .oneshot(
10767 Request::builder()
10768 .method("POST")
10769 .uri(format!("/entries/{entry_id}/read"))
10770 .header(header::COOKIE, cookie)
10771 .header("HX-Request", "true")
10772 .header("X-FR-Reader", "1")
10773 .header("content-type", "application/x-www-form-urlencoded")
10774 .body(Body::from("read=false"))
10775 .unwrap(),
10776 )
10777 .await
10778 .unwrap();
10779 assert_eq!(resp2.status(), StatusCode::OK);
10780 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10781 .await
10782 .unwrap();
10783 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10784 assert!(
10785 html2.contains(r#"aria-pressed="false""#),
10786 "read button must show un-pressed after reversing: {html2}"
10787 );
10788 assert!(
10789 html2.contains(r#"name="read" value="true""#),
10790 "hidden read value must flip back to true: {html2}"
10791 );
10792 }
10793
10794 #[tokio::test]
10797 async fn list_mark_read_returns_row_not_oob_actionbar() {
10798 let did = "did:plc:listv";
10799 let state = test_state(&[]).await;
10800 store::grant_access(&state.db, did, None, "test", None)
10801 .await
10802 .unwrap();
10803 let feed = store::upsert_feed(
10804 &state.db,
10805 &store::NewFeed {
10806 url: "https://list.example/feed.xml".to_string(),
10807 title: Some("List".to_string()),
10808 ..Default::default()
10809 },
10810 )
10811 .await
10812 .unwrap();
10813 store::insert_entries(
10814 &state.db,
10815 feed,
10816 &[store::NewEntry {
10817 guid: "l-1".to_string(),
10818 url: Some("https://list.example/1".to_string()),
10819 title: Some("Article".to_string()),
10820 published: Some("2026-07-11T00:00:00Z".to_string()),
10821 ..Default::default()
10822 }],
10823 0,
10824 )
10825 .await
10826 .unwrap();
10827 store::replace_sub_refs(&state.db, did, &[feed])
10828 .await
10829 .unwrap();
10830 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10831
10832 let cookie = session_cookie(&state, did, None);
10833 let app = router(state.clone());
10834
10835 let resp = app
10836 .oneshot(
10837 Request::builder()
10838 .method("POST")
10839 .uri(format!("/entries/{entry_id}/read"))
10840 .header(header::COOKIE, cookie)
10841 .header("HX-Request", "true")
10842 .header("content-type", "application/x-www-form-urlencoded")
10843 .body(Body::from("read=true"))
10844 .unwrap(),
10845 )
10846 .await
10847 .unwrap();
10848 assert_eq!(resp.status(), StatusCode::OK);
10849 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10850 .await
10851 .unwrap();
10852 let html = String::from_utf8(bytes.to_vec()).unwrap();
10853 assert!(
10854 !html.contains("hx-swap-oob"),
10855 "list-view response must NOT be an OOB swap: {html}"
10856 );
10857 assert!(
10862 html.contains(&format!("/entries/{entry_id}")),
10863 "the response is not the row for this entry: {html}",
10864 );
10865 assert!(
10866 html.contains("Article"),
10867 "the row rendered without its title: {html}",
10868 );
10869 assert!(
10886 html.contains("is-read"),
10887 "the row came back without the read state it was just given: {html}",
10888 );
10889 }
10890
10891 #[tokio::test]
10916 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10917 let did = "did:plc:autodiscovered";
10918 let state = test_state_with_caps(did, 0, 0).await;
10921
10922 let page = r#"<!doctype html><html><head><title>Blog</title>
10923 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10924 </head><body>hi</body></html>"#;
10925 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10926 let port: u16 = base
10927 .trim_end_matches('/')
10928 .rsplit(':')
10929 .next()
10930 .unwrap()
10931 .parse()
10932 .unwrap();
10933 crate::net::test_host_override(
10934 "autodiscover-ftp.test",
10935 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10936 );
10937
10938 let cookie = session_cookie(&state, did, None);
10939 let resp = router(state.clone())
10940 .oneshot(
10941 Request::builder()
10942 .method("POST")
10943 .uri("/subscriptions")
10944 .header(header::COOKIE, cookie)
10945 .header("content-type", "application/x-www-form-urlencoded")
10946 .body(Body::from(format!(
10947 "url=http://autodiscover-ftp.test:{port}/"
10948 )))
10949 .unwrap(),
10950 )
10951 .await
10952 .unwrap();
10953 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10954 let loc = resp
10955 .headers()
10956 .get(header::LOCATION)
10957 .unwrap()
10958 .to_str()
10959 .unwrap();
10960 assert_ne!(loc, "/login", "the test never reached the add path");
10961 assert_ne!(loc, "/", "the subscribe succeeded");
10962
10963 assert_eq!(
10964 store::count_feeds(&state.db).await.unwrap(),
10965 0,
10966 "a non-http(s) URL from autodiscovery was stored"
10967 );
10968 assert_eq!(
10969 store::count_subscriptions_for_did(&state.db, did)
10970 .await
10971 .unwrap(),
10972 0
10973 );
10974 }
10975
10976 #[tokio::test]
10981 async fn rename_to_new_url_refused_at_global_feeds_cap() {
10982 let did = "did:plc:renamer4";
10983 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10984 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10986 store::upsert_feed(
10987 &state.db,
10988 &store::NewFeed {
10989 url: "https://existing.example/feed.xml".to_string(),
10990 ..Default::default()
10991 },
10992 )
10993 .await
10994 .unwrap();
10995 let before = store::count_feeds(&state.db).await.unwrap();
10996 assert_eq!(before, 1);
10997
10998 let cookie = session_cookie(&state, did, None);
10999 let resp = router(state.clone())
11000 .oneshot(
11001 Request::builder()
11002 .method("POST")
11003 .uri("/subscriptions/rk-keep/rename")
11004 .header(header::COOKIE, cookie)
11005 .header("content-type", "application/x-www-form-urlencoded")
11006 .body(Body::from(
11008 "url=https://brand-new.example/feed.xml&title=Renamed",
11009 ))
11010 .unwrap(),
11011 )
11012 .await
11013 .unwrap();
11014 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11015 let loc = resp
11016 .headers()
11017 .get(header::LOCATION)
11018 .unwrap()
11019 .to_str()
11020 .unwrap();
11021 assert!(
11022 loc.contains("feed%20capacity"),
11023 "expected the feed-capacity flash, got {loc}"
11024 );
11025 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11027 assert!(
11028 puts.lock().unwrap().is_empty(),
11029 "a refused repoint reached the PDS"
11030 );
11031 }
11032
11033 #[tokio::test]
11040 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
11041 let did = "did:plc:renamer4";
11042 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11043 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11044 store::upsert_feed(
11045 &state.db,
11046 &store::NewFeed {
11047 url: "https://existing.example/feed.xml".to_string(),
11048 ..Default::default()
11049 },
11050 )
11051 .await
11052 .unwrap();
11053 let before = store::count_feeds(&state.db).await.unwrap();
11054
11055 let cookie = session_cookie(&state, did, None);
11056 let resp = router(state.clone())
11057 .oneshot(
11058 Request::builder()
11059 .method("POST")
11060 .uri("/subscriptions/rk-keep/rename")
11061 .header(header::COOKIE, cookie)
11062 .header("content-type", "application/x-www-form-urlencoded")
11063 .body(Body::from(
11064 "url=https://existing.example/feed.xml&title=Retitled",
11065 ))
11066 .unwrap(),
11067 )
11068 .await
11069 .unwrap();
11070 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11071 let loc = resp
11072 .headers()
11073 .get(header::LOCATION)
11074 .unwrap()
11075 .to_str()
11076 .unwrap();
11077 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
11078 assert_eq!(
11079 puts.lock().unwrap().len(),
11080 1,
11081 "the repoint did not reach the PDS"
11082 );
11083 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11084 }
11085
11086 #[tokio::test]
11088 async fn rename_with_blank_url_writes_nothing() {
11089 let did = "did:plc:renamer3";
11090 let state = test_state_with_caps(did, 0, 0).await;
11091 let before = store::count_feeds(&state.db).await.unwrap();
11092 assert_eq!(before, 0);
11093
11094 let cookie = session_cookie(&state, did, None);
11095 let app = router(state.clone());
11096 let resp = app
11097 .oneshot(
11098 Request::builder()
11099 .method("POST")
11100 .uri("/subscriptions/rkey123/rename")
11101 .header(header::COOKIE, cookie)
11102 .header("content-type", "application/x-www-form-urlencoded")
11103 .body(Body::from("url=%20%20&title=Nope"))
11105 .unwrap(),
11106 )
11107 .await
11108 .unwrap();
11109 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11110 assert_eq!(
11111 resp.headers()
11112 .get(header::LOCATION)
11113 .unwrap()
11114 .to_str()
11115 .unwrap(),
11116 "/",
11117 );
11118 assert_eq!(
11120 store::count_feeds(&state.db).await.unwrap(),
11121 0,
11122 "blank-URL rename wrote a junk feeds row"
11123 );
11124 }
11125
11126 async fn spawn_rename_sidecar(
11135 existing: serde_json::Value,
11136 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
11137 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11138 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11139 let addr = listener.local_addr().unwrap();
11140 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
11141 let sink = puts.clone();
11142 tokio::spawn(async move {
11143 loop {
11144 let Ok((mut sock, _)) = listener.accept().await else {
11145 break;
11146 };
11147 let mut raw: Vec<u8> = Vec::new();
11148 let mut chunk = [0u8; 4096];
11149 let body_text = loop {
11150 let Ok(n) = sock.read(&mut chunk).await else {
11151 break String::new();
11152 };
11153 if n == 0 {
11154 break String::from_utf8_lossy(&raw).to_string();
11155 }
11156 raw.extend_from_slice(&chunk[..n]);
11157 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
11158 continue;
11159 };
11160 let (head, body) = raw.split_at(split + 4);
11161 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
11162 let (k, v) = l.split_once(':')?;
11163 k.eq_ignore_ascii_case("content-length")
11164 .then(|| v.trim().parse::<usize>().ok())?
11165 });
11166 if want.is_none_or(|want| body.len() >= want) {
11167 break String::from_utf8_lossy(body).to_string();
11168 }
11169 };
11170
11171 let is_put = body_text.contains("\"action\":\"put\"");
11173 let data = if is_put {
11174 sink.lock().unwrap().push(body_text.clone());
11175 serde_json::json!({
11176 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
11177 "cid": "bafyreiafter"
11178 })
11179 } else {
11180 serde_json::json!({ "records": [existing.clone()] })
11181 };
11182 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
11183 let resp = format!(
11184 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11185 body.len(),
11186 body
11187 );
11188 let _ = sock.write_all(resp.as_bytes()).await;
11189 let _ = sock.flush().await;
11190 }
11191 });
11192 (format!("http://{addr}"), puts)
11193 }
11194
11195 fn seeded_subscription() -> serde_json::Value {
11197 serde_json::json!({
11198 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
11199 "cid": "bafyreibefore",
11200 "value": {
11201 "$type": "community.lexicon.rss.subscription",
11202 "url": "https://example.com/feed.xml",
11203 "title": "Old title",
11204 "siteUrl": "https://example.com/blog",
11205 "fetchHint": "hourly",
11206 "private": false,
11207 "createdAt": "2024-03-01T00:00:00.000Z"
11208 }
11209 })
11210 }
11211
11212 fn seeded_at_uri_subscription() -> serde_json::Value {
11215 seeded_subscription_with_url(AT_URI_SUB)
11216 }
11217 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
11219 serde_json::json!({
11220 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
11221 "cid": "bafyreibefore",
11222 "value": {
11223 "$type": "community.lexicon.rss.subscription",
11224 "url": url,
11225 "title": "Old title",
11226 "private": false,
11227 "createdAt": "2024-03-01T00:00:00.000Z"
11228 }
11229 })
11230 }
11231 const AT_URI_SUB: &str =
11232 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
11233 const AT_URI_SUB_ENC: &str =
11234 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
11235
11236 #[tokio::test]
11245 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
11246 let did = "did:plc:renamer5";
11247 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11248 let state = test_state_with_sidecar(&[did], &sidecar).await;
11249 assert!(
11250 !state.config.standard_site,
11251 "the flag must be off for this test"
11252 );
11253 let cookie = session_cookie(&state, did, None);
11254 let resp = router(state.clone())
11255 .oneshot(
11256 Request::builder()
11257 .method("POST")
11258 .uri("/subscriptions/rk-keep/rename")
11259 .header(header::COOKIE, cookie)
11260 .header("content-type", "application/x-www-form-urlencoded")
11261 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
11262 .unwrap(),
11263 )
11264 .await
11265 .unwrap();
11266 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11267 let loc = resp
11268 .headers()
11269 .get(header::LOCATION)
11270 .unwrap()
11271 .to_str()
11272 .unwrap();
11273 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11274
11275 let bodies = puts.lock().unwrap().clone();
11276 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11277 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11278 assert_eq!(
11279 sent["record"]["title"], "New title",
11280 "the rename did not apply"
11281 );
11282 assert_eq!(
11283 sent["record"]["url"], AT_URI_SUB,
11284 "the rename changed the URL"
11285 );
11286
11287 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11289 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
11290 }
11291
11292 #[tokio::test]
11296 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
11297 let did = "did:plc:renamer4";
11298 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11299 let state = test_state_with_sidecar(&[did], &sidecar).await;
11300 let cookie = session_cookie(&state, did, None);
11301 let resp = router(state.clone())
11302 .oneshot(
11303 Request::builder()
11304 .method("POST")
11305 .uri("/subscriptions/rk-keep/rename")
11306 .header(header::COOKIE, cookie)
11307 .header("content-type", "application/x-www-form-urlencoded")
11308 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11309 .unwrap(),
11310 )
11311 .await
11312 .unwrap();
11313 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11314 let loc = resp
11315 .headers()
11316 .get(header::LOCATION)
11317 .unwrap()
11318 .to_str()
11319 .unwrap();
11320 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
11321 assert!(
11322 !loc.contains("Private"),
11323 "a storability refusal was reported as a privacy one: {loc}"
11324 );
11325 assert!(
11326 puts.lock().unwrap().is_empty(),
11327 "the repoint reached the PDS"
11328 );
11329 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11330 assert_eq!(cached, 0);
11331 }
11332
11333 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
11336 let cookie = session_cookie(state, did, None);
11337 let resp = router(state.clone())
11338 .oneshot(
11339 Request::builder()
11340 .method("POST")
11341 .uri("/subscriptions/rk-keep/rename")
11342 .header(header::COOKIE, cookie)
11343 .header("content-type", "application/x-www-form-urlencoded")
11344 .body(Body::from(format!("url={url_enc}&title=New+title")))
11345 .unwrap(),
11346 )
11347 .await
11348 .unwrap();
11349 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11350 resp.headers()
11351 .get(header::LOCATION)
11352 .unwrap()
11353 .to_str()
11354 .unwrap()
11355 .to_string()
11356 }
11357
11358 #[tokio::test]
11368 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
11369 let did = "did:plc:renamer5";
11370 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
11371 let other_enc =
11372 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
11373 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
11374 let state = test_state_with_sidecar(&[did], &sidecar).await;
11375 let loc = retitle_unchanged(&state, did, other_enc).await;
11376 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11377 let bodies = puts.lock().unwrap().clone();
11378 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11379 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11380 assert_eq!(sent["record"]["title"], "New title");
11381 assert_eq!(sent["record"]["url"], other);
11382 }
11383
11384 #[tokio::test]
11388 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
11389 let did = "did:plc:renamer4";
11390 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11391 let state = test_state_with_sidecar(&[did], &sidecar).await;
11392 let cookie = session_cookie(&state, did, None);
11393 let resp = router(state.clone())
11394 .oneshot(
11395 Request::builder()
11396 .method("POST")
11397 .uri("/subscriptions/rk-keep/rename")
11398 .header(header::COOKIE, cookie)
11399 .header("content-type", "application/x-www-form-urlencoded")
11400 .body(Body::from(
11401 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
11402 ))
11403 .unwrap(),
11404 )
11405 .await
11406 .unwrap();
11407 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11408 let loc = resp
11409 .headers()
11410 .get(header::LOCATION)
11411 .unwrap()
11412 .to_str()
11413 .unwrap();
11414 assert!(
11415 loc.contains("Private"),
11416 "the private repoint was not refused: {loc}"
11417 );
11418 assert!(
11419 puts.lock().unwrap().is_empty(),
11420 "a secret-bearing URL reached the PDS"
11421 );
11422 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
11425 assert!(store::get_feed_by_url(&state.db, leaked)
11426 .await
11427 .unwrap()
11428 .is_none());
11429 }
11430
11431 #[tokio::test]
11437 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
11438 let did = "did:plc:renamer5";
11439 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11440 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11442 store::upsert_feed(
11443 &state.db,
11444 &store::NewFeed {
11445 url: "https://filler.example/feed.xml".to_string(),
11446 ..Default::default()
11447 },
11448 )
11449 .await
11450 .unwrap();
11451 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11452 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11453 assert_eq!(
11454 puts.lock().unwrap().len(),
11455 1,
11456 "the retitle did not reach the PDS"
11457 );
11458 assert_eq!(
11459 store::count_feeds(&state.db).await.unwrap(),
11460 1,
11461 "a row was inserted"
11462 );
11463 }
11464
11465 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
11467 let cookie = session_cookie(state, did, None);
11468 let resp = router(state.clone())
11469 .oneshot(
11470 Request::builder()
11471 .method("POST")
11472 .uri("/subscriptions")
11473 .header(header::COOKIE, cookie)
11474 .header("content-type", "application/x-www-form-urlencoded")
11475 .body(Body::from(format!("url={url_enc}")))
11476 .unwrap(),
11477 )
11478 .await
11479 .unwrap();
11480 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11481 resp.headers()
11482 .get(header::LOCATION)
11483 .unwrap()
11484 .to_str()
11485 .unwrap()
11486 .to_string()
11487 }
11488
11489 async fn serve_resolver(did: &str) -> String {
11491 let base = crate::net::tests::serve_body(
11492 serde_json::json!({ "did": did }).to_string().into_bytes(),
11493 )
11494 .await;
11495 let port: u16 = base
11496 .trim_end_matches('/')
11497 .rsplit(':')
11498 .next()
11499 .unwrap()
11500 .parse()
11501 .unwrap();
11502 let host = format!("resolver-{port}.test");
11503 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11504 format!("http://{host}:{port}")
11505 }
11506
11507 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
11508 let mut config = (*state.config).clone();
11509 f(&mut config);
11510 state.config = std::sync::Arc::new(config);
11511 state
11512 }
11513
11514 #[tokio::test]
11519 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
11520 let did = "did:plc:renamer5";
11521 let (sidecar, log) = spawn_logging_sidecar().await;
11522 let state = with_config(
11523 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11524 |c| {
11525 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11526 },
11527 );
11528 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
11529 assert_eq!(loc, "/", "the paste was refused: {loc}");
11530 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
11531 .await
11532 .unwrap()
11533 .expect("no feed row");
11534 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
11535 let sent = log.lock().unwrap().join("\n");
11536 assert!(
11537 sent.contains(AT_URI_SUB),
11538 "the subscription was not written to the PDS: {sent}"
11539 );
11540 }
11541
11542 #[tokio::test]
11546 async fn a0_subscribing_from_the_form_delivers_entries() {
11547 let did = "did:plc:renamer5";
11548 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11549 let site = AT_URI_SUB;
11550 let (plc, _) = crate::standard_site::tests::serve_repo(
11551 author,
11552 vec![
11553 (
11554 lexicon::nsid::STANDARD_PUBLICATION,
11555 "3lab2c4d5e6f7g8h",
11556 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
11557 ),
11558 (
11559 lexicon::nsid::STANDARD_DOCUMENT,
11560 "3l2a0frmaaa2a",
11561 serde_json::json!({ "title": "From the form", "path": "/f",
11562 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
11563 ),
11564 ],
11565 )
11566 .await;
11567 let (sidecar, _log) = spawn_logging_sidecar().await;
11568 let state = with_config(
11569 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11570 |c| {
11571 c.oauth.plc_directory = plc;
11572 },
11573 );
11574 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
11575 let row = store::get_feed_by_url(&state.db, site)
11576 .await
11577 .unwrap()
11578 .unwrap();
11579 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
11580 .bind(row.id)
11581 .fetch_all(&state.db)
11582 .await
11583 .unwrap();
11584 assert_eq!(
11585 titles,
11586 vec!["From the form".to_string()],
11587 "the first poll stored nothing"
11588 );
11589 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
11590 }
11591
11592 #[tokio::test]
11595 async fn a_handle_form_paste_is_stored_by_its_did() {
11596 let did = "did:plc:renamer5";
11597 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11598 let (sidecar, _log) = spawn_logging_sidecar().await;
11599 let resolver = serve_resolver(author).await;
11600 let state = with_config(
11601 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11602 |c| {
11603 c.resolver_base = resolver;
11604 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11605 },
11606 );
11607 let loc = subscribe(
11608 &state,
11609 did,
11610 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11611 )
11612 .await;
11613 assert_eq!(loc, "/", "the paste was refused: {loc}");
11614 assert!(
11615 store::get_feed_by_url(&state.db, AT_URI_SUB)
11616 .await
11617 .unwrap()
11618 .is_some(),
11619 "not stored by its DID"
11620 );
11621 assert_eq!(
11622 store::count_feeds(&state.db).await.unwrap(),
11623 1,
11624 "the handle form was stored too"
11625 );
11626 }
11627
11628 async fn serve_counting_resolver(
11630 did: &str,
11631 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
11632 let (base, hits) = crate::net::tests::serve_body_counted(
11633 serde_json::json!({ "did": did }).to_string().into_bytes(),
11634 )
11635 .await;
11636 let port: u16 = base
11637 .trim_end_matches('/')
11638 .rsplit(':')
11639 .next()
11640 .unwrap()
11641 .parse()
11642 .unwrap();
11643 let host = format!("counting-resolver-{port}.test");
11644 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11645 (format!("http://{host}:{port}"), hits)
11646 }
11647
11648 #[tokio::test]
11652 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
11653 let did = "did:plc:renamer5";
11654 let (sidecar, _log) = spawn_logging_sidecar().await;
11655 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11656 let state = with_config(
11657 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11658 |c| {
11659 c.resolver_base = resolver;
11660 c.max_subs_per_did = 1;
11661 },
11662 );
11663 let feed_id = store::upsert_feed(
11664 &state.db,
11665 &store::NewFeed {
11666 url: "https://already.example/feed.xml".into(),
11667 ..Default::default()
11668 },
11669 )
11670 .await
11671 .unwrap();
11672 store::replace_sub_refs(&state.db, did, &[feed_id])
11673 .await
11674 .unwrap();
11675 let loc = subscribe(
11676 &state,
11677 did,
11678 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11679 )
11680 .await;
11681 assert!(
11682 loc.contains("Subscription%20limit"),
11683 "expected the cap flash: {loc}"
11684 );
11685 assert_eq!(
11686 hits.load(std::sync::atomic::Ordering::SeqCst),
11687 0,
11688 "an over-cap paste resolved a handle"
11689 );
11690 }
11691
11692 #[tokio::test]
11696 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
11697 let did = "did:plc:renamer5";
11698 let (sidecar, _log) = spawn_logging_sidecar().await;
11699 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11700 let state = with_config(
11701 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11702 |c| {
11703 c.resolver_base = resolver;
11704 },
11705 );
11706 for authority in [
11707 "did%3Aplc%3ATOOSHORT",
11708 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
11709 "bad%0Ahandle.example",
11710 ] {
11711 let loc = subscribe(
11712 &state,
11713 did,
11714 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
11715 )
11716 .await;
11717 assert!(
11718 loc.contains("kind%20of%20feed"),
11719 "{authority}: expected the unsupported flash: {loc}"
11720 );
11721 }
11722 assert_eq!(
11723 hits.load(std::sync::atomic::Ordering::SeqCst),
11724 0,
11725 "a malformed authority reached the resolver"
11726 );
11727 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11728 }
11729
11730 #[tokio::test]
11732 async fn an_unresolvable_handle_paste_is_refused() {
11733 let did = "did:plc:renamer5";
11734 let (sidecar, _log) = spawn_logging_sidecar().await;
11735 let state = with_config(
11736 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11737 |c| {
11738 c.resolver_base = "http://resolver.nowhere.invalid".into();
11739 },
11740 );
11741 let loc = subscribe(
11742 &state,
11743 did,
11744 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11745 )
11746 .await;
11747 assert!(
11748 loc.contains("resolve%20the%20handle"),
11749 "expected the unresolvable-handle flash: {loc}"
11750 );
11751 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11752 }
11753
11754 #[tokio::test]
11756 async fn a_non_publication_at_uri_paste_is_refused() {
11757 let did = "did:plc:renamer5";
11758 let (sidecar, _log) = spawn_logging_sidecar().await;
11759 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11760 let loc = subscribe(
11761 &state,
11762 did,
11763 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
11764 )
11765 .await;
11766 assert!(
11767 loc.contains("kind%20of%20feed"),
11768 "expected the unsupported flash: {loc}"
11769 );
11770 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11771 }
11772
11773 #[tokio::test]
11776 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11777 let did = "did:plc:renamer5";
11778 let (sidecar, _log) = spawn_logging_sidecar().await;
11779 let state = with_config(
11780 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11781 |c| {
11782 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11783 },
11784 );
11785 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11786 assert_eq!(loc, "/", "the paste was refused: {loc}");
11787 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11788 .await
11789 .unwrap()
11790 .is_some());
11791 }
11792
11793 #[tokio::test]
11796 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11797 let did = "did:plc:renamer5";
11798 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11799 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11800 let opml = format!(
11801 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11802 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11803 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11804 </body></opml>"
11805 );
11806 let (ct, body) = opml_multipart(opml.as_bytes());
11807 let cookie = session_cookie(&state, did, None);
11808 let resp = router(state.clone())
11809 .oneshot(
11810 Request::builder()
11811 .method("POST")
11812 .uri("/opml")
11813 .header(header::COOKIE, cookie)
11814 .header("content-type", ct)
11815 .body(Body::from(body))
11816 .unwrap(),
11817 )
11818 .await
11819 .unwrap();
11820 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11821 let loc = resp
11822 .headers()
11823 .get(header::LOCATION)
11824 .unwrap()
11825 .to_str()
11826 .unwrap();
11827 assert!(
11828 loc.contains("Imported%202%20feeds"),
11829 "unexpected flash: {loc}"
11830 );
11831 assert!(
11832 !loc.contains("skipped"),
11833 "the at:// entry was skipped with the flag on: {loc}"
11834 );
11835 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11836 assert!(
11837 stored.is_some(),
11838 "the at:// entry was not stored with the flag on"
11839 );
11840 }
11841
11842 #[tokio::test]
11852 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11853 let did = "did:plc:renamer5";
11854 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11855 let tokened_enc =
11856 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11857 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11858 let state = test_state_with_sidecar(&[did], &sidecar).await;
11859 let loc = retitle_unchanged(&state, did, tokened_enc).await;
11860 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11861 assert_eq!(
11862 puts.lock().unwrap().len(),
11863 1,
11864 "the retitle did not reach the PDS"
11865 );
11866 assert!(
11867 store::get_feed_by_url(&state.db, tokened)
11868 .await
11869 .unwrap()
11870 .is_none(),
11871 "a secret-bearing URL was written to the shared cache by a retitle"
11872 );
11873 }
11874
11875 #[tokio::test]
11880 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11881 let did = "did:plc:renamer4";
11882 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11883 let state = test_state_with_sidecar(&[did], &sidecar).await;
11884 let cookie = session_cookie(&state, did, None);
11885 let resp = router(state.clone())
11886 .oneshot(
11887 Request::builder()
11888 .method("POST")
11889 .uri("/subscriptions/rk-keep/rename")
11890 .header(header::COOKIE, cookie)
11891 .header("content-type", "application/x-www-form-urlencoded")
11892 .body(Body::from(
11893 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11894 ))
11895 .unwrap(),
11896 )
11897 .await
11898 .unwrap();
11899 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11900 let loc = resp
11901 .headers()
11902 .get(header::LOCATION)
11903 .unwrap()
11904 .to_str()
11905 .unwrap();
11906 assert!(
11907 loc.contains("kind%20of%20feed"),
11908 "expected the unsupported flash: {loc}"
11909 );
11910 assert!(
11911 !loc.contains("Private"),
11912 "a typo was reported as a paid feed: {loc}"
11913 );
11914 assert!(puts.lock().unwrap().is_empty());
11915 }
11916
11917 #[tokio::test]
11918 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11919 let did = "did:plc:renamer4";
11920 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11921 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11922 store::upsert_feed(
11923 &state.db,
11924 &store::NewFeed {
11925 url: "https://filler.example/feed.xml".to_string(),
11926 ..Default::default()
11927 },
11928 )
11929 .await
11930 .unwrap();
11931 let cookie = session_cookie(&state, did, None);
11932 let resp = router(state.clone())
11933 .oneshot(
11934 Request::builder()
11935 .method("POST")
11936 .uri("/subscriptions/rk-keep/rename")
11937 .header(header::COOKIE, cookie)
11938 .header("content-type", "application/x-www-form-urlencoded")
11939 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11940 .unwrap(),
11941 )
11942 .await
11943 .unwrap();
11944 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11945 let loc = resp
11946 .headers()
11947 .get(header::LOCATION)
11948 .unwrap()
11949 .to_str()
11950 .unwrap();
11951 assert!(
11952 loc.contains("kind%20of%20feed"),
11953 "expected the unsupported flash: {loc}"
11954 );
11955 assert!(
11956 !loc.contains("capacity"),
11957 "an unacceptable URL was reported as a capacity problem: {loc}"
11958 );
11959 assert!(puts.lock().unwrap().is_empty());
11960 }
11961
11962 #[tokio::test]
11967 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11968 let did = "did:plc:renamer5";
11969 let padded = format!("{AT_URI_SUB} ");
11970 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11971 let state = test_state_with_sidecar(&[did], &sidecar).await;
11972 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11974 assert_eq!(
11975 loc, "/",
11976 "the retitle was treated as a repoint and refused: {loc}"
11977 );
11978 let bodies = puts.lock().unwrap().clone();
11979 assert_eq!(bodies.len(), 1);
11980 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11981 assert_eq!(
11982 sent["record"]["url"], AT_URI_SUB,
11983 "the padding was not normalised away"
11984 );
11985 }
11986
11987 #[tokio::test]
11993 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11994 let did = "did:plc:renamer5";
11995 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11996 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11997 store::upsert_feed(
11998 &state.db,
11999 &store::NewFeed {
12000 url: "https://filler.example/feed.xml".to_string(),
12001 ..Default::default()
12002 },
12003 )
12004 .await
12005 .unwrap();
12006 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
12007 assert_eq!(loc, "/", "the retitle was refused: {loc}");
12008 assert_eq!(puts.lock().unwrap().len(), 1);
12009 assert_eq!(
12010 store::count_feeds(&state.db).await.unwrap(),
12011 1,
12012 "a retitle inserted a cache row past the ceiling"
12013 );
12014 }
12015
12016 #[tokio::test]
12023 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
12024 let did = "did:plc:typoist";
12025 let state = test_state_with_caps(did, 0, 0).await;
12026 let cookie = session_cookie(&state, did, None);
12027 let resp = router(state.clone())
12028 .oneshot(
12029 Request::builder()
12030 .method("POST")
12031 .uri("/subscriptions")
12032 .header(header::COOKIE, cookie)
12033 .header("content-type", "application/x-www-form-urlencoded")
12034 .body(Body::from(
12035 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12036 ))
12037 .unwrap(),
12038 )
12039 .await
12040 .unwrap();
12041 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12042 let loc = resp
12043 .headers()
12044 .get(header::LOCATION)
12045 .unwrap()
12046 .to_str()
12047 .unwrap();
12048 assert!(
12049 loc.contains("kind%20of%20feed"),
12050 "expected the unsupported flash: {loc}"
12051 );
12052 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
12053 }
12054
12055 #[derive(Default)]
12061 struct SwapRepo {
12062 value: serde_json::Value,
12064 version: u32,
12066 puts: Vec<serde_json::Value>,
12068 concurrent: Option<serde_json::Value>,
12071 refuse_every_swap: bool,
12073 fail_puts: Option<(u16, &'static str)>,
12075 collection: Option<&'static str>,
12077 missing: bool,
12079 fail_list: bool,
12081 }
12082
12083 impl SwapRepo {
12084 fn cid(&self) -> String {
12085 format!("bafyreiversion{}", self.version)
12086 }
12087
12088 fn nsid(&self) -> &'static str {
12089 self.collection
12090 .unwrap_or(crate::lexicon::nsid::SUBSCRIPTION)
12091 }
12092
12093 fn page(&self) -> serde_json::Value {
12094 if self.missing {
12095 return serde_json::json!({ "records": [] });
12096 }
12097 serde_json::json!({ "records": [{
12098 "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12099 "cid": self.cid(),
12100 "value": self.value,
12101 }] })
12102 }
12103
12104 fn put(&mut self, body: &serde_json::Value) -> Result<serde_json::Value, (u16, String)> {
12106 self.puts.push(body.clone());
12107 if let Some(theirs) = self.concurrent.take() {
12108 self.value = theirs;
12109 self.version += 1;
12110 }
12111 if let Some((status, error)) = self.fail_puts {
12112 return Err((status, error.to_string()));
12113 }
12114 if let Some(swap) = body.get("swapRecord").and_then(|v| v.as_str()) {
12115 if self.refuse_every_swap || swap != self.cid() {
12116 return Err((400, "InvalidSwap".to_string()));
12117 }
12118 }
12119 self.value = body["record"].clone();
12120 self.version += 1;
12121 Ok(serde_json::json!({
12122 "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12123 "cid": self.cid(),
12124 }))
12125 }
12126 }
12127
12128 const RACE_DID: &str = "did:plc:racer149";
12129
12130 async fn serve_swap_repo(repo: std::sync::Arc<std::sync::Mutex<SwapRepo>>) -> (String, String) {
12134 use axum::response::IntoResponse as _;
12135 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12136 let addr = listener.local_addr().unwrap();
12137 let host = format!("pds-{}.race.test", addr.port());
12138 crate::net::test_host_override(&host, addr);
12139 let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
12140 let repo = std::sync::Arc::clone(&repo);
12141 async move {
12142 let (parts, body) = req.into_parts();
12143 let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
12144 let body: serde_json::Value =
12145 serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
12146 let reply = |status: u16, body: serde_json::Value| {
12147 (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
12148 };
12149 let mut repo = repo.lock().unwrap();
12150 match (parts.uri.path(), body["action"].as_str()) {
12151 ("/internal/repo", Some("list")) if repo.fail_list => reply(
12152 502,
12153 serde_json::json!({
12154 "ok": false, "error": "UpstreamFailure", "message": "down", "status": 502,
12155 }),
12156 ),
12157 ("/internal/repo", Some("list")) => {
12158 reply(200, serde_json::json!({ "ok": true, "data": repo.page() }))
12159 }
12160 ("/internal/repo", Some("put")) => match repo.put(&body) {
12161 Ok(data) => reply(200, serde_json::json!({ "ok": true, "data": data })),
12162 Err((status, error)) => reply(
12163 status,
12164 serde_json::json!({
12165 "ok": false, "error": error, "message": "refused", "status": status,
12166 }),
12167 ),
12168 },
12169 ("/xrpc/com.atproto.repo.listRecords", _) if repo.fail_list => reply(
12170 502,
12171 serde_json::json!({ "error": "UpstreamFailure", "message": "down" }),
12172 ),
12173 ("/xrpc/com.atproto.repo.listRecords", _) => reply(200, repo.page()),
12174 ("/xrpc/com.atproto.repo.putRecord", _) => match repo.put(&body) {
12175 Ok(data) => reply(200, data),
12176 Err((status, error)) => reply(
12177 status,
12178 serde_json::json!({ "error": error, "message": "refused" }),
12179 ),
12180 },
12181 other => panic!("unexpected request {other:?}"),
12182 }
12183 }
12184 });
12185 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
12186 (
12187 format!("http://{addr}"),
12188 format!("http://{host}:{}", addr.port()),
12189 )
12190 }
12191
12192 async fn race_state(
12196 backend: crate::metrics::Backend,
12197 repo: &std::sync::Arc<std::sync::Mutex<SwapRepo>>,
12198 ) -> AppState {
12199 let (sidecar, aud) = serve_swap_repo(std::sync::Arc::clone(repo)).await;
12200 let db = store::init_url("sqlite::memory:").await.unwrap();
12201 store::ensure_seed(&db, &[RACE_DID.to_string()])
12202 .await
12203 .unwrap();
12204 let mut config = Config {
12205 allowed_dids: vec![RACE_DID.to_string()],
12206 cookie_secret: "test-cookie-secret-000".to_string(),
12207 beta_cap: 3,
12208 repo_backend: backend,
12209 oauth: crate::config::OauthConfig {
12210 key_path: std::env::temp_dir().join(format!(
12212 "fr-race-oauth-key-{}-{:p}.json",
12213 std::process::id(),
12214 &db as *const _
12215 )),
12216 encryption_key: Some("a".repeat(43)),
12217 ..crate::config::OauthConfig::default()
12218 },
12219 ..Config::default()
12220 };
12221 config.sidecar.public_url = sidecar.clone();
12222 config.sidecar.internal_url = sidecar;
12223 let state = AppState::new(config, db).unwrap();
12224 if backend == crate::metrics::Backend::Rust {
12225 let runtime = state.oauth.as_deref().expect("oauth runtime");
12226 crate::oauth::store::put_session(
12227 &state.db,
12228 &runtime.codec,
12229 &crate::oauth::store::OAuthSession {
12230 sub: RACE_DID.into(),
12231 issuer: "https://auth.invalid".into(),
12232 aud,
12233 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
12234 .to_jwk_json()
12235 .unwrap(),
12236 access_token: "at".into(),
12237 refresh_token: "rt".into(),
12238 token_type: "DPoP".into(),
12239 granted_scope: "atproto".into(),
12240 expires_at: Some(store::now_unix() + 3600),
12241 },
12242 )
12243 .await
12244 .unwrap();
12245 }
12246 state
12247 }
12248
12249 fn race_seed() -> serde_json::Value {
12251 seeded_subscription()["value"].clone()
12252 }
12253
12254 async fn post_race_rename(state: &AppState) -> String {
12257 post_race_rename_body(
12258 state,
12259 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
12260 )
12261 .await
12262 }
12263
12264 async fn post_race_rename_body(state: &AppState, body: &str) -> String {
12266 let cookie = session_cookie(state, RACE_DID, None);
12267 let resp = router(state.clone())
12268 .oneshot(
12269 Request::builder()
12270 .method("POST")
12271 .uri("/subscriptions/rk-keep/rename")
12272 .header(header::COOKIE, cookie)
12273 .header("content-type", "application/x-www-form-urlencoded")
12274 .body(Body::from(body.to_string()))
12275 .unwrap(),
12276 )
12277 .await
12278 .unwrap();
12279 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12280 resp.headers()
12281 .get(header::LOCATION)
12282 .unwrap()
12283 .to_str()
12284 .unwrap()
12285 .to_string()
12286 }
12287
12288 const RACE_BACKENDS: [crate::metrics::Backend; 2] = [
12289 crate::metrics::Backend::Sidecar,
12290 crate::metrics::Backend::Rust,
12291 ];
12292
12293 #[tokio::test]
12306 async fn a_rename_that_loses_a_race_keeps_the_concurrent_edit_and_lands() {
12307 for backend in RACE_BACKENDS {
12308 let mut theirs = race_seed();
12309 theirs["siteUrl"] = serde_json::json!("https://elsewhere.example/blog");
12310 theirs["fetchHint"] = serde_json::json!("daily");
12311 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12312 value: race_seed(),
12313 concurrent: Some(theirs),
12314 ..SwapRepo::default()
12315 }));
12316 let state = race_state(backend, &repo).await;
12317
12318 let loc = post_race_rename(&state).await;
12319
12320 let repo = repo.lock().unwrap();
12321 assert_eq!(
12322 loc, "/",
12323 "{backend:?}: a rename that converged was not reported as done"
12324 );
12325 assert_eq!(
12326 repo.puts.len(),
12327 2,
12328 "{backend:?}: expected the refused put and one retry: {:?}",
12329 repo.puts
12330 );
12331 assert_eq!(
12332 repo.puts[0]["swapRecord"], "bafyreiversion0",
12333 "{backend:?}: the first put did not name the CID it read: {}",
12334 repo.puts[0]
12335 );
12336 assert_eq!(
12337 repo.puts[1]["swapRecord"], "bafyreiversion1",
12338 "{backend:?}: the retry did not name the RE-READ CID: {}",
12339 repo.puts[1]
12340 );
12341 let landed = &repo.value;
12342 assert_eq!(landed["title"], "New title", "{backend:?}: {landed}");
12344 assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12345 assert_eq!(
12347 landed["siteUrl"], "https://elsewhere.example/blog",
12348 "{backend:?}: the concurrent edit was lost: {landed}"
12349 );
12350 assert_eq!(
12351 landed["fetchHint"], "daily",
12352 "{backend:?}: the concurrent edit was lost: {landed}"
12353 );
12354 assert_eq!(
12356 landed["createdAt"], "2024-03-01T00:00:00.000Z",
12357 "{backend:?}: {landed}"
12358 );
12359 }
12360 }
12361
12362 #[tokio::test]
12366 async fn a_rename_refused_on_every_swap_reports_the_conflict() {
12367 for backend in RACE_BACKENDS {
12368 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12369 value: race_seed(),
12370 refuse_every_swap: true,
12371 ..SwapRepo::default()
12372 }));
12373 let state = race_state(backend, &repo).await;
12374
12375 let loc = post_race_rename(&state).await;
12376
12377 let repo = repo.lock().unwrap();
12378 assert_ne!(loc, "/", "{backend:?}: a refused rename reported success");
12379 assert!(
12380 loc.contains("changed%20elsewhere"),
12381 "{backend:?}: expected the conflict flash, got {loc}"
12382 );
12383 assert!(
12384 (1..=2).contains(&repo.puts.len()),
12385 "{backend:?}: expected at most two put attempts, got {}",
12386 repo.puts.len()
12387 );
12388 assert_eq!(
12389 repo.value,
12390 race_seed(),
12391 "{backend:?}: the record changed though every put was refused"
12392 );
12393 }
12394 }
12395
12396 #[tokio::test]
12401 async fn a_rename_refused_for_another_reason_is_not_retried() {
12402 for backend in RACE_BACKENDS {
12403 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12404 value: race_seed(),
12405 fail_puts: Some((400, "InvalidRequest")),
12406 ..SwapRepo::default()
12407 }));
12408 let state = race_state(backend, &repo).await;
12409
12410 let loc = post_race_rename(&state).await;
12411
12412 let repo = repo.lock().unwrap();
12413 assert_eq!(
12414 repo.puts.len(),
12415 1,
12416 "{backend:?}: a non-swap refusal was retried"
12417 );
12418 assert!(
12419 loc.contains("Could%20not%20save"),
12420 "{backend:?}: expected the save-failed flash, got {loc}"
12421 );
12422 assert!(
12423 !loc.contains("changed%20elsewhere"),
12424 "{backend:?}: a non-swap refusal was reported as a conflict: {loc}"
12425 );
12426 }
12427 }
12428
12429 const SEEN_SEED: &str = "seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12433 &seen_title=Old+title&seen_folder=";
12434
12435 #[tokio::test]
12444 async fn a_retry_keeps_a_concurrent_repoint_the_reader_did_not_make() {
12445 for backend in RACE_BACKENDS {
12446 for with_seen in [true, false] {
12447 let mut theirs = race_seed();
12448 theirs["url"] = serde_json::json!("https://moved.example/feed.xml");
12449 theirs["siteUrl"] = serde_json::json!("https://moved.example/");
12450 theirs["fetchHint"] = serde_json::json!("daily");
12451 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12452 value: race_seed(),
12453 concurrent: Some(theirs),
12454 ..SwapRepo::default()
12455 }));
12456 let state = race_state(backend, &repo).await;
12457 let mut body =
12458 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title".to_string();
12459 if with_seen {
12460 body.push_str(
12461 "&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml&seen_title=Old+title",
12462 );
12463 }
12464
12465 let loc = post_race_rename_body(&state, &body).await;
12466
12467 let repo = repo.lock().unwrap();
12468 let ctx = format!("{backend:?} seen={with_seen}");
12469 assert_eq!(loc, "/", "{ctx}: the rename did not land: {loc}");
12470 assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12471 let landed = &repo.value;
12472 assert_eq!(landed["title"], "New title", "{ctx}: {landed}");
12473 assert_eq!(
12474 landed["url"], "https://moved.example/feed.xml",
12475 "{ctx}: the retry repointed the record back to the stale URL: {landed}"
12476 );
12477 assert_eq!(
12478 landed["siteUrl"], "https://moved.example/",
12479 "{ctx}: {landed}"
12480 );
12481 assert_eq!(landed["fetchHint"], "daily", "{ctx}: {landed}");
12482 }
12483 }
12484 }
12485
12486 #[tokio::test]
12490 async fn a_retry_keeps_a_concurrent_retitle_when_the_reader_only_moved_it() {
12491 for backend in RACE_BACKENDS {
12492 let mut theirs = race_seed();
12493 theirs["title"] = serde_json::json!("Their title");
12494 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12495 value: race_seed(),
12496 concurrent: Some(theirs),
12497 ..SwapRepo::default()
12498 }));
12499 let state = race_state(backend, &repo).await;
12500
12501 let loc = post_race_rename_body(
12502 &state,
12503 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Old+title&folder=Tech&{SEEN_SEED}"),
12504 )
12505 .await;
12506
12507 let repo = repo.lock().unwrap();
12508 assert_eq!(loc, "/", "{backend:?}: {loc}");
12509 let landed = &repo.value;
12510 assert_eq!(
12511 landed["title"], "Their title",
12512 "{backend:?}: the reader's untouched title overwrote the other client's: {landed}"
12513 );
12514 assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12515 }
12516 }
12517
12518 #[tokio::test]
12522 async fn both_retitling_is_a_conflict_that_writes_nothing() {
12523 for backend in RACE_BACKENDS {
12524 let mut theirs = race_seed();
12525 theirs["title"] = serde_json::json!("Their title");
12526 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12527 value: race_seed(),
12528 concurrent: Some(theirs.clone()),
12529 ..SwapRepo::default()
12530 }));
12531 let state = race_state(backend, &repo).await;
12532
12533 let loc = post_race_rename_body(
12534 &state,
12535 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12536 )
12537 .await;
12538
12539 let repo = repo.lock().unwrap();
12540 assert!(
12541 loc.contains("changed%20elsewhere"),
12542 "{backend:?}: expected the conflict flash, got {loc}"
12543 );
12544 assert_eq!(
12545 repo.puts.len(),
12546 1,
12547 "{backend:?}: a conflicting retry was written: {:?}",
12548 repo.puts
12549 );
12550 assert_eq!(
12551 repo.value, theirs,
12552 "{backend:?}: their title was overwritten"
12553 );
12554 }
12555 }
12556
12557 #[tokio::test]
12564 async fn a_repoint_before_the_first_read_is_kept_when_the_reader_only_retitled() {
12565 for backend in RACE_BACKENDS {
12566 let mut moved = race_seed();
12567 moved["url"] = serde_json::json!("https://moved.example/feed.xml");
12568 moved["siteUrl"] = serde_json::json!("https://moved.example/");
12569 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12570 value: moved,
12571 ..SwapRepo::default()
12572 }));
12573 let state = race_state(backend, &repo).await;
12574
12575 let loc = post_race_rename_body(
12576 &state,
12577 &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12578 )
12579 .await;
12580
12581 let repo = repo.lock().unwrap();
12582 assert_eq!(loc, "/", "{backend:?}: {loc}");
12583 assert_eq!(repo.puts.len(), 1, "{backend:?}");
12584 let landed = &repo.value;
12585 assert_eq!(
12586 landed["url"], "https://moved.example/feed.xml",
12587 "{backend:?}: the stale hidden url repointed the record: {landed}"
12588 );
12589 assert_eq!(landed["siteUrl"], "https://moved.example/", "{backend:?}");
12590 assert_eq!(landed["title"], "New title", "{backend:?}");
12591 }
12592 }
12593
12594 #[tokio::test]
12600 async fn a_rename_that_did_not_land_leaves_the_cache_alone() {
12601 const NEW_URL: &str = "https://other.example/feed.xml";
12602 const OLD_URL: &str = "https://example.com/feed.xml";
12603 for (refuse_every_swap, fail_puts, lands) in [
12605 (true, None, false),
12606 (false, Some((400, "InvalidRequest")), false),
12607 (false, Some((502, "UpstreamFailure")), false),
12608 (false, None, true),
12609 ] {
12610 for backend in RACE_BACKENDS {
12611 let ctx = format!("{backend:?} refuse={refuse_every_swap} fail={fail_puts:?}");
12612 for repoint in [false, true] {
12613 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12614 value: race_seed(),
12615 refuse_every_swap,
12616 fail_puts,
12617 ..SwapRepo::default()
12618 }));
12619 let state = race_state(backend, &repo).await;
12620 store::upsert_feed(
12621 &state.db,
12622 &store::NewFeed {
12623 url: OLD_URL.to_string(),
12624 title: Some("Cached title".to_string()),
12625 ..Default::default()
12626 },
12627 )
12628 .await
12629 .unwrap();
12630 let url = if repoint { NEW_URL } else { OLD_URL };
12631 let body = format!("url={}&title=New+title&{SEEN_SEED}", qenc(url));
12632
12633 let loc = post_race_rename_body(&state, &body).await;
12634
12635 let new_row = store::get_feed_by_url(&state.db, NEW_URL).await.unwrap();
12636 let old_row = store::get_feed_by_url(&state.db, OLD_URL)
12637 .await
12638 .unwrap()
12639 .expect("the old row");
12640 let ctx = format!("{ctx} repoint={repoint} -> {loc}");
12641 if lands {
12642 assert_eq!(loc, "/", "{ctx}");
12643 if repoint {
12644 assert!(
12645 new_row.is_some(),
12646 "{ctx}: a landed repoint got no cache row"
12647 );
12648 } else {
12649 assert_eq!(old_row.title.as_deref(), Some("New title"), "{ctx}");
12650 }
12651 } else {
12652 assert_ne!(loc, "/", "{ctx}");
12653 assert!(
12654 new_row.is_none(),
12655 "{ctx}: a repoint that did not land left a feeds row for its URL"
12656 );
12657 assert_eq!(
12658 old_row.title.as_deref(),
12659 Some("Cached title"),
12660 "{ctx}: a rename that did not land changed the cached title"
12661 );
12662 }
12663 }
12664 }
12665 }
12666 }
12667
12668 #[tokio::test]
12675 async fn a_rename_from_a_page_without_a_folder_select_keeps_the_folder() {
12676 for backend in RACE_BACKENDS {
12677 for raced in [false, true] {
12678 let mut seed = race_seed();
12679 seed["folder"] = serde_json::json!("at://did:plc:racer149/folder/kept");
12680 let concurrent = raced.then(|| {
12681 let mut theirs = seed.clone();
12682 theirs["folder"] = serde_json::json!("at://did:plc:racer149/folder/theirs");
12683 theirs
12684 });
12685 let want_folder = concurrent
12686 .as_ref()
12687 .map_or(seed["folder"].clone(), |t| t["folder"].clone());
12688 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12689 value: seed,
12690 concurrent,
12691 ..SwapRepo::default()
12692 }));
12693 let state = race_state(backend, &repo).await;
12694
12695 let loc = post_race_rename_body(
12697 &state,
12698 "url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12699 &seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12700 &seen_title=Old+title&title=New+title",
12701 )
12702 .await;
12703
12704 let repo = repo.lock().unwrap();
12705 let ctx = format!("{backend:?} raced={raced}");
12706 assert_eq!(loc, "/", "{ctx}: {loc}");
12707 assert_eq!(repo.value["title"], "New title", "{ctx}");
12708 assert_eq!(
12709 repo.value["folder"], want_folder,
12710 "{ctx}: a page that never showed a folder changed it: {}",
12711 repo.value
12712 );
12713 }
12714 }
12715 }
12716
12717 #[tokio::test]
12722 async fn a_double_submitted_rename_reports_success_and_writes_once() {
12723 for backend in RACE_BACKENDS {
12724 let mut first = race_seed();
12727 first["title"] = serde_json::json!("New title");
12728 first["folder"] = serde_json::json!("Tech");
12729 let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12730 value: race_seed(),
12731 concurrent: Some(first.clone()),
12732 ..SwapRepo::default()
12733 }));
12734 let state = race_state(backend, &repo).await;
12735
12736 let loc = post_race_rename_body(
12737 &state,
12738 &format!(
12739 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech&{SEEN_SEED}"
12740 ),
12741 )
12742 .await;
12743
12744 let repo = repo.lock().unwrap();
12745 assert_eq!(
12746 loc, "/",
12747 "{backend:?}: a save that landed was reported as a conflict: {loc}"
12748 );
12749 assert_eq!(
12750 repo.puts.len(),
12751 1,
12752 "{backend:?}: only the refused put; the re-read has nothing left to write: {:?}",
12753 repo.puts
12754 );
12755 assert_eq!(repo.value, first, "{backend:?}");
12756 }
12757 }
12758
12759 fn folder_seed() -> serde_json::Value {
12765 serde_json::json!({
12766 "$type": crate::lexicon::nsid::FOLDER,
12767 "name": "Old name",
12768 "position": 3,
12769 "createdAt": "2024-01-01T00:00:00.000Z",
12770 "color": "#abc",
12771 })
12772 }
12773
12774 fn folder_repo(value: serde_json::Value) -> SwapRepo {
12776 SwapRepo {
12777 value,
12778 collection: Some(crate::lexicon::nsid::FOLDER),
12779 ..SwapRepo::default()
12780 }
12781 }
12782
12783 async fn post_folder_rename(state: &AppState, body: &str) -> String {
12785 let cookie = session_cookie(state, RACE_DID, None);
12786 let resp = router(state.clone())
12787 .oneshot(
12788 Request::builder()
12789 .method("POST")
12790 .uri("/folders/rk-keep/rename")
12791 .header(header::COOKIE, cookie)
12792 .header("content-type", "application/x-www-form-urlencoded")
12793 .body(Body::from(body.to_string()))
12794 .unwrap(),
12795 )
12796 .await
12797 .unwrap();
12798 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12799 resp.headers()
12800 .get(header::LOCATION)
12801 .unwrap()
12802 .to_str()
12803 .unwrap()
12804 .to_string()
12805 }
12806
12807 fn renamed(mut value: serde_json::Value, name: &str) -> serde_json::Value {
12809 value["name"] = serde_json::json!(name);
12810 value
12811 }
12812
12813 #[tokio::test]
12819 async fn renaming_a_folder_changes_only_its_name() {
12820 for backend in RACE_BACKENDS {
12821 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(folder_seed())));
12822 let state = race_state(backend, &repo).await;
12823
12824 let loc = post_folder_rename(&state, "name=New+name").await;
12825
12826 let repo = repo.lock().unwrap();
12827 assert_eq!(
12828 loc, "/",
12829 "{backend:?}: a landed rename was not reported as done"
12830 );
12831 assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12832 assert_eq!(
12833 repo.puts[0]["record"],
12834 renamed(folder_seed(), "New name"),
12835 "{backend:?}: the put did not keep the record whole: {}",
12836 repo.puts[0]
12837 );
12838 assert_eq!(
12839 repo.puts[0]["collection"],
12840 crate::lexicon::nsid::FOLDER,
12841 "{backend:?}"
12842 );
12843 assert_eq!(
12844 repo.puts[0]["swapRecord"], "bafyreiversion0",
12845 "{backend:?}: the put did not name the CID it read: {}",
12846 repo.puts[0]
12847 );
12848 }
12849 }
12850
12851 #[tokio::test]
12856 async fn a_folder_rename_that_loses_a_race_keeps_the_concurrent_edit() {
12857 for backend in RACE_BACKENDS {
12858 for with_seen in [true, false] {
12859 let mut theirs = folder_seed();
12860 theirs["position"] = serde_json::json!(7);
12861 theirs["icon"] = serde_json::json!("star");
12862 let mut fake = folder_repo(folder_seed());
12863 fake.concurrent = Some(theirs.clone());
12864 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12865 let state = race_state(backend, &repo).await;
12866
12867 let body = if with_seen {
12868 "name=New+name&seen_name=Old+name"
12869 } else {
12870 "name=New+name"
12871 };
12872 let loc = post_folder_rename(&state, body).await;
12873
12874 let repo = repo.lock().unwrap();
12875 let ctx = format!("{backend:?} with_seen={with_seen}");
12876 assert_eq!(
12877 loc, "/",
12878 "{ctx}: a converged rename was not reported as done"
12879 );
12880 assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12881 assert_eq!(repo.puts[0]["swapRecord"], "bafyreiversion0", "{ctx}");
12882 assert_eq!(
12883 repo.puts[1]["swapRecord"], "bafyreiversion1",
12884 "{ctx}: the retry did not name the RE-READ CID"
12885 );
12886 assert_eq!(
12887 repo.value,
12888 renamed(theirs, "New name"),
12889 "{ctx}: the concurrent edit was lost"
12890 );
12891 }
12892 }
12893 }
12894
12895 #[tokio::test]
12900 async fn both_renaming_a_folder_differently_is_a_conflict() {
12901 for backend in RACE_BACKENDS {
12902 for body in ["name=New+name&seen_name=Old+name", "name=New+name"] {
12903 let theirs = renamed(folder_seed(), "Their name");
12904 let mut fake = folder_repo(folder_seed());
12905 fake.concurrent = Some(theirs.clone());
12906 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12907 let state = race_state(backend, &repo).await;
12908
12909 let loc = post_folder_rename(&state, body).await;
12910
12911 let repo = repo.lock().unwrap();
12912 assert!(
12913 loc.contains("changed%20elsewhere"),
12914 "{backend:?} {body}: expected the conflict flash, got {loc}"
12915 );
12916 assert_eq!(
12917 repo.puts.len(),
12918 1,
12919 "{backend:?} {body}: only the refused put: {:?}",
12920 repo.puts
12921 );
12922 assert_eq!(
12923 repo.value, theirs,
12924 "{backend:?} {body}: the other client's name was overwritten"
12925 );
12926 }
12927 }
12928 }
12929
12930 #[tokio::test]
12933 async fn both_renaming_a_folder_the_same_is_success_without_a_write() {
12934 for backend in RACE_BACKENDS {
12935 let theirs = renamed(folder_seed(), "New name");
12936 let mut fake = folder_repo(folder_seed());
12937 fake.concurrent = Some(theirs.clone());
12938 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12939 let state = race_state(backend, &repo).await;
12940
12941 let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
12942
12943 let repo = repo.lock().unwrap();
12944 assert_eq!(
12945 loc, "/",
12946 "{backend:?}: agreement reported as a failure: {loc}"
12947 );
12948 assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12949 assert_eq!(repo.value, theirs, "{backend:?}");
12950 }
12951 }
12952
12953 #[tokio::test]
12956 async fn a_folder_rename_refused_on_every_swap_reports_the_conflict() {
12957 for backend in RACE_BACKENDS {
12958 let mut fake = folder_repo(folder_seed());
12959 fake.refuse_every_swap = true;
12960 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12961 let state = race_state(backend, &repo).await;
12962
12963 let loc = post_folder_rename(&state, "name=New+name").await;
12964
12965 let repo = repo.lock().unwrap();
12966 assert!(
12967 loc.contains("changed%20elsewhere"),
12968 "{backend:?}: expected the conflict flash, got {loc}"
12969 );
12970 assert_eq!(repo.puts.len(), 2, "{backend:?}: one try and one retry");
12971 assert_eq!(repo.value, folder_seed(), "{backend:?}");
12972 }
12973 }
12974
12975 #[tokio::test]
12978 async fn a_failed_folder_rename_shows_an_error() {
12979 for backend in RACE_BACKENDS {
12980 let mut fake = folder_repo(folder_seed());
12981 fake.fail_puts = Some((502, "UpstreamFailure"));
12982 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12983 let state = race_state(backend, &repo).await;
12984
12985 let loc = post_folder_rename(&state, "name=New+name").await;
12986
12987 let repo = repo.lock().unwrap();
12988 assert_ne!(loc, "/", "{backend:?}: a failed rename reported success");
12989 assert!(
12990 loc.contains("Could%20not%20save"),
12991 "{backend:?}: expected the save-failed flash, got {loc}"
12992 );
12993 assert!(!loc.contains("changed%20elsewhere"), "{backend:?}: {loc}");
12994 assert_eq!(
12995 repo.puts.len(),
12996 1,
12997 "{backend:?}: a non-swap failure was retried"
12998 );
12999 }
13000 }
13001
13002 #[tokio::test]
13005 async fn renaming_a_folder_that_no_longer_exists_writes_nothing() {
13006 for backend in RACE_BACKENDS {
13007 let mut fake = folder_repo(folder_seed());
13008 fake.missing = true;
13009 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13010 let state = race_state(backend, &repo).await;
13011
13012 let loc = post_folder_rename(&state, "name=New+name").await;
13013
13014 let repo = repo.lock().unwrap();
13015 assert!(
13016 loc.contains("no%20longer%20exists"),
13017 "{backend:?}: expected the missing-folder flash, got {loc}"
13018 );
13019 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13020 }
13021 }
13022
13023 #[tokio::test]
13026 async fn a_folder_rename_whose_read_fails_writes_nothing() {
13027 for backend in RACE_BACKENDS {
13028 let mut fake = folder_repo(folder_seed());
13029 fake.fail_list = true;
13030 let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13031 let state = race_state(backend, &repo).await;
13032
13033 let loc = post_folder_rename(&state, "name=New+name").await;
13034
13035 let repo = repo.lock().unwrap();
13036 assert!(
13037 loc.contains("Could%20not%20reach"),
13038 "{backend:?}: expected the read-failed flash, got {loc}"
13039 );
13040 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13041 }
13042 }
13043
13044 #[tokio::test]
13049 async fn a_rename_elsewhere_after_page_load_is_a_conflict_with_seen_name() {
13050 for backend in RACE_BACKENDS {
13051 let theirs = renamed(folder_seed(), "Their name");
13052 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13053 let state = race_state(backend, &repo).await;
13054
13055 let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
13056
13057 let repo = repo.lock().unwrap();
13058 assert!(
13059 loc.contains("changed%20elsewhere"),
13060 "{backend:?}: expected the conflict flash, got {loc}"
13061 );
13062 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13063 assert_eq!(repo.value, theirs, "{backend:?}");
13064 }
13065 }
13066
13067 #[tokio::test]
13070 async fn an_unchanged_folder_name_writes_nothing() {
13071 for backend in RACE_BACKENDS {
13072 let theirs = renamed(folder_seed(), "Their name");
13073 let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13074 let state = race_state(backend, &repo).await;
13075
13076 let loc = post_folder_rename(&state, "name=Old+name&seen_name=Old+name").await;
13077
13078 let repo = repo.lock().unwrap();
13079 assert_eq!(loc, "/", "{backend:?}");
13080 assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13081 assert_eq!(repo.value, theirs, "{backend:?}");
13082 }
13083 }
13084
13085 #[test]
13087 fn merge_folder_rename_is_a_three_way_merge_on_the_name() {
13088 let base = Folder::new("Old", "2024-01-01T00:00:00.000Z");
13089 let with = |name: &str| {
13090 let mut f = base.clone();
13091 f.name = name.to_string();
13092 f.position = Some(3);
13093 f.extra
13094 .insert("color".to_string(), serde_json::json!("#abc"));
13095 f
13096 };
13097 assert_eq!(
13099 merge_folder_rename("Old", Some("Old"), &base, with("Other")),
13100 Ok(FolderMerge::Unchanged)
13101 );
13102 assert_eq!(
13104 merge_folder_rename("New", Some("Old"), &base, with("Old")),
13105 Ok(FolderMerge::Write(with("New")))
13106 );
13107 assert_eq!(
13109 merge_folder_rename("New", Some("Old"), &base, with("New")),
13110 Ok(FolderMerge::AlreadySaved)
13111 );
13112 assert_eq!(
13114 merge_folder_rename("New", Some("Old"), &base, with("Other")),
13115 Err(RenameConflict("name"))
13116 );
13117 assert_eq!(
13119 merge_folder_rename("New", None, &with("Other"), with("Other")),
13120 Ok(FolderMerge::Write(with("New")))
13121 );
13122 assert_eq!(
13123 merge_folder_rename("New", None, &base, with("Other")),
13124 Err(RenameConflict("name"))
13125 );
13126 assert_eq!(
13128 merge_folder_rename(" Old ", Some("Old "), &base, with("Other")),
13129 Ok(FolderMerge::Unchanged)
13130 );
13131 assert_eq!(
13132 merge_folder_rename("New ", Some("Old"), &base, with(" Old ")),
13133 Ok(FolderMerge::Write(with("New")))
13134 );
13135 }
13136
13137 #[test]
13142 fn the_same_change_on_both_sides_is_not_a_conflict() {
13143 let base = merge_base();
13144 let url = "https://a.example/feed.xml";
13145 let new_url = "https://c.example/feed.xml";
13146
13147 let mut fresh = base.clone();
13149 fresh.title = Some("New".to_string());
13150 let merged = merge_rename(&merge_form(url, "New", Some("at://f/old")), &base, fresh)
13151 .expect("same title is no conflict");
13152 assert!(merged.already_saved, "nothing left to write");
13153
13154 let mut fresh = base.clone();
13156 fresh.folder = Some("at://f/new".to_string());
13157 let merged = merge_rename(&merge_form(url, "Mine", Some("at://f/new")), &base, fresh)
13158 .expect("same folder is no conflict");
13159 assert!(!merged.already_saved, "the title is still to write");
13160 assert_eq!(merged.sub.title.as_deref(), Some("Mine"));
13161 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/new"));
13162
13163 let mut fresh = base.clone();
13166 fresh.url = new_url.to_string();
13167 fresh.site_url = Some("https://c.example/".to_string());
13168 let merged = merge_rename(
13169 &merge_form(new_url, "Old", Some("at://f/old")),
13170 &base,
13171 fresh,
13172 )
13173 .expect("same url is no conflict");
13174 assert!(merged.already_saved);
13175 assert!(!merged.repoint);
13176 assert_eq!(merged.sub.site_url.as_deref(), Some("https://c.example/"));
13177
13178 let mut fresh = base.clone();
13180 fresh.site_url = Some("https://same.example/".to_string());
13181 let mut form = merge_form(url, "Old", Some("at://f/old"));
13182 form.site_url = Some("https://same.example/".to_string());
13183 let merged = merge_rename(&form, &base, fresh).expect("same siteUrl is no conflict");
13184 assert!(merged.already_saved);
13185
13186 let merged = merge_rename(
13189 &merge_form(url, "Old", Some("at://f/old")),
13190 &base,
13191 base.clone(),
13192 )
13193 .unwrap();
13194 assert!(!merged.already_saved);
13195 }
13196
13197 fn merge_form(url: &str, title: &str, folder: Option<&str>) -> RenameSubForm {
13198 RenameSubForm {
13199 url: url.to_string(),
13200 title: Some(title.to_string()),
13201 site_url: None,
13202 folder: folder.map(str::to_string),
13203 seen_url: None,
13204 seen_title: None,
13205 seen_folder: None,
13206 }
13207 }
13208
13209 fn merge_base() -> Subscription {
13210 let mut s = Subscription::new("https://a.example/feed.xml", "2024-03-01T00:00:00.000Z");
13211 s.title = Some("Old".to_string());
13212 s.folder = Some("at://f/old".to_string());
13213 s.site_url = Some("https://a.example/".to_string());
13214 s
13215 }
13216
13217 #[test]
13221 fn merge_rename_is_a_three_way_merge_per_field() {
13222 let base = merge_base();
13223 let url = "https://a.example/feed.xml";
13224
13225 let mut theirs = base.clone();
13227 theirs.folder = Some("at://f/theirs".to_string());
13228 assert_eq!(
13229 merge_rename(
13230 &merge_form(url, "Old", Some("at://f/mine")),
13231 &base,
13232 theirs.clone()
13233 ),
13234 Err(RenameConflict("folder"))
13235 );
13236 let merged = merge_rename(
13237 &merge_form(url, "Old", Some("at://f/mine")),
13238 &base,
13239 base.clone(),
13240 )
13241 .unwrap();
13242 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/mine"));
13243 assert!(!merged.repoint);
13244 let merged =
13246 merge_rename(&merge_form(url, "Old", Some("at://f/old")), &base, theirs).unwrap();
13247 assert_eq!(merged.sub.folder.as_deref(), Some("at://f/theirs"));
13248
13249 let mut moved = base.clone();
13251 moved.url = "https://b.example/feed.xml".to_string();
13252 assert_eq!(
13253 merge_rename(
13254 &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13255 &base,
13256 moved
13257 ),
13258 Err(RenameConflict("url"))
13259 );
13260
13261 let mut resited = base.clone();
13263 resited.site_url = Some("https://theirs.example/".to_string());
13264 let mut form = merge_form(url, "Old", Some("at://f/old"));
13265 form.site_url = Some("https://mine.example/".to_string());
13266 assert_eq!(
13267 merge_rename(&form, &base, resited),
13268 Err(RenameConflict("siteUrl"))
13269 );
13270
13271 let merged = merge_rename(
13273 &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13274 &base,
13275 base.clone(),
13276 )
13277 .unwrap();
13278 assert!(merged.repoint);
13279 assert_eq!(merged.sub.url, "https://c.example/feed.xml");
13280 assert_eq!(merged.sub.site_url, None);
13281
13282 let mut untitled = base.clone();
13285 untitled.title = None;
13286 let mut form = merge_form(url, "A display fallback", Some("at://f/old"));
13287 form.seen_title = Some("A display fallback".to_string());
13288 let merged = merge_rename(&form, &untitled, untitled.clone()).unwrap();
13289 assert_eq!(
13290 merged.sub.title, None,
13291 "an untouched display title was written"
13292 );
13293 }
13294
13295 #[tokio::test]
13317 async fn renaming_preserves_the_fields_the_form_never_carries() {
13318 let did = "did:plc:renamer4";
13319 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13320 let state = test_state_with_sidecar(&[did], &sidecar).await;
13321 let cookie = session_cookie(&state, did, None);
13322
13323 let resp = router(state.clone())
13324 .oneshot(
13325 Request::builder()
13326 .method("POST")
13327 .uri("/subscriptions/rk-keep/rename")
13328 .header(header::COOKIE, cookie)
13329 .header("content-type", "application/x-www-form-urlencoded")
13330 .body(Body::from(
13332 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
13333 ))
13334 .unwrap(),
13335 )
13336 .await
13337 .unwrap();
13338 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13339
13340 let bodies = puts.lock().unwrap().clone();
13341 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13342 let body = &bodies[0];
13343 assert!(
13345 body.contains("community.lexicon.rss.subscription"),
13346 "captured no usable put body: {body:?}"
13347 );
13348
13349 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
13350 let record = &sent["record"];
13351
13352 assert_eq!(record["title"], "New title", "the rename did not apply");
13354 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
13355
13356 assert_eq!(
13358 record["createdAt"], "2024-03-01T00:00:00.000Z",
13359 "the rename reset createdAt — the reader's subscribe time is gone \
13360 from their own repo, and nothing told them"
13361 );
13362 assert_eq!(
13363 record["siteUrl"], "https://example.com/blog",
13364 "the rename erased siteUrl"
13365 );
13366 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
13367 assert_eq!(record["private"], false, "the rename erased private");
13368 }
13369
13370 #[tokio::test]
13379 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
13380 let did = "did:plc:renamer4";
13381 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13382 let state = test_state_with_sidecar(&[did], &sidecar).await;
13383 let cookie = session_cookie(&state, did, None);
13384
13385 let resp = router(state.clone())
13386 .oneshot(
13387 Request::builder()
13388 .method("POST")
13389 .uri("/subscriptions/rk-keep/rename")
13390 .header(header::COOKIE, cookie)
13391 .header("content-type", "application/x-www-form-urlencoded")
13392 .body(Body::from(
13394 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
13395 ))
13396 .unwrap(),
13397 )
13398 .await
13399 .unwrap();
13400 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13401
13402 let bodies = puts.lock().unwrap().clone();
13403 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13404 assert!(
13405 bodies[0].contains("community.lexicon.rss.subscription"),
13406 "captured no usable put body: {:?}",
13407 bodies[0]
13408 );
13409 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13410 let record = &sent["record"];
13411
13412 assert_eq!(record["url"], "https://other.example/feed.xml");
13413 assert!(
13415 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
13416 "the old feed's site link followed the subscription to a new feed: {record}"
13417 );
13418 assert!(
13419 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
13420 "the old feed's fetch hint followed the subscription to a new feed: {record}"
13421 );
13422 assert_eq!(
13424 record["createdAt"], "2024-03-01T00:00:00.000Z",
13425 "a repoint is still not a new subscription; createdAt must not move"
13426 );
13427 assert_eq!(record["private"], false, "the repoint erased private");
13428 }
13429
13430 #[tokio::test]
13442 async fn renaming_an_unknown_rkey_writes_nothing() {
13443 let did = "did:plc:renamer4";
13444 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13446 let state = test_state_with_sidecar(&[did], &sidecar).await;
13447 let cookie = session_cookie(&state, did, None);
13448
13449 let resp = router(state.clone())
13450 .oneshot(
13451 Request::builder()
13452 .method("POST")
13453 .uri("/subscriptions/rk-does-not-exist/rename")
13455 .header(header::COOKIE, cookie)
13456 .header("content-type", "application/x-www-form-urlencoded")
13457 .body(Body::from(
13458 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
13459 ))
13460 .unwrap(),
13461 )
13462 .await
13463 .unwrap();
13464
13465 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13466 let loc = resp
13467 .headers()
13468 .get(header::LOCATION)
13469 .unwrap()
13470 .to_str()
13471 .unwrap();
13472 assert!(
13473 loc.contains("flash="),
13474 "an unknown rkey redirected as though the rename had worked: {loc}"
13475 );
13476 assert!(
13477 puts.lock().unwrap().is_empty(),
13478 "a rename against an unknown rkey wrote a record — putRecord would \
13479 CREATE it, dated today: {:?}",
13480 puts.lock().unwrap()
13481 );
13482 }
13483
13484 #[tokio::test]
13496 async fn a_client_supplied_site_url_reaches_the_record() {
13497 let did = "did:plc:renamer4";
13498 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13499 let state = test_state_with_sidecar(&[did], &sidecar).await;
13500 let cookie = session_cookie(&state, did, None);
13501
13502 let resp = router(state.clone())
13503 .oneshot(
13504 Request::builder()
13505 .method("POST")
13506 .uri("/subscriptions/rk-keep/rename")
13507 .header(header::COOKIE, cookie)
13508 .header("content-type", "application/x-www-form-urlencoded")
13509 .body(Body::from(
13512 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
13513 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
13514 ))
13515 .unwrap(),
13516 )
13517 .await
13518 .unwrap();
13519 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13520
13521 let bodies = puts.lock().unwrap().clone();
13522 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13523 assert!(
13524 bodies[0].contains("community.lexicon.rss.subscription"),
13525 "captured no usable put body: {:?}",
13526 bodies[0]
13527 );
13528 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13529 assert_eq!(
13530 sent["record"]["siteUrl"], "https://typed.example/site",
13531 "the client's siteUrl was dropped; the seeded record's survived instead"
13532 );
13533 }
13534
13535 #[tokio::test]
13543 async fn a_rename_whose_read_fails_writes_nothing() {
13544 let did = "did:plc:renamer5";
13545 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13547 let dead = format!("http://{}", listener.local_addr().unwrap());
13548 drop(listener);
13549
13550 let state = test_state_with_sidecar(&[did], &dead).await;
13551 let cookie = session_cookie(&state, did, None);
13552 let before = store::count_feeds(&state.db).await.unwrap();
13553
13554 let resp = router(state.clone())
13555 .oneshot(
13556 Request::builder()
13557 .method("POST")
13558 .uri("/subscriptions/rk-keep/rename")
13559 .header(header::COOKIE, cookie)
13560 .header("content-type", "application/x-www-form-urlencoded")
13561 .body(Body::from(
13562 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
13563 ))
13564 .unwrap(),
13565 )
13566 .await
13567 .unwrap();
13568
13569 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13570 let loc = resp
13571 .headers()
13572 .get(header::LOCATION)
13573 .unwrap()
13574 .to_str()
13575 .unwrap();
13576 assert!(
13577 loc.contains("flash="),
13578 "a failed read redirected as though the rename had worked: {loc}"
13579 );
13580 assert_eq!(
13581 store::count_feeds(&state.db).await.unwrap(),
13582 before,
13583 "a rename that could not read the record still wrote to the cache"
13584 );
13585 }
13586
13587 #[test]
13593 fn manage_rename_row_preselects_current_folder() {
13594 let nav = Nav {
13595 handle: "@reader.example".to_string(),
13596 avatar: "RE".to_string(),
13597 view: "unread".to_string(),
13598 scope_qs: String::new(),
13599 folders: Vec::new(),
13600 loose_feeds: Vec::new(),
13601 manage_active: true,
13602 };
13603 let folder_options = vec![
13604 FolderOption {
13605 uri: "at://did:plc:x/app.folder/work".to_string(),
13606 name: "Work".to_string(),
13607 },
13608 FolderOption {
13609 uri: "at://did:plc:x/app.folder/fun".to_string(),
13610 name: "Fun".to_string(),
13611 },
13612 ];
13613 let foldered = FeedView {
13616 rkey: "sub-foldered".to_string(),
13617 url: "https://work.example/feed.xml".to_string(),
13618 title: "Work Feed".to_string(),
13619 unread: 0,
13620 selected: false,
13621 folder: Some("at://did:plc:x/app.folder/work".to_string()),
13622 };
13623 let loose = FeedView {
13624 rkey: "sub-loose".to_string(),
13625 url: "https://loose.example/feed.xml".to_string(),
13626 title: "Loose Feed".to_string(),
13627 unread: 0,
13628 selected: false,
13629 folder: None,
13630 };
13631 let tmpl = ManageTemplate {
13632 card: Card::private(&Config::default()),
13633 version: VERSION,
13634 repo_url: REPO_URL,
13635 kofi_url: KOFI_URL,
13636 flash: String::new(),
13637 alert: String::new(),
13638 nav,
13639 folder_options,
13640 folders: vec![FolderView {
13641 rkey: "folder-work".to_string(),
13642 uri: "at://did:plc:x/app.folder/work".to_string(),
13643 name: "Work".to_string(),
13644 feeds: vec![foldered],
13645 selected: false,
13646 }],
13647 loose_feeds: vec![loose],
13648 standard_site: false,
13649 };
13650 let html = tmpl.render().unwrap();
13651
13652 assert!(
13654 html.contains(
13655 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
13656 ),
13657 "foldered feed must pre-select its current folder: {html}"
13658 );
13659 assert!(
13662 html.contains(r#"<option value="" selected>No folder</option>"#),
13663 "loose feed must pre-select 'No folder': {html}"
13664 );
13665
13666 for want in [
13669 r#"<input type="hidden" name="seen_url" value="https://work.example/feed.xml" />"#,
13670 r#"<input type="hidden" name="seen_title" value="Work Feed" />"#,
13671 r#"<input type="hidden" name="seen_folder" value="at://did:plc:x/app.folder/work" />"#,
13672 r#"<input type="hidden" name="seen_folder" value="" />"#,
13673 r#"<input type="hidden" name="seen_name" value="Work" />"#,
13675 ] {
13676 assert!(html.contains(want), "missing {want}: {html}");
13677 }
13678 }
13679
13680 #[tokio::test]
13686 async fn the_public_stats_page_exposes_no_user_data() {
13687 let state = test_state(&[]).await;
13688 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
13689 .await
13690 .unwrap();
13691
13692 let resp = router(state)
13693 .oneshot(
13694 Request::builder()
13695 .uri("/stats")
13696 .body(Body::empty())
13697 .unwrap(),
13698 )
13699 .await
13700 .unwrap();
13701 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
13702
13703 let body = String::from_utf8(
13704 axum::body::to_bytes(resp.into_body(), usize::MAX)
13705 .await
13706 .unwrap()
13707 .to_vec(),
13708 )
13709 .unwrap();
13710
13711 assert!(
13717 !body.contains("did:"),
13718 "the public stats page leaked an identifier"
13719 );
13720 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
13721 assert!(
13722 !body.contains(admin_only),
13723 "the public page is showing the admin metrics column {admin_only:?}"
13724 );
13725 }
13726 assert!(body.contains("Feeds tracked"));
13728 assert!(body.contains("Waiting to be polled"));
13729 }
13730
13731 #[tokio::test]
13739 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
13740 let state = test_state(&[]).await;
13741 for (url, errors) in [
13743 ("https://ok.example/f.xml", 0),
13744 ("https://flaky.example/f.xml", 2),
13745 ("https://dead.example/f.xml", 9),
13746 ] {
13747 store::upsert_feed(
13748 &state.db,
13749 &store::NewFeed {
13750 url: url.to_string(),
13751 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13754 ..Default::default()
13755 },
13756 )
13757 .await
13758 .unwrap();
13759 for _ in 0..errors {
13760 store::bump_feed_errors(
13761 &state.db,
13762 url,
13763 feed::FailureKind::Fetch,
13764 "connection refused",
13765 )
13766 .await
13767 .unwrap();
13768 }
13769 }
13770
13771 let render_stats = |state: AppState| async move {
13772 let resp = router(state)
13773 .oneshot(
13774 Request::builder()
13775 .uri("/stats")
13776 .body(Body::empty())
13777 .unwrap(),
13778 )
13779 .await
13780 .unwrap();
13781 assert_eq!(resp.status(), StatusCode::OK);
13782 String::from_utf8(
13783 axum::body::to_bytes(resp.into_body(), usize::MAX)
13784 .await
13785 .unwrap()
13786 .to_vec(),
13787 )
13788 .unwrap()
13789 };
13790
13791 state.runtime_health.set_schedulers_enabled(true);
13798 state
13799 .runtime_health
13800 .poll_tick_completed(crate::store::now_unix());
13801
13802 let body = render_stats(state.clone()).await;
13803 assert!(
13804 body.contains("Failing"),
13805 "backoff is still invisible on the public page"
13806 );
13807 assert!(
13811 body.contains("2, 1 badly"),
13812 "expected '2, 1 badly' in the failing row; got:\n{}",
13813 body.split("Failing")
13814 .nth(1)
13815 .unwrap_or("")
13816 .chars()
13817 .take(300)
13818 .collect::<String>()
13819 );
13820 assert!(
13828 !body.contains("the poller is not running")
13829 && !body.contains("the cache is at its size limit")
13830 && !body.contains("has not completed a round"),
13831 "expected the running state; the page reported a stopped one",
13832 );
13833
13834 state.runtime_health.set_watermark(true);
13838 let paused = render_stats(state.clone()).await;
13839 assert!(
13844 paused.contains("the cache is at its size limit"),
13845 "a watermark pause is still invisible on the public page"
13846 );
13847
13848 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
13850 assert!(
13851 !paused.contains(leak),
13852 "the public page leaked {leak:?} while reporting failures"
13853 );
13854 }
13855 }
13856
13857 #[tokio::test]
13869 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
13870 let admin = "did:plc:adminseed";
13871 let state = test_state(&[admin]).await;
13880 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
13881 .await
13882 .unwrap();
13883 let url = "https://broken.example/f.xml";
13884 store::upsert_feed(
13885 &state.db,
13886 &store::NewFeed {
13887 url: url.to_string(),
13888 ..Default::default()
13889 },
13890 )
13891 .await
13892 .unwrap();
13893 store::bump_feed_errors(
13894 &state.db,
13895 url,
13896 feed::FailureKind::Fetch,
13897 "SENTINEL_ADMIN_ONLY",
13898 )
13899 .await
13900 .unwrap();
13901
13902 let get = |state: AppState, cookie: Option<String>| async move {
13903 let mut req = Request::builder().uri("/admin/metrics");
13904 if let Some(c) = cookie {
13905 req = req.header(header::COOKIE, c);
13906 }
13907 let resp = router(state)
13908 .oneshot(req.body(Body::empty()).unwrap())
13909 .await
13910 .unwrap();
13911 let status = resp.status();
13912 let body = String::from_utf8(
13913 axum::body::to_bytes(resp.into_body(), usize::MAX)
13914 .await
13915 .unwrap()
13916 .to_vec(),
13917 )
13918 .unwrap();
13919 (status, body)
13920 };
13921
13922 let (status, body) = get(state.clone(), None).await;
13924 assert_eq!(status, StatusCode::UNAUTHORIZED);
13925 assert!(
13926 !body.contains("SENTINEL_ADMIN_ONLY"),
13927 "leaked to anonymous: {body}"
13928 );
13929
13930 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
13932 let (status, body) = get(state.clone(), Some(ordinary)).await;
13933 assert_eq!(
13934 status,
13935 StatusCode::FORBIDDEN,
13936 "a non-admin session was let in"
13937 );
13938 assert!(
13939 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
13940 "leaked to a non-admin: {body}",
13941 );
13942
13943 let admin_cookie = session_cookie(&state, admin, None);
13946 let (status, body) = get(state, Some(admin_cookie)).await;
13947 assert_eq!(status, StatusCode::OK);
13948 assert!(
13949 body.contains("SENTINEL_ADMIN_ONLY"),
13950 "admin cannot see it: {body}"
13951 );
13952 }
13953
13954 #[tokio::test]
13971 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
13972 let admin = "did:plc:adminseed";
13973 let state = test_state(&[admin]).await;
13974 let url = "https://broken.example/f.xml";
13975 store::upsert_feed(
13976 &state.db,
13977 &store::NewFeed {
13978 url: url.to_string(),
13979 ..Default::default()
13980 },
13981 )
13982 .await
13983 .unwrap();
13984 store::bump_feed_errors(
13985 &state.db,
13986 url,
13987 feed::FailureKind::Fetch,
13988 "SENTINEL_REDIRECT_NO_LOCATION",
13989 )
13990 .await
13991 .unwrap();
13992
13993 let cookie = session_cookie(&state, admin, None);
13994 let resp = router(state.clone())
13995 .oneshot(
13996 Request::builder()
13997 .uri("/admin/metrics")
13998 .header(header::COOKIE, cookie)
13999 .body(Body::empty())
14000 .unwrap(),
14001 )
14002 .await
14003 .unwrap();
14004 assert_eq!(resp.status(), StatusCode::OK);
14005 let admin_body = String::from_utf8(
14006 axum::body::to_bytes(resp.into_body(), usize::MAX)
14007 .await
14008 .unwrap()
14009 .to_vec(),
14010 )
14011 .unwrap();
14012 assert!(
14013 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
14014 "the admin page does not carry the failure detail: {admin_body}",
14015 );
14016 assert!(
14017 admin_body.contains("broken.example"),
14018 "the admin page does not name the failing feed: {admin_body}",
14019 );
14020
14021 let resp = router(state)
14023 .oneshot(
14024 Request::builder()
14025 .uri("/stats")
14026 .body(Body::empty())
14027 .unwrap(),
14028 )
14029 .await
14030 .unwrap();
14031 let public = String::from_utf8(
14032 axum::body::to_bytes(resp.into_body(), usize::MAX)
14033 .await
14034 .unwrap()
14035 .to_vec(),
14036 )
14037 .unwrap();
14038 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
14039 assert!(
14040 !public.contains(secret),
14041 "{secret:?} reached the PUBLIC stats page: {public}",
14042 );
14043 }
14044 }
14045
14046 #[tokio::test]
14059 async fn a_successful_direct_poll_clears_a_stale_failure() {
14060 let state = test_state(&[]).await;
14061 let url = "https://recovered.example/f.xml";
14062 store::upsert_feed(
14063 &state.db,
14064 &store::NewFeed {
14065 url: url.to_string(),
14066 ..Default::default()
14067 },
14068 )
14069 .await
14070 .unwrap();
14071 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
14072 .await
14073 .unwrap();
14074 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
14076 .bind(url)
14077 .execute(&state.db)
14078 .await
14079 .unwrap();
14080
14081 feed::settle_poll(
14083 &state.db,
14084 url,
14085 &feed::PollOutcome::NotModified,
14086 state.config.poll_interval,
14087 )
14088 .await;
14089
14090 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14091 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14092 )
14093 .bind(url)
14094 .fetch_one(&state.db)
14095 .await
14096 .unwrap();
14097 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
14098 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
14099 let next = row.2.expect("next_poll was cleared to NULL");
14103 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14107 let delta = parsed
14108 .signed_duration_since(chrono::Utc::now())
14109 .num_seconds();
14110 let cadence = state.config.poll_interval.as_secs() as i64;
14111 assert!(
14112 (cadence - 60..=cadence + 60).contains(&delta),
14113 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
14114 );
14115 }
14116
14117 #[tokio::test]
14123 async fn a_failing_direct_poll_is_recorded() {
14124 let state = test_state(&[]).await;
14125 let url = "https://born-broken.example/f.xml";
14126 store::upsert_feed(
14127 &state.db,
14128 &store::NewFeed {
14129 url: url.to_string(),
14130 ..Default::default()
14131 },
14132 )
14133 .await
14134 .unwrap();
14135
14136 feed::settle_poll(
14137 &state.db,
14138 url,
14139 &feed::PollOutcome::Failed {
14140 backoff: std::time::Duration::from_secs(300),
14141 kind: feed::FailureKind::Parse,
14142 detail: "SENTINEL_BORN_BROKEN".to_string(),
14143 },
14144 state.config.poll_interval,
14145 )
14146 .await;
14147
14148 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14149 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14150 )
14151 .bind(url)
14152 .fetch_one(&state.db)
14153 .await
14154 .unwrap();
14155 assert_eq!(row.0, 1, "a failed first poll was not counted");
14156 assert_eq!(
14157 row.1.as_deref(),
14158 Some("parse"),
14159 "its cause was not recorded"
14160 );
14161 let next = row.2.expect("a failed direct poll left next_poll NULL");
14165 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14166 let delta = parsed
14167 .signed_duration_since(chrono::Utc::now())
14168 .num_seconds();
14169 assert!(
14170 (240..=360).contains(&delta),
14171 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
14172 );
14173 }
14174
14175 #[tokio::test]
14187 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
14188 let state = test_state(&[]).await;
14189 for url in [
14191 "https://legacy1.example/f.xml",
14192 "https://legacy2.example/f.xml",
14193 ] {
14194 store::upsert_feed(
14195 &state.db,
14196 &store::NewFeed {
14197 url: url.to_string(),
14198 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14199 ..Default::default()
14200 },
14201 )
14202 .await
14203 .unwrap();
14204 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
14205 .bind(url)
14206 .execute(&state.db)
14207 .await
14208 .unwrap();
14209 }
14210 store::upsert_feed(
14212 &state.db,
14213 &store::NewFeed {
14214 url: "https://known.example/f.xml".to_string(),
14215 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14216 ..Default::default()
14217 },
14218 )
14219 .await
14220 .unwrap();
14221 store::bump_feed_errors(
14222 &state.db,
14223 "https://known.example/f.xml",
14224 feed::FailureKind::Status,
14225 "SENTINEL",
14226 )
14227 .await
14228 .unwrap();
14229
14230 let now = chrono::Utc::now();
14231 let health = store::poll_health(
14232 &state.db,
14233 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14234 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14235 )
14236 .await
14237 .unwrap();
14238 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
14239 assert_eq!(
14240 counted, health.in_backoff,
14241 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
14242 health.in_backoff, health.failure_kinds,
14243 );
14244 assert!(
14245 health
14246 .failure_kinds
14247 .iter()
14248 .any(|(k, n)| k == "unknown" && *n == 2),
14249 "no unknown bucket for the legacy rows: {:?}",
14250 health.failure_kinds,
14251 );
14252 }
14253
14254 #[tokio::test]
14259 async fn the_failure_breakdown_is_ordered_by_count() {
14260 let state = test_state(&[]).await;
14261 for (url, kind, n) in [
14262 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
14263 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
14264 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
14265 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
14266 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
14267 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
14268 ] {
14269 store::upsert_feed(
14270 &state.db,
14271 &store::NewFeed {
14272 url: url.to_string(),
14273 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14274 ..Default::default()
14275 },
14276 )
14277 .await
14278 .unwrap();
14279 for _ in 0..n {
14280 store::bump_feed_errors(&state.db, url, kind, "d")
14281 .await
14282 .unwrap();
14283 }
14284 }
14285 let now = chrono::Utc::now();
14286 let health = store::poll_health(
14287 &state.db,
14288 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14289 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14290 )
14291 .await
14292 .unwrap();
14293 let labels: Vec<&str> = health
14294 .failure_kinds
14295 .iter()
14296 .map(|(k, _)| k.as_str())
14297 .collect();
14298 assert_eq!(
14299 labels,
14300 ["fetch", "status", "parse"],
14301 "not ordered by count, descending: {:?}",
14302 health.failure_kinds,
14303 );
14304 }
14305
14306 #[tokio::test]
14318 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
14319 let state = test_state(&[]).await;
14320 for (url, kind, detail, errors) in [
14321 (
14327 "https://a.example/f.xml",
14328 feed::FailureKind::Fetch,
14329 "SENTINEL_CONNREFUSED",
14330 3,
14331 ),
14332 (
14333 "https://b.example/f.xml",
14334 feed::FailureKind::Fetch,
14335 "SENTINEL_DNSFAIL",
14336 2,
14337 ),
14338 (
14339 "https://c.example/f.xml",
14340 feed::FailureKind::Status,
14341 "SENTINEL_404",
14342 1,
14343 ),
14344 (
14345 "https://d.example/f.xml",
14346 feed::FailureKind::Parse,
14347 "SENTINEL_UNPARSEABLE",
14348 1,
14349 ),
14350 ] {
14351 store::upsert_feed(
14352 &state.db,
14353 &store::NewFeed {
14354 url: url.to_string(),
14355 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14356 ..Default::default()
14357 },
14358 )
14359 .await
14360 .unwrap();
14361 for _ in 0..errors {
14362 store::bump_feed_errors(&state.db, url, kind, detail)
14363 .await
14364 .unwrap();
14365 }
14366 }
14367
14368 let resp = router(state.clone())
14369 .oneshot(
14370 Request::builder()
14371 .uri("/stats")
14372 .body(Body::empty())
14373 .unwrap(),
14374 )
14375 .await
14376 .unwrap();
14377 assert_eq!(resp.status(), StatusCode::OK);
14378 let body = String::from_utf8(
14379 axum::body::to_bytes(resp.into_body(), usize::MAX)
14380 .await
14381 .unwrap()
14382 .to_vec(),
14383 )
14384 .unwrap();
14385
14386 assert!(
14388 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
14389 "the cause histogram did not render: {body}",
14390 );
14391
14392 for secret in [
14395 "a.example",
14396 "b.example",
14397 "c.example",
14398 "d.example",
14399 "SENTINEL_CONNREFUSED",
14400 "SENTINEL_DNSFAIL",
14401 "SENTINEL_404",
14402 "SENTINEL_UNPARSEABLE",
14403 ] {
14404 assert!(
14405 !body.contains(secret),
14406 "{secret:?} reached the PUBLIC stats page: {body}",
14407 );
14408 }
14409 }
14410
14411 #[tokio::test]
14414 async fn health_checks_the_database_and_reports_the_loops() {
14415 let state = test_state(&[]).await;
14416 let body_of = |state: AppState| async move {
14417 let resp = router(state)
14418 .oneshot(
14419 Request::builder()
14420 .uri("/health")
14421 .body(Body::empty())
14422 .unwrap(),
14423 )
14424 .await
14425 .unwrap();
14426 let status = resp.status();
14427 let body = String::from_utf8(
14428 axum::body::to_bytes(resp.into_body(), usize::MAX)
14429 .await
14430 .unwrap()
14431 .to_vec(),
14432 )
14433 .unwrap();
14434 (status, body)
14435 };
14436
14437 state
14440 .runtime_health
14441 .set_started_at(chrono::Utc::now().timestamp());
14442
14443 let (status, body) = body_of(state.clone()).await;
14444 assert_eq!(status, StatusCode::OK);
14445 assert!(
14446 body.contains("db: ok"),
14447 "health did not probe the DB: {body}"
14448 );
14449 assert!(
14450 body.contains("uptime:"),
14451 "no uptime — the first thing anyone asks about a container that may \
14452 be restarting: {body}"
14453 );
14454 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
14455 assert!(body.contains("polling-paused: no"), "{body}");
14456 assert!(body.contains("backend:"), "{body}");
14457 assert!(body.contains("oauth-runtime:"), "{body}");
14458
14459 state.runtime_health.set_watermark(true);
14464 state.runtime_health.set_schedulers_enabled(true);
14465 let (status, body) = body_of(state.clone()).await;
14466 assert_eq!(
14467 status,
14468 StatusCode::OK,
14469 "a watermark pause must not fail the liveness check: {body}"
14470 );
14471 assert!(body.contains("polling-paused: yes"), "{body}");
14472 assert!(
14475 body.contains("poller: not-yet-ticked"),
14476 "a never-ticked poller must say so: {body}"
14477 );
14478
14479 let stale_after = health_tick_stale_secs(configured_poll_tick());
14481 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
14482 state.runtime_health.poll_tick_completed(long_ago);
14483 let (status, body) = body_of(state.clone()).await;
14484 assert_eq!(
14485 status,
14486 StatusCode::OK,
14487 "a stale poller must not 503: {body}"
14488 );
14489 assert!(body.contains("poller: stale"), "{body}");
14490
14491 state.runtime_health.poll_tick_completed(0); state
14499 .runtime_health
14500 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
14501 let (status, body) = body_of(state.clone()).await;
14502 assert_eq!(status, StatusCode::OK);
14503 assert!(
14504 body.contains("poller: stale never-ticked"),
14505 "a poller that never ticked long after boot still reads as benign: {body}"
14506 );
14507
14508 state.db.close().await;
14511 let (status, body) = body_of(state.clone()).await;
14512 assert_eq!(
14513 status,
14514 StatusCode::SERVICE_UNAVAILABLE,
14515 "an unreachable database must fail the check: {body}"
14516 );
14517 assert!(body.starts_with("FAIL"), "{body}");
14518 assert!(
14522 !body.contains("PoolClosed") && !body.contains("sqlx"),
14523 "health leaked the raw database error to an unauthenticated caller: {body}"
14524 );
14525 }
14526
14527 #[test]
14533 fn the_stale_threshold_follows_the_poll_tick() {
14534 assert_eq!(
14537 health_tick_stale_secs(Duration::from_secs(60)),
14538 HEALTH_TICK_STALE_FLOOR_SECS
14539 );
14540 let slow = Duration::from_secs(30 * 60);
14543 assert!(
14544 health_tick_stale_secs(slow) > slow.as_secs() as i64,
14545 "a 30-minute tick must not be stale after one interval"
14546 );
14547 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
14548 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
14550 }
14551
14552 #[tokio::test]
14558 async fn stats_does_not_call_a_stopped_poller_running() {
14559 let state = test_state(&[]).await;
14560 let render = |state: AppState| async move {
14561 let resp = router(state)
14562 .oneshot(
14563 Request::builder()
14564 .uri("/stats")
14565 .body(Body::empty())
14566 .unwrap(),
14567 )
14568 .await
14569 .unwrap();
14570 assert_eq!(resp.status(), StatusCode::OK);
14571 String::from_utf8(
14572 axum::body::to_bytes(resp.into_body(), usize::MAX)
14573 .await
14574 .unwrap()
14575 .to_vec(),
14576 )
14577 .unwrap()
14578 };
14579
14580 let body = render(state.clone()).await;
14582 assert!(
14583 body.contains("the poller is not running on this instance"),
14584 "a disabled poller renders as healthy"
14585 );
14586
14587 state.runtime_health.set_schedulers_enabled(true);
14589 let body = render(state.clone()).await;
14590 assert!(
14591 body.contains("no poll has finished since this instance booted"),
14592 "a poller that has not ticked renders as healthy"
14593 );
14594
14595 state
14597 .runtime_health
14598 .poll_tick_completed(chrono::Utc::now().timestamp());
14599 let body = render(state.clone()).await;
14600 assert!(
14601 body.contains("running"),
14602 "a healthy poller must read as running"
14603 );
14604
14605 state.runtime_health.set_watermark(true);
14607 let body = render(state.clone()).await;
14608 assert!(
14609 body.contains("the cache is at its size limit"),
14610 "a watermark pause is hidden once the poller is ticking"
14611 );
14612 }
14613
14614 #[tokio::test]
14621 async fn stats_shows_ingest_starved_of_sanitize_permits() {
14622 let mut state = test_state(&[]).await;
14623 let starvation: &'static crate::feed::Starvation =
14624 Box::leak(Box::new(crate::feed::Starvation::new()));
14625 state.sanitize_starvation = starvation;
14626 let render = |state: AppState| async move {
14627 let resp = router(state)
14628 .oneshot(
14629 Request::builder()
14630 .uri("/stats")
14631 .body(Body::empty())
14632 .unwrap(),
14633 )
14634 .await
14635 .unwrap();
14636 assert_eq!(resp.status(), StatusCode::OK);
14637 String::from_utf8(
14638 axum::body::to_bytes(resp.into_body(), usize::MAX)
14639 .await
14640 .unwrap()
14641 .to_vec(),
14642 )
14643 .unwrap()
14644 };
14645
14646 let body = render(state.clone()).await;
14647 assert!(
14648 !body.contains("no sanitize capacity"),
14649 "the row shows on an instance that never deferred"
14650 );
14651
14652 let ten_min_ago = chrono::Utc::now().timestamp() - 600;
14653 starvation.record_no_permit(ten_min_ago);
14654 starvation.record_no_permit(ten_min_ago + 1);
14655 starvation.record_no_permit(ten_min_ago + 2);
14656 let body = render(state.clone()).await;
14657 assert!(body.contains("3 polls deferred since boot"), "{body}");
14658 assert!(
14659 body.contains(
14660 "<strong>stalled</strong> — no feed body has been sanitized since 10m ago"
14661 ),
14662 "a starved instance does not read as stalled"
14663 );
14664
14665 starvation.record_permit();
14666 let body = render(state).await;
14667 assert!(body.contains("3 polls deferred since boot"));
14668 assert!(
14669 !body.contains("<strong>stalled</strong> — no feed body"),
14670 "a permit came free but /stats still reads stalled"
14671 );
14672 }
14673
14674 #[tokio::test]
14685 async fn health_reports_an_unmeasured_database_without_failing() {
14686 use crate::runtime_health::DbProbe;
14687 let state = test_state(&[]).await;
14688
14689 let held = state
14692 .runtime_health
14693 .begin_db_probe()
14694 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
14695
14696 let resp = router(state.clone())
14697 .oneshot(
14698 Request::builder()
14699 .uri("/health")
14700 .body(Body::empty())
14701 .unwrap(),
14702 )
14703 .await
14704 .unwrap();
14705 let status = resp.status();
14706 let body = String::from_utf8(
14707 axum::body::to_bytes(resp.into_body(), usize::MAX)
14708 .await
14709 .unwrap()
14710 .to_vec(),
14711 )
14712 .unwrap();
14713 drop(held);
14714
14715 assert_eq!(
14716 status,
14717 StatusCode::OK,
14718 "an unmeasured database failed the check, which an unauthenticated \
14719 caller can cause on demand: {body}"
14720 );
14721 assert!(
14722 body.contains("db: unknown"),
14723 "the unmeasured state must still be REPORTED: {body}"
14724 );
14725 assert!(!body.starts_with("FAIL"), "{body}");
14726 assert!(
14731 !body.starts_with("ok"),
14732 "the unmeasured state is indistinguishable from healthy to a \
14733 body-matching monitor: {body}"
14734 );
14735 assert!(body.starts_with("unknown"), "{body}");
14736
14737 let held = state
14748 .runtime_health
14749 .begin_db_probe()
14750 .unwrap_or_else(|_| panic!("claim"));
14751 state
14752 .runtime_health
14753 .record_for_test(DbProbe::Failed("unavailable".to_string()));
14754 let resp = router(state.clone())
14755 .oneshot(
14756 Request::builder()
14757 .uri("/health")
14758 .body(Body::empty())
14759 .unwrap(),
14760 )
14761 .await
14762 .unwrap();
14763 let status = resp.status();
14764 let body = String::from_utf8(
14765 axum::body::to_bytes(resp.into_body(), usize::MAX)
14766 .await
14767 .unwrap()
14768 .to_vec(),
14769 )
14770 .unwrap();
14771 drop(held);
14772 assert_eq!(
14773 status,
14774 StatusCode::SERVICE_UNAVAILABLE,
14775 "a BORROWED failure verdict must fail the check, not just a freshly \
14776 measured one: {body}"
14777 );
14778 assert!(body.starts_with("FAIL"), "{body}");
14779
14780 state.db.close().await;
14781 let resp = router(state.clone())
14782 .oneshot(
14783 Request::builder()
14784 .uri("/health")
14785 .body(Body::empty())
14786 .unwrap(),
14787 )
14788 .await
14789 .unwrap();
14790 assert_eq!(
14791 resp.status(),
14792 StatusCode::SERVICE_UNAVAILABLE,
14793 "a measured database failure must still fail the check"
14794 );
14795 }
14796
14797 #[tokio::test]
14806 async fn an_abandoned_request_still_records_its_probe() {
14807 use crate::runtime_health::DbProbe;
14808 let state = test_state(&[]).await;
14809 let rh = state.runtime_health.clone();
14810
14811 let app = router(state.clone());
14813 let fut = app.oneshot(
14814 Request::builder()
14815 .uri("/health")
14816 .body(Body::empty())
14817 .unwrap(),
14818 );
14819 let handle = tokio::spawn(fut);
14820 handle.abort();
14821 let _ = handle.await;
14822
14823 for _ in 0..50 {
14826 if rh.begin_db_probe().is_ok() {
14827 break;
14828 }
14829 tokio::time::sleep(Duration::from_millis(20)).await;
14830 }
14831 let resp = router(state.clone())
14832 .oneshot(
14833 Request::builder()
14834 .uri("/health")
14835 .body(Body::empty())
14836 .unwrap(),
14837 )
14838 .await
14839 .unwrap();
14840 let body = String::from_utf8(
14841 axum::body::to_bytes(resp.into_body(), usize::MAX)
14842 .await
14843 .unwrap()
14844 .to_vec(),
14845 )
14846 .unwrap();
14847 assert!(
14848 body.contains("db: ok"),
14849 "after an abandoned request the next caller still reads an \
14850 unmeasured database — the probe was cancelled with it: {body}"
14851 );
14852 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
14854 }
14855
14856 #[tokio::test]
14863 async fn the_health_probe_opens_a_real_table() {
14864 use sqlx::Row;
14865 let state = test_state(&[]).await;
14866 let opcodes = |sql: &'static str| {
14868 let db = state.db.clone();
14869 async move {
14870 sqlx::query(sql)
14871 .fetch_all(&db)
14872 .await
14873 .unwrap()
14874 .into_iter()
14875 .map(|r| r.get::<String, _>("opcode"))
14876 .collect::<Vec<String>>()
14877 }
14878 };
14879
14880 let explain: &'static str =
14883 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
14884 let probe = opcodes(explain).await;
14885 assert!(
14887 health_db_probe(&state.db).await.is_ok(),
14888 "the probe does not run against the real schema",
14889 );
14890 assert!(
14891 probe.iter().any(|op| op == "OpenRead"),
14892 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
14893 );
14894 let bare = opcodes("EXPLAIN SELECT 1").await;
14896 assert!(
14897 !bare.iter().any(|op| op == "OpenRead"),
14898 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
14899 );
14900 }
14901
14902 #[test]
14905 fn an_instance_that_has_never_polled_says_so() {
14906 assert_eq!(humanise_ago(None), "never");
14907 assert_eq!(humanise_ago(Some(0)), "0s ago");
14908 assert_eq!(humanise_ago(Some(59)), "59s ago");
14909 assert_eq!(humanise_ago(Some(60)), "1m ago");
14910 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
14911 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
14912 }
14913
14914 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
14917 use tokio::io::{AsyncReadExt, AsyncWriteExt};
14918 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14919 let addr = listener.local_addr().unwrap();
14920 let (url, title) = (saved_url.to_string(), saved_title.to_string());
14921 tokio::spawn(async move {
14922 loop {
14923 let Ok((mut sock, _)) = listener.accept().await else {
14924 break;
14925 };
14926 let mut buf = vec![0u8; 8192];
14927 let Ok(n) = sock.read(&mut buf).await else {
14928 continue;
14929 };
14930 let req = String::from_utf8_lossy(&buf[..n]).to_string();
14931 let wants_saved = req.contains("community.lexicon.rss.saved");
14932 let records = if wants_saved {
14933 serde_json::json!([{
14934 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
14935 "cid": "bafy",
14936 "value": {
14937 "$type": "community.lexicon.rss.saved",
14938 "url": url,
14939 "title": title,
14940 "createdAt": "2026-01-01T00:00:00Z"
14941 }
14942 }])
14943 } else {
14944 serde_json::json!([])
14945 };
14946 let body = serde_json::json!({
14947 "ok": true, "data": { "records": records }
14948 })
14949 .to_string();
14950 let resp = format!(
14951 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
14952 body.len(), body
14953 );
14954 let _ = sock.write_all(resp.as_bytes()).await;
14955 let _ = sock.flush().await;
14956 }
14957 });
14958 format!("http://{addr}")
14959 }
14960
14961 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
14964 let feed = subscribed_feed.to_string();
14965 use tokio::io::{AsyncReadExt, AsyncWriteExt};
14966 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14967 let addr = listener.local_addr().unwrap();
14968 tokio::spawn(async move {
14969 loop {
14970 let Ok((mut sock, _)) = listener.accept().await else {
14971 break;
14972 };
14973 let mut buf = vec![0u8; 8192];
14974 let Ok(read) = sock.read(&mut buf).await else {
14975 continue;
14976 };
14977 let req = String::from_utf8_lossy(&buf[..read]).to_string();
14978 let records = if req.contains("community.lexicon.rss.saved") {
14979 serde_json::Value::Array(
14980 (0..n)
14981 .map(|i| {
14982 serde_json::json!({
14983 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
14984 "cid": "bafy",
14985 "value": {
14986 "$type": "community.lexicon.rss.saved",
14987 "url": format!("https://elsewhere.example/{i}"),
14988 "title": format!("Elsewhere {i}"),
14989 "createdAt": "2026-01-01T00:00:00Z"
14990 }
14991 })
14992 })
14993 .collect(),
14994 )
14995 } else if req.contains("community.lexicon.rss.subscription") {
14996 serde_json::json!([{
15001 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
15002 "cid": "bafy",
15003 "value": {
15004 "$type": "community.lexicon.rss.subscription",
15005 "url": feed,
15006 "createdAt": "2026-01-01T00:00:00Z"
15007 }
15008 }])
15009 } else {
15010 serde_json::json!([])
15011 };
15012 let body =
15013 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15014 let resp = format!(
15015 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15016 body.len(), body
15017 );
15018 let _ = sock.write_all(resp.as_bytes()).await;
15019 let _ = sock.flush().await;
15020 }
15021 });
15022 format!("http://{addr}")
15023 }
15024
15025 #[tokio::test]
15033 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
15034 let did = "did:plc:pagerloop";
15035 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
15036 let state = test_state_with_sidecar(&[], &sidecar).await;
15037 store::grant_access(&state.db, did, None, "test", None)
15038 .await
15039 .unwrap();
15040 let feed = store::upsert_feed(
15041 &state.db,
15042 &store::NewFeed {
15043 url: "https://loop.example/feed.xml".to_string(),
15044 title: Some("Loop".to_string()),
15045 ..Default::default()
15046 },
15047 )
15048 .await
15049 .unwrap();
15050 let entries: Vec<store::NewEntry> = (0..250)
15053 .map(|i| store::NewEntry {
15054 guid: format!("s-{i:04}"),
15055 url: Some(format!("https://loop.example/{i}")),
15056 title: Some(format!("Starred {i:04}")),
15057 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
15058 ..Default::default()
15059 })
15060 .collect();
15061 store::insert_entries(&state.db, feed, &entries, 0)
15062 .await
15063 .unwrap();
15064 store::replace_sub_refs(&state.db, did, &[feed])
15065 .await
15066 .unwrap();
15067 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
15068 .await
15069 .unwrap()
15070 {
15071 store::mark_starred(&state.db, did, row.id, true)
15072 .await
15073 .unwrap();
15074 }
15075
15076 let cookie = session_cookie(&state, did, None);
15077 let app = router(state.clone());
15078 let get = |uri: &str| {
15079 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
15080 async move {
15081 let resp = app
15082 .oneshot(
15083 Request::builder()
15084 .uri(uri)
15085 .header(header::COOKIE, cookie)
15086 .body(Body::empty())
15087 .unwrap(),
15088 )
15089 .await
15090 .unwrap();
15091 assert_eq!(resp.status(), StatusCode::OK);
15092 String::from_utf8(
15093 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
15094 .await
15095 .unwrap()
15096 .to_vec(),
15097 )
15098 .unwrap()
15099 }
15100 };
15101
15102 let p3 = get("/?view=starred&page=3").await;
15107 assert!(
15108 p3.contains("Page 3 of 4"),
15109 "the pager and the clamp disagree on the total: {}",
15110 p3.split("pager-pos")
15111 .nth(1)
15112 .unwrap_or("")
15113 .chars()
15114 .take(120)
15115 .collect::<String>()
15116 );
15117 assert!(
15120 p3.contains("Elsewhere 0"),
15121 "page 3 should start the uncached run"
15122 );
15123 assert_eq!(
15124 p3.matches("<li class=\"entry").count(),
15125 ENTRIES_PER_PAGE as usize,
15126 "the boundary page is not full"
15127 );
15128
15129 {
15138 let body = &p3;
15139 assert!(
15140 body.contains("330 entries"),
15141 "the heading must count the whole sequence: {}",
15142 body.split("content-count")
15143 .nth(1)
15144 .unwrap_or("")
15145 .chars()
15146 .take(120)
15147 .collect::<String>()
15148 );
15149 assert!(
15150 body.contains("(80 saved elsewhere)"),
15151 "the heading must say how many of the total the cache cannot show, \
15152 as a whole-list figure and not a per-page one: {}",
15153 body.split("content-count")
15154 .nth(1)
15155 .unwrap_or("")
15156 .chars()
15157 .take(120)
15158 .collect::<String>()
15159 );
15160 assert!(
15161 !body.contains("plus 50") && !body.contains("plus 80"),
15162 "the heading is adding the uncached rows to a total that already \
15163 includes them"
15164 );
15165 }
15166
15167 let p4 = get("/?view=starred&page=4").await;
15168 assert!(
15169 p4.contains("Page 4 of 4"),
15170 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
15171 );
15172 assert_eq!(
15173 p4.matches("<li class=\"entry").count(),
15174 30,
15175 "page 4 should hold the remaining 30 uncached records"
15176 );
15177 assert!(
15178 p4.contains("Elsewhere 79"),
15179 "the LAST saved record is unreachable — it can only be removed from here"
15180 );
15181
15182 assert!(
15184 !p4.contains("Elsewhere 0"),
15185 "an uncached record was rendered on more than one page"
15186 );
15187 let first = get("/?view=starred").await;
15190 assert!(
15191 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
15192 "the heading changed between pages; it describes the list, not the page"
15193 );
15194 assert!(
15195 !first.contains("Elsewhere "),
15196 "uncached saved records leaked onto the first page"
15197 );
15198 }
15199
15200 #[tokio::test]
15207 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
15208 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
15209 let sidecar =
15210 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
15211 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
15212 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
15213
15214 let resp = router(state)
15215 .oneshot(
15216 Request::builder()
15217 .uri("/?view=starred")
15218 .body(Body::empty())
15219 .unwrap(),
15220 )
15221 .await
15222 .unwrap();
15223 assert_eq!(resp.status(), StatusCode::OK);
15224 let body = String::from_utf8(
15225 axum::body::to_bytes(resp.into_body(), usize::MAX)
15226 .await
15227 .unwrap()
15228 .to_vec(),
15229 )
15230 .unwrap();
15231
15232 assert!(
15233 body.contains("Starred elsewhere"),
15234 "the saved record was not rendered at all"
15235 );
15236 assert!(
15237 body.contains("entry-uncached"),
15238 "it was not marked as uncached, so it looks like a normal entry"
15239 );
15240 assert!(
15241 body.contains("https://elsewhere.example/article"),
15242 "the row must link straight to the article"
15243 );
15244 assert!(
15245 !body.contains("/entries/0/"),
15246 "an uncached row must not offer entry actions against a nonexistent id"
15247 );
15248 }
15249
15250 #[test]
15258 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
15259 for hostile in [
15260 "日本語日本語日本",
15261 "é",
15262 "",
15263 "2026",
15264 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
15265 ] {
15266 let out = display_date(Some(hostile));
15267 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
15268 }
15269 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
15270 assert_eq!(display_date(None), "");
15271 }
15272
15273 #[test]
15276 fn the_unsave_route_is_rate_limited() {
15277 use axum::http::Method;
15278 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
15279 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
15281 }
15282
15283 #[tokio::test]
15292 async fn health_reports_a_broken_database() {
15293 let state = test_state(&[]).await;
15294 assert!(
15296 health_db_probe(&state.db).await.is_ok(),
15297 "the fixture was not healthy to begin with",
15298 );
15299
15300 sqlx::query("DROP TABLE feeds")
15301 .execute(&state.db)
15302 .await
15303 .unwrap();
15304
15305 assert!(
15306 health_db_probe(&state.db).await.is_err(),
15307 "the probe reported success against a database missing the table it \
15308 claims to read; `SELECT 1` would do exactly this",
15309 );
15310
15311 let resp = router(state)
15312 .oneshot(
15313 Request::builder()
15314 .uri("/health")
15315 .body(Body::empty())
15316 .unwrap(),
15317 )
15318 .await
15319 .unwrap();
15320 let body = String::from_utf8(
15321 axum::body::to_bytes(resp.into_body(), usize::MAX)
15322 .await
15323 .unwrap()
15324 .to_vec(),
15325 )
15326 .unwrap();
15327 assert!(
15329 body.starts_with("FAIL"),
15330 "/health did not report FAIL for a broken database: {body}",
15331 );
15332 assert!(
15333 !body.contains("db: ok"),
15334 "/health still called the database ok: {body}",
15335 );
15336 }
15337
15338 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
15343 use tokio::io::{AsyncReadExt, AsyncWriteExt};
15344 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15345 let addr = listener.local_addr().unwrap();
15346 tokio::spawn(async move {
15347 loop {
15348 let Ok((mut sock, _)) = listener.accept().await else {
15349 break;
15350 };
15351 let mut buf = vec![0u8; 8192];
15352 let Ok(n) = sock.read(&mut buf).await else {
15353 continue;
15354 };
15355 let req = String::from_utf8_lossy(&buf[..n]).to_string();
15356 let wants = |c: &str| req.contains(c);
15357 if fail_on.is_some_and(wants) {
15358 let body = r#"{"ok":false,"error":"ShortList"}"#;
15359 let resp = format!(
15360 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15361 body.len(),
15362 body
15363 );
15364 let _ = sock.write_all(resp.as_bytes()).await;
15365 let _ = sock.flush().await;
15366 continue;
15367 }
15368 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
15369 serde_json::json!([{
15370 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
15371 "cid": "bafy",
15372 "value": {
15373 "$type": crate::lexicon::nsid::SUBSCRIPTION,
15374 "url": "https://kept.example/feed.xml",
15375 "title": "Kept",
15376 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15381 "createdAt": "2026-01-01T00:00:00Z"
15382 }
15383 }])
15384 } else if wants(crate::lexicon::nsid::FOLDER) {
15385 serde_json::json!([{
15386 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15387 "cid": "bafy",
15388 "value": {
15389 "$type": crate::lexicon::nsid::FOLDER,
15390 "name": "Kept folder",
15391 "createdAt": "2026-01-01T00:00:00Z"
15392 }
15393 }])
15394 } else {
15395 serde_json::json!([])
15396 };
15397 let body =
15398 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15399 let resp = format!(
15400 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15401 body.len(),
15402 body
15403 );
15404 let _ = sock.write_all(resp.as_bytes()).await;
15405 let _ = sock.flush().await;
15406 }
15407 });
15408 format!("http://{addr}")
15409 }
15410
15411 async fn spawn_malformed_sidecar() -> String {
15414 use tokio::io::{AsyncReadExt, AsyncWriteExt};
15415 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15416 let addr = listener.local_addr().unwrap();
15417 tokio::spawn(async move {
15418 loop {
15419 let Ok((mut sock, _)) = listener.accept().await else {
15420 break;
15421 };
15422 let mut buf = vec![0u8; 8192];
15423 let _ = sock.read(&mut buf).await;
15424 let body = serde_json::json!({ "ok": true, "data": { "records": [
15425 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
15426 { "cid": "bafy", "value": {} },
15427 ]}})
15428 .to_string();
15429 let resp = format!(
15430 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15431 body.len(),
15432 body
15433 );
15434 let _ = sock.write_all(resp.as_bytes()).await;
15435 let _ = sock.flush().await;
15436 }
15437 });
15438 format!("http://{addr}")
15439 }
15440
15441 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
15442 let cookie = session_cookie(&state, did, None);
15443 let resp = router(state)
15444 .oneshot(
15445 Request::builder()
15446 .uri(uri)
15447 .header(header::COOKIE, cookie)
15448 .body(Body::empty())
15449 .unwrap(),
15450 )
15451 .await
15452 .unwrap();
15453 let status = resp.status();
15454 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15455 .await
15456 .unwrap();
15457 (status, String::from_utf8_lossy(&body).to_string())
15458 }
15459
15460 #[tokio::test]
15466 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
15467 let did = "did:plc:displayer";
15468 let state = test_state(&[did]).await;
15469 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
15470 let feed_id = store::upsert_feed(
15471 &state.db,
15472 &store::NewFeed {
15473 url: url.into(),
15474 title: Some("Quiet Journal".into()),
15475 ..Default::default()
15476 },
15477 )
15478 .await
15479 .unwrap();
15480 store::replace_sub_refs(&state.db, did, &[feed_id])
15481 .await
15482 .unwrap();
15483 store::insert_entries(
15484 &state.db,
15485 feed_id,
15486 &[store::NewEntry {
15487 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
15488 .into(),
15489 url: Some("https://quiet.example/no-summary".into()),
15490 title: Some("A title-only article".into()),
15491 published: Some("2026-07-11T00:00:00Z".into()),
15492 content_html: None,
15493 ..Default::default()
15494 }],
15495 0,
15496 )
15497 .await
15498 .unwrap();
15499 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
15500 assert_eq!(status, StatusCode::OK);
15501 assert!(
15502 list.contains("A title-only article"),
15503 "the entry is missing from the list"
15504 );
15505
15506 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
15507 .bind(feed_id)
15508 .fetch_one(&state.db)
15509 .await
15510 .unwrap();
15511 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
15512 assert_eq!(
15513 status,
15514 StatusCode::OK,
15515 "the article page failed for an entry with no body"
15516 );
15517 assert!(page.contains("A title-only article"));
15518 assert!(
15519 page.contains("https://quiet.example/no-summary"),
15520 "no link to the original"
15521 );
15522 assert!(
15523 page.contains(r#"<time datetime=""#),
15524 "no date on the article page"
15525 );
15526 }
15527
15528 #[tokio::test]
15533 async fn a_malformed_subscription_record_raises_an_alert() {
15534 let did = "did:plc:alerted";
15535 for page in ["/", "/manage"] {
15536 let sidecar = spawn_malformed_sidecar().await;
15537 let state = test_state_with_sidecar(&[did], &sidecar).await;
15538 let (status, body) = page_body(state, did, page).await;
15539 assert_eq!(status, StatusCode::OK, "{page} did not render");
15540 assert!(
15541 body.contains(r#"role="alert""#) && body.contains("could not be read"),
15542 "{page} rendered no alert for a refused subscription list"
15543 );
15544 assert!(
15545 body.contains("1 record(s) in your subscription list"),
15546 "{page} gave the generic alert, not the malformed-record one"
15547 );
15548 }
15549 }
15550
15551 #[tokio::test]
15553 async fn a_healthy_subscription_listing_raises_no_alert() {
15554 let did = "did:plc:exporter";
15555 let sidecar = spawn_export_sidecar(None).await;
15556 let state = test_state_with_sidecar(&[did], &sidecar).await;
15557 let (status, body) = page_body(state, did, "/").await;
15558 assert_eq!(status, StatusCode::OK);
15559 assert!(
15560 !body.contains("could not be read"),
15561 "a healthy listing raised an alert"
15562 );
15563 }
15564
15565 async fn export_opml_response(
15567 fail_on: Option<&'static str>,
15568 ) -> (StatusCode, HeaderMap, String) {
15569 let did = "did:plc:exporter";
15570 let sidecar = spawn_export_sidecar(fail_on).await;
15571 let state = test_state_with_sidecar(&[did], &sidecar).await;
15572 let cookie = session_cookie(&state, did, None);
15573 let resp = router(state)
15574 .oneshot(
15575 Request::builder()
15576 .uri("/opml/export")
15577 .header(header::COOKIE, cookie)
15578 .body(Body::empty())
15579 .unwrap(),
15580 )
15581 .await
15582 .unwrap();
15583 let status = resp.status();
15584 let headers = resp.headers().clone();
15585 let body = String::from_utf8_lossy(
15586 &axum::body::to_bytes(resp.into_body(), usize::MAX)
15587 .await
15588 .unwrap(),
15589 )
15590 .to_string();
15591 (status, headers, body)
15592 }
15593
15594 #[tokio::test]
15607 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
15608 let (status, headers, body) =
15609 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
15610
15611 assert_ne!(
15612 status,
15613 StatusCode::OK,
15614 "a failed subscription walk answered 200: {body}",
15615 );
15616 assert!(
15617 !headers.contains_key(header::CONTENT_DISPOSITION),
15618 "a failed subscription walk still offered a download: {headers:?}",
15619 );
15620 assert!(
15621 !body.contains("<opml"),
15622 "a failed subscription walk still served an OPML document: {body}",
15623 );
15624 }
15625
15626 #[tokio::test]
15630 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
15631 let (status, headers, body) =
15632 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
15633
15634 assert_ne!(
15635 status,
15636 StatusCode::OK,
15637 "a failed folder walk answered 200: {body}",
15638 );
15639 assert!(
15640 !headers.contains_key(header::CONTENT_DISPOSITION),
15641 "a failed folder walk still offered a download: {headers:?}",
15642 );
15643 assert!(
15644 !body.contains("<opml"),
15645 "a failed folder walk still served an OPML document: {body}",
15646 );
15647 }
15648
15649 #[tokio::test]
15652 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
15653 let (status, headers, body) = export_opml_response(None).await;
15654
15655 assert_eq!(
15656 status,
15657 StatusCode::OK,
15658 "a healthy export did not answer 200"
15659 );
15660 assert_eq!(
15661 headers
15662 .get(header::CONTENT_DISPOSITION)
15663 .and_then(|v| v.to_str().ok()),
15664 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
15665 "a healthy export did not offer the download",
15666 );
15667 assert!(
15668 body.contains("https://kept.example/feed.xml"),
15669 "the exported OPML lost the subscription: {body}",
15670 );
15671 assert!(
15672 body.contains("Kept folder"),
15673 "the exported OPML lost the folder: {body}",
15674 );
15675 }
15676}