1use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57 extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58 http::{header, HeaderMap, StatusCode},
59 middleware::{self, Next},
60 response::{Html, IntoResponse, Redirect, Response},
61 routing::{get, post},
62 Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::safe_link::SafeLink;
74use crate::{feed, store, AppState, Session, VERSION};
75
76#[path = "opml.rs"]
81mod opml;
82
83const SESSION_COOKIE: &str = "fr_session";
85
86const INVITE_COOKIE: &str = "fr_invite";
94
95const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
103
104const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
106
107const INVITE_TTL_SECS: i64 = 1800;
110
111const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
114
115const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
117
118const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
120
121const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
137 script-src 'self'; \
138 style-src 'self' 'unsafe-inline'; \
139 img-src 'self' https: data:; \
140 font-src 'self'; \
141 connect-src 'self'; \
142 form-action 'self'; \
143 base-uri 'self'; \
144 frame-ancestors 'none'; \
145 object-src 'none'";
146
147#[derive(Clone, Debug)]
154struct CurrentUser {
155 did: String,
156 handle: Option<String>,
157 sid: Option<String>,
160}
161
162async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
173 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
174 if let Some(session) = state.sessions.get(&sid) {
175 if store::has_beta_access(&state.db, &session.did)
176 .await
177 .unwrap_or(false)
178 {
179 return Some(CurrentUser {
180 did: session.did,
181 handle: session.handle,
182 sid: Some(sid),
183 });
184 }
185 state.sessions.remove(&sid);
188 }
189 }
190 if let Some(did) = state.config.dev_did.clone() {
193 if store::has_beta_access(&state.db, &did)
194 .await
195 .unwrap_or(false)
196 {
197 return Some(CurrentUser {
198 did,
199 handle: None,
200 sid: None,
201 });
202 }
203 }
204 None
205}
206
207async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
209 current_session(state, headers).await.map(|u| u.did)
210}
211
212pub fn router(state: AppState) -> Router {
218 let limiter = RateLimiter::shared();
222 let rl_state = RateLimitState {
226 limiter,
227 trusted_header: state.config.trusted_ip_header.clone(),
228 };
229
230 Router::new()
231 .route("/health", get(health))
232 .route("/about", get(about))
233 .route("/standard-site", get(standard_site))
234 .route("/stats", get(stats))
235 .route("/privacy", get(privacy))
236 .route("/terms", get(terms))
237 .route("/manage", get(manage))
238 .route("/", get(index))
239 .route("/entries/{id}", get(entry_view))
240 .route("/entries/{id}/read", post(mark_read))
241 .route("/entries/{id}/star", post(toggle_star))
242 .route("/saved/{rkey}/delete", post(unsave_record))
243 .route("/read-all", post(mark_all_read))
244 .route("/subscriptions", post(add_subscription))
245 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
246 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
247 .route("/folders", post(create_folder))
248 .route("/folders/{rkey}/rename", post(rename_folder))
249 .route("/folders/{rkey}/delete", post(delete_folder))
250 .route(
253 "/opml",
254 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
255 )
256 .route("/opml/export", get(export_opml))
257 .route("/login", get(login_form).post(login_submit))
258 .route(
259 "/beta/redeem",
260 get(beta_redeem_form).post(beta_redeem_submit),
261 )
262 .route("/claim", get(claim))
265 .route("/bot/claims", post(bot_mint_claim))
268 .route("/admin/invites", post(admin_mint_invites))
269 .route("/admin/metrics", get(admin_metrics))
270 .route("/oauth/client-metadata.json", get(oauth_client_metadata))
271 .route("/oauth/jwks.json", get(oauth_jwks))
272 .route("/account/delete", post(account_delete))
273 .route("/oauth/callback", get(oauth_callback))
274 .route("/logout", post(logout))
275 .nest_service("/static", ServeDir::new("static"))
276 .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
280 .layer(middleware::from_fn(cache_control))
285 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
288 .layer(TraceLayer::new_for_http())
289 .layer(static_header_layer(
293 "content-security-policy",
294 CONTENT_SECURITY_POLICY,
295 ))
296 .layer(static_header_layer("x-content-type-options", "nosniff"))
297 .layer(static_header_layer(
298 "referrer-policy",
299 "strict-origin-when-cross-origin",
300 ))
301 .layer(static_header_layer("x-frame-options", "DENY"))
302 .with_state(state)
303}
304
305const OPML_BODY_LIMIT: usize = 1024 * 1024;
320
321#[cfg(test)]
331const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
332
333#[cfg(test)]
338const _: () = assert!(
339 OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
340 "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
341);
342
343fn static_header_layer(
347 name: &'static str,
348 value: &'static str,
349) -> SetResponseHeaderLayer<header::HeaderValue> {
350 SetResponseHeaderLayer::overriding(
351 header::HeaderName::from_static(name),
352 header::HeaderValue::from_static(value),
353 )
354}
355
356fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
376 use axum::http::Method;
377 if method != Method::POST
385 && !(method == Method::GET
386 && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
387 {
388 return false;
389 }
390 match path {
391 "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
396 | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
397 | "/folders" => true,
398 p => {
401 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
402 || p.starts_with("/saved/")
406 || p.starts_with("/subscriptions/")
407 || p.starts_with("/folders/")
408 }
409 }
410}
411
412#[derive(Clone)]
415struct RateLimitState {
416 limiter: RateLimiter,
417 trusted_header: Option<String>,
420}
421
422#[derive(Clone)]
427struct RateLimiter {
428 inner: std::sync::Arc<Mutex<RateLimiterState>>,
429}
430
431struct RateLimiterState {
433 buckets: HashMap<IpAddr, Bucket>,
434 last_sweep: Instant,
435}
436
437struct Bucket {
439 tokens: f64,
440 last: Instant,
441}
442
443const RATE_BURST: f64 = 20.0;
445const RATE_REFILL_PER_SEC: f64 = 1.0;
447const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
449
450const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
459
460const MAX_RATE_BUCKETS: usize = 10_000;
468
469const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
473
474impl RateLimiter {
475 fn shared() -> Self {
477 Self {
478 inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
479 buckets: HashMap::new(),
480 last_sweep: Instant::now(),
481 })),
482 }
483 }
484
485 fn check(&self, ip: IpAddr) -> bool {
488 self.check_at(ip, Instant::now())
489 }
490
491 fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
494 let mut state = match self.inner.lock() {
495 Ok(m) => m,
496 Err(p) => p.into_inner(),
498 };
499
500 if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
502 state
503 .buckets
504 .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
505 state.last_sweep = now;
506 }
507
508 if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
516 let mut by_age: Vec<(IpAddr, Instant)> =
517 state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
518 by_age.sort_unstable_by_key(|(_, last)| *last);
519 for (victim, _) in by_age
520 .into_iter()
521 .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
522 {
523 state.buckets.remove(&victim);
524 }
525 warn!(
526 buckets = state.buckets.len(),
527 "rate-limit bucket cap reached; evicted the least recently seen clients"
528 );
529 }
530
531 let bucket = state.buckets.entry(ip).or_insert(Bucket {
532 tokens: RATE_BURST,
533 last: now,
534 });
535 let elapsed = now.duration_since(bucket.last).as_secs_f64();
536 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
537 bucket.last = now;
538 if bucket.tokens >= 1.0 {
539 bucket.tokens -= 1.0;
540 true
541 } else {
542 false
543 }
544 }
545}
546
547fn client_ip(
568 headers: &HeaderMap,
569 conn: Option<&SocketAddr>,
570 trusted_header: Option<&str>,
571) -> Option<IpAddr> {
572 if let Some(name) = trusted_header {
573 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
574 if let Some(last) = raw.split(',').next_back() {
577 if let Ok(ip) = last.trim().parse::<IpAddr>() {
578 return Some(ip);
579 }
580 }
581 }
582 }
584 conn.map(|s| s.ip())
585}
586
587async fn rate_limit(
592 State(rl): State<RateLimitState>,
593 req: axum::extract::Request,
594 next: Next,
595) -> Response {
596 let path = req.uri().path().to_string();
597 let method = req.method().clone();
598 if is_rate_limited_path(&path, &method) {
599 let conn = req
600 .extensions()
601 .get::<ConnectInfo<SocketAddr>>()
602 .map(|c| c.0);
603 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
604 if let Some(ip) = ip {
610 if !rl.limiter.check(ip) {
611 warn!(%ip, %path, "rate limit exceeded");
612 return (
613 StatusCode::TOO_MANY_REQUESTS,
614 [(header::RETRY_AFTER, "1")],
615 "rate limit exceeded\n",
616 )
617 .into_response();
618 }
619 }
620 }
621 next.run(req).await
622}
623
624async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
635 let path = req.uri().path().to_string();
636 let is_login_landing = path == "/login"
639 && req.method() == axum::http::Method::GET
640 && !req.uri().query().unwrap_or("").contains("handle=");
641 let public = is_login_landing
642 || path == "/about"
643 || path == "/standard-site"
644 || path == "/privacy"
645 || path == "/terms"
646 || path.starts_with("/static/");
647
648 let mut resp = next.run(req).await;
649 if resp.headers().contains_key(header::CACHE_CONTROL) {
650 return resp;
651 }
652 let value = if public {
653 "public, max-age=300"
654 } else {
655 "no-store"
656 };
657 if let Ok(hv) = header::HeaderValue::from_str(value) {
658 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
659 }
660 resp
661}
662
663async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
672 sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
673 .fetch_optional(pool)
674 .await
675}
676
677const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
684
685const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
691
692const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
700
701fn health_tick_stale_secs(tick: Duration) -> i64 {
705 let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
706 tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
707}
708
709fn configured_poll_tick() -> Duration {
713 std::env::var("FEATHERREADER_POLL_TICK_SECS")
714 .ok()
715 .and_then(|v| v.trim().parse::<u64>().ok())
716 .filter(|s| *s > 0)
717 .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
718}
719
720const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
725
726const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
738
739async fn health(State(state): State<AppState>) -> Response {
783 let now = chrono::Utc::now().timestamp();
784 let rh = &state.runtime_health;
785
786 use crate::runtime_health::DbProbe;
787 let db = match rh.begin_db_probe() {
788 Err(borrowed) => borrowed,
791 Ok(probe) => {
792 let pool = state.db.clone();
802 let task = tokio::spawn(async move {
803 let verdict =
813 match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
814 Ok(Ok(_)) => DbProbe::Ok,
815 Ok(Err(err)) => {
820 warn!(%err, "health: database probe failed");
821 DbProbe::Failed("unavailable".to_string())
822 }
823 Err(_) => {
824 warn!(
825 timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
826 "health: database probe timed out (pool exhausted?)"
827 );
828 DbProbe::Failed("timeout".to_string())
829 }
830 };
831 probe.record(verdict.clone());
832 verdict
833 });
834 task.await.unwrap_or(DbProbe::Unknown)
838 }
839 };
840
841 let uptime = rh.uptime_secs(now);
842 let poller = if !rh.schedulers_enabled() {
843 "disabled".to_string()
846 } else {
847 match rh.secs_since_poll_tick(now) {
848 None => match uptime {
851 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
852 format!("stale never-ticked {up}s")
853 }
854 _ => "not-yet-ticked".to_string(),
855 },
856 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
857 format!("stale {secs}s")
858 }
859 Some(secs) => format!("ok {secs}s"),
860 }
861 };
862
863 let mut body = String::new();
872 let status = match &db {
873 DbProbe::Ok => {
874 body.push_str(&format!("ok featherreader/{VERSION}\n"));
875 body.push_str("db: ok\n");
876 StatusCode::OK
877 }
878 DbProbe::Unknown => {
885 body.push_str(&format!("unknown featherreader/{VERSION}\n"));
886 body.push_str("db: unknown (no probe has completed yet)\n");
887 StatusCode::OK
888 }
889 DbProbe::Failed(why) => {
890 body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
891 body.push_str(&format!("db: {why}\n"));
892 StatusCode::SERVICE_UNAVAILABLE
893 }
894 };
895 body.push_str(&format!(
899 "uptime: {}\n",
900 match uptime {
901 Some(secs) => format!("{secs}s"),
902 None => "unknown".to_string(),
903 }
904 ));
905 body.push_str(&format!("poller: {poller}\n"));
906 body.push_str(&format!(
907 "polling-paused: {}\n",
908 if rh.watermark_paused() { "yes" } else { "no" }
909 ));
910 body.push_str(&format!(
917 "backend: {}\n",
918 state.config.repo_backend.as_str()
919 ));
920 body.push_str(&format!(
921 "oauth-runtime: {}\n",
922 if state.oauth.is_some() {
923 "built"
924 } else {
925 "absent"
926 }
927 ));
928
929 let mut resp = (status, body).into_response();
932 if let Ok(hv) = header::HeaderValue::from_str("no-store") {
933 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
934 }
935 resp
936}
937
938async fn about(State(state): State<AppState>) -> Response {
947 let adoption = if state.config.show_adoption {
948 adoption_line(&state).await
949 } else {
950 None
951 };
952 render(&AboutTemplate {
953 card: Card::public(
954 &state.config,
955 "/about",
956 "About — FeatherReader",
957 "What FeatherReader is and isn't: an open-source, atproto-native reader for \
958 RSS feeds and standard.site publications, run as an experiment, free to \
959 self-host under the AGPL.",
960 ),
961 version: VERSION,
962 repo_url: REPO_URL,
963 kofi_url: KOFI_URL,
964 adoption,
965 standard_site: state.config.standard_site,
966 })
967}
968
969async fn standard_site(State(state): State<AppState>) -> Response {
975 render(&StandardSiteTemplate {
976 card: Card::public(
977 &state.config,
978 "/standard-site",
979 "standard.site — FeatherReader",
980 "Read standard.site publications beside your RSS feeds: articles \
981 published as atproto records, followed with the same portable \
982 subscription record.",
983 ),
984 version: VERSION,
985 repo_url: REPO_URL,
986 kofi_url: KOFI_URL,
987 standard_site: state.config.standard_site,
988 releases: RELEASES,
989 })
990}
991
992async fn unsave_record(
1007 State(state): State<AppState>,
1008 headers: HeaderMap,
1009 Path(rkey): Path<String>,
1010) -> Response {
1011 let Some(did) = current_did(&state, &headers).await else {
1012 return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1013 };
1014
1015 let identity = match state.repo().list_saved(&did).await {
1020 Ok(records) => records
1021 .into_iter()
1022 .find(|(k, _)| *k == rkey)
1023 .map(|(_, rec)| (rec.url, rec.entry_id)),
1024 Err(err) => {
1025 warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1026 a local star for the same article may survive");
1027 None
1028 }
1029 };
1030
1031 match state.repo().remove_saved(&did, &rkey).await {
1032 Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1033 Err(err) => {
1034 warn!(%err, %did, %rkey, "could not remove the saved record");
1035 return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1036 }
1037 }
1038
1039 if let Some((url, guid)) = identity {
1043 match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1044 Ok(0) => {}
1045 Ok(n) => {
1046 info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1047 }
1048 Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1049 }
1050 }
1051 if is_htmx(&headers) {
1053 return (StatusCode::OK, "").into_response();
1054 }
1055 Redirect::to("/?view=starred").into_response()
1056}
1057
1058fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1071 if !rh.schedulers_enabled() {
1072 return "off";
1073 }
1074 match rh.secs_since_poll_tick(now_unix) {
1077 None => {
1078 match rh.uptime_secs(now_unix) {
1081 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1082 _ => "starting",
1083 }
1084 }
1085 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1086 _ if rh.watermark_paused() => "paused",
1087 _ => "running",
1088 }
1089}
1090
1091async fn stats(State(state): State<AppState>) -> Response {
1093 let now = chrono::Utc::now();
1094 let health = match store::poll_health(
1095 &state.db,
1096 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1097 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1098 )
1099 .await
1100 {
1101 Ok(health) => health,
1102 Err(err) => {
1103 warn!(%err, "could not compute poll health");
1104 return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1105 }
1106 };
1107
1108 let polled_pct = if health.feeds_tracked == 0 {
1111 100
1112 } else {
1113 health.polled_last_hour * 100 / health.feeds_tracked
1114 };
1115
1116 render(&StatsTemplate {
1117 card: Card::public(
1118 &state.config,
1119 "/stats",
1120 "Stats — FeatherReader",
1121 "Is this instance's poller keeping up? Aggregate feed-polling health — \
1122 counts only; no feed and no reader is named.",
1123 ),
1124 version: VERSION,
1125 repo_url: REPO_URL,
1126 kofi_url: KOFI_URL,
1127 feeds_tracked: health.feeds_tracked,
1128 polled_last_hour: health.polled_last_hour,
1129 polled_pct,
1130 overdue: health.overdue,
1131 last_poll: humanise_ago(health.last_poll_secs_ago),
1132 oldest_poll: if health.never_polled > 0 {
1133 "never".to_string()
1134 } else {
1135 humanise_ago(health.oldest_poll_secs_ago)
1136 },
1137 never_polled: health.never_polled,
1138 poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1139 in_backoff: health.in_backoff,
1148 badly_broken: health.badly_broken,
1149 failure_kinds: health.failure_kinds,
1150 fetching: fetching_state(&state.runtime_health, now.timestamp()),
1151 })
1152}
1153
1154fn humanise_ago(secs: Option<i64>) -> String {
1159 let Some(secs) = secs else {
1160 return "never".to_string();
1161 };
1162 match secs {
1163 s if s < 60 => format!("{s}s ago"),
1164 s if s < 3600 => format!("{}m ago", s / 60),
1165 s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1166 }
1167}
1168
1169async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1177 match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1178 Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1180 repos: stat.value,
1181 truncated: stat.truncated,
1182 observed_on: stat
1183 .observed_at
1184 .split('T')
1185 .next()
1186 .unwrap_or_default()
1187 .to_string(),
1188 }),
1189 Ok(_) => None,
1190 Err(err) => {
1191 warn!(%err, "about: adoption stat read failed; omitting the line");
1192 None
1193 }
1194 }
1195}
1196
1197async fn privacy(State(state): State<AppState>) -> Response {
1201 render(&PrivacyTemplate {
1202 card: Card::public(
1203 &state.config,
1204 "/privacy",
1205 "Privacy — FeatherReader",
1206 "No account and no tracking: your subscriptions and reading state live in \
1207 your own PDS. What this server caches, for how long, and how the session \
1208 token is handled.",
1209 ),
1210 version: VERSION,
1211 repo_url: REPO_URL,
1212 kofi_url: KOFI_URL,
1213 })
1214}
1215
1216async fn terms(State(state): State<AppState>) -> Response {
1220 render(&TermsTemplate {
1221 card: Card::public(
1222 &state.config,
1223 "/terms",
1224 "Terms — FeatherReader",
1225 "The terms of use: an experimental service offered as-is with no warranty, \
1226 what acceptable use means here, and the AGPL self-host note.",
1227 ),
1228 version: VERSION,
1229 repo_url: REPO_URL,
1230 kofi_url: KOFI_URL,
1231 })
1232}
1233
1234struct FeedView {
1241 rkey: String,
1243 url: String,
1245 title: String,
1246 unread: i64,
1247 selected: bool,
1249 folder: Option<String>,
1254}
1255
1256struct FolderView {
1258 rkey: String,
1260 uri: String,
1262 name: String,
1263 feeds: Vec<FeedView>,
1264 selected: bool,
1266}
1267
1268struct EntryRow {
1270 id: i64,
1271 title: String,
1272 feed_title: String,
1273 published: String,
1274 read: bool,
1275 starred: bool,
1276 link: SafeLink,
1279 cached: bool,
1287 rkey: String,
1289}
1290
1291struct FolderOption {
1293 uri: String,
1294 name: String,
1295}
1296
1297struct Nav {
1302 handle: String,
1304 avatar: String,
1306 view: String,
1308 scope_qs: String,
1311 folders: Vec<FolderView>,
1314 loose_feeds: Vec<FeedView>,
1315 manage_active: bool,
1317}
1318
1319pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1328
1329const SITE_TITLE: &str = "FeatherReader — read, quietly";
1336
1337const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1340standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1341password, no tracking.";
1342
1343const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1348
1349#[derive(Debug, Clone)]
1360pub(crate) struct Card {
1361 pub title: String,
1363 pub description: String,
1366 pub url: String,
1368 pub image: String,
1370 pub private: bool,
1374}
1375
1376impl Card {
1377 fn public(
1379 config: &Config,
1380 path: &str,
1381 title: impl Into<String>,
1382 description: impl Into<String>,
1383 ) -> Self {
1384 let origin = config.public_url.trim_end_matches('/');
1385 Card {
1386 title: title.into(),
1387 description: description.into(),
1388 url: format!("{origin}{path}"),
1389 image: format!("{origin}{SHARE_IMAGE_PATH}"),
1390 private: false,
1391 }
1392 }
1393
1394 fn site(config: &Config) -> Self {
1396 Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1397 }
1398
1399 fn private(config: &Config) -> Self {
1403 Card {
1404 private: true,
1405 ..Card::site(config)
1406 }
1407 }
1408}
1409
1410#[derive(Template)]
1412#[template(path = "index.html")]
1413struct IndexTemplate {
1414 card: Card,
1416 version: &'static str,
1417 repo_url: &'static str,
1418 kofi_url: &'static str,
1419 flash: String,
1420 alert: String,
1423 nav: Nav,
1425 entries: Vec<EntryRow>,
1427 heading: String,
1429 feed_scope: Option<String>,
1431 total: i64,
1439 uncached_total: i64,
1447 page: i64,
1449 page_count: i64,
1451 prev_href: Option<String>,
1453 next_href: Option<String>,
1455}
1456
1457#[derive(Template)]
1459#[template(path = "manage.html")]
1460struct ManageTemplate {
1461 card: Card,
1463 version: &'static str,
1464 repo_url: &'static str,
1465 kofi_url: &'static str,
1466 flash: String,
1467 alert: String,
1469 nav: Nav,
1470 folder_options: Vec<FolderOption>,
1472 folders: Vec<FolderView>,
1474 loose_feeds: Vec<FeedView>,
1475 standard_site: bool,
1481}
1482
1483struct AdoptionLine {
1488 repos: i64,
1490 truncated: bool,
1492 observed_on: String,
1494}
1495
1496#[derive(Template)]
1499#[template(path = "about.html")]
1500struct AboutTemplate {
1501 card: Card,
1503 version: &'static str,
1504 repo_url: &'static str,
1505 kofi_url: &'static str,
1506 adoption: Option<AdoptionLine>,
1507 standard_site: bool,
1510}
1511
1512#[derive(Template)]
1516#[template(path = "standard_site.html")]
1517struct StandardSiteTemplate {
1518 card: Card,
1520 version: &'static str,
1521 repo_url: &'static str,
1522 kofi_url: &'static str,
1523 standard_site: bool,
1526 releases: &'static [Release],
1528}
1529
1530pub(crate) struct Release {
1535 pub(crate) version: &'static str,
1537 pub(crate) date: &'static str,
1539 pub(crate) summary: &'static str,
1541}
1542
1543impl Release {
1544 pub(crate) fn url(&self) -> String {
1546 format!("{REPO_URL}/releases/tag/v{}", self.version)
1547 }
1548
1549 pub(crate) fn changelog_url(&self) -> String {
1553 format!(
1554 "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1555 self.version.replace('.', ""),
1556 self.date
1557 )
1558 }
1559}
1560
1561pub(crate) const RELEASES: &[Release] = &[
1566 Release {
1567 version: "0.4.4",
1568 date: "2026-10-05",
1569 summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1570 links unchanged and real RSS bylines, and the address guard \
1571 refuses the reserved ranges it missed.",
1572 },
1573 Release {
1574 version: "0.4.3",
1575 date: "2026-10-05",
1576 summary: "Two write-path fixes for any PDS: large OPML imports and \
1577 read-state syncs are sent in calls the PDS accepts, and a \
1578 read-state sync that disagreed with the PDS recovers instead \
1579 of failing every round.",
1580 },
1581 Release {
1582 version: "0.4.2",
1583 date: "2026-10-04",
1584 summary: "A public standard.site feature page with this list of recent \
1585 releases, and link cards: a posted feather-reader.com link \
1586 now unfurls with a description and an image.",
1587 },
1588 Release {
1589 version: "0.4.1",
1590 date: "2026-10-04",
1591 summary: "The public pages explain standard.site publications, and the \
1592 subscribe form can submit the DID form of a publication URI, \
1593 which browsers refused in 0.4.0.",
1594 },
1595 Release {
1596 version: "0.4.0",
1597 date: "2026-10-03",
1598 summary: "standard.site support: publications are read from their \
1599 authors' atproto repos as subscriptions, beside RSS, on their \
1600 own polling loop. Every stored field from a feed or a \
1601 publication now has a size bound.",
1602 },
1603];
1604
1605#[derive(Template)]
1617#[template(path = "stats.html")]
1618struct StatsTemplate {
1619 card: Card,
1621 version: &'static str,
1622 repo_url: &'static str,
1623 kofi_url: &'static str,
1624 feeds_tracked: i64,
1625 polled_last_hour: i64,
1626 polled_pct: i64,
1627 overdue: i64,
1628 last_poll: String,
1629 oldest_poll: String,
1630 never_polled: i64,
1631 poll_interval_mins: i64,
1632 in_backoff: i64,
1634 badly_broken: i64,
1638 failure_kinds: Vec<(String, i64)>,
1640 fetching: &'static str,
1644}
1645
1646#[derive(Template)]
1650#[template(path = "privacy.html")]
1651struct PrivacyTemplate {
1652 card: Card,
1654 version: &'static str,
1655 repo_url: &'static str,
1656 kofi_url: &'static str,
1657}
1658
1659#[derive(Template)]
1662#[template(path = "terms.html")]
1663struct TermsTemplate {
1664 card: Card,
1666 version: &'static str,
1667 repo_url: &'static str,
1668 kofi_url: &'static str,
1669}
1670
1671#[derive(Template)]
1674#[template(path = "landing.html")]
1675struct LandingTemplate {
1676 card: Card,
1678 version: &'static str,
1679 repo_url: &'static str,
1680 crates_url: &'static str,
1681 kofi_url: &'static str,
1682 standard_site: bool,
1685 releases: &'static [Release],
1687}
1688
1689#[derive(Template)]
1691#[template(path = "entry.html")]
1692struct EntryTemplate {
1693 card: Card,
1695 version: &'static str,
1696 repo_url: &'static str,
1697 kofi_url: &'static str,
1698 nav: Nav,
1699 id: i64,
1700 title: String,
1701 feed_title: String,
1702 author: Option<String>,
1703 published: String,
1704 url: Option<SafeLink>,
1714 content_html: Option<String>,
1715 read: bool,
1716 starred: bool,
1717 back_qs: String,
1719 prev_id: Option<i64>,
1721 next_id: Option<i64>,
1722 oob: bool,
1724}
1725
1726#[derive(Template)]
1728#[template(path = "entry_row.html")]
1729struct EntryRowTemplate {
1730 e: EntryRow,
1731}
1732
1733#[derive(Template)]
1738#[template(path = "entry_actionbar.html")]
1739struct EntryActionBarTemplate {
1740 id: i64,
1741 read: bool,
1742 starred: bool,
1743 oob: bool,
1745}
1746
1747#[derive(Template)]
1749#[template(path = "login.html")]
1750struct LoginTemplate {
1751 card: Card,
1753 repo_url: &'static str,
1754 error: String,
1755 flash: String,
1758}
1759
1760#[derive(Template)]
1762#[template(path = "beta_redeem.html")]
1763struct BetaRedeemTemplate {
1764 card: Card,
1766 repo_url: &'static str,
1767 error: String,
1768 capacity_full: bool,
1771}
1772
1773fn render<T: Template>(tmpl: &T) -> Response {
1780 match tmpl.render() {
1781 Ok(body) => Html(body).into_response(),
1782 Err(err) => {
1783 warn!(%err, "template render failed");
1784 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1785 }
1786 }
1787}
1788
1789struct WebError {
1794 err: anyhow::Error,
1795 status: StatusCode,
1796}
1797
1798impl<E: Into<anyhow::Error>> From<E> for WebError {
1799 fn from(err: E) -> Self {
1800 WebError {
1801 err: err.into(),
1802 status: StatusCode::INTERNAL_SERVER_ERROR,
1803 }
1804 }
1805}
1806
1807impl WebError {
1808 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1810 WebError {
1811 err: err.into(),
1812 status,
1813 }
1814 }
1815}
1816
1817impl IntoResponse for WebError {
1818 fn into_response(self) -> Response {
1819 warn!(error = %self.err, status = %self.status, "request failed");
1820 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1821 "internal error"
1822 } else {
1823 self.status.canonical_reason().unwrap_or("error")
1824 };
1825 (self.status, body).into_response()
1826 }
1827}
1828
1829fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1834 let status = err.status();
1835 WebError::with_status(err, status)
1836}
1837
1838fn display_title(title: Option<&str>, url: &str) -> String {
1841 if let Some(t) = title {
1842 let t = t.trim();
1843 if !t.is_empty() {
1844 return t.to_string();
1845 }
1846 }
1847 url::Url::parse(url)
1848 .ok()
1849 .and_then(|u| u.host_str().map(str::to_string))
1850 .unwrap_or_else(|| url.to_string())
1851}
1852
1853fn display_handle(handle: Option<&str>, did: &str) -> String {
1856 match handle {
1857 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1858 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1859 }
1860}
1861
1862fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1864 let source = handle
1865 .map(|h| h.trim().trim_start_matches('@'))
1866 .filter(|h| !h.is_empty())
1867 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1868 let letters: String = source
1869 .chars()
1870 .filter(|c| c.is_alphanumeric())
1871 .take(2)
1872 .collect::<String>()
1873 .to_lowercase();
1874 if letters.is_empty() {
1875 "fr".to_string()
1876 } else {
1877 letters
1878 }
1879}
1880
1881fn display_date(published: Option<&str>) -> String {
1884 match published {
1893 Some(p) => p.chars().take(10).collect(),
1894 None => String::new(),
1895 }
1896}
1897
1898fn qenc(s: &str) -> String {
1902 let mut out = String::with_capacity(s.len() * 3);
1903 for b in s.bytes() {
1904 match b {
1905 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1906 out.push(b as char)
1907 }
1908 _ => out.push_str(&format!("%{b:02X}")),
1909 }
1910 }
1911 out
1912}
1913
1914#[derive(Debug, Deserialize, Default)]
1920struct IndexQuery {
1921 #[serde(default)]
1923 feed: Option<String>,
1924 #[serde(default)]
1926 folder: Option<String>,
1927 #[serde(default)]
1929 view: Option<String>,
1930 #[serde(default)]
1932 page: Option<u32>,
1933 #[serde(default)]
1935 flash: Option<String>,
1936}
1937
1938const ENTRIES_PER_PAGE: i64 = 100;
1946
1947fn page_count_for(total: i64) -> i64 {
1950 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1951}
1952
1953const PREV_NEXT_MAX: i64 = 5_000;
1960
1961const STARRED_IDENTITY_MAX: i64 = 20_000;
1969
1970const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1984
1985struct ResolvedSub {
1988 rkey: String,
1989 sub: Subscription,
1990 feed: Option<store::Feed>,
1991}
1992
1993async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1997 resolve_subscriptions_noting(state, did).await.0
1998}
1999
2000fn subscriptions_alert(err: &anyhow::Error) -> String {
2007 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2008 Some(m) => format!(
2009 "{} record(s) in your subscription list could not be read, so it was not \
2010 refreshed. Showing your last-known subscriptions; nothing was removed.",
2011 m.count
2012 ),
2013 None => "Your subscription list could not be read from your PDS just now. \
2014 Showing your last-known subscriptions."
2015 .to_string(),
2016 }
2017}
2018
2019async fn resolve_subscriptions_noting(
2022 state: &AppState,
2023 did: &str,
2024) -> (Vec<ResolvedSub>, Option<String>) {
2025 let pool = &state.db;
2026 let subs = match state.repo().list_subscriptions_sorted(did).await {
2027 Ok(s) => s,
2028 Err(err) => {
2029 let alert = subscriptions_alert(&err);
2030 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2031 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2044 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2045 projection could not be read; rendering an EMPTY \
2046 feed list, which is not the same as having none");
2047 Vec::new()
2048 });
2049 let cached = feeds
2050 .into_iter()
2051 .map(|f| ResolvedSub {
2052 rkey: String::new(),
2053 sub: Subscription::new(f.url.clone(), now_rfc3339()),
2054 feed: Some(f),
2055 })
2056 .collect();
2057 return (cached, Some(alert));
2058 }
2059 };
2060
2061 let mut out = Vec::with_capacity(subs.len());
2077 for (rkey, sub) in subs {
2078 let feed = match store::get_feed_by_url(pool, &sub.url).await {
2079 Ok(Some(f)) => Some(f),
2080 Ok(None) => {
2081 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2092 || feed::classify_feed_privacy(&sub.url).is_private()
2093 {
2094 warn!(
2095 %did,
2096 "skipping cache row for a subscription URL that is private or not http(s)"
2097 );
2098 out.push(ResolvedSub {
2099 rkey,
2100 sub,
2101 feed: None,
2102 });
2103 continue;
2104 }
2105 if let Err(err) = store::upsert_feed(
2113 pool,
2114 &store::NewFeed {
2115 url: sub.url.clone(),
2116 title: sub.title.clone(),
2117 site_url: sub.site_url.clone(),
2118 ..Default::default()
2119 },
2120 )
2121 .await
2122 {
2123 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2124 it will not be polled");
2125 }
2126 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2127 }
2128 Err(err) => {
2129 warn!(%err, url = %sub.url, "get_feed_by_url failed");
2130 None
2131 }
2132 };
2133 out.push(ResolvedSub { rkey, sub, feed });
2134 }
2135 sync_sub_refs(pool, did, &out).await;
2139 (out, None)
2140}
2141
2142async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2146 let feed_ids: Vec<i64> = subs
2147 .iter()
2148 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2149 .collect();
2150 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2151 warn!(%err, %did, "failed to sync sub_ref projection");
2152 }
2153}
2154
2155async fn index(
2158 State(state): State<AppState>,
2159 headers: HeaderMap,
2160 Query(q): Query<IndexQuery>,
2161) -> Result<Response, WebError> {
2162 let user = match current_session(&state, &headers).await {
2163 Some(u) => u,
2164 None => {
2167 return Ok(render(&LandingTemplate {
2168 card: Card::site(&state.config),
2169 version: VERSION,
2170 repo_url: REPO_URL,
2171 crates_url: CRATES_URL,
2172 kofi_url: KOFI_URL,
2173 standard_site: state.config.standard_site,
2174 releases: RELEASES,
2175 }))
2176 }
2177 };
2178 let did = user.did.clone();
2179 let pool = &state.db;
2180
2181 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2182
2183 let view = match q.view.as_deref() {
2185 Some("all") => "all",
2186 Some("starred") => "starred",
2187 _ => "unread",
2188 }
2189 .to_string();
2190 let list_view = list_view_of(q.view.as_deref());
2191
2192 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2194 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2199
2200 let feed_title_by_id = |id: i64| -> String {
2201 subs.iter()
2202 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2203 .map(|s| {
2204 display_title(
2205 s.sub
2206 .title
2207 .as_deref()
2208 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2209 &s.sub.url,
2210 )
2211 })
2212 .unwrap_or_default()
2213 };
2214
2215 let mut uncached: Vec<EntryRow> = Vec::new();
2228 if view == "starred" {
2229 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2258 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2259 Ok(store::StarredIdentities::Truncated) => {
2264 warn!(
2265 %did,
2266 cap = STARRED_IDENTITY_MAX,
2267 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2268 rather than rendering record-deleting buttons for cached articles"
2269 );
2270 None
2271 }
2272 Err(err) => {
2273 warn!(%err, %did, "cached-starred identity lookup failed; \
2274 suppressing uncached saved rows this render");
2275 None
2276 }
2277 };
2278 let identities_ok = identities.is_some();
2285 let identities = identities.unwrap_or_default();
2286 let cached_urls: std::collections::HashSet<&str> = identities
2287 .iter()
2288 .filter_map(|(url, _)| url.as_deref())
2289 .collect();
2290 let cached_guids: std::collections::HashSet<&str> =
2291 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2292
2293 let mut uncached_dropped = 0usize;
2306 match state.repo().list_saved_sorted(&did).await {
2307 Ok(saved) if identities_ok => {
2308 for (rkey, item) in saved {
2309 let known = cached_urls.contains(item.url.as_str())
2310 || item
2311 .entry_id
2312 .as_deref()
2313 .is_some_and(|g| cached_guids.contains(g));
2314 if known {
2315 continue;
2316 }
2317 if let Some(urls) = &scope_urls {
2321 match item.feed_url.as_deref() {
2322 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2323 _ => continue,
2327 }
2328 }
2329 let link = SafeLink::external(&item.url);
2355 if link.is_empty() {
2356 warn!(
2357 %did, %rkey,
2358 "a saved record has an unusable URL; rendering it without a link \
2359 so it can still be removed"
2360 );
2361 }
2362
2363 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2377 uncached_dropped += 1;
2378 continue;
2379 }
2380 if let Some(feed_url) = item.feed_url.as_deref() {
2381 if subs.iter().any(|s| s.sub.url == feed_url) {
2382 let stale_before = (chrono::Utc::now()
2386 - chrono::Duration::from_std(state.config.poll_interval)
2387 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2388 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2389 if let Err(err) =
2390 store::mark_feed_due(pool, feed_url, &stale_before).await
2391 {
2392 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2393 }
2394 }
2395 }
2396 uncached.push(EntryRow {
2397 id: 0,
2398 title: item
2399 .title
2400 .clone()
2401 .filter(|t| !t.trim().is_empty())
2402 .unwrap_or_else(|| {
2410 if link.is_empty() {
2411 format!("Saved item {rkey}")
2412 } else {
2413 item.url.clone()
2414 }
2415 }),
2416 feed_title: item.feed_url.clone().unwrap_or_default(),
2417 published: display_date(Some(&item.created_at)),
2418 read: false,
2419 starred: true,
2420 link,
2424 cached: false,
2425 rkey,
2426 });
2427 }
2428 }
2429 Ok(_) => {}
2431 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2432 }
2433 if uncached_dropped > 0 {
2434 warn!(
2435 %did,
2436 dropped = uncached_dropped,
2437 cap = MAX_UNCACHED_SAVED_ROWS,
2438 "more saved records than this instance will hold in one response; the \
2439 rest are not reachable from here"
2440 );
2441 }
2442 }
2443
2444 let total_cached =
2460 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2461 let uncached_len = uncached.len();
2462 let total = total_cached + uncached_len as i64;
2463 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2468 let offset = (page - 1) * ENTRIES_PER_PAGE;
2469 let source = store::list_entries(
2472 pool,
2473 &did,
2474 list_view,
2475 scope_ids.as_deref(),
2476 ENTRIES_PER_PAGE,
2477 offset,
2478 )
2479 .await?;
2480 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2500 let cached_here = cached_allotment.min(source.len());
2501 let source = if uncached_len == 0 {
2506 &source[..]
2507 } else {
2508 &source[..cached_here]
2509 };
2510 let uncached_page: Vec<EntryRow> = {
2511 let skip = (offset - total_cached).max(0) as usize;
2512 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2513 uncached.into_iter().skip(skip).take(take).collect()
2514 };
2515 let uncached_total = uncached_len as i64;
2518
2519 let entry_scope_qs = {
2521 let mut parts = Vec::new();
2522 if let Some(f) = q.feed.as_deref() {
2523 parts.push(format!("feed={}", qenc(f)));
2524 }
2525 if let Some(f) = q.folder.as_deref() {
2526 parts.push(format!("folder={}", qenc(f)));
2527 }
2528 if view != "unread" {
2529 parts.push(format!("view={}", qenc(&view)));
2530 }
2531 parts.join("&")
2532 };
2533 let entries: Vec<EntryRow> = source
2534 .iter()
2535 .map(|e| EntryRow {
2536 id: e.id,
2537 title: e
2538 .title
2539 .clone()
2540 .filter(|t| !t.trim().is_empty())
2541 .unwrap_or_else(|| "(untitled)".to_string()),
2542 feed_title: feed_title_by_id(e.feed_id),
2543 published: display_date(e.published.as_deref()),
2544 read: e.read,
2549 starred: e.starred,
2550 link: SafeLink::entry(e.id, &entry_scope_qs),
2551 cached: true,
2552 rkey: String::new(),
2553 })
2554 .collect();
2555
2556 let mut entries = entries;
2558 entries.extend(uncached_page);
2559 let entries = entries;
2560
2561 let selected_feed = q.feed.as_deref();
2562 let selected_folder = q.folder.as_deref();
2563
2564 let (folder_views, loose_feeds, _folder_options) =
2566 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2567
2568 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2570 let name = subs
2571 .iter()
2572 .find(|s| s.sub.url == feed_url)
2573 .map(|s| {
2574 display_title(
2575 s.sub
2576 .title
2577 .as_deref()
2578 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2579 &s.sub.url,
2580 )
2581 })
2582 .unwrap_or_else(|| display_title(None, feed_url));
2583 (name, format!("feed={}", qenc(feed_url)))
2584 } else if let Some(folder_uri) = selected_folder {
2585 let name = folder_views
2586 .iter()
2587 .find(|f| f.uri == folder_uri)
2588 .map(|f| f.name.clone())
2589 .unwrap_or_else(|| "Folder".to_string());
2590 (name, format!("folder={}", qenc(folder_uri)))
2591 } else {
2592 let h = match view.as_str() {
2593 "all" => "All",
2594 "starred" => "Starred",
2595 _ => "Unread",
2596 };
2597 (h.to_string(), String::new())
2598 };
2599
2600 let feed_scope = selected_feed.map(str::to_string);
2601 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2602
2603 let page_href = |n: i64| -> String {
2607 let mut parts = Vec::new();
2608 if !entry_scope_qs.is_empty() {
2609 parts.push(entry_scope_qs.clone());
2610 }
2611 if n > 1 {
2612 parts.push(format!("page={n}"));
2613 }
2614 if parts.is_empty() {
2615 "/".to_string()
2616 } else {
2617 format!("/?{}", parts.join("&"))
2618 }
2619 };
2620 let prev_href = (page > 1).then(|| page_href(page - 1));
2621 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2622
2623 let tmpl = IndexTemplate {
2624 card: Card::private(&state.config),
2625 version: VERSION,
2626 repo_url: REPO_URL,
2627 kofi_url: KOFI_URL,
2628 flash: q.flash.unwrap_or_default(),
2629 alert: alert.unwrap_or_default(),
2630 nav,
2631 entries,
2632 heading,
2633 feed_scope,
2634 total,
2635 uncached_total,
2638 page,
2639 page_count: page_count_for(total),
2640 prev_href,
2641 next_href,
2642 };
2643 Ok(render(&tmpl))
2644}
2645
2646#[derive(Debug, Deserialize, Default)]
2648struct ManageQuery {
2649 #[serde(default)]
2650 flash: Option<String>,
2651}
2652
2653async fn manage(
2658 State(state): State<AppState>,
2659 headers: HeaderMap,
2660 Query(q): Query<ManageQuery>,
2661) -> Result<Response, WebError> {
2662 let user = match current_session(&state, &headers).await {
2663 Some(u) => u,
2664 None => return Ok(Redirect::to("/login").into_response()),
2665 };
2666 let did = user.did.clone();
2667
2668 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2669 let (folder_views, loose_feeds, folder_options) =
2670 build_sidebar(&state, &did, &subs, None, None).await;
2671
2672 let nav = build_nav(
2674 &user,
2675 "unread",
2676 String::new(),
2677 folder_views.iter().map(clone_folder_view).collect(),
2678 loose_feeds.iter().map(clone_feed_view).collect(),
2679 true,
2680 );
2681
2682 let tmpl = ManageTemplate {
2683 card: Card::private(&state.config),
2684 version: VERSION,
2685 repo_url: REPO_URL,
2686 kofi_url: KOFI_URL,
2687 flash: q.flash.unwrap_or_default(),
2688 alert: alert.unwrap_or_default(),
2689 nav,
2690 folder_options,
2691 folders: folder_views,
2692 loose_feeds,
2693 standard_site: state.config.standard_site,
2694 };
2695 Ok(render(&tmpl))
2696}
2697
2698fn clone_feed_view(f: &FeedView) -> FeedView {
2701 FeedView {
2702 rkey: f.rkey.clone(),
2703 url: f.url.clone(),
2704 title: f.title.clone(),
2705 unread: f.unread,
2706 selected: f.selected,
2707 folder: f.folder.clone(),
2708 }
2709}
2710
2711fn clone_folder_view(f: &FolderView) -> FolderView {
2712 FolderView {
2713 rkey: f.rkey.clone(),
2714 uri: f.uri.clone(),
2715 name: f.name.clone(),
2716 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2717 selected: f.selected,
2718 }
2719}
2720
2721fn scope_urls_for(
2726 subs: &[ResolvedSub],
2727 feed: Option<&str>,
2728 folder: Option<&str>,
2729) -> Option<Vec<String>> {
2730 if let Some(feed_url) = feed {
2731 Some(vec![feed_url.to_string()])
2732 } else {
2733 folder.map(|folder_uri| {
2734 subs.iter()
2735 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2736 .map(|s| s.sub.url.clone())
2737 .collect()
2738 })
2739 }
2740}
2741
2742fn folder_uri(did: &str, rkey: &str) -> String {
2744 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2745}
2746
2747async fn build_sidebar(
2751 state: &AppState,
2752 did: &str,
2753 subs: &[ResolvedSub],
2754 selected_feed: Option<&str>,
2755 selected_folder: Option<&str>,
2756) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2757 let pool = &state.db;
2758 let unread_counts = store::unread_counts_by_feed(pool, did)
2763 .await
2764 .unwrap_or_else(|err| {
2765 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2766 Default::default()
2767 });
2768 let folders = state
2769 .repo()
2770 .list_folders_sorted(did)
2771 .await
2772 .unwrap_or_default();
2773
2774 let unread_count = |feed_id: Option<i64>| -> i64 {
2775 feed_id
2776 .and_then(|id| unread_counts.get(&id).copied())
2777 .unwrap_or(0)
2778 };
2779 let mk_feed_view = |s: &ResolvedSub| FeedView {
2780 rkey: s.rkey.clone(),
2781 url: s.sub.url.clone(),
2782 title: display_title(
2783 s.sub
2784 .title
2785 .as_deref()
2786 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2787 &s.sub.url,
2788 ),
2789 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2790 selected: selected_feed == Some(s.sub.url.as_str()),
2791 folder: s.sub.folder.clone(),
2792 };
2793
2794 let mut folder_views = Vec::with_capacity(folders.len());
2795 for (rkey, folder) in &folders {
2796 let uri = folder_uri(did, rkey);
2797 let feeds: Vec<FeedView> = subs
2798 .iter()
2799 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2800 .map(mk_feed_view)
2801 .collect();
2802 folder_views.push(FolderView {
2803 rkey: rkey.clone(),
2804 uri: uri.clone(),
2805 name: folder.name.clone(),
2806 feeds,
2807 selected: selected_folder == Some(uri.as_str()),
2808 });
2809 }
2810
2811 let known_uris: std::collections::HashSet<String> =
2812 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2813 let loose_feeds: Vec<FeedView> = subs
2814 .iter()
2815 .filter(|s| {
2816 s.sub
2817 .folder
2818 .as_deref()
2819 .map(|f| !known_uris.contains(f))
2820 .unwrap_or(true)
2821 })
2822 .map(mk_feed_view)
2823 .collect();
2824
2825 let folder_options: Vec<FolderOption> = folders
2826 .iter()
2827 .map(|(rkey, folder)| FolderOption {
2828 name: folder.name.clone(),
2829 uri: folder_uri(did, rkey),
2830 })
2831 .collect();
2832
2833 (folder_views, loose_feeds, folder_options)
2834}
2835
2836fn build_nav(
2838 user: &CurrentUser,
2839 view: &str,
2840 scope_qs: String,
2841 folders: Vec<FolderView>,
2842 loose_feeds: Vec<FeedView>,
2843 manage_active: bool,
2844) -> Nav {
2845 Nav {
2846 handle: display_handle(user.handle.as_deref(), &user.did),
2847 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2848 view: view.to_string(),
2849 scope_qs,
2850 folders,
2851 loose_feeds,
2852 manage_active,
2853 }
2854}
2855
2856#[derive(Debug, Deserialize, Default)]
2863struct EntryQuery {
2864 #[serde(default)]
2865 feed: Option<String>,
2866 #[serde(default)]
2867 folder: Option<String>,
2868 #[serde(default)]
2869 view: Option<String>,
2870}
2871
2872async fn entry_view(
2875 State(state): State<AppState>,
2876 headers: HeaderMap,
2877 Path(id): Path<i64>,
2878 Query(q): Query<EntryQuery>,
2879) -> Result<Response, WebError> {
2880 let user = match current_session(&state, &headers).await {
2881 Some(u) => u,
2882 None => return Ok(Redirect::to("/login").into_response()),
2883 };
2884 let did = user.did.clone();
2885 let pool = &state.db;
2886
2887 let subs = resolve_subscriptions(&state, &did).await;
2891
2892 let entry = match get_entry_by_id(pool, &did, id).await? {
2893 Some(e) => e,
2894 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2895 };
2896
2897 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2898
2899 let read = entry_is_read(pool, &did, id).await?;
2900 let starred = entry_is_starred(pool, &did, id).await?;
2901
2902 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2905
2906 let back_qs = scope_query(&q);
2907
2908 let (folder_views, loose_feeds, _) =
2909 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2910 let nav_view = match q.view.as_deref() {
2911 Some("all") => "all",
2912 Some("starred") => "starred",
2913 _ => "unread",
2914 };
2915 let nav = build_nav(
2916 &user,
2917 nav_view,
2918 back_qs.clone(),
2919 folder_views,
2920 loose_feeds,
2921 false,
2922 );
2923
2924 let tmpl = EntryTemplate {
2925 card: Card::private(&state.config),
2926 version: VERSION,
2927 repo_url: REPO_URL,
2928 kofi_url: KOFI_URL,
2929 nav,
2930 id: entry.id,
2931 title: entry
2932 .title
2933 .clone()
2934 .filter(|t| !t.trim().is_empty())
2935 .unwrap_or_else(|| "(untitled)".to_string()),
2936 feed_title,
2937 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2938 published: display_date(entry.published.as_deref()),
2939 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2940 content_html: entry.content_html.clone(),
2941 read,
2942 starred,
2943 back_qs,
2944 prev_id,
2945 next_id,
2946 oob: false,
2947 };
2948 Ok(render(&tmpl))
2949}
2950
2951async fn neighbors_in_scope(
2954 state: &AppState,
2955 did: &str,
2956 q: &EntryQuery,
2957 current: i64,
2958) -> (Option<i64>, Option<i64>) {
2959 let idx_q = IndexQuery {
2960 feed: q.feed.clone(),
2961 folder: q.folder.clone(),
2962 view: q.view.clone(),
2963 page: None,
2965 flash: None,
2966 };
2967 let ids = list_entry_ids(state, did, &idx_q).await;
2968 let pos = ids.iter().position(|&x| x == current);
2969 match pos {
2970 Some(p) => {
2971 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2972 let next = ids.get(p + 1).copied();
2973 (prev, next)
2974 }
2975 None => (None, None),
2976 }
2977}
2978
2979async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2982 let pool = &state.db;
2983 let subs = resolve_subscriptions(state, did).await;
2984
2985 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2986
2987 store::list_entry_ids(
2993 pool,
2994 did,
2995 list_view_of(q.view.as_deref()),
2996 scoped_feed_ids(&subs, &scope_urls).as_deref(),
2997 PREV_NEXT_MAX,
2998 )
2999 .await
3000 .unwrap_or_else(|err| {
3001 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3002 Vec::new()
3003 })
3004}
3005
3006fn list_view_of(view: Option<&str>) -> store::ListView {
3009 match view {
3010 Some("all") => store::ListView::All,
3011 Some("starred") => store::ListView::Starred,
3012 _ => store::ListView::Unread,
3013 }
3014}
3015
3016fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3022 let urls = scope_urls.as_ref()?;
3023 Some(
3024 subs.iter()
3025 .filter(|s| urls.contains(&s.sub.url))
3026 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3027 .collect(),
3028 )
3029}
3030
3031fn scope_query(q: &EntryQuery) -> String {
3033 let mut parts = Vec::new();
3034 if let Some(f) = q.feed.as_deref() {
3035 parts.push(format!("feed={}", qenc(f)));
3036 }
3037 if let Some(f) = q.folder.as_deref() {
3038 parts.push(format!("folder={}", qenc(f)));
3039 }
3040 if let Some(v) = q.view.as_deref() {
3041 if v != "unread" {
3042 parts.push(format!("view={}", qenc(v)));
3043 }
3044 }
3045 parts.join("&")
3046}
3047
3048#[derive(Debug, Deserialize)]
3054struct ReadForm {
3055 #[serde(default)]
3056 read: Option<String>,
3057}
3058
3059async fn mark_read(
3061 State(state): State<AppState>,
3062 Path(id): Path<i64>,
3063 headers: HeaderMap,
3064 Form(form): Form<ReadForm>,
3065) -> Result<Response, WebError> {
3066 let did = match current_did(&state, &headers).await {
3067 Some(d) => d,
3068 None => return Ok(Redirect::to("/login").into_response()),
3069 };
3070 let pool = &state.db;
3071
3072 let read = matches!(
3073 form.read.as_deref(),
3074 Some("true") | Some("1") | Some("on") | None
3075 );
3076
3077 resolve_subscriptions(&state, &did).await;
3082 if !store::mark_read(pool, &did, id, read).await? {
3083 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3084 }
3085
3086 if !is_htmx(&headers) {
3087 return Ok(Redirect::to("/").into_response());
3088 }
3089
3090 if is_reader_request(&headers) {
3094 let starred = entry_is_starred(pool, &did, id).await?;
3095 return Ok(render(&EntryActionBarTemplate {
3096 id,
3097 read,
3098 starred,
3099 oob: true,
3100 }));
3101 }
3102
3103 let row = build_entry_row(pool, &did, id, Some(read)).await?;
3104 match row {
3105 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3106 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3107 }
3108}
3109
3110#[derive(Debug, Deserialize)]
3116struct StarForm {
3117 #[serde(default)]
3118 starred: Option<String>,
3119}
3120
3121async fn toggle_star(
3127 State(state): State<AppState>,
3128 Path(id): Path<i64>,
3129 headers: HeaderMap,
3130 Form(form): Form<StarForm>,
3131) -> Result<Response, WebError> {
3132 let did = match current_did(&state, &headers).await {
3133 Some(d) => d,
3134 None => return Ok(Redirect::to("/login").into_response()),
3135 };
3136 let pool = &state.db;
3137
3138 let starred = matches!(
3139 form.starred.as_deref(),
3140 Some("true") | Some("1") | Some("on") | None
3141 );
3142
3143 resolve_subscriptions(&state, &did).await;
3147 if !store::mark_starred(pool, &did, id, starred).await? {
3148 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3149 }
3150
3151 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3154 let entry_url = entry.url.clone().unwrap_or_default();
3155 if !entry_url.is_empty() {
3156 if starred {
3157 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3158 saved.title = entry.title.clone();
3159 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3160 saved.entry_id = Some(entry.guid.clone());
3161 match state.repo().add_saved(&did, &saved).await {
3162 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3163 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3164 }
3165 } else {
3166 match state.repo().list_saved(&did).await {
3168 Ok(records) => {
3169 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3170 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3171 warn!(%err, %did, %rkey, "PDS saved delete failed");
3172 }
3173 }
3174 }
3175 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3176 }
3177 }
3178 }
3179 }
3180
3181 if !is_htmx(&headers) {
3182 return Ok(Redirect::to("/").into_response());
3183 }
3184
3185 if is_reader_request(&headers) {
3187 let read = entry_is_read(pool, &did, id).await?;
3188 return Ok(render(&EntryActionBarTemplate {
3189 id,
3190 read,
3191 starred,
3192 oob: true,
3193 }));
3194 }
3195
3196 let row = build_entry_row(pool, &did, id, None).await?;
3197 match row {
3198 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3199 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3200 }
3201}
3202
3203async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3205 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3206 .bind(feed_id)
3207 .fetch_optional(pool)
3208 .await
3209 .ok()
3210 .flatten()
3211}
3212
3213#[derive(Debug, Deserialize, Default)]
3220struct ReadAllQuery {
3221 #[serde(default)]
3222 feed: Option<String>,
3223}
3224
3225async fn mark_all_read(
3228 State(state): State<AppState>,
3229 headers: HeaderMap,
3230 Query(q): Query<ReadAllQuery>,
3231) -> Result<Response, WebError> {
3232 let did = match current_did(&state, &headers).await {
3233 Some(d) => d,
3234 None => return Ok(Redirect::to("/login").into_response()),
3235 };
3236 let pool = &state.db;
3237
3238 resolve_subscriptions(&state, &did).await;
3241
3242 if let Some(feed_url) = q.feed.as_deref() {
3243 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3244 store::mark_feed_read(pool, &did, feed.id, true).await?;
3245 }
3246 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3247 }
3248
3249 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3254 store::mark_feed_read(pool, &did, feed_id, true).await?;
3255 }
3256 Ok(Redirect::to("/").into_response())
3257}
3258
3259const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3269 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3270
3271const EXPORT_INCOMPLETE_REFUSAL: &str =
3278 "Could not read your subscriptions in full, so nothing was exported. Your \
3279 feeds are unchanged — try again, and if it keeps failing the list may be \
3280 larger than this reader can page through.";
3281
3282const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3288 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3289 feeds for now — private-feed support arrives when atproto's private data \
3290 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3291
3292#[derive(Debug, Deserialize)]
3294struct SubscribeForm {
3295 url: String,
3296 #[serde(default)]
3298 folder: Option<String>,
3299}
3300
3301async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3311 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3312 let canonical = format!(
3313 "{}{}",
3314 crate::atproto::AT_URI_PREFIX,
3315 &input[crate::atproto::AT_URI_PREFIX.len()..]
3316 );
3317 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3318 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3319 return Err(unsupported());
3320 }
3321 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3322 uri.authority.clone()
3323 } else {
3324 let handle =
3325 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3330 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3331 .await
3332 .map_err(|err| {
3333 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3334 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3335 })?
3336 };
3337 let url = format!(
3338 "{}{did}/{}/{}",
3339 crate::atproto::AT_URI_PREFIX,
3340 uri.collection,
3341 uri.rkey
3342 );
3343 if !feed::is_storable_feed_url(&url, true) {
3344 return Err(unsupported());
3345 }
3346 Ok(url)
3347}
3348
3349async fn add_subscription(
3351 State(state): State<AppState>,
3352 headers: HeaderMap,
3353 Form(form): Form<SubscribeForm>,
3354) -> Result<Response, WebError> {
3355 let did = match current_did(&state, &headers).await {
3356 Some(d) => d,
3357 None => return Ok(Redirect::to("/login").into_response()),
3358 };
3359 let pool = &state.db;
3360 let input = form.url.trim().to_string();
3361 if input.is_empty() {
3362 return Ok(Redirect::to("/").into_response());
3363 }
3364
3365 let cap = state.config.max_subs_per_did;
3369 if cap > 0 {
3370 match store::count_subscriptions_for_did(pool, &did).await {
3371 Ok(n) if n >= cap => {
3372 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3373 return Ok(Redirect::to(&format!(
3374 "/?flash={}",
3375 qenc(&format!(
3376 "Subscription limit reached ({cap}). Remove a feed before adding another."
3377 ))
3378 ))
3379 .into_response());
3380 }
3381 Ok(_) => {}
3382 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3383 }
3384 }
3385
3386 let is_at_uri = input
3391 .get(..crate::atproto::AT_URI_PREFIX.len())
3392 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3393 let publication_url = if is_at_uri {
3394 if !state.config.standard_site {
3395 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3396 return Ok(
3397 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3398 .into_response(),
3399 );
3400 }
3401 match publication_url_from_paste(&state, &input).await {
3402 Ok(url) => Some(url),
3403 Err(flash) => {
3404 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3405 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3406 }
3407 }
3408 } else {
3409 None
3410 };
3411
3412 if let feed::FeedPrivacy::Private(reason) =
3413 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3414 {
3415 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3416 return Ok(
3417 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3418 );
3419 }
3420
3421 let resolved = match publication_url {
3422 Some(url) => Ok(url),
3423 None => resolve_feed_url(&state.config, &input).await,
3424 };
3425 let feed_url = match resolved {
3426 Ok(u) => u,
3427 Err(err) => {
3428 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3429 return Ok(Redirect::to(&format!(
3430 "/?flash={}",
3431 qenc("Couldn't find a feed at that URL")
3432 ))
3433 .into_response());
3434 }
3435 };
3436
3437 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3441 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3442 return Ok(
3443 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3444 );
3445 }
3446
3447 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3452 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3453 return Ok(
3454 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3455 .into_response(),
3456 );
3457 }
3458
3459 let feeds_cap = state.config.max_feeds_global;
3463 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3464 match store::count_feeds(pool).await {
3465 Ok(n) if n >= feeds_cap => {
3466 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3467 return Ok(Redirect::to(&format!(
3468 "/?flash={}",
3469 qenc(
3470 "This instance is at its feed capacity right now. Please try again later."
3471 )
3472 ))
3473 .into_response());
3474 }
3475 Ok(_) => {}
3476 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3477 }
3478 }
3479
3480 store::upsert_feed(
3481 pool,
3482 &store::NewFeed {
3483 url: feed_url.clone(),
3484 ..Default::default()
3485 },
3486 )
3487 .await?;
3488
3489 if let Ok(client) = feed::build_client() {
3490 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3491 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3492 Ok(outcome) => {
3493 info!(feed = %feed_url, ?outcome, "polled new subscription");
3494 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3498 }
3499 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3500 }
3501 }
3502 }
3503
3504 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3505 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3506 sub.title = feed_row.title.clone();
3507 sub.site_url = feed_row.site_url.clone();
3508 }
3509 sub.folder = form
3510 .folder
3511 .map(|f| f.trim().to_string())
3512 .filter(|f| !f.is_empty());
3513
3514 match state.repo().add_subscription(&did, &sub).await {
3515 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3516 Err(err) => {
3517 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3518 }
3519 }
3520
3521 Ok(Redirect::to("/").into_response())
3522}
3523
3524async fn delete_subscription(
3526 State(state): State<AppState>,
3527 headers: HeaderMap,
3528 Path(rkey): Path<String>,
3529) -> Result<Response, WebError> {
3530 let did = match current_did(&state, &headers).await {
3531 Some(d) => d,
3532 None => return Ok(Redirect::to("/login").into_response()),
3533 };
3534 match state.repo().remove_subscription(&did, &rkey).await {
3535 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3536 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3537 }
3538 Ok(Redirect::to("/").into_response())
3539}
3540
3541#[derive(Debug, Deserialize)]
3543struct RenameSubForm {
3544 url: String,
3545 #[serde(default)]
3546 title: Option<String>,
3547 #[serde(default)]
3548 site_url: Option<String>,
3549 #[serde(default)]
3550 folder: Option<String>,
3551}
3552
3553async fn rename_subscription(
3556 State(state): State<AppState>,
3557 headers: HeaderMap,
3558 Path(rkey): Path<String>,
3559 Form(form): Form<RenameSubForm>,
3560) -> Result<Response, WebError> {
3561 let did = match current_did(&state, &headers).await {
3562 Some(d) => d,
3563 None => return Ok(Redirect::to("/login").into_response()),
3564 };
3565 let feed_url = form.url.trim().to_string();
3566
3567 if feed_url.is_empty() {
3571 return Ok(Redirect::to("/").into_response());
3572 }
3573
3574 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3599 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3600 Err(err) => {
3601 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3602 return Ok(Redirect::to(&format!(
3603 "/?flash={}",
3604 qenc("Could not reach your PDS — nothing was renamed or moved.")
3605 ))
3606 .into_response());
3607 }
3608 };
3609 let Some(existing) = existing else {
3610 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3614 return Ok(Redirect::to(&format!(
3615 "/?flash={}",
3616 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3617 ))
3618 .into_response());
3619 };
3620
3621 let url_changed = existing.url.trim() != feed_url;
3640
3641 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3654 if url_changed && !storable {
3655 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3656 return Ok(
3657 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3658 .into_response(),
3659 );
3660 }
3661
3662 if url_changed {
3668 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3669 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3670 return Ok(
3671 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3672 );
3673 }
3674 }
3675
3676 let feeds_cap = state.config.max_feeds_global;
3681 if url_changed
3682 && feeds_cap > 0
3683 && store::get_feed_by_url(&state.db, &feed_url)
3684 .await?
3685 .is_none()
3686 {
3687 match store::count_feeds(&state.db).await {
3688 Ok(n) if n >= feeds_cap => {
3689 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3690 return Ok(Redirect::to(&format!(
3691 "/?flash={}",
3692 qenc(
3693 "This instance is at its feed capacity right now. Please try again later."
3694 )
3695 ))
3696 .into_response());
3697 }
3698 Ok(_) => {}
3699 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3700 }
3701 }
3702
3703 let mut sub = existing;
3704 sub.url = feed_url;
3705 sub.title = form
3706 .title
3707 .map(|t| t.trim().to_string())
3708 .filter(|t| !t.is_empty());
3709 sub.folder = form
3710 .folder
3711 .map(|f| f.trim().to_string())
3712 .filter(|f| !f.is_empty());
3713 match form
3721 .site_url
3722 .map(|t| t.trim().to_string())
3723 .filter(|t| !t.is_empty())
3724 {
3725 Some(site) => sub.site_url = Some(site),
3726 None if url_changed => sub.site_url = None,
3727 None => {}
3728 }
3729 if url_changed {
3730 sub.fetch_hint = None;
3731 }
3732
3733 let cache_write =
3748 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3749 if !cache_write {
3750 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3751 } else if let Err(err) = store::upsert_feed(
3752 &state.db,
3753 &store::NewFeed {
3754 url: sub.url.clone(),
3755 title: sub.title.clone(),
3756 site_url: sub.site_url.clone(),
3757 ..Default::default()
3758 },
3759 )
3760 .await
3761 {
3762 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3765 }
3766
3767 match state.repo().update_subscription(&did, &rkey, &sub).await {
3775 Ok(res) => {
3776 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3777 Ok(Redirect::to("/").into_response())
3778 }
3779 Err(err) => {
3780 warn!(%err, %did, %rkey, "PDS subscription update failed");
3781 Ok(Redirect::to(&format!(
3782 "/?flash={}",
3783 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3784 ))
3785 .into_response())
3786 }
3787 }
3788}
3789
3790#[derive(Debug, Deserialize)]
3796struct FolderForm {
3797 name: String,
3798}
3799
3800async fn create_folder(
3802 State(state): State<AppState>,
3803 headers: HeaderMap,
3804 Form(form): Form<FolderForm>,
3805) -> Result<Response, WebError> {
3806 let did = match current_did(&state, &headers).await {
3807 Some(d) => d,
3808 None => return Ok(Redirect::to("/login").into_response()),
3809 };
3810 let name = form.name.trim();
3811 if name.is_empty() {
3812 return Ok(Redirect::to("/").into_response());
3813 }
3814 let folder = Folder::new(name.to_string(), now_rfc3339());
3815 match state.repo().add_folder(&did, &folder).await {
3816 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3817 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3818 }
3819 Ok(Redirect::to("/").into_response())
3820}
3821
3822async fn rename_folder(
3824 State(state): State<AppState>,
3825 headers: HeaderMap,
3826 Path(rkey): Path<String>,
3827 Form(form): Form<FolderForm>,
3828) -> Result<Response, WebError> {
3829 let did = match current_did(&state, &headers).await {
3830 Some(d) => d,
3831 None => return Ok(Redirect::to("/login").into_response()),
3832 };
3833 let name = form.name.trim();
3834 if name.is_empty() {
3835 return Ok(Redirect::to("/").into_response());
3836 }
3837 let folder = Folder::new(name.to_string(), now_rfc3339());
3838 match state.repo().rename_folder(&did, &rkey, &folder).await {
3839 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3840 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3841 }
3842 Ok(Redirect::to("/").into_response())
3843}
3844
3845async fn delete_folder(
3848 State(state): State<AppState>,
3849 headers: HeaderMap,
3850 Path(rkey): Path<String>,
3851) -> Result<Response, WebError> {
3852 let did = match current_did(&state, &headers).await {
3853 Some(d) => d,
3854 None => return Ok(Redirect::to("/login").into_response()),
3855 };
3856 match state.repo().remove_folder(&did, &rkey).await {
3857 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3858 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3859 }
3860 Ok(Redirect::to("/").into_response())
3861}
3862
3863async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3867 let parsed =
3868 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3869
3870 let client = feed::build_client()?;
3871 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3875 let final_url = resp.url().clone();
3876 let content_type = resp
3877 .headers()
3878 .get(axum::http::header::CONTENT_TYPE)
3879 .and_then(|v| v.to_str().ok())
3880 .unwrap_or("")
3881 .to_ascii_lowercase();
3882 let raw = crate::net::read_capped(resp).await?;
3885 let body = String::from_utf8_lossy(&raw).into_owned();
3886
3887 let looks_like_feed = content_type.contains("xml")
3888 || content_type.contains("rss")
3889 || content_type.contains("atom")
3890 || content_type.contains("application/feed+json")
3891 || {
3892 let head = body.trim_start();
3893 head.starts_with("<?xml")
3894 || head.starts_with("<rss")
3895 || head.starts_with("<feed")
3896 || head.contains("<rss")
3897 || head.contains("<feed")
3898 };
3899 if looks_like_feed {
3900 return Ok(final_url.to_string());
3901 }
3902
3903 match feed::discover_feed(&body, Some(&final_url)) {
3904 Some(u) => Ok(u.to_string()),
3905 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3906 }
3907}
3908
3909#[derive(Debug, Deserialize, Default)]
3915struct LoginQuery {
3916 #[serde(default)]
3917 handle: Option<String>,
3918 #[serde(default)]
3919 error: Option<String>,
3920 #[serde(default)]
3921 flash: Option<String>,
3922}
3923
3924async fn login_form(
3932 State(state): State<AppState>,
3933 headers: HeaderMap,
3934 Query(q): Query<LoginQuery>,
3935) -> Response {
3936 if let Some(handle) = q
3937 .handle
3938 .map(|h| h.trim().to_string())
3939 .filter(|h| !h.is_empty())
3940 {
3941 if !may_start_oauth(&state, &headers, &handle).await {
3942 return Redirect::to("/beta/redeem").into_response();
3943 }
3944 return start_oauth(&state, &handle).await;
3945 }
3946 render(&LoginTemplate {
3947 card: login_card(&state.config),
3948 repo_url: REPO_URL,
3949 error: q.error.unwrap_or_default(),
3950 flash: q.flash.unwrap_or_default(),
3951 })
3952}
3953
3954async fn login_submit(
3957 State(state): State<AppState>,
3958 headers: HeaderMap,
3959 Form(form): Form<LoginForm>,
3960) -> Response {
3961 let handle = form.handle.trim();
3962 if handle.is_empty() {
3963 return login_error(&state, "Enter your atproto handle.");
3964 }
3965 if !may_start_oauth(&state, &headers, handle).await {
3966 return Redirect::to("/beta/redeem").into_response();
3967 }
3968 start_oauth(&state, handle).await
3969}
3970
3971async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3989 may_start_oauth_with(state, headers, handle, |h| async move {
3993 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3994 .await
3995 .ok()
3996 })
3997 .await
3998}
3999
4000async fn may_start_oauth_with<F, Fut>(
4006 state: &AppState,
4007 headers: &HeaderMap,
4008 handle: &str,
4009 resolve: F,
4010) -> bool
4011where
4012 F: FnOnce(String) -> Fut,
4013 Fut: std::future::Future<Output = Option<String>>,
4014{
4015 if let Some(did) = current_did(state, headers).await {
4017 if store::has_beta_access(&state.db, &did)
4018 .await
4019 .unwrap_or(false)
4020 {
4021 return true;
4022 }
4023 }
4024 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4026 return true;
4027 }
4028 match resolve(handle.to_string()).await {
4032 Some(did) => store::has_beta_access(&state.db, &did)
4033 .await
4034 .unwrap_or(false),
4035 None => {
4036 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4037 false
4038 }
4039 }
4040}
4041
4042async fn start_oauth(state: &AppState, handle: &str) -> Response {
4064 match state.config.repo_backend {
4065 crate::metrics::Backend::Sidecar => {
4066 let url = state.sidecar.login_url(handle, None);
4067 info!(%handle, "redirecting to OAuth sidecar login");
4068 Redirect::to(&url).into_response()
4069 }
4070 crate::metrics::Backend::Rust => {
4071 let Some(runtime) = state.oauth.as_deref() else {
4072 warn!("the rust backend is live but its OAuth runtime is absent");
4073 return login_error(state, "Login is not available right now.");
4074 };
4075 match crate::oauth::login::start(
4076 runtime,
4077 &state.http,
4078 &state.db,
4079 handle,
4080 crate::store::now_unix(),
4081 )
4082 .await
4083 {
4084 Ok(started) => {
4085 info!(%handle, "pushed authorization request; redirecting to the PDS");
4086 let mut resp = Redirect::to(&started.authorize_url).into_response();
4087 set_cookie(
4088 &mut resp,
4089 &cookie::sign_value(
4090 OAUTH_BINDING_COOKIE,
4091 &started.binding_token,
4092 &state.config.cookie_secret,
4093 OAUTH_BINDING_MAX_AGE_SECS,
4094 ),
4095 );
4096 resp
4097 }
4098 Err(err) => {
4099 warn!(%err, %handle, "could not start the OAuth login");
4102 login_error(state, "Could not start login for that handle.")
4103 }
4104 }
4105 }
4106 }
4107}
4108
4109fn clear_binding_cookie(resp: &mut Response) {
4113 set_cookie(
4114 resp,
4115 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4116 );
4117}
4118
4119#[derive(Debug, Deserialize)]
4121struct LoginForm {
4122 handle: String,
4123}
4124
4125#[derive(Debug, Deserialize, Default)]
4134struct CallbackQuery {
4135 #[serde(default)]
4137 session_id: Option<String>,
4138 #[serde(default)]
4140 code: Option<String>,
4141 #[serde(default)]
4142 state: Option<String>,
4143 #[serde(default)]
4144 iss: Option<String>,
4145 #[serde(default)]
4148 response: Option<String>,
4149 #[serde(default)]
4150 error: Option<String>,
4151 #[serde(default)]
4152 error_description: Option<String>,
4153}
4154
4155async fn oauth_callback(
4162 State(state): State<AppState>,
4163 headers: HeaderMap,
4164 Query(q): Query<CallbackQuery>,
4165) -> Response {
4166 let sidecar_shape =
4196 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4197 let sidecar_handoff = sidecar_shape
4198 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4199 if let Some(err) = q.error.clone() {
4200 let slug = crate::oauth::flow::known_error_slug(&err);
4216 warn!(
4217 error = slug,
4218 desc_len = q.error_description.as_deref().map_or(0, str::len),
4219 "OAuth callback returned an error"
4220 );
4221 if sidecar_handoff || state.oauth.is_none() {
4222 return login_error(&state, &format!("Login failed: {slug}"));
4223 }
4224 }
4227
4228 let session = if sidecar_handoff {
4231 let session_id = q.session_id.clone().unwrap_or_default();
4232 match state.sidecar.resolve_session(&session_id).await {
4233 Ok(Some(s)) => s,
4234 Ok(None) => {
4235 warn!("OAuth callback session_id did not resolve (expired/unknown)");
4236 return login_error(&state, "Login session expired — please try again.");
4237 }
4238 Err(err) => {
4239 warn!(%err, "failed to resolve OAuth session via the sidecar");
4240 return login_error(&state, "Login failed talking to the auth service.");
4241 }
4242 }
4243 } else {
4244 let Some(runtime) = state.oauth.as_deref() else {
4245 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4246 return login_error(&state, "Login failed: this login could not be completed.");
4247 };
4248 let params = crate::oauth::flow::CallbackParams {
4249 code: q.code.clone(),
4250 state: q.state.clone(),
4251 iss: q.iss.clone(),
4252 error: q.error.clone(),
4256 error_description: q.error_description.clone(),
4257 response: q.response.clone(),
4258 };
4259 let binding =
4260 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4261 match crate::oauth::login::complete(
4262 runtime,
4263 &state.http,
4264 &state.db,
4265 ¶ms,
4266 binding.as_deref(),
4267 crate::store::now_unix(),
4268 )
4269 .await
4270 {
4271 Ok(done) => crate::atproto::SidecarSession {
4272 did: done.did,
4273 handle: done.handle,
4274 },
4275 Err(err) => {
4276 warn!(%err, "could not complete the OAuth callback");
4279 let mut resp = login_error(&state, "Login failed — please try again.");
4280 clear_binding_cookie(&mut resp);
4281 return resp;
4282 }
4283 }
4284 };
4285
4286 let mut clear_invite = false;
4289 if !store::has_beta_access(&state.db, &session.did)
4290 .await
4291 .unwrap_or(false)
4292 {
4293 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4295 Some(c) => c,
4296 None => {
4297 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4298 return Redirect::to("/beta/redeem").into_response();
4299 }
4300 };
4301 match store::redeem_code(
4302 &state.db,
4303 &code,
4304 &session.did,
4305 session.handle.as_deref(),
4306 state.config.beta_cap,
4307 )
4308 .await
4309 {
4310 Ok(Ok(())) => {
4311 clear_invite = true;
4312 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4313 }
4314 Ok(Err(policy)) => {
4315 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4316 let mut resp = redeem_bounce(&state, &policy).into_response();
4317 clear_invite_cookie(&mut resp);
4319 return resp;
4320 }
4321 Err(err) => {
4322 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4323 return login_error(&state, "Login failed while confirming your invite.");
4324 }
4325 }
4326 }
4327
4328 let sid = state.sessions.create(Session {
4331 did: session.did.clone(),
4332 handle: session.handle.clone(),
4333 });
4334 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4335 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4336
4337 let mut resp = Redirect::to("/").into_response();
4338 set_cookie(&mut resp, &cookie);
4339 clear_binding_cookie(&mut resp);
4340 if clear_invite {
4341 clear_invite_cookie(&mut resp);
4342 }
4343 resp
4344}
4345
4346const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4364
4365async fn flush_before_revoke(state: &AppState, did: &str) {
4378 match tokio::time::timeout(
4379 SIGN_OUT_FLUSH_BUDGET,
4380 crate::readstate::flush_did(state, did),
4381 )
4382 .await
4383 {
4384 Ok(Ok(())) => {}
4385 Ok(Err(err)) => {
4386 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4387 }
4388 Err(_) => warn!(
4389 %did,
4390 budget = ?SIGN_OUT_FLUSH_BUDGET,
4391 "sign-out: final read-state flush timed out; it will park until next sign-in"
4392 ),
4393 }
4394}
4395
4396async fn revoke_everywhere(state: &AppState, did: &str) {
4397 let sidecar_started = std::time::Instant::now();
4407 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4408 Ok(res) => {
4409 info!(%did, revoked = res.revoked, "sidecar session revoked");
4410 true
4411 }
4412 Err(err) => {
4413 warn!(%did, %err, "sidecar revoke failed; continuing");
4414 false
4415 }
4416 };
4417 state.metrics.record(
4418 crate::metrics::Backend::Sidecar,
4419 "oauth_revoke",
4420 sidecar_started.elapsed().as_micros() as u64,
4421 sidecar_ok,
4422 );
4423
4424 if let Some(runtime) = state.oauth.as_deref() {
4425 let revoke_started = std::time::Instant::now();
4426 let outcome = crate::oauth::revoke::sign_out_discovering(
4427 runtime,
4428 &state.http,
4429 &state.db,
4430 did,
4431 crate::store::now_unix(),
4432 )
4433 .await;
4434 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4445 state.metrics.record(
4446 crate::metrics::Backend::Rust,
4447 "oauth_revoke",
4448 revoke_started.elapsed().as_micros() as u64,
4449 revoke_ok,
4450 );
4451 match outcome {
4452 crate::oauth::revoke::Revocation::Revoked => {
4453 info!(%did, "rust OAuth session revoked at the PDS")
4454 }
4455 crate::oauth::revoke::Revocation::NoSession => {}
4456 crate::oauth::revoke::Revocation::Failed(reason) => {
4457 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4458 }
4459 }
4460 }
4461}
4462
4463async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4473 if let Some(user) = current_session(&state, &headers).await {
4474 if let Some(sid) = user.sid {
4477 state.sessions.remove(&sid);
4478 flush_before_revoke(&state, &user.did).await;
4480 revoke_everywhere(&state, &user.did).await;
4481 }
4482 }
4483 let mut resp = Redirect::to("/login").into_response();
4484 set_cookie(
4485 &mut resp,
4486 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4487 );
4488 resp
4489}
4490
4491#[derive(Debug, Deserialize)]
4494struct DeleteAccountForm {
4495 #[serde(default)]
4496 confirm: String,
4497}
4498
4499const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4501
4502async fn account_delete(
4517 State(state): State<AppState>,
4518 headers: HeaderMap,
4519 Form(form): Form<DeleteAccountForm>,
4520) -> Result<Response, WebError> {
4521 let user = match current_session(&state, &headers).await {
4522 Some(u) => u,
4523 None => return Ok(Redirect::to("/login").into_response()),
4524 };
4525 let did = user.did.clone();
4526
4527 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4529 return Ok(Redirect::to(&format!(
4530 "/manage?flash={}",
4531 qenc("Type DELETE to confirm — nothing was deleted.")
4532 ))
4533 .into_response());
4534 }
4535
4536 let counts = store::purge_did_data(&state.db, &did).await?;
4538 info!(
4539 %did,
4540 total = counts.total(),
4541 entry_state = counts.entry_state,
4542 read_cursor = counts.read_cursor,
4543 sub_ref = counts.sub_ref,
4544 beta_access = counts.beta_access,
4545 invite_codes = counts.invite_codes,
4546 "account/delete: local rows purged"
4547 );
4548
4549 revoke_everywhere(&state, &did).await;
4552
4553 if let Some(sid) = user.sid {
4555 state.sessions.remove(&sid);
4556 }
4557 let mut resp = Redirect::to(&format!(
4558 "/login?flash={}",
4559 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4560 ))
4561 .into_response();
4562 set_cookie(
4563 &mut resp,
4564 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4565 );
4566 Ok(resp)
4567}
4568
4569fn login_card(config: &Config) -> Card {
4571 Card::public(
4572 config,
4573 "/login",
4574 "Sign in — FeatherReader",
4575 "Sign in to FeatherReader with your atproto handle. You approve access on \
4576 your own server — no signup, no password.",
4577 )
4578}
4579
4580fn login_error(state: &AppState, msg: &str) -> Response {
4582 render(&LoginTemplate {
4583 card: login_card(&state.config),
4584 repo_url: REPO_URL,
4585 error: msg.to_string(),
4586 flash: String::new(),
4587 })
4588}
4589
4590#[derive(Debug, Deserialize)]
4596struct RedeemForm {
4597 code: String,
4598}
4599
4600async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4603 let full = store::count_beta_access(&state.db)
4604 .await
4605 .map(|n| n >= state.config.beta_cap)
4606 .unwrap_or(false);
4607 render(&BetaRedeemTemplate {
4608 card: redeem_card(&state.config),
4609 repo_url: REPO_URL,
4610 error: String::new(),
4611 capacity_full: full,
4612 })
4613}
4614
4615async fn beta_redeem_submit(
4625 State(state): State<AppState>,
4626 Form(form): Form<RedeemForm>,
4627) -> Response {
4628 let code = form.code.trim().to_uppercase();
4629 if code.is_empty() {
4630 return render(&BetaRedeemTemplate {
4631 card: redeem_card(&state.config),
4632 repo_url: REPO_URL,
4633 error: "Enter your invite code.".to_string(),
4634 capacity_full: false,
4635 });
4636 }
4637
4638 match preflight_code(&state, &code).await {
4639 Ok(()) => {
4640 let cookie = sign_invite(&code, &state.config.cookie_secret);
4641 let mut resp = Redirect::to("/login").into_response();
4642 set_cookie(&mut resp, &cookie);
4643 info!("invite code preflight OK; reserving intent + redirecting to /login");
4644 resp
4645 }
4646 Err(policy) => {
4647 warn!(?policy, "invite code preflight rejected");
4648 redeem_bounce(&state, &policy)
4649 }
4650 }
4651}
4652
4653async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4659 let count = match store::count_beta_access(&state.db).await {
4667 Ok(n) => n,
4668 Err(err) => {
4669 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4670 return Err(store::RedeemError::CapacityFull);
4671 }
4672 };
4673 if count >= state.config.beta_cap {
4674 return Err(store::RedeemError::CapacityFull);
4675 }
4676 let row = sqlx::query_as::<_, (String, i64)>(
4678 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4679 )
4680 .bind(code)
4681 .fetch_optional(&state.db)
4682 .await
4683 .ok()
4684 .flatten();
4685 let (status, expires_at) = match row {
4686 Some(r) => r,
4687 None => return Err(store::RedeemError::NotFound),
4688 };
4689 let now = chrono::Utc::now().timestamp();
4690 match status.as_str() {
4691 "active" if expires_at >= now => Ok(()),
4692 "active" => Err(store::RedeemError::Expired),
4693 "expired" => Err(store::RedeemError::Expired),
4694 _ => Err(store::RedeemError::AlreadyRedeemed),
4696 }
4697}
4698
4699fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4702 use store::RedeemError::*;
4703 let (msg, capacity_full) = match policy {
4704 NotFound => ("That invite code isn't valid.", false),
4705 Expired => ("That invite code has expired.", false),
4706 AlreadyRedeemed => ("That invite code has already been used.", false),
4707 CapacityFull => ("", true),
4708 };
4709 render(&BetaRedeemTemplate {
4710 card: redeem_card(&state.config),
4711 repo_url: REPO_URL,
4712 error: msg.to_string(),
4713 capacity_full,
4714 })
4715}
4716
4717fn redeem_card(config: &Config) -> Card {
4720 Card::public(
4721 config,
4722 "/beta/redeem",
4723 "Redeem an invite — FeatherReader",
4724 "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4725 in with your atproto handle.",
4726 )
4727}
4728
4729#[derive(Debug, Deserialize, Default)]
4731struct MintQuery {
4732 #[serde(default)]
4733 n: Option<u32>,
4734}
4735
4736async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4749 let Some(runtime) = state.oauth.as_deref() else {
4750 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4752 };
4753 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4754}
4755
4756async fn oauth_jwks(State(state): State<AppState>) -> Response {
4763 let Some(runtime) = state.oauth.as_deref() else {
4764 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4765 };
4766 match runtime.client_key.as_ref() {
4767 Some(key) => match key.jwks_document() {
4768 Ok(doc) => axum::Json(doc).into_response(),
4769 Err(err) => {
4770 warn!(%err, "could not render the client JWKS");
4771 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4772 }
4773 },
4774 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4775 }
4776}
4777
4778const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4780
4781async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4790 let did = match current_did(&state, &headers).await {
4791 Some(d) => d,
4792 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4793 };
4794 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4795 warn!(%did, "admin metrics denied: not an admin-seed DID");
4796 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4797 }
4798
4799 if let Err(err) =
4804 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4805 {
4806 warn!(%err, "could not flush repo timings before rendering");
4807 }
4808 let rows = match crate::metrics::persisted_rows(&state.db).await {
4809 Ok(rows) => rows,
4810 Err(err) => {
4811 warn!(%err, "could not read persisted repo timings");
4812 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4813 }
4814 };
4815
4816 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4822 Ok(n) => n.to_string(),
4823 Err(err) => {
4824 warn!(%err, "could not count parked read-state DIDs");
4825 "unknown".to_string()
4826 }
4827 };
4828 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4835 Ok(f) => f,
4836 Err(err) => {
4837 warn!(%err, "could not list failing feeds");
4838 Vec::new()
4839 }
4840 };
4841 let mut failing_block = String::new();
4842 if !failing.is_empty() {
4843 failing_block.push_str("\nfailing feeds (worst first)\n");
4844 for f in &failing {
4845 failing_block.push_str(&format!(
4846 " {:>4}x {:<8} {}\n {}\n",
4847 f.consecutive_errors,
4848 f.kind.as_deref().unwrap_or("unknown"),
4849 f.url,
4850 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4851 ));
4852 }
4853 }
4854
4855 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4860 Ok(n) => n,
4861 Err(err) => {
4862 warn!(%err, "could not count unpollable feeds");
4863 -1
4864 }
4865 };
4866 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4867
4868 let body = format!(
4869 "live backend: {}\nparked read-state DIDs: {}\n\
4870 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4871 state.config.repo_backend.as_str(),
4872 parked,
4873 cached,
4874 state.config.max_feeds_global,
4875 unpollable,
4876 crate::metrics::render(&rows),
4877 failing_block,
4878 );
4879 (StatusCode::OK, body).into_response()
4880}
4881
4882async fn admin_mint_invites(
4886 State(state): State<AppState>,
4887 headers: HeaderMap,
4888 Query(q): Query<MintQuery>,
4889) -> Response {
4890 let did = match current_did(&state, &headers).await {
4893 Some(d) => d,
4894 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4895 };
4896 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4897 warn!(%did, "admin mint denied: not an admin-seed DID");
4898 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4899 }
4900
4901 let n = q.n.unwrap_or(1).clamp(1, 100);
4902 let mut codes = Vec::with_capacity(n as usize);
4903 for _ in 0..n {
4904 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4905 Ok(code) => codes.push(code),
4906 Err(err) => {
4907 warn!(%err, %did, "admin mint_code failed");
4908 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4909 }
4910 }
4911 }
4912 info!(%did, count = codes.len(), "admin minted invite codes");
4913 let mut body = codes.join("\n");
4914 body.push('\n');
4915 (StatusCode::OK, body).into_response()
4916}
4917
4918#[derive(Debug, Deserialize)]
4924struct ClaimQuery {
4925 t: Option<String>,
4927}
4928
4929async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4948 let token = match q.t {
4949 Some(t) if !t.is_empty() => t,
4950 _ => {
4951 warn!("claim link with no token");
4952 return redeem_bounce(&state, &store::RedeemError::NotFound);
4953 }
4954 };
4955
4956 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4959 Some(c) => c,
4960 None => {
4961 warn!("claim token invalid (bad signature / malformed)");
4962 return redeem_bounce(&state, &store::RedeemError::NotFound);
4963 }
4964 };
4965
4966 match preflight_code(&state, &code).await {
4970 Ok(()) => {
4971 let cookie = sign_invite(&code, &state.config.cookie_secret);
4972 let mut resp = Redirect::to("/login").into_response();
4973 set_cookie(&mut resp, &cookie);
4974 info!("claim token preflight OK; reserving intent + redirecting to /login");
4975 resp
4976 }
4977 Err(policy) => {
4978 warn!(?policy, "claim token preflight rejected");
4979 redeem_bounce(&state, &policy)
4980 }
4981 }
4982}
4983
4984#[derive(Debug, Default, Deserialize)]
4991struct BotClaimRequest {
4992 #[serde(default)]
4995 did: Option<String>,
4996 #[serde(default)]
4998 #[allow(dead_code)]
4999 handle: Option<String>,
5000}
5001
5002#[derive(Debug, serde::Serialize)]
5004struct BotClaimResponse {
5005 status: &'static str,
5011 code: String,
5015 token: String,
5018 url: String,
5021}
5022
5023async fn bot_mint_claim(
5051 State(state): State<AppState>,
5052 headers: HeaderMap,
5053 body: axum::body::Bytes,
5054) -> Response {
5055 let bot_secret = match state.config.bot_secret.as_deref() {
5057 Some(s) => s,
5058 None => {
5059 warn!(
5060 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5061 );
5062 return (
5063 StatusCode::SERVICE_UNAVAILABLE,
5064 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5065 )
5066 .into_response();
5067 }
5068 };
5069
5070 let presented = headers
5072 .get("x-bot-secret")
5073 .and_then(|v| v.to_str().ok())
5074 .unwrap_or("");
5075 if !bot_secret_matches(presented, bot_secret) {
5076 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5077 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5078 }
5079
5080 let req: BotClaimRequest = if body.is_empty() {
5083 BotClaimRequest::default()
5084 } else {
5085 match serde_json::from_slice(&body) {
5086 Ok(r) => r,
5087 Err(err) => {
5088 warn!(%err, "POST /bot/claims: bad JSON body");
5089 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5090 }
5091 }
5092 };
5093 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5094
5095 if let Some(did) = follower_did {
5097 match store::has_beta_access(&state.db, did).await {
5099 Ok(true) => {
5100 info!("bot mint: DID already holds beta access; already_seated");
5101 return bot_claim_json(BotClaimResponse {
5102 status: "already_seated",
5103 code: String::new(),
5104 token: String::new(),
5105 url: String::new(),
5106 });
5107 }
5108 Ok(false) => {}
5109 Err(err) => {
5110 warn!(%err, "bot mint: has_beta_access failed");
5112 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5113 }
5114 }
5115 match store::find_active_code_for_did(&state.db, did).await {
5118 Ok(Some(code)) => {
5119 info!("bot mint: existing outstanding claim for DID; returning same code");
5120 let token = sign_claim_token(&code, &state.config.cookie_secret);
5121 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5122 return bot_claim_json(BotClaimResponse {
5123 status: "existing",
5124 code,
5125 token,
5126 url,
5127 });
5128 }
5129 Ok(None) => {}
5130 Err(err) => {
5131 warn!(%err, "bot mint: find_active_code_for_did failed");
5132 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5133 }
5134 }
5135 }
5136
5137 let granted = match store::count_beta_access(&state.db).await {
5140 Ok(n) => n,
5141 Err(err) => {
5142 warn!(%err, "bot mint: count_beta_access failed; failing closed");
5143 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5144 }
5145 };
5146 let outstanding = match store::count_active_codes(&state.db).await {
5147 Ok(n) => n,
5148 Err(err) => {
5149 warn!(%err, "bot mint: count_active_codes failed; failing closed");
5150 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5151 }
5152 };
5153 if granted + outstanding >= state.config.beta_cap {
5154 info!(
5155 granted,
5156 outstanding,
5157 cap = state.config.beta_cap,
5158 "bot mint refused: at capacity"
5159 );
5160 return (
5161 StatusCode::CONFLICT,
5162 [(header::CONTENT_TYPE, "application/json")],
5163 "{\"error\":\"full\"}\n",
5164 )
5165 .into_response();
5166 }
5167
5168 let bot_did = state
5171 .config
5172 .admin_seed_dids()
5173 .first()
5174 .cloned()
5175 .unwrap_or_else(|| "did:bot:featherreader".to_string());
5176 let minted = match follower_did {
5177 Some(did) => {
5178 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5179 }
5180 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5181 };
5182 let code = match minted {
5183 Ok(c) => c,
5184 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5191 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5192 Ok(Some(code)) => {
5193 info!("bot mint: lost the mint race; returning the concurrently-minted code");
5194 let token = sign_claim_token(&code, &state.config.cookie_secret);
5195 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5196 return bot_claim_json(BotClaimResponse {
5197 status: "existing",
5198 code,
5199 token,
5200 url,
5201 });
5202 }
5203 Ok(None) => {
5207 warn!("bot mint: conflict but no active code found on recovery");
5208 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5209 }
5210 Err(err) => {
5211 warn!(%err, "bot mint: recovery lookup after conflict failed");
5212 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5213 }
5214 }
5215 }
5216 Err(err) => {
5217 warn!(%err, "bot mint_code failed");
5218 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5219 }
5220 };
5221 let token = sign_claim_token(&code, &state.config.cookie_secret);
5222 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5223 info!("bot minted a claim code + token");
5224
5225 bot_claim_json(BotClaimResponse {
5226 status: "minted",
5227 code,
5228 token,
5229 url,
5230 })
5231}
5232
5233fn bot_claim_json(resp: BotClaimResponse) -> Response {
5236 match serde_json::to_string(&resp) {
5237 Ok(body) => (
5238 StatusCode::OK,
5239 [(header::CONTENT_TYPE, "application/json")],
5240 body,
5241 )
5242 .into_response(),
5243 Err(err) => {
5244 warn!(%err, "serializing bot claim response failed");
5245 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5246 }
5247 }
5248}
5249
5250fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5255 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5256}
5257
5258fn sign_invite(code: &str, secret: &str) -> String {
5267 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5268}
5269
5270fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5275 cookie::verify_value(headers, INVITE_COOKIE, secret)
5276}
5277
5278const CLAIM_TOKEN_LABEL: &str = "claim-token";
5282
5283fn sign_claim_token(code: &str, secret: &str) -> String {
5295 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5296}
5297
5298fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5303 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5304}
5305
5306fn clear_invite_cookie(resp: &mut Response) {
5309 set_cookie(
5310 resp,
5311 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5312 );
5313}
5314
5315async fn import_opml(
5328 State(state): State<AppState>,
5329 headers: HeaderMap,
5330 mut multipart: Multipart,
5331) -> Result<Response, WebError> {
5332 let did = match current_did(&state, &headers).await {
5333 Some(d) => d,
5334 None => return Ok(Redirect::to("/login").into_response()),
5335 };
5336 let pool = &state.db;
5337
5338 let mut opml_text = String::new();
5344 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5345 let name = field.name().unwrap_or("").to_string();
5346 if name == "opml" || name == "file" {
5347 let bytes = field.bytes().await.map_err(multipart_response)?;
5348 if !bytes.is_empty() {
5349 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5350 if name == "file" {
5351 break;
5352 }
5353 }
5354 }
5355 }
5356
5357 let feeds =
5362 match opml::parse_opml(&opml_text) {
5363 Ok(feeds) => feeds,
5364 Err(err) => {
5365 warn!(%err, %did, "OPML import could not parse the uploaded file");
5366 return Ok(Redirect::to(&format!(
5367 "/?flash={}",
5368 qenc("That file could not be read as OPML. Export it again from your other reader?")
5369 ))
5370 .into_response());
5371 }
5372 };
5373 if feeds.is_empty() {
5374 info!(%did, "OPML import found no feeds");
5375 return Ok(
5376 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5377 .into_response(),
5378 );
5379 }
5380
5381 let now = now_rfc3339();
5384 let mut folder_uris: std::collections::HashMap<String, String> =
5385 std::collections::HashMap::new();
5386 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5388 for (rkey, folder) in existing {
5389 folder_uris
5390 .entry(folder.name.clone())
5391 .or_insert_with(|| folder_uri(&did, &rkey));
5392 }
5393 }
5394 let mut wanted_folders: Vec<String> = feeds
5395 .iter()
5396 .filter_map(|f| f.folder.clone())
5397 .filter(|n| !n.is_empty())
5398 .collect();
5399 wanted_folders.sort();
5400 wanted_folders.dedup();
5401 for name in wanted_folders {
5402 if folder_uris.contains_key(&name) {
5403 continue;
5404 }
5405 let folder = Folder::new(name.clone(), now.clone());
5406 match state.repo().add_folder(&did, &folder).await {
5407 Ok(rkey) => {
5408 folder_uris.insert(name, folder_uri(&did, &rkey));
5409 }
5410 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5411 }
5412 }
5413
5414 let sub_cap = state.config.max_subs_per_did;
5423 let mut headroom: Option<i64> = if sub_cap > 0 {
5424 let existing = store::count_subscriptions_for_did(pool, &did)
5425 .await
5426 .unwrap_or(0);
5427 Some((sub_cap - existing).max(0))
5428 } else {
5429 None
5430 };
5431 let mut trimmed_over_cap: usize = 0;
5432
5433 let feeds_cap = state.config.max_feeds_global;
5440 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5441 let existing = store::count_feeds(pool).await.unwrap_or(0);
5442 Some((feeds_cap - existing).max(0))
5443 } else {
5444 None
5445 };
5446 let mut trimmed_over_global: usize = 0;
5447
5448 let mut subs = Vec::with_capacity(feeds.len());
5449 let mut skipped_private: Vec<String> = Vec::new();
5450 let mut uncached: usize = 0;
5453 let mut skipped_unsupported: usize = 0;
5459 for f in &feeds {
5460 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5468 info!(
5469 %did,
5470 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5471 );
5472 skipped_unsupported += 1;
5473 continue;
5474 }
5475 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5476 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5477 let label = f
5479 .title
5480 .clone()
5481 .filter(|t| !t.trim().is_empty())
5482 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5483 skipped_private.push(label);
5484 continue;
5485 }
5486
5487 if let Some(h) = headroom.as_mut() {
5490 if *h <= 0 {
5491 trimmed_over_cap += 1;
5492 continue;
5493 }
5494 }
5495
5496 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5501 Ok(existing) => existing.is_none(),
5502 Err(err) => {
5505 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5506 false
5507 }
5508 };
5509 if is_new {
5510 if let Some(g) = global_headroom.as_mut() {
5511 if *g <= 0 {
5512 trimmed_over_global += 1;
5513 continue;
5514 }
5515 *g -= 1;
5516 }
5517 }
5518
5519 if let Some(h) = headroom.as_mut() {
5522 *h -= 1;
5523 }
5524
5525 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5526 sub.title = f.title.clone();
5527 sub.site_url = f.site_url.clone();
5528 sub.folder = f
5529 .folder
5530 .as_ref()
5531 .and_then(|name| folder_uris.get(name).cloned());
5532 subs.push(sub);
5533 if let Err(err) = store::upsert_feed(
5539 pool,
5540 &store::NewFeed {
5541 url: f.feed_url.clone(),
5542 title: f.title.clone(),
5543 site_url: f.site_url.clone(),
5544 ..Default::default()
5545 },
5546 )
5547 .await
5548 {
5549 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5550 it will not be polled");
5551 uncached += 1;
5552 }
5553 }
5554
5555 let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5572 Ok(rkeys) => {
5573 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5574 rkeys.len()
5575 }
5576 Err(err) => {
5577 let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
5578 warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
5579 landed
5580 }
5581 };
5582 if landed == 0 && !subs.is_empty() {
5583 return Ok(Redirect::to(&format!(
5584 "/?flash={}",
5585 qenc(
5586 "Could not save those subscriptions to your PDS, so nothing was imported. \
5587 Try again in a moment."
5588 )
5589 ))
5590 .into_response());
5591 }
5592
5593 let mut flash = if landed < subs.len() {
5595 format!(
5596 "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
5597 other {} may not have been saved. Importing the same file again would add the first \
5598 {landed} a second time",
5599 subs.len(),
5600 subs.len() - landed
5601 )
5602 } else {
5603 format!("Imported {} feeds", subs.len())
5604 };
5605 if uncached > 0 {
5606 flash.push_str(&format!(
5607 ". {uncached} of them could not be cached locally and may not update until the next import."
5608 ));
5609 }
5610 if trimmed_over_cap > 0 {
5611 flash.push_str(&format!(
5612 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5613 ));
5614 }
5615 if trimmed_over_global > 0 {
5616 flash.push_str(&format!(
5617 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5618 ));
5619 }
5620 if !skipped_private.is_empty() {
5621 flash.push_str(&format!(
5622 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5623 skipped_private.len(),
5624 skipped_private.join(", ")
5625 ));
5626 }
5627 if skipped_unsupported > 0 {
5628 flash.push_str(&format!(
5631 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5632 ));
5633 }
5634 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5635}
5636
5637fn private_feed_label(url: &str) -> String {
5640 url::Url::parse(url)
5641 .ok()
5642 .and_then(|u| u.host_str().map(str::to_string))
5643 .unwrap_or_else(|| "a private feed".to_string())
5644}
5645
5646async fn export_opml(
5648 State(state): State<AppState>,
5649 headers: HeaderMap,
5650) -> Result<Response, WebError> {
5651 let did = match current_did(&state, &headers).await {
5652 Some(d) => d,
5653 None => return Ok(Redirect::to("/login").into_response()),
5654 };
5655
5656 let subs = match state.repo().list_subscriptions_sorted(&did).await {
5663 Ok(subs) => subs,
5664 Err(err) => {
5665 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5666 return Ok(Redirect::to(&format!(
5667 "/manage?flash={}",
5668 qenc(EXPORT_INCOMPLETE_REFUSAL)
5669 ))
5670 .into_response());
5671 }
5672 };
5673 let folders = match state.repo().list_folders_sorted(&did).await {
5674 Ok(folders) => folders,
5675 Err(err) => {
5676 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5677 return Ok(Redirect::to(&format!(
5678 "/manage?flash={}",
5679 qenc(EXPORT_INCOMPLETE_REFUSAL)
5680 ))
5681 .into_response());
5682 }
5683 };
5684 let folder_pairs: Vec<(String, Folder)> = folders
5687 .into_iter()
5688 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5689 .collect();
5690
5691 let body = opml::to_opml(&subs, &folder_pairs);
5692 let mut resp = (StatusCode::OK, body).into_response();
5693 resp.headers_mut().insert(
5694 header::CONTENT_TYPE,
5695 "text/x-opml; charset=utf-8".parse().unwrap(),
5696 );
5697 resp.headers_mut().insert(
5698 header::CONTENT_DISPOSITION,
5699 "attachment; filename=\"featherreader-subscriptions.opml\""
5700 .parse()
5701 .unwrap(),
5702 );
5703 Ok(resp)
5704}
5705
5706fn set_cookie(resp: &mut Response, cookie: &str) {
5712 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5713 resp.headers_mut()
5714 .append(axum::http::header::SET_COOKIE, value);
5715 }
5716}
5717
5718fn is_htmx(headers: &HeaderMap) -> bool {
5720 headers
5721 .get("HX-Request")
5722 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5723}
5724
5725fn is_reader_request(headers: &HeaderMap) -> bool {
5731 headers
5732 .get("X-FR-Reader")
5733 .is_some_and(|v| v.as_bytes() == b"1")
5734}
5735
5736mod cookie {
5741 use super::{HeaderMap, SESSION_COOKIE};
5742
5743 pub fn sign_session(sid: &str, secret: &str) -> String {
5745 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5746 }
5747
5748 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5750 verify_value(headers, SESSION_COOKIE, secret)
5751 }
5752
5753 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5759 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5760 msg.extend_from_slice(name.as_bytes());
5761 msg.push(0);
5762 msg.extend_from_slice(value.as_bytes());
5763 msg
5764 }
5765
5766 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5772 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5773 let b64 = b64url_encode(value.as_bytes());
5774 format!(
5775 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5776 )
5777 }
5778
5779 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5782 let raw = cookie_value(headers, name)?;
5783 let (b64, sig) = raw.split_once('.')?;
5784 let bytes = b64url_decode(b64)?;
5785 let value = String::from_utf8(bytes).ok()?;
5786 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5787 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5788 Some(value)
5789 } else {
5790 None
5791 }
5792 }
5793
5794 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5800 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5801 let b64 = b64url_encode(value.as_bytes());
5802 format!("{b64}.{sig}")
5803 }
5804
5805 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5808 let (b64, sig) = token.split_once('.')?;
5809 let bytes = b64url_decode(b64)?;
5810 let value = String::from_utf8(bytes).ok()?;
5811 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5812 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5813 Some(value)
5814 } else {
5815 None
5816 }
5817 }
5818
5819 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5821 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5822 for part in header.split(';') {
5823 let part = part.trim();
5824 if let Some((k, v)) = part.split_once('=') {
5825 if k == name {
5826 return Some(v.to_string());
5827 }
5828 }
5829 }
5830 None
5831 }
5832
5833 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5837 if a.len() != b.len() {
5838 return false;
5839 }
5840 let mut diff = 0u8;
5841 for (x, y) in a.iter().zip(b.iter()) {
5842 diff |= x ^ y;
5843 }
5844 diff == 0
5845 }
5846
5847 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5850
5851 fn b64url_encode(input: &[u8]) -> String {
5852 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5853 for chunk in input.chunks(3) {
5854 let b = [
5855 chunk[0],
5856 *chunk.get(1).unwrap_or(&0),
5857 *chunk.get(2).unwrap_or(&0),
5858 ];
5859 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5860 out.push(B64[((n >> 18) & 63) as usize] as char);
5861 out.push(B64[((n >> 12) & 63) as usize] as char);
5862 if chunk.len() > 1 {
5863 out.push(B64[((n >> 6) & 63) as usize] as char);
5864 }
5865 if chunk.len() > 2 {
5866 out.push(B64[(n & 63) as usize] as char);
5867 }
5868 }
5869 out
5870 }
5871
5872 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5873 fn val(c: u8) -> Option<u32> {
5874 match c {
5875 b'A'..=b'Z' => Some((c - b'A') as u32),
5876 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5877 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5878 b'-' => Some(62),
5879 b'_' => Some(63),
5880 _ => None,
5881 }
5882 }
5883 let bytes = input.as_bytes();
5884 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5885 for chunk in bytes.chunks(4) {
5886 let mut n = 0u32;
5887 let mut valid = 0;
5888 for (i, &c) in chunk.iter().enumerate() {
5889 n |= val(c)? << (18 - 6 * i);
5890 valid += 1;
5891 }
5892 out.push((n >> 16) as u8);
5893 if valid > 2 {
5894 out.push((n >> 8) as u8);
5895 }
5896 if valid > 3 {
5897 out.push(n as u8);
5898 }
5899 }
5900 Some(out)
5901 }
5902
5903 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5907 const BLOCK: usize = 64;
5908 let mut k = [0u8; BLOCK];
5909 if key.len() > BLOCK {
5910 let d = sha256(key);
5911 k[..32].copy_from_slice(&d);
5912 } else {
5913 k[..key.len()].copy_from_slice(key);
5914 }
5915 let mut ipad = [0x36u8; BLOCK];
5916 let mut opad = [0x5cu8; BLOCK];
5917 for i in 0..BLOCK {
5918 ipad[i] ^= k[i];
5919 opad[i] ^= k[i];
5920 }
5921 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5922 inner.extend_from_slice(&ipad);
5923 inner.extend_from_slice(msg);
5924 let inner_hash = sha256(&inner);
5925 let mut outer = Vec::with_capacity(BLOCK + 32);
5926 outer.extend_from_slice(&opad);
5927 outer.extend_from_slice(&inner_hash);
5928 let mac = sha256(&outer);
5929 let mut hex = String::with_capacity(64);
5930 for b in mac {
5931 hex.push_str(&format!("{b:02x}"));
5932 }
5933 hex
5934 }
5935
5936 fn sha256(data: &[u8]) -> [u8; 32] {
5938 const K: [u32; 64] = [
5939 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5940 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5941 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5942 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5943 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5944 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5945 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5946 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5947 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5948 0xc67178f2,
5949 ];
5950 let mut h: [u32; 8] = [
5951 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5952 0x5be0cd19,
5953 ];
5954
5955 let bit_len = (data.len() as u64) * 8;
5956 let mut msg = data.to_vec();
5957 msg.push(0x80);
5958 while msg.len() % 64 != 56 {
5959 msg.push(0);
5960 }
5961 msg.extend_from_slice(&bit_len.to_be_bytes());
5962
5963 for block in msg.chunks(64) {
5964 let mut w = [0u32; 64];
5965 for i in 0..16 {
5966 w[i] = u32::from_be_bytes([
5967 block[i * 4],
5968 block[i * 4 + 1],
5969 block[i * 4 + 2],
5970 block[i * 4 + 3],
5971 ]);
5972 }
5973 for i in 16..64 {
5974 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5975 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5976 w[i] = w[i - 16]
5977 .wrapping_add(s0)
5978 .wrapping_add(w[i - 7])
5979 .wrapping_add(s1);
5980 }
5981 let mut a = h;
5982 for i in 0..64 {
5983 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5984 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5985 let t1 = a[7]
5986 .wrapping_add(s1)
5987 .wrapping_add(ch)
5988 .wrapping_add(K[i])
5989 .wrapping_add(w[i]);
5990 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5991 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5992 let t2 = s0.wrapping_add(maj);
5993 a[7] = a[6];
5994 a[6] = a[5];
5995 a[5] = a[4];
5996 a[4] = a[3].wrapping_add(t1);
5997 a[3] = a[2];
5998 a[2] = a[1];
5999 a[1] = a[0];
6000 a[0] = t1.wrapping_add(t2);
6001 }
6002 for i in 0..8 {
6003 h[i] = h[i].wrapping_add(a[i]);
6004 }
6005 }
6006
6007 let mut out = [0u8; 32];
6008 for (i, word) in h.iter().enumerate() {
6009 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6010 }
6011 out
6012 }
6013
6014 #[cfg(test)]
6015 mod tests {
6016 use super::*;
6017
6018 #[test]
6019 fn sha256_known_vector() {
6020 let d = sha256(b"abc");
6021 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6022 assert_eq!(
6023 hex,
6024 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6025 );
6026 }
6027
6028 #[test]
6029 fn hmac_known_vector() {
6030 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6031 assert_eq!(
6032 mac,
6033 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6034 );
6035 }
6036
6037 #[test]
6038 fn sign_verify_round_trips() {
6039 let secret = "test-secret";
6040 let sid = "9f2c-opaque-session-id";
6041 let cookie = sign_session(sid, secret);
6042 let pair = cookie.split(';').next().unwrap().to_string();
6043 let mut headers = HeaderMap::new();
6044 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6045 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6046 assert!(verify_session(&headers, "other-secret").is_none());
6048 }
6049
6050 #[test]
6051 fn forged_and_tampered_cookies_are_rejected() {
6052 let secret = "test-secret";
6053
6054 let forged = format!(
6057 "{SESSION_COOKIE}={}.{}",
6058 b64url_encode(b"attacker-chosen-sid"),
6059 "deadbeef".repeat(8) );
6061 let mut headers = HeaderMap::new();
6062 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6063 assert!(verify_session(&headers, secret).is_none());
6064
6065 let cookie = sign_session("real-sid", secret);
6068 let pair = cookie.split(';').next().unwrap();
6069 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6070 let tampered = format!(
6071 "{SESSION_COOKIE}={}.{}",
6072 b64url_encode(b"different-sid"),
6073 sig
6074 );
6075 let mut headers2 = HeaderMap::new();
6076 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6077 assert!(verify_session(&headers2, secret).is_none());
6078 }
6079
6080 #[test]
6081 fn b64url_round_trips() {
6082 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6083 let enc = b64url_encode(s.as_bytes());
6084 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6085 }
6086 }
6087 }
6088}
6089
6090async fn get_entry_by_id(
6106 pool: &store::Pool,
6107 did: &str,
6108 id: i64,
6109) -> anyhow::Result<Option<store::Entry>> {
6110 let entry = sqlx::query_as::<_, store::Entry>(
6111 r#"
6112 SELECT e.* FROM entries e
6113 WHERE e.id = ?2
6114 AND EXISTS (
6115 SELECT 1 FROM sub_ref sr
6116 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6117 )
6118 "#,
6119 )
6120 .bind(did)
6121 .bind(id)
6122 .fetch_optional(pool)
6123 .await?;
6124 Ok(entry)
6125}
6126
6127async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6129 let read: Option<bool> =
6130 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6131 .bind(did)
6132 .bind(entry_id)
6133 .fetch_optional(pool)
6134 .await?
6135 .flatten();
6136 Ok(read.unwrap_or(false))
6137}
6138
6139async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6141 let starred: Option<bool> =
6142 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6143 .bind(did)
6144 .bind(entry_id)
6145 .fetch_optional(pool)
6146 .await?
6147 .flatten();
6148 Ok(starred.unwrap_or(false))
6149}
6150
6151async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6153 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6154 .bind(feed_id)
6155 .fetch_optional(pool)
6156 .await
6157 {
6158 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6159 _ => String::new(),
6160 }
6161}
6162
6163async fn build_entry_row(
6166 pool: &store::Pool,
6167 did: &str,
6168 id: i64,
6169 read: Option<bool>,
6170) -> anyhow::Result<Option<EntryRow>> {
6171 let entry = match get_entry_by_id(pool, did, id).await? {
6172 Some(e) => e,
6173 None => return Ok(None),
6174 };
6175 let read = match read {
6176 Some(r) => r,
6177 None => entry_is_read(pool, did, id).await?,
6178 };
6179 let starred = entry_is_starred(pool, did, id).await?;
6180 Ok(Some(EntryRow {
6181 id: entry.id,
6182 title: entry
6183 .title
6184 .clone()
6185 .filter(|t| !t.trim().is_empty())
6186 .unwrap_or_else(|| "(untitled)".to_string()),
6187 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6188 published: display_date(entry.published.as_deref()),
6189 read,
6190 starred,
6191 link: SafeLink::entry(id, ""),
6192 cached: true,
6193 rkey: String::new(),
6194 }))
6195}
6196
6197fn now_rfc3339() -> String {
6199 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6200}
6201
6202#[cfg(test)]
6203mod tests {
6204 use super::*;
6205
6206 #[test]
6207 fn qenc_encodes_reserved() {
6208 assert_eq!(qenc("a b"), "a%20b");
6209 assert_eq!(
6210 qenc("https://example.com/feed.xml"),
6211 "https%3A%2F%2Fexample.com%2Ffeed.xml"
6212 );
6213 assert_eq!(
6214 qenc("at://did:plc:x/c/r"),
6215 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6216 );
6217 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6219 }
6220
6221 #[test]
6222 fn folder_uri_shape() {
6223 assert_eq!(
6224 folder_uri("did:plc:abc", "3kfolder"),
6225 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6226 );
6227 }
6228
6229 #[test]
6232 fn private_feeds_are_classified_private_across_providers() {
6233 for url in [
6237 "https://author.substack.com/feed/private/deadbeefcafe1234",
6238 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6239 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6240 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6241 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6242 "https://user:pass@example.com/feed",
6243 ] {
6244 assert!(
6245 feed::classify_feed_privacy(url).is_private(),
6246 "expected private: {url}"
6247 );
6248 }
6249 }
6250
6251 #[test]
6252 fn public_feeds_stay_public() {
6253 for url in [
6254 "https://author.substack.com/feed",
6255 "https://wordpress.example.com/feed/",
6256 "https://example.com/rss.xml",
6257 "https://example.org/atom.xml",
6258 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6260 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6261 ] {
6262 assert!(
6263 !feed::classify_feed_privacy(url).is_private(),
6264 "expected public: {url}"
6265 );
6266 }
6267 }
6268
6269 #[test]
6270 fn private_feed_label_is_public_safe_host_only() {
6271 let label =
6273 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6274 assert_eq!(label, "author.substack.com");
6275 assert!(!label.contains("deadbeefcafe1234token"));
6276 assert!(!label.contains("/private/"));
6277 assert_eq!(private_feed_label("not a url"), "a private feed");
6279 }
6280
6281 #[test]
6282 fn refusal_message_promises_nothing_stored() {
6283 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6284 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6285 }
6286
6287 #[test]
6288 fn scope_query_preserves_context() {
6289 let q = EntryQuery {
6290 feed: Some("https://example.com/feed.xml".to_string()),
6291 folder: None,
6292 view: Some("all".to_string()),
6293 };
6294 let s = scope_query(&q);
6295 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6296 assert!(s.contains("view=all"));
6297
6298 let q2 = EntryQuery {
6300 feed: None,
6301 folder: None,
6302 view: Some("unread".to_string()),
6303 };
6304 assert_eq!(scope_query(&q2), "");
6305 }
6306
6307 use axum::body::Body;
6310 use axum::http::Request;
6311 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6317 let db = store::init_url("sqlite::memory:").await.unwrap();
6318 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6319 store::ensure_seed(&db, &dids).await.unwrap();
6320 let config = Config {
6321 allowed_dids: dids,
6322 cookie_secret: "test-cookie-secret-000".to_string(),
6323 beta_cap: 3,
6324 ..Config::default()
6325 };
6326 AppState::new(config, db).unwrap()
6327 }
6328
6329 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6332 let sid = state.sessions.create(Session {
6333 did: did.to_string(),
6334 handle: handle.map(str::to_string),
6335 });
6336 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6337 sc.split(';').next().unwrap().to_string()
6338 }
6339
6340 #[test]
6344 fn the_rate_limit_map_is_bounded() {
6345 let rl = RateLimiter::shared();
6346 let now = Instant::now();
6347 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6348 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6351 rl.check_at(ip, now + Duration::from_millis(i as u64));
6352 }
6353 let len = rl.inner.lock().unwrap().buckets.len();
6354 assert!(
6355 len <= MAX_RATE_BUCKETS,
6356 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6357 );
6358 }
6359
6360 #[test]
6367 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6368 let rl = RateLimiter::shared();
6369 let base = Instant::now();
6370 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6371 let at = |n: u64| base + Duration::from_nanos(n);
6376
6377 for i in 0..(RATE_BURST as u64) {
6379 assert!(rl.check_at(attacker, at(i)));
6380 }
6381 assert!(
6382 !rl.check_at(attacker, at(RATE_BURST as u64)),
6383 "burst was not exhausted; the rest of this test proves nothing"
6384 );
6385
6386 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6389 let t = at(100 + i as u64 * 2);
6390 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6391 rl.check_at(ip, t);
6392 assert!(
6393 !rl.check_at(attacker, t),
6394 "the attacker got a token back after evictions at i={i}"
6395 );
6396 }
6397 }
6398
6399 #[test]
6402 fn the_idle_sweep_does_not_run_on_every_request() {
6403 let rl = RateLimiter::shared();
6404 let start = Instant::now();
6405 let a: IpAddr = "198.51.100.1".parse().unwrap();
6406 let b: IpAddr = "198.51.100.2".parse().unwrap();
6407
6408 rl.check_at(a, start);
6409 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6412 assert!(
6413 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6414 "an idle bucket survived a sweep that was due"
6415 );
6416
6417 let before = rl.inner.lock().unwrap().last_sweep;
6420 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6421 assert_eq!(
6422 rl.inner.lock().unwrap().last_sweep,
6423 before,
6424 "the sweep ran again within the interval"
6425 );
6426 }
6427
6428 #[test]
6429 fn rate_limited_paths_match_expected() {
6430 use axum::http::Method;
6431 assert!(is_rate_limited_path("/login", &Method::GET));
6432 assert!(is_rate_limited_path("/login", &Method::POST));
6433 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6434 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6435 assert!(is_rate_limited_path("/opml", &Method::POST));
6436 assert!(is_rate_limited_path("/read-all", &Method::POST));
6437 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6438 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6439 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6440 assert!(!is_rate_limited_path("/", &Method::GET));
6442 assert!(!is_rate_limited_path("/about", &Method::GET));
6443 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6444 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6445 }
6446
6447 #[test]
6448 fn rate_limiter_allows_burst_then_429s() {
6449 let rl = RateLimiter::shared();
6450 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6451 for _ in 0..(RATE_BURST as usize) {
6453 assert!(rl.check(ip));
6454 }
6455 assert!(!rl.check(ip));
6457 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6459 assert!(rl.check(ip2));
6460 }
6461
6462 #[test]
6463 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6464 let mut h = HeaderMap::new();
6468 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6469 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6470 assert_eq!(
6471 client_ip(&h, Some(&sock), None),
6472 Some("203.0.113.55".parse().unwrap()),
6473 "spoofed XFF must not override the socket peer"
6474 );
6475 }
6476
6477 #[test]
6478 fn client_ip_uses_trusted_header_last_hop() {
6479 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6484
6485 let mut h = HeaderMap::new();
6486 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6487 assert_eq!(
6488 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6489 Some("198.51.100.9".parse().unwrap())
6490 );
6491
6492 let mut h2 = HeaderMap::new();
6494 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6495 assert_eq!(
6496 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6497 Some("198.51.100.9".parse().unwrap()),
6498 "must take the right-most (trusted) hop, not the forged left-most"
6499 );
6500
6501 let h3 = HeaderMap::new();
6503 assert_eq!(
6504 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6505 Some("10.0.0.1".parse().unwrap())
6506 );
6507 }
6508
6509 #[test]
6510 fn invite_cookie_round_trips_and_rejects_tamper() {
6511 let secret = "test-cookie-secret-000";
6512 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6513 let pair = sc.split(';').next().unwrap();
6514 let mut h = HeaderMap::new();
6515 h.insert(header::COOKIE, pair.parse().unwrap());
6516 assert_eq!(
6517 invite_cookie_code(&h, secret).as_deref(),
6518 Some("FEATHER-ABCDWXYZ")
6519 );
6520 assert!(invite_cookie_code(&h, "other").is_none());
6522 }
6523
6524 #[tokio::test]
6525 async fn preflight_valid_expired_and_full() {
6526 let state = test_state(&["did:plc:admin"]).await;
6527 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6529 .await
6530 .unwrap();
6531 assert!(preflight_code(&state, &code).await.is_ok());
6532
6533 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6537 .await
6538 .unwrap();
6539 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6540 .bind(chrono::Utc::now().timestamp() - 3600)
6541 .bind(&expired)
6542 .execute(&state.db)
6543 .await
6544 .unwrap();
6545 assert_eq!(
6546 preflight_code(&state, &expired).await,
6547 Err(store::RedeemError::Expired)
6548 );
6549
6550 assert_eq!(
6552 preflight_code(&state, "FEATHER-NOPENOPE").await,
6553 Err(store::RedeemError::NotFound)
6554 );
6555
6556 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6559 .await
6560 .unwrap();
6561 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6562 .await
6563 .unwrap();
6564 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6565 assert_eq!(
6566 preflight_code(&state, &code).await,
6567 Err(store::RedeemError::CapacityFull)
6568 );
6569 }
6570
6571 async fn bot_state(bot_secret: &str) -> AppState {
6575 let db = store::init_url("sqlite::memory:").await.unwrap();
6576 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6577 .await
6578 .unwrap();
6579 let config = Config {
6580 allowed_dids: vec!["did:plc:admin".to_string()],
6581 cookie_secret: "test-cookie-secret-000".to_string(),
6582 beta_cap: 3,
6583 bot_secret: Some(bot_secret.to_string()),
6584 public_url: "https://feather-reader.com".to_string(),
6585 ..Config::default()
6586 };
6587 AppState::new(config, db).unwrap()
6588 }
6589
6590 #[test]
6591 fn claim_token_round_trips_and_rejects_tamper() {
6592 let secret = "test-cookie-secret-000";
6593 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6594 assert!(!token.contains(';'));
6596 assert_eq!(
6597 claim_token_code(&token, secret).as_deref(),
6598 Some("FEATHER-ABCDWXYZ")
6599 );
6600 assert!(claim_token_code(&token, "other").is_none());
6602 let mut bad = token.clone();
6604 bad.push('x');
6605 assert!(claim_token_code(&bad, secret).is_none());
6606 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6612 assert_eq!(
6613 test_b64url_decode(b64).as_deref(),
6614 Some("FEATHER-ABCDWXYZ".as_bytes()),
6615 "the code half of the token is plain base64url, decodable by anyone"
6616 );
6617 }
6618
6619 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6622 fn val(c: u8) -> Option<u32> {
6623 match c {
6624 b'A'..=b'Z' => Some((c - b'A') as u32),
6625 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6626 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6627 b'-' => Some(62),
6628 b'_' => Some(63),
6629 _ => None,
6630 }
6631 }
6632 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6633 for chunk in input.as_bytes().chunks(4) {
6634 let mut n = 0u32;
6635 let mut bits = 0;
6636 for &c in chunk {
6637 n = (n << 6) | val(c)?;
6638 bits += 6;
6639 }
6640 let bytes = bits / 8;
6641 n <<= 24 - bits;
6642 for i in 0..bytes {
6643 out.push((n >> (16 - i * 8)) as u8);
6644 }
6645 }
6646 Some(out)
6647 }
6648
6649 #[tokio::test]
6650 async fn bot_mint_then_claim_grants_a_seat() {
6651 let state = bot_state("bot-secret-abcdef").await;
6652 let app = router(state.clone());
6653
6654 let resp = app
6656 .clone()
6657 .oneshot(
6658 Request::builder()
6659 .method("POST")
6660 .uri("/bot/claims")
6661 .header("x-bot-secret", "bot-secret-abcdef")
6662 .body(Body::empty())
6663 .unwrap(),
6664 )
6665 .await
6666 .unwrap();
6667 assert_eq!(resp.status(), StatusCode::OK);
6668 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6669 .await
6670 .unwrap();
6671 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6672 let token = json["token"].as_str().unwrap().to_string();
6673 let url = json["url"].as_str().unwrap();
6674 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6675 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6677 assert!(!url.contains("FEATHER-"));
6678
6679 let resp = app
6681 .clone()
6682 .oneshot(
6683 Request::builder()
6684 .method("GET")
6685 .uri(format!("/claim?t={}", qenc(&token)))
6686 .body(Body::empty())
6687 .unwrap(),
6688 )
6689 .await
6690 .unwrap();
6691 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6692 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6693 let set_cookie = resp
6694 .headers()
6695 .get(header::SET_COOKIE)
6696 .unwrap()
6697 .to_str()
6698 .unwrap();
6699 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6700
6701 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6704 let out = store::redeem_code(
6705 &state.db,
6706 &code,
6707 "did:plc:follower",
6708 None,
6709 state.config.beta_cap,
6710 )
6711 .await
6712 .unwrap();
6713 assert_eq!(out, Ok(()));
6714 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6715 .await
6716 .unwrap());
6717 }
6718
6719 #[tokio::test]
6720 async fn claim_with_invalid_token_bounces() {
6721 let state = bot_state("bot-secret-abcdef").await;
6722 let app = router(state);
6723 let resp = app
6724 .oneshot(
6725 Request::builder()
6726 .method("GET")
6727 .uri("/claim?t=not-a-real-token")
6728 .body(Body::empty())
6729 .unwrap(),
6730 )
6731 .await
6732 .unwrap();
6733 assert_eq!(resp.status(), StatusCode::OK);
6735 }
6736
6737 #[tokio::test]
6738 async fn claim_with_used_token_is_refused() {
6739 let state = bot_state("bot-secret-abcdef").await;
6740 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6742 .await
6743 .unwrap();
6744 let token = sign_claim_token(&code, &state.config.cookie_secret);
6745 store::redeem_code(
6746 &state.db,
6747 &code,
6748 "did:plc:someone",
6749 None,
6750 state.config.beta_cap,
6751 )
6752 .await
6753 .unwrap()
6754 .unwrap();
6755 let app = router(state);
6756 let resp = app
6757 .oneshot(
6758 Request::builder()
6759 .method("GET")
6760 .uri(format!("/claim?t={}", qenc(&token)))
6761 .body(Body::empty())
6762 .unwrap(),
6763 )
6764 .await
6765 .unwrap();
6766 assert_eq!(resp.status(), StatusCode::OK);
6768 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6769 }
6770
6771 #[tokio::test]
6772 async fn bot_claims_rejects_bad_and_missing_secret() {
6773 let state = bot_state("bot-secret-abcdef").await;
6774 let app = router(state);
6775 let resp = app
6777 .clone()
6778 .oneshot(
6779 Request::builder()
6780 .method("POST")
6781 .uri("/bot/claims")
6782 .header("x-bot-secret", "wrong")
6783 .body(Body::empty())
6784 .unwrap(),
6785 )
6786 .await
6787 .unwrap();
6788 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6789 let resp = app
6791 .oneshot(
6792 Request::builder()
6793 .method("POST")
6794 .uri("/bot/claims")
6795 .body(Body::empty())
6796 .unwrap(),
6797 )
6798 .await
6799 .unwrap();
6800 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6801 }
6802
6803 #[tokio::test]
6804 async fn bot_claims_disabled_when_secret_unset() {
6805 let state = test_state(&["did:plc:admin"]).await;
6807 let app = router(state);
6808 let resp = app
6809 .oneshot(
6810 Request::builder()
6811 .method("POST")
6812 .uri("/bot/claims")
6813 .header("x-bot-secret", "anything")
6814 .body(Body::empty())
6815 .unwrap(),
6816 )
6817 .await
6818 .unwrap();
6819 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6820 }
6821
6822 #[tokio::test]
6823 async fn bot_claims_refuses_at_capacity() {
6824 let state = bot_state("bot-secret-abcdef").await;
6825 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6827 .await
6828 .unwrap();
6829 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6830 .await
6831 .unwrap();
6832 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6833 let app = router(state);
6834 let resp = app
6835 .oneshot(
6836 Request::builder()
6837 .method("POST")
6838 .uri("/bot/claims")
6839 .header("x-bot-secret", "bot-secret-abcdef")
6840 .body(Body::empty())
6841 .unwrap(),
6842 )
6843 .await
6844 .unwrap();
6845 assert_eq!(resp.status(), StatusCode::CONFLICT);
6846 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6847 .await
6848 .unwrap();
6849 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6850 }
6851
6852 #[tokio::test]
6853 async fn bot_claims_counts_outstanding_codes_against_cap() {
6854 let state = bot_state("bot-secret-abcdef").await;
6855 store::mint_code(&state.db, "did:plc:admin", 3600)
6857 .await
6858 .unwrap();
6859 store::mint_code(&state.db, "did:plc:admin", 3600)
6860 .await
6861 .unwrap();
6862 let app = router(state);
6863 let resp = app
6864 .oneshot(
6865 Request::builder()
6866 .method("POST")
6867 .uri("/bot/claims")
6868 .header("x-bot-secret", "bot-secret-abcdef")
6869 .body(Body::empty())
6870 .unwrap(),
6871 )
6872 .await
6873 .unwrap();
6874 assert_eq!(resp.status(), StatusCode::CONFLICT);
6876 }
6877
6878 async fn post_bot_claim_for(
6880 app: &axum::Router,
6881 secret: &str,
6882 did: &str,
6883 ) -> (StatusCode, serde_json::Value) {
6884 let resp = app
6885 .clone()
6886 .oneshot(
6887 Request::builder()
6888 .method("POST")
6889 .uri("/bot/claims")
6890 .header("x-bot-secret", secret)
6891 .header("content-type", "application/json")
6892 .body(Body::from(format!(
6893 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6894 )))
6895 .unwrap(),
6896 )
6897 .await
6898 .unwrap();
6899 let status = resp.status();
6900 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6901 .await
6902 .unwrap();
6903 let json = if bytes.is_empty() {
6904 serde_json::Value::Null
6905 } else {
6906 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6907 };
6908 (status, json)
6909 }
6910
6911 #[tokio::test]
6912 async fn bot_claims_returns_already_seated_for_a_member() {
6913 let state = bot_state("bot-secret-abcdef").await;
6917 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6918 .await
6919 .unwrap();
6920 let app = router(state.clone());
6921 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6922 assert_eq!(status, StatusCode::OK);
6923 assert_eq!(json["status"], "already_seated");
6924 assert_eq!(json["code"], "");
6925 assert_eq!(json["url"], "");
6926 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6928 .await
6929 .unwrap()
6930 .is_none());
6931 }
6932
6933 #[tokio::test]
6934 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6935 let state = bot_state("bot-secret-abcdef").await;
6939 let app = router(state.clone());
6940
6941 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6942 assert_eq!(s1, StatusCode::OK);
6943 assert_eq!(j1["status"], "minted");
6944 let code1 = j1["code"].as_str().unwrap().to_string();
6945
6946 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6947 assert_eq!(s2, StatusCode::OK);
6948 assert_eq!(j2["status"], "existing");
6949 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6950 assert_eq!(j2["url"], j1["url"], "same url returned");
6951
6952 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6954 }
6955
6956 #[tokio::test]
6957 async fn bot_claims_records_intended_did_at_mint() {
6958 let state = bot_state("bot-secret-abcdef").await;
6960 let app = router(state.clone());
6961 let (status, json) =
6962 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6963 assert_eq!(status, StatusCode::OK);
6964 let code = json["code"].as_str().unwrap();
6965 assert_eq!(
6966 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6967 .await
6968 .unwrap()
6969 .as_deref(),
6970 Some(code)
6971 );
6972 }
6973
6974 #[tokio::test]
6975 async fn bot_claims_concurrent_same_did_never_double_mints() {
6976 let state = bot_state("bot-secret-abcdef").await;
6983 let app = router(state.clone());
6984
6985 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6986 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6987 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6988
6989 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6990 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6991
6992 assert_eq!(
6994 store::count_active_codes(&state.db).await.unwrap(),
6995 1,
6996 "concurrent mints must not create two active codes"
6997 );
6998
6999 let ca = ja["code"].as_str().unwrap_or("");
7001 let cb = jb["code"].as_str().unwrap_or("");
7002 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7003 assert_eq!(ca, cb, "both callers must get the one minted code");
7004 for st in [&ja["status"], &jb["status"]] {
7007 let s = st.as_str().unwrap_or("");
7008 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7009 }
7010 }
7011
7012 #[tokio::test]
7013 async fn bot_claims_rejects_malformed_json_body() {
7014 let state = bot_state("bot-secret-abcdef").await;
7015 let app = router(state);
7016 let resp = app
7017 .oneshot(
7018 Request::builder()
7019 .method("POST")
7020 .uri("/bot/claims")
7021 .header("x-bot-secret", "bot-secret-abcdef")
7022 .header("content-type", "application/json")
7023 .body(Body::from("{not json"))
7024 .unwrap(),
7025 )
7026 .await
7027 .unwrap();
7028 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7029 }
7030
7031 #[tokio::test]
7032 async fn favicon_ico_served_at_root() {
7033 let state = test_state(&[]).await;
7036 let app = router(state);
7037 let resp = app
7038 .oneshot(
7039 Request::builder()
7040 .uri("/favicon.ico")
7041 .body(Body::empty())
7042 .unwrap(),
7043 )
7044 .await
7045 .unwrap();
7046 assert_eq!(resp.status(), StatusCode::OK);
7047 let ct = resp
7048 .headers()
7049 .get(header::CONTENT_TYPE)
7050 .unwrap()
7051 .to_str()
7052 .unwrap();
7053 assert!(
7054 ct.contains("icon") || ct.starts_with("image/"),
7055 "content-type = {ct}"
7056 );
7057 }
7058
7059 #[tokio::test]
7060 async fn login_without_invite_redirects_to_beta_redeem() {
7061 let state = test_state(&[]).await;
7063 let app = router(state);
7064 let resp = app
7065 .oneshot(
7066 Request::builder()
7067 .method("POST")
7068 .uri("/login")
7069 .header("content-type", "application/x-www-form-urlencoded")
7070 .body(Body::from("handle=alice.bsky.social"))
7071 .unwrap(),
7072 )
7073 .await
7074 .unwrap();
7075 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7076 assert_eq!(
7077 resp.headers().get(header::LOCATION).unwrap(),
7078 "/beta/redeem"
7079 );
7080 }
7081
7082 #[tokio::test]
7083 async fn login_with_valid_invite_cookie_starts_oauth() {
7084 let state = test_state(&[]).await;
7085 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7086 let cookie = cookie.split(';').next().unwrap().to_string();
7087 let app = router(state);
7088 let resp = app
7089 .oneshot(
7090 Request::builder()
7091 .method("POST")
7092 .uri("/login")
7093 .header("content-type", "application/x-www-form-urlencoded")
7094 .header(header::COOKIE, cookie)
7095 .body(Body::from("handle=alice.bsky.social"))
7096 .unwrap(),
7097 )
7098 .await
7099 .unwrap();
7100 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7102 let loc = resp
7103 .headers()
7104 .get(header::LOCATION)
7105 .unwrap()
7106 .to_str()
7107 .unwrap();
7108 assert!(loc.contains("/login"), "loc = {loc}");
7109 assert_ne!(loc, "/beta/redeem");
7110 }
7111
7112 async fn resolver_never(_handle: String) -> Option<String> {
7115 None
7116 }
7117
7118 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7120 move |_handle| std::future::ready(Some(did.to_string()))
7121 }
7122
7123 #[tokio::test]
7127 async fn may_start_oauth_honors_seat_via_resolved_handle() {
7128 let state = test_state(&["did:plc:admin"]).await;
7131 let headers = HeaderMap::new();
7132 assert!(
7133 may_start_oauth_with(
7134 &state,
7135 &headers,
7136 "admin.example",
7137 resolver_to("did:plc:admin")
7138 )
7139 .await,
7140 "a handle resolving to a seated DID must pass the gate"
7141 );
7142 }
7143
7144 #[tokio::test]
7148 async fn may_start_oauth_bounces_non_member_handle() {
7149 let state = test_state(&["did:plc:admin"]).await;
7150 let headers = HeaderMap::new();
7151 assert!(
7152 !may_start_oauth_with(
7153 &state,
7154 &headers,
7155 "rando.example",
7156 resolver_to("did:plc:rando")
7157 )
7158 .await,
7159 "a resolved DID with no seat must be bounced"
7160 );
7161 }
7162
7163 #[tokio::test]
7166 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7167 let state = test_state(&["did:plc:admin"]).await;
7168 let headers = HeaderMap::new();
7169 assert!(
7170 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7171 "an unresolvable handle must fail closed"
7172 );
7173 }
7174
7175 #[tokio::test]
7179 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7180 let state = test_state(&[]).await;
7181 let did = "did:plc:member";
7182 store::grant_access(&state.db, did, Some("member.example"), "test", None)
7183 .await
7184 .unwrap();
7185 let cookie = session_cookie(&state, did, Some("member.example"));
7186 let mut headers = HeaderMap::new();
7187 headers.insert(header::COOKIE, cookie.parse().unwrap());
7188 assert!(
7189 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7190 "a seated session cookie must pass without resolution"
7191 );
7192 }
7193
7194 #[tokio::test]
7196 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7197 let state = test_state(&[]).await;
7198 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7199 let cookie = cookie.split(';').next().unwrap().to_string();
7200 let mut headers = HeaderMap::new();
7201 headers.insert(header::COOKIE, cookie.parse().unwrap());
7202 assert!(
7203 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7204 "a valid invite cookie must pass without resolution"
7205 );
7206 }
7207
7208 #[tokio::test]
7209 async fn admin_mint_requires_admin_seed_did() {
7210 let state = test_state(&["did:plc:admin"]).await;
7211 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7213 .await
7214 .unwrap();
7215 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7216 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7218 let app = router(state);
7219
7220 let forbidden = app
7221 .clone()
7222 .oneshot(
7223 Request::builder()
7224 .method("POST")
7225 .uri("/admin/invites?n=2")
7226 .header(header::COOKIE, rando_cookie)
7227 .body(Body::empty())
7228 .unwrap(),
7229 )
7230 .await
7231 .unwrap();
7232 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7233
7234 let ok = app
7235 .oneshot(
7236 Request::builder()
7237 .method("POST")
7238 .uri("/admin/invites?n=2")
7239 .header(header::COOKIE, admin_cookie)
7240 .body(Body::empty())
7241 .unwrap(),
7242 )
7243 .await
7244 .unwrap();
7245 assert_eq!(ok.status(), StatusCode::OK);
7246 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7247 .await
7248 .unwrap();
7249 let body = String::from_utf8(bytes.to_vec()).unwrap();
7250 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7251 assert_eq!(minted.len(), 2);
7252 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7253 }
7254
7255 #[tokio::test]
7256 async fn admin_mint_unauthenticated_is_401() {
7257 let state = test_state(&["did:plc:admin"]).await;
7258 let app = router(state);
7259 let resp = app
7260 .oneshot(
7261 Request::builder()
7262 .method("POST")
7263 .uri("/admin/invites")
7264 .body(Body::empty())
7265 .unwrap(),
7266 )
7267 .await
7268 .unwrap();
7269 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7270 }
7271
7272 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7275 let db = store::init_url("sqlite::memory:").await.unwrap();
7276 store::record_network_stat(
7277 &db,
7278 &store::NetworkStat {
7279 key: store::ADOPTION_STAT_KEY.to_string(),
7280 source: "https://relay1.us-west.bsky.network".to_string(),
7281 value: repos,
7282 truncated,
7283 observed_at: "2026-08-13T04:05:06Z".to_string(),
7284 },
7285 )
7286 .await
7287 .unwrap();
7288 let config = Config {
7289 cookie_secret: "test-cookie-secret-000".to_string(),
7290 show_adoption: true,
7291 ..Config::default()
7292 };
7293 AppState::new(config, db).unwrap()
7294 }
7295
7296 async fn about_body(state: AppState) -> String {
7297 let resp = router(state)
7298 .oneshot(
7299 Request::builder()
7300 .uri("/about")
7301 .body(Body::empty())
7302 .unwrap(),
7303 )
7304 .await
7305 .unwrap();
7306 assert_eq!(resp.status(), StatusCode::OK);
7307 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7308 .await
7309 .unwrap();
7310 String::from_utf8(bytes.to_vec()).unwrap()
7311 }
7312
7313 #[tokio::test]
7315 async fn about_omits_adoption_line_by_default() {
7316 let state = test_state(&[]).await;
7317 assert!(!state.config.show_adoption);
7318 let body = about_body(state).await;
7319 assert!(
7320 !body.contains("atproto network"),
7321 "the adoption line must not render by default"
7322 );
7323 }
7324
7325 #[tokio::test]
7326 async fn about_renders_adoption_line_when_enabled() {
7327 let body = about_body(adoption_state(7_318, false).await).await;
7335 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7338 assert!(
7339 flat.contains("7318 accounts on the atproto network hold"),
7340 "the count did not render in its own sentence: {flat}",
7341 );
7342 assert!(
7343 body.contains("accounts on the atproto network hold"),
7344 "{body}"
7345 );
7346 assert!(
7347 body.contains("2026-08-13"),
7348 "the observation date must render"
7349 );
7350 assert!(
7351 body.contains("lower bound"),
7352 "the non-archival caveat must ride along with the number"
7353 );
7354 assert!(
7355 !body.contains("At least"),
7356 "an untruncated count is exact-ish"
7357 );
7358 }
7359
7360 #[tokio::test]
7362 async fn about_adoption_line_is_singular_at_one() {
7363 let body = about_body(adoption_state(1, false).await).await;
7364 assert!(
7365 body.contains("account on the atproto network holds"),
7366 "{body}"
7367 );
7368 }
7369
7370 #[tokio::test]
7372 async fn about_adoption_line_says_at_least_when_truncated() {
7373 let body = about_body(adoption_state(25_000, true).await).await;
7374 assert!(body.contains("At least"), "{body}");
7375 }
7376
7377 #[tokio::test]
7379 async fn about_omits_line_when_enabled_with_no_observation() {
7380 let db = store::init_url("sqlite::memory:").await.unwrap();
7381 let config = Config {
7382 cookie_secret: "test-cookie-secret-000".to_string(),
7383 show_adoption: true,
7384 ..Config::default()
7385 };
7386 let body = about_body(AppState::new(config, db).unwrap()).await;
7387 assert!(!body.contains("atproto network"));
7388 }
7389
7390 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7401 let db = store::init_url("sqlite::memory:").await.unwrap();
7402 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7403 let config = Config {
7404 allowed_dids: vec![did.to_string()],
7405 cookie_secret: "test-cookie-secret-000".to_string(),
7406 beta_cap: 3,
7407 standard_site,
7408 ..Config::default()
7409 };
7410 AppState::new(config, db).unwrap()
7411 }
7412
7413 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7415 let cookie = session_cookie(&state, did, Some("reader.example"));
7416 let resp = router(state)
7417 .oneshot(
7418 Request::builder()
7419 .uri(path)
7420 .header(header::COOKIE, cookie)
7421 .body(Body::empty())
7422 .unwrap(),
7423 )
7424 .await
7425 .unwrap();
7426 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7427 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7428 .await
7429 .unwrap();
7430 String::from_utf8(bytes.to_vec()).unwrap()
7431 }
7432
7433 async fn public_body(state: AppState, path: &str) -> String {
7435 let resp = router(state)
7436 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7437 .await
7438 .unwrap();
7439 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7440 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7441 .await
7442 .unwrap();
7443 String::from_utf8(bytes.to_vec()).unwrap()
7444 }
7445
7446 fn feed_url_input(body: &str) -> &str {
7448 let start = body
7449 .find("id=\"feed-url\"")
7450 .and_then(|i| body[..i].rfind("<input"))
7451 .expect("the subscribe form's URL input renders");
7452 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7453 &body[start..end]
7454 }
7455
7456 #[tokio::test]
7459 async fn manage_hints_at_publications_when_the_flag_is_on() {
7460 let did = "did:plc:reader";
7461 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7462 assert!(
7463 body.contains("at://did:plc:…/site.standard.publication/…"),
7464 "the DID form must be shown: {body}"
7465 );
7466 assert!(
7467 body.contains("at://alice.example.com/site.standard.publication/…"),
7468 "the handle form must be shown: {body}"
7469 );
7470 }
7471
7472 #[tokio::test]
7478 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7479 let did = "did:plc:reader";
7480 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7481 let input = feed_url_input(&body);
7482 assert!(
7483 input.contains("type=\"text\""),
7484 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7485 );
7486 assert!(
7487 input.contains("inputmode=\"url\""),
7488 "the URL keyboard is still wanted: {input}"
7489 );
7490 }
7491
7492 #[tokio::test]
7498 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7499 let did = "did:plc:reader";
7500 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7501 let input = feed_url_input(&body);
7502 assert!(
7503 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7504 "the text input must keep a scheme check: {input}"
7505 );
7506 }
7507
7508 #[tokio::test]
7511 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7512 let did = "did:plc:reader";
7513 let state = standard_site_state(false, did).await;
7514 assert!(!state.config.standard_site);
7515 let page = signed_in_body(state, "/manage", did).await;
7516 let body = &page[page.find("</head>").expect("a <head>")..];
7521 assert!(
7522 !body.contains("site.standard.publication"),
7523 "a refused form must not be advertised: {body}"
7524 );
7525 let above_footer = body
7530 .split("<footer")
7531 .next()
7532 .expect("split yields at least one piece");
7533 assert!(
7534 above_footer.contains("id=\"feed-url\""),
7535 "the form must be above the footer: {body}"
7536 );
7537 assert!(
7538 !above_footer.contains("standard.site"),
7539 "a refused form must not be advertised: {body}"
7540 );
7541 assert!(
7542 feed_url_input(body).contains("type=\"url\""),
7543 "with the flag off the input is unchanged"
7544 );
7545 }
7546
7547 #[tokio::test]
7550 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7551 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7552 assert!(body.contains("standard.site"), "{body}");
7553 assert!(
7554 body.contains("at://did:plc:…/site.standard.publication/…"),
7555 "the landing page must show the DID form: {body}"
7556 );
7557 assert!(
7558 body.contains("at://alice.example.com/site.standard.publication/…"),
7559 "the landing page must show the handle form: {body}"
7560 );
7561 }
7562
7563 #[tokio::test]
7567 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7568 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7569 assert!(body.contains("standard.site"), "{body}");
7570 assert!(
7571 !body.contains("at://did:plc:…/site.standard.publication/…"),
7572 "no paste instructions with the flag off: {body}"
7573 );
7574 assert!(
7575 !body.contains("at://alice.example.com/site.standard.publication/…"),
7576 "no paste instructions with the flag off: {body}"
7577 );
7578 assert!(
7579 body.contains("isn't accepting new publication subscriptions"),
7580 "the page must say the form is closed here: {body}"
7581 );
7582 }
7583
7584 #[tokio::test]
7586 async fn about_describes_publications_and_how_to_subscribe_when_on() {
7587 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7588 assert!(body.contains("site.standard.publication"), "{body}");
7589 assert!(body.contains("site.standard.document"), "{body}");
7590 assert!(
7591 body.contains("at://did:plc:…/site.standard.publication/…"),
7592 "{body}"
7593 );
7594 assert!(
7595 body.contains("at://alice.example.com/site.standard.publication/…"),
7596 "{body}"
7597 );
7598 }
7599
7600 #[tokio::test]
7602 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7603 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7604 assert!(body.contains("site.standard.publication"), "{body}");
7605 assert!(
7606 !body.contains("at://did:plc:…/site.standard.publication/…"),
7607 "no paste instructions with the flag off: {body}"
7608 );
7609 assert!(
7610 !body.contains("at://alice.example.com/site.standard.publication/…"),
7611 "no paste instructions with the flag off: {body}"
7612 );
7613 assert!(
7614 body.contains("isn't accepting new publication subscriptions"),
7615 "{body}"
7616 );
7617 }
7618
7619 #[tokio::test]
7627 async fn standard_site_page_renders_signed_out() {
7628 let body = public_body(test_state(&[]).await, "/standard-site").await;
7629 assert!(body.contains("site.standard.publication"), "{body}");
7630 assert!(body.contains("site.standard.document"), "{body}");
7631 assert!(
7632 body.contains("<title>standard.site — FeatherReader</title>"),
7633 "{body}"
7634 );
7635 }
7636
7637 #[tokio::test]
7640 async fn standard_site_page_tells_how_to_subscribe_when_on() {
7641 let body = public_body(
7642 standard_site_state(true, "did:plc:x").await,
7643 "/standard-site",
7644 )
7645 .await;
7646 assert!(
7647 body.contains("at://did:plc:…/site.standard.publication/…"),
7648 "the DID form must be shown: {body}"
7649 );
7650 assert!(
7651 body.contains("at://alice.example.com/site.standard.publication/…"),
7652 "the handle form must be shown: {body}"
7653 );
7654 assert!(
7655 body.contains("resolved to its DID"),
7656 "the handle resolution must be stated: {body}"
7657 );
7658 assert!(
7659 !body.contains("isn't accepting new publication subscriptions"),
7660 "{body}"
7661 );
7662 }
7663
7664 #[tokio::test]
7668 async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7669 let state = standard_site_state(false, "did:plc:x").await;
7670 assert!(!state.config.standard_site);
7671 let body = public_body(state, "/standard-site").await;
7672 assert!(body.contains("site.standard.publication"), "{body}");
7673 assert!(
7674 !body.contains("at://did:plc:…/site.standard.publication/…"),
7675 "no paste instructions with the flag off: {body}"
7676 );
7677 assert!(
7678 !body.contains("at://alice.example.com/site.standard.publication/…"),
7679 "no paste instructions with the flag off: {body}"
7680 );
7681 assert!(
7682 body.contains("isn't accepting new publication subscriptions"),
7683 "the page must say the form is closed here: {body}"
7684 );
7685 assert!(
7686 body.contains("already follows are still read"),
7687 "stored publications are polled whatever the flag says: {body}"
7688 );
7689 }
7690
7691 #[tokio::test]
7694 async fn releases_callout_links_the_release_pages() {
7695 for path in ["/standard-site", "/"] {
7696 let body = public_body(test_state(&[]).await, path).await;
7697 for tag in ["v0.4.1", "v0.4.0"] {
7698 let href = format!(
7699 "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7700 );
7701 assert!(body.contains(&href), "{path} must link {tag}: {body}");
7702 }
7703 assert!(
7704 body.contains(
7705 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7706 ),
7707 "{path} must link the changelog: {body}"
7708 );
7709 }
7710 }
7711
7712 #[tokio::test]
7716 async fn landing_about_and_footer_link_the_standard_site_page() {
7717 for path in ["/", "/about", "/privacy"] {
7718 let body = public_body(test_state(&[]).await, path).await;
7719 assert!(
7720 body.contains("href=\"/standard-site\""),
7721 "{path} must link the feature page: {body}"
7722 );
7723 }
7724 }
7725
7726 #[test]
7730 fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7731 assert!(!RELEASES.is_empty());
7732 let parse = |v: &str| -> Vec<u32> {
7733 v.split('.')
7734 .map(|p| p.parse::<u32>().expect("a numeric version part"))
7735 .collect()
7736 };
7737 for pair in RELEASES.windows(2) {
7738 assert!(
7739 parse(pair[0].version) > parse(pair[1].version),
7740 "{} must come before {}",
7741 pair[0].version,
7742 pair[1].version
7743 );
7744 }
7745 for r in RELEASES {
7746 assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7747 assert!(
7748 chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7749 "{} is not YYYY-MM-DD",
7750 r.date
7751 );
7752 assert!(!r.summary.trim().is_empty());
7753 assert!(!r.summary.contains('<'), "the summary is plain text");
7754 assert_eq!(
7755 r.url(),
7756 format!(
7757 "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7758 r.version
7759 )
7760 );
7761 }
7762 let latest = &RELEASES[0];
7765 assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7766 assert_eq!(
7767 latest.changelog_url(),
7768 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#044--2026-10-05"
7769 );
7770 }
7771
7772 #[tokio::test]
7774 async fn standard_site_page_is_publicly_cacheable() {
7775 let resp = router(test_state(&[]).await)
7776 .oneshot(
7777 Request::builder()
7778 .uri("/standard-site")
7779 .body(Body::empty())
7780 .unwrap(),
7781 )
7782 .await
7783 .unwrap();
7784 assert_eq!(resp.status(), StatusCode::OK);
7785 assert_eq!(
7786 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7787 "public, max-age=300"
7788 );
7789 }
7790
7791 #[tokio::test]
7792 async fn cache_control_public_on_about_no_store_on_authed() {
7793 let state = test_state(&["did:plc:admin"]).await;
7794 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7795 let app = router(state);
7796
7797 let about = app
7799 .clone()
7800 .oneshot(
7801 Request::builder()
7802 .uri("/about")
7803 .body(Body::empty())
7804 .unwrap(),
7805 )
7806 .await
7807 .unwrap();
7808 assert_eq!(
7809 about.headers().get(header::CACHE_CONTROL).unwrap(),
7810 "public, max-age=300"
7811 );
7812 assert_eq!(
7818 about.headers()["content-security-policy"],
7819 EXPECTED_CSP,
7820 "the CSP is not the policy the router promises"
7821 );
7822 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7823
7824 for path in ["/privacy", "/terms"] {
7826 let resp = app
7827 .clone()
7828 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7829 .await
7830 .unwrap();
7831 assert_eq!(resp.status(), StatusCode::OK);
7832 assert_eq!(
7833 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7834 "public, max-age=300",
7835 "{path} should be publicly cacheable"
7836 );
7837 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7839 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7840 }
7841
7842 let login = app
7844 .clone()
7845 .oneshot(
7846 Request::builder()
7847 .uri("/login")
7848 .body(Body::empty())
7849 .unwrap(),
7850 )
7851 .await
7852 .unwrap();
7853 assert_eq!(
7854 login.headers().get(header::CACHE_CONTROL).unwrap(),
7855 "public, max-age=300"
7856 );
7857
7858 let home = app
7860 .oneshot(
7861 Request::builder()
7862 .uri("/")
7863 .header(header::COOKIE, admin_cookie)
7864 .body(Body::empty())
7865 .unwrap(),
7866 )
7867 .await
7868 .unwrap();
7869 assert_eq!(
7870 home.headers().get(header::CACHE_CONTROL).unwrap(),
7871 "no-store"
7872 );
7873 }
7874
7875 fn head(body: &str) -> &str {
7884 let end = body.find("</head>").expect("a <head>");
7885 &body[..end]
7886 }
7887
7888 fn meta(head: &str, attr: &str) -> Option<String> {
7891 let tag_start = head.find(attr)?;
7892 let rest = &head[tag_start..];
7893 let tag_end = rest.find('>')?;
7894 let tag = &rest[..tag_end];
7895 let content = tag.find("content=\"")? + "content=\"".len();
7896 let close = tag[content..].find('"')?;
7897 Some(tag[content..content + close].to_string())
7898 }
7899
7900 async fn production_origin_state() -> AppState {
7904 let db = store::init_url("sqlite::memory:").await.unwrap();
7905 store::ensure_seed(&db, &["did:plc:admin".to_string()])
7906 .await
7907 .unwrap();
7908 let config = Config {
7909 allowed_dids: vec!["did:plc:admin".to_string()],
7910 cookie_secret: "test-cookie-secret-000".to_string(),
7911 beta_cap: 3,
7912 public_url: "https://feather-reader.com".to_string(),
7913 ..Config::default()
7914 };
7915 AppState::new(config, db).unwrap()
7916 }
7917
7918 #[tokio::test]
7921 async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7922 let landing = public_body(production_origin_state().await, "/").await;
7923 let about = public_body(production_origin_state().await, "/about").await;
7924 let (lh, ah) = (head(&landing), head(&about));
7925
7926 assert_eq!(
7927 meta(lh, "property=\"og:title\"").as_deref(),
7928 Some("FeatherReader — read, quietly"),
7929 "{lh}"
7930 );
7931 assert_eq!(
7932 meta(ah, "property=\"og:title\"").as_deref(),
7933 Some("About — FeatherReader"),
7934 "{ah}"
7935 );
7936 for (h, path) in [(lh, "/"), (ah, "/about")] {
7937 let url = format!("https://feather-reader.com{path}");
7938 assert_eq!(
7939 meta(h, "property=\"og:url\"").as_deref(),
7940 Some(url.as_str())
7941 );
7942 assert!(
7943 h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7944 "{path} must carry a canonical link: {h}"
7945 );
7946 let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7947 assert!(
7948 image.starts_with("https://feather-reader.com/static/"),
7949 "{path}: og:image must be absolute on the public origin, got {image:?}"
7950 );
7951 assert_eq!(
7952 meta(h, "name=\"twitter:card\"").as_deref(),
7953 Some("summary_large_image")
7954 );
7955 assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7956 assert_eq!(
7957 meta(h, "property=\"og:site_name\"").as_deref(),
7958 Some("FeatherReader")
7959 );
7960 let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7961 assert!(!description.is_empty(), "{path}: og:description is empty");
7962 assert_eq!(
7963 meta(h, "name=\"description\"").as_deref(),
7964 Some(description.as_str()),
7965 "{path}: the meta description and og:description must agree"
7966 );
7967 }
7968 assert_ne!(
7969 meta(lh, "property=\"og:description\""),
7970 meta(ah, "property=\"og:description\""),
7971 "the landing page and /about must not share a description"
7972 );
7973 }
7974
7975 #[tokio::test]
7977 async fn card_urls_follow_the_configured_public_url() {
7978 let db = store::init_url("sqlite::memory:").await.unwrap();
7979 store::ensure_seed(&db, &[]).await.unwrap();
7980 let config = Config {
7981 cookie_secret: "test-cookie-secret-000".to_string(),
7982 public_url: "https://reader.example.org".to_string(),
7983 ..Config::default()
7984 };
7985 let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
7986 let h = head(&body);
7987 assert_eq!(
7988 meta(h, "property=\"og:url\"").as_deref(),
7989 Some("https://reader.example.org/privacy")
7990 );
7991 assert_eq!(
7992 meta(h, "property=\"og:image\"").as_deref(),
7993 Some("https://reader.example.org/static/social-card.png")
7994 );
7995 }
7996
7997 #[tokio::test]
8000 async fn public_pages_each_carry_their_own_description() {
8001 let paths = [
8002 "/",
8003 "/about",
8004 "/privacy",
8005 "/terms",
8006 "/stats",
8007 "/standard-site",
8008 "/login",
8009 "/beta/redeem",
8010 ];
8011 let mut seen = std::collections::HashSet::new();
8012 for path in paths {
8013 let body = public_body(production_origin_state().await, path).await;
8014 let h = head(&body);
8015 let description = meta(h, "name=\"description\"").unwrap_or_default();
8016 assert!(!description.is_empty(), "{path} has no description: {h}");
8017 assert!(
8018 seen.insert(description.clone()),
8019 "{path} repeats another page's description: {description:?}"
8020 );
8021 assert_eq!(
8022 meta(h, "property=\"og:url\"").as_deref(),
8023 Some(format!("https://feather-reader.com{path}").as_str()),
8024 "{path}"
8025 );
8026 assert!(
8027 !h.contains("name=\"robots\""),
8028 "{path} is public and must not be noindex: {h}"
8029 );
8030 }
8031 }
8032
8033 #[tokio::test]
8036 async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8037 let landing = public_body(production_origin_state().await, "/").await;
8038 let h = head(&landing);
8039 let image = meta(h, "property=\"og:image\"").unwrap();
8040 let path = image.strip_prefix("https://feather-reader.com").unwrap();
8041 let width: u32 = meta(h, "property=\"og:image:width\"")
8042 .unwrap()
8043 .parse()
8044 .unwrap();
8045 let height: u32 = meta(h, "property=\"og:image:height\"")
8046 .unwrap()
8047 .parse()
8048 .unwrap();
8049 assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8050 assert_eq!(
8051 meta(h, "property=\"og:image:type\"").as_deref(),
8052 Some("image/png")
8053 );
8054 assert!(
8055 !meta(h, "property=\"og:image:alt\"")
8056 .unwrap_or_default()
8057 .is_empty(),
8058 "the image needs alt text"
8059 );
8060
8061 let resp = router(production_origin_state().await)
8062 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8063 .await
8064 .unwrap();
8065 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8066 assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8067 assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8068 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8069 .await
8070 .expect("the image is under 1 MB");
8071 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8072 let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8074 assert_eq!(
8075 (be(16), be(20)),
8076 (width, height),
8077 "the PNG's own dimensions must match the tags"
8078 );
8079 }
8080
8081 #[tokio::test]
8084 async fn private_pages_keep_user_data_out_of_the_card() {
8085 for path in ["/", "/manage"] {
8086 let state = production_origin_state().await;
8087 let body = signed_in_body(state, path, "did:plc:admin").await;
8088 let h = head(&body);
8089 assert!(
8090 h.contains("<meta name=\"robots\" content=\"noindex\""),
8091 "{path}: a private view must be noindex: {h}"
8092 );
8093 assert_eq!(
8094 meta(h, "property=\"og:title\"").as_deref(),
8095 Some("FeatherReader — read, quietly"),
8096 "{path}: the card of a private view is the site's generic one"
8097 );
8098 assert_eq!(
8099 meta(h, "property=\"og:url\"").as_deref(),
8100 Some("https://feather-reader.com/"),
8101 "{path}: og:url of a private view is the front door, not the private path"
8102 );
8103 for private in ["reader.example", "did:plc:admin"] {
8104 assert!(
8105 !h.contains(private),
8106 "{path}: {private:?} must not reach <head>: {h}"
8107 );
8108 }
8109 }
8110 }
8111
8112 #[tokio::test]
8113 async fn beta_redeem_page_renders() {
8114 let state = test_state(&[]).await;
8115 let app = router(state);
8116 let resp = app
8117 .oneshot(
8118 Request::builder()
8119 .uri("/beta/redeem")
8120 .body(Body::empty())
8121 .unwrap(),
8122 )
8123 .await
8124 .unwrap();
8125 assert_eq!(resp.status(), StatusCode::OK);
8126 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8127 .await
8128 .unwrap();
8129 let html = String::from_utf8(bytes.to_vec()).unwrap();
8130 assert!(html.contains("Invite code"));
8131 assert!(html.contains("/beta/redeem"));
8132 }
8133
8134 #[tokio::test]
8135 async fn rate_limit_returns_429_after_burst() {
8136 let db = store::init_url("sqlite::memory:").await.unwrap();
8139 store::ensure_seed(&db, &[]).await.unwrap();
8140 let config = Config {
8141 cookie_secret: "test-cookie-secret-000".to_string(),
8142 beta_cap: 3,
8143 trusted_ip_header: Some("cf-connecting-ip".to_string()),
8144 ..Config::default()
8145 };
8146 let state = AppState::new(config, db).unwrap();
8147 let app = router(state);
8148 let mut saw_429 = false;
8152 for _ in 0..(RATE_BURST as usize + 5) {
8153 let resp = app
8154 .clone()
8155 .oneshot(
8156 Request::builder()
8157 .method("POST")
8158 .uri("/beta/redeem")
8159 .header("content-type", "application/x-www-form-urlencoded")
8160 .header("cf-connecting-ip", "203.0.113.200")
8161 .body(Body::from("code=FEATHER-NOPENOPE"))
8162 .unwrap(),
8163 )
8164 .await
8165 .unwrap();
8166 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8167 saw_429 = true;
8168 break;
8169 }
8170 }
8171 assert!(saw_429, "expected a 429 after exhausting the burst");
8172 }
8173
8174 #[tokio::test]
8187 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8188 let state = test_state(&[]).await;
8189 assert!(
8190 state.config.trusted_ip_header.is_none(),
8191 "no proxy header is trusted here"
8192 );
8193 let app = router(state);
8194 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8195 let mut saw_429 = false;
8196 for i in 0..(RATE_BURST as usize + 5) {
8197 let forged = format!("10.9.8.{}", i % 250);
8198 let resp = app
8199 .clone()
8200 .oneshot(
8201 Request::builder()
8202 .method("POST")
8203 .uri("/beta/redeem")
8204 .header("content-type", "application/x-www-form-urlencoded")
8205 .header("x-forwarded-for", forged)
8206 .extension(axum::extract::ConnectInfo(peer))
8207 .body(Body::from("code=FEATHER-NOPENOPE"))
8208 .unwrap(),
8209 )
8210 .await
8211 .unwrap();
8212 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8213 saw_429 = true;
8214 break;
8215 }
8216 }
8217 assert!(
8218 saw_429,
8219 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8220 );
8221 }
8222
8223 #[tokio::test]
8232 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8233 let did = "did:plc:privateadder";
8234 let state = test_state_with_caps(did, 0, 0).await;
8235 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8236 let port: u16 = base
8237 .trim_end_matches('/')
8238 .rsplit(':')
8239 .next()
8240 .unwrap()
8241 .parse()
8242 .unwrap();
8243 crate::net::test_host_override(
8244 "private-add.test",
8245 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8246 );
8247 let cookie = session_cookie(&state, did, None);
8248 let resp = router(state.clone())
8249 .oneshot(
8250 Request::builder()
8251 .method("POST")
8252 .uri("/subscriptions")
8253 .header(header::COOKIE, cookie)
8254 .header("content-type", "application/x-www-form-urlencoded")
8255 .body(Body::from(format!(
8256 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8257 )))
8258 .unwrap(),
8259 )
8260 .await
8261 .unwrap();
8262 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8263 let loc = resp
8264 .headers()
8265 .get(header::LOCATION)
8266 .unwrap()
8267 .to_str()
8268 .unwrap();
8269 assert!(loc.contains("Private"), "not refused as private: {loc}");
8270 assert_eq!(
8271 hits.load(std::sync::atomic::Ordering::SeqCst),
8272 0,
8273 "the private feed was FETCHED before being refused"
8274 );
8275 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8276 }
8277
8278 #[tokio::test]
8283 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8284 let did = "did:plc:renamer4";
8285 let (sidecar, bodies) = spawn_logging_sidecar().await;
8286 let state = test_state_with_sidecar(&[did], &sidecar).await;
8287 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8288 let opml = format!(
8289 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8290 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8291 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8292 </body></opml>"
8293 );
8294 let (ct, body) = opml_multipart(opml.as_bytes());
8295 let cookie = session_cookie(&state, did, None);
8296 let resp = router(state.clone())
8297 .oneshot(
8298 Request::builder()
8299 .method("POST")
8300 .uri("/opml")
8301 .header(header::COOKIE, cookie)
8302 .header("content-type", ct)
8303 .body(Body::from(body))
8304 .unwrap(),
8305 )
8306 .await
8307 .unwrap();
8308 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8309 let loc = resp
8310 .headers()
8311 .get(header::LOCATION)
8312 .unwrap()
8313 .to_str()
8314 .unwrap();
8315 assert!(
8316 loc.contains("skipped%20as%20private"),
8317 "not reported as skipped: {loc}"
8318 );
8319 assert!(store::get_feed_by_url(&state.db, tokened)
8320 .await
8321 .unwrap()
8322 .is_none());
8323 let sent = bodies.lock().unwrap().join("\n");
8324 assert!(
8325 sent.contains("public.example"),
8326 "the public feed was not written: {sent}"
8327 );
8328 assert!(
8329 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8330 "the secret was PUBLISHED to the PDS: {sent}"
8331 );
8332 }
8333
8334 #[tokio::test]
8338 async fn get_login_without_a_seat_is_refused() {
8339 let state = test_state(&[]).await;
8340 let resp = router(state)
8341 .oneshot(
8342 Request::builder()
8343 .method("GET")
8344 .uri("/login?handle=alice.bsky.social")
8345 .body(Body::empty())
8346 .unwrap(),
8347 )
8348 .await
8349 .unwrap();
8350 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8351 assert_eq!(
8352 resp.headers().get(header::LOCATION).unwrap(),
8353 "/beta/redeem"
8354 );
8355 }
8356
8357 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8361 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8362 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8363 let addr = listener.local_addr().unwrap();
8364 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8365 let sink = log.clone();
8366 tokio::spawn(async move {
8367 loop {
8368 let Ok((mut sock, _)) = listener.accept().await else {
8369 break;
8370 };
8371 let mut raw: Vec<u8> = Vec::new();
8372 let mut chunk = [0u8; 4096];
8373 let text = loop {
8374 let Ok(n) = sock.read(&mut chunk).await else {
8375 break String::new();
8376 };
8377 if n == 0 {
8378 break String::from_utf8_lossy(&raw).to_string();
8379 }
8380 raw.extend_from_slice(&chunk[..n]);
8381 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8382 continue;
8383 };
8384 let (head, body) = raw.split_at(split + 4);
8385 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8386 let (k, v) = l.split_once(':')?;
8387 k.eq_ignore_ascii_case("content-length")
8388 .then(|| v.trim().parse::<usize>().ok())?
8389 });
8390 if want.is_none_or(|w| body.len() >= w) {
8391 break String::from_utf8_lossy(&raw).to_string();
8392 }
8393 };
8394 let path = text
8395 .lines()
8396 .next()
8397 .and_then(|l| l.split_whitespace().nth(1))
8398 .unwrap_or("")
8399 .to_string();
8400 let body_text = text
8401 .split_once("\r\n\r\n")
8402 .map(|(_, b)| b)
8403 .unwrap_or("")
8404 .to_string();
8405 sink.lock().unwrap().push(format!("{path} {body_text}"));
8406 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();
8407 let resp = format!(
8408 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8409 body.len(),
8410 body
8411 );
8412 let _ = sock.write_all(resp.as_bytes()).await;
8413 let _ = sock.flush().await;
8414 }
8415 });
8416 (format!("http://{addr}"), log)
8417 }
8418
8419 #[tokio::test]
8425 async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8426 use crate::readstate::tests as rs;
8427 for backend in [
8428 crate::metrics::Backend::Sidecar,
8429 crate::metrics::Backend::Rust,
8430 ] {
8431 let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8432 let state = rs::state_on(backend, &fake).await;
8433 for i in 0..250 {
8434 rs::mark_read(&state, i, "1").await;
8435 }
8436 fake.lock().unwrap().drop_call = Some(2);
8437
8438 flush_before_revoke(&state, rs::DID).await;
8439
8440 let order = rs::send_order(250);
8441 let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8442 for &i in landed {
8443 let c = rs::cursor(&state, i).await;
8444 assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8445 }
8446 for &i in rest {
8447 let c = rs::cursor(&state, i).await;
8448 assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8449 }
8450 assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8451 }
8452 }
8453
8454 #[tokio::test]
8463 async fn signing_out_flushes_before_it_revokes_through_the_route() {
8464 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8465 let (sidecar, log) = spawn_logging_sidecar().await;
8466 let state = test_state_with_sidecar(&[did], &sidecar).await;
8467 crate::store::upsert_cursor(
8468 &state.db,
8469 &crate::store::ReadCursor {
8470 did: did.to_string(),
8471 feed_url: "https://example.com/feed.xml".into(),
8472 read_through: None,
8473 read_ids: "[\"1\"]".into(),
8474 unread_ids: "[]".into(),
8475 dirty: true,
8476 pds_created: false,
8477 updated_at: "2026-09-13T21:22:40Z".into(),
8478 },
8479 )
8480 .await
8481 .unwrap();
8482 let cookie = session_cookie(&state, did, None);
8483 let resp = router(state.clone())
8484 .oneshot(
8485 Request::builder()
8486 .method("POST")
8487 .uri("/logout")
8488 .header(header::COOKIE, cookie)
8489 .body(Body::empty())
8490 .unwrap(),
8491 )
8492 .await
8493 .unwrap();
8494 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8495
8496 let entries = log.lock().unwrap().clone();
8497 let flush = entries
8498 .iter()
8499 .position(|e| e.starts_with("/internal/repo "));
8500 let revoke = entries
8501 .iter()
8502 .position(|e| e.starts_with("/internal/revoke "));
8503 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8504 assert!(
8505 flush.is_some(),
8506 "sign-out did not attempt a flush before revoking: {entries:?}"
8507 );
8508 assert!(
8509 flush < revoke,
8510 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8511 );
8512 }
8513
8514 const EXPECTED_CSP: &str = "default-src 'self'; \
8518 script-src 'self'; \
8519 style-src 'self' 'unsafe-inline'; \
8520 img-src 'self' https: data:; \
8521 font-src 'self'; \
8522 connect-src 'self'; \
8523 form-action 'self'; \
8524 base-uri 'self'; \
8525 frame-ancestors 'none'; \
8526 object-src 'none'";
8527
8528 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8531 let boundary = "----featherreadertestboundary";
8532 let mut body = Vec::new();
8533 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8534 body.extend_from_slice(
8535 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8536 );
8537 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8538 body.extend_from_slice(payload);
8539 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8540 (format!("multipart/form-data; boundary={boundary}"), body)
8541 }
8542
8543 #[tokio::test]
8544 async fn opml_import_oversize_upload_returns_413() {
8545 let state = test_state(&["did:plc:admin"]).await;
8546 let cookie = session_cookie(&state, "did:plc:admin", None);
8547 let app = router(state);
8548
8549 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8551 let (content_type, body) = opml_multipart(&payload);
8552
8553 let resp = app
8554 .oneshot(
8555 Request::builder()
8556 .method("POST")
8557 .uri("/opml")
8558 .header("content-type", content_type)
8559 .header(header::COOKIE, cookie)
8560 .body(Body::from(body))
8561 .unwrap(),
8562 )
8563 .await
8564 .unwrap();
8565 assert_eq!(
8566 resp.status(),
8567 StatusCode::PAYLOAD_TOO_LARGE,
8568 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8569 );
8570 }
8571
8572 #[tokio::test]
8583 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8584 let state = test_state(&["did:plc:admin"]).await;
8585 let cookie = session_cookie(&state, "did:plc:admin", None);
8586 let app = router(state);
8587
8588 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8590 let (content_type, body) = opml_multipart(&payload);
8591
8592 let resp = app
8593 .oneshot(
8594 Request::builder()
8595 .method("POST")
8596 .uri("/opml")
8597 .header("content-type", content_type)
8598 .header(header::COOKIE, cookie)
8599 .body(Body::from(body))
8600 .unwrap(),
8601 )
8602 .await
8603 .unwrap();
8604 assert_eq!(
8605 resp.status(),
8606 StatusCode::PAYLOAD_TOO_LARGE,
8607 "a payload over the route's cap but under the framework's was accepted — \
8608 the route's own DefaultBodyLimit layer is not doing anything"
8609 );
8610 }
8611
8612 #[tokio::test]
8613 async fn opml_import_under_limit_upload_is_accepted() {
8614 let state = test_state(&["did:plc:admin"]).await;
8615 let cookie = session_cookie(&state, "did:plc:admin", None);
8616 let db = state.db.clone();
8617 let app = router(state);
8618
8619 let opml = br#"<?xml version="1.0"?>
8622<opml version="2.0"><body>
8623 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8624</body></opml>"#;
8625 let (content_type, body) = opml_multipart(opml);
8626
8627 let resp = app
8628 .oneshot(
8629 Request::builder()
8630 .method("POST")
8631 .uri("/opml")
8632 .header("content-type", content_type)
8633 .header(header::COOKIE, cookie)
8634 .body(Body::from(body))
8635 .unwrap(),
8636 )
8637 .await
8638 .unwrap();
8639 assert_eq!(
8646 resp.status(),
8647 StatusCode::SEE_OTHER,
8648 "an under-cap OPML upload was not accepted (status {})",
8649 resp.status(),
8650 );
8651 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8655 .bind("https://example.com/feed.xml")
8656 .fetch_one(&db)
8657 .await
8658 .unwrap();
8659 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8660 let location = resp
8661 .headers()
8662 .get(header::LOCATION)
8663 .and_then(|v| v.to_str().ok())
8664 .unwrap_or_default()
8665 .to_string();
8666 assert!(
8667 !location.starts_with("/login"),
8668 "the import bounced to login instead of being accepted: {location}",
8669 );
8670 }
8671
8672 #[tokio::test]
8673 async fn opml_import_logged_out_redirects_to_login() {
8674 let state = test_state(&["did:plc:admin"]).await;
8677 let app = router(state);
8678
8679 let opml = b"<opml version=\"2.0\"><body></body></opml>";
8680 let (content_type, body) = opml_multipart(opml);
8681
8682 let resp = app
8683 .oneshot(
8684 Request::builder()
8685 .method("POST")
8686 .uri("/opml")
8687 .header("content-type", content_type)
8688 .body(Body::from(body))
8689 .unwrap(),
8690 )
8691 .await
8692 .unwrap();
8693 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8694 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8695 }
8696
8697 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8704 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8705 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8706 let addr = listener.local_addr().unwrap();
8707 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8708 tokio::spawn(async move {
8709 let (mut sock, _) = listener.accept().await.unwrap();
8710 let mut buf = vec![0u8; 4096];
8711 let n = sock.read(&mut buf).await.unwrap();
8712 let req = String::from_utf8_lossy(&buf[..n]).to_string();
8713 let did = req
8715 .split("\r\n\r\n")
8716 .nth(1)
8717 .and_then(|body| {
8718 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8719 v.get("did")?.as_str().map(str::to_string)
8720 })
8721 .unwrap_or_default();
8722 let is_revoke = req.starts_with("POST /internal/revoke");
8723 let body = serde_json::json!({
8724 "ok": true, "did": did, "revoked": true, "hadSession": true
8725 })
8726 .to_string();
8727 let resp = format!(
8728 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8729 body.len(),
8730 body
8731 );
8732 sock.write_all(resp.as_bytes()).await.unwrap();
8733 sock.flush().await.unwrap();
8734 let _ = tx.send(if is_revoke { did } else { String::new() });
8735 });
8736 (format!("http://{addr}"), rx)
8737 }
8738
8739 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8741 let defaults = Config::default();
8742 test_state_with_sidecar_and(
8743 allowed,
8744 sidecar_url,
8745 defaults.standard_site,
8746 defaults.max_feeds_global,
8747 )
8748 .await
8749 }
8750
8751 async fn test_state_with_sidecar_and(
8754 allowed: &[&str],
8755 sidecar_url: &str,
8756 standard_site: bool,
8757 max_feeds_global: i64,
8758 ) -> AppState {
8759 let db = store::init_url("sqlite::memory:").await.unwrap();
8760 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8761 store::ensure_seed(&db, &dids).await.unwrap();
8762 let mut config = Config {
8763 allowed_dids: dids,
8764 cookie_secret: "test-cookie-secret-000".to_string(),
8765 beta_cap: 3,
8766 standard_site,
8767 max_feeds_global,
8768 ..Config::default()
8769 };
8770 config.sidecar.public_url = sidecar_url.to_string();
8771 config.sidecar.internal_url = sidecar_url.to_string();
8772 AppState::new(config, db).unwrap()
8773 }
8774
8775 #[tokio::test]
8778 async fn account_delete_purges_rows_and_triggers_revoke() {
8779 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8780 let did = "did:plc:leaver";
8781 let state = test_state_with_sidecar(&[], &sidecar_url).await;
8782
8783 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8785 .await
8786 .unwrap();
8787 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8788 store::mint_code(&state.db, did, 3600).await.unwrap();
8789 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8790
8791 let cookie = session_cookie(&state, did, Some("leaver.example"));
8792 let app = router(state.clone());
8793
8794 let resp = app
8795 .oneshot(
8796 Request::builder()
8797 .method("POST")
8798 .uri("/account/delete")
8799 .header(header::COOKIE, cookie)
8800 .header("content-type", "application/x-www-form-urlencoded")
8801 .body(Body::from("confirm=DELETE"))
8802 .unwrap(),
8803 )
8804 .await
8805 .unwrap();
8806
8807 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8809 assert!(resp
8810 .headers()
8811 .get(header::LOCATION)
8812 .unwrap()
8813 .to_str()
8814 .unwrap()
8815 .starts_with("/login"));
8816 let set_cookie = resp
8817 .headers()
8818 .get(header::SET_COOKIE)
8819 .unwrap()
8820 .to_str()
8821 .unwrap();
8822 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8823
8824 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8831 .await
8832 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8833 .unwrap();
8834 assert_eq!(
8835 revoked_did, did,
8836 "sidecar revoke must fire for the caller DID"
8837 );
8838
8839 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8841 let codes: i64 =
8842 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8843 .bind(did)
8844 .fetch_one(&state.db)
8845 .await
8846 .unwrap();
8847 assert_eq!(codes, 0);
8848 }
8849
8850 #[tokio::test]
8853 async fn account_delete_without_confirm_is_a_noop() {
8854 let did = "did:plc:staying";
8855 let state = test_state(&[]).await;
8856 store::grant_access(&state.db, did, None, "test", None)
8857 .await
8858 .unwrap();
8859 let cookie = session_cookie(&state, did, None);
8860 let app = router(state.clone());
8861
8862 let resp = app
8863 .oneshot(
8864 Request::builder()
8865 .method("POST")
8866 .uri("/account/delete")
8867 .header(header::COOKIE, cookie)
8868 .header("content-type", "application/x-www-form-urlencoded")
8869 .body(Body::from("confirm=nope"))
8870 .unwrap(),
8871 )
8872 .await
8873 .unwrap();
8874
8875 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8876 assert!(resp
8877 .headers()
8878 .get(header::LOCATION)
8879 .unwrap()
8880 .to_str()
8881 .unwrap()
8882 .starts_with("/manage"));
8883 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8885 }
8886
8887 #[tokio::test]
8894 async fn pds_outage_does_not_widen_cross_did_access() {
8895 let did_a = "did:plc:aaaa";
8896 let state = test_state(&[]).await;
8897 store::grant_access(&state.db, did_a, None, "test", None)
8898 .await
8899 .unwrap();
8900
8901 let feed_a = store::upsert_feed(
8904 &state.db,
8905 &store::NewFeed {
8906 url: "https://a.example/feed.xml".to_string(),
8907 title: Some("A".to_string()),
8908 ..Default::default()
8909 },
8910 )
8911 .await
8912 .unwrap();
8913 let feed_b = store::upsert_feed(
8914 &state.db,
8915 &store::NewFeed {
8916 url: "https://b.example/feed.xml".to_string(),
8917 title: Some("B".to_string()),
8918 ..Default::default()
8919 },
8920 )
8921 .await
8922 .unwrap();
8923 store::insert_entries(
8924 &state.db,
8925 feed_b,
8926 &[store::NewEntry {
8927 guid: "b-1".to_string(),
8928 url: Some("https://b.example/1".to_string()),
8929 title: Some("B one".to_string()),
8930 published: Some("2026-07-11T00:00:00Z".to_string()),
8931 content_html: Some("<p>secret B body</p>".to_string()),
8932 ..Default::default()
8933 }],
8934 0,
8935 )
8936 .await
8937 .unwrap();
8938 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8940 .await
8941 .unwrap();
8942 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8945 .await
8946 .unwrap();
8947 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8948 .await
8949 .unwrap()[0]
8950 .id;
8951 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8952 .await
8953 .unwrap();
8954
8955 let cookie = session_cookie(&state, did_a, None);
8956 let app = router(state.clone());
8957
8958 let get_b = app
8960 .clone()
8961 .oneshot(
8962 Request::builder()
8963 .method("GET")
8964 .uri(format!("/entries/{b_entry_id}"))
8965 .header(header::COOKIE, cookie.clone())
8966 .body(Body::empty())
8967 .unwrap(),
8968 )
8969 .await
8970 .unwrap();
8971 assert_eq!(
8972 get_b.status(),
8973 StatusCode::NOT_FOUND,
8974 "A must not read B's entry during a PDS outage"
8975 );
8976
8977 let read_b = app
8979 .oneshot(
8980 Request::builder()
8981 .method("POST")
8982 .uri(format!("/entries/{b_entry_id}/read"))
8983 .header(header::COOKIE, cookie)
8984 .header("content-type", "application/x-www-form-urlencoded")
8985 .body(Body::from("read=true"))
8986 .unwrap(),
8987 )
8988 .await
8989 .unwrap();
8990 assert_eq!(
8991 read_b.status(),
8992 StatusCode::NOT_FOUND,
8993 "A must not mark B's entry read during a PDS outage"
8994 );
8995
8996 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8998 .bind(did_a)
8999 .fetch_all(&state.db)
9000 .await
9001 .unwrap();
9002 assert_eq!(
9003 a_feed_ids,
9004 vec![feed_a],
9005 "outage fallback must not add feeds A never subscribed to"
9006 );
9007 let es_count: i64 =
9009 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9010 .bind(did_a)
9011 .bind(b_entry_id)
9012 .fetch_one(&state.db)
9013 .await
9014 .unwrap();
9015 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9016 }
9017
9018 #[tokio::test]
9032 async fn a_logout_with_no_session_counts_as_success() {
9033 let did = "did:plc:aaaa";
9034 let state = test_state(&[]).await;
9035 assert!(
9036 state.oauth.is_some(),
9037 "meaningless without an oauth runtime; the revoke arm would be skipped",
9038 );
9039
9040 revoke_everywhere(&state, did).await;
9041 let rows = state.metrics.snapshot();
9042 let find = |b: crate::metrics::Backend| {
9043 rows.iter()
9044 .find(|r| r.op == "oauth_revoke" && r.backend == b)
9045 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9046 };
9047
9048 let rust = find(crate::metrics::Backend::Rust);
9050 assert_eq!(
9051 rust.stats.err_count, 0,
9052 "NoSession was counted as a failure; logout is idempotent",
9053 );
9054 assert_eq!(rust.stats.ok_count, 1);
9055
9056 let sidecar = find(crate::metrics::Backend::Sidecar);
9060 assert_eq!(
9061 sidecar.stats.err_count, 1,
9062 "a failed sidecar revoke was not counted",
9063 );
9064 }
9065
9066 #[tokio::test]
9076 async fn a_failed_rust_revoke_counts_as_an_error() {
9077 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9078 let state = test_state(&[]).await;
9079 let runtime = state.oauth.as_deref().expect("oauth runtime");
9080 crate::oauth::store::put_session(
9081 &state.db,
9082 &runtime.codec,
9083 &crate::oauth::store::OAuthSession {
9084 sub: did.into(),
9085 issuer: "https://auth.invalid".into(),
9086 aud: "https://pds.invalid".into(),
9087 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9088 .to_jwk_json()
9089 .unwrap(),
9090 access_token: "at".into(),
9091 refresh_token: "rt".into(),
9092 token_type: "DPoP".into(),
9093 granted_scope: "atproto".into(),
9094 expires_at: Some(crate::store::now_unix() + 3600),
9095 },
9096 )
9097 .await
9098 .unwrap();
9099
9100 revoke_everywhere(&state, did).await;
9101
9102 let rows = state.metrics.snapshot();
9103 let rust = rows
9104 .iter()
9105 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9106 .expect("no rust oauth_revoke row");
9107 assert_eq!(
9108 rust.stats.err_count, 1,
9109 "an unreachable PDS must count as a revocation failure",
9110 );
9111 assert_eq!(rust.stats.ok_count, 0);
9112 }
9113
9114 #[test]
9130 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9131 for hostile in [
9132 "javascript:alert(1)",
9133 "JavaScript:alert(1)",
9134 " javascript:alert(1)",
9135 "data:text/html;base64,PHNjcmlwdD4=",
9136 "vbscript:msgbox(1)",
9137 "file:///etc/passwd",
9138 "//evil.example/path",
9142 ] {
9143 let link = SafeLink::external(hostile);
9144 assert!(
9145 link.is_empty(),
9146 "{hostile:?} produced a non-empty href: {link}",
9147 );
9148 assert!(
9149 !link.to_string().to_ascii_lowercase().contains("script"),
9150 "{hostile:?} leaked into the rendered link",
9151 );
9152 }
9153
9154 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9157 let link = SafeLink::external(good);
9158 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9159 assert_eq!(link.to_string(), good);
9160 }
9161 }
9162
9163 #[tokio::test]
9178 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9179 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9180 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9181 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9182 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9183
9184 let resp = router(state)
9185 .oneshot(
9186 Request::builder()
9187 .uri("/?view=starred")
9188 .body(Body::empty())
9189 .unwrap(),
9190 )
9191 .await
9192 .unwrap();
9193 assert_eq!(resp.status(), StatusCode::OK);
9194 let body = String::from_utf8(
9195 axum::body::to_bytes(resp.into_body(), usize::MAX)
9196 .await
9197 .unwrap()
9198 .to_vec(),
9199 )
9200 .unwrap();
9201
9202 assert!(
9205 !body.to_ascii_lowercase().contains("javascript:"),
9206 "the hostile scheme reached the rendered page",
9207 );
9208 assert!(
9211 body.contains("unusable link"),
9212 "the row was dropped instead of rendering without an anchor",
9213 );
9214 }
9215
9216 #[tokio::test]
9233 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9234 let did = "did:plc:readerhref";
9235 let state = test_state(&[]).await;
9236 store::grant_access(&state.db, did, None, "test", None)
9237 .await
9238 .unwrap();
9239 let feed = store::upsert_feed(
9240 &state.db,
9241 &store::NewFeed {
9242 url: "https://href.example/feed.xml".to_string(),
9243 title: Some("Href".to_string()),
9244 ..Default::default()
9245 },
9246 )
9247 .await
9248 .unwrap();
9249 store::insert_entries(
9251 &state.db,
9252 feed,
9253 &[
9254 store::NewEntry {
9255 guid: "hostile-1".to_string(),
9256 url: Some("javascript:alert(1)".to_string()),
9257 title: Some("Hostile entry".to_string()),
9258 published: Some("2026-07-11T00:00:00Z".to_string()),
9259 ..Default::default()
9260 },
9261 store::NewEntry {
9262 guid: "benign-1".to_string(),
9263 url: Some("https://href.example/post".to_string()),
9264 title: Some("Benign entry".to_string()),
9265 published: Some("2026-07-10T00:00:00Z".to_string()),
9266 ..Default::default()
9267 },
9268 ],
9269 0,
9270 )
9271 .await
9272 .unwrap();
9273 store::replace_sub_refs(&state.db, did, &[feed])
9274 .await
9275 .unwrap();
9276 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9277 let id_of = |guid: &str| {
9278 rows.iter()
9279 .find(|r| r.guid == guid)
9280 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9281 .id
9282 };
9283
9284 let cookie = session_cookie(&state, did, None);
9285 let app = router(state.clone());
9286
9287 let render = |id: i64| {
9288 let app = app.clone();
9289 let cookie = cookie.clone();
9290 async move {
9291 let resp = app
9292 .oneshot(
9293 Request::builder()
9294 .method("GET")
9295 .uri(format!("/entries/{id}"))
9296 .header(header::COOKIE, cookie)
9297 .body(Body::empty())
9298 .unwrap(),
9299 )
9300 .await
9301 .unwrap();
9302 assert_eq!(resp.status(), StatusCode::OK);
9303 String::from_utf8(
9304 axum::body::to_bytes(resp.into_body(), usize::MAX)
9305 .await
9306 .unwrap()
9307 .to_vec(),
9308 )
9309 .unwrap()
9310 }
9311 };
9312
9313 let hostile = render(id_of("hostile-1")).await;
9314 assert!(
9317 hostile.contains("Hostile entry"),
9318 "the reader did not render the entry: {hostile}",
9319 );
9320 assert!(
9321 !hostile.to_ascii_lowercase().contains("javascript:"),
9322 "the hostile scheme reached the reader page: {hostile}",
9323 );
9324 assert!(
9328 !hostile.contains("actionbar-open"),
9329 "the action bar rendered an open-original link for a refused URL: {hostile}",
9330 );
9331 assert!(
9332 !hostile.contains("Original \u{2197}"),
9333 "the byline rendered an original link for a refused URL: {hostile}",
9334 );
9335
9336 let benign = render(id_of("benign-1")).await;
9339 assert!(
9340 benign.contains("Benign entry"),
9341 "the reader did not render the benign entry: {benign}",
9342 );
9343 assert_eq!(
9347 benign
9348 .matches(r#"href="https://href.example/post""#)
9349 .count(),
9350 2,
9351 "entry.html has two `href`s for the entry URL — the byline link and \
9352 the action-bar button — and this render produced a different \
9353 number: {benign}",
9354 );
9355 assert!(
9356 benign.contains("actionbar-open"),
9357 "a legitimate entry lost its open-original button: {benign}",
9358 );
9359 assert!(
9360 benign.contains("Original \u{2197}"),
9361 "a legitimate entry lost its byline link: {benign}",
9362 );
9363 }
9364
9365 #[tokio::test]
9385 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9386 let did_a = "did:plc:aaaa";
9387 let state = test_state(&[]).await;
9388 store::grant_access(&state.db, did_a, None, "test", None)
9389 .await
9390 .unwrap();
9391
9392 let feed_a = store::upsert_feed(
9393 &state.db,
9394 &store::NewFeed {
9395 url: "https://a.example/feed.xml".to_string(),
9396 title: Some("A".to_string()),
9397 ..Default::default()
9398 },
9399 )
9400 .await
9401 .unwrap();
9402 let _feed_b = store::upsert_feed(
9403 &state.db,
9404 &store::NewFeed {
9405 url: "https://b.example/feed.xml".to_string(),
9406 title: Some("B".to_string()),
9407 ..Default::default()
9408 },
9409 )
9410 .await
9411 .unwrap();
9412 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9415 .await
9416 .unwrap();
9417
9418 assert!(
9423 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9424 "this test is only meaningful on the outage path; the repo answered",
9425 );
9426
9427 let resolved = resolve_subscriptions(&state, did_a).await;
9428
9429 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9430 assert_eq!(
9431 urls,
9432 vec!["https://a.example/feed.xml"],
9433 "the outage fallback must return the caller's OWN subscriptions only; \
9434 any other feed here is cross-tenant read access granted by an outage",
9435 );
9436 }
9437
9438 async fn test_state_with_caps(
9441 did: &str,
9442 max_subs_per_did: i64,
9443 max_feeds_global: i64,
9444 ) -> AppState {
9445 let db = store::init_url("sqlite::memory:").await.unwrap();
9446 let config = Config {
9447 cookie_secret: "test-cookie-secret-000".to_string(),
9448 beta_cap: 100,
9449 max_subs_per_did,
9450 max_feeds_global,
9451 ..Config::default()
9452 };
9453 store::grant_access(&db, did, None, "test", None)
9454 .await
9455 .unwrap();
9456 AppState::new(config, db).unwrap()
9457 }
9458
9459 fn opml_with_feeds(n: usize) -> String {
9461 let mut outlines = String::new();
9462 for i in 0..n {
9463 outlines.push_str(&format!(
9464 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9465 ));
9466 }
9467 format!(
9468 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9469 )
9470 }
9471
9472 #[tokio::test]
9477 async fn opml_import_enforces_global_feeds_ceiling() {
9478 let did = "did:plc:importer";
9479 let state = test_state_with_caps(did, 0, 3).await;
9481 let cookie = session_cookie(&state, did, None);
9482 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9483 let app = router(state.clone());
9484
9485 let resp = app
9486 .oneshot(
9487 Request::builder()
9488 .method("POST")
9489 .uri("/opml")
9490 .header(header::COOKIE, cookie)
9491 .header("content-type", ct)
9492 .body(Body::from(body))
9493 .unwrap(),
9494 )
9495 .await
9496 .unwrap();
9497 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9498
9499 let feeds = store::count_feeds(&state.db).await.unwrap();
9500 assert!(
9501 feeds <= 3,
9502 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9503 );
9504 }
9505
9506 async fn import_against_strict_pds(
9509 did: &str,
9510 n: usize,
9511 fail_call: Option<usize>,
9512 ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9513 let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9514 let state = test_state_with_sidecar(&[did], &sidecar).await;
9515 let cookie = session_cookie(&state, did, None);
9516 let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9517 let resp = router(state)
9518 .oneshot(
9519 Request::builder()
9520 .method("POST")
9521 .uri("/opml")
9522 .header(header::COOKIE, cookie)
9523 .header("content-type", ct)
9524 .body(Body::from(body))
9525 .unwrap(),
9526 )
9527 .await
9528 .unwrap();
9529 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9530 let loc = resp.headers()[header::LOCATION].to_str().unwrap();
9531 let flash = url::Url::parse(&format!("http://x{loc}"))
9532 .unwrap()
9533 .query_pairs()
9534 .find(|(k, _)| k == "flash")
9535 .map(|(_, v)| v.into_owned())
9536 .unwrap_or_default();
9537 (flash, log)
9538 }
9539
9540 #[tokio::test]
9544 async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
9545 let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
9546 assert_eq!(flash, "Imported 450 feeds", "{flash}");
9547 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
9548 }
9549
9550 #[tokio::test]
9554 async fn opml_import_that_part_lands_reports_what_landed() {
9555 let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
9556 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
9557 assert!(
9558 flash.contains("200 of 450"),
9559 "the landed count is not reported: {flash}"
9560 );
9561 assert!(
9562 !flash.contains("nothing was imported"),
9563 "200 feeds landed and the reader was told none did: {flash}"
9564 );
9565 }
9566
9567 #[tokio::test]
9570 async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
9571 let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
9572 assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
9573 assert!(flash.contains("nothing was imported"), "{flash}");
9574 }
9575
9576 #[tokio::test]
9585 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9586 let did = "did:plc:typoist";
9587 let state = test_state_with_caps(did, 0, 0).await;
9588 let cookie = session_cookie(&state, did, None);
9589 for input in [
9590 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9591 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9592 ] {
9593 let resp = router(state.clone())
9594 .oneshot(
9595 Request::builder()
9596 .method("POST")
9597 .uri("/subscriptions")
9598 .header(header::COOKIE, cookie.clone())
9599 .header("content-type", "application/x-www-form-urlencoded")
9600 .body(Body::from(format!("url={input}")))
9601 .unwrap(),
9602 )
9603 .await
9604 .unwrap();
9605 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9606 let loc = resp
9607 .headers()
9608 .get(header::LOCATION)
9609 .unwrap()
9610 .to_str()
9611 .unwrap();
9612 assert!(
9613 loc.contains("kind%20of%20feed"),
9614 "expected the unsupported-feed flash for {input}, got {loc}"
9615 );
9616 assert!(
9617 !loc.contains("Private"),
9618 "a storability refusal was reported as a privacy one for {input}: {loc}"
9619 );
9620 }
9621 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9622 }
9623
9624 #[tokio::test]
9631 async fn opml_import_reports_entries_this_instance_cannot_store() {
9632 let did = "did:plc:renamer4";
9633 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9634 let state = test_state_with_sidecar(&[did], &sidecar).await;
9635 assert!(!state.config.standard_site);
9636 let opml = format!(
9637 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9638 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9639 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9640 </body></opml>"
9641 );
9642 let (ct, body) = opml_multipart(opml.as_bytes());
9643 let cookie = session_cookie(&state, did, None);
9644 let resp = router(state.clone())
9645 .oneshot(
9646 Request::builder()
9647 .method("POST")
9648 .uri("/opml")
9649 .header(header::COOKIE, cookie)
9650 .header("content-type", ct)
9651 .body(Body::from(body))
9652 .unwrap(),
9653 )
9654 .await
9655 .unwrap();
9656 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9657 let loc = resp
9658 .headers()
9659 .get(header::LOCATION)
9660 .unwrap()
9661 .to_str()
9662 .unwrap();
9663 assert!(
9664 loc.contains("Imported%201%20feed"),
9665 "unexpected flash: {loc}"
9666 );
9667 assert!(
9668 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9669 "the dropped entry was not reported: {loc}"
9670 );
9671 assert!(
9673 !loc.contains("site.standard.publication"),
9674 "the URI was echoed: {loc}"
9675 );
9676 }
9677
9678 #[tokio::test]
9681 async fn opml_import_enforces_per_did_cap() {
9682 let did = "did:plc:capped";
9683 let state = test_state_with_caps(did, 2, 0).await;
9685 let existing_a = store::upsert_feed(
9686 &state.db,
9687 &store::NewFeed {
9688 url: "https://have-a.example/feed.xml".to_string(),
9689 ..Default::default()
9690 },
9691 )
9692 .await
9693 .unwrap();
9694 let existing_b = store::upsert_feed(
9695 &state.db,
9696 &store::NewFeed {
9697 url: "https://have-b.example/feed.xml".to_string(),
9698 ..Default::default()
9699 },
9700 )
9701 .await
9702 .unwrap();
9703 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9704 .await
9705 .unwrap();
9706 let before = store::count_feeds(&state.db).await.unwrap();
9707
9708 let cookie = session_cookie(&state, did, None);
9709 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9710 let app = router(state.clone());
9711 let resp = app
9712 .oneshot(
9713 Request::builder()
9714 .method("POST")
9715 .uri("/opml")
9716 .header(header::COOKIE, cookie)
9717 .header("content-type", ct)
9718 .body(Body::from(body))
9719 .unwrap(),
9720 )
9721 .await
9722 .unwrap();
9723 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9724 let after = store::count_feeds(&state.db).await.unwrap();
9726 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9727 }
9728
9729 #[tokio::test]
9732 async fn single_add_enforces_per_did_cap() {
9733 let did = "did:plc:subcapped";
9734 let state = test_state_with_caps(did, 1, 0).await;
9735 let f = store::upsert_feed(
9736 &state.db,
9737 &store::NewFeed {
9738 url: "https://have.example/feed.xml".to_string(),
9739 ..Default::default()
9740 },
9741 )
9742 .await
9743 .unwrap();
9744 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9745 let cookie = session_cookie(&state, did, None);
9746 let app = router(state.clone());
9747 let resp = app
9748 .oneshot(
9749 Request::builder()
9750 .method("POST")
9751 .uri("/subscriptions")
9752 .header(header::COOKIE, cookie)
9753 .header("content-type", "application/x-www-form-urlencoded")
9754 .body(Body::from("url=https://another.example/feed.xml"))
9755 .unwrap(),
9756 )
9757 .await
9758 .unwrap();
9759 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9760 let loc = resp
9761 .headers()
9762 .get(header::LOCATION)
9763 .unwrap()
9764 .to_str()
9765 .unwrap();
9766 assert!(
9767 loc.contains("Subscription%20limit%20reached"),
9768 "expected sub-limit flash, got {loc}"
9769 );
9770 }
9771
9772 #[tokio::test]
9781 async fn the_reader_index_pages_instead_of_rendering_everything() {
9782 let did = "did:plc:pager";
9783 let state = test_state(&[]).await;
9784 store::grant_access(&state.db, did, None, "test", None)
9785 .await
9786 .unwrap();
9787 let feed = store::upsert_feed(
9788 &state.db,
9789 &store::NewFeed {
9790 url: "https://pager.example/feed.xml".to_string(),
9791 title: Some("Pager".to_string()),
9792 ..Default::default()
9793 },
9794 )
9795 .await
9796 .unwrap();
9797 let total = 250_usize;
9798 let entries: Vec<store::NewEntry> = (0..total)
9799 .map(|i| store::NewEntry {
9800 guid: format!("p-{i:04}"),
9801 url: Some(format!("https://pager.example/{i}")),
9802 title: Some(format!("Article {i:04}")),
9803 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9804 content_html: Some("x".repeat(4_000)),
9805 ..Default::default()
9806 })
9807 .collect();
9808 store::insert_entries(&state.db, feed, &entries, 0)
9809 .await
9810 .unwrap();
9811 store::replace_sub_refs(&state.db, did, &[feed])
9812 .await
9813 .unwrap();
9814
9815 let cookie = session_cookie(&state, did, None);
9816 let app = router(state.clone());
9817 let get = |uri: &str| {
9818 let app = app.clone();
9819 let cookie = cookie.clone();
9820 let uri = uri.to_string();
9821 async move {
9822 let resp = app
9823 .oneshot(
9824 Request::builder()
9825 .uri(uri)
9826 .header(header::COOKIE, cookie)
9827 .body(Body::empty())
9828 .unwrap(),
9829 )
9830 .await
9831 .unwrap();
9832 assert_eq!(resp.status(), StatusCode::OK);
9833 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9834 .await
9835 .unwrap();
9836 String::from_utf8(bytes.to_vec()).unwrap()
9837 }
9838 };
9839
9840 let page1 = get("/").await;
9841 let rows1 = page1.matches("<li class=\"entry").count();
9845 assert!(
9846 rows1 <= ENTRIES_PER_PAGE as usize,
9847 "page 1 rendered {rows1} entry links; the list is unbounded"
9848 );
9849 assert!(
9850 rows1 > 0,
9851 "page 1 rendered nothing at all: the page bound swallowed the list"
9852 );
9853 assert!(
9856 page1.contains("250 entries"),
9857 "heading must report the full total, not the page"
9858 );
9859 assert!(
9860 page1.contains("page=2"),
9861 "no way to reach the rest of the list: {}",
9862 &page1[..page1.len().min(400)]
9863 );
9864 assert!(
9866 !page1.contains(&"x".repeat(4_000)),
9867 "the list response carried an article body"
9868 );
9869
9870 let page2 = get("/?page=2").await;
9871 assert!(
9872 page2.matches("<li class=\"entry").count() > 0,
9873 "page 2 rendered no rows at all"
9874 );
9875 assert!(
9876 page2.contains("page=1") || page2.contains("Newer"),
9877 "page 2 offers no way back"
9878 );
9879 let first_title = (0..total)
9881 .map(|i| format!("Article {i:04}"))
9882 .find(|t| page1.contains(t))
9883 .expect("page 1 shows at least one titled article");
9884 assert!(
9885 !page2.contains(&first_title),
9886 "{first_title} appears on both pages"
9887 );
9888
9889 let past_end = get("/?page=999").await;
9895 assert!(
9896 past_end.matches("<li class=\"entry").count() > 0,
9897 "an out-of-range page rendered nothing and offered no way back"
9898 );
9899 assert!(
9900 past_end.contains("page=2"),
9901 "the clamped page offers no pager"
9902 );
9903 }
9904
9905 #[tokio::test]
9912 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9913 let did = "did:plc:reader";
9914 let state = test_state(&[]).await;
9915 store::grant_access(&state.db, did, None, "test", None)
9916 .await
9917 .unwrap();
9918 let feed = store::upsert_feed(
9919 &state.db,
9920 &store::NewFeed {
9921 url: "https://reader.example/feed.xml".to_string(),
9922 title: Some("Reader".to_string()),
9923 ..Default::default()
9924 },
9925 )
9926 .await
9927 .unwrap();
9928 store::insert_entries(
9929 &state.db,
9930 feed,
9931 &[store::NewEntry {
9932 guid: "r-1".to_string(),
9933 url: Some("https://reader.example/1".to_string()),
9934 title: Some("Article".to_string()),
9935 published: Some("2026-07-11T00:00:00Z".to_string()),
9936 content_html: Some("<p>body</p>".to_string()),
9937 ..Default::default()
9938 }],
9939 0,
9940 )
9941 .await
9942 .unwrap();
9943 store::replace_sub_refs(&state.db, did, &[feed])
9944 .await
9945 .unwrap();
9946 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9947
9948 let cookie = session_cookie(&state, did, None);
9949 let app = router(state.clone());
9950
9951 let resp = app
9953 .clone()
9954 .oneshot(
9955 Request::builder()
9956 .method("POST")
9957 .uri(format!("/entries/{entry_id}/read"))
9958 .header(header::COOKIE, cookie.clone())
9959 .header("HX-Request", "true")
9960 .header("X-FR-Reader", "1")
9961 .header("content-type", "application/x-www-form-urlencoded")
9962 .body(Body::from("read=true"))
9963 .unwrap(),
9964 )
9965 .await
9966 .unwrap();
9967 assert_eq!(resp.status(), StatusCode::OK);
9968 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9969 .await
9970 .unwrap();
9971 let html = String::from_utf8(bytes.to_vec()).unwrap();
9972 assert!(
9973 html.contains("hx-swap-oob=\"outerHTML\""),
9974 "reader response must be an OOB swap: {html}"
9975 );
9976 assert!(
9977 html.contains(r#"id="entry-actionbar""#),
9978 "reader response must be the action-bar fragment: {html}"
9979 );
9980 assert!(
9983 html.contains(r#"aria-pressed="true""#),
9984 "read button must show pressed after marking read: {html}"
9985 );
9986 assert!(
9987 html.contains(r#"name="read" value="false""#),
9988 "hidden read value must flip to false so a second tap reverses: {html}"
9989 );
9990
9991 let resp2 = app
9994 .oneshot(
9995 Request::builder()
9996 .method("POST")
9997 .uri(format!("/entries/{entry_id}/read"))
9998 .header(header::COOKIE, cookie)
9999 .header("HX-Request", "true")
10000 .header("X-FR-Reader", "1")
10001 .header("content-type", "application/x-www-form-urlencoded")
10002 .body(Body::from("read=false"))
10003 .unwrap(),
10004 )
10005 .await
10006 .unwrap();
10007 assert_eq!(resp2.status(), StatusCode::OK);
10008 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10009 .await
10010 .unwrap();
10011 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10012 assert!(
10013 html2.contains(r#"aria-pressed="false""#),
10014 "read button must show un-pressed after reversing: {html2}"
10015 );
10016 assert!(
10017 html2.contains(r#"name="read" value="true""#),
10018 "hidden read value must flip back to true: {html2}"
10019 );
10020 }
10021
10022 #[tokio::test]
10025 async fn list_mark_read_returns_row_not_oob_actionbar() {
10026 let did = "did:plc:listv";
10027 let state = test_state(&[]).await;
10028 store::grant_access(&state.db, did, None, "test", None)
10029 .await
10030 .unwrap();
10031 let feed = store::upsert_feed(
10032 &state.db,
10033 &store::NewFeed {
10034 url: "https://list.example/feed.xml".to_string(),
10035 title: Some("List".to_string()),
10036 ..Default::default()
10037 },
10038 )
10039 .await
10040 .unwrap();
10041 store::insert_entries(
10042 &state.db,
10043 feed,
10044 &[store::NewEntry {
10045 guid: "l-1".to_string(),
10046 url: Some("https://list.example/1".to_string()),
10047 title: Some("Article".to_string()),
10048 published: Some("2026-07-11T00:00:00Z".to_string()),
10049 ..Default::default()
10050 }],
10051 0,
10052 )
10053 .await
10054 .unwrap();
10055 store::replace_sub_refs(&state.db, did, &[feed])
10056 .await
10057 .unwrap();
10058 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10059
10060 let cookie = session_cookie(&state, did, None);
10061 let app = router(state.clone());
10062
10063 let resp = app
10064 .oneshot(
10065 Request::builder()
10066 .method("POST")
10067 .uri(format!("/entries/{entry_id}/read"))
10068 .header(header::COOKIE, cookie)
10069 .header("HX-Request", "true")
10070 .header("content-type", "application/x-www-form-urlencoded")
10071 .body(Body::from("read=true"))
10072 .unwrap(),
10073 )
10074 .await
10075 .unwrap();
10076 assert_eq!(resp.status(), StatusCode::OK);
10077 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10078 .await
10079 .unwrap();
10080 let html = String::from_utf8(bytes.to_vec()).unwrap();
10081 assert!(
10082 !html.contains("hx-swap-oob"),
10083 "list-view response must NOT be an OOB swap: {html}"
10084 );
10085 assert!(
10090 html.contains(&format!("/entries/{entry_id}")),
10091 "the response is not the row for this entry: {html}",
10092 );
10093 assert!(
10094 html.contains("Article"),
10095 "the row rendered without its title: {html}",
10096 );
10097 assert!(
10114 html.contains("is-read"),
10115 "the row came back without the read state it was just given: {html}",
10116 );
10117 }
10118
10119 #[tokio::test]
10144 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10145 let did = "did:plc:autodiscovered";
10146 let state = test_state_with_caps(did, 0, 0).await;
10149
10150 let page = r#"<!doctype html><html><head><title>Blog</title>
10151 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10152 </head><body>hi</body></html>"#;
10153 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10154 let port: u16 = base
10155 .trim_end_matches('/')
10156 .rsplit(':')
10157 .next()
10158 .unwrap()
10159 .parse()
10160 .unwrap();
10161 crate::net::test_host_override(
10162 "autodiscover-ftp.test",
10163 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10164 );
10165
10166 let cookie = session_cookie(&state, did, None);
10167 let resp = router(state.clone())
10168 .oneshot(
10169 Request::builder()
10170 .method("POST")
10171 .uri("/subscriptions")
10172 .header(header::COOKIE, cookie)
10173 .header("content-type", "application/x-www-form-urlencoded")
10174 .body(Body::from(format!(
10175 "url=http://autodiscover-ftp.test:{port}/"
10176 )))
10177 .unwrap(),
10178 )
10179 .await
10180 .unwrap();
10181 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10182 let loc = resp
10183 .headers()
10184 .get(header::LOCATION)
10185 .unwrap()
10186 .to_str()
10187 .unwrap();
10188 assert_ne!(loc, "/login", "the test never reached the add path");
10189 assert_ne!(loc, "/", "the subscribe succeeded");
10190
10191 assert_eq!(
10192 store::count_feeds(&state.db).await.unwrap(),
10193 0,
10194 "a non-http(s) URL from autodiscovery was stored"
10195 );
10196 assert_eq!(
10197 store::count_subscriptions_for_did(&state.db, did)
10198 .await
10199 .unwrap(),
10200 0
10201 );
10202 }
10203
10204 #[tokio::test]
10209 async fn rename_to_new_url_refused_at_global_feeds_cap() {
10210 let did = "did:plc:renamer4";
10211 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10212 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10214 store::upsert_feed(
10215 &state.db,
10216 &store::NewFeed {
10217 url: "https://existing.example/feed.xml".to_string(),
10218 ..Default::default()
10219 },
10220 )
10221 .await
10222 .unwrap();
10223 let before = store::count_feeds(&state.db).await.unwrap();
10224 assert_eq!(before, 1);
10225
10226 let cookie = session_cookie(&state, did, None);
10227 let resp = router(state.clone())
10228 .oneshot(
10229 Request::builder()
10230 .method("POST")
10231 .uri("/subscriptions/rk-keep/rename")
10232 .header(header::COOKIE, cookie)
10233 .header("content-type", "application/x-www-form-urlencoded")
10234 .body(Body::from(
10236 "url=https://brand-new.example/feed.xml&title=Renamed",
10237 ))
10238 .unwrap(),
10239 )
10240 .await
10241 .unwrap();
10242 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10243 let loc = resp
10244 .headers()
10245 .get(header::LOCATION)
10246 .unwrap()
10247 .to_str()
10248 .unwrap();
10249 assert!(
10250 loc.contains("feed%20capacity"),
10251 "expected the feed-capacity flash, got {loc}"
10252 );
10253 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10255 assert!(
10256 puts.lock().unwrap().is_empty(),
10257 "a refused repoint reached the PDS"
10258 );
10259 }
10260
10261 #[tokio::test]
10268 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10269 let did = "did:plc:renamer4";
10270 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10271 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10272 store::upsert_feed(
10273 &state.db,
10274 &store::NewFeed {
10275 url: "https://existing.example/feed.xml".to_string(),
10276 ..Default::default()
10277 },
10278 )
10279 .await
10280 .unwrap();
10281 let before = store::count_feeds(&state.db).await.unwrap();
10282
10283 let cookie = session_cookie(&state, did, None);
10284 let resp = router(state.clone())
10285 .oneshot(
10286 Request::builder()
10287 .method("POST")
10288 .uri("/subscriptions/rk-keep/rename")
10289 .header(header::COOKIE, cookie)
10290 .header("content-type", "application/x-www-form-urlencoded")
10291 .body(Body::from(
10292 "url=https://existing.example/feed.xml&title=Retitled",
10293 ))
10294 .unwrap(),
10295 )
10296 .await
10297 .unwrap();
10298 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10299 let loc = resp
10300 .headers()
10301 .get(header::LOCATION)
10302 .unwrap()
10303 .to_str()
10304 .unwrap();
10305 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10306 assert_eq!(
10307 puts.lock().unwrap().len(),
10308 1,
10309 "the repoint did not reach the PDS"
10310 );
10311 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10312 }
10313
10314 #[tokio::test]
10316 async fn rename_with_blank_url_writes_nothing() {
10317 let did = "did:plc:renamer3";
10318 let state = test_state_with_caps(did, 0, 0).await;
10319 let before = store::count_feeds(&state.db).await.unwrap();
10320 assert_eq!(before, 0);
10321
10322 let cookie = session_cookie(&state, did, None);
10323 let app = router(state.clone());
10324 let resp = app
10325 .oneshot(
10326 Request::builder()
10327 .method("POST")
10328 .uri("/subscriptions/rkey123/rename")
10329 .header(header::COOKIE, cookie)
10330 .header("content-type", "application/x-www-form-urlencoded")
10331 .body(Body::from("url=%20%20&title=Nope"))
10333 .unwrap(),
10334 )
10335 .await
10336 .unwrap();
10337 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10338 assert_eq!(
10339 resp.headers()
10340 .get(header::LOCATION)
10341 .unwrap()
10342 .to_str()
10343 .unwrap(),
10344 "/",
10345 );
10346 assert_eq!(
10348 store::count_feeds(&state.db).await.unwrap(),
10349 0,
10350 "blank-URL rename wrote a junk feeds row"
10351 );
10352 }
10353
10354 async fn spawn_rename_sidecar(
10363 existing: serde_json::Value,
10364 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10365 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10366 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10367 let addr = listener.local_addr().unwrap();
10368 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10369 let sink = puts.clone();
10370 tokio::spawn(async move {
10371 loop {
10372 let Ok((mut sock, _)) = listener.accept().await else {
10373 break;
10374 };
10375 let mut raw: Vec<u8> = Vec::new();
10376 let mut chunk = [0u8; 4096];
10377 let body_text = loop {
10378 let Ok(n) = sock.read(&mut chunk).await else {
10379 break String::new();
10380 };
10381 if n == 0 {
10382 break String::from_utf8_lossy(&raw).to_string();
10383 }
10384 raw.extend_from_slice(&chunk[..n]);
10385 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10386 continue;
10387 };
10388 let (head, body) = raw.split_at(split + 4);
10389 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10390 let (k, v) = l.split_once(':')?;
10391 k.eq_ignore_ascii_case("content-length")
10392 .then(|| v.trim().parse::<usize>().ok())?
10393 });
10394 if want.is_none_or(|want| body.len() >= want) {
10395 break String::from_utf8_lossy(body).to_string();
10396 }
10397 };
10398
10399 let is_put = body_text.contains("\"action\":\"put\"");
10401 let data = if is_put {
10402 sink.lock().unwrap().push(body_text.clone());
10403 serde_json::json!({
10404 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10405 "cid": "bafyreiafter"
10406 })
10407 } else {
10408 serde_json::json!({ "records": [existing.clone()] })
10409 };
10410 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10411 let resp = format!(
10412 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10413 body.len(),
10414 body
10415 );
10416 let _ = sock.write_all(resp.as_bytes()).await;
10417 let _ = sock.flush().await;
10418 }
10419 });
10420 (format!("http://{addr}"), puts)
10421 }
10422
10423 fn seeded_subscription() -> serde_json::Value {
10425 serde_json::json!({
10426 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10427 "cid": "bafyreibefore",
10428 "value": {
10429 "$type": "community.lexicon.rss.subscription",
10430 "url": "https://example.com/feed.xml",
10431 "title": "Old title",
10432 "siteUrl": "https://example.com/blog",
10433 "fetchHint": "hourly",
10434 "private": false,
10435 "createdAt": "2024-03-01T00:00:00.000Z"
10436 }
10437 })
10438 }
10439
10440 fn seeded_at_uri_subscription() -> serde_json::Value {
10443 seeded_subscription_with_url(AT_URI_SUB)
10444 }
10445 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10447 serde_json::json!({
10448 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10449 "cid": "bafyreibefore",
10450 "value": {
10451 "$type": "community.lexicon.rss.subscription",
10452 "url": url,
10453 "title": "Old title",
10454 "private": false,
10455 "createdAt": "2024-03-01T00:00:00.000Z"
10456 }
10457 })
10458 }
10459 const AT_URI_SUB: &str =
10460 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10461 const AT_URI_SUB_ENC: &str =
10462 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10463
10464 #[tokio::test]
10473 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10474 let did = "did:plc:renamer5";
10475 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10476 let state = test_state_with_sidecar(&[did], &sidecar).await;
10477 assert!(
10478 !state.config.standard_site,
10479 "the flag must be off for this test"
10480 );
10481 let cookie = session_cookie(&state, did, None);
10482 let resp = router(state.clone())
10483 .oneshot(
10484 Request::builder()
10485 .method("POST")
10486 .uri("/subscriptions/rk-keep/rename")
10487 .header(header::COOKIE, cookie)
10488 .header("content-type", "application/x-www-form-urlencoded")
10489 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10490 .unwrap(),
10491 )
10492 .await
10493 .unwrap();
10494 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10495 let loc = resp
10496 .headers()
10497 .get(header::LOCATION)
10498 .unwrap()
10499 .to_str()
10500 .unwrap();
10501 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10502
10503 let bodies = puts.lock().unwrap().clone();
10504 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10505 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10506 assert_eq!(
10507 sent["record"]["title"], "New title",
10508 "the rename did not apply"
10509 );
10510 assert_eq!(
10511 sent["record"]["url"], AT_URI_SUB,
10512 "the rename changed the URL"
10513 );
10514
10515 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10517 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10518 }
10519
10520 #[tokio::test]
10524 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10525 let did = "did:plc:renamer4";
10526 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10527 let state = test_state_with_sidecar(&[did], &sidecar).await;
10528 let cookie = session_cookie(&state, did, None);
10529 let resp = router(state.clone())
10530 .oneshot(
10531 Request::builder()
10532 .method("POST")
10533 .uri("/subscriptions/rk-keep/rename")
10534 .header(header::COOKIE, cookie)
10535 .header("content-type", "application/x-www-form-urlencoded")
10536 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10537 .unwrap(),
10538 )
10539 .await
10540 .unwrap();
10541 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10542 let loc = resp
10543 .headers()
10544 .get(header::LOCATION)
10545 .unwrap()
10546 .to_str()
10547 .unwrap();
10548 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10549 assert!(
10550 !loc.contains("Private"),
10551 "a storability refusal was reported as a privacy one: {loc}"
10552 );
10553 assert!(
10554 puts.lock().unwrap().is_empty(),
10555 "the repoint reached the PDS"
10556 );
10557 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10558 assert_eq!(cached, 0);
10559 }
10560
10561 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10564 let cookie = session_cookie(state, did, None);
10565 let resp = router(state.clone())
10566 .oneshot(
10567 Request::builder()
10568 .method("POST")
10569 .uri("/subscriptions/rk-keep/rename")
10570 .header(header::COOKIE, cookie)
10571 .header("content-type", "application/x-www-form-urlencoded")
10572 .body(Body::from(format!("url={url_enc}&title=New+title")))
10573 .unwrap(),
10574 )
10575 .await
10576 .unwrap();
10577 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10578 resp.headers()
10579 .get(header::LOCATION)
10580 .unwrap()
10581 .to_str()
10582 .unwrap()
10583 .to_string()
10584 }
10585
10586 #[tokio::test]
10596 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10597 let did = "did:plc:renamer5";
10598 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10599 let other_enc =
10600 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10601 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10602 let state = test_state_with_sidecar(&[did], &sidecar).await;
10603 let loc = retitle_unchanged(&state, did, other_enc).await;
10604 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10605 let bodies = puts.lock().unwrap().clone();
10606 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10607 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10608 assert_eq!(sent["record"]["title"], "New title");
10609 assert_eq!(sent["record"]["url"], other);
10610 }
10611
10612 #[tokio::test]
10616 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10617 let did = "did:plc:renamer4";
10618 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10619 let state = test_state_with_sidecar(&[did], &sidecar).await;
10620 let cookie = session_cookie(&state, did, None);
10621 let resp = router(state.clone())
10622 .oneshot(
10623 Request::builder()
10624 .method("POST")
10625 .uri("/subscriptions/rk-keep/rename")
10626 .header(header::COOKIE, cookie)
10627 .header("content-type", "application/x-www-form-urlencoded")
10628 .body(Body::from(
10629 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10630 ))
10631 .unwrap(),
10632 )
10633 .await
10634 .unwrap();
10635 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10636 let loc = resp
10637 .headers()
10638 .get(header::LOCATION)
10639 .unwrap()
10640 .to_str()
10641 .unwrap();
10642 assert!(
10643 loc.contains("Private"),
10644 "the private repoint was not refused: {loc}"
10645 );
10646 assert!(
10647 puts.lock().unwrap().is_empty(),
10648 "a secret-bearing URL reached the PDS"
10649 );
10650 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10653 assert!(store::get_feed_by_url(&state.db, leaked)
10654 .await
10655 .unwrap()
10656 .is_none());
10657 }
10658
10659 #[tokio::test]
10665 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10666 let did = "did:plc:renamer5";
10667 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10668 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10670 store::upsert_feed(
10671 &state.db,
10672 &store::NewFeed {
10673 url: "https://filler.example/feed.xml".to_string(),
10674 ..Default::default()
10675 },
10676 )
10677 .await
10678 .unwrap();
10679 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10680 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10681 assert_eq!(
10682 puts.lock().unwrap().len(),
10683 1,
10684 "the retitle did not reach the PDS"
10685 );
10686 assert_eq!(
10687 store::count_feeds(&state.db).await.unwrap(),
10688 1,
10689 "a row was inserted"
10690 );
10691 }
10692
10693 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10695 let cookie = session_cookie(state, did, None);
10696 let resp = router(state.clone())
10697 .oneshot(
10698 Request::builder()
10699 .method("POST")
10700 .uri("/subscriptions")
10701 .header(header::COOKIE, cookie)
10702 .header("content-type", "application/x-www-form-urlencoded")
10703 .body(Body::from(format!("url={url_enc}")))
10704 .unwrap(),
10705 )
10706 .await
10707 .unwrap();
10708 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10709 resp.headers()
10710 .get(header::LOCATION)
10711 .unwrap()
10712 .to_str()
10713 .unwrap()
10714 .to_string()
10715 }
10716
10717 async fn serve_resolver(did: &str) -> String {
10719 let base = crate::net::tests::serve_body(
10720 serde_json::json!({ "did": did }).to_string().into_bytes(),
10721 )
10722 .await;
10723 let port: u16 = base
10724 .trim_end_matches('/')
10725 .rsplit(':')
10726 .next()
10727 .unwrap()
10728 .parse()
10729 .unwrap();
10730 let host = format!("resolver-{port}.test");
10731 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10732 format!("http://{host}:{port}")
10733 }
10734
10735 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10736 let mut config = (*state.config).clone();
10737 f(&mut config);
10738 state.config = std::sync::Arc::new(config);
10739 state
10740 }
10741
10742 #[tokio::test]
10747 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10748 let did = "did:plc:renamer5";
10749 let (sidecar, log) = spawn_logging_sidecar().await;
10750 let state = with_config(
10751 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10752 |c| {
10753 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10754 },
10755 );
10756 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10757 assert_eq!(loc, "/", "the paste was refused: {loc}");
10758 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10759 .await
10760 .unwrap()
10761 .expect("no feed row");
10762 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10763 let sent = log.lock().unwrap().join("\n");
10764 assert!(
10765 sent.contains(AT_URI_SUB),
10766 "the subscription was not written to the PDS: {sent}"
10767 );
10768 }
10769
10770 #[tokio::test]
10774 async fn a0_subscribing_from_the_form_delivers_entries() {
10775 let did = "did:plc:renamer5";
10776 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10777 let site = AT_URI_SUB;
10778 let (plc, _) = crate::standard_site::tests::serve_repo(
10779 author,
10780 vec![
10781 (
10782 lexicon::nsid::STANDARD_PUBLICATION,
10783 "3lab2c4d5e6f7g8h",
10784 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10785 ),
10786 (
10787 lexicon::nsid::STANDARD_DOCUMENT,
10788 "3l2a0frmaaa2a",
10789 serde_json::json!({ "title": "From the form", "path": "/f",
10790 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10791 ),
10792 ],
10793 )
10794 .await;
10795 let (sidecar, _log) = spawn_logging_sidecar().await;
10796 let state = with_config(
10797 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10798 |c| {
10799 c.oauth.plc_directory = plc;
10800 },
10801 );
10802 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10803 let row = store::get_feed_by_url(&state.db, site)
10804 .await
10805 .unwrap()
10806 .unwrap();
10807 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10808 .bind(row.id)
10809 .fetch_all(&state.db)
10810 .await
10811 .unwrap();
10812 assert_eq!(
10813 titles,
10814 vec!["From the form".to_string()],
10815 "the first poll stored nothing"
10816 );
10817 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10818 }
10819
10820 #[tokio::test]
10823 async fn a_handle_form_paste_is_stored_by_its_did() {
10824 let did = "did:plc:renamer5";
10825 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10826 let (sidecar, _log) = spawn_logging_sidecar().await;
10827 let resolver = serve_resolver(author).await;
10828 let state = with_config(
10829 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10830 |c| {
10831 c.resolver_base = resolver;
10832 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10833 },
10834 );
10835 let loc = subscribe(
10836 &state,
10837 did,
10838 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10839 )
10840 .await;
10841 assert_eq!(loc, "/", "the paste was refused: {loc}");
10842 assert!(
10843 store::get_feed_by_url(&state.db, AT_URI_SUB)
10844 .await
10845 .unwrap()
10846 .is_some(),
10847 "not stored by its DID"
10848 );
10849 assert_eq!(
10850 store::count_feeds(&state.db).await.unwrap(),
10851 1,
10852 "the handle form was stored too"
10853 );
10854 }
10855
10856 async fn serve_counting_resolver(
10858 did: &str,
10859 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10860 let (base, hits) = crate::net::tests::serve_body_counted(
10861 serde_json::json!({ "did": did }).to_string().into_bytes(),
10862 )
10863 .await;
10864 let port: u16 = base
10865 .trim_end_matches('/')
10866 .rsplit(':')
10867 .next()
10868 .unwrap()
10869 .parse()
10870 .unwrap();
10871 let host = format!("counting-resolver-{port}.test");
10872 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10873 (format!("http://{host}:{port}"), hits)
10874 }
10875
10876 #[tokio::test]
10880 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10881 let did = "did:plc:renamer5";
10882 let (sidecar, _log) = spawn_logging_sidecar().await;
10883 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10884 let state = with_config(
10885 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10886 |c| {
10887 c.resolver_base = resolver;
10888 c.max_subs_per_did = 1;
10889 },
10890 );
10891 let feed_id = store::upsert_feed(
10892 &state.db,
10893 &store::NewFeed {
10894 url: "https://already.example/feed.xml".into(),
10895 ..Default::default()
10896 },
10897 )
10898 .await
10899 .unwrap();
10900 store::replace_sub_refs(&state.db, did, &[feed_id])
10901 .await
10902 .unwrap();
10903 let loc = subscribe(
10904 &state,
10905 did,
10906 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10907 )
10908 .await;
10909 assert!(
10910 loc.contains("Subscription%20limit"),
10911 "expected the cap flash: {loc}"
10912 );
10913 assert_eq!(
10914 hits.load(std::sync::atomic::Ordering::SeqCst),
10915 0,
10916 "an over-cap paste resolved a handle"
10917 );
10918 }
10919
10920 #[tokio::test]
10924 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10925 let did = "did:plc:renamer5";
10926 let (sidecar, _log) = spawn_logging_sidecar().await;
10927 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10928 let state = with_config(
10929 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10930 |c| {
10931 c.resolver_base = resolver;
10932 },
10933 );
10934 for authority in [
10935 "did%3Aplc%3ATOOSHORT",
10936 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10937 "bad%0Ahandle.example",
10938 ] {
10939 let loc = subscribe(
10940 &state,
10941 did,
10942 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10943 )
10944 .await;
10945 assert!(
10946 loc.contains("kind%20of%20feed"),
10947 "{authority}: expected the unsupported flash: {loc}"
10948 );
10949 }
10950 assert_eq!(
10951 hits.load(std::sync::atomic::Ordering::SeqCst),
10952 0,
10953 "a malformed authority reached the resolver"
10954 );
10955 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10956 }
10957
10958 #[tokio::test]
10960 async fn an_unresolvable_handle_paste_is_refused() {
10961 let did = "did:plc:renamer5";
10962 let (sidecar, _log) = spawn_logging_sidecar().await;
10963 let state = with_config(
10964 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10965 |c| {
10966 c.resolver_base = "http://resolver.nowhere.invalid".into();
10967 },
10968 );
10969 let loc = subscribe(
10970 &state,
10971 did,
10972 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10973 )
10974 .await;
10975 assert!(
10976 loc.contains("resolve%20the%20handle"),
10977 "expected the unresolvable-handle flash: {loc}"
10978 );
10979 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10980 }
10981
10982 #[tokio::test]
10984 async fn a_non_publication_at_uri_paste_is_refused() {
10985 let did = "did:plc:renamer5";
10986 let (sidecar, _log) = spawn_logging_sidecar().await;
10987 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10988 let loc = subscribe(
10989 &state,
10990 did,
10991 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10992 )
10993 .await;
10994 assert!(
10995 loc.contains("kind%20of%20feed"),
10996 "expected the unsupported flash: {loc}"
10997 );
10998 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10999 }
11000
11001 #[tokio::test]
11004 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11005 let did = "did:plc:renamer5";
11006 let (sidecar, _log) = spawn_logging_sidecar().await;
11007 let state = with_config(
11008 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11009 |c| {
11010 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11011 },
11012 );
11013 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11014 assert_eq!(loc, "/", "the paste was refused: {loc}");
11015 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11016 .await
11017 .unwrap()
11018 .is_some());
11019 }
11020
11021 #[tokio::test]
11024 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11025 let did = "did:plc:renamer5";
11026 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11027 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11028 let opml = format!(
11029 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11030 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11031 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11032 </body></opml>"
11033 );
11034 let (ct, body) = opml_multipart(opml.as_bytes());
11035 let cookie = session_cookie(&state, did, None);
11036 let resp = router(state.clone())
11037 .oneshot(
11038 Request::builder()
11039 .method("POST")
11040 .uri("/opml")
11041 .header(header::COOKIE, cookie)
11042 .header("content-type", ct)
11043 .body(Body::from(body))
11044 .unwrap(),
11045 )
11046 .await
11047 .unwrap();
11048 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11049 let loc = resp
11050 .headers()
11051 .get(header::LOCATION)
11052 .unwrap()
11053 .to_str()
11054 .unwrap();
11055 assert!(
11056 loc.contains("Imported%202%20feeds"),
11057 "unexpected flash: {loc}"
11058 );
11059 assert!(
11060 !loc.contains("skipped"),
11061 "the at:// entry was skipped with the flag on: {loc}"
11062 );
11063 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11064 assert!(
11065 stored.is_some(),
11066 "the at:// entry was not stored with the flag on"
11067 );
11068 }
11069
11070 #[tokio::test]
11080 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11081 let did = "did:plc:renamer5";
11082 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11083 let tokened_enc =
11084 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11085 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11086 let state = test_state_with_sidecar(&[did], &sidecar).await;
11087 let loc = retitle_unchanged(&state, did, tokened_enc).await;
11088 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11089 assert_eq!(
11090 puts.lock().unwrap().len(),
11091 1,
11092 "the retitle did not reach the PDS"
11093 );
11094 assert!(
11095 store::get_feed_by_url(&state.db, tokened)
11096 .await
11097 .unwrap()
11098 .is_none(),
11099 "a secret-bearing URL was written to the shared cache by a retitle"
11100 );
11101 }
11102
11103 #[tokio::test]
11108 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11109 let did = "did:plc:renamer4";
11110 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11111 let state = test_state_with_sidecar(&[did], &sidecar).await;
11112 let cookie = session_cookie(&state, did, None);
11113 let resp = router(state.clone())
11114 .oneshot(
11115 Request::builder()
11116 .method("POST")
11117 .uri("/subscriptions/rk-keep/rename")
11118 .header(header::COOKIE, cookie)
11119 .header("content-type", "application/x-www-form-urlencoded")
11120 .body(Body::from(
11121 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11122 ))
11123 .unwrap(),
11124 )
11125 .await
11126 .unwrap();
11127 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11128 let loc = resp
11129 .headers()
11130 .get(header::LOCATION)
11131 .unwrap()
11132 .to_str()
11133 .unwrap();
11134 assert!(
11135 loc.contains("kind%20of%20feed"),
11136 "expected the unsupported flash: {loc}"
11137 );
11138 assert!(
11139 !loc.contains("Private"),
11140 "a typo was reported as a paid feed: {loc}"
11141 );
11142 assert!(puts.lock().unwrap().is_empty());
11143 }
11144
11145 #[tokio::test]
11146 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11147 let did = "did:plc:renamer4";
11148 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11149 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11150 store::upsert_feed(
11151 &state.db,
11152 &store::NewFeed {
11153 url: "https://filler.example/feed.xml".to_string(),
11154 ..Default::default()
11155 },
11156 )
11157 .await
11158 .unwrap();
11159 let cookie = session_cookie(&state, did, None);
11160 let resp = router(state.clone())
11161 .oneshot(
11162 Request::builder()
11163 .method("POST")
11164 .uri("/subscriptions/rk-keep/rename")
11165 .header(header::COOKIE, cookie)
11166 .header("content-type", "application/x-www-form-urlencoded")
11167 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11168 .unwrap(),
11169 )
11170 .await
11171 .unwrap();
11172 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11173 let loc = resp
11174 .headers()
11175 .get(header::LOCATION)
11176 .unwrap()
11177 .to_str()
11178 .unwrap();
11179 assert!(
11180 loc.contains("kind%20of%20feed"),
11181 "expected the unsupported flash: {loc}"
11182 );
11183 assert!(
11184 !loc.contains("capacity"),
11185 "an unacceptable URL was reported as a capacity problem: {loc}"
11186 );
11187 assert!(puts.lock().unwrap().is_empty());
11188 }
11189
11190 #[tokio::test]
11195 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11196 let did = "did:plc:renamer5";
11197 let padded = format!("{AT_URI_SUB} ");
11198 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11199 let state = test_state_with_sidecar(&[did], &sidecar).await;
11200 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11202 assert_eq!(
11203 loc, "/",
11204 "the retitle was treated as a repoint and refused: {loc}"
11205 );
11206 let bodies = puts.lock().unwrap().clone();
11207 assert_eq!(bodies.len(), 1);
11208 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11209 assert_eq!(
11210 sent["record"]["url"], AT_URI_SUB,
11211 "the padding was not normalised away"
11212 );
11213 }
11214
11215 #[tokio::test]
11221 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11222 let did = "did:plc:renamer5";
11223 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11224 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11225 store::upsert_feed(
11226 &state.db,
11227 &store::NewFeed {
11228 url: "https://filler.example/feed.xml".to_string(),
11229 ..Default::default()
11230 },
11231 )
11232 .await
11233 .unwrap();
11234 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11235 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11236 assert_eq!(puts.lock().unwrap().len(), 1);
11237 assert_eq!(
11238 store::count_feeds(&state.db).await.unwrap(),
11239 1,
11240 "a retitle inserted a cache row past the ceiling"
11241 );
11242 }
11243
11244 #[tokio::test]
11251 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11252 let did = "did:plc:typoist";
11253 let state = test_state_with_caps(did, 0, 0).await;
11254 let cookie = session_cookie(&state, did, None);
11255 let resp = router(state.clone())
11256 .oneshot(
11257 Request::builder()
11258 .method("POST")
11259 .uri("/subscriptions")
11260 .header(header::COOKIE, cookie)
11261 .header("content-type", "application/x-www-form-urlencoded")
11262 .body(Body::from(
11263 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11264 ))
11265 .unwrap(),
11266 )
11267 .await
11268 .unwrap();
11269 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11270 let loc = resp
11271 .headers()
11272 .get(header::LOCATION)
11273 .unwrap()
11274 .to_str()
11275 .unwrap();
11276 assert!(
11277 loc.contains("kind%20of%20feed"),
11278 "expected the unsupported flash: {loc}"
11279 );
11280 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11281 }
11282
11283 #[tokio::test]
11305 async fn renaming_preserves_the_fields_the_form_never_carries() {
11306 let did = "did:plc:renamer4";
11307 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11308 let state = test_state_with_sidecar(&[did], &sidecar).await;
11309 let cookie = session_cookie(&state, did, None);
11310
11311 let resp = router(state.clone())
11312 .oneshot(
11313 Request::builder()
11314 .method("POST")
11315 .uri("/subscriptions/rk-keep/rename")
11316 .header(header::COOKIE, cookie)
11317 .header("content-type", "application/x-www-form-urlencoded")
11318 .body(Body::from(
11320 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11321 ))
11322 .unwrap(),
11323 )
11324 .await
11325 .unwrap();
11326 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11327
11328 let bodies = puts.lock().unwrap().clone();
11329 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11330 let body = &bodies[0];
11331 assert!(
11333 body.contains("community.lexicon.rss.subscription"),
11334 "captured no usable put body: {body:?}"
11335 );
11336
11337 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11338 let record = &sent["record"];
11339
11340 assert_eq!(record["title"], "New title", "the rename did not apply");
11342 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11343
11344 assert_eq!(
11346 record["createdAt"], "2024-03-01T00:00:00.000Z",
11347 "the rename reset createdAt — the reader's subscribe time is gone \
11348 from their own repo, and nothing told them"
11349 );
11350 assert_eq!(
11351 record["siteUrl"], "https://example.com/blog",
11352 "the rename erased siteUrl"
11353 );
11354 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11355 assert_eq!(record["private"], false, "the rename erased private");
11356 }
11357
11358 #[tokio::test]
11367 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11368 let did = "did:plc:renamer4";
11369 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11370 let state = test_state_with_sidecar(&[did], &sidecar).await;
11371 let cookie = session_cookie(&state, did, None);
11372
11373 let resp = router(state.clone())
11374 .oneshot(
11375 Request::builder()
11376 .method("POST")
11377 .uri("/subscriptions/rk-keep/rename")
11378 .header(header::COOKIE, cookie)
11379 .header("content-type", "application/x-www-form-urlencoded")
11380 .body(Body::from(
11382 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11383 ))
11384 .unwrap(),
11385 )
11386 .await
11387 .unwrap();
11388 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11389
11390 let bodies = puts.lock().unwrap().clone();
11391 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11392 assert!(
11393 bodies[0].contains("community.lexicon.rss.subscription"),
11394 "captured no usable put body: {:?}",
11395 bodies[0]
11396 );
11397 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11398 let record = &sent["record"];
11399
11400 assert_eq!(record["url"], "https://other.example/feed.xml");
11401 assert!(
11403 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11404 "the old feed's site link followed the subscription to a new feed: {record}"
11405 );
11406 assert!(
11407 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11408 "the old feed's fetch hint followed the subscription to a new feed: {record}"
11409 );
11410 assert_eq!(
11412 record["createdAt"], "2024-03-01T00:00:00.000Z",
11413 "a repoint is still not a new subscription; createdAt must not move"
11414 );
11415 assert_eq!(record["private"], false, "the repoint erased private");
11416 }
11417
11418 #[tokio::test]
11430 async fn renaming_an_unknown_rkey_writes_nothing() {
11431 let did = "did:plc:renamer4";
11432 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11434 let state = test_state_with_sidecar(&[did], &sidecar).await;
11435 let cookie = session_cookie(&state, did, None);
11436
11437 let resp = router(state.clone())
11438 .oneshot(
11439 Request::builder()
11440 .method("POST")
11441 .uri("/subscriptions/rk-does-not-exist/rename")
11443 .header(header::COOKIE, cookie)
11444 .header("content-type", "application/x-www-form-urlencoded")
11445 .body(Body::from(
11446 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11447 ))
11448 .unwrap(),
11449 )
11450 .await
11451 .unwrap();
11452
11453 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11454 let loc = resp
11455 .headers()
11456 .get(header::LOCATION)
11457 .unwrap()
11458 .to_str()
11459 .unwrap();
11460 assert!(
11461 loc.contains("flash="),
11462 "an unknown rkey redirected as though the rename had worked: {loc}"
11463 );
11464 assert!(
11465 puts.lock().unwrap().is_empty(),
11466 "a rename against an unknown rkey wrote a record — putRecord would \
11467 CREATE it, dated today: {:?}",
11468 puts.lock().unwrap()
11469 );
11470 }
11471
11472 #[tokio::test]
11484 async fn a_client_supplied_site_url_reaches_the_record() {
11485 let did = "did:plc:renamer4";
11486 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11487 let state = test_state_with_sidecar(&[did], &sidecar).await;
11488 let cookie = session_cookie(&state, did, None);
11489
11490 let resp = router(state.clone())
11491 .oneshot(
11492 Request::builder()
11493 .method("POST")
11494 .uri("/subscriptions/rk-keep/rename")
11495 .header(header::COOKIE, cookie)
11496 .header("content-type", "application/x-www-form-urlencoded")
11497 .body(Body::from(
11500 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11501 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11502 ))
11503 .unwrap(),
11504 )
11505 .await
11506 .unwrap();
11507 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11508
11509 let bodies = puts.lock().unwrap().clone();
11510 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11511 assert!(
11512 bodies[0].contains("community.lexicon.rss.subscription"),
11513 "captured no usable put body: {:?}",
11514 bodies[0]
11515 );
11516 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11517 assert_eq!(
11518 sent["record"]["siteUrl"], "https://typed.example/site",
11519 "the client's siteUrl was dropped; the seeded record's survived instead"
11520 );
11521 }
11522
11523 #[tokio::test]
11531 async fn a_rename_whose_read_fails_writes_nothing() {
11532 let did = "did:plc:renamer5";
11533 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11535 let dead = format!("http://{}", listener.local_addr().unwrap());
11536 drop(listener);
11537
11538 let state = test_state_with_sidecar(&[did], &dead).await;
11539 let cookie = session_cookie(&state, did, None);
11540 let before = store::count_feeds(&state.db).await.unwrap();
11541
11542 let resp = router(state.clone())
11543 .oneshot(
11544 Request::builder()
11545 .method("POST")
11546 .uri("/subscriptions/rk-keep/rename")
11547 .header(header::COOKIE, cookie)
11548 .header("content-type", "application/x-www-form-urlencoded")
11549 .body(Body::from(
11550 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11551 ))
11552 .unwrap(),
11553 )
11554 .await
11555 .unwrap();
11556
11557 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11558 let loc = resp
11559 .headers()
11560 .get(header::LOCATION)
11561 .unwrap()
11562 .to_str()
11563 .unwrap();
11564 assert!(
11565 loc.contains("flash="),
11566 "a failed read redirected as though the rename had worked: {loc}"
11567 );
11568 assert_eq!(
11569 store::count_feeds(&state.db).await.unwrap(),
11570 before,
11571 "a rename that could not read the record still wrote to the cache"
11572 );
11573 }
11574
11575 #[test]
11581 fn manage_rename_row_preselects_current_folder() {
11582 let nav = Nav {
11583 handle: "@reader.example".to_string(),
11584 avatar: "RE".to_string(),
11585 view: "unread".to_string(),
11586 scope_qs: String::new(),
11587 folders: Vec::new(),
11588 loose_feeds: Vec::new(),
11589 manage_active: true,
11590 };
11591 let folder_options = vec![
11592 FolderOption {
11593 uri: "at://did:plc:x/app.folder/work".to_string(),
11594 name: "Work".to_string(),
11595 },
11596 FolderOption {
11597 uri: "at://did:plc:x/app.folder/fun".to_string(),
11598 name: "Fun".to_string(),
11599 },
11600 ];
11601 let foldered = FeedView {
11604 rkey: "sub-foldered".to_string(),
11605 url: "https://work.example/feed.xml".to_string(),
11606 title: "Work Feed".to_string(),
11607 unread: 0,
11608 selected: false,
11609 folder: Some("at://did:plc:x/app.folder/work".to_string()),
11610 };
11611 let loose = FeedView {
11612 rkey: "sub-loose".to_string(),
11613 url: "https://loose.example/feed.xml".to_string(),
11614 title: "Loose Feed".to_string(),
11615 unread: 0,
11616 selected: false,
11617 folder: None,
11618 };
11619 let tmpl = ManageTemplate {
11620 card: Card::private(&Config::default()),
11621 version: VERSION,
11622 repo_url: REPO_URL,
11623 kofi_url: KOFI_URL,
11624 flash: String::new(),
11625 alert: String::new(),
11626 nav,
11627 folder_options,
11628 folders: vec![FolderView {
11629 rkey: "folder-work".to_string(),
11630 uri: "at://did:plc:x/app.folder/work".to_string(),
11631 name: "Work".to_string(),
11632 feeds: vec![foldered],
11633 selected: false,
11634 }],
11635 loose_feeds: vec![loose],
11636 standard_site: false,
11637 };
11638 let html = tmpl.render().unwrap();
11639
11640 assert!(
11642 html.contains(
11643 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11644 ),
11645 "foldered feed must pre-select its current folder: {html}"
11646 );
11647 assert!(
11650 html.contains(r#"<option value="" selected>No folder</option>"#),
11651 "loose feed must pre-select 'No folder': {html}"
11652 );
11653 }
11654
11655 #[tokio::test]
11661 async fn the_public_stats_page_exposes_no_user_data() {
11662 let state = test_state(&[]).await;
11663 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11664 .await
11665 .unwrap();
11666
11667 let resp = router(state)
11668 .oneshot(
11669 Request::builder()
11670 .uri("/stats")
11671 .body(Body::empty())
11672 .unwrap(),
11673 )
11674 .await
11675 .unwrap();
11676 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11677
11678 let body = String::from_utf8(
11679 axum::body::to_bytes(resp.into_body(), usize::MAX)
11680 .await
11681 .unwrap()
11682 .to_vec(),
11683 )
11684 .unwrap();
11685
11686 assert!(
11692 !body.contains("did:"),
11693 "the public stats page leaked an identifier"
11694 );
11695 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11696 assert!(
11697 !body.contains(admin_only),
11698 "the public page is showing the admin metrics column {admin_only:?}"
11699 );
11700 }
11701 assert!(body.contains("Feeds tracked"));
11703 assert!(body.contains("Waiting to be polled"));
11704 }
11705
11706 #[tokio::test]
11714 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11715 let state = test_state(&[]).await;
11716 for (url, errors) in [
11718 ("https://ok.example/f.xml", 0),
11719 ("https://flaky.example/f.xml", 2),
11720 ("https://dead.example/f.xml", 9),
11721 ] {
11722 store::upsert_feed(
11723 &state.db,
11724 &store::NewFeed {
11725 url: url.to_string(),
11726 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11729 ..Default::default()
11730 },
11731 )
11732 .await
11733 .unwrap();
11734 for _ in 0..errors {
11735 store::bump_feed_errors(
11736 &state.db,
11737 url,
11738 feed::FailureKind::Fetch,
11739 "connection refused",
11740 )
11741 .await
11742 .unwrap();
11743 }
11744 }
11745
11746 let render_stats = |state: AppState| async move {
11747 let resp = router(state)
11748 .oneshot(
11749 Request::builder()
11750 .uri("/stats")
11751 .body(Body::empty())
11752 .unwrap(),
11753 )
11754 .await
11755 .unwrap();
11756 assert_eq!(resp.status(), StatusCode::OK);
11757 String::from_utf8(
11758 axum::body::to_bytes(resp.into_body(), usize::MAX)
11759 .await
11760 .unwrap()
11761 .to_vec(),
11762 )
11763 .unwrap()
11764 };
11765
11766 state.runtime_health.set_schedulers_enabled(true);
11773 state
11774 .runtime_health
11775 .poll_tick_completed(crate::store::now_unix());
11776
11777 let body = render_stats(state.clone()).await;
11778 assert!(
11779 body.contains("Failing"),
11780 "backoff is still invisible on the public page"
11781 );
11782 assert!(
11786 body.contains("2, 1 badly"),
11787 "expected '2, 1 badly' in the failing row; got:\n{}",
11788 body.split("Failing")
11789 .nth(1)
11790 .unwrap_or("")
11791 .chars()
11792 .take(300)
11793 .collect::<String>()
11794 );
11795 assert!(
11803 !body.contains("the poller is not running")
11804 && !body.contains("the cache is at its size limit")
11805 && !body.contains("has not completed a round"),
11806 "expected the running state; the page reported a stopped one",
11807 );
11808
11809 state.runtime_health.set_watermark(true);
11813 let paused = render_stats(state.clone()).await;
11814 assert!(
11819 paused.contains("the cache is at its size limit"),
11820 "a watermark pause is still invisible on the public page"
11821 );
11822
11823 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11825 assert!(
11826 !paused.contains(leak),
11827 "the public page leaked {leak:?} while reporting failures"
11828 );
11829 }
11830 }
11831
11832 #[tokio::test]
11844 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11845 let admin = "did:plc:adminseed";
11846 let state = test_state(&[admin]).await;
11855 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11856 .await
11857 .unwrap();
11858 let url = "https://broken.example/f.xml";
11859 store::upsert_feed(
11860 &state.db,
11861 &store::NewFeed {
11862 url: url.to_string(),
11863 ..Default::default()
11864 },
11865 )
11866 .await
11867 .unwrap();
11868 store::bump_feed_errors(
11869 &state.db,
11870 url,
11871 feed::FailureKind::Fetch,
11872 "SENTINEL_ADMIN_ONLY",
11873 )
11874 .await
11875 .unwrap();
11876
11877 let get = |state: AppState, cookie: Option<String>| async move {
11878 let mut req = Request::builder().uri("/admin/metrics");
11879 if let Some(c) = cookie {
11880 req = req.header(header::COOKIE, c);
11881 }
11882 let resp = router(state)
11883 .oneshot(req.body(Body::empty()).unwrap())
11884 .await
11885 .unwrap();
11886 let status = resp.status();
11887 let body = String::from_utf8(
11888 axum::body::to_bytes(resp.into_body(), usize::MAX)
11889 .await
11890 .unwrap()
11891 .to_vec(),
11892 )
11893 .unwrap();
11894 (status, body)
11895 };
11896
11897 let (status, body) = get(state.clone(), None).await;
11899 assert_eq!(status, StatusCode::UNAUTHORIZED);
11900 assert!(
11901 !body.contains("SENTINEL_ADMIN_ONLY"),
11902 "leaked to anonymous: {body}"
11903 );
11904
11905 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11907 let (status, body) = get(state.clone(), Some(ordinary)).await;
11908 assert_eq!(
11909 status,
11910 StatusCode::FORBIDDEN,
11911 "a non-admin session was let in"
11912 );
11913 assert!(
11914 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11915 "leaked to a non-admin: {body}",
11916 );
11917
11918 let admin_cookie = session_cookie(&state, admin, None);
11921 let (status, body) = get(state, Some(admin_cookie)).await;
11922 assert_eq!(status, StatusCode::OK);
11923 assert!(
11924 body.contains("SENTINEL_ADMIN_ONLY"),
11925 "admin cannot see it: {body}"
11926 );
11927 }
11928
11929 #[tokio::test]
11946 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11947 let admin = "did:plc:adminseed";
11948 let state = test_state(&[admin]).await;
11949 let url = "https://broken.example/f.xml";
11950 store::upsert_feed(
11951 &state.db,
11952 &store::NewFeed {
11953 url: url.to_string(),
11954 ..Default::default()
11955 },
11956 )
11957 .await
11958 .unwrap();
11959 store::bump_feed_errors(
11960 &state.db,
11961 url,
11962 feed::FailureKind::Fetch,
11963 "SENTINEL_REDIRECT_NO_LOCATION",
11964 )
11965 .await
11966 .unwrap();
11967
11968 let cookie = session_cookie(&state, admin, None);
11969 let resp = router(state.clone())
11970 .oneshot(
11971 Request::builder()
11972 .uri("/admin/metrics")
11973 .header(header::COOKIE, cookie)
11974 .body(Body::empty())
11975 .unwrap(),
11976 )
11977 .await
11978 .unwrap();
11979 assert_eq!(resp.status(), StatusCode::OK);
11980 let admin_body = String::from_utf8(
11981 axum::body::to_bytes(resp.into_body(), usize::MAX)
11982 .await
11983 .unwrap()
11984 .to_vec(),
11985 )
11986 .unwrap();
11987 assert!(
11988 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11989 "the admin page does not carry the failure detail: {admin_body}",
11990 );
11991 assert!(
11992 admin_body.contains("broken.example"),
11993 "the admin page does not name the failing feed: {admin_body}",
11994 );
11995
11996 let resp = router(state)
11998 .oneshot(
11999 Request::builder()
12000 .uri("/stats")
12001 .body(Body::empty())
12002 .unwrap(),
12003 )
12004 .await
12005 .unwrap();
12006 let public = String::from_utf8(
12007 axum::body::to_bytes(resp.into_body(), usize::MAX)
12008 .await
12009 .unwrap()
12010 .to_vec(),
12011 )
12012 .unwrap();
12013 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
12014 assert!(
12015 !public.contains(secret),
12016 "{secret:?} reached the PUBLIC stats page: {public}",
12017 );
12018 }
12019 }
12020
12021 #[tokio::test]
12034 async fn a_successful_direct_poll_clears_a_stale_failure() {
12035 let state = test_state(&[]).await;
12036 let url = "https://recovered.example/f.xml";
12037 store::upsert_feed(
12038 &state.db,
12039 &store::NewFeed {
12040 url: url.to_string(),
12041 ..Default::default()
12042 },
12043 )
12044 .await
12045 .unwrap();
12046 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
12047 .await
12048 .unwrap();
12049 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
12051 .bind(url)
12052 .execute(&state.db)
12053 .await
12054 .unwrap();
12055
12056 feed::settle_poll(
12058 &state.db,
12059 url,
12060 &feed::PollOutcome::NotModified,
12061 state.config.poll_interval,
12062 )
12063 .await;
12064
12065 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12066 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12067 )
12068 .bind(url)
12069 .fetch_one(&state.db)
12070 .await
12071 .unwrap();
12072 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
12073 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
12074 let next = row.2.expect("next_poll was cleared to NULL");
12078 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12082 let delta = parsed
12083 .signed_duration_since(chrono::Utc::now())
12084 .num_seconds();
12085 let cadence = state.config.poll_interval.as_secs() as i64;
12086 assert!(
12087 (cadence - 60..=cadence + 60).contains(&delta),
12088 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
12089 );
12090 }
12091
12092 #[tokio::test]
12098 async fn a_failing_direct_poll_is_recorded() {
12099 let state = test_state(&[]).await;
12100 let url = "https://born-broken.example/f.xml";
12101 store::upsert_feed(
12102 &state.db,
12103 &store::NewFeed {
12104 url: url.to_string(),
12105 ..Default::default()
12106 },
12107 )
12108 .await
12109 .unwrap();
12110
12111 feed::settle_poll(
12112 &state.db,
12113 url,
12114 &feed::PollOutcome::Failed {
12115 backoff: std::time::Duration::from_secs(300),
12116 kind: feed::FailureKind::Parse,
12117 detail: "SENTINEL_BORN_BROKEN".to_string(),
12118 },
12119 state.config.poll_interval,
12120 )
12121 .await;
12122
12123 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12124 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12125 )
12126 .bind(url)
12127 .fetch_one(&state.db)
12128 .await
12129 .unwrap();
12130 assert_eq!(row.0, 1, "a failed first poll was not counted");
12131 assert_eq!(
12132 row.1.as_deref(),
12133 Some("parse"),
12134 "its cause was not recorded"
12135 );
12136 let next = row.2.expect("a failed direct poll left next_poll NULL");
12140 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12141 let delta = parsed
12142 .signed_duration_since(chrono::Utc::now())
12143 .num_seconds();
12144 assert!(
12145 (240..=360).contains(&delta),
12146 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12147 );
12148 }
12149
12150 #[tokio::test]
12162 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12163 let state = test_state(&[]).await;
12164 for url in [
12166 "https://legacy1.example/f.xml",
12167 "https://legacy2.example/f.xml",
12168 ] {
12169 store::upsert_feed(
12170 &state.db,
12171 &store::NewFeed {
12172 url: url.to_string(),
12173 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12174 ..Default::default()
12175 },
12176 )
12177 .await
12178 .unwrap();
12179 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12180 .bind(url)
12181 .execute(&state.db)
12182 .await
12183 .unwrap();
12184 }
12185 store::upsert_feed(
12187 &state.db,
12188 &store::NewFeed {
12189 url: "https://known.example/f.xml".to_string(),
12190 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12191 ..Default::default()
12192 },
12193 )
12194 .await
12195 .unwrap();
12196 store::bump_feed_errors(
12197 &state.db,
12198 "https://known.example/f.xml",
12199 feed::FailureKind::Status,
12200 "SENTINEL",
12201 )
12202 .await
12203 .unwrap();
12204
12205 let now = chrono::Utc::now();
12206 let health = store::poll_health(
12207 &state.db,
12208 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12209 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12210 )
12211 .await
12212 .unwrap();
12213 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12214 assert_eq!(
12215 counted, health.in_backoff,
12216 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12217 health.in_backoff, health.failure_kinds,
12218 );
12219 assert!(
12220 health
12221 .failure_kinds
12222 .iter()
12223 .any(|(k, n)| k == "unknown" && *n == 2),
12224 "no unknown bucket for the legacy rows: {:?}",
12225 health.failure_kinds,
12226 );
12227 }
12228
12229 #[tokio::test]
12234 async fn the_failure_breakdown_is_ordered_by_count() {
12235 let state = test_state(&[]).await;
12236 for (url, kind, n) in [
12237 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12238 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12239 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12240 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12241 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12242 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12243 ] {
12244 store::upsert_feed(
12245 &state.db,
12246 &store::NewFeed {
12247 url: url.to_string(),
12248 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12249 ..Default::default()
12250 },
12251 )
12252 .await
12253 .unwrap();
12254 for _ in 0..n {
12255 store::bump_feed_errors(&state.db, url, kind, "d")
12256 .await
12257 .unwrap();
12258 }
12259 }
12260 let now = chrono::Utc::now();
12261 let health = store::poll_health(
12262 &state.db,
12263 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12264 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12265 )
12266 .await
12267 .unwrap();
12268 let labels: Vec<&str> = health
12269 .failure_kinds
12270 .iter()
12271 .map(|(k, _)| k.as_str())
12272 .collect();
12273 assert_eq!(
12274 labels,
12275 ["fetch", "status", "parse"],
12276 "not ordered by count, descending: {:?}",
12277 health.failure_kinds,
12278 );
12279 }
12280
12281 #[tokio::test]
12293 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12294 let state = test_state(&[]).await;
12295 for (url, kind, detail, errors) in [
12296 (
12302 "https://a.example/f.xml",
12303 feed::FailureKind::Fetch,
12304 "SENTINEL_CONNREFUSED",
12305 3,
12306 ),
12307 (
12308 "https://b.example/f.xml",
12309 feed::FailureKind::Fetch,
12310 "SENTINEL_DNSFAIL",
12311 2,
12312 ),
12313 (
12314 "https://c.example/f.xml",
12315 feed::FailureKind::Status,
12316 "SENTINEL_404",
12317 1,
12318 ),
12319 (
12320 "https://d.example/f.xml",
12321 feed::FailureKind::Parse,
12322 "SENTINEL_UNPARSEABLE",
12323 1,
12324 ),
12325 ] {
12326 store::upsert_feed(
12327 &state.db,
12328 &store::NewFeed {
12329 url: url.to_string(),
12330 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12331 ..Default::default()
12332 },
12333 )
12334 .await
12335 .unwrap();
12336 for _ in 0..errors {
12337 store::bump_feed_errors(&state.db, url, kind, detail)
12338 .await
12339 .unwrap();
12340 }
12341 }
12342
12343 let resp = router(state.clone())
12344 .oneshot(
12345 Request::builder()
12346 .uri("/stats")
12347 .body(Body::empty())
12348 .unwrap(),
12349 )
12350 .await
12351 .unwrap();
12352 assert_eq!(resp.status(), StatusCode::OK);
12353 let body = String::from_utf8(
12354 axum::body::to_bytes(resp.into_body(), usize::MAX)
12355 .await
12356 .unwrap()
12357 .to_vec(),
12358 )
12359 .unwrap();
12360
12361 assert!(
12363 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12364 "the cause histogram did not render: {body}",
12365 );
12366
12367 for secret in [
12370 "a.example",
12371 "b.example",
12372 "c.example",
12373 "d.example",
12374 "SENTINEL_CONNREFUSED",
12375 "SENTINEL_DNSFAIL",
12376 "SENTINEL_404",
12377 "SENTINEL_UNPARSEABLE",
12378 ] {
12379 assert!(
12380 !body.contains(secret),
12381 "{secret:?} reached the PUBLIC stats page: {body}",
12382 );
12383 }
12384 }
12385
12386 #[tokio::test]
12389 async fn health_checks_the_database_and_reports_the_loops() {
12390 let state = test_state(&[]).await;
12391 let body_of = |state: AppState| async move {
12392 let resp = router(state)
12393 .oneshot(
12394 Request::builder()
12395 .uri("/health")
12396 .body(Body::empty())
12397 .unwrap(),
12398 )
12399 .await
12400 .unwrap();
12401 let status = resp.status();
12402 let body = String::from_utf8(
12403 axum::body::to_bytes(resp.into_body(), usize::MAX)
12404 .await
12405 .unwrap()
12406 .to_vec(),
12407 )
12408 .unwrap();
12409 (status, body)
12410 };
12411
12412 state
12415 .runtime_health
12416 .set_started_at(chrono::Utc::now().timestamp());
12417
12418 let (status, body) = body_of(state.clone()).await;
12419 assert_eq!(status, StatusCode::OK);
12420 assert!(
12421 body.contains("db: ok"),
12422 "health did not probe the DB: {body}"
12423 );
12424 assert!(
12425 body.contains("uptime:"),
12426 "no uptime — the first thing anyone asks about a container that may \
12427 be restarting: {body}"
12428 );
12429 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12430 assert!(body.contains("polling-paused: no"), "{body}");
12431 assert!(body.contains("backend:"), "{body}");
12432 assert!(body.contains("oauth-runtime:"), "{body}");
12433
12434 state.runtime_health.set_watermark(true);
12439 state.runtime_health.set_schedulers_enabled(true);
12440 let (status, body) = body_of(state.clone()).await;
12441 assert_eq!(
12442 status,
12443 StatusCode::OK,
12444 "a watermark pause must not fail the liveness check: {body}"
12445 );
12446 assert!(body.contains("polling-paused: yes"), "{body}");
12447 assert!(
12450 body.contains("poller: not-yet-ticked"),
12451 "a never-ticked poller must say so: {body}"
12452 );
12453
12454 let stale_after = health_tick_stale_secs(configured_poll_tick());
12456 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12457 state.runtime_health.poll_tick_completed(long_ago);
12458 let (status, body) = body_of(state.clone()).await;
12459 assert_eq!(
12460 status,
12461 StatusCode::OK,
12462 "a stale poller must not 503: {body}"
12463 );
12464 assert!(body.contains("poller: stale"), "{body}");
12465
12466 state.runtime_health.poll_tick_completed(0); state
12474 .runtime_health
12475 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12476 let (status, body) = body_of(state.clone()).await;
12477 assert_eq!(status, StatusCode::OK);
12478 assert!(
12479 body.contains("poller: stale never-ticked"),
12480 "a poller that never ticked long after boot still reads as benign: {body}"
12481 );
12482
12483 state.db.close().await;
12486 let (status, body) = body_of(state.clone()).await;
12487 assert_eq!(
12488 status,
12489 StatusCode::SERVICE_UNAVAILABLE,
12490 "an unreachable database must fail the check: {body}"
12491 );
12492 assert!(body.starts_with("FAIL"), "{body}");
12493 assert!(
12497 !body.contains("PoolClosed") && !body.contains("sqlx"),
12498 "health leaked the raw database error to an unauthenticated caller: {body}"
12499 );
12500 }
12501
12502 #[test]
12508 fn the_stale_threshold_follows_the_poll_tick() {
12509 assert_eq!(
12512 health_tick_stale_secs(Duration::from_secs(60)),
12513 HEALTH_TICK_STALE_FLOOR_SECS
12514 );
12515 let slow = Duration::from_secs(30 * 60);
12518 assert!(
12519 health_tick_stale_secs(slow) > slow.as_secs() as i64,
12520 "a 30-minute tick must not be stale after one interval"
12521 );
12522 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12523 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12525 }
12526
12527 #[tokio::test]
12533 async fn stats_does_not_call_a_stopped_poller_running() {
12534 let state = test_state(&[]).await;
12535 let render = |state: AppState| async move {
12536 let resp = router(state)
12537 .oneshot(
12538 Request::builder()
12539 .uri("/stats")
12540 .body(Body::empty())
12541 .unwrap(),
12542 )
12543 .await
12544 .unwrap();
12545 assert_eq!(resp.status(), StatusCode::OK);
12546 String::from_utf8(
12547 axum::body::to_bytes(resp.into_body(), usize::MAX)
12548 .await
12549 .unwrap()
12550 .to_vec(),
12551 )
12552 .unwrap()
12553 };
12554
12555 let body = render(state.clone()).await;
12557 assert!(
12558 body.contains("the poller is not running on this instance"),
12559 "a disabled poller renders as healthy"
12560 );
12561
12562 state.runtime_health.set_schedulers_enabled(true);
12564 let body = render(state.clone()).await;
12565 assert!(
12566 body.contains("no poll has finished since this instance booted"),
12567 "a poller that has not ticked renders as healthy"
12568 );
12569
12570 state
12572 .runtime_health
12573 .poll_tick_completed(chrono::Utc::now().timestamp());
12574 let body = render(state.clone()).await;
12575 assert!(
12576 body.contains("running"),
12577 "a healthy poller must read as running"
12578 );
12579
12580 state.runtime_health.set_watermark(true);
12582 let body = render(state.clone()).await;
12583 assert!(
12584 body.contains("the cache is at its size limit"),
12585 "a watermark pause is hidden once the poller is ticking"
12586 );
12587 }
12588
12589 #[tokio::test]
12600 async fn health_reports_an_unmeasured_database_without_failing() {
12601 use crate::runtime_health::DbProbe;
12602 let state = test_state(&[]).await;
12603
12604 let held = state
12607 .runtime_health
12608 .begin_db_probe()
12609 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12610
12611 let resp = router(state.clone())
12612 .oneshot(
12613 Request::builder()
12614 .uri("/health")
12615 .body(Body::empty())
12616 .unwrap(),
12617 )
12618 .await
12619 .unwrap();
12620 let status = resp.status();
12621 let body = String::from_utf8(
12622 axum::body::to_bytes(resp.into_body(), usize::MAX)
12623 .await
12624 .unwrap()
12625 .to_vec(),
12626 )
12627 .unwrap();
12628 drop(held);
12629
12630 assert_eq!(
12631 status,
12632 StatusCode::OK,
12633 "an unmeasured database failed the check, which an unauthenticated \
12634 caller can cause on demand: {body}"
12635 );
12636 assert!(
12637 body.contains("db: unknown"),
12638 "the unmeasured state must still be REPORTED: {body}"
12639 );
12640 assert!(!body.starts_with("FAIL"), "{body}");
12641 assert!(
12646 !body.starts_with("ok"),
12647 "the unmeasured state is indistinguishable from healthy to a \
12648 body-matching monitor: {body}"
12649 );
12650 assert!(body.starts_with("unknown"), "{body}");
12651
12652 let held = state
12663 .runtime_health
12664 .begin_db_probe()
12665 .unwrap_or_else(|_| panic!("claim"));
12666 state
12667 .runtime_health
12668 .record_for_test(DbProbe::Failed("unavailable".to_string()));
12669 let resp = router(state.clone())
12670 .oneshot(
12671 Request::builder()
12672 .uri("/health")
12673 .body(Body::empty())
12674 .unwrap(),
12675 )
12676 .await
12677 .unwrap();
12678 let status = resp.status();
12679 let body = String::from_utf8(
12680 axum::body::to_bytes(resp.into_body(), usize::MAX)
12681 .await
12682 .unwrap()
12683 .to_vec(),
12684 )
12685 .unwrap();
12686 drop(held);
12687 assert_eq!(
12688 status,
12689 StatusCode::SERVICE_UNAVAILABLE,
12690 "a BORROWED failure verdict must fail the check, not just a freshly \
12691 measured one: {body}"
12692 );
12693 assert!(body.starts_with("FAIL"), "{body}");
12694
12695 state.db.close().await;
12696 let resp = router(state.clone())
12697 .oneshot(
12698 Request::builder()
12699 .uri("/health")
12700 .body(Body::empty())
12701 .unwrap(),
12702 )
12703 .await
12704 .unwrap();
12705 assert_eq!(
12706 resp.status(),
12707 StatusCode::SERVICE_UNAVAILABLE,
12708 "a measured database failure must still fail the check"
12709 );
12710 }
12711
12712 #[tokio::test]
12721 async fn an_abandoned_request_still_records_its_probe() {
12722 use crate::runtime_health::DbProbe;
12723 let state = test_state(&[]).await;
12724 let rh = state.runtime_health.clone();
12725
12726 let app = router(state.clone());
12728 let fut = app.oneshot(
12729 Request::builder()
12730 .uri("/health")
12731 .body(Body::empty())
12732 .unwrap(),
12733 );
12734 let handle = tokio::spawn(fut);
12735 handle.abort();
12736 let _ = handle.await;
12737
12738 for _ in 0..50 {
12741 if rh.begin_db_probe().is_ok() {
12742 break;
12743 }
12744 tokio::time::sleep(Duration::from_millis(20)).await;
12745 }
12746 let resp = router(state.clone())
12747 .oneshot(
12748 Request::builder()
12749 .uri("/health")
12750 .body(Body::empty())
12751 .unwrap(),
12752 )
12753 .await
12754 .unwrap();
12755 let body = String::from_utf8(
12756 axum::body::to_bytes(resp.into_body(), usize::MAX)
12757 .await
12758 .unwrap()
12759 .to_vec(),
12760 )
12761 .unwrap();
12762 assert!(
12763 body.contains("db: ok"),
12764 "after an abandoned request the next caller still reads an \
12765 unmeasured database — the probe was cancelled with it: {body}"
12766 );
12767 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12769 }
12770
12771 #[tokio::test]
12778 async fn the_health_probe_opens_a_real_table() {
12779 use sqlx::Row;
12780 let state = test_state(&[]).await;
12781 let opcodes = |sql: &'static str| {
12783 let db = state.db.clone();
12784 async move {
12785 sqlx::query(sql)
12786 .fetch_all(&db)
12787 .await
12788 .unwrap()
12789 .into_iter()
12790 .map(|r| r.get::<String, _>("opcode"))
12791 .collect::<Vec<String>>()
12792 }
12793 };
12794
12795 let explain: &'static str =
12798 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12799 let probe = opcodes(explain).await;
12800 assert!(
12802 health_db_probe(&state.db).await.is_ok(),
12803 "the probe does not run against the real schema",
12804 );
12805 assert!(
12806 probe.iter().any(|op| op == "OpenRead"),
12807 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12808 );
12809 let bare = opcodes("EXPLAIN SELECT 1").await;
12811 assert!(
12812 !bare.iter().any(|op| op == "OpenRead"),
12813 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12814 );
12815 }
12816
12817 #[test]
12820 fn an_instance_that_has_never_polled_says_so() {
12821 assert_eq!(humanise_ago(None), "never");
12822 assert_eq!(humanise_ago(Some(0)), "0s ago");
12823 assert_eq!(humanise_ago(Some(59)), "59s ago");
12824 assert_eq!(humanise_ago(Some(60)), "1m ago");
12825 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12826 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12827 }
12828
12829 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12832 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12833 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12834 let addr = listener.local_addr().unwrap();
12835 let (url, title) = (saved_url.to_string(), saved_title.to_string());
12836 tokio::spawn(async move {
12837 loop {
12838 let Ok((mut sock, _)) = listener.accept().await else {
12839 break;
12840 };
12841 let mut buf = vec![0u8; 8192];
12842 let Ok(n) = sock.read(&mut buf).await else {
12843 continue;
12844 };
12845 let req = String::from_utf8_lossy(&buf[..n]).to_string();
12846 let wants_saved = req.contains("community.lexicon.rss.saved");
12847 let records = if wants_saved {
12848 serde_json::json!([{
12849 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12850 "cid": "bafy",
12851 "value": {
12852 "$type": "community.lexicon.rss.saved",
12853 "url": url,
12854 "title": title,
12855 "createdAt": "2026-01-01T00:00:00Z"
12856 }
12857 }])
12858 } else {
12859 serde_json::json!([])
12860 };
12861 let body = serde_json::json!({
12862 "ok": true, "data": { "records": records }
12863 })
12864 .to_string();
12865 let resp = format!(
12866 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12867 body.len(), body
12868 );
12869 let _ = sock.write_all(resp.as_bytes()).await;
12870 let _ = sock.flush().await;
12871 }
12872 });
12873 format!("http://{addr}")
12874 }
12875
12876 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12879 let feed = subscribed_feed.to_string();
12880 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12881 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12882 let addr = listener.local_addr().unwrap();
12883 tokio::spawn(async move {
12884 loop {
12885 let Ok((mut sock, _)) = listener.accept().await else {
12886 break;
12887 };
12888 let mut buf = vec![0u8; 8192];
12889 let Ok(read) = sock.read(&mut buf).await else {
12890 continue;
12891 };
12892 let req = String::from_utf8_lossy(&buf[..read]).to_string();
12893 let records = if req.contains("community.lexicon.rss.saved") {
12894 serde_json::Value::Array(
12895 (0..n)
12896 .map(|i| {
12897 serde_json::json!({
12898 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12899 "cid": "bafy",
12900 "value": {
12901 "$type": "community.lexicon.rss.saved",
12902 "url": format!("https://elsewhere.example/{i}"),
12903 "title": format!("Elsewhere {i}"),
12904 "createdAt": "2026-01-01T00:00:00Z"
12905 }
12906 })
12907 })
12908 .collect(),
12909 )
12910 } else if req.contains("community.lexicon.rss.subscription") {
12911 serde_json::json!([{
12916 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12917 "cid": "bafy",
12918 "value": {
12919 "$type": "community.lexicon.rss.subscription",
12920 "url": feed,
12921 "createdAt": "2026-01-01T00:00:00Z"
12922 }
12923 }])
12924 } else {
12925 serde_json::json!([])
12926 };
12927 let body =
12928 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12929 let resp = format!(
12930 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12931 body.len(), body
12932 );
12933 let _ = sock.write_all(resp.as_bytes()).await;
12934 let _ = sock.flush().await;
12935 }
12936 });
12937 format!("http://{addr}")
12938 }
12939
12940 #[tokio::test]
12948 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12949 let did = "did:plc:pagerloop";
12950 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12951 let state = test_state_with_sidecar(&[], &sidecar).await;
12952 store::grant_access(&state.db, did, None, "test", None)
12953 .await
12954 .unwrap();
12955 let feed = store::upsert_feed(
12956 &state.db,
12957 &store::NewFeed {
12958 url: "https://loop.example/feed.xml".to_string(),
12959 title: Some("Loop".to_string()),
12960 ..Default::default()
12961 },
12962 )
12963 .await
12964 .unwrap();
12965 let entries: Vec<store::NewEntry> = (0..250)
12968 .map(|i| store::NewEntry {
12969 guid: format!("s-{i:04}"),
12970 url: Some(format!("https://loop.example/{i}")),
12971 title: Some(format!("Starred {i:04}")),
12972 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12973 ..Default::default()
12974 })
12975 .collect();
12976 store::insert_entries(&state.db, feed, &entries, 0)
12977 .await
12978 .unwrap();
12979 store::replace_sub_refs(&state.db, did, &[feed])
12980 .await
12981 .unwrap();
12982 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12983 .await
12984 .unwrap()
12985 {
12986 store::mark_starred(&state.db, did, row.id, true)
12987 .await
12988 .unwrap();
12989 }
12990
12991 let cookie = session_cookie(&state, did, None);
12992 let app = router(state.clone());
12993 let get = |uri: &str| {
12994 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12995 async move {
12996 let resp = app
12997 .oneshot(
12998 Request::builder()
12999 .uri(uri)
13000 .header(header::COOKIE, cookie)
13001 .body(Body::empty())
13002 .unwrap(),
13003 )
13004 .await
13005 .unwrap();
13006 assert_eq!(resp.status(), StatusCode::OK);
13007 String::from_utf8(
13008 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
13009 .await
13010 .unwrap()
13011 .to_vec(),
13012 )
13013 .unwrap()
13014 }
13015 };
13016
13017 let p3 = get("/?view=starred&page=3").await;
13022 assert!(
13023 p3.contains("Page 3 of 4"),
13024 "the pager and the clamp disagree on the total: {}",
13025 p3.split("pager-pos")
13026 .nth(1)
13027 .unwrap_or("")
13028 .chars()
13029 .take(120)
13030 .collect::<String>()
13031 );
13032 assert!(
13035 p3.contains("Elsewhere 0"),
13036 "page 3 should start the uncached run"
13037 );
13038 assert_eq!(
13039 p3.matches("<li class=\"entry").count(),
13040 ENTRIES_PER_PAGE as usize,
13041 "the boundary page is not full"
13042 );
13043
13044 {
13053 let body = &p3;
13054 assert!(
13055 body.contains("330 entries"),
13056 "the heading must count the whole sequence: {}",
13057 body.split("content-count")
13058 .nth(1)
13059 .unwrap_or("")
13060 .chars()
13061 .take(120)
13062 .collect::<String>()
13063 );
13064 assert!(
13065 body.contains("(80 saved elsewhere)"),
13066 "the heading must say how many of the total the cache cannot show, \
13067 as a whole-list figure and not a per-page one: {}",
13068 body.split("content-count")
13069 .nth(1)
13070 .unwrap_or("")
13071 .chars()
13072 .take(120)
13073 .collect::<String>()
13074 );
13075 assert!(
13076 !body.contains("plus 50") && !body.contains("plus 80"),
13077 "the heading is adding the uncached rows to a total that already \
13078 includes them"
13079 );
13080 }
13081
13082 let p4 = get("/?view=starred&page=4").await;
13083 assert!(
13084 p4.contains("Page 4 of 4"),
13085 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
13086 );
13087 assert_eq!(
13088 p4.matches("<li class=\"entry").count(),
13089 30,
13090 "page 4 should hold the remaining 30 uncached records"
13091 );
13092 assert!(
13093 p4.contains("Elsewhere 79"),
13094 "the LAST saved record is unreachable — it can only be removed from here"
13095 );
13096
13097 assert!(
13099 !p4.contains("Elsewhere 0"),
13100 "an uncached record was rendered on more than one page"
13101 );
13102 let first = get("/?view=starred").await;
13105 assert!(
13106 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
13107 "the heading changed between pages; it describes the list, not the page"
13108 );
13109 assert!(
13110 !first.contains("Elsewhere "),
13111 "uncached saved records leaked onto the first page"
13112 );
13113 }
13114
13115 #[tokio::test]
13122 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
13123 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
13124 let sidecar =
13125 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
13126 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
13127 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
13128
13129 let resp = router(state)
13130 .oneshot(
13131 Request::builder()
13132 .uri("/?view=starred")
13133 .body(Body::empty())
13134 .unwrap(),
13135 )
13136 .await
13137 .unwrap();
13138 assert_eq!(resp.status(), StatusCode::OK);
13139 let body = String::from_utf8(
13140 axum::body::to_bytes(resp.into_body(), usize::MAX)
13141 .await
13142 .unwrap()
13143 .to_vec(),
13144 )
13145 .unwrap();
13146
13147 assert!(
13148 body.contains("Starred elsewhere"),
13149 "the saved record was not rendered at all"
13150 );
13151 assert!(
13152 body.contains("entry-uncached"),
13153 "it was not marked as uncached, so it looks like a normal entry"
13154 );
13155 assert!(
13156 body.contains("https://elsewhere.example/article"),
13157 "the row must link straight to the article"
13158 );
13159 assert!(
13160 !body.contains("/entries/0/"),
13161 "an uncached row must not offer entry actions against a nonexistent id"
13162 );
13163 }
13164
13165 #[test]
13173 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13174 for hostile in [
13175 "日本語日本語日本",
13176 "é",
13177 "",
13178 "2026",
13179 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13180 ] {
13181 let out = display_date(Some(hostile));
13182 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13183 }
13184 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13185 assert_eq!(display_date(None), "");
13186 }
13187
13188 #[test]
13191 fn the_unsave_route_is_rate_limited() {
13192 use axum::http::Method;
13193 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13194 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13196 }
13197
13198 #[tokio::test]
13207 async fn health_reports_a_broken_database() {
13208 let state = test_state(&[]).await;
13209 assert!(
13211 health_db_probe(&state.db).await.is_ok(),
13212 "the fixture was not healthy to begin with",
13213 );
13214
13215 sqlx::query("DROP TABLE feeds")
13216 .execute(&state.db)
13217 .await
13218 .unwrap();
13219
13220 assert!(
13221 health_db_probe(&state.db).await.is_err(),
13222 "the probe reported success against a database missing the table it \
13223 claims to read; `SELECT 1` would do exactly this",
13224 );
13225
13226 let resp = router(state)
13227 .oneshot(
13228 Request::builder()
13229 .uri("/health")
13230 .body(Body::empty())
13231 .unwrap(),
13232 )
13233 .await
13234 .unwrap();
13235 let body = String::from_utf8(
13236 axum::body::to_bytes(resp.into_body(), usize::MAX)
13237 .await
13238 .unwrap()
13239 .to_vec(),
13240 )
13241 .unwrap();
13242 assert!(
13244 body.starts_with("FAIL"),
13245 "/health did not report FAIL for a broken database: {body}",
13246 );
13247 assert!(
13248 !body.contains("db: ok"),
13249 "/health still called the database ok: {body}",
13250 );
13251 }
13252
13253 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13258 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13259 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13260 let addr = listener.local_addr().unwrap();
13261 tokio::spawn(async move {
13262 loop {
13263 let Ok((mut sock, _)) = listener.accept().await else {
13264 break;
13265 };
13266 let mut buf = vec![0u8; 8192];
13267 let Ok(n) = sock.read(&mut buf).await else {
13268 continue;
13269 };
13270 let req = String::from_utf8_lossy(&buf[..n]).to_string();
13271 let wants = |c: &str| req.contains(c);
13272 if fail_on.is_some_and(wants) {
13273 let body = r#"{"ok":false,"error":"ShortList"}"#;
13274 let resp = format!(
13275 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13276 body.len(),
13277 body
13278 );
13279 let _ = sock.write_all(resp.as_bytes()).await;
13280 let _ = sock.flush().await;
13281 continue;
13282 }
13283 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13284 serde_json::json!([{
13285 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13286 "cid": "bafy",
13287 "value": {
13288 "$type": crate::lexicon::nsid::SUBSCRIPTION,
13289 "url": "https://kept.example/feed.xml",
13290 "title": "Kept",
13291 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13296 "createdAt": "2026-01-01T00:00:00Z"
13297 }
13298 }])
13299 } else if wants(crate::lexicon::nsid::FOLDER) {
13300 serde_json::json!([{
13301 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13302 "cid": "bafy",
13303 "value": {
13304 "$type": crate::lexicon::nsid::FOLDER,
13305 "name": "Kept folder",
13306 "createdAt": "2026-01-01T00:00:00Z"
13307 }
13308 }])
13309 } else {
13310 serde_json::json!([])
13311 };
13312 let body =
13313 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13314 let resp = format!(
13315 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13316 body.len(),
13317 body
13318 );
13319 let _ = sock.write_all(resp.as_bytes()).await;
13320 let _ = sock.flush().await;
13321 }
13322 });
13323 format!("http://{addr}")
13324 }
13325
13326 async fn spawn_malformed_sidecar() -> String {
13329 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13330 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13331 let addr = listener.local_addr().unwrap();
13332 tokio::spawn(async move {
13333 loop {
13334 let Ok((mut sock, _)) = listener.accept().await else {
13335 break;
13336 };
13337 let mut buf = vec![0u8; 8192];
13338 let _ = sock.read(&mut buf).await;
13339 let body = serde_json::json!({ "ok": true, "data": { "records": [
13340 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13341 { "cid": "bafy", "value": {} },
13342 ]}})
13343 .to_string();
13344 let resp = format!(
13345 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13346 body.len(),
13347 body
13348 );
13349 let _ = sock.write_all(resp.as_bytes()).await;
13350 let _ = sock.flush().await;
13351 }
13352 });
13353 format!("http://{addr}")
13354 }
13355
13356 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13357 let cookie = session_cookie(&state, did, None);
13358 let resp = router(state)
13359 .oneshot(
13360 Request::builder()
13361 .uri(uri)
13362 .header(header::COOKIE, cookie)
13363 .body(Body::empty())
13364 .unwrap(),
13365 )
13366 .await
13367 .unwrap();
13368 let status = resp.status();
13369 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13370 .await
13371 .unwrap();
13372 (status, String::from_utf8_lossy(&body).to_string())
13373 }
13374
13375 #[tokio::test]
13381 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13382 let did = "did:plc:displayer";
13383 let state = test_state(&[did]).await;
13384 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13385 let feed_id = store::upsert_feed(
13386 &state.db,
13387 &store::NewFeed {
13388 url: url.into(),
13389 title: Some("Quiet Journal".into()),
13390 ..Default::default()
13391 },
13392 )
13393 .await
13394 .unwrap();
13395 store::replace_sub_refs(&state.db, did, &[feed_id])
13396 .await
13397 .unwrap();
13398 store::insert_entries(
13399 &state.db,
13400 feed_id,
13401 &[store::NewEntry {
13402 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13403 .into(),
13404 url: Some("https://quiet.example/no-summary".into()),
13405 title: Some("A title-only article".into()),
13406 published: Some("2026-07-11T00:00:00Z".into()),
13407 content_html: None,
13408 ..Default::default()
13409 }],
13410 0,
13411 )
13412 .await
13413 .unwrap();
13414 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13415 assert_eq!(status, StatusCode::OK);
13416 assert!(
13417 list.contains("A title-only article"),
13418 "the entry is missing from the list"
13419 );
13420
13421 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13422 .bind(feed_id)
13423 .fetch_one(&state.db)
13424 .await
13425 .unwrap();
13426 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13427 assert_eq!(
13428 status,
13429 StatusCode::OK,
13430 "the article page failed for an entry with no body"
13431 );
13432 assert!(page.contains("A title-only article"));
13433 assert!(
13434 page.contains("https://quiet.example/no-summary"),
13435 "no link to the original"
13436 );
13437 assert!(
13438 page.contains(r#"<time datetime=""#),
13439 "no date on the article page"
13440 );
13441 }
13442
13443 #[tokio::test]
13448 async fn a_malformed_subscription_record_raises_an_alert() {
13449 let did = "did:plc:alerted";
13450 for page in ["/", "/manage"] {
13451 let sidecar = spawn_malformed_sidecar().await;
13452 let state = test_state_with_sidecar(&[did], &sidecar).await;
13453 let (status, body) = page_body(state, did, page).await;
13454 assert_eq!(status, StatusCode::OK, "{page} did not render");
13455 assert!(
13456 body.contains(r#"role="alert""#) && body.contains("could not be read"),
13457 "{page} rendered no alert for a refused subscription list"
13458 );
13459 assert!(
13460 body.contains("1 record(s) in your subscription list"),
13461 "{page} gave the generic alert, not the malformed-record one"
13462 );
13463 }
13464 }
13465
13466 #[tokio::test]
13468 async fn a_healthy_subscription_listing_raises_no_alert() {
13469 let did = "did:plc:exporter";
13470 let sidecar = spawn_export_sidecar(None).await;
13471 let state = test_state_with_sidecar(&[did], &sidecar).await;
13472 let (status, body) = page_body(state, did, "/").await;
13473 assert_eq!(status, StatusCode::OK);
13474 assert!(
13475 !body.contains("could not be read"),
13476 "a healthy listing raised an alert"
13477 );
13478 }
13479
13480 async fn export_opml_response(
13482 fail_on: Option<&'static str>,
13483 ) -> (StatusCode, HeaderMap, String) {
13484 let did = "did:plc:exporter";
13485 let sidecar = spawn_export_sidecar(fail_on).await;
13486 let state = test_state_with_sidecar(&[did], &sidecar).await;
13487 let cookie = session_cookie(&state, did, None);
13488 let resp = router(state)
13489 .oneshot(
13490 Request::builder()
13491 .uri("/opml/export")
13492 .header(header::COOKIE, cookie)
13493 .body(Body::empty())
13494 .unwrap(),
13495 )
13496 .await
13497 .unwrap();
13498 let status = resp.status();
13499 let headers = resp.headers().clone();
13500 let body = String::from_utf8_lossy(
13501 &axum::body::to_bytes(resp.into_body(), usize::MAX)
13502 .await
13503 .unwrap(),
13504 )
13505 .to_string();
13506 (status, headers, body)
13507 }
13508
13509 #[tokio::test]
13522 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13523 let (status, headers, body) =
13524 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13525
13526 assert_ne!(
13527 status,
13528 StatusCode::OK,
13529 "a failed subscription walk answered 200: {body}",
13530 );
13531 assert!(
13532 !headers.contains_key(header::CONTENT_DISPOSITION),
13533 "a failed subscription walk still offered a download: {headers:?}",
13534 );
13535 assert!(
13536 !body.contains("<opml"),
13537 "a failed subscription walk still served an OPML document: {body}",
13538 );
13539 }
13540
13541 #[tokio::test]
13545 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13546 let (status, headers, body) =
13547 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13548
13549 assert_ne!(
13550 status,
13551 StatusCode::OK,
13552 "a failed folder walk answered 200: {body}",
13553 );
13554 assert!(
13555 !headers.contains_key(header::CONTENT_DISPOSITION),
13556 "a failed folder walk still offered a download: {headers:?}",
13557 );
13558 assert!(
13559 !body.contains("<opml"),
13560 "a failed folder walk still served an OPML document: {body}",
13561 );
13562 }
13563
13564 #[tokio::test]
13567 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13568 let (status, headers, body) = export_opml_response(None).await;
13569
13570 assert_eq!(
13571 status,
13572 StatusCode::OK,
13573 "a healthy export did not answer 200"
13574 );
13575 assert_eq!(
13576 headers
13577 .get(header::CONTENT_DISPOSITION)
13578 .and_then(|v| v.to_str().ok()),
13579 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13580 "a healthy export did not offer the download",
13581 );
13582 assert!(
13583 body.contains("https://kept.example/feed.xml"),
13584 "the exported OPML lost the subscription: {body}",
13585 );
13586 assert!(
13587 body.contains("Kept folder"),
13588 "the exported OPML lost the folder: {body}",
13589 );
13590 }
13591}