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.2",
1568 date: "2026-10-04",
1569 summary: "A public standard.site feature page with this list of recent \
1570 releases, and link cards: a posted feather-reader.com link \
1571 now unfurls with a description and an image.",
1572 },
1573 Release {
1574 version: "0.4.1",
1575 date: "2026-10-04",
1576 summary: "The public pages explain standard.site publications, and the \
1577 subscribe form can submit the DID form of a publication URI, \
1578 which browsers refused in 0.4.0.",
1579 },
1580 Release {
1581 version: "0.4.0",
1582 date: "2026-10-03",
1583 summary: "standard.site support: publications are read from their \
1584 authors' atproto repos as subscriptions, beside RSS, on their \
1585 own polling loop. Every stored field from a feed or a \
1586 publication now has a size bound.",
1587 },
1588];
1589
1590#[derive(Template)]
1602#[template(path = "stats.html")]
1603struct StatsTemplate {
1604 card: Card,
1606 version: &'static str,
1607 repo_url: &'static str,
1608 kofi_url: &'static str,
1609 feeds_tracked: i64,
1610 polled_last_hour: i64,
1611 polled_pct: i64,
1612 overdue: i64,
1613 last_poll: String,
1614 oldest_poll: String,
1615 never_polled: i64,
1616 poll_interval_mins: i64,
1617 in_backoff: i64,
1619 badly_broken: i64,
1623 failure_kinds: Vec<(String, i64)>,
1625 fetching: &'static str,
1629}
1630
1631#[derive(Template)]
1635#[template(path = "privacy.html")]
1636struct PrivacyTemplate {
1637 card: Card,
1639 version: &'static str,
1640 repo_url: &'static str,
1641 kofi_url: &'static str,
1642}
1643
1644#[derive(Template)]
1647#[template(path = "terms.html")]
1648struct TermsTemplate {
1649 card: Card,
1651 version: &'static str,
1652 repo_url: &'static str,
1653 kofi_url: &'static str,
1654}
1655
1656#[derive(Template)]
1659#[template(path = "landing.html")]
1660struct LandingTemplate {
1661 card: Card,
1663 version: &'static str,
1664 repo_url: &'static str,
1665 crates_url: &'static str,
1666 kofi_url: &'static str,
1667 standard_site: bool,
1670 releases: &'static [Release],
1672}
1673
1674#[derive(Template)]
1676#[template(path = "entry.html")]
1677struct EntryTemplate {
1678 card: Card,
1680 version: &'static str,
1681 repo_url: &'static str,
1682 kofi_url: &'static str,
1683 nav: Nav,
1684 id: i64,
1685 title: String,
1686 feed_title: String,
1687 author: Option<String>,
1688 published: String,
1689 url: Option<SafeLink>,
1699 content_html: Option<String>,
1700 read: bool,
1701 starred: bool,
1702 back_qs: String,
1704 prev_id: Option<i64>,
1706 next_id: Option<i64>,
1707 oob: bool,
1709}
1710
1711#[derive(Template)]
1713#[template(path = "entry_row.html")]
1714struct EntryRowTemplate {
1715 e: EntryRow,
1716}
1717
1718#[derive(Template)]
1723#[template(path = "entry_actionbar.html")]
1724struct EntryActionBarTemplate {
1725 id: i64,
1726 read: bool,
1727 starred: bool,
1728 oob: bool,
1730}
1731
1732#[derive(Template)]
1734#[template(path = "login.html")]
1735struct LoginTemplate {
1736 card: Card,
1738 repo_url: &'static str,
1739 error: String,
1740 flash: String,
1743}
1744
1745#[derive(Template)]
1747#[template(path = "beta_redeem.html")]
1748struct BetaRedeemTemplate {
1749 card: Card,
1751 repo_url: &'static str,
1752 error: String,
1753 capacity_full: bool,
1756}
1757
1758fn render<T: Template>(tmpl: &T) -> Response {
1765 match tmpl.render() {
1766 Ok(body) => Html(body).into_response(),
1767 Err(err) => {
1768 warn!(%err, "template render failed");
1769 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1770 }
1771 }
1772}
1773
1774struct WebError {
1779 err: anyhow::Error,
1780 status: StatusCode,
1781}
1782
1783impl<E: Into<anyhow::Error>> From<E> for WebError {
1784 fn from(err: E) -> Self {
1785 WebError {
1786 err: err.into(),
1787 status: StatusCode::INTERNAL_SERVER_ERROR,
1788 }
1789 }
1790}
1791
1792impl WebError {
1793 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1795 WebError {
1796 err: err.into(),
1797 status,
1798 }
1799 }
1800}
1801
1802impl IntoResponse for WebError {
1803 fn into_response(self) -> Response {
1804 warn!(error = %self.err, status = %self.status, "request failed");
1805 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1806 "internal error"
1807 } else {
1808 self.status.canonical_reason().unwrap_or("error")
1809 };
1810 (self.status, body).into_response()
1811 }
1812}
1813
1814fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1819 let status = err.status();
1820 WebError::with_status(err, status)
1821}
1822
1823fn display_title(title: Option<&str>, url: &str) -> String {
1826 if let Some(t) = title {
1827 let t = t.trim();
1828 if !t.is_empty() {
1829 return t.to_string();
1830 }
1831 }
1832 url::Url::parse(url)
1833 .ok()
1834 .and_then(|u| u.host_str().map(str::to_string))
1835 .unwrap_or_else(|| url.to_string())
1836}
1837
1838fn display_handle(handle: Option<&str>, did: &str) -> String {
1841 match handle {
1842 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1843 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1844 }
1845}
1846
1847fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1849 let source = handle
1850 .map(|h| h.trim().trim_start_matches('@'))
1851 .filter(|h| !h.is_empty())
1852 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1853 let letters: String = source
1854 .chars()
1855 .filter(|c| c.is_alphanumeric())
1856 .take(2)
1857 .collect::<String>()
1858 .to_lowercase();
1859 if letters.is_empty() {
1860 "fr".to_string()
1861 } else {
1862 letters
1863 }
1864}
1865
1866fn display_date(published: Option<&str>) -> String {
1869 match published {
1878 Some(p) => p.chars().take(10).collect(),
1879 None => String::new(),
1880 }
1881}
1882
1883fn qenc(s: &str) -> String {
1887 let mut out = String::with_capacity(s.len() * 3);
1888 for b in s.bytes() {
1889 match b {
1890 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1891 out.push(b as char)
1892 }
1893 _ => out.push_str(&format!("%{b:02X}")),
1894 }
1895 }
1896 out
1897}
1898
1899#[derive(Debug, Deserialize, Default)]
1905struct IndexQuery {
1906 #[serde(default)]
1908 feed: Option<String>,
1909 #[serde(default)]
1911 folder: Option<String>,
1912 #[serde(default)]
1914 view: Option<String>,
1915 #[serde(default)]
1917 page: Option<u32>,
1918 #[serde(default)]
1920 flash: Option<String>,
1921}
1922
1923const ENTRIES_PER_PAGE: i64 = 100;
1931
1932fn page_count_for(total: i64) -> i64 {
1935 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1936}
1937
1938const PREV_NEXT_MAX: i64 = 5_000;
1945
1946const STARRED_IDENTITY_MAX: i64 = 20_000;
1954
1955const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1969
1970struct ResolvedSub {
1973 rkey: String,
1974 sub: Subscription,
1975 feed: Option<store::Feed>,
1976}
1977
1978async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1982 resolve_subscriptions_noting(state, did).await.0
1983}
1984
1985fn subscriptions_alert(err: &anyhow::Error) -> String {
1992 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
1993 Some(m) => format!(
1994 "{} record(s) in your subscription list could not be read, so it was not \
1995 refreshed. Showing your last-known subscriptions; nothing was removed.",
1996 m.count
1997 ),
1998 None => "Your subscription list could not be read from your PDS just now. \
1999 Showing your last-known subscriptions."
2000 .to_string(),
2001 }
2002}
2003
2004async fn resolve_subscriptions_noting(
2007 state: &AppState,
2008 did: &str,
2009) -> (Vec<ResolvedSub>, Option<String>) {
2010 let pool = &state.db;
2011 let subs = match state.repo().list_subscriptions_sorted(did).await {
2012 Ok(s) => s,
2013 Err(err) => {
2014 let alert = subscriptions_alert(&err);
2015 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2016 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2029 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2030 projection could not be read; rendering an EMPTY \
2031 feed list, which is not the same as having none");
2032 Vec::new()
2033 });
2034 let cached = feeds
2035 .into_iter()
2036 .map(|f| ResolvedSub {
2037 rkey: String::new(),
2038 sub: Subscription::new(f.url.clone(), now_rfc3339()),
2039 feed: Some(f),
2040 })
2041 .collect();
2042 return (cached, Some(alert));
2043 }
2044 };
2045
2046 let mut out = Vec::with_capacity(subs.len());
2062 for (rkey, sub) in subs {
2063 let feed = match store::get_feed_by_url(pool, &sub.url).await {
2064 Ok(Some(f)) => Some(f),
2065 Ok(None) => {
2066 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2077 || feed::classify_feed_privacy(&sub.url).is_private()
2078 {
2079 warn!(
2080 %did,
2081 "skipping cache row for a subscription URL that is private or not http(s)"
2082 );
2083 out.push(ResolvedSub {
2084 rkey,
2085 sub,
2086 feed: None,
2087 });
2088 continue;
2089 }
2090 if let Err(err) = store::upsert_feed(
2098 pool,
2099 &store::NewFeed {
2100 url: sub.url.clone(),
2101 title: sub.title.clone(),
2102 site_url: sub.site_url.clone(),
2103 ..Default::default()
2104 },
2105 )
2106 .await
2107 {
2108 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2109 it will not be polled");
2110 }
2111 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2112 }
2113 Err(err) => {
2114 warn!(%err, url = %sub.url, "get_feed_by_url failed");
2115 None
2116 }
2117 };
2118 out.push(ResolvedSub { rkey, sub, feed });
2119 }
2120 sync_sub_refs(pool, did, &out).await;
2124 (out, None)
2125}
2126
2127async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2131 let feed_ids: Vec<i64> = subs
2132 .iter()
2133 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2134 .collect();
2135 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2136 warn!(%err, %did, "failed to sync sub_ref projection");
2137 }
2138}
2139
2140async fn index(
2143 State(state): State<AppState>,
2144 headers: HeaderMap,
2145 Query(q): Query<IndexQuery>,
2146) -> Result<Response, WebError> {
2147 let user = match current_session(&state, &headers).await {
2148 Some(u) => u,
2149 None => {
2152 return Ok(render(&LandingTemplate {
2153 card: Card::site(&state.config),
2154 version: VERSION,
2155 repo_url: REPO_URL,
2156 crates_url: CRATES_URL,
2157 kofi_url: KOFI_URL,
2158 standard_site: state.config.standard_site,
2159 releases: RELEASES,
2160 }))
2161 }
2162 };
2163 let did = user.did.clone();
2164 let pool = &state.db;
2165
2166 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2167
2168 let view = match q.view.as_deref() {
2170 Some("all") => "all",
2171 Some("starred") => "starred",
2172 _ => "unread",
2173 }
2174 .to_string();
2175 let list_view = list_view_of(q.view.as_deref());
2176
2177 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2179 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2184
2185 let feed_title_by_id = |id: i64| -> String {
2186 subs.iter()
2187 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2188 .map(|s| {
2189 display_title(
2190 s.sub
2191 .title
2192 .as_deref()
2193 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2194 &s.sub.url,
2195 )
2196 })
2197 .unwrap_or_default()
2198 };
2199
2200 let mut uncached: Vec<EntryRow> = Vec::new();
2213 if view == "starred" {
2214 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2243 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2244 Ok(store::StarredIdentities::Truncated) => {
2249 warn!(
2250 %did,
2251 cap = STARRED_IDENTITY_MAX,
2252 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2253 rather than rendering record-deleting buttons for cached articles"
2254 );
2255 None
2256 }
2257 Err(err) => {
2258 warn!(%err, %did, "cached-starred identity lookup failed; \
2259 suppressing uncached saved rows this render");
2260 None
2261 }
2262 };
2263 let identities_ok = identities.is_some();
2270 let identities = identities.unwrap_or_default();
2271 let cached_urls: std::collections::HashSet<&str> = identities
2272 .iter()
2273 .filter_map(|(url, _)| url.as_deref())
2274 .collect();
2275 let cached_guids: std::collections::HashSet<&str> =
2276 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2277
2278 let mut uncached_dropped = 0usize;
2291 match state.repo().list_saved_sorted(&did).await {
2292 Ok(saved) if identities_ok => {
2293 for (rkey, item) in saved {
2294 let known = cached_urls.contains(item.url.as_str())
2295 || item
2296 .entry_id
2297 .as_deref()
2298 .is_some_and(|g| cached_guids.contains(g));
2299 if known {
2300 continue;
2301 }
2302 if let Some(urls) = &scope_urls {
2306 match item.feed_url.as_deref() {
2307 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2308 _ => continue,
2312 }
2313 }
2314 let link = SafeLink::external(&item.url);
2340 if link.is_empty() {
2341 warn!(
2342 %did, %rkey,
2343 "a saved record has an unusable URL; rendering it without a link \
2344 so it can still be removed"
2345 );
2346 }
2347
2348 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2362 uncached_dropped += 1;
2363 continue;
2364 }
2365 if let Some(feed_url) = item.feed_url.as_deref() {
2366 if subs.iter().any(|s| s.sub.url == feed_url) {
2367 let stale_before = (chrono::Utc::now()
2371 - chrono::Duration::from_std(state.config.poll_interval)
2372 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2373 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2374 if let Err(err) =
2375 store::mark_feed_due(pool, feed_url, &stale_before).await
2376 {
2377 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2378 }
2379 }
2380 }
2381 uncached.push(EntryRow {
2382 id: 0,
2383 title: item
2384 .title
2385 .clone()
2386 .filter(|t| !t.trim().is_empty())
2387 .unwrap_or_else(|| {
2395 if link.is_empty() {
2396 format!("Saved item {rkey}")
2397 } else {
2398 item.url.clone()
2399 }
2400 }),
2401 feed_title: item.feed_url.clone().unwrap_or_default(),
2402 published: display_date(Some(&item.created_at)),
2403 read: false,
2404 starred: true,
2405 link,
2409 cached: false,
2410 rkey,
2411 });
2412 }
2413 }
2414 Ok(_) => {}
2416 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2417 }
2418 if uncached_dropped > 0 {
2419 warn!(
2420 %did,
2421 dropped = uncached_dropped,
2422 cap = MAX_UNCACHED_SAVED_ROWS,
2423 "more saved records than this instance will hold in one response; the \
2424 rest are not reachable from here"
2425 );
2426 }
2427 }
2428
2429 let total_cached =
2445 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2446 let uncached_len = uncached.len();
2447 let total = total_cached + uncached_len as i64;
2448 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2453 let offset = (page - 1) * ENTRIES_PER_PAGE;
2454 let source = store::list_entries(
2457 pool,
2458 &did,
2459 list_view,
2460 scope_ids.as_deref(),
2461 ENTRIES_PER_PAGE,
2462 offset,
2463 )
2464 .await?;
2465 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2485 let cached_here = cached_allotment.min(source.len());
2486 let source = if uncached_len == 0 {
2491 &source[..]
2492 } else {
2493 &source[..cached_here]
2494 };
2495 let uncached_page: Vec<EntryRow> = {
2496 let skip = (offset - total_cached).max(0) as usize;
2497 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2498 uncached.into_iter().skip(skip).take(take).collect()
2499 };
2500 let uncached_total = uncached_len as i64;
2503
2504 let entry_scope_qs = {
2506 let mut parts = Vec::new();
2507 if let Some(f) = q.feed.as_deref() {
2508 parts.push(format!("feed={}", qenc(f)));
2509 }
2510 if let Some(f) = q.folder.as_deref() {
2511 parts.push(format!("folder={}", qenc(f)));
2512 }
2513 if view != "unread" {
2514 parts.push(format!("view={}", qenc(&view)));
2515 }
2516 parts.join("&")
2517 };
2518 let entries: Vec<EntryRow> = source
2519 .iter()
2520 .map(|e| EntryRow {
2521 id: e.id,
2522 title: e
2523 .title
2524 .clone()
2525 .filter(|t| !t.trim().is_empty())
2526 .unwrap_or_else(|| "(untitled)".to_string()),
2527 feed_title: feed_title_by_id(e.feed_id),
2528 published: display_date(e.published.as_deref()),
2529 read: e.read,
2534 starred: e.starred,
2535 link: SafeLink::entry(e.id, &entry_scope_qs),
2536 cached: true,
2537 rkey: String::new(),
2538 })
2539 .collect();
2540
2541 let mut entries = entries;
2543 entries.extend(uncached_page);
2544 let entries = entries;
2545
2546 let selected_feed = q.feed.as_deref();
2547 let selected_folder = q.folder.as_deref();
2548
2549 let (folder_views, loose_feeds, _folder_options) =
2551 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2552
2553 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2555 let name = subs
2556 .iter()
2557 .find(|s| s.sub.url == feed_url)
2558 .map(|s| {
2559 display_title(
2560 s.sub
2561 .title
2562 .as_deref()
2563 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2564 &s.sub.url,
2565 )
2566 })
2567 .unwrap_or_else(|| display_title(None, feed_url));
2568 (name, format!("feed={}", qenc(feed_url)))
2569 } else if let Some(folder_uri) = selected_folder {
2570 let name = folder_views
2571 .iter()
2572 .find(|f| f.uri == folder_uri)
2573 .map(|f| f.name.clone())
2574 .unwrap_or_else(|| "Folder".to_string());
2575 (name, format!("folder={}", qenc(folder_uri)))
2576 } else {
2577 let h = match view.as_str() {
2578 "all" => "All",
2579 "starred" => "Starred",
2580 _ => "Unread",
2581 };
2582 (h.to_string(), String::new())
2583 };
2584
2585 let feed_scope = selected_feed.map(str::to_string);
2586 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2587
2588 let page_href = |n: i64| -> String {
2592 let mut parts = Vec::new();
2593 if !entry_scope_qs.is_empty() {
2594 parts.push(entry_scope_qs.clone());
2595 }
2596 if n > 1 {
2597 parts.push(format!("page={n}"));
2598 }
2599 if parts.is_empty() {
2600 "/".to_string()
2601 } else {
2602 format!("/?{}", parts.join("&"))
2603 }
2604 };
2605 let prev_href = (page > 1).then(|| page_href(page - 1));
2606 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2607
2608 let tmpl = IndexTemplate {
2609 card: Card::private(&state.config),
2610 version: VERSION,
2611 repo_url: REPO_URL,
2612 kofi_url: KOFI_URL,
2613 flash: q.flash.unwrap_or_default(),
2614 alert: alert.unwrap_or_default(),
2615 nav,
2616 entries,
2617 heading,
2618 feed_scope,
2619 total,
2620 uncached_total,
2623 page,
2624 page_count: page_count_for(total),
2625 prev_href,
2626 next_href,
2627 };
2628 Ok(render(&tmpl))
2629}
2630
2631#[derive(Debug, Deserialize, Default)]
2633struct ManageQuery {
2634 #[serde(default)]
2635 flash: Option<String>,
2636}
2637
2638async fn manage(
2643 State(state): State<AppState>,
2644 headers: HeaderMap,
2645 Query(q): Query<ManageQuery>,
2646) -> Result<Response, WebError> {
2647 let user = match current_session(&state, &headers).await {
2648 Some(u) => u,
2649 None => return Ok(Redirect::to("/login").into_response()),
2650 };
2651 let did = user.did.clone();
2652
2653 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2654 let (folder_views, loose_feeds, folder_options) =
2655 build_sidebar(&state, &did, &subs, None, None).await;
2656
2657 let nav = build_nav(
2659 &user,
2660 "unread",
2661 String::new(),
2662 folder_views.iter().map(clone_folder_view).collect(),
2663 loose_feeds.iter().map(clone_feed_view).collect(),
2664 true,
2665 );
2666
2667 let tmpl = ManageTemplate {
2668 card: Card::private(&state.config),
2669 version: VERSION,
2670 repo_url: REPO_URL,
2671 kofi_url: KOFI_URL,
2672 flash: q.flash.unwrap_or_default(),
2673 alert: alert.unwrap_or_default(),
2674 nav,
2675 folder_options,
2676 folders: folder_views,
2677 loose_feeds,
2678 standard_site: state.config.standard_site,
2679 };
2680 Ok(render(&tmpl))
2681}
2682
2683fn clone_feed_view(f: &FeedView) -> FeedView {
2686 FeedView {
2687 rkey: f.rkey.clone(),
2688 url: f.url.clone(),
2689 title: f.title.clone(),
2690 unread: f.unread,
2691 selected: f.selected,
2692 folder: f.folder.clone(),
2693 }
2694}
2695
2696fn clone_folder_view(f: &FolderView) -> FolderView {
2697 FolderView {
2698 rkey: f.rkey.clone(),
2699 uri: f.uri.clone(),
2700 name: f.name.clone(),
2701 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2702 selected: f.selected,
2703 }
2704}
2705
2706fn scope_urls_for(
2711 subs: &[ResolvedSub],
2712 feed: Option<&str>,
2713 folder: Option<&str>,
2714) -> Option<Vec<String>> {
2715 if let Some(feed_url) = feed {
2716 Some(vec![feed_url.to_string()])
2717 } else {
2718 folder.map(|folder_uri| {
2719 subs.iter()
2720 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2721 .map(|s| s.sub.url.clone())
2722 .collect()
2723 })
2724 }
2725}
2726
2727fn folder_uri(did: &str, rkey: &str) -> String {
2729 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2730}
2731
2732async fn build_sidebar(
2736 state: &AppState,
2737 did: &str,
2738 subs: &[ResolvedSub],
2739 selected_feed: Option<&str>,
2740 selected_folder: Option<&str>,
2741) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2742 let pool = &state.db;
2743 let unread_counts = store::unread_counts_by_feed(pool, did)
2748 .await
2749 .unwrap_or_else(|err| {
2750 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2751 Default::default()
2752 });
2753 let folders = state
2754 .repo()
2755 .list_folders_sorted(did)
2756 .await
2757 .unwrap_or_default();
2758
2759 let unread_count = |feed_id: Option<i64>| -> i64 {
2760 feed_id
2761 .and_then(|id| unread_counts.get(&id).copied())
2762 .unwrap_or(0)
2763 };
2764 let mk_feed_view = |s: &ResolvedSub| FeedView {
2765 rkey: s.rkey.clone(),
2766 url: s.sub.url.clone(),
2767 title: display_title(
2768 s.sub
2769 .title
2770 .as_deref()
2771 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2772 &s.sub.url,
2773 ),
2774 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2775 selected: selected_feed == Some(s.sub.url.as_str()),
2776 folder: s.sub.folder.clone(),
2777 };
2778
2779 let mut folder_views = Vec::with_capacity(folders.len());
2780 for (rkey, folder) in &folders {
2781 let uri = folder_uri(did, rkey);
2782 let feeds: Vec<FeedView> = subs
2783 .iter()
2784 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2785 .map(mk_feed_view)
2786 .collect();
2787 folder_views.push(FolderView {
2788 rkey: rkey.clone(),
2789 uri: uri.clone(),
2790 name: folder.name.clone(),
2791 feeds,
2792 selected: selected_folder == Some(uri.as_str()),
2793 });
2794 }
2795
2796 let known_uris: std::collections::HashSet<String> =
2797 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2798 let loose_feeds: Vec<FeedView> = subs
2799 .iter()
2800 .filter(|s| {
2801 s.sub
2802 .folder
2803 .as_deref()
2804 .map(|f| !known_uris.contains(f))
2805 .unwrap_or(true)
2806 })
2807 .map(mk_feed_view)
2808 .collect();
2809
2810 let folder_options: Vec<FolderOption> = folders
2811 .iter()
2812 .map(|(rkey, folder)| FolderOption {
2813 name: folder.name.clone(),
2814 uri: folder_uri(did, rkey),
2815 })
2816 .collect();
2817
2818 (folder_views, loose_feeds, folder_options)
2819}
2820
2821fn build_nav(
2823 user: &CurrentUser,
2824 view: &str,
2825 scope_qs: String,
2826 folders: Vec<FolderView>,
2827 loose_feeds: Vec<FeedView>,
2828 manage_active: bool,
2829) -> Nav {
2830 Nav {
2831 handle: display_handle(user.handle.as_deref(), &user.did),
2832 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2833 view: view.to_string(),
2834 scope_qs,
2835 folders,
2836 loose_feeds,
2837 manage_active,
2838 }
2839}
2840
2841#[derive(Debug, Deserialize, Default)]
2848struct EntryQuery {
2849 #[serde(default)]
2850 feed: Option<String>,
2851 #[serde(default)]
2852 folder: Option<String>,
2853 #[serde(default)]
2854 view: Option<String>,
2855}
2856
2857async fn entry_view(
2860 State(state): State<AppState>,
2861 headers: HeaderMap,
2862 Path(id): Path<i64>,
2863 Query(q): Query<EntryQuery>,
2864) -> Result<Response, WebError> {
2865 let user = match current_session(&state, &headers).await {
2866 Some(u) => u,
2867 None => return Ok(Redirect::to("/login").into_response()),
2868 };
2869 let did = user.did.clone();
2870 let pool = &state.db;
2871
2872 let subs = resolve_subscriptions(&state, &did).await;
2876
2877 let entry = match get_entry_by_id(pool, &did, id).await? {
2878 Some(e) => e,
2879 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2880 };
2881
2882 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2883
2884 let read = entry_is_read(pool, &did, id).await?;
2885 let starred = entry_is_starred(pool, &did, id).await?;
2886
2887 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2890
2891 let back_qs = scope_query(&q);
2892
2893 let (folder_views, loose_feeds, _) =
2894 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2895 let nav_view = match q.view.as_deref() {
2896 Some("all") => "all",
2897 Some("starred") => "starred",
2898 _ => "unread",
2899 };
2900 let nav = build_nav(
2901 &user,
2902 nav_view,
2903 back_qs.clone(),
2904 folder_views,
2905 loose_feeds,
2906 false,
2907 );
2908
2909 let tmpl = EntryTemplate {
2910 card: Card::private(&state.config),
2911 version: VERSION,
2912 repo_url: REPO_URL,
2913 kofi_url: KOFI_URL,
2914 nav,
2915 id: entry.id,
2916 title: entry
2917 .title
2918 .clone()
2919 .filter(|t| !t.trim().is_empty())
2920 .unwrap_or_else(|| "(untitled)".to_string()),
2921 feed_title,
2922 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2923 published: display_date(entry.published.as_deref()),
2924 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2925 content_html: entry.content_html.clone(),
2926 read,
2927 starred,
2928 back_qs,
2929 prev_id,
2930 next_id,
2931 oob: false,
2932 };
2933 Ok(render(&tmpl))
2934}
2935
2936async fn neighbors_in_scope(
2939 state: &AppState,
2940 did: &str,
2941 q: &EntryQuery,
2942 current: i64,
2943) -> (Option<i64>, Option<i64>) {
2944 let idx_q = IndexQuery {
2945 feed: q.feed.clone(),
2946 folder: q.folder.clone(),
2947 view: q.view.clone(),
2948 page: None,
2950 flash: None,
2951 };
2952 let ids = list_entry_ids(state, did, &idx_q).await;
2953 let pos = ids.iter().position(|&x| x == current);
2954 match pos {
2955 Some(p) => {
2956 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2957 let next = ids.get(p + 1).copied();
2958 (prev, next)
2959 }
2960 None => (None, None),
2961 }
2962}
2963
2964async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2967 let pool = &state.db;
2968 let subs = resolve_subscriptions(state, did).await;
2969
2970 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2971
2972 store::list_entry_ids(
2978 pool,
2979 did,
2980 list_view_of(q.view.as_deref()),
2981 scoped_feed_ids(&subs, &scope_urls).as_deref(),
2982 PREV_NEXT_MAX,
2983 )
2984 .await
2985 .unwrap_or_else(|err| {
2986 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2987 Vec::new()
2988 })
2989}
2990
2991fn list_view_of(view: Option<&str>) -> store::ListView {
2994 match view {
2995 Some("all") => store::ListView::All,
2996 Some("starred") => store::ListView::Starred,
2997 _ => store::ListView::Unread,
2998 }
2999}
3000
3001fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3007 let urls = scope_urls.as_ref()?;
3008 Some(
3009 subs.iter()
3010 .filter(|s| urls.contains(&s.sub.url))
3011 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3012 .collect(),
3013 )
3014}
3015
3016fn scope_query(q: &EntryQuery) -> String {
3018 let mut parts = Vec::new();
3019 if let Some(f) = q.feed.as_deref() {
3020 parts.push(format!("feed={}", qenc(f)));
3021 }
3022 if let Some(f) = q.folder.as_deref() {
3023 parts.push(format!("folder={}", qenc(f)));
3024 }
3025 if let Some(v) = q.view.as_deref() {
3026 if v != "unread" {
3027 parts.push(format!("view={}", qenc(v)));
3028 }
3029 }
3030 parts.join("&")
3031}
3032
3033#[derive(Debug, Deserialize)]
3039struct ReadForm {
3040 #[serde(default)]
3041 read: Option<String>,
3042}
3043
3044async fn mark_read(
3046 State(state): State<AppState>,
3047 Path(id): Path<i64>,
3048 headers: HeaderMap,
3049 Form(form): Form<ReadForm>,
3050) -> Result<Response, WebError> {
3051 let did = match current_did(&state, &headers).await {
3052 Some(d) => d,
3053 None => return Ok(Redirect::to("/login").into_response()),
3054 };
3055 let pool = &state.db;
3056
3057 let read = matches!(
3058 form.read.as_deref(),
3059 Some("true") | Some("1") | Some("on") | None
3060 );
3061
3062 resolve_subscriptions(&state, &did).await;
3067 if !store::mark_read(pool, &did, id, read).await? {
3068 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3069 }
3070
3071 if !is_htmx(&headers) {
3072 return Ok(Redirect::to("/").into_response());
3073 }
3074
3075 if is_reader_request(&headers) {
3079 let starred = entry_is_starred(pool, &did, id).await?;
3080 return Ok(render(&EntryActionBarTemplate {
3081 id,
3082 read,
3083 starred,
3084 oob: true,
3085 }));
3086 }
3087
3088 let row = build_entry_row(pool, &did, id, Some(read)).await?;
3089 match row {
3090 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3091 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3092 }
3093}
3094
3095#[derive(Debug, Deserialize)]
3101struct StarForm {
3102 #[serde(default)]
3103 starred: Option<String>,
3104}
3105
3106async fn toggle_star(
3112 State(state): State<AppState>,
3113 Path(id): Path<i64>,
3114 headers: HeaderMap,
3115 Form(form): Form<StarForm>,
3116) -> Result<Response, WebError> {
3117 let did = match current_did(&state, &headers).await {
3118 Some(d) => d,
3119 None => return Ok(Redirect::to("/login").into_response()),
3120 };
3121 let pool = &state.db;
3122
3123 let starred = matches!(
3124 form.starred.as_deref(),
3125 Some("true") | Some("1") | Some("on") | None
3126 );
3127
3128 resolve_subscriptions(&state, &did).await;
3132 if !store::mark_starred(pool, &did, id, starred).await? {
3133 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3134 }
3135
3136 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3139 let entry_url = entry.url.clone().unwrap_or_default();
3140 if !entry_url.is_empty() {
3141 if starred {
3142 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3143 saved.title = entry.title.clone();
3144 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3145 saved.entry_id = Some(entry.guid.clone());
3146 match state.repo().add_saved(&did, &saved).await {
3147 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3148 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3149 }
3150 } else {
3151 match state.repo().list_saved(&did).await {
3153 Ok(records) => {
3154 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3155 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3156 warn!(%err, %did, %rkey, "PDS saved delete failed");
3157 }
3158 }
3159 }
3160 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3161 }
3162 }
3163 }
3164 }
3165
3166 if !is_htmx(&headers) {
3167 return Ok(Redirect::to("/").into_response());
3168 }
3169
3170 if is_reader_request(&headers) {
3172 let read = entry_is_read(pool, &did, id).await?;
3173 return Ok(render(&EntryActionBarTemplate {
3174 id,
3175 read,
3176 starred,
3177 oob: true,
3178 }));
3179 }
3180
3181 let row = build_entry_row(pool, &did, id, None).await?;
3182 match row {
3183 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3184 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3185 }
3186}
3187
3188async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3190 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3191 .bind(feed_id)
3192 .fetch_optional(pool)
3193 .await
3194 .ok()
3195 .flatten()
3196}
3197
3198#[derive(Debug, Deserialize, Default)]
3205struct ReadAllQuery {
3206 #[serde(default)]
3207 feed: Option<String>,
3208}
3209
3210async fn mark_all_read(
3213 State(state): State<AppState>,
3214 headers: HeaderMap,
3215 Query(q): Query<ReadAllQuery>,
3216) -> Result<Response, WebError> {
3217 let did = match current_did(&state, &headers).await {
3218 Some(d) => d,
3219 None => return Ok(Redirect::to("/login").into_response()),
3220 };
3221 let pool = &state.db;
3222
3223 resolve_subscriptions(&state, &did).await;
3226
3227 if let Some(feed_url) = q.feed.as_deref() {
3228 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3229 store::mark_feed_read(pool, &did, feed.id, true).await?;
3230 }
3231 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3232 }
3233
3234 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3239 store::mark_feed_read(pool, &did, feed_id, true).await?;
3240 }
3241 Ok(Redirect::to("/").into_response())
3242}
3243
3244const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3254 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3255
3256const EXPORT_INCOMPLETE_REFUSAL: &str =
3263 "Could not read your subscriptions in full, so nothing was exported. Your \
3264 feeds are unchanged — try again, and if it keeps failing the list may be \
3265 larger than this reader can page through.";
3266
3267const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3273 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3274 feeds for now — private-feed support arrives when atproto's private data \
3275 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3276
3277#[derive(Debug, Deserialize)]
3279struct SubscribeForm {
3280 url: String,
3281 #[serde(default)]
3283 folder: Option<String>,
3284}
3285
3286async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3296 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3297 let canonical = format!(
3298 "{}{}",
3299 crate::atproto::AT_URI_PREFIX,
3300 &input[crate::atproto::AT_URI_PREFIX.len()..]
3301 );
3302 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3303 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3304 return Err(unsupported());
3305 }
3306 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3307 uri.authority.clone()
3308 } else {
3309 let handle =
3310 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3315 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3316 .await
3317 .map_err(|err| {
3318 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3319 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3320 })?
3321 };
3322 let url = format!(
3323 "{}{did}/{}/{}",
3324 crate::atproto::AT_URI_PREFIX,
3325 uri.collection,
3326 uri.rkey
3327 );
3328 if !feed::is_storable_feed_url(&url, true) {
3329 return Err(unsupported());
3330 }
3331 Ok(url)
3332}
3333
3334async fn add_subscription(
3336 State(state): State<AppState>,
3337 headers: HeaderMap,
3338 Form(form): Form<SubscribeForm>,
3339) -> Result<Response, WebError> {
3340 let did = match current_did(&state, &headers).await {
3341 Some(d) => d,
3342 None => return Ok(Redirect::to("/login").into_response()),
3343 };
3344 let pool = &state.db;
3345 let input = form.url.trim().to_string();
3346 if input.is_empty() {
3347 return Ok(Redirect::to("/").into_response());
3348 }
3349
3350 let cap = state.config.max_subs_per_did;
3354 if cap > 0 {
3355 match store::count_subscriptions_for_did(pool, &did).await {
3356 Ok(n) if n >= cap => {
3357 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3358 return Ok(Redirect::to(&format!(
3359 "/?flash={}",
3360 qenc(&format!(
3361 "Subscription limit reached ({cap}). Remove a feed before adding another."
3362 ))
3363 ))
3364 .into_response());
3365 }
3366 Ok(_) => {}
3367 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3368 }
3369 }
3370
3371 let is_at_uri = input
3376 .get(..crate::atproto::AT_URI_PREFIX.len())
3377 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3378 let publication_url = if is_at_uri {
3379 if !state.config.standard_site {
3380 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3381 return Ok(
3382 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3383 .into_response(),
3384 );
3385 }
3386 match publication_url_from_paste(&state, &input).await {
3387 Ok(url) => Some(url),
3388 Err(flash) => {
3389 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3390 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3391 }
3392 }
3393 } else {
3394 None
3395 };
3396
3397 if let feed::FeedPrivacy::Private(reason) =
3398 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3399 {
3400 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3401 return Ok(
3402 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3403 );
3404 }
3405
3406 let resolved = match publication_url {
3407 Some(url) => Ok(url),
3408 None => resolve_feed_url(&state.config, &input).await,
3409 };
3410 let feed_url = match resolved {
3411 Ok(u) => u,
3412 Err(err) => {
3413 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3414 return Ok(Redirect::to(&format!(
3415 "/?flash={}",
3416 qenc("Couldn't find a feed at that URL")
3417 ))
3418 .into_response());
3419 }
3420 };
3421
3422 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3426 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3427 return Ok(
3428 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3429 );
3430 }
3431
3432 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3437 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3438 return Ok(
3439 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3440 .into_response(),
3441 );
3442 }
3443
3444 let feeds_cap = state.config.max_feeds_global;
3448 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3449 match store::count_feeds(pool).await {
3450 Ok(n) if n >= feeds_cap => {
3451 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3452 return Ok(Redirect::to(&format!(
3453 "/?flash={}",
3454 qenc(
3455 "This instance is at its feed capacity right now. Please try again later."
3456 )
3457 ))
3458 .into_response());
3459 }
3460 Ok(_) => {}
3461 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3462 }
3463 }
3464
3465 store::upsert_feed(
3466 pool,
3467 &store::NewFeed {
3468 url: feed_url.clone(),
3469 ..Default::default()
3470 },
3471 )
3472 .await?;
3473
3474 if let Ok(client) = feed::build_client() {
3475 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3476 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3477 Ok(outcome) => {
3478 info!(feed = %feed_url, ?outcome, "polled new subscription");
3479 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3483 }
3484 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3485 }
3486 }
3487 }
3488
3489 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3490 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3491 sub.title = feed_row.title.clone();
3492 sub.site_url = feed_row.site_url.clone();
3493 }
3494 sub.folder = form
3495 .folder
3496 .map(|f| f.trim().to_string())
3497 .filter(|f| !f.is_empty());
3498
3499 match state.repo().add_subscription(&did, &sub).await {
3500 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3501 Err(err) => {
3502 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3503 }
3504 }
3505
3506 Ok(Redirect::to("/").into_response())
3507}
3508
3509async fn delete_subscription(
3511 State(state): State<AppState>,
3512 headers: HeaderMap,
3513 Path(rkey): Path<String>,
3514) -> Result<Response, WebError> {
3515 let did = match current_did(&state, &headers).await {
3516 Some(d) => d,
3517 None => return Ok(Redirect::to("/login").into_response()),
3518 };
3519 match state.repo().remove_subscription(&did, &rkey).await {
3520 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3521 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3522 }
3523 Ok(Redirect::to("/").into_response())
3524}
3525
3526#[derive(Debug, Deserialize)]
3528struct RenameSubForm {
3529 url: String,
3530 #[serde(default)]
3531 title: Option<String>,
3532 #[serde(default)]
3533 site_url: Option<String>,
3534 #[serde(default)]
3535 folder: Option<String>,
3536}
3537
3538async fn rename_subscription(
3541 State(state): State<AppState>,
3542 headers: HeaderMap,
3543 Path(rkey): Path<String>,
3544 Form(form): Form<RenameSubForm>,
3545) -> Result<Response, WebError> {
3546 let did = match current_did(&state, &headers).await {
3547 Some(d) => d,
3548 None => return Ok(Redirect::to("/login").into_response()),
3549 };
3550 let feed_url = form.url.trim().to_string();
3551
3552 if feed_url.is_empty() {
3556 return Ok(Redirect::to("/").into_response());
3557 }
3558
3559 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3584 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3585 Err(err) => {
3586 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3587 return Ok(Redirect::to(&format!(
3588 "/?flash={}",
3589 qenc("Could not reach your PDS — nothing was renamed or moved.")
3590 ))
3591 .into_response());
3592 }
3593 };
3594 let Some(existing) = existing else {
3595 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3599 return Ok(Redirect::to(&format!(
3600 "/?flash={}",
3601 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3602 ))
3603 .into_response());
3604 };
3605
3606 let url_changed = existing.url.trim() != feed_url;
3625
3626 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3639 if url_changed && !storable {
3640 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3641 return Ok(
3642 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3643 .into_response(),
3644 );
3645 }
3646
3647 if url_changed {
3653 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3654 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3655 return Ok(
3656 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3657 );
3658 }
3659 }
3660
3661 let feeds_cap = state.config.max_feeds_global;
3666 if url_changed
3667 && feeds_cap > 0
3668 && store::get_feed_by_url(&state.db, &feed_url)
3669 .await?
3670 .is_none()
3671 {
3672 match store::count_feeds(&state.db).await {
3673 Ok(n) if n >= feeds_cap => {
3674 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3675 return Ok(Redirect::to(&format!(
3676 "/?flash={}",
3677 qenc(
3678 "This instance is at its feed capacity right now. Please try again later."
3679 )
3680 ))
3681 .into_response());
3682 }
3683 Ok(_) => {}
3684 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3685 }
3686 }
3687
3688 let mut sub = existing;
3689 sub.url = feed_url;
3690 sub.title = form
3691 .title
3692 .map(|t| t.trim().to_string())
3693 .filter(|t| !t.is_empty());
3694 sub.folder = form
3695 .folder
3696 .map(|f| f.trim().to_string())
3697 .filter(|f| !f.is_empty());
3698 match form
3706 .site_url
3707 .map(|t| t.trim().to_string())
3708 .filter(|t| !t.is_empty())
3709 {
3710 Some(site) => sub.site_url = Some(site),
3711 None if url_changed => sub.site_url = None,
3712 None => {}
3713 }
3714 if url_changed {
3715 sub.fetch_hint = None;
3716 }
3717
3718 let cache_write =
3733 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3734 if !cache_write {
3735 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3736 } else if let Err(err) = store::upsert_feed(
3737 &state.db,
3738 &store::NewFeed {
3739 url: sub.url.clone(),
3740 title: sub.title.clone(),
3741 site_url: sub.site_url.clone(),
3742 ..Default::default()
3743 },
3744 )
3745 .await
3746 {
3747 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3750 }
3751
3752 match state.repo().update_subscription(&did, &rkey, &sub).await {
3760 Ok(res) => {
3761 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3762 Ok(Redirect::to("/").into_response())
3763 }
3764 Err(err) => {
3765 warn!(%err, %did, %rkey, "PDS subscription update failed");
3766 Ok(Redirect::to(&format!(
3767 "/?flash={}",
3768 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3769 ))
3770 .into_response())
3771 }
3772 }
3773}
3774
3775#[derive(Debug, Deserialize)]
3781struct FolderForm {
3782 name: String,
3783}
3784
3785async fn create_folder(
3787 State(state): State<AppState>,
3788 headers: HeaderMap,
3789 Form(form): Form<FolderForm>,
3790) -> Result<Response, WebError> {
3791 let did = match current_did(&state, &headers).await {
3792 Some(d) => d,
3793 None => return Ok(Redirect::to("/login").into_response()),
3794 };
3795 let name = form.name.trim();
3796 if name.is_empty() {
3797 return Ok(Redirect::to("/").into_response());
3798 }
3799 let folder = Folder::new(name.to_string(), now_rfc3339());
3800 match state.repo().add_folder(&did, &folder).await {
3801 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3802 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3803 }
3804 Ok(Redirect::to("/").into_response())
3805}
3806
3807async fn rename_folder(
3809 State(state): State<AppState>,
3810 headers: HeaderMap,
3811 Path(rkey): Path<String>,
3812 Form(form): Form<FolderForm>,
3813) -> Result<Response, WebError> {
3814 let did = match current_did(&state, &headers).await {
3815 Some(d) => d,
3816 None => return Ok(Redirect::to("/login").into_response()),
3817 };
3818 let name = form.name.trim();
3819 if name.is_empty() {
3820 return Ok(Redirect::to("/").into_response());
3821 }
3822 let folder = Folder::new(name.to_string(), now_rfc3339());
3823 match state.repo().rename_folder(&did, &rkey, &folder).await {
3824 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3825 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3826 }
3827 Ok(Redirect::to("/").into_response())
3828}
3829
3830async fn delete_folder(
3833 State(state): State<AppState>,
3834 headers: HeaderMap,
3835 Path(rkey): Path<String>,
3836) -> Result<Response, WebError> {
3837 let did = match current_did(&state, &headers).await {
3838 Some(d) => d,
3839 None => return Ok(Redirect::to("/login").into_response()),
3840 };
3841 match state.repo().remove_folder(&did, &rkey).await {
3842 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3843 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3844 }
3845 Ok(Redirect::to("/").into_response())
3846}
3847
3848async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3852 let parsed =
3853 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3854
3855 let client = feed::build_client()?;
3856 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3860 let final_url = resp.url().clone();
3861 let content_type = resp
3862 .headers()
3863 .get(axum::http::header::CONTENT_TYPE)
3864 .and_then(|v| v.to_str().ok())
3865 .unwrap_or("")
3866 .to_ascii_lowercase();
3867 let raw = crate::net::read_capped(resp).await?;
3870 let body = String::from_utf8_lossy(&raw).into_owned();
3871
3872 let looks_like_feed = content_type.contains("xml")
3873 || content_type.contains("rss")
3874 || content_type.contains("atom")
3875 || content_type.contains("application/feed+json")
3876 || {
3877 let head = body.trim_start();
3878 head.starts_with("<?xml")
3879 || head.starts_with("<rss")
3880 || head.starts_with("<feed")
3881 || head.contains("<rss")
3882 || head.contains("<feed")
3883 };
3884 if looks_like_feed {
3885 return Ok(final_url.to_string());
3886 }
3887
3888 match feed::discover_feed(&body, Some(&final_url)) {
3889 Some(u) => Ok(u.to_string()),
3890 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3891 }
3892}
3893
3894#[derive(Debug, Deserialize, Default)]
3900struct LoginQuery {
3901 #[serde(default)]
3902 handle: Option<String>,
3903 #[serde(default)]
3904 error: Option<String>,
3905 #[serde(default)]
3906 flash: Option<String>,
3907}
3908
3909async fn login_form(
3917 State(state): State<AppState>,
3918 headers: HeaderMap,
3919 Query(q): Query<LoginQuery>,
3920) -> Response {
3921 if let Some(handle) = q
3922 .handle
3923 .map(|h| h.trim().to_string())
3924 .filter(|h| !h.is_empty())
3925 {
3926 if !may_start_oauth(&state, &headers, &handle).await {
3927 return Redirect::to("/beta/redeem").into_response();
3928 }
3929 return start_oauth(&state, &handle).await;
3930 }
3931 render(&LoginTemplate {
3932 card: login_card(&state.config),
3933 repo_url: REPO_URL,
3934 error: q.error.unwrap_or_default(),
3935 flash: q.flash.unwrap_or_default(),
3936 })
3937}
3938
3939async fn login_submit(
3942 State(state): State<AppState>,
3943 headers: HeaderMap,
3944 Form(form): Form<LoginForm>,
3945) -> Response {
3946 let handle = form.handle.trim();
3947 if handle.is_empty() {
3948 return login_error(&state, "Enter your atproto handle.");
3949 }
3950 if !may_start_oauth(&state, &headers, handle).await {
3951 return Redirect::to("/beta/redeem").into_response();
3952 }
3953 start_oauth(&state, handle).await
3954}
3955
3956async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3974 may_start_oauth_with(state, headers, handle, |h| async move {
3978 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3979 .await
3980 .ok()
3981 })
3982 .await
3983}
3984
3985async fn may_start_oauth_with<F, Fut>(
3991 state: &AppState,
3992 headers: &HeaderMap,
3993 handle: &str,
3994 resolve: F,
3995) -> bool
3996where
3997 F: FnOnce(String) -> Fut,
3998 Fut: std::future::Future<Output = Option<String>>,
3999{
4000 if let Some(did) = current_did(state, headers).await {
4002 if store::has_beta_access(&state.db, &did)
4003 .await
4004 .unwrap_or(false)
4005 {
4006 return true;
4007 }
4008 }
4009 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4011 return true;
4012 }
4013 match resolve(handle.to_string()).await {
4017 Some(did) => store::has_beta_access(&state.db, &did)
4018 .await
4019 .unwrap_or(false),
4020 None => {
4021 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4022 false
4023 }
4024 }
4025}
4026
4027async fn start_oauth(state: &AppState, handle: &str) -> Response {
4049 match state.config.repo_backend {
4050 crate::metrics::Backend::Sidecar => {
4051 let url = state.sidecar.login_url(handle, None);
4052 info!(%handle, "redirecting to OAuth sidecar login");
4053 Redirect::to(&url).into_response()
4054 }
4055 crate::metrics::Backend::Rust => {
4056 let Some(runtime) = state.oauth.as_deref() else {
4057 warn!("the rust backend is live but its OAuth runtime is absent");
4058 return login_error(state, "Login is not available right now.");
4059 };
4060 match crate::oauth::login::start(
4061 runtime,
4062 &state.http,
4063 &state.db,
4064 handle,
4065 crate::store::now_unix(),
4066 )
4067 .await
4068 {
4069 Ok(started) => {
4070 info!(%handle, "pushed authorization request; redirecting to the PDS");
4071 let mut resp = Redirect::to(&started.authorize_url).into_response();
4072 set_cookie(
4073 &mut resp,
4074 &cookie::sign_value(
4075 OAUTH_BINDING_COOKIE,
4076 &started.binding_token,
4077 &state.config.cookie_secret,
4078 OAUTH_BINDING_MAX_AGE_SECS,
4079 ),
4080 );
4081 resp
4082 }
4083 Err(err) => {
4084 warn!(%err, %handle, "could not start the OAuth login");
4087 login_error(state, "Could not start login for that handle.")
4088 }
4089 }
4090 }
4091 }
4092}
4093
4094fn clear_binding_cookie(resp: &mut Response) {
4098 set_cookie(
4099 resp,
4100 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4101 );
4102}
4103
4104#[derive(Debug, Deserialize)]
4106struct LoginForm {
4107 handle: String,
4108}
4109
4110#[derive(Debug, Deserialize, Default)]
4119struct CallbackQuery {
4120 #[serde(default)]
4122 session_id: Option<String>,
4123 #[serde(default)]
4125 code: Option<String>,
4126 #[serde(default)]
4127 state: Option<String>,
4128 #[serde(default)]
4129 iss: Option<String>,
4130 #[serde(default)]
4133 response: Option<String>,
4134 #[serde(default)]
4135 error: Option<String>,
4136 #[serde(default)]
4137 error_description: Option<String>,
4138}
4139
4140async fn oauth_callback(
4147 State(state): State<AppState>,
4148 headers: HeaderMap,
4149 Query(q): Query<CallbackQuery>,
4150) -> Response {
4151 let sidecar_shape =
4181 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4182 let sidecar_handoff = sidecar_shape
4183 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4184 if let Some(err) = q.error.clone() {
4185 let slug = crate::oauth::flow::known_error_slug(&err);
4201 warn!(
4202 error = slug,
4203 desc_len = q.error_description.as_deref().map_or(0, str::len),
4204 "OAuth callback returned an error"
4205 );
4206 if sidecar_handoff || state.oauth.is_none() {
4207 return login_error(&state, &format!("Login failed: {slug}"));
4208 }
4209 }
4212
4213 let session = if sidecar_handoff {
4216 let session_id = q.session_id.clone().unwrap_or_default();
4217 match state.sidecar.resolve_session(&session_id).await {
4218 Ok(Some(s)) => s,
4219 Ok(None) => {
4220 warn!("OAuth callback session_id did not resolve (expired/unknown)");
4221 return login_error(&state, "Login session expired — please try again.");
4222 }
4223 Err(err) => {
4224 warn!(%err, "failed to resolve OAuth session via the sidecar");
4225 return login_error(&state, "Login failed talking to the auth service.");
4226 }
4227 }
4228 } else {
4229 let Some(runtime) = state.oauth.as_deref() else {
4230 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4231 return login_error(&state, "Login failed: this login could not be completed.");
4232 };
4233 let params = crate::oauth::flow::CallbackParams {
4234 code: q.code.clone(),
4235 state: q.state.clone(),
4236 iss: q.iss.clone(),
4237 error: q.error.clone(),
4241 error_description: q.error_description.clone(),
4242 response: q.response.clone(),
4243 };
4244 let binding =
4245 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4246 match crate::oauth::login::complete(
4247 runtime,
4248 &state.http,
4249 &state.db,
4250 ¶ms,
4251 binding.as_deref(),
4252 crate::store::now_unix(),
4253 )
4254 .await
4255 {
4256 Ok(done) => crate::atproto::SidecarSession {
4257 did: done.did,
4258 handle: done.handle,
4259 },
4260 Err(err) => {
4261 warn!(%err, "could not complete the OAuth callback");
4264 let mut resp = login_error(&state, "Login failed — please try again.");
4265 clear_binding_cookie(&mut resp);
4266 return resp;
4267 }
4268 }
4269 };
4270
4271 let mut clear_invite = false;
4274 if !store::has_beta_access(&state.db, &session.did)
4275 .await
4276 .unwrap_or(false)
4277 {
4278 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4280 Some(c) => c,
4281 None => {
4282 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4283 return Redirect::to("/beta/redeem").into_response();
4284 }
4285 };
4286 match store::redeem_code(
4287 &state.db,
4288 &code,
4289 &session.did,
4290 session.handle.as_deref(),
4291 state.config.beta_cap,
4292 )
4293 .await
4294 {
4295 Ok(Ok(())) => {
4296 clear_invite = true;
4297 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4298 }
4299 Ok(Err(policy)) => {
4300 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4301 let mut resp = redeem_bounce(&state, &policy).into_response();
4302 clear_invite_cookie(&mut resp);
4304 return resp;
4305 }
4306 Err(err) => {
4307 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4308 return login_error(&state, "Login failed while confirming your invite.");
4309 }
4310 }
4311 }
4312
4313 let sid = state.sessions.create(Session {
4316 did: session.did.clone(),
4317 handle: session.handle.clone(),
4318 });
4319 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4320 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4321
4322 let mut resp = Redirect::to("/").into_response();
4323 set_cookie(&mut resp, &cookie);
4324 clear_binding_cookie(&mut resp);
4325 if clear_invite {
4326 clear_invite_cookie(&mut resp);
4327 }
4328 resp
4329}
4330
4331const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4349
4350async fn flush_before_revoke(state: &AppState, did: &str) {
4363 match tokio::time::timeout(
4364 SIGN_OUT_FLUSH_BUDGET,
4365 crate::readstate::flush_did(state, did),
4366 )
4367 .await
4368 {
4369 Ok(Ok(())) => {}
4370 Ok(Err(err)) => {
4371 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4372 }
4373 Err(_) => warn!(
4374 %did,
4375 budget = ?SIGN_OUT_FLUSH_BUDGET,
4376 "sign-out: final read-state flush timed out; it will park until next sign-in"
4377 ),
4378 }
4379}
4380
4381async fn revoke_everywhere(state: &AppState, did: &str) {
4382 let sidecar_started = std::time::Instant::now();
4392 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4393 Ok(res) => {
4394 info!(%did, revoked = res.revoked, "sidecar session revoked");
4395 true
4396 }
4397 Err(err) => {
4398 warn!(%did, %err, "sidecar revoke failed; continuing");
4399 false
4400 }
4401 };
4402 state.metrics.record(
4403 crate::metrics::Backend::Sidecar,
4404 "oauth_revoke",
4405 sidecar_started.elapsed().as_micros() as u64,
4406 sidecar_ok,
4407 );
4408
4409 if let Some(runtime) = state.oauth.as_deref() {
4410 let revoke_started = std::time::Instant::now();
4411 let outcome = crate::oauth::revoke::sign_out_discovering(
4412 runtime,
4413 &state.http,
4414 &state.db,
4415 did,
4416 crate::store::now_unix(),
4417 )
4418 .await;
4419 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4430 state.metrics.record(
4431 crate::metrics::Backend::Rust,
4432 "oauth_revoke",
4433 revoke_started.elapsed().as_micros() as u64,
4434 revoke_ok,
4435 );
4436 match outcome {
4437 crate::oauth::revoke::Revocation::Revoked => {
4438 info!(%did, "rust OAuth session revoked at the PDS")
4439 }
4440 crate::oauth::revoke::Revocation::NoSession => {}
4441 crate::oauth::revoke::Revocation::Failed(reason) => {
4442 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4443 }
4444 }
4445 }
4446}
4447
4448async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4458 if let Some(user) = current_session(&state, &headers).await {
4459 if let Some(sid) = user.sid {
4462 state.sessions.remove(&sid);
4463 flush_before_revoke(&state, &user.did).await;
4465 revoke_everywhere(&state, &user.did).await;
4466 }
4467 }
4468 let mut resp = Redirect::to("/login").into_response();
4469 set_cookie(
4470 &mut resp,
4471 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4472 );
4473 resp
4474}
4475
4476#[derive(Debug, Deserialize)]
4479struct DeleteAccountForm {
4480 #[serde(default)]
4481 confirm: String,
4482}
4483
4484const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4486
4487async fn account_delete(
4502 State(state): State<AppState>,
4503 headers: HeaderMap,
4504 Form(form): Form<DeleteAccountForm>,
4505) -> Result<Response, WebError> {
4506 let user = match current_session(&state, &headers).await {
4507 Some(u) => u,
4508 None => return Ok(Redirect::to("/login").into_response()),
4509 };
4510 let did = user.did.clone();
4511
4512 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4514 return Ok(Redirect::to(&format!(
4515 "/manage?flash={}",
4516 qenc("Type DELETE to confirm — nothing was deleted.")
4517 ))
4518 .into_response());
4519 }
4520
4521 let counts = store::purge_did_data(&state.db, &did).await?;
4523 info!(
4524 %did,
4525 total = counts.total(),
4526 entry_state = counts.entry_state,
4527 read_cursor = counts.read_cursor,
4528 sub_ref = counts.sub_ref,
4529 beta_access = counts.beta_access,
4530 invite_codes = counts.invite_codes,
4531 "account/delete: local rows purged"
4532 );
4533
4534 revoke_everywhere(&state, &did).await;
4537
4538 if let Some(sid) = user.sid {
4540 state.sessions.remove(&sid);
4541 }
4542 let mut resp = Redirect::to(&format!(
4543 "/login?flash={}",
4544 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4545 ))
4546 .into_response();
4547 set_cookie(
4548 &mut resp,
4549 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4550 );
4551 Ok(resp)
4552}
4553
4554fn login_card(config: &Config) -> Card {
4556 Card::public(
4557 config,
4558 "/login",
4559 "Sign in — FeatherReader",
4560 "Sign in to FeatherReader with your atproto handle. You approve access on \
4561 your own server — no signup, no password.",
4562 )
4563}
4564
4565fn login_error(state: &AppState, msg: &str) -> Response {
4567 render(&LoginTemplate {
4568 card: login_card(&state.config),
4569 repo_url: REPO_URL,
4570 error: msg.to_string(),
4571 flash: String::new(),
4572 })
4573}
4574
4575#[derive(Debug, Deserialize)]
4581struct RedeemForm {
4582 code: String,
4583}
4584
4585async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4588 let full = store::count_beta_access(&state.db)
4589 .await
4590 .map(|n| n >= state.config.beta_cap)
4591 .unwrap_or(false);
4592 render(&BetaRedeemTemplate {
4593 card: redeem_card(&state.config),
4594 repo_url: REPO_URL,
4595 error: String::new(),
4596 capacity_full: full,
4597 })
4598}
4599
4600async fn beta_redeem_submit(
4610 State(state): State<AppState>,
4611 Form(form): Form<RedeemForm>,
4612) -> Response {
4613 let code = form.code.trim().to_uppercase();
4614 if code.is_empty() {
4615 return render(&BetaRedeemTemplate {
4616 card: redeem_card(&state.config),
4617 repo_url: REPO_URL,
4618 error: "Enter your invite code.".to_string(),
4619 capacity_full: false,
4620 });
4621 }
4622
4623 match preflight_code(&state, &code).await {
4624 Ok(()) => {
4625 let cookie = sign_invite(&code, &state.config.cookie_secret);
4626 let mut resp = Redirect::to("/login").into_response();
4627 set_cookie(&mut resp, &cookie);
4628 info!("invite code preflight OK; reserving intent + redirecting to /login");
4629 resp
4630 }
4631 Err(policy) => {
4632 warn!(?policy, "invite code preflight rejected");
4633 redeem_bounce(&state, &policy)
4634 }
4635 }
4636}
4637
4638async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4644 let count = match store::count_beta_access(&state.db).await {
4652 Ok(n) => n,
4653 Err(err) => {
4654 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4655 return Err(store::RedeemError::CapacityFull);
4656 }
4657 };
4658 if count >= state.config.beta_cap {
4659 return Err(store::RedeemError::CapacityFull);
4660 }
4661 let row = sqlx::query_as::<_, (String, i64)>(
4663 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4664 )
4665 .bind(code)
4666 .fetch_optional(&state.db)
4667 .await
4668 .ok()
4669 .flatten();
4670 let (status, expires_at) = match row {
4671 Some(r) => r,
4672 None => return Err(store::RedeemError::NotFound),
4673 };
4674 let now = chrono::Utc::now().timestamp();
4675 match status.as_str() {
4676 "active" if expires_at >= now => Ok(()),
4677 "active" => Err(store::RedeemError::Expired),
4678 "expired" => Err(store::RedeemError::Expired),
4679 _ => Err(store::RedeemError::AlreadyRedeemed),
4681 }
4682}
4683
4684fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4687 use store::RedeemError::*;
4688 let (msg, capacity_full) = match policy {
4689 NotFound => ("That invite code isn't valid.", false),
4690 Expired => ("That invite code has expired.", false),
4691 AlreadyRedeemed => ("That invite code has already been used.", false),
4692 CapacityFull => ("", true),
4693 };
4694 render(&BetaRedeemTemplate {
4695 card: redeem_card(&state.config),
4696 repo_url: REPO_URL,
4697 error: msg.to_string(),
4698 capacity_full,
4699 })
4700}
4701
4702fn redeem_card(config: &Config) -> Card {
4705 Card::public(
4706 config,
4707 "/beta/redeem",
4708 "Redeem an invite — FeatherReader",
4709 "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4710 in with your atproto handle.",
4711 )
4712}
4713
4714#[derive(Debug, Deserialize, Default)]
4716struct MintQuery {
4717 #[serde(default)]
4718 n: Option<u32>,
4719}
4720
4721async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4734 let Some(runtime) = state.oauth.as_deref() else {
4735 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4737 };
4738 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4739}
4740
4741async fn oauth_jwks(State(state): State<AppState>) -> Response {
4748 let Some(runtime) = state.oauth.as_deref() else {
4749 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4750 };
4751 match runtime.client_key.as_ref() {
4752 Some(key) => match key.jwks_document() {
4753 Ok(doc) => axum::Json(doc).into_response(),
4754 Err(err) => {
4755 warn!(%err, "could not render the client JWKS");
4756 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4757 }
4758 },
4759 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4760 }
4761}
4762
4763const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4765
4766async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4775 let did = match current_did(&state, &headers).await {
4776 Some(d) => d,
4777 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4778 };
4779 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4780 warn!(%did, "admin metrics denied: not an admin-seed DID");
4781 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4782 }
4783
4784 if let Err(err) =
4789 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4790 {
4791 warn!(%err, "could not flush repo timings before rendering");
4792 }
4793 let rows = match crate::metrics::persisted_rows(&state.db).await {
4794 Ok(rows) => rows,
4795 Err(err) => {
4796 warn!(%err, "could not read persisted repo timings");
4797 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4798 }
4799 };
4800
4801 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4807 Ok(n) => n.to_string(),
4808 Err(err) => {
4809 warn!(%err, "could not count parked read-state DIDs");
4810 "unknown".to_string()
4811 }
4812 };
4813 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4820 Ok(f) => f,
4821 Err(err) => {
4822 warn!(%err, "could not list failing feeds");
4823 Vec::new()
4824 }
4825 };
4826 let mut failing_block = String::new();
4827 if !failing.is_empty() {
4828 failing_block.push_str("\nfailing feeds (worst first)\n");
4829 for f in &failing {
4830 failing_block.push_str(&format!(
4831 " {:>4}x {:<8} {}\n {}\n",
4832 f.consecutive_errors,
4833 f.kind.as_deref().unwrap_or("unknown"),
4834 f.url,
4835 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4836 ));
4837 }
4838 }
4839
4840 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4845 Ok(n) => n,
4846 Err(err) => {
4847 warn!(%err, "could not count unpollable feeds");
4848 -1
4849 }
4850 };
4851 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4852
4853 let body = format!(
4854 "live backend: {}\nparked read-state DIDs: {}\n\
4855 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4856 state.config.repo_backend.as_str(),
4857 parked,
4858 cached,
4859 state.config.max_feeds_global,
4860 unpollable,
4861 crate::metrics::render(&rows),
4862 failing_block,
4863 );
4864 (StatusCode::OK, body).into_response()
4865}
4866
4867async fn admin_mint_invites(
4871 State(state): State<AppState>,
4872 headers: HeaderMap,
4873 Query(q): Query<MintQuery>,
4874) -> Response {
4875 let did = match current_did(&state, &headers).await {
4878 Some(d) => d,
4879 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4880 };
4881 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4882 warn!(%did, "admin mint denied: not an admin-seed DID");
4883 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4884 }
4885
4886 let n = q.n.unwrap_or(1).clamp(1, 100);
4887 let mut codes = Vec::with_capacity(n as usize);
4888 for _ in 0..n {
4889 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4890 Ok(code) => codes.push(code),
4891 Err(err) => {
4892 warn!(%err, %did, "admin mint_code failed");
4893 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4894 }
4895 }
4896 }
4897 info!(%did, count = codes.len(), "admin minted invite codes");
4898 let mut body = codes.join("\n");
4899 body.push('\n');
4900 (StatusCode::OK, body).into_response()
4901}
4902
4903#[derive(Debug, Deserialize)]
4909struct ClaimQuery {
4910 t: Option<String>,
4912}
4913
4914async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4933 let token = match q.t {
4934 Some(t) if !t.is_empty() => t,
4935 _ => {
4936 warn!("claim link with no token");
4937 return redeem_bounce(&state, &store::RedeemError::NotFound);
4938 }
4939 };
4940
4941 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4944 Some(c) => c,
4945 None => {
4946 warn!("claim token invalid (bad signature / malformed)");
4947 return redeem_bounce(&state, &store::RedeemError::NotFound);
4948 }
4949 };
4950
4951 match preflight_code(&state, &code).await {
4955 Ok(()) => {
4956 let cookie = sign_invite(&code, &state.config.cookie_secret);
4957 let mut resp = Redirect::to("/login").into_response();
4958 set_cookie(&mut resp, &cookie);
4959 info!("claim token preflight OK; reserving intent + redirecting to /login");
4960 resp
4961 }
4962 Err(policy) => {
4963 warn!(?policy, "claim token preflight rejected");
4964 redeem_bounce(&state, &policy)
4965 }
4966 }
4967}
4968
4969#[derive(Debug, Default, Deserialize)]
4976struct BotClaimRequest {
4977 #[serde(default)]
4980 did: Option<String>,
4981 #[serde(default)]
4983 #[allow(dead_code)]
4984 handle: Option<String>,
4985}
4986
4987#[derive(Debug, serde::Serialize)]
4989struct BotClaimResponse {
4990 status: &'static str,
4996 code: String,
5000 token: String,
5003 url: String,
5006}
5007
5008async fn bot_mint_claim(
5036 State(state): State<AppState>,
5037 headers: HeaderMap,
5038 body: axum::body::Bytes,
5039) -> Response {
5040 let bot_secret = match state.config.bot_secret.as_deref() {
5042 Some(s) => s,
5043 None => {
5044 warn!(
5045 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5046 );
5047 return (
5048 StatusCode::SERVICE_UNAVAILABLE,
5049 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5050 )
5051 .into_response();
5052 }
5053 };
5054
5055 let presented = headers
5057 .get("x-bot-secret")
5058 .and_then(|v| v.to_str().ok())
5059 .unwrap_or("");
5060 if !bot_secret_matches(presented, bot_secret) {
5061 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5062 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5063 }
5064
5065 let req: BotClaimRequest = if body.is_empty() {
5068 BotClaimRequest::default()
5069 } else {
5070 match serde_json::from_slice(&body) {
5071 Ok(r) => r,
5072 Err(err) => {
5073 warn!(%err, "POST /bot/claims: bad JSON body");
5074 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5075 }
5076 }
5077 };
5078 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5079
5080 if let Some(did) = follower_did {
5082 match store::has_beta_access(&state.db, did).await {
5084 Ok(true) => {
5085 info!("bot mint: DID already holds beta access; already_seated");
5086 return bot_claim_json(BotClaimResponse {
5087 status: "already_seated",
5088 code: String::new(),
5089 token: String::new(),
5090 url: String::new(),
5091 });
5092 }
5093 Ok(false) => {}
5094 Err(err) => {
5095 warn!(%err, "bot mint: has_beta_access failed");
5097 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5098 }
5099 }
5100 match store::find_active_code_for_did(&state.db, did).await {
5103 Ok(Some(code)) => {
5104 info!("bot mint: existing outstanding claim for DID; returning same code");
5105 let token = sign_claim_token(&code, &state.config.cookie_secret);
5106 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5107 return bot_claim_json(BotClaimResponse {
5108 status: "existing",
5109 code,
5110 token,
5111 url,
5112 });
5113 }
5114 Ok(None) => {}
5115 Err(err) => {
5116 warn!(%err, "bot mint: find_active_code_for_did failed");
5117 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5118 }
5119 }
5120 }
5121
5122 let granted = match store::count_beta_access(&state.db).await {
5125 Ok(n) => n,
5126 Err(err) => {
5127 warn!(%err, "bot mint: count_beta_access failed; failing closed");
5128 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5129 }
5130 };
5131 let outstanding = match store::count_active_codes(&state.db).await {
5132 Ok(n) => n,
5133 Err(err) => {
5134 warn!(%err, "bot mint: count_active_codes failed; failing closed");
5135 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5136 }
5137 };
5138 if granted + outstanding >= state.config.beta_cap {
5139 info!(
5140 granted,
5141 outstanding,
5142 cap = state.config.beta_cap,
5143 "bot mint refused: at capacity"
5144 );
5145 return (
5146 StatusCode::CONFLICT,
5147 [(header::CONTENT_TYPE, "application/json")],
5148 "{\"error\":\"full\"}\n",
5149 )
5150 .into_response();
5151 }
5152
5153 let bot_did = state
5156 .config
5157 .admin_seed_dids()
5158 .first()
5159 .cloned()
5160 .unwrap_or_else(|| "did:bot:featherreader".to_string());
5161 let minted = match follower_did {
5162 Some(did) => {
5163 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5164 }
5165 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5166 };
5167 let code = match minted {
5168 Ok(c) => c,
5169 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5176 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5177 Ok(Some(code)) => {
5178 info!("bot mint: lost the mint race; returning the concurrently-minted code");
5179 let token = sign_claim_token(&code, &state.config.cookie_secret);
5180 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5181 return bot_claim_json(BotClaimResponse {
5182 status: "existing",
5183 code,
5184 token,
5185 url,
5186 });
5187 }
5188 Ok(None) => {
5192 warn!("bot mint: conflict but no active code found on recovery");
5193 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5194 }
5195 Err(err) => {
5196 warn!(%err, "bot mint: recovery lookup after conflict failed");
5197 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5198 }
5199 }
5200 }
5201 Err(err) => {
5202 warn!(%err, "bot mint_code failed");
5203 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5204 }
5205 };
5206 let token = sign_claim_token(&code, &state.config.cookie_secret);
5207 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5208 info!("bot minted a claim code + token");
5209
5210 bot_claim_json(BotClaimResponse {
5211 status: "minted",
5212 code,
5213 token,
5214 url,
5215 })
5216}
5217
5218fn bot_claim_json(resp: BotClaimResponse) -> Response {
5221 match serde_json::to_string(&resp) {
5222 Ok(body) => (
5223 StatusCode::OK,
5224 [(header::CONTENT_TYPE, "application/json")],
5225 body,
5226 )
5227 .into_response(),
5228 Err(err) => {
5229 warn!(%err, "serializing bot claim response failed");
5230 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5231 }
5232 }
5233}
5234
5235fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5240 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5241}
5242
5243fn sign_invite(code: &str, secret: &str) -> String {
5252 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5253}
5254
5255fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5260 cookie::verify_value(headers, INVITE_COOKIE, secret)
5261}
5262
5263const CLAIM_TOKEN_LABEL: &str = "claim-token";
5267
5268fn sign_claim_token(code: &str, secret: &str) -> String {
5280 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5281}
5282
5283fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5288 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5289}
5290
5291fn clear_invite_cookie(resp: &mut Response) {
5294 set_cookie(
5295 resp,
5296 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5297 );
5298}
5299
5300async fn import_opml(
5313 State(state): State<AppState>,
5314 headers: HeaderMap,
5315 mut multipart: Multipart,
5316) -> Result<Response, WebError> {
5317 let did = match current_did(&state, &headers).await {
5318 Some(d) => d,
5319 None => return Ok(Redirect::to("/login").into_response()),
5320 };
5321 let pool = &state.db;
5322
5323 let mut opml_text = String::new();
5329 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5330 let name = field.name().unwrap_or("").to_string();
5331 if name == "opml" || name == "file" {
5332 let bytes = field.bytes().await.map_err(multipart_response)?;
5333 if !bytes.is_empty() {
5334 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5335 if name == "file" {
5336 break;
5337 }
5338 }
5339 }
5340 }
5341
5342 let feeds =
5347 match opml::parse_opml(&opml_text) {
5348 Ok(feeds) => feeds,
5349 Err(err) => {
5350 warn!(%err, %did, "OPML import could not parse the uploaded file");
5351 return Ok(Redirect::to(&format!(
5352 "/?flash={}",
5353 qenc("That file could not be read as OPML. Export it again from your other reader?")
5354 ))
5355 .into_response());
5356 }
5357 };
5358 if feeds.is_empty() {
5359 info!(%did, "OPML import found no feeds");
5360 return Ok(
5361 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5362 .into_response(),
5363 );
5364 }
5365
5366 let now = now_rfc3339();
5369 let mut folder_uris: std::collections::HashMap<String, String> =
5370 std::collections::HashMap::new();
5371 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5373 for (rkey, folder) in existing {
5374 folder_uris
5375 .entry(folder.name.clone())
5376 .or_insert_with(|| folder_uri(&did, &rkey));
5377 }
5378 }
5379 let mut wanted_folders: Vec<String> = feeds
5380 .iter()
5381 .filter_map(|f| f.folder.clone())
5382 .filter(|n| !n.is_empty())
5383 .collect();
5384 wanted_folders.sort();
5385 wanted_folders.dedup();
5386 for name in wanted_folders {
5387 if folder_uris.contains_key(&name) {
5388 continue;
5389 }
5390 let folder = Folder::new(name.clone(), now.clone());
5391 match state.repo().add_folder(&did, &folder).await {
5392 Ok(rkey) => {
5393 folder_uris.insert(name, folder_uri(&did, &rkey));
5394 }
5395 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5396 }
5397 }
5398
5399 let sub_cap = state.config.max_subs_per_did;
5408 let mut headroom: Option<i64> = if sub_cap > 0 {
5409 let existing = store::count_subscriptions_for_did(pool, &did)
5410 .await
5411 .unwrap_or(0);
5412 Some((sub_cap - existing).max(0))
5413 } else {
5414 None
5415 };
5416 let mut trimmed_over_cap: usize = 0;
5417
5418 let feeds_cap = state.config.max_feeds_global;
5425 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5426 let existing = store::count_feeds(pool).await.unwrap_or(0);
5427 Some((feeds_cap - existing).max(0))
5428 } else {
5429 None
5430 };
5431 let mut trimmed_over_global: usize = 0;
5432
5433 let mut subs = Vec::with_capacity(feeds.len());
5434 let mut skipped_private: Vec<String> = Vec::new();
5435 let mut uncached: usize = 0;
5438 let mut skipped_unsupported: usize = 0;
5444 for f in &feeds {
5445 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5453 info!(
5454 %did,
5455 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5456 );
5457 skipped_unsupported += 1;
5458 continue;
5459 }
5460 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5461 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5462 let label = f
5464 .title
5465 .clone()
5466 .filter(|t| !t.trim().is_empty())
5467 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5468 skipped_private.push(label);
5469 continue;
5470 }
5471
5472 if let Some(h) = headroom.as_mut() {
5475 if *h <= 0 {
5476 trimmed_over_cap += 1;
5477 continue;
5478 }
5479 }
5480
5481 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5486 Ok(existing) => existing.is_none(),
5487 Err(err) => {
5490 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5491 false
5492 }
5493 };
5494 if is_new {
5495 if let Some(g) = global_headroom.as_mut() {
5496 if *g <= 0 {
5497 trimmed_over_global += 1;
5498 continue;
5499 }
5500 *g -= 1;
5501 }
5502 }
5503
5504 if let Some(h) = headroom.as_mut() {
5507 *h -= 1;
5508 }
5509
5510 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5511 sub.title = f.title.clone();
5512 sub.site_url = f.site_url.clone();
5513 sub.folder = f
5514 .folder
5515 .as_ref()
5516 .and_then(|name| folder_uris.get(name).cloned());
5517 subs.push(sub);
5518 if let Err(err) = store::upsert_feed(
5524 pool,
5525 &store::NewFeed {
5526 url: f.feed_url.clone(),
5527 title: f.title.clone(),
5528 site_url: f.site_url.clone(),
5529 ..Default::default()
5530 },
5531 )
5532 .await
5533 {
5534 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5535 it will not be polled");
5536 uncached += 1;
5537 }
5538 }
5539
5540 let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5547 Ok(rkeys) => {
5548 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5549 true
5550 }
5551 Err(err) => {
5552 warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5553 false
5554 }
5555 };
5556 if !pds_written {
5557 return Ok(Redirect::to(&format!(
5558 "/?flash={}",
5559 qenc(
5560 "Could not save those subscriptions to your PDS, so nothing was imported. \
5561 Try again in a moment."
5562 )
5563 ))
5564 .into_response());
5565 }
5566
5567 let mut flash = format!("Imported {} feeds", subs.len());
5569 if uncached > 0 {
5570 flash.push_str(&format!(
5571 ". {uncached} of them could not be cached locally and may not update until the next import."
5572 ));
5573 }
5574 if trimmed_over_cap > 0 {
5575 flash.push_str(&format!(
5576 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5577 ));
5578 }
5579 if trimmed_over_global > 0 {
5580 flash.push_str(&format!(
5581 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5582 ));
5583 }
5584 if !skipped_private.is_empty() {
5585 flash.push_str(&format!(
5586 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5587 skipped_private.len(),
5588 skipped_private.join(", ")
5589 ));
5590 }
5591 if skipped_unsupported > 0 {
5592 flash.push_str(&format!(
5595 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5596 ));
5597 }
5598 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5599}
5600
5601fn private_feed_label(url: &str) -> String {
5604 url::Url::parse(url)
5605 .ok()
5606 .and_then(|u| u.host_str().map(str::to_string))
5607 .unwrap_or_else(|| "a private feed".to_string())
5608}
5609
5610async fn export_opml(
5612 State(state): State<AppState>,
5613 headers: HeaderMap,
5614) -> Result<Response, WebError> {
5615 let did = match current_did(&state, &headers).await {
5616 Some(d) => d,
5617 None => return Ok(Redirect::to("/login").into_response()),
5618 };
5619
5620 let subs = match state.repo().list_subscriptions_sorted(&did).await {
5627 Ok(subs) => subs,
5628 Err(err) => {
5629 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5630 return Ok(Redirect::to(&format!(
5631 "/manage?flash={}",
5632 qenc(EXPORT_INCOMPLETE_REFUSAL)
5633 ))
5634 .into_response());
5635 }
5636 };
5637 let folders = match state.repo().list_folders_sorted(&did).await {
5638 Ok(folders) => folders,
5639 Err(err) => {
5640 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5641 return Ok(Redirect::to(&format!(
5642 "/manage?flash={}",
5643 qenc(EXPORT_INCOMPLETE_REFUSAL)
5644 ))
5645 .into_response());
5646 }
5647 };
5648 let folder_pairs: Vec<(String, Folder)> = folders
5651 .into_iter()
5652 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5653 .collect();
5654
5655 let body = opml::to_opml(&subs, &folder_pairs);
5656 let mut resp = (StatusCode::OK, body).into_response();
5657 resp.headers_mut().insert(
5658 header::CONTENT_TYPE,
5659 "text/x-opml; charset=utf-8".parse().unwrap(),
5660 );
5661 resp.headers_mut().insert(
5662 header::CONTENT_DISPOSITION,
5663 "attachment; filename=\"featherreader-subscriptions.opml\""
5664 .parse()
5665 .unwrap(),
5666 );
5667 Ok(resp)
5668}
5669
5670fn set_cookie(resp: &mut Response, cookie: &str) {
5676 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5677 resp.headers_mut()
5678 .append(axum::http::header::SET_COOKIE, value);
5679 }
5680}
5681
5682fn is_htmx(headers: &HeaderMap) -> bool {
5684 headers
5685 .get("HX-Request")
5686 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5687}
5688
5689fn is_reader_request(headers: &HeaderMap) -> bool {
5695 headers
5696 .get("X-FR-Reader")
5697 .is_some_and(|v| v.as_bytes() == b"1")
5698}
5699
5700mod cookie {
5705 use super::{HeaderMap, SESSION_COOKIE};
5706
5707 pub fn sign_session(sid: &str, secret: &str) -> String {
5709 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5710 }
5711
5712 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5714 verify_value(headers, SESSION_COOKIE, secret)
5715 }
5716
5717 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5723 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5724 msg.extend_from_slice(name.as_bytes());
5725 msg.push(0);
5726 msg.extend_from_slice(value.as_bytes());
5727 msg
5728 }
5729
5730 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5736 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5737 let b64 = b64url_encode(value.as_bytes());
5738 format!(
5739 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5740 )
5741 }
5742
5743 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5746 let raw = cookie_value(headers, name)?;
5747 let (b64, sig) = raw.split_once('.')?;
5748 let bytes = b64url_decode(b64)?;
5749 let value = String::from_utf8(bytes).ok()?;
5750 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5751 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5752 Some(value)
5753 } else {
5754 None
5755 }
5756 }
5757
5758 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5764 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5765 let b64 = b64url_encode(value.as_bytes());
5766 format!("{b64}.{sig}")
5767 }
5768
5769 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5772 let (b64, sig) = token.split_once('.')?;
5773 let bytes = b64url_decode(b64)?;
5774 let value = String::from_utf8(bytes).ok()?;
5775 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5776 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5777 Some(value)
5778 } else {
5779 None
5780 }
5781 }
5782
5783 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5785 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5786 for part in header.split(';') {
5787 let part = part.trim();
5788 if let Some((k, v)) = part.split_once('=') {
5789 if k == name {
5790 return Some(v.to_string());
5791 }
5792 }
5793 }
5794 None
5795 }
5796
5797 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5801 if a.len() != b.len() {
5802 return false;
5803 }
5804 let mut diff = 0u8;
5805 for (x, y) in a.iter().zip(b.iter()) {
5806 diff |= x ^ y;
5807 }
5808 diff == 0
5809 }
5810
5811 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5814
5815 fn b64url_encode(input: &[u8]) -> String {
5816 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5817 for chunk in input.chunks(3) {
5818 let b = [
5819 chunk[0],
5820 *chunk.get(1).unwrap_or(&0),
5821 *chunk.get(2).unwrap_or(&0),
5822 ];
5823 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5824 out.push(B64[((n >> 18) & 63) as usize] as char);
5825 out.push(B64[((n >> 12) & 63) as usize] as char);
5826 if chunk.len() > 1 {
5827 out.push(B64[((n >> 6) & 63) as usize] as char);
5828 }
5829 if chunk.len() > 2 {
5830 out.push(B64[(n & 63) as usize] as char);
5831 }
5832 }
5833 out
5834 }
5835
5836 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5837 fn val(c: u8) -> Option<u32> {
5838 match c {
5839 b'A'..=b'Z' => Some((c - b'A') as u32),
5840 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5841 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5842 b'-' => Some(62),
5843 b'_' => Some(63),
5844 _ => None,
5845 }
5846 }
5847 let bytes = input.as_bytes();
5848 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5849 for chunk in bytes.chunks(4) {
5850 let mut n = 0u32;
5851 let mut valid = 0;
5852 for (i, &c) in chunk.iter().enumerate() {
5853 n |= val(c)? << (18 - 6 * i);
5854 valid += 1;
5855 }
5856 out.push((n >> 16) as u8);
5857 if valid > 2 {
5858 out.push((n >> 8) as u8);
5859 }
5860 if valid > 3 {
5861 out.push(n as u8);
5862 }
5863 }
5864 Some(out)
5865 }
5866
5867 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5871 const BLOCK: usize = 64;
5872 let mut k = [0u8; BLOCK];
5873 if key.len() > BLOCK {
5874 let d = sha256(key);
5875 k[..32].copy_from_slice(&d);
5876 } else {
5877 k[..key.len()].copy_from_slice(key);
5878 }
5879 let mut ipad = [0x36u8; BLOCK];
5880 let mut opad = [0x5cu8; BLOCK];
5881 for i in 0..BLOCK {
5882 ipad[i] ^= k[i];
5883 opad[i] ^= k[i];
5884 }
5885 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5886 inner.extend_from_slice(&ipad);
5887 inner.extend_from_slice(msg);
5888 let inner_hash = sha256(&inner);
5889 let mut outer = Vec::with_capacity(BLOCK + 32);
5890 outer.extend_from_slice(&opad);
5891 outer.extend_from_slice(&inner_hash);
5892 let mac = sha256(&outer);
5893 let mut hex = String::with_capacity(64);
5894 for b in mac {
5895 hex.push_str(&format!("{b:02x}"));
5896 }
5897 hex
5898 }
5899
5900 fn sha256(data: &[u8]) -> [u8; 32] {
5902 const K: [u32; 64] = [
5903 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5904 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5905 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5906 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5907 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5908 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5909 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5910 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5911 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5912 0xc67178f2,
5913 ];
5914 let mut h: [u32; 8] = [
5915 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5916 0x5be0cd19,
5917 ];
5918
5919 let bit_len = (data.len() as u64) * 8;
5920 let mut msg = data.to_vec();
5921 msg.push(0x80);
5922 while msg.len() % 64 != 56 {
5923 msg.push(0);
5924 }
5925 msg.extend_from_slice(&bit_len.to_be_bytes());
5926
5927 for block in msg.chunks(64) {
5928 let mut w = [0u32; 64];
5929 for i in 0..16 {
5930 w[i] = u32::from_be_bytes([
5931 block[i * 4],
5932 block[i * 4 + 1],
5933 block[i * 4 + 2],
5934 block[i * 4 + 3],
5935 ]);
5936 }
5937 for i in 16..64 {
5938 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5939 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5940 w[i] = w[i - 16]
5941 .wrapping_add(s0)
5942 .wrapping_add(w[i - 7])
5943 .wrapping_add(s1);
5944 }
5945 let mut a = h;
5946 for i in 0..64 {
5947 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5948 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5949 let t1 = a[7]
5950 .wrapping_add(s1)
5951 .wrapping_add(ch)
5952 .wrapping_add(K[i])
5953 .wrapping_add(w[i]);
5954 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5955 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5956 let t2 = s0.wrapping_add(maj);
5957 a[7] = a[6];
5958 a[6] = a[5];
5959 a[5] = a[4];
5960 a[4] = a[3].wrapping_add(t1);
5961 a[3] = a[2];
5962 a[2] = a[1];
5963 a[1] = a[0];
5964 a[0] = t1.wrapping_add(t2);
5965 }
5966 for i in 0..8 {
5967 h[i] = h[i].wrapping_add(a[i]);
5968 }
5969 }
5970
5971 let mut out = [0u8; 32];
5972 for (i, word) in h.iter().enumerate() {
5973 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5974 }
5975 out
5976 }
5977
5978 #[cfg(test)]
5979 mod tests {
5980 use super::*;
5981
5982 #[test]
5983 fn sha256_known_vector() {
5984 let d = sha256(b"abc");
5985 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5986 assert_eq!(
5987 hex,
5988 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5989 );
5990 }
5991
5992 #[test]
5993 fn hmac_known_vector() {
5994 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5995 assert_eq!(
5996 mac,
5997 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5998 );
5999 }
6000
6001 #[test]
6002 fn sign_verify_round_trips() {
6003 let secret = "test-secret";
6004 let sid = "9f2c-opaque-session-id";
6005 let cookie = sign_session(sid, secret);
6006 let pair = cookie.split(';').next().unwrap().to_string();
6007 let mut headers = HeaderMap::new();
6008 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6009 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6010 assert!(verify_session(&headers, "other-secret").is_none());
6012 }
6013
6014 #[test]
6015 fn forged_and_tampered_cookies_are_rejected() {
6016 let secret = "test-secret";
6017
6018 let forged = format!(
6021 "{SESSION_COOKIE}={}.{}",
6022 b64url_encode(b"attacker-chosen-sid"),
6023 "deadbeef".repeat(8) );
6025 let mut headers = HeaderMap::new();
6026 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6027 assert!(verify_session(&headers, secret).is_none());
6028
6029 let cookie = sign_session("real-sid", secret);
6032 let pair = cookie.split(';').next().unwrap();
6033 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6034 let tampered = format!(
6035 "{SESSION_COOKIE}={}.{}",
6036 b64url_encode(b"different-sid"),
6037 sig
6038 );
6039 let mut headers2 = HeaderMap::new();
6040 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6041 assert!(verify_session(&headers2, secret).is_none());
6042 }
6043
6044 #[test]
6045 fn b64url_round_trips() {
6046 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6047 let enc = b64url_encode(s.as_bytes());
6048 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6049 }
6050 }
6051 }
6052}
6053
6054async fn get_entry_by_id(
6070 pool: &store::Pool,
6071 did: &str,
6072 id: i64,
6073) -> anyhow::Result<Option<store::Entry>> {
6074 let entry = sqlx::query_as::<_, store::Entry>(
6075 r#"
6076 SELECT e.* FROM entries e
6077 WHERE e.id = ?2
6078 AND EXISTS (
6079 SELECT 1 FROM sub_ref sr
6080 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6081 )
6082 "#,
6083 )
6084 .bind(did)
6085 .bind(id)
6086 .fetch_optional(pool)
6087 .await?;
6088 Ok(entry)
6089}
6090
6091async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6093 let read: Option<bool> =
6094 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6095 .bind(did)
6096 .bind(entry_id)
6097 .fetch_optional(pool)
6098 .await?
6099 .flatten();
6100 Ok(read.unwrap_or(false))
6101}
6102
6103async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6105 let starred: Option<bool> =
6106 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6107 .bind(did)
6108 .bind(entry_id)
6109 .fetch_optional(pool)
6110 .await?
6111 .flatten();
6112 Ok(starred.unwrap_or(false))
6113}
6114
6115async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6117 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6118 .bind(feed_id)
6119 .fetch_optional(pool)
6120 .await
6121 {
6122 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6123 _ => String::new(),
6124 }
6125}
6126
6127async fn build_entry_row(
6130 pool: &store::Pool,
6131 did: &str,
6132 id: i64,
6133 read: Option<bool>,
6134) -> anyhow::Result<Option<EntryRow>> {
6135 let entry = match get_entry_by_id(pool, did, id).await? {
6136 Some(e) => e,
6137 None => return Ok(None),
6138 };
6139 let read = match read {
6140 Some(r) => r,
6141 None => entry_is_read(pool, did, id).await?,
6142 };
6143 let starred = entry_is_starred(pool, did, id).await?;
6144 Ok(Some(EntryRow {
6145 id: entry.id,
6146 title: entry
6147 .title
6148 .clone()
6149 .filter(|t| !t.trim().is_empty())
6150 .unwrap_or_else(|| "(untitled)".to_string()),
6151 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6152 published: display_date(entry.published.as_deref()),
6153 read,
6154 starred,
6155 link: SafeLink::entry(id, ""),
6156 cached: true,
6157 rkey: String::new(),
6158 }))
6159}
6160
6161fn now_rfc3339() -> String {
6163 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6164}
6165
6166#[cfg(test)]
6167mod tests {
6168 use super::*;
6169
6170 #[test]
6171 fn qenc_encodes_reserved() {
6172 assert_eq!(qenc("a b"), "a%20b");
6173 assert_eq!(
6174 qenc("https://example.com/feed.xml"),
6175 "https%3A%2F%2Fexample.com%2Ffeed.xml"
6176 );
6177 assert_eq!(
6178 qenc("at://did:plc:x/c/r"),
6179 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6180 );
6181 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6183 }
6184
6185 #[test]
6186 fn folder_uri_shape() {
6187 assert_eq!(
6188 folder_uri("did:plc:abc", "3kfolder"),
6189 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6190 );
6191 }
6192
6193 #[test]
6196 fn private_feeds_are_classified_private_across_providers() {
6197 for url in [
6201 "https://author.substack.com/feed/private/deadbeefcafe1234",
6202 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6203 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6204 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6205 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6206 "https://user:pass@example.com/feed",
6207 ] {
6208 assert!(
6209 feed::classify_feed_privacy(url).is_private(),
6210 "expected private: {url}"
6211 );
6212 }
6213 }
6214
6215 #[test]
6216 fn public_feeds_stay_public() {
6217 for url in [
6218 "https://author.substack.com/feed",
6219 "https://wordpress.example.com/feed/",
6220 "https://example.com/rss.xml",
6221 "https://example.org/atom.xml",
6222 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6224 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6225 ] {
6226 assert!(
6227 !feed::classify_feed_privacy(url).is_private(),
6228 "expected public: {url}"
6229 );
6230 }
6231 }
6232
6233 #[test]
6234 fn private_feed_label_is_public_safe_host_only() {
6235 let label =
6237 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6238 assert_eq!(label, "author.substack.com");
6239 assert!(!label.contains("deadbeefcafe1234token"));
6240 assert!(!label.contains("/private/"));
6241 assert_eq!(private_feed_label("not a url"), "a private feed");
6243 }
6244
6245 #[test]
6246 fn refusal_message_promises_nothing_stored() {
6247 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6248 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6249 }
6250
6251 #[test]
6252 fn scope_query_preserves_context() {
6253 let q = EntryQuery {
6254 feed: Some("https://example.com/feed.xml".to_string()),
6255 folder: None,
6256 view: Some("all".to_string()),
6257 };
6258 let s = scope_query(&q);
6259 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6260 assert!(s.contains("view=all"));
6261
6262 let q2 = EntryQuery {
6264 feed: None,
6265 folder: None,
6266 view: Some("unread".to_string()),
6267 };
6268 assert_eq!(scope_query(&q2), "");
6269 }
6270
6271 use axum::body::Body;
6274 use axum::http::Request;
6275 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6281 let db = store::init_url("sqlite::memory:").await.unwrap();
6282 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6283 store::ensure_seed(&db, &dids).await.unwrap();
6284 let config = Config {
6285 allowed_dids: dids,
6286 cookie_secret: "test-cookie-secret-000".to_string(),
6287 beta_cap: 3,
6288 ..Config::default()
6289 };
6290 AppState::new(config, db).unwrap()
6291 }
6292
6293 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6296 let sid = state.sessions.create(Session {
6297 did: did.to_string(),
6298 handle: handle.map(str::to_string),
6299 });
6300 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6301 sc.split(';').next().unwrap().to_string()
6302 }
6303
6304 #[test]
6308 fn the_rate_limit_map_is_bounded() {
6309 let rl = RateLimiter::shared();
6310 let now = Instant::now();
6311 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6312 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6315 rl.check_at(ip, now + Duration::from_millis(i as u64));
6316 }
6317 let len = rl.inner.lock().unwrap().buckets.len();
6318 assert!(
6319 len <= MAX_RATE_BUCKETS,
6320 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6321 );
6322 }
6323
6324 #[test]
6331 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6332 let rl = RateLimiter::shared();
6333 let base = Instant::now();
6334 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6335 let at = |n: u64| base + Duration::from_nanos(n);
6340
6341 for i in 0..(RATE_BURST as u64) {
6343 assert!(rl.check_at(attacker, at(i)));
6344 }
6345 assert!(
6346 !rl.check_at(attacker, at(RATE_BURST as u64)),
6347 "burst was not exhausted; the rest of this test proves nothing"
6348 );
6349
6350 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6353 let t = at(100 + i as u64 * 2);
6354 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6355 rl.check_at(ip, t);
6356 assert!(
6357 !rl.check_at(attacker, t),
6358 "the attacker got a token back after evictions at i={i}"
6359 );
6360 }
6361 }
6362
6363 #[test]
6366 fn the_idle_sweep_does_not_run_on_every_request() {
6367 let rl = RateLimiter::shared();
6368 let start = Instant::now();
6369 let a: IpAddr = "198.51.100.1".parse().unwrap();
6370 let b: IpAddr = "198.51.100.2".parse().unwrap();
6371
6372 rl.check_at(a, start);
6373 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6376 assert!(
6377 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6378 "an idle bucket survived a sweep that was due"
6379 );
6380
6381 let before = rl.inner.lock().unwrap().last_sweep;
6384 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6385 assert_eq!(
6386 rl.inner.lock().unwrap().last_sweep,
6387 before,
6388 "the sweep ran again within the interval"
6389 );
6390 }
6391
6392 #[test]
6393 fn rate_limited_paths_match_expected() {
6394 use axum::http::Method;
6395 assert!(is_rate_limited_path("/login", &Method::GET));
6396 assert!(is_rate_limited_path("/login", &Method::POST));
6397 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6398 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6399 assert!(is_rate_limited_path("/opml", &Method::POST));
6400 assert!(is_rate_limited_path("/read-all", &Method::POST));
6401 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6402 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6403 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6404 assert!(!is_rate_limited_path("/", &Method::GET));
6406 assert!(!is_rate_limited_path("/about", &Method::GET));
6407 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6408 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6409 }
6410
6411 #[test]
6412 fn rate_limiter_allows_burst_then_429s() {
6413 let rl = RateLimiter::shared();
6414 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6415 for _ in 0..(RATE_BURST as usize) {
6417 assert!(rl.check(ip));
6418 }
6419 assert!(!rl.check(ip));
6421 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6423 assert!(rl.check(ip2));
6424 }
6425
6426 #[test]
6427 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6428 let mut h = HeaderMap::new();
6432 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6433 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6434 assert_eq!(
6435 client_ip(&h, Some(&sock), None),
6436 Some("203.0.113.55".parse().unwrap()),
6437 "spoofed XFF must not override the socket peer"
6438 );
6439 }
6440
6441 #[test]
6442 fn client_ip_uses_trusted_header_last_hop() {
6443 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6448
6449 let mut h = HeaderMap::new();
6450 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6451 assert_eq!(
6452 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6453 Some("198.51.100.9".parse().unwrap())
6454 );
6455
6456 let mut h2 = HeaderMap::new();
6458 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6459 assert_eq!(
6460 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6461 Some("198.51.100.9".parse().unwrap()),
6462 "must take the right-most (trusted) hop, not the forged left-most"
6463 );
6464
6465 let h3 = HeaderMap::new();
6467 assert_eq!(
6468 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6469 Some("10.0.0.1".parse().unwrap())
6470 );
6471 }
6472
6473 #[test]
6474 fn invite_cookie_round_trips_and_rejects_tamper() {
6475 let secret = "test-cookie-secret-000";
6476 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6477 let pair = sc.split(';').next().unwrap();
6478 let mut h = HeaderMap::new();
6479 h.insert(header::COOKIE, pair.parse().unwrap());
6480 assert_eq!(
6481 invite_cookie_code(&h, secret).as_deref(),
6482 Some("FEATHER-ABCDWXYZ")
6483 );
6484 assert!(invite_cookie_code(&h, "other").is_none());
6486 }
6487
6488 #[tokio::test]
6489 async fn preflight_valid_expired_and_full() {
6490 let state = test_state(&["did:plc:admin"]).await;
6491 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6493 .await
6494 .unwrap();
6495 assert!(preflight_code(&state, &code).await.is_ok());
6496
6497 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6501 .await
6502 .unwrap();
6503 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6504 .bind(chrono::Utc::now().timestamp() - 3600)
6505 .bind(&expired)
6506 .execute(&state.db)
6507 .await
6508 .unwrap();
6509 assert_eq!(
6510 preflight_code(&state, &expired).await,
6511 Err(store::RedeemError::Expired)
6512 );
6513
6514 assert_eq!(
6516 preflight_code(&state, "FEATHER-NOPENOPE").await,
6517 Err(store::RedeemError::NotFound)
6518 );
6519
6520 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6523 .await
6524 .unwrap();
6525 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6526 .await
6527 .unwrap();
6528 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6529 assert_eq!(
6530 preflight_code(&state, &code).await,
6531 Err(store::RedeemError::CapacityFull)
6532 );
6533 }
6534
6535 async fn bot_state(bot_secret: &str) -> AppState {
6539 let db = store::init_url("sqlite::memory:").await.unwrap();
6540 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6541 .await
6542 .unwrap();
6543 let config = Config {
6544 allowed_dids: vec!["did:plc:admin".to_string()],
6545 cookie_secret: "test-cookie-secret-000".to_string(),
6546 beta_cap: 3,
6547 bot_secret: Some(bot_secret.to_string()),
6548 public_url: "https://feather-reader.com".to_string(),
6549 ..Config::default()
6550 };
6551 AppState::new(config, db).unwrap()
6552 }
6553
6554 #[test]
6555 fn claim_token_round_trips_and_rejects_tamper() {
6556 let secret = "test-cookie-secret-000";
6557 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6558 assert!(!token.contains(';'));
6560 assert_eq!(
6561 claim_token_code(&token, secret).as_deref(),
6562 Some("FEATHER-ABCDWXYZ")
6563 );
6564 assert!(claim_token_code(&token, "other").is_none());
6566 let mut bad = token.clone();
6568 bad.push('x');
6569 assert!(claim_token_code(&bad, secret).is_none());
6570 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6576 assert_eq!(
6577 test_b64url_decode(b64).as_deref(),
6578 Some("FEATHER-ABCDWXYZ".as_bytes()),
6579 "the code half of the token is plain base64url, decodable by anyone"
6580 );
6581 }
6582
6583 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6586 fn val(c: u8) -> Option<u32> {
6587 match c {
6588 b'A'..=b'Z' => Some((c - b'A') as u32),
6589 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6590 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6591 b'-' => Some(62),
6592 b'_' => Some(63),
6593 _ => None,
6594 }
6595 }
6596 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6597 for chunk in input.as_bytes().chunks(4) {
6598 let mut n = 0u32;
6599 let mut bits = 0;
6600 for &c in chunk {
6601 n = (n << 6) | val(c)?;
6602 bits += 6;
6603 }
6604 let bytes = bits / 8;
6605 n <<= 24 - bits;
6606 for i in 0..bytes {
6607 out.push((n >> (16 - i * 8)) as u8);
6608 }
6609 }
6610 Some(out)
6611 }
6612
6613 #[tokio::test]
6614 async fn bot_mint_then_claim_grants_a_seat() {
6615 let state = bot_state("bot-secret-abcdef").await;
6616 let app = router(state.clone());
6617
6618 let resp = app
6620 .clone()
6621 .oneshot(
6622 Request::builder()
6623 .method("POST")
6624 .uri("/bot/claims")
6625 .header("x-bot-secret", "bot-secret-abcdef")
6626 .body(Body::empty())
6627 .unwrap(),
6628 )
6629 .await
6630 .unwrap();
6631 assert_eq!(resp.status(), StatusCode::OK);
6632 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6633 .await
6634 .unwrap();
6635 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6636 let token = json["token"].as_str().unwrap().to_string();
6637 let url = json["url"].as_str().unwrap();
6638 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6639 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6641 assert!(!url.contains("FEATHER-"));
6642
6643 let resp = app
6645 .clone()
6646 .oneshot(
6647 Request::builder()
6648 .method("GET")
6649 .uri(format!("/claim?t={}", qenc(&token)))
6650 .body(Body::empty())
6651 .unwrap(),
6652 )
6653 .await
6654 .unwrap();
6655 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6656 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6657 let set_cookie = resp
6658 .headers()
6659 .get(header::SET_COOKIE)
6660 .unwrap()
6661 .to_str()
6662 .unwrap();
6663 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6664
6665 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6668 let out = store::redeem_code(
6669 &state.db,
6670 &code,
6671 "did:plc:follower",
6672 None,
6673 state.config.beta_cap,
6674 )
6675 .await
6676 .unwrap();
6677 assert_eq!(out, Ok(()));
6678 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6679 .await
6680 .unwrap());
6681 }
6682
6683 #[tokio::test]
6684 async fn claim_with_invalid_token_bounces() {
6685 let state = bot_state("bot-secret-abcdef").await;
6686 let app = router(state);
6687 let resp = app
6688 .oneshot(
6689 Request::builder()
6690 .method("GET")
6691 .uri("/claim?t=not-a-real-token")
6692 .body(Body::empty())
6693 .unwrap(),
6694 )
6695 .await
6696 .unwrap();
6697 assert_eq!(resp.status(), StatusCode::OK);
6699 }
6700
6701 #[tokio::test]
6702 async fn claim_with_used_token_is_refused() {
6703 let state = bot_state("bot-secret-abcdef").await;
6704 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6706 .await
6707 .unwrap();
6708 let token = sign_claim_token(&code, &state.config.cookie_secret);
6709 store::redeem_code(
6710 &state.db,
6711 &code,
6712 "did:plc:someone",
6713 None,
6714 state.config.beta_cap,
6715 )
6716 .await
6717 .unwrap()
6718 .unwrap();
6719 let app = router(state);
6720 let resp = app
6721 .oneshot(
6722 Request::builder()
6723 .method("GET")
6724 .uri(format!("/claim?t={}", qenc(&token)))
6725 .body(Body::empty())
6726 .unwrap(),
6727 )
6728 .await
6729 .unwrap();
6730 assert_eq!(resp.status(), StatusCode::OK);
6732 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6733 }
6734
6735 #[tokio::test]
6736 async fn bot_claims_rejects_bad_and_missing_secret() {
6737 let state = bot_state("bot-secret-abcdef").await;
6738 let app = router(state);
6739 let resp = app
6741 .clone()
6742 .oneshot(
6743 Request::builder()
6744 .method("POST")
6745 .uri("/bot/claims")
6746 .header("x-bot-secret", "wrong")
6747 .body(Body::empty())
6748 .unwrap(),
6749 )
6750 .await
6751 .unwrap();
6752 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6753 let resp = app
6755 .oneshot(
6756 Request::builder()
6757 .method("POST")
6758 .uri("/bot/claims")
6759 .body(Body::empty())
6760 .unwrap(),
6761 )
6762 .await
6763 .unwrap();
6764 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6765 }
6766
6767 #[tokio::test]
6768 async fn bot_claims_disabled_when_secret_unset() {
6769 let state = test_state(&["did:plc:admin"]).await;
6771 let app = router(state);
6772 let resp = app
6773 .oneshot(
6774 Request::builder()
6775 .method("POST")
6776 .uri("/bot/claims")
6777 .header("x-bot-secret", "anything")
6778 .body(Body::empty())
6779 .unwrap(),
6780 )
6781 .await
6782 .unwrap();
6783 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6784 }
6785
6786 #[tokio::test]
6787 async fn bot_claims_refuses_at_capacity() {
6788 let state = bot_state("bot-secret-abcdef").await;
6789 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6791 .await
6792 .unwrap();
6793 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6794 .await
6795 .unwrap();
6796 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6797 let app = router(state);
6798 let resp = app
6799 .oneshot(
6800 Request::builder()
6801 .method("POST")
6802 .uri("/bot/claims")
6803 .header("x-bot-secret", "bot-secret-abcdef")
6804 .body(Body::empty())
6805 .unwrap(),
6806 )
6807 .await
6808 .unwrap();
6809 assert_eq!(resp.status(), StatusCode::CONFLICT);
6810 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6811 .await
6812 .unwrap();
6813 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6814 }
6815
6816 #[tokio::test]
6817 async fn bot_claims_counts_outstanding_codes_against_cap() {
6818 let state = bot_state("bot-secret-abcdef").await;
6819 store::mint_code(&state.db, "did:plc:admin", 3600)
6821 .await
6822 .unwrap();
6823 store::mint_code(&state.db, "did:plc:admin", 3600)
6824 .await
6825 .unwrap();
6826 let app = router(state);
6827 let resp = app
6828 .oneshot(
6829 Request::builder()
6830 .method("POST")
6831 .uri("/bot/claims")
6832 .header("x-bot-secret", "bot-secret-abcdef")
6833 .body(Body::empty())
6834 .unwrap(),
6835 )
6836 .await
6837 .unwrap();
6838 assert_eq!(resp.status(), StatusCode::CONFLICT);
6840 }
6841
6842 async fn post_bot_claim_for(
6844 app: &axum::Router,
6845 secret: &str,
6846 did: &str,
6847 ) -> (StatusCode, serde_json::Value) {
6848 let resp = app
6849 .clone()
6850 .oneshot(
6851 Request::builder()
6852 .method("POST")
6853 .uri("/bot/claims")
6854 .header("x-bot-secret", secret)
6855 .header("content-type", "application/json")
6856 .body(Body::from(format!(
6857 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6858 )))
6859 .unwrap(),
6860 )
6861 .await
6862 .unwrap();
6863 let status = resp.status();
6864 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6865 .await
6866 .unwrap();
6867 let json = if bytes.is_empty() {
6868 serde_json::Value::Null
6869 } else {
6870 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6871 };
6872 (status, json)
6873 }
6874
6875 #[tokio::test]
6876 async fn bot_claims_returns_already_seated_for_a_member() {
6877 let state = bot_state("bot-secret-abcdef").await;
6881 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6882 .await
6883 .unwrap();
6884 let app = router(state.clone());
6885 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6886 assert_eq!(status, StatusCode::OK);
6887 assert_eq!(json["status"], "already_seated");
6888 assert_eq!(json["code"], "");
6889 assert_eq!(json["url"], "");
6890 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6892 .await
6893 .unwrap()
6894 .is_none());
6895 }
6896
6897 #[tokio::test]
6898 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6899 let state = bot_state("bot-secret-abcdef").await;
6903 let app = router(state.clone());
6904
6905 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6906 assert_eq!(s1, StatusCode::OK);
6907 assert_eq!(j1["status"], "minted");
6908 let code1 = j1["code"].as_str().unwrap().to_string();
6909
6910 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6911 assert_eq!(s2, StatusCode::OK);
6912 assert_eq!(j2["status"], "existing");
6913 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6914 assert_eq!(j2["url"], j1["url"], "same url returned");
6915
6916 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6918 }
6919
6920 #[tokio::test]
6921 async fn bot_claims_records_intended_did_at_mint() {
6922 let state = bot_state("bot-secret-abcdef").await;
6924 let app = router(state.clone());
6925 let (status, json) =
6926 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6927 assert_eq!(status, StatusCode::OK);
6928 let code = json["code"].as_str().unwrap();
6929 assert_eq!(
6930 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6931 .await
6932 .unwrap()
6933 .as_deref(),
6934 Some(code)
6935 );
6936 }
6937
6938 #[tokio::test]
6939 async fn bot_claims_concurrent_same_did_never_double_mints() {
6940 let state = bot_state("bot-secret-abcdef").await;
6947 let app = router(state.clone());
6948
6949 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6950 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6951 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6952
6953 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6954 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6955
6956 assert_eq!(
6958 store::count_active_codes(&state.db).await.unwrap(),
6959 1,
6960 "concurrent mints must not create two active codes"
6961 );
6962
6963 let ca = ja["code"].as_str().unwrap_or("");
6965 let cb = jb["code"].as_str().unwrap_or("");
6966 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6967 assert_eq!(ca, cb, "both callers must get the one minted code");
6968 for st in [&ja["status"], &jb["status"]] {
6971 let s = st.as_str().unwrap_or("");
6972 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6973 }
6974 }
6975
6976 #[tokio::test]
6977 async fn bot_claims_rejects_malformed_json_body() {
6978 let state = bot_state("bot-secret-abcdef").await;
6979 let app = router(state);
6980 let resp = app
6981 .oneshot(
6982 Request::builder()
6983 .method("POST")
6984 .uri("/bot/claims")
6985 .header("x-bot-secret", "bot-secret-abcdef")
6986 .header("content-type", "application/json")
6987 .body(Body::from("{not json"))
6988 .unwrap(),
6989 )
6990 .await
6991 .unwrap();
6992 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6993 }
6994
6995 #[tokio::test]
6996 async fn favicon_ico_served_at_root() {
6997 let state = test_state(&[]).await;
7000 let app = router(state);
7001 let resp = app
7002 .oneshot(
7003 Request::builder()
7004 .uri("/favicon.ico")
7005 .body(Body::empty())
7006 .unwrap(),
7007 )
7008 .await
7009 .unwrap();
7010 assert_eq!(resp.status(), StatusCode::OK);
7011 let ct = resp
7012 .headers()
7013 .get(header::CONTENT_TYPE)
7014 .unwrap()
7015 .to_str()
7016 .unwrap();
7017 assert!(
7018 ct.contains("icon") || ct.starts_with("image/"),
7019 "content-type = {ct}"
7020 );
7021 }
7022
7023 #[tokio::test]
7024 async fn login_without_invite_redirects_to_beta_redeem() {
7025 let state = test_state(&[]).await;
7027 let app = router(state);
7028 let resp = app
7029 .oneshot(
7030 Request::builder()
7031 .method("POST")
7032 .uri("/login")
7033 .header("content-type", "application/x-www-form-urlencoded")
7034 .body(Body::from("handle=alice.bsky.social"))
7035 .unwrap(),
7036 )
7037 .await
7038 .unwrap();
7039 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7040 assert_eq!(
7041 resp.headers().get(header::LOCATION).unwrap(),
7042 "/beta/redeem"
7043 );
7044 }
7045
7046 #[tokio::test]
7047 async fn login_with_valid_invite_cookie_starts_oauth() {
7048 let state = test_state(&[]).await;
7049 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7050 let cookie = cookie.split(';').next().unwrap().to_string();
7051 let app = router(state);
7052 let resp = app
7053 .oneshot(
7054 Request::builder()
7055 .method("POST")
7056 .uri("/login")
7057 .header("content-type", "application/x-www-form-urlencoded")
7058 .header(header::COOKIE, cookie)
7059 .body(Body::from("handle=alice.bsky.social"))
7060 .unwrap(),
7061 )
7062 .await
7063 .unwrap();
7064 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7066 let loc = resp
7067 .headers()
7068 .get(header::LOCATION)
7069 .unwrap()
7070 .to_str()
7071 .unwrap();
7072 assert!(loc.contains("/login"), "loc = {loc}");
7073 assert_ne!(loc, "/beta/redeem");
7074 }
7075
7076 async fn resolver_never(_handle: String) -> Option<String> {
7079 None
7080 }
7081
7082 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7084 move |_handle| std::future::ready(Some(did.to_string()))
7085 }
7086
7087 #[tokio::test]
7091 async fn may_start_oauth_honors_seat_via_resolved_handle() {
7092 let state = test_state(&["did:plc:admin"]).await;
7095 let headers = HeaderMap::new();
7096 assert!(
7097 may_start_oauth_with(
7098 &state,
7099 &headers,
7100 "admin.example",
7101 resolver_to("did:plc:admin")
7102 )
7103 .await,
7104 "a handle resolving to a seated DID must pass the gate"
7105 );
7106 }
7107
7108 #[tokio::test]
7112 async fn may_start_oauth_bounces_non_member_handle() {
7113 let state = test_state(&["did:plc:admin"]).await;
7114 let headers = HeaderMap::new();
7115 assert!(
7116 !may_start_oauth_with(
7117 &state,
7118 &headers,
7119 "rando.example",
7120 resolver_to("did:plc:rando")
7121 )
7122 .await,
7123 "a resolved DID with no seat must be bounced"
7124 );
7125 }
7126
7127 #[tokio::test]
7130 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7131 let state = test_state(&["did:plc:admin"]).await;
7132 let headers = HeaderMap::new();
7133 assert!(
7134 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7135 "an unresolvable handle must fail closed"
7136 );
7137 }
7138
7139 #[tokio::test]
7143 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7144 let state = test_state(&[]).await;
7145 let did = "did:plc:member";
7146 store::grant_access(&state.db, did, Some("member.example"), "test", None)
7147 .await
7148 .unwrap();
7149 let cookie = session_cookie(&state, did, Some("member.example"));
7150 let mut headers = HeaderMap::new();
7151 headers.insert(header::COOKIE, cookie.parse().unwrap());
7152 assert!(
7153 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7154 "a seated session cookie must pass without resolution"
7155 );
7156 }
7157
7158 #[tokio::test]
7160 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7161 let state = test_state(&[]).await;
7162 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7163 let cookie = cookie.split(';').next().unwrap().to_string();
7164 let mut headers = HeaderMap::new();
7165 headers.insert(header::COOKIE, cookie.parse().unwrap());
7166 assert!(
7167 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7168 "a valid invite cookie must pass without resolution"
7169 );
7170 }
7171
7172 #[tokio::test]
7173 async fn admin_mint_requires_admin_seed_did() {
7174 let state = test_state(&["did:plc:admin"]).await;
7175 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7177 .await
7178 .unwrap();
7179 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7180 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7182 let app = router(state);
7183
7184 let forbidden = app
7185 .clone()
7186 .oneshot(
7187 Request::builder()
7188 .method("POST")
7189 .uri("/admin/invites?n=2")
7190 .header(header::COOKIE, rando_cookie)
7191 .body(Body::empty())
7192 .unwrap(),
7193 )
7194 .await
7195 .unwrap();
7196 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7197
7198 let ok = app
7199 .oneshot(
7200 Request::builder()
7201 .method("POST")
7202 .uri("/admin/invites?n=2")
7203 .header(header::COOKIE, admin_cookie)
7204 .body(Body::empty())
7205 .unwrap(),
7206 )
7207 .await
7208 .unwrap();
7209 assert_eq!(ok.status(), StatusCode::OK);
7210 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7211 .await
7212 .unwrap();
7213 let body = String::from_utf8(bytes.to_vec()).unwrap();
7214 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7215 assert_eq!(minted.len(), 2);
7216 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7217 }
7218
7219 #[tokio::test]
7220 async fn admin_mint_unauthenticated_is_401() {
7221 let state = test_state(&["did:plc:admin"]).await;
7222 let app = router(state);
7223 let resp = app
7224 .oneshot(
7225 Request::builder()
7226 .method("POST")
7227 .uri("/admin/invites")
7228 .body(Body::empty())
7229 .unwrap(),
7230 )
7231 .await
7232 .unwrap();
7233 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7234 }
7235
7236 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7239 let db = store::init_url("sqlite::memory:").await.unwrap();
7240 store::record_network_stat(
7241 &db,
7242 &store::NetworkStat {
7243 key: store::ADOPTION_STAT_KEY.to_string(),
7244 source: "https://relay1.us-west.bsky.network".to_string(),
7245 value: repos,
7246 truncated,
7247 observed_at: "2026-08-13T04:05:06Z".to_string(),
7248 },
7249 )
7250 .await
7251 .unwrap();
7252 let config = Config {
7253 cookie_secret: "test-cookie-secret-000".to_string(),
7254 show_adoption: true,
7255 ..Config::default()
7256 };
7257 AppState::new(config, db).unwrap()
7258 }
7259
7260 async fn about_body(state: AppState) -> String {
7261 let resp = router(state)
7262 .oneshot(
7263 Request::builder()
7264 .uri("/about")
7265 .body(Body::empty())
7266 .unwrap(),
7267 )
7268 .await
7269 .unwrap();
7270 assert_eq!(resp.status(), StatusCode::OK);
7271 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7272 .await
7273 .unwrap();
7274 String::from_utf8(bytes.to_vec()).unwrap()
7275 }
7276
7277 #[tokio::test]
7279 async fn about_omits_adoption_line_by_default() {
7280 let state = test_state(&[]).await;
7281 assert!(!state.config.show_adoption);
7282 let body = about_body(state).await;
7283 assert!(
7284 !body.contains("atproto network"),
7285 "the adoption line must not render by default"
7286 );
7287 }
7288
7289 #[tokio::test]
7290 async fn about_renders_adoption_line_when_enabled() {
7291 let body = about_body(adoption_state(7_318, false).await).await;
7299 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7302 assert!(
7303 flat.contains("7318 accounts on the atproto network hold"),
7304 "the count did not render in its own sentence: {flat}",
7305 );
7306 assert!(
7307 body.contains("accounts on the atproto network hold"),
7308 "{body}"
7309 );
7310 assert!(
7311 body.contains("2026-08-13"),
7312 "the observation date must render"
7313 );
7314 assert!(
7315 body.contains("lower bound"),
7316 "the non-archival caveat must ride along with the number"
7317 );
7318 assert!(
7319 !body.contains("At least"),
7320 "an untruncated count is exact-ish"
7321 );
7322 }
7323
7324 #[tokio::test]
7326 async fn about_adoption_line_is_singular_at_one() {
7327 let body = about_body(adoption_state(1, false).await).await;
7328 assert!(
7329 body.contains("account on the atproto network holds"),
7330 "{body}"
7331 );
7332 }
7333
7334 #[tokio::test]
7336 async fn about_adoption_line_says_at_least_when_truncated() {
7337 let body = about_body(adoption_state(25_000, true).await).await;
7338 assert!(body.contains("At least"), "{body}");
7339 }
7340
7341 #[tokio::test]
7343 async fn about_omits_line_when_enabled_with_no_observation() {
7344 let db = store::init_url("sqlite::memory:").await.unwrap();
7345 let config = Config {
7346 cookie_secret: "test-cookie-secret-000".to_string(),
7347 show_adoption: true,
7348 ..Config::default()
7349 };
7350 let body = about_body(AppState::new(config, db).unwrap()).await;
7351 assert!(!body.contains("atproto network"));
7352 }
7353
7354 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7365 let db = store::init_url("sqlite::memory:").await.unwrap();
7366 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7367 let config = Config {
7368 allowed_dids: vec![did.to_string()],
7369 cookie_secret: "test-cookie-secret-000".to_string(),
7370 beta_cap: 3,
7371 standard_site,
7372 ..Config::default()
7373 };
7374 AppState::new(config, db).unwrap()
7375 }
7376
7377 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7379 let cookie = session_cookie(&state, did, Some("reader.example"));
7380 let resp = router(state)
7381 .oneshot(
7382 Request::builder()
7383 .uri(path)
7384 .header(header::COOKIE, cookie)
7385 .body(Body::empty())
7386 .unwrap(),
7387 )
7388 .await
7389 .unwrap();
7390 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7391 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7392 .await
7393 .unwrap();
7394 String::from_utf8(bytes.to_vec()).unwrap()
7395 }
7396
7397 async fn public_body(state: AppState, path: &str) -> String {
7399 let resp = router(state)
7400 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7401 .await
7402 .unwrap();
7403 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7404 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7405 .await
7406 .unwrap();
7407 String::from_utf8(bytes.to_vec()).unwrap()
7408 }
7409
7410 fn feed_url_input(body: &str) -> &str {
7412 let start = body
7413 .find("id=\"feed-url\"")
7414 .and_then(|i| body[..i].rfind("<input"))
7415 .expect("the subscribe form's URL input renders");
7416 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7417 &body[start..end]
7418 }
7419
7420 #[tokio::test]
7423 async fn manage_hints_at_publications_when_the_flag_is_on() {
7424 let did = "did:plc:reader";
7425 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7426 assert!(
7427 body.contains("at://did:plc:…/site.standard.publication/…"),
7428 "the DID form must be shown: {body}"
7429 );
7430 assert!(
7431 body.contains("at://alice.example.com/site.standard.publication/…"),
7432 "the handle form must be shown: {body}"
7433 );
7434 }
7435
7436 #[tokio::test]
7442 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7443 let did = "did:plc:reader";
7444 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7445 let input = feed_url_input(&body);
7446 assert!(
7447 input.contains("type=\"text\""),
7448 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7449 );
7450 assert!(
7451 input.contains("inputmode=\"url\""),
7452 "the URL keyboard is still wanted: {input}"
7453 );
7454 }
7455
7456 #[tokio::test]
7462 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7463 let did = "did:plc:reader";
7464 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7465 let input = feed_url_input(&body);
7466 assert!(
7467 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7468 "the text input must keep a scheme check: {input}"
7469 );
7470 }
7471
7472 #[tokio::test]
7475 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7476 let did = "did:plc:reader";
7477 let state = standard_site_state(false, did).await;
7478 assert!(!state.config.standard_site);
7479 let page = signed_in_body(state, "/manage", did).await;
7480 let body = &page[page.find("</head>").expect("a <head>")..];
7485 assert!(
7486 !body.contains("site.standard.publication"),
7487 "a refused form must not be advertised: {body}"
7488 );
7489 let above_footer = body
7494 .split("<footer")
7495 .next()
7496 .expect("split yields at least one piece");
7497 assert!(
7498 above_footer.contains("id=\"feed-url\""),
7499 "the form must be above the footer: {body}"
7500 );
7501 assert!(
7502 !above_footer.contains("standard.site"),
7503 "a refused form must not be advertised: {body}"
7504 );
7505 assert!(
7506 feed_url_input(body).contains("type=\"url\""),
7507 "with the flag off the input is unchanged"
7508 );
7509 }
7510
7511 #[tokio::test]
7514 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7515 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7516 assert!(body.contains("standard.site"), "{body}");
7517 assert!(
7518 body.contains("at://did:plc:…/site.standard.publication/…"),
7519 "the landing page must show the DID form: {body}"
7520 );
7521 assert!(
7522 body.contains("at://alice.example.com/site.standard.publication/…"),
7523 "the landing page must show the handle form: {body}"
7524 );
7525 }
7526
7527 #[tokio::test]
7531 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7532 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7533 assert!(body.contains("standard.site"), "{body}");
7534 assert!(
7535 !body.contains("at://did:plc:…/site.standard.publication/…"),
7536 "no paste instructions with the flag off: {body}"
7537 );
7538 assert!(
7539 !body.contains("at://alice.example.com/site.standard.publication/…"),
7540 "no paste instructions with the flag off: {body}"
7541 );
7542 assert!(
7543 body.contains("isn't accepting new publication subscriptions"),
7544 "the page must say the form is closed here: {body}"
7545 );
7546 }
7547
7548 #[tokio::test]
7550 async fn about_describes_publications_and_how_to_subscribe_when_on() {
7551 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7552 assert!(body.contains("site.standard.publication"), "{body}");
7553 assert!(body.contains("site.standard.document"), "{body}");
7554 assert!(
7555 body.contains("at://did:plc:…/site.standard.publication/…"),
7556 "{body}"
7557 );
7558 assert!(
7559 body.contains("at://alice.example.com/site.standard.publication/…"),
7560 "{body}"
7561 );
7562 }
7563
7564 #[tokio::test]
7566 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7567 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7568 assert!(body.contains("site.standard.publication"), "{body}");
7569 assert!(
7570 !body.contains("at://did:plc:…/site.standard.publication/…"),
7571 "no paste instructions with the flag off: {body}"
7572 );
7573 assert!(
7574 !body.contains("at://alice.example.com/site.standard.publication/…"),
7575 "no paste instructions with the flag off: {body}"
7576 );
7577 assert!(
7578 body.contains("isn't accepting new publication subscriptions"),
7579 "{body}"
7580 );
7581 }
7582
7583 #[tokio::test]
7591 async fn standard_site_page_renders_signed_out() {
7592 let body = public_body(test_state(&[]).await, "/standard-site").await;
7593 assert!(body.contains("site.standard.publication"), "{body}");
7594 assert!(body.contains("site.standard.document"), "{body}");
7595 assert!(
7596 body.contains("<title>standard.site — FeatherReader</title>"),
7597 "{body}"
7598 );
7599 }
7600
7601 #[tokio::test]
7604 async fn standard_site_page_tells_how_to_subscribe_when_on() {
7605 let body = public_body(
7606 standard_site_state(true, "did:plc:x").await,
7607 "/standard-site",
7608 )
7609 .await;
7610 assert!(
7611 body.contains("at://did:plc:…/site.standard.publication/…"),
7612 "the DID form must be shown: {body}"
7613 );
7614 assert!(
7615 body.contains("at://alice.example.com/site.standard.publication/…"),
7616 "the handle form must be shown: {body}"
7617 );
7618 assert!(
7619 body.contains("resolved to its DID"),
7620 "the handle resolution must be stated: {body}"
7621 );
7622 assert!(
7623 !body.contains("isn't accepting new publication subscriptions"),
7624 "{body}"
7625 );
7626 }
7627
7628 #[tokio::test]
7632 async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7633 let state = standard_site_state(false, "did:plc:x").await;
7634 assert!(!state.config.standard_site);
7635 let body = public_body(state, "/standard-site").await;
7636 assert!(body.contains("site.standard.publication"), "{body}");
7637 assert!(
7638 !body.contains("at://did:plc:…/site.standard.publication/…"),
7639 "no paste instructions with the flag off: {body}"
7640 );
7641 assert!(
7642 !body.contains("at://alice.example.com/site.standard.publication/…"),
7643 "no paste instructions with the flag off: {body}"
7644 );
7645 assert!(
7646 body.contains("isn't accepting new publication subscriptions"),
7647 "the page must say the form is closed here: {body}"
7648 );
7649 assert!(
7650 body.contains("already follows are still read"),
7651 "stored publications are polled whatever the flag says: {body}"
7652 );
7653 }
7654
7655 #[tokio::test]
7658 async fn releases_callout_links_the_release_pages() {
7659 for path in ["/standard-site", "/"] {
7660 let body = public_body(test_state(&[]).await, path).await;
7661 for tag in ["v0.4.1", "v0.4.0"] {
7662 let href = format!(
7663 "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7664 );
7665 assert!(body.contains(&href), "{path} must link {tag}: {body}");
7666 }
7667 assert!(
7668 body.contains(
7669 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7670 ),
7671 "{path} must link the changelog: {body}"
7672 );
7673 }
7674 }
7675
7676 #[tokio::test]
7680 async fn landing_about_and_footer_link_the_standard_site_page() {
7681 for path in ["/", "/about", "/privacy"] {
7682 let body = public_body(test_state(&[]).await, path).await;
7683 assert!(
7684 body.contains("href=\"/standard-site\""),
7685 "{path} must link the feature page: {body}"
7686 );
7687 }
7688 }
7689
7690 #[test]
7694 fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7695 assert!(!RELEASES.is_empty());
7696 let parse = |v: &str| -> Vec<u32> {
7697 v.split('.')
7698 .map(|p| p.parse::<u32>().expect("a numeric version part"))
7699 .collect()
7700 };
7701 for pair in RELEASES.windows(2) {
7702 assert!(
7703 parse(pair[0].version) > parse(pair[1].version),
7704 "{} must come before {}",
7705 pair[0].version,
7706 pair[1].version
7707 );
7708 }
7709 for r in RELEASES {
7710 assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7711 assert!(
7712 chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7713 "{} is not YYYY-MM-DD",
7714 r.date
7715 );
7716 assert!(!r.summary.trim().is_empty());
7717 assert!(!r.summary.contains('<'), "the summary is plain text");
7718 assert_eq!(
7719 r.url(),
7720 format!(
7721 "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7722 r.version
7723 )
7724 );
7725 }
7726 let latest = &RELEASES[0];
7729 assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7730 assert_eq!(
7731 latest.changelog_url(),
7732 "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#042--2026-10-04"
7733 );
7734 }
7735
7736 #[tokio::test]
7738 async fn standard_site_page_is_publicly_cacheable() {
7739 let resp = router(test_state(&[]).await)
7740 .oneshot(
7741 Request::builder()
7742 .uri("/standard-site")
7743 .body(Body::empty())
7744 .unwrap(),
7745 )
7746 .await
7747 .unwrap();
7748 assert_eq!(resp.status(), StatusCode::OK);
7749 assert_eq!(
7750 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7751 "public, max-age=300"
7752 );
7753 }
7754
7755 #[tokio::test]
7756 async fn cache_control_public_on_about_no_store_on_authed() {
7757 let state = test_state(&["did:plc:admin"]).await;
7758 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7759 let app = router(state);
7760
7761 let about = app
7763 .clone()
7764 .oneshot(
7765 Request::builder()
7766 .uri("/about")
7767 .body(Body::empty())
7768 .unwrap(),
7769 )
7770 .await
7771 .unwrap();
7772 assert_eq!(
7773 about.headers().get(header::CACHE_CONTROL).unwrap(),
7774 "public, max-age=300"
7775 );
7776 assert_eq!(
7782 about.headers()["content-security-policy"],
7783 EXPECTED_CSP,
7784 "the CSP is not the policy the router promises"
7785 );
7786 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7787
7788 for path in ["/privacy", "/terms"] {
7790 let resp = app
7791 .clone()
7792 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7793 .await
7794 .unwrap();
7795 assert_eq!(resp.status(), StatusCode::OK);
7796 assert_eq!(
7797 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7798 "public, max-age=300",
7799 "{path} should be publicly cacheable"
7800 );
7801 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7803 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7804 }
7805
7806 let login = app
7808 .clone()
7809 .oneshot(
7810 Request::builder()
7811 .uri("/login")
7812 .body(Body::empty())
7813 .unwrap(),
7814 )
7815 .await
7816 .unwrap();
7817 assert_eq!(
7818 login.headers().get(header::CACHE_CONTROL).unwrap(),
7819 "public, max-age=300"
7820 );
7821
7822 let home = app
7824 .oneshot(
7825 Request::builder()
7826 .uri("/")
7827 .header(header::COOKIE, admin_cookie)
7828 .body(Body::empty())
7829 .unwrap(),
7830 )
7831 .await
7832 .unwrap();
7833 assert_eq!(
7834 home.headers().get(header::CACHE_CONTROL).unwrap(),
7835 "no-store"
7836 );
7837 }
7838
7839 fn head(body: &str) -> &str {
7848 let end = body.find("</head>").expect("a <head>");
7849 &body[..end]
7850 }
7851
7852 fn meta(head: &str, attr: &str) -> Option<String> {
7855 let tag_start = head.find(attr)?;
7856 let rest = &head[tag_start..];
7857 let tag_end = rest.find('>')?;
7858 let tag = &rest[..tag_end];
7859 let content = tag.find("content=\"")? + "content=\"".len();
7860 let close = tag[content..].find('"')?;
7861 Some(tag[content..content + close].to_string())
7862 }
7863
7864 async fn production_origin_state() -> AppState {
7868 let db = store::init_url("sqlite::memory:").await.unwrap();
7869 store::ensure_seed(&db, &["did:plc:admin".to_string()])
7870 .await
7871 .unwrap();
7872 let config = Config {
7873 allowed_dids: vec!["did:plc:admin".to_string()],
7874 cookie_secret: "test-cookie-secret-000".to_string(),
7875 beta_cap: 3,
7876 public_url: "https://feather-reader.com".to_string(),
7877 ..Config::default()
7878 };
7879 AppState::new(config, db).unwrap()
7880 }
7881
7882 #[tokio::test]
7885 async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7886 let landing = public_body(production_origin_state().await, "/").await;
7887 let about = public_body(production_origin_state().await, "/about").await;
7888 let (lh, ah) = (head(&landing), head(&about));
7889
7890 assert_eq!(
7891 meta(lh, "property=\"og:title\"").as_deref(),
7892 Some("FeatherReader — read, quietly"),
7893 "{lh}"
7894 );
7895 assert_eq!(
7896 meta(ah, "property=\"og:title\"").as_deref(),
7897 Some("About — FeatherReader"),
7898 "{ah}"
7899 );
7900 for (h, path) in [(lh, "/"), (ah, "/about")] {
7901 let url = format!("https://feather-reader.com{path}");
7902 assert_eq!(
7903 meta(h, "property=\"og:url\"").as_deref(),
7904 Some(url.as_str())
7905 );
7906 assert!(
7907 h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7908 "{path} must carry a canonical link: {h}"
7909 );
7910 let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7911 assert!(
7912 image.starts_with("https://feather-reader.com/static/"),
7913 "{path}: og:image must be absolute on the public origin, got {image:?}"
7914 );
7915 assert_eq!(
7916 meta(h, "name=\"twitter:card\"").as_deref(),
7917 Some("summary_large_image")
7918 );
7919 assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7920 assert_eq!(
7921 meta(h, "property=\"og:site_name\"").as_deref(),
7922 Some("FeatherReader")
7923 );
7924 let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7925 assert!(!description.is_empty(), "{path}: og:description is empty");
7926 assert_eq!(
7927 meta(h, "name=\"description\"").as_deref(),
7928 Some(description.as_str()),
7929 "{path}: the meta description and og:description must agree"
7930 );
7931 }
7932 assert_ne!(
7933 meta(lh, "property=\"og:description\""),
7934 meta(ah, "property=\"og:description\""),
7935 "the landing page and /about must not share a description"
7936 );
7937 }
7938
7939 #[tokio::test]
7941 async fn card_urls_follow_the_configured_public_url() {
7942 let db = store::init_url("sqlite::memory:").await.unwrap();
7943 store::ensure_seed(&db, &[]).await.unwrap();
7944 let config = Config {
7945 cookie_secret: "test-cookie-secret-000".to_string(),
7946 public_url: "https://reader.example.org".to_string(),
7947 ..Config::default()
7948 };
7949 let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
7950 let h = head(&body);
7951 assert_eq!(
7952 meta(h, "property=\"og:url\"").as_deref(),
7953 Some("https://reader.example.org/privacy")
7954 );
7955 assert_eq!(
7956 meta(h, "property=\"og:image\"").as_deref(),
7957 Some("https://reader.example.org/static/social-card.png")
7958 );
7959 }
7960
7961 #[tokio::test]
7964 async fn public_pages_each_carry_their_own_description() {
7965 let paths = [
7966 "/",
7967 "/about",
7968 "/privacy",
7969 "/terms",
7970 "/stats",
7971 "/standard-site",
7972 "/login",
7973 "/beta/redeem",
7974 ];
7975 let mut seen = std::collections::HashSet::new();
7976 for path in paths {
7977 let body = public_body(production_origin_state().await, path).await;
7978 let h = head(&body);
7979 let description = meta(h, "name=\"description\"").unwrap_or_default();
7980 assert!(!description.is_empty(), "{path} has no description: {h}");
7981 assert!(
7982 seen.insert(description.clone()),
7983 "{path} repeats another page's description: {description:?}"
7984 );
7985 assert_eq!(
7986 meta(h, "property=\"og:url\"").as_deref(),
7987 Some(format!("https://feather-reader.com{path}").as_str()),
7988 "{path}"
7989 );
7990 assert!(
7991 !h.contains("name=\"robots\""),
7992 "{path} is public and must not be noindex: {h}"
7993 );
7994 }
7995 }
7996
7997 #[tokio::test]
8000 async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8001 let landing = public_body(production_origin_state().await, "/").await;
8002 let h = head(&landing);
8003 let image = meta(h, "property=\"og:image\"").unwrap();
8004 let path = image.strip_prefix("https://feather-reader.com").unwrap();
8005 let width: u32 = meta(h, "property=\"og:image:width\"")
8006 .unwrap()
8007 .parse()
8008 .unwrap();
8009 let height: u32 = meta(h, "property=\"og:image:height\"")
8010 .unwrap()
8011 .parse()
8012 .unwrap();
8013 assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8014 assert_eq!(
8015 meta(h, "property=\"og:image:type\"").as_deref(),
8016 Some("image/png")
8017 );
8018 assert!(
8019 !meta(h, "property=\"og:image:alt\"")
8020 .unwrap_or_default()
8021 .is_empty(),
8022 "the image needs alt text"
8023 );
8024
8025 let resp = router(production_origin_state().await)
8026 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8027 .await
8028 .unwrap();
8029 assert_eq!(resp.status(), StatusCode::OK, "{path}");
8030 assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8031 assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8032 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8033 .await
8034 .expect("the image is under 1 MB");
8035 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8036 let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8038 assert_eq!(
8039 (be(16), be(20)),
8040 (width, height),
8041 "the PNG's own dimensions must match the tags"
8042 );
8043 }
8044
8045 #[tokio::test]
8048 async fn private_pages_keep_user_data_out_of_the_card() {
8049 for path in ["/", "/manage"] {
8050 let state = production_origin_state().await;
8051 let body = signed_in_body(state, path, "did:plc:admin").await;
8052 let h = head(&body);
8053 assert!(
8054 h.contains("<meta name=\"robots\" content=\"noindex\""),
8055 "{path}: a private view must be noindex: {h}"
8056 );
8057 assert_eq!(
8058 meta(h, "property=\"og:title\"").as_deref(),
8059 Some("FeatherReader — read, quietly"),
8060 "{path}: the card of a private view is the site's generic one"
8061 );
8062 assert_eq!(
8063 meta(h, "property=\"og:url\"").as_deref(),
8064 Some("https://feather-reader.com/"),
8065 "{path}: og:url of a private view is the front door, not the private path"
8066 );
8067 for private in ["reader.example", "did:plc:admin"] {
8068 assert!(
8069 !h.contains(private),
8070 "{path}: {private:?} must not reach <head>: {h}"
8071 );
8072 }
8073 }
8074 }
8075
8076 #[tokio::test]
8077 async fn beta_redeem_page_renders() {
8078 let state = test_state(&[]).await;
8079 let app = router(state);
8080 let resp = app
8081 .oneshot(
8082 Request::builder()
8083 .uri("/beta/redeem")
8084 .body(Body::empty())
8085 .unwrap(),
8086 )
8087 .await
8088 .unwrap();
8089 assert_eq!(resp.status(), StatusCode::OK);
8090 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8091 .await
8092 .unwrap();
8093 let html = String::from_utf8(bytes.to_vec()).unwrap();
8094 assert!(html.contains("Invite code"));
8095 assert!(html.contains("/beta/redeem"));
8096 }
8097
8098 #[tokio::test]
8099 async fn rate_limit_returns_429_after_burst() {
8100 let db = store::init_url("sqlite::memory:").await.unwrap();
8103 store::ensure_seed(&db, &[]).await.unwrap();
8104 let config = Config {
8105 cookie_secret: "test-cookie-secret-000".to_string(),
8106 beta_cap: 3,
8107 trusted_ip_header: Some("cf-connecting-ip".to_string()),
8108 ..Config::default()
8109 };
8110 let state = AppState::new(config, db).unwrap();
8111 let app = router(state);
8112 let mut saw_429 = false;
8116 for _ in 0..(RATE_BURST as usize + 5) {
8117 let resp = app
8118 .clone()
8119 .oneshot(
8120 Request::builder()
8121 .method("POST")
8122 .uri("/beta/redeem")
8123 .header("content-type", "application/x-www-form-urlencoded")
8124 .header("cf-connecting-ip", "203.0.113.200")
8125 .body(Body::from("code=FEATHER-NOPENOPE"))
8126 .unwrap(),
8127 )
8128 .await
8129 .unwrap();
8130 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8131 saw_429 = true;
8132 break;
8133 }
8134 }
8135 assert!(saw_429, "expected a 429 after exhausting the burst");
8136 }
8137
8138 #[tokio::test]
8151 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8152 let state = test_state(&[]).await;
8153 assert!(
8154 state.config.trusted_ip_header.is_none(),
8155 "no proxy header is trusted here"
8156 );
8157 let app = router(state);
8158 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8159 let mut saw_429 = false;
8160 for i in 0..(RATE_BURST as usize + 5) {
8161 let forged = format!("10.9.8.{}", i % 250);
8162 let resp = app
8163 .clone()
8164 .oneshot(
8165 Request::builder()
8166 .method("POST")
8167 .uri("/beta/redeem")
8168 .header("content-type", "application/x-www-form-urlencoded")
8169 .header("x-forwarded-for", forged)
8170 .extension(axum::extract::ConnectInfo(peer))
8171 .body(Body::from("code=FEATHER-NOPENOPE"))
8172 .unwrap(),
8173 )
8174 .await
8175 .unwrap();
8176 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8177 saw_429 = true;
8178 break;
8179 }
8180 }
8181 assert!(
8182 saw_429,
8183 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8184 );
8185 }
8186
8187 #[tokio::test]
8196 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8197 let did = "did:plc:privateadder";
8198 let state = test_state_with_caps(did, 0, 0).await;
8199 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8200 let port: u16 = base
8201 .trim_end_matches('/')
8202 .rsplit(':')
8203 .next()
8204 .unwrap()
8205 .parse()
8206 .unwrap();
8207 crate::net::test_host_override(
8208 "private-add.test",
8209 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8210 );
8211 let cookie = session_cookie(&state, did, None);
8212 let resp = router(state.clone())
8213 .oneshot(
8214 Request::builder()
8215 .method("POST")
8216 .uri("/subscriptions")
8217 .header(header::COOKIE, cookie)
8218 .header("content-type", "application/x-www-form-urlencoded")
8219 .body(Body::from(format!(
8220 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8221 )))
8222 .unwrap(),
8223 )
8224 .await
8225 .unwrap();
8226 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8227 let loc = resp
8228 .headers()
8229 .get(header::LOCATION)
8230 .unwrap()
8231 .to_str()
8232 .unwrap();
8233 assert!(loc.contains("Private"), "not refused as private: {loc}");
8234 assert_eq!(
8235 hits.load(std::sync::atomic::Ordering::SeqCst),
8236 0,
8237 "the private feed was FETCHED before being refused"
8238 );
8239 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8240 }
8241
8242 #[tokio::test]
8247 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8248 let did = "did:plc:renamer4";
8249 let (sidecar, bodies) = spawn_logging_sidecar().await;
8250 let state = test_state_with_sidecar(&[did], &sidecar).await;
8251 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8252 let opml = format!(
8253 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8254 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8255 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8256 </body></opml>"
8257 );
8258 let (ct, body) = opml_multipart(opml.as_bytes());
8259 let cookie = session_cookie(&state, did, None);
8260 let resp = router(state.clone())
8261 .oneshot(
8262 Request::builder()
8263 .method("POST")
8264 .uri("/opml")
8265 .header(header::COOKIE, cookie)
8266 .header("content-type", ct)
8267 .body(Body::from(body))
8268 .unwrap(),
8269 )
8270 .await
8271 .unwrap();
8272 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8273 let loc = resp
8274 .headers()
8275 .get(header::LOCATION)
8276 .unwrap()
8277 .to_str()
8278 .unwrap();
8279 assert!(
8280 loc.contains("skipped%20as%20private"),
8281 "not reported as skipped: {loc}"
8282 );
8283 assert!(store::get_feed_by_url(&state.db, tokened)
8284 .await
8285 .unwrap()
8286 .is_none());
8287 let sent = bodies.lock().unwrap().join("\n");
8288 assert!(
8289 sent.contains("public.example"),
8290 "the public feed was not written: {sent}"
8291 );
8292 assert!(
8293 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8294 "the secret was PUBLISHED to the PDS: {sent}"
8295 );
8296 }
8297
8298 #[tokio::test]
8302 async fn get_login_without_a_seat_is_refused() {
8303 let state = test_state(&[]).await;
8304 let resp = router(state)
8305 .oneshot(
8306 Request::builder()
8307 .method("GET")
8308 .uri("/login?handle=alice.bsky.social")
8309 .body(Body::empty())
8310 .unwrap(),
8311 )
8312 .await
8313 .unwrap();
8314 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8315 assert_eq!(
8316 resp.headers().get(header::LOCATION).unwrap(),
8317 "/beta/redeem"
8318 );
8319 }
8320
8321 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8325 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8326 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8327 let addr = listener.local_addr().unwrap();
8328 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8329 let sink = log.clone();
8330 tokio::spawn(async move {
8331 loop {
8332 let Ok((mut sock, _)) = listener.accept().await else {
8333 break;
8334 };
8335 let mut raw: Vec<u8> = Vec::new();
8336 let mut chunk = [0u8; 4096];
8337 let text = loop {
8338 let Ok(n) = sock.read(&mut chunk).await else {
8339 break String::new();
8340 };
8341 if n == 0 {
8342 break String::from_utf8_lossy(&raw).to_string();
8343 }
8344 raw.extend_from_slice(&chunk[..n]);
8345 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8346 continue;
8347 };
8348 let (head, body) = raw.split_at(split + 4);
8349 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8350 let (k, v) = l.split_once(':')?;
8351 k.eq_ignore_ascii_case("content-length")
8352 .then(|| v.trim().parse::<usize>().ok())?
8353 });
8354 if want.is_none_or(|w| body.len() >= w) {
8355 break String::from_utf8_lossy(&raw).to_string();
8356 }
8357 };
8358 let path = text
8359 .lines()
8360 .next()
8361 .and_then(|l| l.split_whitespace().nth(1))
8362 .unwrap_or("")
8363 .to_string();
8364 let body_text = text
8365 .split_once("\r\n\r\n")
8366 .map(|(_, b)| b)
8367 .unwrap_or("")
8368 .to_string();
8369 sink.lock().unwrap().push(format!("{path} {body_text}"));
8370 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();
8371 let resp = format!(
8372 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8373 body.len(),
8374 body
8375 );
8376 let _ = sock.write_all(resp.as_bytes()).await;
8377 let _ = sock.flush().await;
8378 }
8379 });
8380 (format!("http://{addr}"), log)
8381 }
8382
8383 #[tokio::test]
8392 async fn signing_out_flushes_before_it_revokes_through_the_route() {
8393 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8394 let (sidecar, log) = spawn_logging_sidecar().await;
8395 let state = test_state_with_sidecar(&[did], &sidecar).await;
8396 crate::store::upsert_cursor(
8397 &state.db,
8398 &crate::store::ReadCursor {
8399 did: did.to_string(),
8400 feed_url: "https://example.com/feed.xml".into(),
8401 read_through: None,
8402 read_ids: "[\"1\"]".into(),
8403 unread_ids: "[]".into(),
8404 dirty: true,
8405 pds_created: false,
8406 updated_at: "2026-09-13T21:22:40Z".into(),
8407 },
8408 )
8409 .await
8410 .unwrap();
8411 let cookie = session_cookie(&state, did, None);
8412 let resp = router(state.clone())
8413 .oneshot(
8414 Request::builder()
8415 .method("POST")
8416 .uri("/logout")
8417 .header(header::COOKIE, cookie)
8418 .body(Body::empty())
8419 .unwrap(),
8420 )
8421 .await
8422 .unwrap();
8423 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8424
8425 let entries = log.lock().unwrap().clone();
8426 let flush = entries
8427 .iter()
8428 .position(|e| e.starts_with("/internal/repo "));
8429 let revoke = entries
8430 .iter()
8431 .position(|e| e.starts_with("/internal/revoke "));
8432 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8433 assert!(
8434 flush.is_some(),
8435 "sign-out did not attempt a flush before revoking: {entries:?}"
8436 );
8437 assert!(
8438 flush < revoke,
8439 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8440 );
8441 }
8442
8443 const EXPECTED_CSP: &str = "default-src 'self'; \
8447 script-src 'self'; \
8448 style-src 'self' 'unsafe-inline'; \
8449 img-src 'self' https: data:; \
8450 font-src 'self'; \
8451 connect-src 'self'; \
8452 form-action 'self'; \
8453 base-uri 'self'; \
8454 frame-ancestors 'none'; \
8455 object-src 'none'";
8456
8457 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8460 let boundary = "----featherreadertestboundary";
8461 let mut body = Vec::new();
8462 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8463 body.extend_from_slice(
8464 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8465 );
8466 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8467 body.extend_from_slice(payload);
8468 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8469 (format!("multipart/form-data; boundary={boundary}"), body)
8470 }
8471
8472 #[tokio::test]
8473 async fn opml_import_oversize_upload_returns_413() {
8474 let state = test_state(&["did:plc:admin"]).await;
8475 let cookie = session_cookie(&state, "did:plc:admin", None);
8476 let app = router(state);
8477
8478 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8480 let (content_type, body) = opml_multipart(&payload);
8481
8482 let resp = app
8483 .oneshot(
8484 Request::builder()
8485 .method("POST")
8486 .uri("/opml")
8487 .header("content-type", content_type)
8488 .header(header::COOKIE, cookie)
8489 .body(Body::from(body))
8490 .unwrap(),
8491 )
8492 .await
8493 .unwrap();
8494 assert_eq!(
8495 resp.status(),
8496 StatusCode::PAYLOAD_TOO_LARGE,
8497 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8498 );
8499 }
8500
8501 #[tokio::test]
8512 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8513 let state = test_state(&["did:plc:admin"]).await;
8514 let cookie = session_cookie(&state, "did:plc:admin", None);
8515 let app = router(state);
8516
8517 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8519 let (content_type, body) = opml_multipart(&payload);
8520
8521 let resp = app
8522 .oneshot(
8523 Request::builder()
8524 .method("POST")
8525 .uri("/opml")
8526 .header("content-type", content_type)
8527 .header(header::COOKIE, cookie)
8528 .body(Body::from(body))
8529 .unwrap(),
8530 )
8531 .await
8532 .unwrap();
8533 assert_eq!(
8534 resp.status(),
8535 StatusCode::PAYLOAD_TOO_LARGE,
8536 "a payload over the route's cap but under the framework's was accepted — \
8537 the route's own DefaultBodyLimit layer is not doing anything"
8538 );
8539 }
8540
8541 #[tokio::test]
8542 async fn opml_import_under_limit_upload_is_accepted() {
8543 let state = test_state(&["did:plc:admin"]).await;
8544 let cookie = session_cookie(&state, "did:plc:admin", None);
8545 let db = state.db.clone();
8546 let app = router(state);
8547
8548 let opml = br#"<?xml version="1.0"?>
8551<opml version="2.0"><body>
8552 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8553</body></opml>"#;
8554 let (content_type, body) = opml_multipart(opml);
8555
8556 let resp = app
8557 .oneshot(
8558 Request::builder()
8559 .method("POST")
8560 .uri("/opml")
8561 .header("content-type", content_type)
8562 .header(header::COOKIE, cookie)
8563 .body(Body::from(body))
8564 .unwrap(),
8565 )
8566 .await
8567 .unwrap();
8568 assert_eq!(
8575 resp.status(),
8576 StatusCode::SEE_OTHER,
8577 "an under-cap OPML upload was not accepted (status {})",
8578 resp.status(),
8579 );
8580 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8584 .bind("https://example.com/feed.xml")
8585 .fetch_one(&db)
8586 .await
8587 .unwrap();
8588 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8589 let location = resp
8590 .headers()
8591 .get(header::LOCATION)
8592 .and_then(|v| v.to_str().ok())
8593 .unwrap_or_default()
8594 .to_string();
8595 assert!(
8596 !location.starts_with("/login"),
8597 "the import bounced to login instead of being accepted: {location}",
8598 );
8599 }
8600
8601 #[tokio::test]
8602 async fn opml_import_logged_out_redirects_to_login() {
8603 let state = test_state(&["did:plc:admin"]).await;
8606 let app = router(state);
8607
8608 let opml = b"<opml version=\"2.0\"><body></body></opml>";
8609 let (content_type, body) = opml_multipart(opml);
8610
8611 let resp = app
8612 .oneshot(
8613 Request::builder()
8614 .method("POST")
8615 .uri("/opml")
8616 .header("content-type", content_type)
8617 .body(Body::from(body))
8618 .unwrap(),
8619 )
8620 .await
8621 .unwrap();
8622 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8623 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8624 }
8625
8626 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8633 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8634 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8635 let addr = listener.local_addr().unwrap();
8636 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8637 tokio::spawn(async move {
8638 let (mut sock, _) = listener.accept().await.unwrap();
8639 let mut buf = vec![0u8; 4096];
8640 let n = sock.read(&mut buf).await.unwrap();
8641 let req = String::from_utf8_lossy(&buf[..n]).to_string();
8642 let did = req
8644 .split("\r\n\r\n")
8645 .nth(1)
8646 .and_then(|body| {
8647 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8648 v.get("did")?.as_str().map(str::to_string)
8649 })
8650 .unwrap_or_default();
8651 let is_revoke = req.starts_with("POST /internal/revoke");
8652 let body = serde_json::json!({
8653 "ok": true, "did": did, "revoked": true, "hadSession": true
8654 })
8655 .to_string();
8656 let resp = format!(
8657 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8658 body.len(),
8659 body
8660 );
8661 sock.write_all(resp.as_bytes()).await.unwrap();
8662 sock.flush().await.unwrap();
8663 let _ = tx.send(if is_revoke { did } else { String::new() });
8664 });
8665 (format!("http://{addr}"), rx)
8666 }
8667
8668 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8670 let defaults = Config::default();
8671 test_state_with_sidecar_and(
8672 allowed,
8673 sidecar_url,
8674 defaults.standard_site,
8675 defaults.max_feeds_global,
8676 )
8677 .await
8678 }
8679
8680 async fn test_state_with_sidecar_and(
8683 allowed: &[&str],
8684 sidecar_url: &str,
8685 standard_site: bool,
8686 max_feeds_global: i64,
8687 ) -> AppState {
8688 let db = store::init_url("sqlite::memory:").await.unwrap();
8689 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8690 store::ensure_seed(&db, &dids).await.unwrap();
8691 let mut config = Config {
8692 allowed_dids: dids,
8693 cookie_secret: "test-cookie-secret-000".to_string(),
8694 beta_cap: 3,
8695 standard_site,
8696 max_feeds_global,
8697 ..Config::default()
8698 };
8699 config.sidecar.public_url = sidecar_url.to_string();
8700 config.sidecar.internal_url = sidecar_url.to_string();
8701 AppState::new(config, db).unwrap()
8702 }
8703
8704 #[tokio::test]
8707 async fn account_delete_purges_rows_and_triggers_revoke() {
8708 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8709 let did = "did:plc:leaver";
8710 let state = test_state_with_sidecar(&[], &sidecar_url).await;
8711
8712 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8714 .await
8715 .unwrap();
8716 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8717 store::mint_code(&state.db, did, 3600).await.unwrap();
8718 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8719
8720 let cookie = session_cookie(&state, did, Some("leaver.example"));
8721 let app = router(state.clone());
8722
8723 let resp = app
8724 .oneshot(
8725 Request::builder()
8726 .method("POST")
8727 .uri("/account/delete")
8728 .header(header::COOKIE, cookie)
8729 .header("content-type", "application/x-www-form-urlencoded")
8730 .body(Body::from("confirm=DELETE"))
8731 .unwrap(),
8732 )
8733 .await
8734 .unwrap();
8735
8736 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8738 assert!(resp
8739 .headers()
8740 .get(header::LOCATION)
8741 .unwrap()
8742 .to_str()
8743 .unwrap()
8744 .starts_with("/login"));
8745 let set_cookie = resp
8746 .headers()
8747 .get(header::SET_COOKIE)
8748 .unwrap()
8749 .to_str()
8750 .unwrap();
8751 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8752
8753 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8760 .await
8761 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8762 .unwrap();
8763 assert_eq!(
8764 revoked_did, did,
8765 "sidecar revoke must fire for the caller DID"
8766 );
8767
8768 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8770 let codes: i64 =
8771 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8772 .bind(did)
8773 .fetch_one(&state.db)
8774 .await
8775 .unwrap();
8776 assert_eq!(codes, 0);
8777 }
8778
8779 #[tokio::test]
8782 async fn account_delete_without_confirm_is_a_noop() {
8783 let did = "did:plc:staying";
8784 let state = test_state(&[]).await;
8785 store::grant_access(&state.db, did, None, "test", None)
8786 .await
8787 .unwrap();
8788 let cookie = session_cookie(&state, did, None);
8789 let app = router(state.clone());
8790
8791 let resp = app
8792 .oneshot(
8793 Request::builder()
8794 .method("POST")
8795 .uri("/account/delete")
8796 .header(header::COOKIE, cookie)
8797 .header("content-type", "application/x-www-form-urlencoded")
8798 .body(Body::from("confirm=nope"))
8799 .unwrap(),
8800 )
8801 .await
8802 .unwrap();
8803
8804 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8805 assert!(resp
8806 .headers()
8807 .get(header::LOCATION)
8808 .unwrap()
8809 .to_str()
8810 .unwrap()
8811 .starts_with("/manage"));
8812 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8814 }
8815
8816 #[tokio::test]
8823 async fn pds_outage_does_not_widen_cross_did_access() {
8824 let did_a = "did:plc:aaaa";
8825 let state = test_state(&[]).await;
8826 store::grant_access(&state.db, did_a, None, "test", None)
8827 .await
8828 .unwrap();
8829
8830 let feed_a = store::upsert_feed(
8833 &state.db,
8834 &store::NewFeed {
8835 url: "https://a.example/feed.xml".to_string(),
8836 title: Some("A".to_string()),
8837 ..Default::default()
8838 },
8839 )
8840 .await
8841 .unwrap();
8842 let feed_b = store::upsert_feed(
8843 &state.db,
8844 &store::NewFeed {
8845 url: "https://b.example/feed.xml".to_string(),
8846 title: Some("B".to_string()),
8847 ..Default::default()
8848 },
8849 )
8850 .await
8851 .unwrap();
8852 store::insert_entries(
8853 &state.db,
8854 feed_b,
8855 &[store::NewEntry {
8856 guid: "b-1".to_string(),
8857 url: Some("https://b.example/1".to_string()),
8858 title: Some("B one".to_string()),
8859 published: Some("2026-07-11T00:00:00Z".to_string()),
8860 content_html: Some("<p>secret B body</p>".to_string()),
8861 ..Default::default()
8862 }],
8863 0,
8864 )
8865 .await
8866 .unwrap();
8867 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8869 .await
8870 .unwrap();
8871 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8874 .await
8875 .unwrap();
8876 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8877 .await
8878 .unwrap()[0]
8879 .id;
8880 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8881 .await
8882 .unwrap();
8883
8884 let cookie = session_cookie(&state, did_a, None);
8885 let app = router(state.clone());
8886
8887 let get_b = app
8889 .clone()
8890 .oneshot(
8891 Request::builder()
8892 .method("GET")
8893 .uri(format!("/entries/{b_entry_id}"))
8894 .header(header::COOKIE, cookie.clone())
8895 .body(Body::empty())
8896 .unwrap(),
8897 )
8898 .await
8899 .unwrap();
8900 assert_eq!(
8901 get_b.status(),
8902 StatusCode::NOT_FOUND,
8903 "A must not read B's entry during a PDS outage"
8904 );
8905
8906 let read_b = app
8908 .oneshot(
8909 Request::builder()
8910 .method("POST")
8911 .uri(format!("/entries/{b_entry_id}/read"))
8912 .header(header::COOKIE, cookie)
8913 .header("content-type", "application/x-www-form-urlencoded")
8914 .body(Body::from("read=true"))
8915 .unwrap(),
8916 )
8917 .await
8918 .unwrap();
8919 assert_eq!(
8920 read_b.status(),
8921 StatusCode::NOT_FOUND,
8922 "A must not mark B's entry read during a PDS outage"
8923 );
8924
8925 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8927 .bind(did_a)
8928 .fetch_all(&state.db)
8929 .await
8930 .unwrap();
8931 assert_eq!(
8932 a_feed_ids,
8933 vec![feed_a],
8934 "outage fallback must not add feeds A never subscribed to"
8935 );
8936 let es_count: i64 =
8938 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
8939 .bind(did_a)
8940 .bind(b_entry_id)
8941 .fetch_one(&state.db)
8942 .await
8943 .unwrap();
8944 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
8945 }
8946
8947 #[tokio::test]
8961 async fn a_logout_with_no_session_counts_as_success() {
8962 let did = "did:plc:aaaa";
8963 let state = test_state(&[]).await;
8964 assert!(
8965 state.oauth.is_some(),
8966 "meaningless without an oauth runtime; the revoke arm would be skipped",
8967 );
8968
8969 revoke_everywhere(&state, did).await;
8970 let rows = state.metrics.snapshot();
8971 let find = |b: crate::metrics::Backend| {
8972 rows.iter()
8973 .find(|r| r.op == "oauth_revoke" && r.backend == b)
8974 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
8975 };
8976
8977 let rust = find(crate::metrics::Backend::Rust);
8979 assert_eq!(
8980 rust.stats.err_count, 0,
8981 "NoSession was counted as a failure; logout is idempotent",
8982 );
8983 assert_eq!(rust.stats.ok_count, 1);
8984
8985 let sidecar = find(crate::metrics::Backend::Sidecar);
8989 assert_eq!(
8990 sidecar.stats.err_count, 1,
8991 "a failed sidecar revoke was not counted",
8992 );
8993 }
8994
8995 #[tokio::test]
9005 async fn a_failed_rust_revoke_counts_as_an_error() {
9006 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9007 let state = test_state(&[]).await;
9008 let runtime = state.oauth.as_deref().expect("oauth runtime");
9009 crate::oauth::store::put_session(
9010 &state.db,
9011 &runtime.codec,
9012 &crate::oauth::store::OAuthSession {
9013 sub: did.into(),
9014 issuer: "https://auth.invalid".into(),
9015 aud: "https://pds.invalid".into(),
9016 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9017 .to_jwk_json()
9018 .unwrap(),
9019 access_token: "at".into(),
9020 refresh_token: "rt".into(),
9021 token_type: "DPoP".into(),
9022 granted_scope: "atproto".into(),
9023 expires_at: Some(crate::store::now_unix() + 3600),
9024 },
9025 )
9026 .await
9027 .unwrap();
9028
9029 revoke_everywhere(&state, did).await;
9030
9031 let rows = state.metrics.snapshot();
9032 let rust = rows
9033 .iter()
9034 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9035 .expect("no rust oauth_revoke row");
9036 assert_eq!(
9037 rust.stats.err_count, 1,
9038 "an unreachable PDS must count as a revocation failure",
9039 );
9040 assert_eq!(rust.stats.ok_count, 0);
9041 }
9042
9043 #[test]
9059 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9060 for hostile in [
9061 "javascript:alert(1)",
9062 "JavaScript:alert(1)",
9063 " javascript:alert(1)",
9064 "data:text/html;base64,PHNjcmlwdD4=",
9065 "vbscript:msgbox(1)",
9066 "file:///etc/passwd",
9067 "//evil.example/path",
9071 ] {
9072 let link = SafeLink::external(hostile);
9073 assert!(
9074 link.is_empty(),
9075 "{hostile:?} produced a non-empty href: {link}",
9076 );
9077 assert!(
9078 !link.to_string().to_ascii_lowercase().contains("script"),
9079 "{hostile:?} leaked into the rendered link",
9080 );
9081 }
9082
9083 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9086 let link = SafeLink::external(good);
9087 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9088 assert_eq!(link.to_string(), good);
9089 }
9090 }
9091
9092 #[tokio::test]
9107 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9108 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9109 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9110 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9111 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9112
9113 let resp = router(state)
9114 .oneshot(
9115 Request::builder()
9116 .uri("/?view=starred")
9117 .body(Body::empty())
9118 .unwrap(),
9119 )
9120 .await
9121 .unwrap();
9122 assert_eq!(resp.status(), StatusCode::OK);
9123 let body = String::from_utf8(
9124 axum::body::to_bytes(resp.into_body(), usize::MAX)
9125 .await
9126 .unwrap()
9127 .to_vec(),
9128 )
9129 .unwrap();
9130
9131 assert!(
9134 !body.to_ascii_lowercase().contains("javascript:"),
9135 "the hostile scheme reached the rendered page",
9136 );
9137 assert!(
9140 body.contains("unusable link"),
9141 "the row was dropped instead of rendering without an anchor",
9142 );
9143 }
9144
9145 #[tokio::test]
9162 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9163 let did = "did:plc:readerhref";
9164 let state = test_state(&[]).await;
9165 store::grant_access(&state.db, did, None, "test", None)
9166 .await
9167 .unwrap();
9168 let feed = store::upsert_feed(
9169 &state.db,
9170 &store::NewFeed {
9171 url: "https://href.example/feed.xml".to_string(),
9172 title: Some("Href".to_string()),
9173 ..Default::default()
9174 },
9175 )
9176 .await
9177 .unwrap();
9178 store::insert_entries(
9180 &state.db,
9181 feed,
9182 &[
9183 store::NewEntry {
9184 guid: "hostile-1".to_string(),
9185 url: Some("javascript:alert(1)".to_string()),
9186 title: Some("Hostile entry".to_string()),
9187 published: Some("2026-07-11T00:00:00Z".to_string()),
9188 ..Default::default()
9189 },
9190 store::NewEntry {
9191 guid: "benign-1".to_string(),
9192 url: Some("https://href.example/post".to_string()),
9193 title: Some("Benign entry".to_string()),
9194 published: Some("2026-07-10T00:00:00Z".to_string()),
9195 ..Default::default()
9196 },
9197 ],
9198 0,
9199 )
9200 .await
9201 .unwrap();
9202 store::replace_sub_refs(&state.db, did, &[feed])
9203 .await
9204 .unwrap();
9205 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9206 let id_of = |guid: &str| {
9207 rows.iter()
9208 .find(|r| r.guid == guid)
9209 .unwrap_or_else(|| panic!("{guid} was not inserted"))
9210 .id
9211 };
9212
9213 let cookie = session_cookie(&state, did, None);
9214 let app = router(state.clone());
9215
9216 let render = |id: i64| {
9217 let app = app.clone();
9218 let cookie = cookie.clone();
9219 async move {
9220 let resp = app
9221 .oneshot(
9222 Request::builder()
9223 .method("GET")
9224 .uri(format!("/entries/{id}"))
9225 .header(header::COOKIE, cookie)
9226 .body(Body::empty())
9227 .unwrap(),
9228 )
9229 .await
9230 .unwrap();
9231 assert_eq!(resp.status(), StatusCode::OK);
9232 String::from_utf8(
9233 axum::body::to_bytes(resp.into_body(), usize::MAX)
9234 .await
9235 .unwrap()
9236 .to_vec(),
9237 )
9238 .unwrap()
9239 }
9240 };
9241
9242 let hostile = render(id_of("hostile-1")).await;
9243 assert!(
9246 hostile.contains("Hostile entry"),
9247 "the reader did not render the entry: {hostile}",
9248 );
9249 assert!(
9250 !hostile.to_ascii_lowercase().contains("javascript:"),
9251 "the hostile scheme reached the reader page: {hostile}",
9252 );
9253 assert!(
9257 !hostile.contains("actionbar-open"),
9258 "the action bar rendered an open-original link for a refused URL: {hostile}",
9259 );
9260 assert!(
9261 !hostile.contains("Original \u{2197}"),
9262 "the byline rendered an original link for a refused URL: {hostile}",
9263 );
9264
9265 let benign = render(id_of("benign-1")).await;
9268 assert!(
9269 benign.contains("Benign entry"),
9270 "the reader did not render the benign entry: {benign}",
9271 );
9272 assert_eq!(
9276 benign
9277 .matches(r#"href="https://href.example/post""#)
9278 .count(),
9279 2,
9280 "entry.html has two `href`s for the entry URL — the byline link and \
9281 the action-bar button — and this render produced a different \
9282 number: {benign}",
9283 );
9284 assert!(
9285 benign.contains("actionbar-open"),
9286 "a legitimate entry lost its open-original button: {benign}",
9287 );
9288 assert!(
9289 benign.contains("Original \u{2197}"),
9290 "a legitimate entry lost its byline link: {benign}",
9291 );
9292 }
9293
9294 #[tokio::test]
9314 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9315 let did_a = "did:plc:aaaa";
9316 let state = test_state(&[]).await;
9317 store::grant_access(&state.db, did_a, None, "test", None)
9318 .await
9319 .unwrap();
9320
9321 let feed_a = store::upsert_feed(
9322 &state.db,
9323 &store::NewFeed {
9324 url: "https://a.example/feed.xml".to_string(),
9325 title: Some("A".to_string()),
9326 ..Default::default()
9327 },
9328 )
9329 .await
9330 .unwrap();
9331 let _feed_b = store::upsert_feed(
9332 &state.db,
9333 &store::NewFeed {
9334 url: "https://b.example/feed.xml".to_string(),
9335 title: Some("B".to_string()),
9336 ..Default::default()
9337 },
9338 )
9339 .await
9340 .unwrap();
9341 store::replace_sub_refs(&state.db, did_a, &[feed_a])
9344 .await
9345 .unwrap();
9346
9347 assert!(
9352 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9353 "this test is only meaningful on the outage path; the repo answered",
9354 );
9355
9356 let resolved = resolve_subscriptions(&state, did_a).await;
9357
9358 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9359 assert_eq!(
9360 urls,
9361 vec!["https://a.example/feed.xml"],
9362 "the outage fallback must return the caller's OWN subscriptions only; \
9363 any other feed here is cross-tenant read access granted by an outage",
9364 );
9365 }
9366
9367 async fn test_state_with_caps(
9370 did: &str,
9371 max_subs_per_did: i64,
9372 max_feeds_global: i64,
9373 ) -> AppState {
9374 let db = store::init_url("sqlite::memory:").await.unwrap();
9375 let config = Config {
9376 cookie_secret: "test-cookie-secret-000".to_string(),
9377 beta_cap: 100,
9378 max_subs_per_did,
9379 max_feeds_global,
9380 ..Config::default()
9381 };
9382 store::grant_access(&db, did, None, "test", None)
9383 .await
9384 .unwrap();
9385 AppState::new(config, db).unwrap()
9386 }
9387
9388 fn opml_with_feeds(n: usize) -> String {
9390 let mut outlines = String::new();
9391 for i in 0..n {
9392 outlines.push_str(&format!(
9393 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9394 ));
9395 }
9396 format!(
9397 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9398 )
9399 }
9400
9401 #[tokio::test]
9406 async fn opml_import_enforces_global_feeds_ceiling() {
9407 let did = "did:plc:importer";
9408 let state = test_state_with_caps(did, 0, 3).await;
9410 let cookie = session_cookie(&state, did, None);
9411 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9412 let app = router(state.clone());
9413
9414 let resp = app
9415 .oneshot(
9416 Request::builder()
9417 .method("POST")
9418 .uri("/opml")
9419 .header(header::COOKIE, cookie)
9420 .header("content-type", ct)
9421 .body(Body::from(body))
9422 .unwrap(),
9423 )
9424 .await
9425 .unwrap();
9426 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9427
9428 let feeds = store::count_feeds(&state.db).await.unwrap();
9429 assert!(
9430 feeds <= 3,
9431 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9432 );
9433 }
9434
9435 #[tokio::test]
9444 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9445 let did = "did:plc:typoist";
9446 let state = test_state_with_caps(did, 0, 0).await;
9447 let cookie = session_cookie(&state, did, None);
9448 for input in [
9449 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9450 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9451 ] {
9452 let resp = router(state.clone())
9453 .oneshot(
9454 Request::builder()
9455 .method("POST")
9456 .uri("/subscriptions")
9457 .header(header::COOKIE, cookie.clone())
9458 .header("content-type", "application/x-www-form-urlencoded")
9459 .body(Body::from(format!("url={input}")))
9460 .unwrap(),
9461 )
9462 .await
9463 .unwrap();
9464 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9465 let loc = resp
9466 .headers()
9467 .get(header::LOCATION)
9468 .unwrap()
9469 .to_str()
9470 .unwrap();
9471 assert!(
9472 loc.contains("kind%20of%20feed"),
9473 "expected the unsupported-feed flash for {input}, got {loc}"
9474 );
9475 assert!(
9476 !loc.contains("Private"),
9477 "a storability refusal was reported as a privacy one for {input}: {loc}"
9478 );
9479 }
9480 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9481 }
9482
9483 #[tokio::test]
9490 async fn opml_import_reports_entries_this_instance_cannot_store() {
9491 let did = "did:plc:renamer4";
9492 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9493 let state = test_state_with_sidecar(&[did], &sidecar).await;
9494 assert!(!state.config.standard_site);
9495 let opml = format!(
9496 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9497 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9498 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9499 </body></opml>"
9500 );
9501 let (ct, body) = opml_multipart(opml.as_bytes());
9502 let cookie = session_cookie(&state, did, None);
9503 let resp = router(state.clone())
9504 .oneshot(
9505 Request::builder()
9506 .method("POST")
9507 .uri("/opml")
9508 .header(header::COOKIE, cookie)
9509 .header("content-type", ct)
9510 .body(Body::from(body))
9511 .unwrap(),
9512 )
9513 .await
9514 .unwrap();
9515 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9516 let loc = resp
9517 .headers()
9518 .get(header::LOCATION)
9519 .unwrap()
9520 .to_str()
9521 .unwrap();
9522 assert!(
9523 loc.contains("Imported%201%20feed"),
9524 "unexpected flash: {loc}"
9525 );
9526 assert!(
9527 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9528 "the dropped entry was not reported: {loc}"
9529 );
9530 assert!(
9532 !loc.contains("site.standard.publication"),
9533 "the URI was echoed: {loc}"
9534 );
9535 }
9536
9537 #[tokio::test]
9540 async fn opml_import_enforces_per_did_cap() {
9541 let did = "did:plc:capped";
9542 let state = test_state_with_caps(did, 2, 0).await;
9544 let existing_a = store::upsert_feed(
9545 &state.db,
9546 &store::NewFeed {
9547 url: "https://have-a.example/feed.xml".to_string(),
9548 ..Default::default()
9549 },
9550 )
9551 .await
9552 .unwrap();
9553 let existing_b = store::upsert_feed(
9554 &state.db,
9555 &store::NewFeed {
9556 url: "https://have-b.example/feed.xml".to_string(),
9557 ..Default::default()
9558 },
9559 )
9560 .await
9561 .unwrap();
9562 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9563 .await
9564 .unwrap();
9565 let before = store::count_feeds(&state.db).await.unwrap();
9566
9567 let cookie = session_cookie(&state, did, None);
9568 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9569 let app = router(state.clone());
9570 let resp = app
9571 .oneshot(
9572 Request::builder()
9573 .method("POST")
9574 .uri("/opml")
9575 .header(header::COOKIE, cookie)
9576 .header("content-type", ct)
9577 .body(Body::from(body))
9578 .unwrap(),
9579 )
9580 .await
9581 .unwrap();
9582 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9583 let after = store::count_feeds(&state.db).await.unwrap();
9585 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9586 }
9587
9588 #[tokio::test]
9591 async fn single_add_enforces_per_did_cap() {
9592 let did = "did:plc:subcapped";
9593 let state = test_state_with_caps(did, 1, 0).await;
9594 let f = store::upsert_feed(
9595 &state.db,
9596 &store::NewFeed {
9597 url: "https://have.example/feed.xml".to_string(),
9598 ..Default::default()
9599 },
9600 )
9601 .await
9602 .unwrap();
9603 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9604 let cookie = session_cookie(&state, did, None);
9605 let app = router(state.clone());
9606 let resp = app
9607 .oneshot(
9608 Request::builder()
9609 .method("POST")
9610 .uri("/subscriptions")
9611 .header(header::COOKIE, cookie)
9612 .header("content-type", "application/x-www-form-urlencoded")
9613 .body(Body::from("url=https://another.example/feed.xml"))
9614 .unwrap(),
9615 )
9616 .await
9617 .unwrap();
9618 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9619 let loc = resp
9620 .headers()
9621 .get(header::LOCATION)
9622 .unwrap()
9623 .to_str()
9624 .unwrap();
9625 assert!(
9626 loc.contains("Subscription%20limit%20reached"),
9627 "expected sub-limit flash, got {loc}"
9628 );
9629 }
9630
9631 #[tokio::test]
9640 async fn the_reader_index_pages_instead_of_rendering_everything() {
9641 let did = "did:plc:pager";
9642 let state = test_state(&[]).await;
9643 store::grant_access(&state.db, did, None, "test", None)
9644 .await
9645 .unwrap();
9646 let feed = store::upsert_feed(
9647 &state.db,
9648 &store::NewFeed {
9649 url: "https://pager.example/feed.xml".to_string(),
9650 title: Some("Pager".to_string()),
9651 ..Default::default()
9652 },
9653 )
9654 .await
9655 .unwrap();
9656 let total = 250_usize;
9657 let entries: Vec<store::NewEntry> = (0..total)
9658 .map(|i| store::NewEntry {
9659 guid: format!("p-{i:04}"),
9660 url: Some(format!("https://pager.example/{i}")),
9661 title: Some(format!("Article {i:04}")),
9662 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9663 content_html: Some("x".repeat(4_000)),
9664 ..Default::default()
9665 })
9666 .collect();
9667 store::insert_entries(&state.db, feed, &entries, 0)
9668 .await
9669 .unwrap();
9670 store::replace_sub_refs(&state.db, did, &[feed])
9671 .await
9672 .unwrap();
9673
9674 let cookie = session_cookie(&state, did, None);
9675 let app = router(state.clone());
9676 let get = |uri: &str| {
9677 let app = app.clone();
9678 let cookie = cookie.clone();
9679 let uri = uri.to_string();
9680 async move {
9681 let resp = app
9682 .oneshot(
9683 Request::builder()
9684 .uri(uri)
9685 .header(header::COOKIE, cookie)
9686 .body(Body::empty())
9687 .unwrap(),
9688 )
9689 .await
9690 .unwrap();
9691 assert_eq!(resp.status(), StatusCode::OK);
9692 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9693 .await
9694 .unwrap();
9695 String::from_utf8(bytes.to_vec()).unwrap()
9696 }
9697 };
9698
9699 let page1 = get("/").await;
9700 let rows1 = page1.matches("<li class=\"entry").count();
9704 assert!(
9705 rows1 <= ENTRIES_PER_PAGE as usize,
9706 "page 1 rendered {rows1} entry links; the list is unbounded"
9707 );
9708 assert!(
9709 rows1 > 0,
9710 "page 1 rendered nothing at all: the page bound swallowed the list"
9711 );
9712 assert!(
9715 page1.contains("250 entries"),
9716 "heading must report the full total, not the page"
9717 );
9718 assert!(
9719 page1.contains("page=2"),
9720 "no way to reach the rest of the list: {}",
9721 &page1[..page1.len().min(400)]
9722 );
9723 assert!(
9725 !page1.contains(&"x".repeat(4_000)),
9726 "the list response carried an article body"
9727 );
9728
9729 let page2 = get("/?page=2").await;
9730 assert!(
9731 page2.matches("<li class=\"entry").count() > 0,
9732 "page 2 rendered no rows at all"
9733 );
9734 assert!(
9735 page2.contains("page=1") || page2.contains("Newer"),
9736 "page 2 offers no way back"
9737 );
9738 let first_title = (0..total)
9740 .map(|i| format!("Article {i:04}"))
9741 .find(|t| page1.contains(t))
9742 .expect("page 1 shows at least one titled article");
9743 assert!(
9744 !page2.contains(&first_title),
9745 "{first_title} appears on both pages"
9746 );
9747
9748 let past_end = get("/?page=999").await;
9754 assert!(
9755 past_end.matches("<li class=\"entry").count() > 0,
9756 "an out-of-range page rendered nothing and offered no way back"
9757 );
9758 assert!(
9759 past_end.contains("page=2"),
9760 "the clamped page offers no pager"
9761 );
9762 }
9763
9764 #[tokio::test]
9771 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9772 let did = "did:plc:reader";
9773 let state = test_state(&[]).await;
9774 store::grant_access(&state.db, did, None, "test", None)
9775 .await
9776 .unwrap();
9777 let feed = store::upsert_feed(
9778 &state.db,
9779 &store::NewFeed {
9780 url: "https://reader.example/feed.xml".to_string(),
9781 title: Some("Reader".to_string()),
9782 ..Default::default()
9783 },
9784 )
9785 .await
9786 .unwrap();
9787 store::insert_entries(
9788 &state.db,
9789 feed,
9790 &[store::NewEntry {
9791 guid: "r-1".to_string(),
9792 url: Some("https://reader.example/1".to_string()),
9793 title: Some("Article".to_string()),
9794 published: Some("2026-07-11T00:00:00Z".to_string()),
9795 content_html: Some("<p>body</p>".to_string()),
9796 ..Default::default()
9797 }],
9798 0,
9799 )
9800 .await
9801 .unwrap();
9802 store::replace_sub_refs(&state.db, did, &[feed])
9803 .await
9804 .unwrap();
9805 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9806
9807 let cookie = session_cookie(&state, did, None);
9808 let app = router(state.clone());
9809
9810 let resp = app
9812 .clone()
9813 .oneshot(
9814 Request::builder()
9815 .method("POST")
9816 .uri(format!("/entries/{entry_id}/read"))
9817 .header(header::COOKIE, cookie.clone())
9818 .header("HX-Request", "true")
9819 .header("X-FR-Reader", "1")
9820 .header("content-type", "application/x-www-form-urlencoded")
9821 .body(Body::from("read=true"))
9822 .unwrap(),
9823 )
9824 .await
9825 .unwrap();
9826 assert_eq!(resp.status(), StatusCode::OK);
9827 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9828 .await
9829 .unwrap();
9830 let html = String::from_utf8(bytes.to_vec()).unwrap();
9831 assert!(
9832 html.contains("hx-swap-oob=\"outerHTML\""),
9833 "reader response must be an OOB swap: {html}"
9834 );
9835 assert!(
9836 html.contains(r#"id="entry-actionbar""#),
9837 "reader response must be the action-bar fragment: {html}"
9838 );
9839 assert!(
9842 html.contains(r#"aria-pressed="true""#),
9843 "read button must show pressed after marking read: {html}"
9844 );
9845 assert!(
9846 html.contains(r#"name="read" value="false""#),
9847 "hidden read value must flip to false so a second tap reverses: {html}"
9848 );
9849
9850 let resp2 = app
9853 .oneshot(
9854 Request::builder()
9855 .method("POST")
9856 .uri(format!("/entries/{entry_id}/read"))
9857 .header(header::COOKIE, cookie)
9858 .header("HX-Request", "true")
9859 .header("X-FR-Reader", "1")
9860 .header("content-type", "application/x-www-form-urlencoded")
9861 .body(Body::from("read=false"))
9862 .unwrap(),
9863 )
9864 .await
9865 .unwrap();
9866 assert_eq!(resp2.status(), StatusCode::OK);
9867 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
9868 .await
9869 .unwrap();
9870 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
9871 assert!(
9872 html2.contains(r#"aria-pressed="false""#),
9873 "read button must show un-pressed after reversing: {html2}"
9874 );
9875 assert!(
9876 html2.contains(r#"name="read" value="true""#),
9877 "hidden read value must flip back to true: {html2}"
9878 );
9879 }
9880
9881 #[tokio::test]
9884 async fn list_mark_read_returns_row_not_oob_actionbar() {
9885 let did = "did:plc:listv";
9886 let state = test_state(&[]).await;
9887 store::grant_access(&state.db, did, None, "test", None)
9888 .await
9889 .unwrap();
9890 let feed = store::upsert_feed(
9891 &state.db,
9892 &store::NewFeed {
9893 url: "https://list.example/feed.xml".to_string(),
9894 title: Some("List".to_string()),
9895 ..Default::default()
9896 },
9897 )
9898 .await
9899 .unwrap();
9900 store::insert_entries(
9901 &state.db,
9902 feed,
9903 &[store::NewEntry {
9904 guid: "l-1".to_string(),
9905 url: Some("https://list.example/1".to_string()),
9906 title: Some("Article".to_string()),
9907 published: Some("2026-07-11T00:00:00Z".to_string()),
9908 ..Default::default()
9909 }],
9910 0,
9911 )
9912 .await
9913 .unwrap();
9914 store::replace_sub_refs(&state.db, did, &[feed])
9915 .await
9916 .unwrap();
9917 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9918
9919 let cookie = session_cookie(&state, did, None);
9920 let app = router(state.clone());
9921
9922 let resp = app
9923 .oneshot(
9924 Request::builder()
9925 .method("POST")
9926 .uri(format!("/entries/{entry_id}/read"))
9927 .header(header::COOKIE, cookie)
9928 .header("HX-Request", "true")
9929 .header("content-type", "application/x-www-form-urlencoded")
9930 .body(Body::from("read=true"))
9931 .unwrap(),
9932 )
9933 .await
9934 .unwrap();
9935 assert_eq!(resp.status(), StatusCode::OK);
9936 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9937 .await
9938 .unwrap();
9939 let html = String::from_utf8(bytes.to_vec()).unwrap();
9940 assert!(
9941 !html.contains("hx-swap-oob"),
9942 "list-view response must NOT be an OOB swap: {html}"
9943 );
9944 assert!(
9949 html.contains(&format!("/entries/{entry_id}")),
9950 "the response is not the row for this entry: {html}",
9951 );
9952 assert!(
9953 html.contains("Article"),
9954 "the row rendered without its title: {html}",
9955 );
9956 assert!(
9973 html.contains("is-read"),
9974 "the row came back without the read state it was just given: {html}",
9975 );
9976 }
9977
9978 #[tokio::test]
10003 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10004 let did = "did:plc:autodiscovered";
10005 let state = test_state_with_caps(did, 0, 0).await;
10008
10009 let page = r#"<!doctype html><html><head><title>Blog</title>
10010 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10011 </head><body>hi</body></html>"#;
10012 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10013 let port: u16 = base
10014 .trim_end_matches('/')
10015 .rsplit(':')
10016 .next()
10017 .unwrap()
10018 .parse()
10019 .unwrap();
10020 crate::net::test_host_override(
10021 "autodiscover-ftp.test",
10022 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10023 );
10024
10025 let cookie = session_cookie(&state, did, None);
10026 let resp = router(state.clone())
10027 .oneshot(
10028 Request::builder()
10029 .method("POST")
10030 .uri("/subscriptions")
10031 .header(header::COOKIE, cookie)
10032 .header("content-type", "application/x-www-form-urlencoded")
10033 .body(Body::from(format!(
10034 "url=http://autodiscover-ftp.test:{port}/"
10035 )))
10036 .unwrap(),
10037 )
10038 .await
10039 .unwrap();
10040 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10041 let loc = resp
10042 .headers()
10043 .get(header::LOCATION)
10044 .unwrap()
10045 .to_str()
10046 .unwrap();
10047 assert_ne!(loc, "/login", "the test never reached the add path");
10048 assert_ne!(loc, "/", "the subscribe succeeded");
10049
10050 assert_eq!(
10051 store::count_feeds(&state.db).await.unwrap(),
10052 0,
10053 "a non-http(s) URL from autodiscovery was stored"
10054 );
10055 assert_eq!(
10056 store::count_subscriptions_for_did(&state.db, did)
10057 .await
10058 .unwrap(),
10059 0
10060 );
10061 }
10062
10063 #[tokio::test]
10068 async fn rename_to_new_url_refused_at_global_feeds_cap() {
10069 let did = "did:plc:renamer4";
10070 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10071 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10073 store::upsert_feed(
10074 &state.db,
10075 &store::NewFeed {
10076 url: "https://existing.example/feed.xml".to_string(),
10077 ..Default::default()
10078 },
10079 )
10080 .await
10081 .unwrap();
10082 let before = store::count_feeds(&state.db).await.unwrap();
10083 assert_eq!(before, 1);
10084
10085 let cookie = session_cookie(&state, did, None);
10086 let resp = router(state.clone())
10087 .oneshot(
10088 Request::builder()
10089 .method("POST")
10090 .uri("/subscriptions/rk-keep/rename")
10091 .header(header::COOKIE, cookie)
10092 .header("content-type", "application/x-www-form-urlencoded")
10093 .body(Body::from(
10095 "url=https://brand-new.example/feed.xml&title=Renamed",
10096 ))
10097 .unwrap(),
10098 )
10099 .await
10100 .unwrap();
10101 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10102 let loc = resp
10103 .headers()
10104 .get(header::LOCATION)
10105 .unwrap()
10106 .to_str()
10107 .unwrap();
10108 assert!(
10109 loc.contains("feed%20capacity"),
10110 "expected the feed-capacity flash, got {loc}"
10111 );
10112 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10114 assert!(
10115 puts.lock().unwrap().is_empty(),
10116 "a refused repoint reached the PDS"
10117 );
10118 }
10119
10120 #[tokio::test]
10127 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10128 let did = "did:plc:renamer4";
10129 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10130 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10131 store::upsert_feed(
10132 &state.db,
10133 &store::NewFeed {
10134 url: "https://existing.example/feed.xml".to_string(),
10135 ..Default::default()
10136 },
10137 )
10138 .await
10139 .unwrap();
10140 let before = store::count_feeds(&state.db).await.unwrap();
10141
10142 let cookie = session_cookie(&state, did, None);
10143 let resp = router(state.clone())
10144 .oneshot(
10145 Request::builder()
10146 .method("POST")
10147 .uri("/subscriptions/rk-keep/rename")
10148 .header(header::COOKIE, cookie)
10149 .header("content-type", "application/x-www-form-urlencoded")
10150 .body(Body::from(
10151 "url=https://existing.example/feed.xml&title=Retitled",
10152 ))
10153 .unwrap(),
10154 )
10155 .await
10156 .unwrap();
10157 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10158 let loc = resp
10159 .headers()
10160 .get(header::LOCATION)
10161 .unwrap()
10162 .to_str()
10163 .unwrap();
10164 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10165 assert_eq!(
10166 puts.lock().unwrap().len(),
10167 1,
10168 "the repoint did not reach the PDS"
10169 );
10170 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10171 }
10172
10173 #[tokio::test]
10175 async fn rename_with_blank_url_writes_nothing() {
10176 let did = "did:plc:renamer3";
10177 let state = test_state_with_caps(did, 0, 0).await;
10178 let before = store::count_feeds(&state.db).await.unwrap();
10179 assert_eq!(before, 0);
10180
10181 let cookie = session_cookie(&state, did, None);
10182 let app = router(state.clone());
10183 let resp = app
10184 .oneshot(
10185 Request::builder()
10186 .method("POST")
10187 .uri("/subscriptions/rkey123/rename")
10188 .header(header::COOKIE, cookie)
10189 .header("content-type", "application/x-www-form-urlencoded")
10190 .body(Body::from("url=%20%20&title=Nope"))
10192 .unwrap(),
10193 )
10194 .await
10195 .unwrap();
10196 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10197 assert_eq!(
10198 resp.headers()
10199 .get(header::LOCATION)
10200 .unwrap()
10201 .to_str()
10202 .unwrap(),
10203 "/",
10204 );
10205 assert_eq!(
10207 store::count_feeds(&state.db).await.unwrap(),
10208 0,
10209 "blank-URL rename wrote a junk feeds row"
10210 );
10211 }
10212
10213 async fn spawn_rename_sidecar(
10222 existing: serde_json::Value,
10223 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10224 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10225 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10226 let addr = listener.local_addr().unwrap();
10227 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10228 let sink = puts.clone();
10229 tokio::spawn(async move {
10230 loop {
10231 let Ok((mut sock, _)) = listener.accept().await else {
10232 break;
10233 };
10234 let mut raw: Vec<u8> = Vec::new();
10235 let mut chunk = [0u8; 4096];
10236 let body_text = loop {
10237 let Ok(n) = sock.read(&mut chunk).await else {
10238 break String::new();
10239 };
10240 if n == 0 {
10241 break String::from_utf8_lossy(&raw).to_string();
10242 }
10243 raw.extend_from_slice(&chunk[..n]);
10244 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10245 continue;
10246 };
10247 let (head, body) = raw.split_at(split + 4);
10248 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10249 let (k, v) = l.split_once(':')?;
10250 k.eq_ignore_ascii_case("content-length")
10251 .then(|| v.trim().parse::<usize>().ok())?
10252 });
10253 if want.is_none_or(|want| body.len() >= want) {
10254 break String::from_utf8_lossy(body).to_string();
10255 }
10256 };
10257
10258 let is_put = body_text.contains("\"action\":\"put\"");
10260 let data = if is_put {
10261 sink.lock().unwrap().push(body_text.clone());
10262 serde_json::json!({
10263 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10264 "cid": "bafyreiafter"
10265 })
10266 } else {
10267 serde_json::json!({ "records": [existing.clone()] })
10268 };
10269 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10270 let resp = format!(
10271 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10272 body.len(),
10273 body
10274 );
10275 let _ = sock.write_all(resp.as_bytes()).await;
10276 let _ = sock.flush().await;
10277 }
10278 });
10279 (format!("http://{addr}"), puts)
10280 }
10281
10282 fn seeded_subscription() -> serde_json::Value {
10284 serde_json::json!({
10285 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10286 "cid": "bafyreibefore",
10287 "value": {
10288 "$type": "community.lexicon.rss.subscription",
10289 "url": "https://example.com/feed.xml",
10290 "title": "Old title",
10291 "siteUrl": "https://example.com/blog",
10292 "fetchHint": "hourly",
10293 "private": false,
10294 "createdAt": "2024-03-01T00:00:00.000Z"
10295 }
10296 })
10297 }
10298
10299 fn seeded_at_uri_subscription() -> serde_json::Value {
10302 seeded_subscription_with_url(AT_URI_SUB)
10303 }
10304 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10306 serde_json::json!({
10307 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10308 "cid": "bafyreibefore",
10309 "value": {
10310 "$type": "community.lexicon.rss.subscription",
10311 "url": url,
10312 "title": "Old title",
10313 "private": false,
10314 "createdAt": "2024-03-01T00:00:00.000Z"
10315 }
10316 })
10317 }
10318 const AT_URI_SUB: &str =
10319 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10320 const AT_URI_SUB_ENC: &str =
10321 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10322
10323 #[tokio::test]
10332 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10333 let did = "did:plc:renamer5";
10334 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10335 let state = test_state_with_sidecar(&[did], &sidecar).await;
10336 assert!(
10337 !state.config.standard_site,
10338 "the flag must be off for this test"
10339 );
10340 let cookie = session_cookie(&state, did, None);
10341 let resp = router(state.clone())
10342 .oneshot(
10343 Request::builder()
10344 .method("POST")
10345 .uri("/subscriptions/rk-keep/rename")
10346 .header(header::COOKIE, cookie)
10347 .header("content-type", "application/x-www-form-urlencoded")
10348 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10349 .unwrap(),
10350 )
10351 .await
10352 .unwrap();
10353 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10354 let loc = resp
10355 .headers()
10356 .get(header::LOCATION)
10357 .unwrap()
10358 .to_str()
10359 .unwrap();
10360 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10361
10362 let bodies = puts.lock().unwrap().clone();
10363 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10364 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10365 assert_eq!(
10366 sent["record"]["title"], "New title",
10367 "the rename did not apply"
10368 );
10369 assert_eq!(
10370 sent["record"]["url"], AT_URI_SUB,
10371 "the rename changed the URL"
10372 );
10373
10374 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10376 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10377 }
10378
10379 #[tokio::test]
10383 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10384 let did = "did:plc:renamer4";
10385 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10386 let state = test_state_with_sidecar(&[did], &sidecar).await;
10387 let cookie = session_cookie(&state, did, None);
10388 let resp = router(state.clone())
10389 .oneshot(
10390 Request::builder()
10391 .method("POST")
10392 .uri("/subscriptions/rk-keep/rename")
10393 .header(header::COOKIE, cookie)
10394 .header("content-type", "application/x-www-form-urlencoded")
10395 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10396 .unwrap(),
10397 )
10398 .await
10399 .unwrap();
10400 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10401 let loc = resp
10402 .headers()
10403 .get(header::LOCATION)
10404 .unwrap()
10405 .to_str()
10406 .unwrap();
10407 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10408 assert!(
10409 !loc.contains("Private"),
10410 "a storability refusal was reported as a privacy one: {loc}"
10411 );
10412 assert!(
10413 puts.lock().unwrap().is_empty(),
10414 "the repoint reached the PDS"
10415 );
10416 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10417 assert_eq!(cached, 0);
10418 }
10419
10420 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10423 let cookie = session_cookie(state, did, None);
10424 let resp = router(state.clone())
10425 .oneshot(
10426 Request::builder()
10427 .method("POST")
10428 .uri("/subscriptions/rk-keep/rename")
10429 .header(header::COOKIE, cookie)
10430 .header("content-type", "application/x-www-form-urlencoded")
10431 .body(Body::from(format!("url={url_enc}&title=New+title")))
10432 .unwrap(),
10433 )
10434 .await
10435 .unwrap();
10436 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10437 resp.headers()
10438 .get(header::LOCATION)
10439 .unwrap()
10440 .to_str()
10441 .unwrap()
10442 .to_string()
10443 }
10444
10445 #[tokio::test]
10455 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10456 let did = "did:plc:renamer5";
10457 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10458 let other_enc =
10459 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10460 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10461 let state = test_state_with_sidecar(&[did], &sidecar).await;
10462 let loc = retitle_unchanged(&state, did, other_enc).await;
10463 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10464 let bodies = puts.lock().unwrap().clone();
10465 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10466 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10467 assert_eq!(sent["record"]["title"], "New title");
10468 assert_eq!(sent["record"]["url"], other);
10469 }
10470
10471 #[tokio::test]
10475 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10476 let did = "did:plc:renamer4";
10477 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10478 let state = test_state_with_sidecar(&[did], &sidecar).await;
10479 let cookie = session_cookie(&state, did, None);
10480 let resp = router(state.clone())
10481 .oneshot(
10482 Request::builder()
10483 .method("POST")
10484 .uri("/subscriptions/rk-keep/rename")
10485 .header(header::COOKIE, cookie)
10486 .header("content-type", "application/x-www-form-urlencoded")
10487 .body(Body::from(
10488 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10489 ))
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!(
10502 loc.contains("Private"),
10503 "the private repoint was not refused: {loc}"
10504 );
10505 assert!(
10506 puts.lock().unwrap().is_empty(),
10507 "a secret-bearing URL reached the PDS"
10508 );
10509 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10512 assert!(store::get_feed_by_url(&state.db, leaked)
10513 .await
10514 .unwrap()
10515 .is_none());
10516 }
10517
10518 #[tokio::test]
10524 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10525 let did = "did:plc:renamer5";
10526 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10527 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10529 store::upsert_feed(
10530 &state.db,
10531 &store::NewFeed {
10532 url: "https://filler.example/feed.xml".to_string(),
10533 ..Default::default()
10534 },
10535 )
10536 .await
10537 .unwrap();
10538 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10539 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10540 assert_eq!(
10541 puts.lock().unwrap().len(),
10542 1,
10543 "the retitle did not reach the PDS"
10544 );
10545 assert_eq!(
10546 store::count_feeds(&state.db).await.unwrap(),
10547 1,
10548 "a row was inserted"
10549 );
10550 }
10551
10552 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10554 let cookie = session_cookie(state, did, None);
10555 let resp = router(state.clone())
10556 .oneshot(
10557 Request::builder()
10558 .method("POST")
10559 .uri("/subscriptions")
10560 .header(header::COOKIE, cookie)
10561 .header("content-type", "application/x-www-form-urlencoded")
10562 .body(Body::from(format!("url={url_enc}")))
10563 .unwrap(),
10564 )
10565 .await
10566 .unwrap();
10567 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10568 resp.headers()
10569 .get(header::LOCATION)
10570 .unwrap()
10571 .to_str()
10572 .unwrap()
10573 .to_string()
10574 }
10575
10576 async fn serve_resolver(did: &str) -> String {
10578 let base = crate::net::tests::serve_body(
10579 serde_json::json!({ "did": did }).to_string().into_bytes(),
10580 )
10581 .await;
10582 let port: u16 = base
10583 .trim_end_matches('/')
10584 .rsplit(':')
10585 .next()
10586 .unwrap()
10587 .parse()
10588 .unwrap();
10589 let host = format!("resolver-{port}.test");
10590 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10591 format!("http://{host}:{port}")
10592 }
10593
10594 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10595 let mut config = (*state.config).clone();
10596 f(&mut config);
10597 state.config = std::sync::Arc::new(config);
10598 state
10599 }
10600
10601 #[tokio::test]
10606 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10607 let did = "did:plc:renamer5";
10608 let (sidecar, log) = spawn_logging_sidecar().await;
10609 let state = with_config(
10610 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10611 |c| {
10612 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10613 },
10614 );
10615 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10616 assert_eq!(loc, "/", "the paste was refused: {loc}");
10617 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10618 .await
10619 .unwrap()
10620 .expect("no feed row");
10621 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10622 let sent = log.lock().unwrap().join("\n");
10623 assert!(
10624 sent.contains(AT_URI_SUB),
10625 "the subscription was not written to the PDS: {sent}"
10626 );
10627 }
10628
10629 #[tokio::test]
10633 async fn a0_subscribing_from_the_form_delivers_entries() {
10634 let did = "did:plc:renamer5";
10635 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10636 let site = AT_URI_SUB;
10637 let (plc, _) = crate::standard_site::tests::serve_repo(
10638 author,
10639 vec![
10640 (
10641 lexicon::nsid::STANDARD_PUBLICATION,
10642 "3lab2c4d5e6f7g8h",
10643 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10644 ),
10645 (
10646 lexicon::nsid::STANDARD_DOCUMENT,
10647 "3l2a0frmaaa2a",
10648 serde_json::json!({ "title": "From the form", "path": "/f",
10649 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10650 ),
10651 ],
10652 )
10653 .await;
10654 let (sidecar, _log) = spawn_logging_sidecar().await;
10655 let state = with_config(
10656 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10657 |c| {
10658 c.oauth.plc_directory = plc;
10659 },
10660 );
10661 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10662 let row = store::get_feed_by_url(&state.db, site)
10663 .await
10664 .unwrap()
10665 .unwrap();
10666 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10667 .bind(row.id)
10668 .fetch_all(&state.db)
10669 .await
10670 .unwrap();
10671 assert_eq!(
10672 titles,
10673 vec!["From the form".to_string()],
10674 "the first poll stored nothing"
10675 );
10676 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10677 }
10678
10679 #[tokio::test]
10682 async fn a_handle_form_paste_is_stored_by_its_did() {
10683 let did = "did:plc:renamer5";
10684 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10685 let (sidecar, _log) = spawn_logging_sidecar().await;
10686 let resolver = serve_resolver(author).await;
10687 let state = with_config(
10688 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10689 |c| {
10690 c.resolver_base = resolver;
10691 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10692 },
10693 );
10694 let loc = subscribe(
10695 &state,
10696 did,
10697 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10698 )
10699 .await;
10700 assert_eq!(loc, "/", "the paste was refused: {loc}");
10701 assert!(
10702 store::get_feed_by_url(&state.db, AT_URI_SUB)
10703 .await
10704 .unwrap()
10705 .is_some(),
10706 "not stored by its DID"
10707 );
10708 assert_eq!(
10709 store::count_feeds(&state.db).await.unwrap(),
10710 1,
10711 "the handle form was stored too"
10712 );
10713 }
10714
10715 async fn serve_counting_resolver(
10717 did: &str,
10718 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10719 let (base, hits) = crate::net::tests::serve_body_counted(
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!("counting-resolver-{port}.test");
10731 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10732 (format!("http://{host}:{port}"), hits)
10733 }
10734
10735 #[tokio::test]
10739 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10740 let did = "did:plc:renamer5";
10741 let (sidecar, _log) = spawn_logging_sidecar().await;
10742 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10743 let state = with_config(
10744 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10745 |c| {
10746 c.resolver_base = resolver;
10747 c.max_subs_per_did = 1;
10748 },
10749 );
10750 let feed_id = store::upsert_feed(
10751 &state.db,
10752 &store::NewFeed {
10753 url: "https://already.example/feed.xml".into(),
10754 ..Default::default()
10755 },
10756 )
10757 .await
10758 .unwrap();
10759 store::replace_sub_refs(&state.db, did, &[feed_id])
10760 .await
10761 .unwrap();
10762 let loc = subscribe(
10763 &state,
10764 did,
10765 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10766 )
10767 .await;
10768 assert!(
10769 loc.contains("Subscription%20limit"),
10770 "expected the cap flash: {loc}"
10771 );
10772 assert_eq!(
10773 hits.load(std::sync::atomic::Ordering::SeqCst),
10774 0,
10775 "an over-cap paste resolved a handle"
10776 );
10777 }
10778
10779 #[tokio::test]
10783 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10784 let did = "did:plc:renamer5";
10785 let (sidecar, _log) = spawn_logging_sidecar().await;
10786 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10787 let state = with_config(
10788 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10789 |c| {
10790 c.resolver_base = resolver;
10791 },
10792 );
10793 for authority in [
10794 "did%3Aplc%3ATOOSHORT",
10795 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10796 "bad%0Ahandle.example",
10797 ] {
10798 let loc = subscribe(
10799 &state,
10800 did,
10801 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10802 )
10803 .await;
10804 assert!(
10805 loc.contains("kind%20of%20feed"),
10806 "{authority}: expected the unsupported flash: {loc}"
10807 );
10808 }
10809 assert_eq!(
10810 hits.load(std::sync::atomic::Ordering::SeqCst),
10811 0,
10812 "a malformed authority reached the resolver"
10813 );
10814 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10815 }
10816
10817 #[tokio::test]
10819 async fn an_unresolvable_handle_paste_is_refused() {
10820 let did = "did:plc:renamer5";
10821 let (sidecar, _log) = spawn_logging_sidecar().await;
10822 let state = with_config(
10823 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10824 |c| {
10825 c.resolver_base = "http://resolver.nowhere.invalid".into();
10826 },
10827 );
10828 let loc = subscribe(
10829 &state,
10830 did,
10831 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10832 )
10833 .await;
10834 assert!(
10835 loc.contains("resolve%20the%20handle"),
10836 "expected the unresolvable-handle flash: {loc}"
10837 );
10838 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10839 }
10840
10841 #[tokio::test]
10843 async fn a_non_publication_at_uri_paste_is_refused() {
10844 let did = "did:plc:renamer5";
10845 let (sidecar, _log) = spawn_logging_sidecar().await;
10846 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10847 let loc = subscribe(
10848 &state,
10849 did,
10850 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10851 )
10852 .await;
10853 assert!(
10854 loc.contains("kind%20of%20feed"),
10855 "expected the unsupported flash: {loc}"
10856 );
10857 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10858 }
10859
10860 #[tokio::test]
10863 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
10864 let did = "did:plc:renamer5";
10865 let (sidecar, _log) = spawn_logging_sidecar().await;
10866 let state = with_config(
10867 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10868 |c| {
10869 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10870 },
10871 );
10872 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
10873 assert_eq!(loc, "/", "the paste was refused: {loc}");
10874 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
10875 .await
10876 .unwrap()
10877 .is_some());
10878 }
10879
10880 #[tokio::test]
10883 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
10884 let did = "did:plc:renamer5";
10885 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10886 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10887 let opml = format!(
10888 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10889 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10890 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10891 </body></opml>"
10892 );
10893 let (ct, body) = opml_multipart(opml.as_bytes());
10894 let cookie = session_cookie(&state, did, None);
10895 let resp = router(state.clone())
10896 .oneshot(
10897 Request::builder()
10898 .method("POST")
10899 .uri("/opml")
10900 .header(header::COOKIE, cookie)
10901 .header("content-type", ct)
10902 .body(Body::from(body))
10903 .unwrap(),
10904 )
10905 .await
10906 .unwrap();
10907 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10908 let loc = resp
10909 .headers()
10910 .get(header::LOCATION)
10911 .unwrap()
10912 .to_str()
10913 .unwrap();
10914 assert!(
10915 loc.contains("Imported%202%20feeds"),
10916 "unexpected flash: {loc}"
10917 );
10918 assert!(
10919 !loc.contains("skipped"),
10920 "the at:// entry was skipped with the flag on: {loc}"
10921 );
10922 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
10923 assert!(
10924 stored.is_some(),
10925 "the at:// entry was not stored with the flag on"
10926 );
10927 }
10928
10929 #[tokio::test]
10939 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
10940 let did = "did:plc:renamer5";
10941 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
10942 let tokened_enc =
10943 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
10944 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
10945 let state = test_state_with_sidecar(&[did], &sidecar).await;
10946 let loc = retitle_unchanged(&state, did, tokened_enc).await;
10947 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10948 assert_eq!(
10949 puts.lock().unwrap().len(),
10950 1,
10951 "the retitle did not reach the PDS"
10952 );
10953 assert!(
10954 store::get_feed_by_url(&state.db, tokened)
10955 .await
10956 .unwrap()
10957 .is_none(),
10958 "a secret-bearing URL was written to the shared cache by a retitle"
10959 );
10960 }
10961
10962 #[tokio::test]
10967 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
10968 let did = "did:plc:renamer4";
10969 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10970 let state = test_state_with_sidecar(&[did], &sidecar).await;
10971 let cookie = session_cookie(&state, did, None);
10972 let resp = router(state.clone())
10973 .oneshot(
10974 Request::builder()
10975 .method("POST")
10976 .uri("/subscriptions/rk-keep/rename")
10977 .header(header::COOKIE, cookie)
10978 .header("content-type", "application/x-www-form-urlencoded")
10979 .body(Body::from(
10980 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
10981 ))
10982 .unwrap(),
10983 )
10984 .await
10985 .unwrap();
10986 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10987 let loc = resp
10988 .headers()
10989 .get(header::LOCATION)
10990 .unwrap()
10991 .to_str()
10992 .unwrap();
10993 assert!(
10994 loc.contains("kind%20of%20feed"),
10995 "expected the unsupported flash: {loc}"
10996 );
10997 assert!(
10998 !loc.contains("Private"),
10999 "a typo was reported as a paid feed: {loc}"
11000 );
11001 assert!(puts.lock().unwrap().is_empty());
11002 }
11003
11004 #[tokio::test]
11005 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11006 let did = "did:plc:renamer4";
11007 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11008 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11009 store::upsert_feed(
11010 &state.db,
11011 &store::NewFeed {
11012 url: "https://filler.example/feed.xml".to_string(),
11013 ..Default::default()
11014 },
11015 )
11016 .await
11017 .unwrap();
11018 let cookie = session_cookie(&state, did, None);
11019 let resp = router(state.clone())
11020 .oneshot(
11021 Request::builder()
11022 .method("POST")
11023 .uri("/subscriptions/rk-keep/rename")
11024 .header(header::COOKIE, cookie)
11025 .header("content-type", "application/x-www-form-urlencoded")
11026 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11027 .unwrap(),
11028 )
11029 .await
11030 .unwrap();
11031 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11032 let loc = resp
11033 .headers()
11034 .get(header::LOCATION)
11035 .unwrap()
11036 .to_str()
11037 .unwrap();
11038 assert!(
11039 loc.contains("kind%20of%20feed"),
11040 "expected the unsupported flash: {loc}"
11041 );
11042 assert!(
11043 !loc.contains("capacity"),
11044 "an unacceptable URL was reported as a capacity problem: {loc}"
11045 );
11046 assert!(puts.lock().unwrap().is_empty());
11047 }
11048
11049 #[tokio::test]
11054 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11055 let did = "did:plc:renamer5";
11056 let padded = format!("{AT_URI_SUB} ");
11057 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11058 let state = test_state_with_sidecar(&[did], &sidecar).await;
11059 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11061 assert_eq!(
11062 loc, "/",
11063 "the retitle was treated as a repoint and refused: {loc}"
11064 );
11065 let bodies = puts.lock().unwrap().clone();
11066 assert_eq!(bodies.len(), 1);
11067 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11068 assert_eq!(
11069 sent["record"]["url"], AT_URI_SUB,
11070 "the padding was not normalised away"
11071 );
11072 }
11073
11074 #[tokio::test]
11080 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11081 let did = "did:plc:renamer5";
11082 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11083 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11084 store::upsert_feed(
11085 &state.db,
11086 &store::NewFeed {
11087 url: "https://filler.example/feed.xml".to_string(),
11088 ..Default::default()
11089 },
11090 )
11091 .await
11092 .unwrap();
11093 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11094 assert_eq!(loc, "/", "the retitle was refused: {loc}");
11095 assert_eq!(puts.lock().unwrap().len(), 1);
11096 assert_eq!(
11097 store::count_feeds(&state.db).await.unwrap(),
11098 1,
11099 "a retitle inserted a cache row past the ceiling"
11100 );
11101 }
11102
11103 #[tokio::test]
11110 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11111 let did = "did:plc:typoist";
11112 let state = test_state_with_caps(did, 0, 0).await;
11113 let cookie = session_cookie(&state, did, None);
11114 let resp = router(state.clone())
11115 .oneshot(
11116 Request::builder()
11117 .method("POST")
11118 .uri("/subscriptions")
11119 .header(header::COOKIE, cookie)
11120 .header("content-type", "application/x-www-form-urlencoded")
11121 .body(Body::from(
11122 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11123 ))
11124 .unwrap(),
11125 )
11126 .await
11127 .unwrap();
11128 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11129 let loc = resp
11130 .headers()
11131 .get(header::LOCATION)
11132 .unwrap()
11133 .to_str()
11134 .unwrap();
11135 assert!(
11136 loc.contains("kind%20of%20feed"),
11137 "expected the unsupported flash: {loc}"
11138 );
11139 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11140 }
11141
11142 #[tokio::test]
11164 async fn renaming_preserves_the_fields_the_form_never_carries() {
11165 let did = "did:plc:renamer4";
11166 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11167 let state = test_state_with_sidecar(&[did], &sidecar).await;
11168 let cookie = session_cookie(&state, did, None);
11169
11170 let resp = router(state.clone())
11171 .oneshot(
11172 Request::builder()
11173 .method("POST")
11174 .uri("/subscriptions/rk-keep/rename")
11175 .header(header::COOKIE, cookie)
11176 .header("content-type", "application/x-www-form-urlencoded")
11177 .body(Body::from(
11179 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11180 ))
11181 .unwrap(),
11182 )
11183 .await
11184 .unwrap();
11185 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11186
11187 let bodies = puts.lock().unwrap().clone();
11188 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11189 let body = &bodies[0];
11190 assert!(
11192 body.contains("community.lexicon.rss.subscription"),
11193 "captured no usable put body: {body:?}"
11194 );
11195
11196 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11197 let record = &sent["record"];
11198
11199 assert_eq!(record["title"], "New title", "the rename did not apply");
11201 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11202
11203 assert_eq!(
11205 record["createdAt"], "2024-03-01T00:00:00.000Z",
11206 "the rename reset createdAt — the reader's subscribe time is gone \
11207 from their own repo, and nothing told them"
11208 );
11209 assert_eq!(
11210 record["siteUrl"], "https://example.com/blog",
11211 "the rename erased siteUrl"
11212 );
11213 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11214 assert_eq!(record["private"], false, "the rename erased private");
11215 }
11216
11217 #[tokio::test]
11226 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11227 let did = "did:plc:renamer4";
11228 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11229 let state = test_state_with_sidecar(&[did], &sidecar).await;
11230 let cookie = session_cookie(&state, did, None);
11231
11232 let resp = router(state.clone())
11233 .oneshot(
11234 Request::builder()
11235 .method("POST")
11236 .uri("/subscriptions/rk-keep/rename")
11237 .header(header::COOKIE, cookie)
11238 .header("content-type", "application/x-www-form-urlencoded")
11239 .body(Body::from(
11241 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11242 ))
11243 .unwrap(),
11244 )
11245 .await
11246 .unwrap();
11247 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11248
11249 let bodies = puts.lock().unwrap().clone();
11250 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11251 assert!(
11252 bodies[0].contains("community.lexicon.rss.subscription"),
11253 "captured no usable put body: {:?}",
11254 bodies[0]
11255 );
11256 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11257 let record = &sent["record"];
11258
11259 assert_eq!(record["url"], "https://other.example/feed.xml");
11260 assert!(
11262 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11263 "the old feed's site link followed the subscription to a new feed: {record}"
11264 );
11265 assert!(
11266 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11267 "the old feed's fetch hint followed the subscription to a new feed: {record}"
11268 );
11269 assert_eq!(
11271 record["createdAt"], "2024-03-01T00:00:00.000Z",
11272 "a repoint is still not a new subscription; createdAt must not move"
11273 );
11274 assert_eq!(record["private"], false, "the repoint erased private");
11275 }
11276
11277 #[tokio::test]
11289 async fn renaming_an_unknown_rkey_writes_nothing() {
11290 let did = "did:plc:renamer4";
11291 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11293 let state = test_state_with_sidecar(&[did], &sidecar).await;
11294 let cookie = session_cookie(&state, did, None);
11295
11296 let resp = router(state.clone())
11297 .oneshot(
11298 Request::builder()
11299 .method("POST")
11300 .uri("/subscriptions/rk-does-not-exist/rename")
11302 .header(header::COOKIE, cookie)
11303 .header("content-type", "application/x-www-form-urlencoded")
11304 .body(Body::from(
11305 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11306 ))
11307 .unwrap(),
11308 )
11309 .await
11310 .unwrap();
11311
11312 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11313 let loc = resp
11314 .headers()
11315 .get(header::LOCATION)
11316 .unwrap()
11317 .to_str()
11318 .unwrap();
11319 assert!(
11320 loc.contains("flash="),
11321 "an unknown rkey redirected as though the rename had worked: {loc}"
11322 );
11323 assert!(
11324 puts.lock().unwrap().is_empty(),
11325 "a rename against an unknown rkey wrote a record — putRecord would \
11326 CREATE it, dated today: {:?}",
11327 puts.lock().unwrap()
11328 );
11329 }
11330
11331 #[tokio::test]
11343 async fn a_client_supplied_site_url_reaches_the_record() {
11344 let did = "did:plc:renamer4";
11345 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11346 let state = test_state_with_sidecar(&[did], &sidecar).await;
11347 let cookie = session_cookie(&state, did, None);
11348
11349 let resp = router(state.clone())
11350 .oneshot(
11351 Request::builder()
11352 .method("POST")
11353 .uri("/subscriptions/rk-keep/rename")
11354 .header(header::COOKIE, cookie)
11355 .header("content-type", "application/x-www-form-urlencoded")
11356 .body(Body::from(
11359 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11360 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11361 ))
11362 .unwrap(),
11363 )
11364 .await
11365 .unwrap();
11366 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11367
11368 let bodies = puts.lock().unwrap().clone();
11369 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11370 assert!(
11371 bodies[0].contains("community.lexicon.rss.subscription"),
11372 "captured no usable put body: {:?}",
11373 bodies[0]
11374 );
11375 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11376 assert_eq!(
11377 sent["record"]["siteUrl"], "https://typed.example/site",
11378 "the client's siteUrl was dropped; the seeded record's survived instead"
11379 );
11380 }
11381
11382 #[tokio::test]
11390 async fn a_rename_whose_read_fails_writes_nothing() {
11391 let did = "did:plc:renamer5";
11392 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11394 let dead = format!("http://{}", listener.local_addr().unwrap());
11395 drop(listener);
11396
11397 let state = test_state_with_sidecar(&[did], &dead).await;
11398 let cookie = session_cookie(&state, did, None);
11399 let before = store::count_feeds(&state.db).await.unwrap();
11400
11401 let resp = router(state.clone())
11402 .oneshot(
11403 Request::builder()
11404 .method("POST")
11405 .uri("/subscriptions/rk-keep/rename")
11406 .header(header::COOKIE, cookie)
11407 .header("content-type", "application/x-www-form-urlencoded")
11408 .body(Body::from(
11409 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11410 ))
11411 .unwrap(),
11412 )
11413 .await
11414 .unwrap();
11415
11416 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11417 let loc = resp
11418 .headers()
11419 .get(header::LOCATION)
11420 .unwrap()
11421 .to_str()
11422 .unwrap();
11423 assert!(
11424 loc.contains("flash="),
11425 "a failed read redirected as though the rename had worked: {loc}"
11426 );
11427 assert_eq!(
11428 store::count_feeds(&state.db).await.unwrap(),
11429 before,
11430 "a rename that could not read the record still wrote to the cache"
11431 );
11432 }
11433
11434 #[test]
11440 fn manage_rename_row_preselects_current_folder() {
11441 let nav = Nav {
11442 handle: "@reader.example".to_string(),
11443 avatar: "RE".to_string(),
11444 view: "unread".to_string(),
11445 scope_qs: String::new(),
11446 folders: Vec::new(),
11447 loose_feeds: Vec::new(),
11448 manage_active: true,
11449 };
11450 let folder_options = vec![
11451 FolderOption {
11452 uri: "at://did:plc:x/app.folder/work".to_string(),
11453 name: "Work".to_string(),
11454 },
11455 FolderOption {
11456 uri: "at://did:plc:x/app.folder/fun".to_string(),
11457 name: "Fun".to_string(),
11458 },
11459 ];
11460 let foldered = FeedView {
11463 rkey: "sub-foldered".to_string(),
11464 url: "https://work.example/feed.xml".to_string(),
11465 title: "Work Feed".to_string(),
11466 unread: 0,
11467 selected: false,
11468 folder: Some("at://did:plc:x/app.folder/work".to_string()),
11469 };
11470 let loose = FeedView {
11471 rkey: "sub-loose".to_string(),
11472 url: "https://loose.example/feed.xml".to_string(),
11473 title: "Loose Feed".to_string(),
11474 unread: 0,
11475 selected: false,
11476 folder: None,
11477 };
11478 let tmpl = ManageTemplate {
11479 card: Card::private(&Config::default()),
11480 version: VERSION,
11481 repo_url: REPO_URL,
11482 kofi_url: KOFI_URL,
11483 flash: String::new(),
11484 alert: String::new(),
11485 nav,
11486 folder_options,
11487 folders: vec![FolderView {
11488 rkey: "folder-work".to_string(),
11489 uri: "at://did:plc:x/app.folder/work".to_string(),
11490 name: "Work".to_string(),
11491 feeds: vec![foldered],
11492 selected: false,
11493 }],
11494 loose_feeds: vec![loose],
11495 standard_site: false,
11496 };
11497 let html = tmpl.render().unwrap();
11498
11499 assert!(
11501 html.contains(
11502 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11503 ),
11504 "foldered feed must pre-select its current folder: {html}"
11505 );
11506 assert!(
11509 html.contains(r#"<option value="" selected>No folder</option>"#),
11510 "loose feed must pre-select 'No folder': {html}"
11511 );
11512 }
11513
11514 #[tokio::test]
11520 async fn the_public_stats_page_exposes_no_user_data() {
11521 let state = test_state(&[]).await;
11522 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11523 .await
11524 .unwrap();
11525
11526 let resp = router(state)
11527 .oneshot(
11528 Request::builder()
11529 .uri("/stats")
11530 .body(Body::empty())
11531 .unwrap(),
11532 )
11533 .await
11534 .unwrap();
11535 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11536
11537 let body = String::from_utf8(
11538 axum::body::to_bytes(resp.into_body(), usize::MAX)
11539 .await
11540 .unwrap()
11541 .to_vec(),
11542 )
11543 .unwrap();
11544
11545 assert!(
11551 !body.contains("did:"),
11552 "the public stats page leaked an identifier"
11553 );
11554 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11555 assert!(
11556 !body.contains(admin_only),
11557 "the public page is showing the admin metrics column {admin_only:?}"
11558 );
11559 }
11560 assert!(body.contains("Feeds tracked"));
11562 assert!(body.contains("Waiting to be polled"));
11563 }
11564
11565 #[tokio::test]
11573 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11574 let state = test_state(&[]).await;
11575 for (url, errors) in [
11577 ("https://ok.example/f.xml", 0),
11578 ("https://flaky.example/f.xml", 2),
11579 ("https://dead.example/f.xml", 9),
11580 ] {
11581 store::upsert_feed(
11582 &state.db,
11583 &store::NewFeed {
11584 url: url.to_string(),
11585 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11588 ..Default::default()
11589 },
11590 )
11591 .await
11592 .unwrap();
11593 for _ in 0..errors {
11594 store::bump_feed_errors(
11595 &state.db,
11596 url,
11597 feed::FailureKind::Fetch,
11598 "connection refused",
11599 )
11600 .await
11601 .unwrap();
11602 }
11603 }
11604
11605 let render_stats = |state: AppState| async move {
11606 let resp = router(state)
11607 .oneshot(
11608 Request::builder()
11609 .uri("/stats")
11610 .body(Body::empty())
11611 .unwrap(),
11612 )
11613 .await
11614 .unwrap();
11615 assert_eq!(resp.status(), StatusCode::OK);
11616 String::from_utf8(
11617 axum::body::to_bytes(resp.into_body(), usize::MAX)
11618 .await
11619 .unwrap()
11620 .to_vec(),
11621 )
11622 .unwrap()
11623 };
11624
11625 state.runtime_health.set_schedulers_enabled(true);
11632 state
11633 .runtime_health
11634 .poll_tick_completed(crate::store::now_unix());
11635
11636 let body = render_stats(state.clone()).await;
11637 assert!(
11638 body.contains("Failing"),
11639 "backoff is still invisible on the public page"
11640 );
11641 assert!(
11645 body.contains("2, 1 badly"),
11646 "expected '2, 1 badly' in the failing row; got:\n{}",
11647 body.split("Failing")
11648 .nth(1)
11649 .unwrap_or("")
11650 .chars()
11651 .take(300)
11652 .collect::<String>()
11653 );
11654 assert!(
11662 !body.contains("the poller is not running")
11663 && !body.contains("the cache is at its size limit")
11664 && !body.contains("has not completed a round"),
11665 "expected the running state; the page reported a stopped one",
11666 );
11667
11668 state.runtime_health.set_watermark(true);
11672 let paused = render_stats(state.clone()).await;
11673 assert!(
11678 paused.contains("the cache is at its size limit"),
11679 "a watermark pause is still invisible on the public page"
11680 );
11681
11682 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11684 assert!(
11685 !paused.contains(leak),
11686 "the public page leaked {leak:?} while reporting failures"
11687 );
11688 }
11689 }
11690
11691 #[tokio::test]
11703 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11704 let admin = "did:plc:adminseed";
11705 let state = test_state(&[admin]).await;
11714 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11715 .await
11716 .unwrap();
11717 let url = "https://broken.example/f.xml";
11718 store::upsert_feed(
11719 &state.db,
11720 &store::NewFeed {
11721 url: url.to_string(),
11722 ..Default::default()
11723 },
11724 )
11725 .await
11726 .unwrap();
11727 store::bump_feed_errors(
11728 &state.db,
11729 url,
11730 feed::FailureKind::Fetch,
11731 "SENTINEL_ADMIN_ONLY",
11732 )
11733 .await
11734 .unwrap();
11735
11736 let get = |state: AppState, cookie: Option<String>| async move {
11737 let mut req = Request::builder().uri("/admin/metrics");
11738 if let Some(c) = cookie {
11739 req = req.header(header::COOKIE, c);
11740 }
11741 let resp = router(state)
11742 .oneshot(req.body(Body::empty()).unwrap())
11743 .await
11744 .unwrap();
11745 let status = resp.status();
11746 let body = String::from_utf8(
11747 axum::body::to_bytes(resp.into_body(), usize::MAX)
11748 .await
11749 .unwrap()
11750 .to_vec(),
11751 )
11752 .unwrap();
11753 (status, body)
11754 };
11755
11756 let (status, body) = get(state.clone(), None).await;
11758 assert_eq!(status, StatusCode::UNAUTHORIZED);
11759 assert!(
11760 !body.contains("SENTINEL_ADMIN_ONLY"),
11761 "leaked to anonymous: {body}"
11762 );
11763
11764 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11766 let (status, body) = get(state.clone(), Some(ordinary)).await;
11767 assert_eq!(
11768 status,
11769 StatusCode::FORBIDDEN,
11770 "a non-admin session was let in"
11771 );
11772 assert!(
11773 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11774 "leaked to a non-admin: {body}",
11775 );
11776
11777 let admin_cookie = session_cookie(&state, admin, None);
11780 let (status, body) = get(state, Some(admin_cookie)).await;
11781 assert_eq!(status, StatusCode::OK);
11782 assert!(
11783 body.contains("SENTINEL_ADMIN_ONLY"),
11784 "admin cannot see it: {body}"
11785 );
11786 }
11787
11788 #[tokio::test]
11805 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11806 let admin = "did:plc:adminseed";
11807 let state = test_state(&[admin]).await;
11808 let url = "https://broken.example/f.xml";
11809 store::upsert_feed(
11810 &state.db,
11811 &store::NewFeed {
11812 url: url.to_string(),
11813 ..Default::default()
11814 },
11815 )
11816 .await
11817 .unwrap();
11818 store::bump_feed_errors(
11819 &state.db,
11820 url,
11821 feed::FailureKind::Fetch,
11822 "SENTINEL_REDIRECT_NO_LOCATION",
11823 )
11824 .await
11825 .unwrap();
11826
11827 let cookie = session_cookie(&state, admin, None);
11828 let resp = router(state.clone())
11829 .oneshot(
11830 Request::builder()
11831 .uri("/admin/metrics")
11832 .header(header::COOKIE, cookie)
11833 .body(Body::empty())
11834 .unwrap(),
11835 )
11836 .await
11837 .unwrap();
11838 assert_eq!(resp.status(), StatusCode::OK);
11839 let admin_body = String::from_utf8(
11840 axum::body::to_bytes(resp.into_body(), usize::MAX)
11841 .await
11842 .unwrap()
11843 .to_vec(),
11844 )
11845 .unwrap();
11846 assert!(
11847 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11848 "the admin page does not carry the failure detail: {admin_body}",
11849 );
11850 assert!(
11851 admin_body.contains("broken.example"),
11852 "the admin page does not name the failing feed: {admin_body}",
11853 );
11854
11855 let resp = router(state)
11857 .oneshot(
11858 Request::builder()
11859 .uri("/stats")
11860 .body(Body::empty())
11861 .unwrap(),
11862 )
11863 .await
11864 .unwrap();
11865 let public = String::from_utf8(
11866 axum::body::to_bytes(resp.into_body(), usize::MAX)
11867 .await
11868 .unwrap()
11869 .to_vec(),
11870 )
11871 .unwrap();
11872 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
11873 assert!(
11874 !public.contains(secret),
11875 "{secret:?} reached the PUBLIC stats page: {public}",
11876 );
11877 }
11878 }
11879
11880 #[tokio::test]
11893 async fn a_successful_direct_poll_clears_a_stale_failure() {
11894 let state = test_state(&[]).await;
11895 let url = "https://recovered.example/f.xml";
11896 store::upsert_feed(
11897 &state.db,
11898 &store::NewFeed {
11899 url: url.to_string(),
11900 ..Default::default()
11901 },
11902 )
11903 .await
11904 .unwrap();
11905 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
11906 .await
11907 .unwrap();
11908 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
11910 .bind(url)
11911 .execute(&state.db)
11912 .await
11913 .unwrap();
11914
11915 feed::settle_poll(
11917 &state.db,
11918 url,
11919 &feed::PollOutcome::NotModified,
11920 state.config.poll_interval,
11921 )
11922 .await;
11923
11924 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11925 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11926 )
11927 .bind(url)
11928 .fetch_one(&state.db)
11929 .await
11930 .unwrap();
11931 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
11932 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
11933 let next = row.2.expect("next_poll was cleared to NULL");
11937 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11941 let delta = parsed
11942 .signed_duration_since(chrono::Utc::now())
11943 .num_seconds();
11944 let cadence = state.config.poll_interval.as_secs() as i64;
11945 assert!(
11946 (cadence - 60..=cadence + 60).contains(&delta),
11947 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
11948 );
11949 }
11950
11951 #[tokio::test]
11957 async fn a_failing_direct_poll_is_recorded() {
11958 let state = test_state(&[]).await;
11959 let url = "https://born-broken.example/f.xml";
11960 store::upsert_feed(
11961 &state.db,
11962 &store::NewFeed {
11963 url: url.to_string(),
11964 ..Default::default()
11965 },
11966 )
11967 .await
11968 .unwrap();
11969
11970 feed::settle_poll(
11971 &state.db,
11972 url,
11973 &feed::PollOutcome::Failed {
11974 backoff: std::time::Duration::from_secs(300),
11975 kind: feed::FailureKind::Parse,
11976 detail: "SENTINEL_BORN_BROKEN".to_string(),
11977 },
11978 state.config.poll_interval,
11979 )
11980 .await;
11981
11982 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11983 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11984 )
11985 .bind(url)
11986 .fetch_one(&state.db)
11987 .await
11988 .unwrap();
11989 assert_eq!(row.0, 1, "a failed first poll was not counted");
11990 assert_eq!(
11991 row.1.as_deref(),
11992 Some("parse"),
11993 "its cause was not recorded"
11994 );
11995 let next = row.2.expect("a failed direct poll left next_poll NULL");
11999 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12000 let delta = parsed
12001 .signed_duration_since(chrono::Utc::now())
12002 .num_seconds();
12003 assert!(
12004 (240..=360).contains(&delta),
12005 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12006 );
12007 }
12008
12009 #[tokio::test]
12021 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12022 let state = test_state(&[]).await;
12023 for url in [
12025 "https://legacy1.example/f.xml",
12026 "https://legacy2.example/f.xml",
12027 ] {
12028 store::upsert_feed(
12029 &state.db,
12030 &store::NewFeed {
12031 url: url.to_string(),
12032 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12033 ..Default::default()
12034 },
12035 )
12036 .await
12037 .unwrap();
12038 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12039 .bind(url)
12040 .execute(&state.db)
12041 .await
12042 .unwrap();
12043 }
12044 store::upsert_feed(
12046 &state.db,
12047 &store::NewFeed {
12048 url: "https://known.example/f.xml".to_string(),
12049 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12050 ..Default::default()
12051 },
12052 )
12053 .await
12054 .unwrap();
12055 store::bump_feed_errors(
12056 &state.db,
12057 "https://known.example/f.xml",
12058 feed::FailureKind::Status,
12059 "SENTINEL",
12060 )
12061 .await
12062 .unwrap();
12063
12064 let now = chrono::Utc::now();
12065 let health = store::poll_health(
12066 &state.db,
12067 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12068 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12069 )
12070 .await
12071 .unwrap();
12072 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12073 assert_eq!(
12074 counted, health.in_backoff,
12075 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12076 health.in_backoff, health.failure_kinds,
12077 );
12078 assert!(
12079 health
12080 .failure_kinds
12081 .iter()
12082 .any(|(k, n)| k == "unknown" && *n == 2),
12083 "no unknown bucket for the legacy rows: {:?}",
12084 health.failure_kinds,
12085 );
12086 }
12087
12088 #[tokio::test]
12093 async fn the_failure_breakdown_is_ordered_by_count() {
12094 let state = test_state(&[]).await;
12095 for (url, kind, n) in [
12096 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12097 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12098 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12099 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12100 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12101 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12102 ] {
12103 store::upsert_feed(
12104 &state.db,
12105 &store::NewFeed {
12106 url: url.to_string(),
12107 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12108 ..Default::default()
12109 },
12110 )
12111 .await
12112 .unwrap();
12113 for _ in 0..n {
12114 store::bump_feed_errors(&state.db, url, kind, "d")
12115 .await
12116 .unwrap();
12117 }
12118 }
12119 let now = chrono::Utc::now();
12120 let health = store::poll_health(
12121 &state.db,
12122 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12123 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12124 )
12125 .await
12126 .unwrap();
12127 let labels: Vec<&str> = health
12128 .failure_kinds
12129 .iter()
12130 .map(|(k, _)| k.as_str())
12131 .collect();
12132 assert_eq!(
12133 labels,
12134 ["fetch", "status", "parse"],
12135 "not ordered by count, descending: {:?}",
12136 health.failure_kinds,
12137 );
12138 }
12139
12140 #[tokio::test]
12152 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12153 let state = test_state(&[]).await;
12154 for (url, kind, detail, errors) in [
12155 (
12161 "https://a.example/f.xml",
12162 feed::FailureKind::Fetch,
12163 "SENTINEL_CONNREFUSED",
12164 3,
12165 ),
12166 (
12167 "https://b.example/f.xml",
12168 feed::FailureKind::Fetch,
12169 "SENTINEL_DNSFAIL",
12170 2,
12171 ),
12172 (
12173 "https://c.example/f.xml",
12174 feed::FailureKind::Status,
12175 "SENTINEL_404",
12176 1,
12177 ),
12178 (
12179 "https://d.example/f.xml",
12180 feed::FailureKind::Parse,
12181 "SENTINEL_UNPARSEABLE",
12182 1,
12183 ),
12184 ] {
12185 store::upsert_feed(
12186 &state.db,
12187 &store::NewFeed {
12188 url: url.to_string(),
12189 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12190 ..Default::default()
12191 },
12192 )
12193 .await
12194 .unwrap();
12195 for _ in 0..errors {
12196 store::bump_feed_errors(&state.db, url, kind, detail)
12197 .await
12198 .unwrap();
12199 }
12200 }
12201
12202 let resp = router(state.clone())
12203 .oneshot(
12204 Request::builder()
12205 .uri("/stats")
12206 .body(Body::empty())
12207 .unwrap(),
12208 )
12209 .await
12210 .unwrap();
12211 assert_eq!(resp.status(), StatusCode::OK);
12212 let body = String::from_utf8(
12213 axum::body::to_bytes(resp.into_body(), usize::MAX)
12214 .await
12215 .unwrap()
12216 .to_vec(),
12217 )
12218 .unwrap();
12219
12220 assert!(
12222 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12223 "the cause histogram did not render: {body}",
12224 );
12225
12226 for secret in [
12229 "a.example",
12230 "b.example",
12231 "c.example",
12232 "d.example",
12233 "SENTINEL_CONNREFUSED",
12234 "SENTINEL_DNSFAIL",
12235 "SENTINEL_404",
12236 "SENTINEL_UNPARSEABLE",
12237 ] {
12238 assert!(
12239 !body.contains(secret),
12240 "{secret:?} reached the PUBLIC stats page: {body}",
12241 );
12242 }
12243 }
12244
12245 #[tokio::test]
12248 async fn health_checks_the_database_and_reports_the_loops() {
12249 let state = test_state(&[]).await;
12250 let body_of = |state: AppState| async move {
12251 let resp = router(state)
12252 .oneshot(
12253 Request::builder()
12254 .uri("/health")
12255 .body(Body::empty())
12256 .unwrap(),
12257 )
12258 .await
12259 .unwrap();
12260 let status = resp.status();
12261 let body = String::from_utf8(
12262 axum::body::to_bytes(resp.into_body(), usize::MAX)
12263 .await
12264 .unwrap()
12265 .to_vec(),
12266 )
12267 .unwrap();
12268 (status, body)
12269 };
12270
12271 state
12274 .runtime_health
12275 .set_started_at(chrono::Utc::now().timestamp());
12276
12277 let (status, body) = body_of(state.clone()).await;
12278 assert_eq!(status, StatusCode::OK);
12279 assert!(
12280 body.contains("db: ok"),
12281 "health did not probe the DB: {body}"
12282 );
12283 assert!(
12284 body.contains("uptime:"),
12285 "no uptime — the first thing anyone asks about a container that may \
12286 be restarting: {body}"
12287 );
12288 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12289 assert!(body.contains("polling-paused: no"), "{body}");
12290 assert!(body.contains("backend:"), "{body}");
12291 assert!(body.contains("oauth-runtime:"), "{body}");
12292
12293 state.runtime_health.set_watermark(true);
12298 state.runtime_health.set_schedulers_enabled(true);
12299 let (status, body) = body_of(state.clone()).await;
12300 assert_eq!(
12301 status,
12302 StatusCode::OK,
12303 "a watermark pause must not fail the liveness check: {body}"
12304 );
12305 assert!(body.contains("polling-paused: yes"), "{body}");
12306 assert!(
12309 body.contains("poller: not-yet-ticked"),
12310 "a never-ticked poller must say so: {body}"
12311 );
12312
12313 let stale_after = health_tick_stale_secs(configured_poll_tick());
12315 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12316 state.runtime_health.poll_tick_completed(long_ago);
12317 let (status, body) = body_of(state.clone()).await;
12318 assert_eq!(
12319 status,
12320 StatusCode::OK,
12321 "a stale poller must not 503: {body}"
12322 );
12323 assert!(body.contains("poller: stale"), "{body}");
12324
12325 state.runtime_health.poll_tick_completed(0); state
12333 .runtime_health
12334 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12335 let (status, body) = body_of(state.clone()).await;
12336 assert_eq!(status, StatusCode::OK);
12337 assert!(
12338 body.contains("poller: stale never-ticked"),
12339 "a poller that never ticked long after boot still reads as benign: {body}"
12340 );
12341
12342 state.db.close().await;
12345 let (status, body) = body_of(state.clone()).await;
12346 assert_eq!(
12347 status,
12348 StatusCode::SERVICE_UNAVAILABLE,
12349 "an unreachable database must fail the check: {body}"
12350 );
12351 assert!(body.starts_with("FAIL"), "{body}");
12352 assert!(
12356 !body.contains("PoolClosed") && !body.contains("sqlx"),
12357 "health leaked the raw database error to an unauthenticated caller: {body}"
12358 );
12359 }
12360
12361 #[test]
12367 fn the_stale_threshold_follows_the_poll_tick() {
12368 assert_eq!(
12371 health_tick_stale_secs(Duration::from_secs(60)),
12372 HEALTH_TICK_STALE_FLOOR_SECS
12373 );
12374 let slow = Duration::from_secs(30 * 60);
12377 assert!(
12378 health_tick_stale_secs(slow) > slow.as_secs() as i64,
12379 "a 30-minute tick must not be stale after one interval"
12380 );
12381 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12382 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12384 }
12385
12386 #[tokio::test]
12392 async fn stats_does_not_call_a_stopped_poller_running() {
12393 let state = test_state(&[]).await;
12394 let render = |state: AppState| async move {
12395 let resp = router(state)
12396 .oneshot(
12397 Request::builder()
12398 .uri("/stats")
12399 .body(Body::empty())
12400 .unwrap(),
12401 )
12402 .await
12403 .unwrap();
12404 assert_eq!(resp.status(), StatusCode::OK);
12405 String::from_utf8(
12406 axum::body::to_bytes(resp.into_body(), usize::MAX)
12407 .await
12408 .unwrap()
12409 .to_vec(),
12410 )
12411 .unwrap()
12412 };
12413
12414 let body = render(state.clone()).await;
12416 assert!(
12417 body.contains("the poller is not running on this instance"),
12418 "a disabled poller renders as healthy"
12419 );
12420
12421 state.runtime_health.set_schedulers_enabled(true);
12423 let body = render(state.clone()).await;
12424 assert!(
12425 body.contains("no poll has finished since this instance booted"),
12426 "a poller that has not ticked renders as healthy"
12427 );
12428
12429 state
12431 .runtime_health
12432 .poll_tick_completed(chrono::Utc::now().timestamp());
12433 let body = render(state.clone()).await;
12434 assert!(
12435 body.contains("running"),
12436 "a healthy poller must read as running"
12437 );
12438
12439 state.runtime_health.set_watermark(true);
12441 let body = render(state.clone()).await;
12442 assert!(
12443 body.contains("the cache is at its size limit"),
12444 "a watermark pause is hidden once the poller is ticking"
12445 );
12446 }
12447
12448 #[tokio::test]
12459 async fn health_reports_an_unmeasured_database_without_failing() {
12460 use crate::runtime_health::DbProbe;
12461 let state = test_state(&[]).await;
12462
12463 let held = state
12466 .runtime_health
12467 .begin_db_probe()
12468 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12469
12470 let resp = router(state.clone())
12471 .oneshot(
12472 Request::builder()
12473 .uri("/health")
12474 .body(Body::empty())
12475 .unwrap(),
12476 )
12477 .await
12478 .unwrap();
12479 let status = resp.status();
12480 let body = String::from_utf8(
12481 axum::body::to_bytes(resp.into_body(), usize::MAX)
12482 .await
12483 .unwrap()
12484 .to_vec(),
12485 )
12486 .unwrap();
12487 drop(held);
12488
12489 assert_eq!(
12490 status,
12491 StatusCode::OK,
12492 "an unmeasured database failed the check, which an unauthenticated \
12493 caller can cause on demand: {body}"
12494 );
12495 assert!(
12496 body.contains("db: unknown"),
12497 "the unmeasured state must still be REPORTED: {body}"
12498 );
12499 assert!(!body.starts_with("FAIL"), "{body}");
12500 assert!(
12505 !body.starts_with("ok"),
12506 "the unmeasured state is indistinguishable from healthy to a \
12507 body-matching monitor: {body}"
12508 );
12509 assert!(body.starts_with("unknown"), "{body}");
12510
12511 let held = state
12522 .runtime_health
12523 .begin_db_probe()
12524 .unwrap_or_else(|_| panic!("claim"));
12525 state
12526 .runtime_health
12527 .record_for_test(DbProbe::Failed("unavailable".to_string()));
12528 let resp = router(state.clone())
12529 .oneshot(
12530 Request::builder()
12531 .uri("/health")
12532 .body(Body::empty())
12533 .unwrap(),
12534 )
12535 .await
12536 .unwrap();
12537 let status = resp.status();
12538 let body = String::from_utf8(
12539 axum::body::to_bytes(resp.into_body(), usize::MAX)
12540 .await
12541 .unwrap()
12542 .to_vec(),
12543 )
12544 .unwrap();
12545 drop(held);
12546 assert_eq!(
12547 status,
12548 StatusCode::SERVICE_UNAVAILABLE,
12549 "a BORROWED failure verdict must fail the check, not just a freshly \
12550 measured one: {body}"
12551 );
12552 assert!(body.starts_with("FAIL"), "{body}");
12553
12554 state.db.close().await;
12555 let resp = router(state.clone())
12556 .oneshot(
12557 Request::builder()
12558 .uri("/health")
12559 .body(Body::empty())
12560 .unwrap(),
12561 )
12562 .await
12563 .unwrap();
12564 assert_eq!(
12565 resp.status(),
12566 StatusCode::SERVICE_UNAVAILABLE,
12567 "a measured database failure must still fail the check"
12568 );
12569 }
12570
12571 #[tokio::test]
12580 async fn an_abandoned_request_still_records_its_probe() {
12581 use crate::runtime_health::DbProbe;
12582 let state = test_state(&[]).await;
12583 let rh = state.runtime_health.clone();
12584
12585 let app = router(state.clone());
12587 let fut = app.oneshot(
12588 Request::builder()
12589 .uri("/health")
12590 .body(Body::empty())
12591 .unwrap(),
12592 );
12593 let handle = tokio::spawn(fut);
12594 handle.abort();
12595 let _ = handle.await;
12596
12597 for _ in 0..50 {
12600 if rh.begin_db_probe().is_ok() {
12601 break;
12602 }
12603 tokio::time::sleep(Duration::from_millis(20)).await;
12604 }
12605 let resp = router(state.clone())
12606 .oneshot(
12607 Request::builder()
12608 .uri("/health")
12609 .body(Body::empty())
12610 .unwrap(),
12611 )
12612 .await
12613 .unwrap();
12614 let body = String::from_utf8(
12615 axum::body::to_bytes(resp.into_body(), usize::MAX)
12616 .await
12617 .unwrap()
12618 .to_vec(),
12619 )
12620 .unwrap();
12621 assert!(
12622 body.contains("db: ok"),
12623 "after an abandoned request the next caller still reads an \
12624 unmeasured database — the probe was cancelled with it: {body}"
12625 );
12626 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12628 }
12629
12630 #[tokio::test]
12637 async fn the_health_probe_opens_a_real_table() {
12638 use sqlx::Row;
12639 let state = test_state(&[]).await;
12640 let opcodes = |sql: &'static str| {
12642 let db = state.db.clone();
12643 async move {
12644 sqlx::query(sql)
12645 .fetch_all(&db)
12646 .await
12647 .unwrap()
12648 .into_iter()
12649 .map(|r| r.get::<String, _>("opcode"))
12650 .collect::<Vec<String>>()
12651 }
12652 };
12653
12654 let explain: &'static str =
12657 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12658 let probe = opcodes(explain).await;
12659 assert!(
12661 health_db_probe(&state.db).await.is_ok(),
12662 "the probe does not run against the real schema",
12663 );
12664 assert!(
12665 probe.iter().any(|op| op == "OpenRead"),
12666 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12667 );
12668 let bare = opcodes("EXPLAIN SELECT 1").await;
12670 assert!(
12671 !bare.iter().any(|op| op == "OpenRead"),
12672 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12673 );
12674 }
12675
12676 #[test]
12679 fn an_instance_that_has_never_polled_says_so() {
12680 assert_eq!(humanise_ago(None), "never");
12681 assert_eq!(humanise_ago(Some(0)), "0s ago");
12682 assert_eq!(humanise_ago(Some(59)), "59s ago");
12683 assert_eq!(humanise_ago(Some(60)), "1m ago");
12684 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12685 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12686 }
12687
12688 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12691 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12692 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12693 let addr = listener.local_addr().unwrap();
12694 let (url, title) = (saved_url.to_string(), saved_title.to_string());
12695 tokio::spawn(async move {
12696 loop {
12697 let Ok((mut sock, _)) = listener.accept().await else {
12698 break;
12699 };
12700 let mut buf = vec![0u8; 8192];
12701 let Ok(n) = sock.read(&mut buf).await else {
12702 continue;
12703 };
12704 let req = String::from_utf8_lossy(&buf[..n]).to_string();
12705 let wants_saved = req.contains("community.lexicon.rss.saved");
12706 let records = if wants_saved {
12707 serde_json::json!([{
12708 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12709 "cid": "bafy",
12710 "value": {
12711 "$type": "community.lexicon.rss.saved",
12712 "url": url,
12713 "title": title,
12714 "createdAt": "2026-01-01T00:00:00Z"
12715 }
12716 }])
12717 } else {
12718 serde_json::json!([])
12719 };
12720 let body = serde_json::json!({
12721 "ok": true, "data": { "records": records }
12722 })
12723 .to_string();
12724 let resp = format!(
12725 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12726 body.len(), body
12727 );
12728 let _ = sock.write_all(resp.as_bytes()).await;
12729 let _ = sock.flush().await;
12730 }
12731 });
12732 format!("http://{addr}")
12733 }
12734
12735 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12738 let feed = subscribed_feed.to_string();
12739 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12740 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12741 let addr = listener.local_addr().unwrap();
12742 tokio::spawn(async move {
12743 loop {
12744 let Ok((mut sock, _)) = listener.accept().await else {
12745 break;
12746 };
12747 let mut buf = vec![0u8; 8192];
12748 let Ok(read) = sock.read(&mut buf).await else {
12749 continue;
12750 };
12751 let req = String::from_utf8_lossy(&buf[..read]).to_string();
12752 let records = if req.contains("community.lexicon.rss.saved") {
12753 serde_json::Value::Array(
12754 (0..n)
12755 .map(|i| {
12756 serde_json::json!({
12757 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12758 "cid": "bafy",
12759 "value": {
12760 "$type": "community.lexicon.rss.saved",
12761 "url": format!("https://elsewhere.example/{i}"),
12762 "title": format!("Elsewhere {i}"),
12763 "createdAt": "2026-01-01T00:00:00Z"
12764 }
12765 })
12766 })
12767 .collect(),
12768 )
12769 } else if req.contains("community.lexicon.rss.subscription") {
12770 serde_json::json!([{
12775 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12776 "cid": "bafy",
12777 "value": {
12778 "$type": "community.lexicon.rss.subscription",
12779 "url": feed,
12780 "createdAt": "2026-01-01T00:00:00Z"
12781 }
12782 }])
12783 } else {
12784 serde_json::json!([])
12785 };
12786 let body =
12787 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12788 let resp = format!(
12789 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12790 body.len(), body
12791 );
12792 let _ = sock.write_all(resp.as_bytes()).await;
12793 let _ = sock.flush().await;
12794 }
12795 });
12796 format!("http://{addr}")
12797 }
12798
12799 #[tokio::test]
12807 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12808 let did = "did:plc:pagerloop";
12809 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12810 let state = test_state_with_sidecar(&[], &sidecar).await;
12811 store::grant_access(&state.db, did, None, "test", None)
12812 .await
12813 .unwrap();
12814 let feed = store::upsert_feed(
12815 &state.db,
12816 &store::NewFeed {
12817 url: "https://loop.example/feed.xml".to_string(),
12818 title: Some("Loop".to_string()),
12819 ..Default::default()
12820 },
12821 )
12822 .await
12823 .unwrap();
12824 let entries: Vec<store::NewEntry> = (0..250)
12827 .map(|i| store::NewEntry {
12828 guid: format!("s-{i:04}"),
12829 url: Some(format!("https://loop.example/{i}")),
12830 title: Some(format!("Starred {i:04}")),
12831 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12832 ..Default::default()
12833 })
12834 .collect();
12835 store::insert_entries(&state.db, feed, &entries, 0)
12836 .await
12837 .unwrap();
12838 store::replace_sub_refs(&state.db, did, &[feed])
12839 .await
12840 .unwrap();
12841 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12842 .await
12843 .unwrap()
12844 {
12845 store::mark_starred(&state.db, did, row.id, true)
12846 .await
12847 .unwrap();
12848 }
12849
12850 let cookie = session_cookie(&state, did, None);
12851 let app = router(state.clone());
12852 let get = |uri: &str| {
12853 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12854 async move {
12855 let resp = app
12856 .oneshot(
12857 Request::builder()
12858 .uri(uri)
12859 .header(header::COOKIE, cookie)
12860 .body(Body::empty())
12861 .unwrap(),
12862 )
12863 .await
12864 .unwrap();
12865 assert_eq!(resp.status(), StatusCode::OK);
12866 String::from_utf8(
12867 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
12868 .await
12869 .unwrap()
12870 .to_vec(),
12871 )
12872 .unwrap()
12873 }
12874 };
12875
12876 let p3 = get("/?view=starred&page=3").await;
12881 assert!(
12882 p3.contains("Page 3 of 4"),
12883 "the pager and the clamp disagree on the total: {}",
12884 p3.split("pager-pos")
12885 .nth(1)
12886 .unwrap_or("")
12887 .chars()
12888 .take(120)
12889 .collect::<String>()
12890 );
12891 assert!(
12894 p3.contains("Elsewhere 0"),
12895 "page 3 should start the uncached run"
12896 );
12897 assert_eq!(
12898 p3.matches("<li class=\"entry").count(),
12899 ENTRIES_PER_PAGE as usize,
12900 "the boundary page is not full"
12901 );
12902
12903 {
12912 let body = &p3;
12913 assert!(
12914 body.contains("330 entries"),
12915 "the heading must count the whole sequence: {}",
12916 body.split("content-count")
12917 .nth(1)
12918 .unwrap_or("")
12919 .chars()
12920 .take(120)
12921 .collect::<String>()
12922 );
12923 assert!(
12924 body.contains("(80 saved elsewhere)"),
12925 "the heading must say how many of the total the cache cannot show, \
12926 as a whole-list figure and not a per-page one: {}",
12927 body.split("content-count")
12928 .nth(1)
12929 .unwrap_or("")
12930 .chars()
12931 .take(120)
12932 .collect::<String>()
12933 );
12934 assert!(
12935 !body.contains("plus 50") && !body.contains("plus 80"),
12936 "the heading is adding the uncached rows to a total that already \
12937 includes them"
12938 );
12939 }
12940
12941 let p4 = get("/?view=starred&page=4").await;
12942 assert!(
12943 p4.contains("Page 4 of 4"),
12944 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
12945 );
12946 assert_eq!(
12947 p4.matches("<li class=\"entry").count(),
12948 30,
12949 "page 4 should hold the remaining 30 uncached records"
12950 );
12951 assert!(
12952 p4.contains("Elsewhere 79"),
12953 "the LAST saved record is unreachable — it can only be removed from here"
12954 );
12955
12956 assert!(
12958 !p4.contains("Elsewhere 0"),
12959 "an uncached record was rendered on more than one page"
12960 );
12961 let first = get("/?view=starred").await;
12964 assert!(
12965 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
12966 "the heading changed between pages; it describes the list, not the page"
12967 );
12968 assert!(
12969 !first.contains("Elsewhere "),
12970 "uncached saved records leaked onto the first page"
12971 );
12972 }
12973
12974 #[tokio::test]
12981 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
12982 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
12983 let sidecar =
12984 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
12985 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
12986 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
12987
12988 let resp = router(state)
12989 .oneshot(
12990 Request::builder()
12991 .uri("/?view=starred")
12992 .body(Body::empty())
12993 .unwrap(),
12994 )
12995 .await
12996 .unwrap();
12997 assert_eq!(resp.status(), StatusCode::OK);
12998 let body = String::from_utf8(
12999 axum::body::to_bytes(resp.into_body(), usize::MAX)
13000 .await
13001 .unwrap()
13002 .to_vec(),
13003 )
13004 .unwrap();
13005
13006 assert!(
13007 body.contains("Starred elsewhere"),
13008 "the saved record was not rendered at all"
13009 );
13010 assert!(
13011 body.contains("entry-uncached"),
13012 "it was not marked as uncached, so it looks like a normal entry"
13013 );
13014 assert!(
13015 body.contains("https://elsewhere.example/article"),
13016 "the row must link straight to the article"
13017 );
13018 assert!(
13019 !body.contains("/entries/0/"),
13020 "an uncached row must not offer entry actions against a nonexistent id"
13021 );
13022 }
13023
13024 #[test]
13032 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13033 for hostile in [
13034 "日本語日本語日本",
13035 "é",
13036 "",
13037 "2026",
13038 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13039 ] {
13040 let out = display_date(Some(hostile));
13041 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13042 }
13043 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13044 assert_eq!(display_date(None), "");
13045 }
13046
13047 #[test]
13050 fn the_unsave_route_is_rate_limited() {
13051 use axum::http::Method;
13052 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13053 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13055 }
13056
13057 #[tokio::test]
13066 async fn health_reports_a_broken_database() {
13067 let state = test_state(&[]).await;
13068 assert!(
13070 health_db_probe(&state.db).await.is_ok(),
13071 "the fixture was not healthy to begin with",
13072 );
13073
13074 sqlx::query("DROP TABLE feeds")
13075 .execute(&state.db)
13076 .await
13077 .unwrap();
13078
13079 assert!(
13080 health_db_probe(&state.db).await.is_err(),
13081 "the probe reported success against a database missing the table it \
13082 claims to read; `SELECT 1` would do exactly this",
13083 );
13084
13085 let resp = router(state)
13086 .oneshot(
13087 Request::builder()
13088 .uri("/health")
13089 .body(Body::empty())
13090 .unwrap(),
13091 )
13092 .await
13093 .unwrap();
13094 let body = String::from_utf8(
13095 axum::body::to_bytes(resp.into_body(), usize::MAX)
13096 .await
13097 .unwrap()
13098 .to_vec(),
13099 )
13100 .unwrap();
13101 assert!(
13103 body.starts_with("FAIL"),
13104 "/health did not report FAIL for a broken database: {body}",
13105 );
13106 assert!(
13107 !body.contains("db: ok"),
13108 "/health still called the database ok: {body}",
13109 );
13110 }
13111
13112 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13117 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13118 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13119 let addr = listener.local_addr().unwrap();
13120 tokio::spawn(async move {
13121 loop {
13122 let Ok((mut sock, _)) = listener.accept().await else {
13123 break;
13124 };
13125 let mut buf = vec![0u8; 8192];
13126 let Ok(n) = sock.read(&mut buf).await else {
13127 continue;
13128 };
13129 let req = String::from_utf8_lossy(&buf[..n]).to_string();
13130 let wants = |c: &str| req.contains(c);
13131 if fail_on.is_some_and(wants) {
13132 let body = r#"{"ok":false,"error":"ShortList"}"#;
13133 let resp = format!(
13134 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13135 body.len(),
13136 body
13137 );
13138 let _ = sock.write_all(resp.as_bytes()).await;
13139 let _ = sock.flush().await;
13140 continue;
13141 }
13142 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13143 serde_json::json!([{
13144 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13145 "cid": "bafy",
13146 "value": {
13147 "$type": crate::lexicon::nsid::SUBSCRIPTION,
13148 "url": "https://kept.example/feed.xml",
13149 "title": "Kept",
13150 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13155 "createdAt": "2026-01-01T00:00:00Z"
13156 }
13157 }])
13158 } else if wants(crate::lexicon::nsid::FOLDER) {
13159 serde_json::json!([{
13160 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13161 "cid": "bafy",
13162 "value": {
13163 "$type": crate::lexicon::nsid::FOLDER,
13164 "name": "Kept folder",
13165 "createdAt": "2026-01-01T00:00:00Z"
13166 }
13167 }])
13168 } else {
13169 serde_json::json!([])
13170 };
13171 let body =
13172 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13173 let resp = format!(
13174 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13175 body.len(),
13176 body
13177 );
13178 let _ = sock.write_all(resp.as_bytes()).await;
13179 let _ = sock.flush().await;
13180 }
13181 });
13182 format!("http://{addr}")
13183 }
13184
13185 async fn spawn_malformed_sidecar() -> String {
13188 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13189 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13190 let addr = listener.local_addr().unwrap();
13191 tokio::spawn(async move {
13192 loop {
13193 let Ok((mut sock, _)) = listener.accept().await else {
13194 break;
13195 };
13196 let mut buf = vec![0u8; 8192];
13197 let _ = sock.read(&mut buf).await;
13198 let body = serde_json::json!({ "ok": true, "data": { "records": [
13199 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13200 { "cid": "bafy", "value": {} },
13201 ]}})
13202 .to_string();
13203 let resp = format!(
13204 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13205 body.len(),
13206 body
13207 );
13208 let _ = sock.write_all(resp.as_bytes()).await;
13209 let _ = sock.flush().await;
13210 }
13211 });
13212 format!("http://{addr}")
13213 }
13214
13215 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13216 let cookie = session_cookie(&state, did, None);
13217 let resp = router(state)
13218 .oneshot(
13219 Request::builder()
13220 .uri(uri)
13221 .header(header::COOKIE, cookie)
13222 .body(Body::empty())
13223 .unwrap(),
13224 )
13225 .await
13226 .unwrap();
13227 let status = resp.status();
13228 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13229 .await
13230 .unwrap();
13231 (status, String::from_utf8_lossy(&body).to_string())
13232 }
13233
13234 #[tokio::test]
13240 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13241 let did = "did:plc:displayer";
13242 let state = test_state(&[did]).await;
13243 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13244 let feed_id = store::upsert_feed(
13245 &state.db,
13246 &store::NewFeed {
13247 url: url.into(),
13248 title: Some("Quiet Journal".into()),
13249 ..Default::default()
13250 },
13251 )
13252 .await
13253 .unwrap();
13254 store::replace_sub_refs(&state.db, did, &[feed_id])
13255 .await
13256 .unwrap();
13257 store::insert_entries(
13258 &state.db,
13259 feed_id,
13260 &[store::NewEntry {
13261 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13262 .into(),
13263 url: Some("https://quiet.example/no-summary".into()),
13264 title: Some("A title-only article".into()),
13265 published: Some("2026-07-11T00:00:00Z".into()),
13266 content_html: None,
13267 ..Default::default()
13268 }],
13269 0,
13270 )
13271 .await
13272 .unwrap();
13273 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13274 assert_eq!(status, StatusCode::OK);
13275 assert!(
13276 list.contains("A title-only article"),
13277 "the entry is missing from the list"
13278 );
13279
13280 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13281 .bind(feed_id)
13282 .fetch_one(&state.db)
13283 .await
13284 .unwrap();
13285 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13286 assert_eq!(
13287 status,
13288 StatusCode::OK,
13289 "the article page failed for an entry with no body"
13290 );
13291 assert!(page.contains("A title-only article"));
13292 assert!(
13293 page.contains("https://quiet.example/no-summary"),
13294 "no link to the original"
13295 );
13296 assert!(
13297 page.contains(r#"<time datetime=""#),
13298 "no date on the article page"
13299 );
13300 }
13301
13302 #[tokio::test]
13307 async fn a_malformed_subscription_record_raises_an_alert() {
13308 let did = "did:plc:alerted";
13309 for page in ["/", "/manage"] {
13310 let sidecar = spawn_malformed_sidecar().await;
13311 let state = test_state_with_sidecar(&[did], &sidecar).await;
13312 let (status, body) = page_body(state, did, page).await;
13313 assert_eq!(status, StatusCode::OK, "{page} did not render");
13314 assert!(
13315 body.contains(r#"role="alert""#) && body.contains("could not be read"),
13316 "{page} rendered no alert for a refused subscription list"
13317 );
13318 assert!(
13319 body.contains("1 record(s) in your subscription list"),
13320 "{page} gave the generic alert, not the malformed-record one"
13321 );
13322 }
13323 }
13324
13325 #[tokio::test]
13327 async fn a_healthy_subscription_listing_raises_no_alert() {
13328 let did = "did:plc:exporter";
13329 let sidecar = spawn_export_sidecar(None).await;
13330 let state = test_state_with_sidecar(&[did], &sidecar).await;
13331 let (status, body) = page_body(state, did, "/").await;
13332 assert_eq!(status, StatusCode::OK);
13333 assert!(
13334 !body.contains("could not be read"),
13335 "a healthy listing raised an alert"
13336 );
13337 }
13338
13339 async fn export_opml_response(
13341 fail_on: Option<&'static str>,
13342 ) -> (StatusCode, HeaderMap, String) {
13343 let did = "did:plc:exporter";
13344 let sidecar = spawn_export_sidecar(fail_on).await;
13345 let state = test_state_with_sidecar(&[did], &sidecar).await;
13346 let cookie = session_cookie(&state, did, None);
13347 let resp = router(state)
13348 .oneshot(
13349 Request::builder()
13350 .uri("/opml/export")
13351 .header(header::COOKIE, cookie)
13352 .body(Body::empty())
13353 .unwrap(),
13354 )
13355 .await
13356 .unwrap();
13357 let status = resp.status();
13358 let headers = resp.headers().clone();
13359 let body = String::from_utf8_lossy(
13360 &axum::body::to_bytes(resp.into_body(), usize::MAX)
13361 .await
13362 .unwrap(),
13363 )
13364 .to_string();
13365 (status, headers, body)
13366 }
13367
13368 #[tokio::test]
13381 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13382 let (status, headers, body) =
13383 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13384
13385 assert_ne!(
13386 status,
13387 StatusCode::OK,
13388 "a failed subscription walk answered 200: {body}",
13389 );
13390 assert!(
13391 !headers.contains_key(header::CONTENT_DISPOSITION),
13392 "a failed subscription walk still offered a download: {headers:?}",
13393 );
13394 assert!(
13395 !body.contains("<opml"),
13396 "a failed subscription walk still served an OPML document: {body}",
13397 );
13398 }
13399
13400 #[tokio::test]
13404 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13405 let (status, headers, body) =
13406 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13407
13408 assert_ne!(
13409 status,
13410 StatusCode::OK,
13411 "a failed folder walk answered 200: {body}",
13412 );
13413 assert!(
13414 !headers.contains_key(header::CONTENT_DISPOSITION),
13415 "a failed folder walk still offered a download: {headers:?}",
13416 );
13417 assert!(
13418 !body.contains("<opml"),
13419 "a failed folder walk still served an OPML document: {body}",
13420 );
13421 }
13422
13423 #[tokio::test]
13426 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13427 let (status, headers, body) = export_opml_response(None).await;
13428
13429 assert_eq!(
13430 status,
13431 StatusCode::OK,
13432 "a healthy export did not answer 200"
13433 );
13434 assert_eq!(
13435 headers
13436 .get(header::CONTENT_DISPOSITION)
13437 .and_then(|v| v.to_str().ok()),
13438 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13439 "a healthy export did not offer the download",
13440 );
13441 assert!(
13442 body.contains("https://kept.example/feed.xml"),
13443 "the exported OPML lost the subscription: {body}",
13444 );
13445 assert!(
13446 body.contains("Kept folder"),
13447 "the exported OPML lost the folder: {body}",
13448 );
13449 }
13450}