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("/stats", get(stats))
234 .route("/privacy", get(privacy))
235 .route("/terms", get(terms))
236 .route("/manage", get(manage))
237 .route("/", get(index))
238 .route("/entries/{id}", get(entry_view))
239 .route("/entries/{id}/read", post(mark_read))
240 .route("/entries/{id}/star", post(toggle_star))
241 .route("/saved/{rkey}/delete", post(unsave_record))
242 .route("/read-all", post(mark_all_read))
243 .route("/subscriptions", post(add_subscription))
244 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
245 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
246 .route("/folders", post(create_folder))
247 .route("/folders/{rkey}/rename", post(rename_folder))
248 .route("/folders/{rkey}/delete", post(delete_folder))
249 .route(
252 "/opml",
253 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
254 )
255 .route("/opml/export", get(export_opml))
256 .route("/login", get(login_form).post(login_submit))
257 .route(
258 "/beta/redeem",
259 get(beta_redeem_form).post(beta_redeem_submit),
260 )
261 .route("/claim", get(claim))
264 .route("/bot/claims", post(bot_mint_claim))
267 .route("/admin/invites", post(admin_mint_invites))
268 .route("/admin/metrics", get(admin_metrics))
269 .route("/oauth/client-metadata.json", get(oauth_client_metadata))
270 .route("/oauth/jwks.json", get(oauth_jwks))
271 .route("/account/delete", post(account_delete))
272 .route("/oauth/callback", get(oauth_callback))
273 .route("/logout", post(logout))
274 .nest_service("/static", ServeDir::new("static"))
275 .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
279 .layer(middleware::from_fn(cache_control))
284 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
287 .layer(TraceLayer::new_for_http())
288 .layer(static_header_layer(
292 "content-security-policy",
293 CONTENT_SECURITY_POLICY,
294 ))
295 .layer(static_header_layer("x-content-type-options", "nosniff"))
296 .layer(static_header_layer(
297 "referrer-policy",
298 "strict-origin-when-cross-origin",
299 ))
300 .layer(static_header_layer("x-frame-options", "DENY"))
301 .with_state(state)
302}
303
304const OPML_BODY_LIMIT: usize = 1024 * 1024;
319
320#[cfg(test)]
330const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
331
332#[cfg(test)]
337const _: () = assert!(
338 OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
339 "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
340);
341
342fn static_header_layer(
346 name: &'static str,
347 value: &'static str,
348) -> SetResponseHeaderLayer<header::HeaderValue> {
349 SetResponseHeaderLayer::overriding(
350 header::HeaderName::from_static(name),
351 header::HeaderValue::from_static(value),
352 )
353}
354
355fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
375 use axum::http::Method;
376 if method != Method::POST
384 && !(method == Method::GET
385 && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
386 {
387 return false;
388 }
389 match path {
390 "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
395 | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
396 | "/folders" => true,
397 p => {
400 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
401 || p.starts_with("/saved/")
405 || p.starts_with("/subscriptions/")
406 || p.starts_with("/folders/")
407 }
408 }
409}
410
411#[derive(Clone)]
414struct RateLimitState {
415 limiter: RateLimiter,
416 trusted_header: Option<String>,
419}
420
421#[derive(Clone)]
426struct RateLimiter {
427 inner: std::sync::Arc<Mutex<RateLimiterState>>,
428}
429
430struct RateLimiterState {
432 buckets: HashMap<IpAddr, Bucket>,
433 last_sweep: Instant,
434}
435
436struct Bucket {
438 tokens: f64,
439 last: Instant,
440}
441
442const RATE_BURST: f64 = 20.0;
444const RATE_REFILL_PER_SEC: f64 = 1.0;
446const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
448
449const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
458
459const MAX_RATE_BUCKETS: usize = 10_000;
467
468const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
472
473impl RateLimiter {
474 fn shared() -> Self {
476 Self {
477 inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
478 buckets: HashMap::new(),
479 last_sweep: Instant::now(),
480 })),
481 }
482 }
483
484 fn check(&self, ip: IpAddr) -> bool {
487 self.check_at(ip, Instant::now())
488 }
489
490 fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
493 let mut state = match self.inner.lock() {
494 Ok(m) => m,
495 Err(p) => p.into_inner(),
497 };
498
499 if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
501 state
502 .buckets
503 .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
504 state.last_sweep = now;
505 }
506
507 if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
515 let mut by_age: Vec<(IpAddr, Instant)> =
516 state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
517 by_age.sort_unstable_by_key(|(_, last)| *last);
518 for (victim, _) in by_age
519 .into_iter()
520 .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
521 {
522 state.buckets.remove(&victim);
523 }
524 warn!(
525 buckets = state.buckets.len(),
526 "rate-limit bucket cap reached; evicted the least recently seen clients"
527 );
528 }
529
530 let bucket = state.buckets.entry(ip).or_insert(Bucket {
531 tokens: RATE_BURST,
532 last: now,
533 });
534 let elapsed = now.duration_since(bucket.last).as_secs_f64();
535 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
536 bucket.last = now;
537 if bucket.tokens >= 1.0 {
538 bucket.tokens -= 1.0;
539 true
540 } else {
541 false
542 }
543 }
544}
545
546fn client_ip(
567 headers: &HeaderMap,
568 conn: Option<&SocketAddr>,
569 trusted_header: Option<&str>,
570) -> Option<IpAddr> {
571 if let Some(name) = trusted_header {
572 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
573 if let Some(last) = raw.split(',').next_back() {
576 if let Ok(ip) = last.trim().parse::<IpAddr>() {
577 return Some(ip);
578 }
579 }
580 }
581 }
583 conn.map(|s| s.ip())
584}
585
586async fn rate_limit(
591 State(rl): State<RateLimitState>,
592 req: axum::extract::Request,
593 next: Next,
594) -> Response {
595 let path = req.uri().path().to_string();
596 let method = req.method().clone();
597 if is_rate_limited_path(&path, &method) {
598 let conn = req
599 .extensions()
600 .get::<ConnectInfo<SocketAddr>>()
601 .map(|c| c.0);
602 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
603 if let Some(ip) = ip {
609 if !rl.limiter.check(ip) {
610 warn!(%ip, %path, "rate limit exceeded");
611 return (
612 StatusCode::TOO_MANY_REQUESTS,
613 [(header::RETRY_AFTER, "1")],
614 "rate limit exceeded\n",
615 )
616 .into_response();
617 }
618 }
619 }
620 next.run(req).await
621}
622
623async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
634 let path = req.uri().path().to_string();
635 let is_login_landing = path == "/login"
638 && req.method() == axum::http::Method::GET
639 && !req.uri().query().unwrap_or("").contains("handle=");
640 let public = is_login_landing
641 || path == "/about"
642 || path == "/privacy"
643 || path == "/terms"
644 || path.starts_with("/static/");
645
646 let mut resp = next.run(req).await;
647 if resp.headers().contains_key(header::CACHE_CONTROL) {
648 return resp;
649 }
650 let value = if public {
651 "public, max-age=300"
652 } else {
653 "no-store"
654 };
655 if let Ok(hv) = header::HeaderValue::from_str(value) {
656 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
657 }
658 resp
659}
660
661async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
670 sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
671 .fetch_optional(pool)
672 .await
673}
674
675const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
682
683const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
689
690const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
698
699fn health_tick_stale_secs(tick: Duration) -> i64 {
703 let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
704 tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
705}
706
707fn configured_poll_tick() -> Duration {
711 std::env::var("FEATHERREADER_POLL_TICK_SECS")
712 .ok()
713 .and_then(|v| v.trim().parse::<u64>().ok())
714 .filter(|s| *s > 0)
715 .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
716}
717
718const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
723
724const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
736
737async fn health(State(state): State<AppState>) -> Response {
781 let now = chrono::Utc::now().timestamp();
782 let rh = &state.runtime_health;
783
784 use crate::runtime_health::DbProbe;
785 let db = match rh.begin_db_probe() {
786 Err(borrowed) => borrowed,
789 Ok(probe) => {
790 let pool = state.db.clone();
800 let task = tokio::spawn(async move {
801 let verdict =
811 match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
812 Ok(Ok(_)) => DbProbe::Ok,
813 Ok(Err(err)) => {
818 warn!(%err, "health: database probe failed");
819 DbProbe::Failed("unavailable".to_string())
820 }
821 Err(_) => {
822 warn!(
823 timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
824 "health: database probe timed out (pool exhausted?)"
825 );
826 DbProbe::Failed("timeout".to_string())
827 }
828 };
829 probe.record(verdict.clone());
830 verdict
831 });
832 task.await.unwrap_or(DbProbe::Unknown)
836 }
837 };
838
839 let uptime = rh.uptime_secs(now);
840 let poller = if !rh.schedulers_enabled() {
841 "disabled".to_string()
844 } else {
845 match rh.secs_since_poll_tick(now) {
846 None => match uptime {
849 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
850 format!("stale never-ticked {up}s")
851 }
852 _ => "not-yet-ticked".to_string(),
853 },
854 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
855 format!("stale {secs}s")
856 }
857 Some(secs) => format!("ok {secs}s"),
858 }
859 };
860
861 let mut body = String::new();
870 let status = match &db {
871 DbProbe::Ok => {
872 body.push_str(&format!("ok featherreader/{VERSION}\n"));
873 body.push_str("db: ok\n");
874 StatusCode::OK
875 }
876 DbProbe::Unknown => {
883 body.push_str(&format!("unknown featherreader/{VERSION}\n"));
884 body.push_str("db: unknown (no probe has completed yet)\n");
885 StatusCode::OK
886 }
887 DbProbe::Failed(why) => {
888 body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
889 body.push_str(&format!("db: {why}\n"));
890 StatusCode::SERVICE_UNAVAILABLE
891 }
892 };
893 body.push_str(&format!(
897 "uptime: {}\n",
898 match uptime {
899 Some(secs) => format!("{secs}s"),
900 None => "unknown".to_string(),
901 }
902 ));
903 body.push_str(&format!("poller: {poller}\n"));
904 body.push_str(&format!(
905 "polling-paused: {}\n",
906 if rh.watermark_paused() { "yes" } else { "no" }
907 ));
908 body.push_str(&format!(
915 "backend: {}\n",
916 state.config.repo_backend.as_str()
917 ));
918 body.push_str(&format!(
919 "oauth-runtime: {}\n",
920 if state.oauth.is_some() {
921 "built"
922 } else {
923 "absent"
924 }
925 ));
926
927 let mut resp = (status, body).into_response();
930 if let Ok(hv) = header::HeaderValue::from_str("no-store") {
931 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
932 }
933 resp
934}
935
936async fn about(State(state): State<AppState>) -> Response {
945 let adoption = if state.config.show_adoption {
946 adoption_line(&state).await
947 } else {
948 None
949 };
950 render(&AboutTemplate {
951 version: VERSION,
952 repo_url: REPO_URL,
953 kofi_url: KOFI_URL,
954 adoption,
955 standard_site: state.config.standard_site,
956 })
957}
958
959async fn unsave_record(
974 State(state): State<AppState>,
975 headers: HeaderMap,
976 Path(rkey): Path<String>,
977) -> Response {
978 let Some(did) = current_did(&state, &headers).await else {
979 return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
980 };
981
982 let identity = match state.repo().list_saved(&did).await {
987 Ok(records) => records
988 .into_iter()
989 .find(|(k, _)| *k == rkey)
990 .map(|(_, rec)| (rec.url, rec.entry_id)),
991 Err(err) => {
992 warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
993 a local star for the same article may survive");
994 None
995 }
996 };
997
998 match state.repo().remove_saved(&did, &rkey).await {
999 Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1000 Err(err) => {
1001 warn!(%err, %did, %rkey, "could not remove the saved record");
1002 return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1003 }
1004 }
1005
1006 if let Some((url, guid)) = identity {
1010 match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1011 Ok(0) => {}
1012 Ok(n) => {
1013 info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1014 }
1015 Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1016 }
1017 }
1018 if is_htmx(&headers) {
1020 return (StatusCode::OK, "").into_response();
1021 }
1022 Redirect::to("/?view=starred").into_response()
1023}
1024
1025fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1038 if !rh.schedulers_enabled() {
1039 return "off";
1040 }
1041 match rh.secs_since_poll_tick(now_unix) {
1044 None => {
1045 match rh.uptime_secs(now_unix) {
1048 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1049 _ => "starting",
1050 }
1051 }
1052 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1053 _ if rh.watermark_paused() => "paused",
1054 _ => "running",
1055 }
1056}
1057
1058async fn stats(State(state): State<AppState>) -> Response {
1060 let now = chrono::Utc::now();
1061 let health = match store::poll_health(
1062 &state.db,
1063 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1064 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1065 )
1066 .await
1067 {
1068 Ok(health) => health,
1069 Err(err) => {
1070 warn!(%err, "could not compute poll health");
1071 return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1072 }
1073 };
1074
1075 let polled_pct = if health.feeds_tracked == 0 {
1078 100
1079 } else {
1080 health.polled_last_hour * 100 / health.feeds_tracked
1081 };
1082
1083 render(&StatsTemplate {
1084 version: VERSION,
1085 repo_url: REPO_URL,
1086 kofi_url: KOFI_URL,
1087 feeds_tracked: health.feeds_tracked,
1088 polled_last_hour: health.polled_last_hour,
1089 polled_pct,
1090 overdue: health.overdue,
1091 last_poll: humanise_ago(health.last_poll_secs_ago),
1092 oldest_poll: if health.never_polled > 0 {
1093 "never".to_string()
1094 } else {
1095 humanise_ago(health.oldest_poll_secs_ago)
1096 },
1097 never_polled: health.never_polled,
1098 poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1099 in_backoff: health.in_backoff,
1108 badly_broken: health.badly_broken,
1109 failure_kinds: health.failure_kinds,
1110 fetching: fetching_state(&state.runtime_health, now.timestamp()),
1111 })
1112}
1113
1114fn humanise_ago(secs: Option<i64>) -> String {
1119 let Some(secs) = secs else {
1120 return "never".to_string();
1121 };
1122 match secs {
1123 s if s < 60 => format!("{s}s ago"),
1124 s if s < 3600 => format!("{}m ago", s / 60),
1125 s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1126 }
1127}
1128
1129async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1137 match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1138 Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1140 repos: stat.value,
1141 truncated: stat.truncated,
1142 observed_on: stat
1143 .observed_at
1144 .split('T')
1145 .next()
1146 .unwrap_or_default()
1147 .to_string(),
1148 }),
1149 Ok(_) => None,
1150 Err(err) => {
1151 warn!(%err, "about: adoption stat read failed; omitting the line");
1152 None
1153 }
1154 }
1155}
1156
1157async fn privacy() -> Response {
1161 render(&PrivacyTemplate {
1162 version: VERSION,
1163 repo_url: REPO_URL,
1164 kofi_url: KOFI_URL,
1165 })
1166}
1167
1168async fn terms() -> Response {
1172 render(&TermsTemplate {
1173 version: VERSION,
1174 repo_url: REPO_URL,
1175 kofi_url: KOFI_URL,
1176 })
1177}
1178
1179struct FeedView {
1186 rkey: String,
1188 url: String,
1190 title: String,
1191 unread: i64,
1192 selected: bool,
1194 folder: Option<String>,
1199}
1200
1201struct FolderView {
1203 rkey: String,
1205 uri: String,
1207 name: String,
1208 feeds: Vec<FeedView>,
1209 selected: bool,
1211}
1212
1213struct EntryRow {
1215 id: i64,
1216 title: String,
1217 feed_title: String,
1218 published: String,
1219 read: bool,
1220 starred: bool,
1221 link: SafeLink,
1224 cached: bool,
1232 rkey: String,
1234}
1235
1236struct FolderOption {
1238 uri: String,
1239 name: String,
1240}
1241
1242struct Nav {
1247 handle: String,
1249 avatar: String,
1251 view: String,
1253 scope_qs: String,
1256 folders: Vec<FolderView>,
1259 loose_feeds: Vec<FeedView>,
1260 manage_active: bool,
1262}
1263
1264pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1273
1274#[derive(Template)]
1276#[template(path = "index.html")]
1277struct IndexTemplate {
1278 version: &'static str,
1279 repo_url: &'static str,
1280 kofi_url: &'static str,
1281 flash: String,
1282 alert: String,
1285 nav: Nav,
1287 entries: Vec<EntryRow>,
1289 heading: String,
1291 feed_scope: Option<String>,
1293 total: i64,
1301 uncached_total: i64,
1309 page: i64,
1311 page_count: i64,
1313 prev_href: Option<String>,
1315 next_href: Option<String>,
1317}
1318
1319#[derive(Template)]
1321#[template(path = "manage.html")]
1322struct ManageTemplate {
1323 version: &'static str,
1324 repo_url: &'static str,
1325 kofi_url: &'static str,
1326 flash: String,
1327 alert: String,
1329 nav: Nav,
1330 folder_options: Vec<FolderOption>,
1332 folders: Vec<FolderView>,
1334 loose_feeds: Vec<FeedView>,
1335 standard_site: bool,
1341}
1342
1343struct AdoptionLine {
1348 repos: i64,
1350 truncated: bool,
1352 observed_on: String,
1354}
1355
1356#[derive(Template)]
1359#[template(path = "about.html")]
1360struct AboutTemplate {
1361 version: &'static str,
1362 repo_url: &'static str,
1363 kofi_url: &'static str,
1364 adoption: Option<AdoptionLine>,
1365 standard_site: bool,
1368}
1369
1370#[derive(Template)]
1382#[template(path = "stats.html")]
1383struct StatsTemplate {
1384 version: &'static str,
1385 repo_url: &'static str,
1386 kofi_url: &'static str,
1387 feeds_tracked: i64,
1388 polled_last_hour: i64,
1389 polled_pct: i64,
1390 overdue: i64,
1391 last_poll: String,
1392 oldest_poll: String,
1393 never_polled: i64,
1394 poll_interval_mins: i64,
1395 in_backoff: i64,
1397 badly_broken: i64,
1401 failure_kinds: Vec<(String, i64)>,
1403 fetching: &'static str,
1407}
1408
1409#[derive(Template)]
1413#[template(path = "privacy.html")]
1414struct PrivacyTemplate {
1415 version: &'static str,
1416 repo_url: &'static str,
1417 kofi_url: &'static str,
1418}
1419
1420#[derive(Template)]
1423#[template(path = "terms.html")]
1424struct TermsTemplate {
1425 version: &'static str,
1426 repo_url: &'static str,
1427 kofi_url: &'static str,
1428}
1429
1430#[derive(Template)]
1433#[template(path = "landing.html")]
1434struct LandingTemplate {
1435 version: &'static str,
1436 repo_url: &'static str,
1437 crates_url: &'static str,
1438 kofi_url: &'static str,
1439 standard_site: bool,
1442}
1443
1444#[derive(Template)]
1446#[template(path = "entry.html")]
1447struct EntryTemplate {
1448 version: &'static str,
1449 repo_url: &'static str,
1450 kofi_url: &'static str,
1451 nav: Nav,
1452 id: i64,
1453 title: String,
1454 feed_title: String,
1455 author: Option<String>,
1456 published: String,
1457 url: Option<SafeLink>,
1467 content_html: Option<String>,
1468 read: bool,
1469 starred: bool,
1470 back_qs: String,
1472 prev_id: Option<i64>,
1474 next_id: Option<i64>,
1475 oob: bool,
1477}
1478
1479#[derive(Template)]
1481#[template(path = "entry_row.html")]
1482struct EntryRowTemplate {
1483 e: EntryRow,
1484}
1485
1486#[derive(Template)]
1491#[template(path = "entry_actionbar.html")]
1492struct EntryActionBarTemplate {
1493 id: i64,
1494 read: bool,
1495 starred: bool,
1496 oob: bool,
1498}
1499
1500#[derive(Template)]
1502#[template(path = "login.html")]
1503struct LoginTemplate {
1504 repo_url: &'static str,
1505 error: String,
1506 flash: String,
1509}
1510
1511#[derive(Template)]
1513#[template(path = "beta_redeem.html")]
1514struct BetaRedeemTemplate {
1515 repo_url: &'static str,
1516 error: String,
1517 capacity_full: bool,
1520}
1521
1522fn render<T: Template>(tmpl: &T) -> Response {
1529 match tmpl.render() {
1530 Ok(body) => Html(body).into_response(),
1531 Err(err) => {
1532 warn!(%err, "template render failed");
1533 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1534 }
1535 }
1536}
1537
1538struct WebError {
1543 err: anyhow::Error,
1544 status: StatusCode,
1545}
1546
1547impl<E: Into<anyhow::Error>> From<E> for WebError {
1548 fn from(err: E) -> Self {
1549 WebError {
1550 err: err.into(),
1551 status: StatusCode::INTERNAL_SERVER_ERROR,
1552 }
1553 }
1554}
1555
1556impl WebError {
1557 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1559 WebError {
1560 err: err.into(),
1561 status,
1562 }
1563 }
1564}
1565
1566impl IntoResponse for WebError {
1567 fn into_response(self) -> Response {
1568 warn!(error = %self.err, status = %self.status, "request failed");
1569 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1570 "internal error"
1571 } else {
1572 self.status.canonical_reason().unwrap_or("error")
1573 };
1574 (self.status, body).into_response()
1575 }
1576}
1577
1578fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1583 let status = err.status();
1584 WebError::with_status(err, status)
1585}
1586
1587fn display_title(title: Option<&str>, url: &str) -> String {
1590 if let Some(t) = title {
1591 let t = t.trim();
1592 if !t.is_empty() {
1593 return t.to_string();
1594 }
1595 }
1596 url::Url::parse(url)
1597 .ok()
1598 .and_then(|u| u.host_str().map(str::to_string))
1599 .unwrap_or_else(|| url.to_string())
1600}
1601
1602fn display_handle(handle: Option<&str>, did: &str) -> String {
1605 match handle {
1606 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1607 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1608 }
1609}
1610
1611fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1613 let source = handle
1614 .map(|h| h.trim().trim_start_matches('@'))
1615 .filter(|h| !h.is_empty())
1616 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1617 let letters: String = source
1618 .chars()
1619 .filter(|c| c.is_alphanumeric())
1620 .take(2)
1621 .collect::<String>()
1622 .to_lowercase();
1623 if letters.is_empty() {
1624 "fr".to_string()
1625 } else {
1626 letters
1627 }
1628}
1629
1630fn display_date(published: Option<&str>) -> String {
1633 match published {
1642 Some(p) => p.chars().take(10).collect(),
1643 None => String::new(),
1644 }
1645}
1646
1647fn qenc(s: &str) -> String {
1651 let mut out = String::with_capacity(s.len() * 3);
1652 for b in s.bytes() {
1653 match b {
1654 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1655 out.push(b as char)
1656 }
1657 _ => out.push_str(&format!("%{b:02X}")),
1658 }
1659 }
1660 out
1661}
1662
1663#[derive(Debug, Deserialize, Default)]
1669struct IndexQuery {
1670 #[serde(default)]
1672 feed: Option<String>,
1673 #[serde(default)]
1675 folder: Option<String>,
1676 #[serde(default)]
1678 view: Option<String>,
1679 #[serde(default)]
1681 page: Option<u32>,
1682 #[serde(default)]
1684 flash: Option<String>,
1685}
1686
1687const ENTRIES_PER_PAGE: i64 = 100;
1695
1696fn page_count_for(total: i64) -> i64 {
1699 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1700}
1701
1702const PREV_NEXT_MAX: i64 = 5_000;
1709
1710const STARRED_IDENTITY_MAX: i64 = 20_000;
1718
1719const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1733
1734struct ResolvedSub {
1737 rkey: String,
1738 sub: Subscription,
1739 feed: Option<store::Feed>,
1740}
1741
1742async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1746 resolve_subscriptions_noting(state, did).await.0
1747}
1748
1749fn subscriptions_alert(err: &anyhow::Error) -> String {
1756 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
1757 Some(m) => format!(
1758 "{} record(s) in your subscription list could not be read, so it was not \
1759 refreshed. Showing your last-known subscriptions; nothing was removed.",
1760 m.count
1761 ),
1762 None => "Your subscription list could not be read from your PDS just now. \
1763 Showing your last-known subscriptions."
1764 .to_string(),
1765 }
1766}
1767
1768async fn resolve_subscriptions_noting(
1771 state: &AppState,
1772 did: &str,
1773) -> (Vec<ResolvedSub>, Option<String>) {
1774 let pool = &state.db;
1775 let subs = match state.repo().list_subscriptions_sorted(did).await {
1776 Ok(s) => s,
1777 Err(err) => {
1778 let alert = subscriptions_alert(&err);
1779 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
1780 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
1793 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
1794 projection could not be read; rendering an EMPTY \
1795 feed list, which is not the same as having none");
1796 Vec::new()
1797 });
1798 let cached = feeds
1799 .into_iter()
1800 .map(|f| ResolvedSub {
1801 rkey: String::new(),
1802 sub: Subscription::new(f.url.clone(), now_rfc3339()),
1803 feed: Some(f),
1804 })
1805 .collect();
1806 return (cached, Some(alert));
1807 }
1808 };
1809
1810 let mut out = Vec::with_capacity(subs.len());
1826 for (rkey, sub) in subs {
1827 let feed = match store::get_feed_by_url(pool, &sub.url).await {
1828 Ok(Some(f)) => Some(f),
1829 Ok(None) => {
1830 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
1841 || feed::classify_feed_privacy(&sub.url).is_private()
1842 {
1843 warn!(
1844 %did,
1845 "skipping cache row for a subscription URL that is private or not http(s)"
1846 );
1847 out.push(ResolvedSub {
1848 rkey,
1849 sub,
1850 feed: None,
1851 });
1852 continue;
1853 }
1854 if let Err(err) = store::upsert_feed(
1862 pool,
1863 &store::NewFeed {
1864 url: sub.url.clone(),
1865 title: sub.title.clone(),
1866 site_url: sub.site_url.clone(),
1867 ..Default::default()
1868 },
1869 )
1870 .await
1871 {
1872 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
1873 it will not be polled");
1874 }
1875 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
1876 }
1877 Err(err) => {
1878 warn!(%err, url = %sub.url, "get_feed_by_url failed");
1879 None
1880 }
1881 };
1882 out.push(ResolvedSub { rkey, sub, feed });
1883 }
1884 sync_sub_refs(pool, did, &out).await;
1888 (out, None)
1889}
1890
1891async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
1895 let feed_ids: Vec<i64> = subs
1896 .iter()
1897 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
1898 .collect();
1899 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
1900 warn!(%err, %did, "failed to sync sub_ref projection");
1901 }
1902}
1903
1904async fn index(
1907 State(state): State<AppState>,
1908 headers: HeaderMap,
1909 Query(q): Query<IndexQuery>,
1910) -> Result<Response, WebError> {
1911 let user = match current_session(&state, &headers).await {
1912 Some(u) => u,
1913 None => {
1916 return Ok(render(&LandingTemplate {
1917 version: VERSION,
1918 repo_url: REPO_URL,
1919 crates_url: CRATES_URL,
1920 kofi_url: KOFI_URL,
1921 standard_site: state.config.standard_site,
1922 }))
1923 }
1924 };
1925 let did = user.did.clone();
1926 let pool = &state.db;
1927
1928 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
1929
1930 let view = match q.view.as_deref() {
1932 Some("all") => "all",
1933 Some("starred") => "starred",
1934 _ => "unread",
1935 }
1936 .to_string();
1937 let list_view = list_view_of(q.view.as_deref());
1938
1939 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1941 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
1946
1947 let feed_title_by_id = |id: i64| -> String {
1948 subs.iter()
1949 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1950 .map(|s| {
1951 display_title(
1952 s.sub
1953 .title
1954 .as_deref()
1955 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1956 &s.sub.url,
1957 )
1958 })
1959 .unwrap_or_default()
1960 };
1961
1962 let mut uncached: Vec<EntryRow> = Vec::new();
1975 if view == "starred" {
1976 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2005 Ok(store::StarredIdentities::All(rows)) => Some(rows),
2006 Ok(store::StarredIdentities::Truncated) => {
2011 warn!(
2012 %did,
2013 cap = STARRED_IDENTITY_MAX,
2014 "cached-starred set exceeded its cap; suppressing uncached saved rows \
2015 rather than rendering record-deleting buttons for cached articles"
2016 );
2017 None
2018 }
2019 Err(err) => {
2020 warn!(%err, %did, "cached-starred identity lookup failed; \
2021 suppressing uncached saved rows this render");
2022 None
2023 }
2024 };
2025 let identities_ok = identities.is_some();
2032 let identities = identities.unwrap_or_default();
2033 let cached_urls: std::collections::HashSet<&str> = identities
2034 .iter()
2035 .filter_map(|(url, _)| url.as_deref())
2036 .collect();
2037 let cached_guids: std::collections::HashSet<&str> =
2038 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2039
2040 let mut uncached_dropped = 0usize;
2053 match state.repo().list_saved_sorted(&did).await {
2054 Ok(saved) if identities_ok => {
2055 for (rkey, item) in saved {
2056 let known = cached_urls.contains(item.url.as_str())
2057 || item
2058 .entry_id
2059 .as_deref()
2060 .is_some_and(|g| cached_guids.contains(g));
2061 if known {
2062 continue;
2063 }
2064 if let Some(urls) = &scope_urls {
2068 match item.feed_url.as_deref() {
2069 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2070 _ => continue,
2074 }
2075 }
2076 let link = SafeLink::external(&item.url);
2102 if link.is_empty() {
2103 warn!(
2104 %did, %rkey,
2105 "a saved record has an unusable URL; rendering it without a link \
2106 so it can still be removed"
2107 );
2108 }
2109
2110 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2124 uncached_dropped += 1;
2125 continue;
2126 }
2127 if let Some(feed_url) = item.feed_url.as_deref() {
2128 if subs.iter().any(|s| s.sub.url == feed_url) {
2129 let stale_before = (chrono::Utc::now()
2133 - chrono::Duration::from_std(state.config.poll_interval)
2134 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2135 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2136 if let Err(err) =
2137 store::mark_feed_due(pool, feed_url, &stale_before).await
2138 {
2139 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2140 }
2141 }
2142 }
2143 uncached.push(EntryRow {
2144 id: 0,
2145 title: item
2146 .title
2147 .clone()
2148 .filter(|t| !t.trim().is_empty())
2149 .unwrap_or_else(|| {
2157 if link.is_empty() {
2158 format!("Saved item {rkey}")
2159 } else {
2160 item.url.clone()
2161 }
2162 }),
2163 feed_title: item.feed_url.clone().unwrap_or_default(),
2164 published: display_date(Some(&item.created_at)),
2165 read: false,
2166 starred: true,
2167 link,
2171 cached: false,
2172 rkey,
2173 });
2174 }
2175 }
2176 Ok(_) => {}
2178 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2179 }
2180 if uncached_dropped > 0 {
2181 warn!(
2182 %did,
2183 dropped = uncached_dropped,
2184 cap = MAX_UNCACHED_SAVED_ROWS,
2185 "more saved records than this instance will hold in one response; the \
2186 rest are not reachable from here"
2187 );
2188 }
2189 }
2190
2191 let total_cached =
2207 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2208 let uncached_len = uncached.len();
2209 let total = total_cached + uncached_len as i64;
2210 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2215 let offset = (page - 1) * ENTRIES_PER_PAGE;
2216 let source = store::list_entries(
2219 pool,
2220 &did,
2221 list_view,
2222 scope_ids.as_deref(),
2223 ENTRIES_PER_PAGE,
2224 offset,
2225 )
2226 .await?;
2227 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2247 let cached_here = cached_allotment.min(source.len());
2248 let source = if uncached_len == 0 {
2253 &source[..]
2254 } else {
2255 &source[..cached_here]
2256 };
2257 let uncached_page: Vec<EntryRow> = {
2258 let skip = (offset - total_cached).max(0) as usize;
2259 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2260 uncached.into_iter().skip(skip).take(take).collect()
2261 };
2262 let uncached_total = uncached_len as i64;
2265
2266 let entry_scope_qs = {
2268 let mut parts = Vec::new();
2269 if let Some(f) = q.feed.as_deref() {
2270 parts.push(format!("feed={}", qenc(f)));
2271 }
2272 if let Some(f) = q.folder.as_deref() {
2273 parts.push(format!("folder={}", qenc(f)));
2274 }
2275 if view != "unread" {
2276 parts.push(format!("view={}", qenc(&view)));
2277 }
2278 parts.join("&")
2279 };
2280 let entries: Vec<EntryRow> = source
2281 .iter()
2282 .map(|e| EntryRow {
2283 id: e.id,
2284 title: e
2285 .title
2286 .clone()
2287 .filter(|t| !t.trim().is_empty())
2288 .unwrap_or_else(|| "(untitled)".to_string()),
2289 feed_title: feed_title_by_id(e.feed_id),
2290 published: display_date(e.published.as_deref()),
2291 read: e.read,
2296 starred: e.starred,
2297 link: SafeLink::entry(e.id, &entry_scope_qs),
2298 cached: true,
2299 rkey: String::new(),
2300 })
2301 .collect();
2302
2303 let mut entries = entries;
2305 entries.extend(uncached_page);
2306 let entries = entries;
2307
2308 let selected_feed = q.feed.as_deref();
2309 let selected_folder = q.folder.as_deref();
2310
2311 let (folder_views, loose_feeds, _folder_options) =
2313 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2314
2315 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2317 let name = subs
2318 .iter()
2319 .find(|s| s.sub.url == feed_url)
2320 .map(|s| {
2321 display_title(
2322 s.sub
2323 .title
2324 .as_deref()
2325 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2326 &s.sub.url,
2327 )
2328 })
2329 .unwrap_or_else(|| display_title(None, feed_url));
2330 (name, format!("feed={}", qenc(feed_url)))
2331 } else if let Some(folder_uri) = selected_folder {
2332 let name = folder_views
2333 .iter()
2334 .find(|f| f.uri == folder_uri)
2335 .map(|f| f.name.clone())
2336 .unwrap_or_else(|| "Folder".to_string());
2337 (name, format!("folder={}", qenc(folder_uri)))
2338 } else {
2339 let h = match view.as_str() {
2340 "all" => "All",
2341 "starred" => "Starred",
2342 _ => "Unread",
2343 };
2344 (h.to_string(), String::new())
2345 };
2346
2347 let feed_scope = selected_feed.map(str::to_string);
2348 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2349
2350 let page_href = |n: i64| -> String {
2354 let mut parts = Vec::new();
2355 if !entry_scope_qs.is_empty() {
2356 parts.push(entry_scope_qs.clone());
2357 }
2358 if n > 1 {
2359 parts.push(format!("page={n}"));
2360 }
2361 if parts.is_empty() {
2362 "/".to_string()
2363 } else {
2364 format!("/?{}", parts.join("&"))
2365 }
2366 };
2367 let prev_href = (page > 1).then(|| page_href(page - 1));
2368 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2369
2370 let tmpl = IndexTemplate {
2371 version: VERSION,
2372 repo_url: REPO_URL,
2373 kofi_url: KOFI_URL,
2374 flash: q.flash.unwrap_or_default(),
2375 alert: alert.unwrap_or_default(),
2376 nav,
2377 entries,
2378 heading,
2379 feed_scope,
2380 total,
2381 uncached_total,
2384 page,
2385 page_count: page_count_for(total),
2386 prev_href,
2387 next_href,
2388 };
2389 Ok(render(&tmpl))
2390}
2391
2392#[derive(Debug, Deserialize, Default)]
2394struct ManageQuery {
2395 #[serde(default)]
2396 flash: Option<String>,
2397}
2398
2399async fn manage(
2404 State(state): State<AppState>,
2405 headers: HeaderMap,
2406 Query(q): Query<ManageQuery>,
2407) -> Result<Response, WebError> {
2408 let user = match current_session(&state, &headers).await {
2409 Some(u) => u,
2410 None => return Ok(Redirect::to("/login").into_response()),
2411 };
2412 let did = user.did.clone();
2413
2414 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2415 let (folder_views, loose_feeds, folder_options) =
2416 build_sidebar(&state, &did, &subs, None, None).await;
2417
2418 let nav = build_nav(
2420 &user,
2421 "unread",
2422 String::new(),
2423 folder_views.iter().map(clone_folder_view).collect(),
2424 loose_feeds.iter().map(clone_feed_view).collect(),
2425 true,
2426 );
2427
2428 let tmpl = ManageTemplate {
2429 version: VERSION,
2430 repo_url: REPO_URL,
2431 kofi_url: KOFI_URL,
2432 flash: q.flash.unwrap_or_default(),
2433 alert: alert.unwrap_or_default(),
2434 nav,
2435 folder_options,
2436 folders: folder_views,
2437 loose_feeds,
2438 standard_site: state.config.standard_site,
2439 };
2440 Ok(render(&tmpl))
2441}
2442
2443fn clone_feed_view(f: &FeedView) -> FeedView {
2446 FeedView {
2447 rkey: f.rkey.clone(),
2448 url: f.url.clone(),
2449 title: f.title.clone(),
2450 unread: f.unread,
2451 selected: f.selected,
2452 folder: f.folder.clone(),
2453 }
2454}
2455
2456fn clone_folder_view(f: &FolderView) -> FolderView {
2457 FolderView {
2458 rkey: f.rkey.clone(),
2459 uri: f.uri.clone(),
2460 name: f.name.clone(),
2461 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2462 selected: f.selected,
2463 }
2464}
2465
2466fn scope_urls_for(
2471 subs: &[ResolvedSub],
2472 feed: Option<&str>,
2473 folder: Option<&str>,
2474) -> Option<Vec<String>> {
2475 if let Some(feed_url) = feed {
2476 Some(vec![feed_url.to_string()])
2477 } else {
2478 folder.map(|folder_uri| {
2479 subs.iter()
2480 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2481 .map(|s| s.sub.url.clone())
2482 .collect()
2483 })
2484 }
2485}
2486
2487fn folder_uri(did: &str, rkey: &str) -> String {
2489 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2490}
2491
2492async fn build_sidebar(
2496 state: &AppState,
2497 did: &str,
2498 subs: &[ResolvedSub],
2499 selected_feed: Option<&str>,
2500 selected_folder: Option<&str>,
2501) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2502 let pool = &state.db;
2503 let unread_counts = store::unread_counts_by_feed(pool, did)
2508 .await
2509 .unwrap_or_else(|err| {
2510 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2511 Default::default()
2512 });
2513 let folders = state
2514 .repo()
2515 .list_folders_sorted(did)
2516 .await
2517 .unwrap_or_default();
2518
2519 let unread_count = |feed_id: Option<i64>| -> i64 {
2520 feed_id
2521 .and_then(|id| unread_counts.get(&id).copied())
2522 .unwrap_or(0)
2523 };
2524 let mk_feed_view = |s: &ResolvedSub| FeedView {
2525 rkey: s.rkey.clone(),
2526 url: s.sub.url.clone(),
2527 title: display_title(
2528 s.sub
2529 .title
2530 .as_deref()
2531 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2532 &s.sub.url,
2533 ),
2534 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2535 selected: selected_feed == Some(s.sub.url.as_str()),
2536 folder: s.sub.folder.clone(),
2537 };
2538
2539 let mut folder_views = Vec::with_capacity(folders.len());
2540 for (rkey, folder) in &folders {
2541 let uri = folder_uri(did, rkey);
2542 let feeds: Vec<FeedView> = subs
2543 .iter()
2544 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2545 .map(mk_feed_view)
2546 .collect();
2547 folder_views.push(FolderView {
2548 rkey: rkey.clone(),
2549 uri: uri.clone(),
2550 name: folder.name.clone(),
2551 feeds,
2552 selected: selected_folder == Some(uri.as_str()),
2553 });
2554 }
2555
2556 let known_uris: std::collections::HashSet<String> =
2557 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2558 let loose_feeds: Vec<FeedView> = subs
2559 .iter()
2560 .filter(|s| {
2561 s.sub
2562 .folder
2563 .as_deref()
2564 .map(|f| !known_uris.contains(f))
2565 .unwrap_or(true)
2566 })
2567 .map(mk_feed_view)
2568 .collect();
2569
2570 let folder_options: Vec<FolderOption> = folders
2571 .iter()
2572 .map(|(rkey, folder)| FolderOption {
2573 name: folder.name.clone(),
2574 uri: folder_uri(did, rkey),
2575 })
2576 .collect();
2577
2578 (folder_views, loose_feeds, folder_options)
2579}
2580
2581fn build_nav(
2583 user: &CurrentUser,
2584 view: &str,
2585 scope_qs: String,
2586 folders: Vec<FolderView>,
2587 loose_feeds: Vec<FeedView>,
2588 manage_active: bool,
2589) -> Nav {
2590 Nav {
2591 handle: display_handle(user.handle.as_deref(), &user.did),
2592 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2593 view: view.to_string(),
2594 scope_qs,
2595 folders,
2596 loose_feeds,
2597 manage_active,
2598 }
2599}
2600
2601#[derive(Debug, Deserialize, Default)]
2608struct EntryQuery {
2609 #[serde(default)]
2610 feed: Option<String>,
2611 #[serde(default)]
2612 folder: Option<String>,
2613 #[serde(default)]
2614 view: Option<String>,
2615}
2616
2617async fn entry_view(
2620 State(state): State<AppState>,
2621 headers: HeaderMap,
2622 Path(id): Path<i64>,
2623 Query(q): Query<EntryQuery>,
2624) -> Result<Response, WebError> {
2625 let user = match current_session(&state, &headers).await {
2626 Some(u) => u,
2627 None => return Ok(Redirect::to("/login").into_response()),
2628 };
2629 let did = user.did.clone();
2630 let pool = &state.db;
2631
2632 let subs = resolve_subscriptions(&state, &did).await;
2636
2637 let entry = match get_entry_by_id(pool, &did, id).await? {
2638 Some(e) => e,
2639 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2640 };
2641
2642 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2643
2644 let read = entry_is_read(pool, &did, id).await?;
2645 let starred = entry_is_starred(pool, &did, id).await?;
2646
2647 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2650
2651 let back_qs = scope_query(&q);
2652
2653 let (folder_views, loose_feeds, _) =
2654 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2655 let nav_view = match q.view.as_deref() {
2656 Some("all") => "all",
2657 Some("starred") => "starred",
2658 _ => "unread",
2659 };
2660 let nav = build_nav(
2661 &user,
2662 nav_view,
2663 back_qs.clone(),
2664 folder_views,
2665 loose_feeds,
2666 false,
2667 );
2668
2669 let tmpl = EntryTemplate {
2670 version: VERSION,
2671 repo_url: REPO_URL,
2672 kofi_url: KOFI_URL,
2673 nav,
2674 id: entry.id,
2675 title: entry
2676 .title
2677 .clone()
2678 .filter(|t| !t.trim().is_empty())
2679 .unwrap_or_else(|| "(untitled)".to_string()),
2680 feed_title,
2681 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2682 published: display_date(entry.published.as_deref()),
2683 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2684 content_html: entry.content_html.clone(),
2685 read,
2686 starred,
2687 back_qs,
2688 prev_id,
2689 next_id,
2690 oob: false,
2691 };
2692 Ok(render(&tmpl))
2693}
2694
2695async fn neighbors_in_scope(
2698 state: &AppState,
2699 did: &str,
2700 q: &EntryQuery,
2701 current: i64,
2702) -> (Option<i64>, Option<i64>) {
2703 let idx_q = IndexQuery {
2704 feed: q.feed.clone(),
2705 folder: q.folder.clone(),
2706 view: q.view.clone(),
2707 page: None,
2709 flash: None,
2710 };
2711 let ids = list_entry_ids(state, did, &idx_q).await;
2712 let pos = ids.iter().position(|&x| x == current);
2713 match pos {
2714 Some(p) => {
2715 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2716 let next = ids.get(p + 1).copied();
2717 (prev, next)
2718 }
2719 None => (None, None),
2720 }
2721}
2722
2723async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2726 let pool = &state.db;
2727 let subs = resolve_subscriptions(state, did).await;
2728
2729 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2730
2731 store::list_entry_ids(
2737 pool,
2738 did,
2739 list_view_of(q.view.as_deref()),
2740 scoped_feed_ids(&subs, &scope_urls).as_deref(),
2741 PREV_NEXT_MAX,
2742 )
2743 .await
2744 .unwrap_or_else(|err| {
2745 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2746 Vec::new()
2747 })
2748}
2749
2750fn list_view_of(view: Option<&str>) -> store::ListView {
2753 match view {
2754 Some("all") => store::ListView::All,
2755 Some("starred") => store::ListView::Starred,
2756 _ => store::ListView::Unread,
2757 }
2758}
2759
2760fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2766 let urls = scope_urls.as_ref()?;
2767 Some(
2768 subs.iter()
2769 .filter(|s| urls.contains(&s.sub.url))
2770 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2771 .collect(),
2772 )
2773}
2774
2775fn scope_query(q: &EntryQuery) -> String {
2777 let mut parts = Vec::new();
2778 if let Some(f) = q.feed.as_deref() {
2779 parts.push(format!("feed={}", qenc(f)));
2780 }
2781 if let Some(f) = q.folder.as_deref() {
2782 parts.push(format!("folder={}", qenc(f)));
2783 }
2784 if let Some(v) = q.view.as_deref() {
2785 if v != "unread" {
2786 parts.push(format!("view={}", qenc(v)));
2787 }
2788 }
2789 parts.join("&")
2790}
2791
2792#[derive(Debug, Deserialize)]
2798struct ReadForm {
2799 #[serde(default)]
2800 read: Option<String>,
2801}
2802
2803async fn mark_read(
2805 State(state): State<AppState>,
2806 Path(id): Path<i64>,
2807 headers: HeaderMap,
2808 Form(form): Form<ReadForm>,
2809) -> Result<Response, WebError> {
2810 let did = match current_did(&state, &headers).await {
2811 Some(d) => d,
2812 None => return Ok(Redirect::to("/login").into_response()),
2813 };
2814 let pool = &state.db;
2815
2816 let read = matches!(
2817 form.read.as_deref(),
2818 Some("true") | Some("1") | Some("on") | None
2819 );
2820
2821 resolve_subscriptions(&state, &did).await;
2826 if !store::mark_read(pool, &did, id, read).await? {
2827 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2828 }
2829
2830 if !is_htmx(&headers) {
2831 return Ok(Redirect::to("/").into_response());
2832 }
2833
2834 if is_reader_request(&headers) {
2838 let starred = entry_is_starred(pool, &did, id).await?;
2839 return Ok(render(&EntryActionBarTemplate {
2840 id,
2841 read,
2842 starred,
2843 oob: true,
2844 }));
2845 }
2846
2847 let row = build_entry_row(pool, &did, id, Some(read)).await?;
2848 match row {
2849 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2850 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2851 }
2852}
2853
2854#[derive(Debug, Deserialize)]
2860struct StarForm {
2861 #[serde(default)]
2862 starred: Option<String>,
2863}
2864
2865async fn toggle_star(
2871 State(state): State<AppState>,
2872 Path(id): Path<i64>,
2873 headers: HeaderMap,
2874 Form(form): Form<StarForm>,
2875) -> Result<Response, WebError> {
2876 let did = match current_did(&state, &headers).await {
2877 Some(d) => d,
2878 None => return Ok(Redirect::to("/login").into_response()),
2879 };
2880 let pool = &state.db;
2881
2882 let starred = matches!(
2883 form.starred.as_deref(),
2884 Some("true") | Some("1") | Some("on") | None
2885 );
2886
2887 resolve_subscriptions(&state, &did).await;
2891 if !store::mark_starred(pool, &did, id, starred).await? {
2892 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2893 }
2894
2895 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2898 let entry_url = entry.url.clone().unwrap_or_default();
2899 if !entry_url.is_empty() {
2900 if starred {
2901 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2902 saved.title = entry.title.clone();
2903 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2904 saved.entry_id = Some(entry.guid.clone());
2905 match state.repo().add_saved(&did, &saved).await {
2906 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2907 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2908 }
2909 } else {
2910 match state.repo().list_saved(&did).await {
2912 Ok(records) => {
2913 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2914 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2915 warn!(%err, %did, %rkey, "PDS saved delete failed");
2916 }
2917 }
2918 }
2919 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2920 }
2921 }
2922 }
2923 }
2924
2925 if !is_htmx(&headers) {
2926 return Ok(Redirect::to("/").into_response());
2927 }
2928
2929 if is_reader_request(&headers) {
2931 let read = entry_is_read(pool, &did, id).await?;
2932 return Ok(render(&EntryActionBarTemplate {
2933 id,
2934 read,
2935 starred,
2936 oob: true,
2937 }));
2938 }
2939
2940 let row = build_entry_row(pool, &did, id, None).await?;
2941 match row {
2942 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2943 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2944 }
2945}
2946
2947async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2949 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2950 .bind(feed_id)
2951 .fetch_optional(pool)
2952 .await
2953 .ok()
2954 .flatten()
2955}
2956
2957#[derive(Debug, Deserialize, Default)]
2964struct ReadAllQuery {
2965 #[serde(default)]
2966 feed: Option<String>,
2967}
2968
2969async fn mark_all_read(
2972 State(state): State<AppState>,
2973 headers: HeaderMap,
2974 Query(q): Query<ReadAllQuery>,
2975) -> Result<Response, WebError> {
2976 let did = match current_did(&state, &headers).await {
2977 Some(d) => d,
2978 None => return Ok(Redirect::to("/login").into_response()),
2979 };
2980 let pool = &state.db;
2981
2982 resolve_subscriptions(&state, &did).await;
2985
2986 if let Some(feed_url) = q.feed.as_deref() {
2987 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2988 store::mark_feed_read(pool, &did, feed.id, true).await?;
2989 }
2990 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2991 }
2992
2993 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2998 store::mark_feed_read(pool, &did, feed_id, true).await?;
2999 }
3000 Ok(Redirect::to("/").into_response())
3001}
3002
3003const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3013 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3014
3015const EXPORT_INCOMPLETE_REFUSAL: &str =
3022 "Could not read your subscriptions in full, so nothing was exported. Your \
3023 feeds are unchanged — try again, and if it keeps failing the list may be \
3024 larger than this reader can page through.";
3025
3026const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3032 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3033 feeds for now — private-feed support arrives when atproto's private data \
3034 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3035
3036#[derive(Debug, Deserialize)]
3038struct SubscribeForm {
3039 url: String,
3040 #[serde(default)]
3042 folder: Option<String>,
3043}
3044
3045async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3055 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3056 let canonical = format!(
3057 "{}{}",
3058 crate::atproto::AT_URI_PREFIX,
3059 &input[crate::atproto::AT_URI_PREFIX.len()..]
3060 );
3061 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3062 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3063 return Err(unsupported());
3064 }
3065 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3066 uri.authority.clone()
3067 } else {
3068 let handle =
3069 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3074 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3075 .await
3076 .map_err(|err| {
3077 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3078 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3079 })?
3080 };
3081 let url = format!(
3082 "{}{did}/{}/{}",
3083 crate::atproto::AT_URI_PREFIX,
3084 uri.collection,
3085 uri.rkey
3086 );
3087 if !feed::is_storable_feed_url(&url, true) {
3088 return Err(unsupported());
3089 }
3090 Ok(url)
3091}
3092
3093async fn add_subscription(
3095 State(state): State<AppState>,
3096 headers: HeaderMap,
3097 Form(form): Form<SubscribeForm>,
3098) -> Result<Response, WebError> {
3099 let did = match current_did(&state, &headers).await {
3100 Some(d) => d,
3101 None => return Ok(Redirect::to("/login").into_response()),
3102 };
3103 let pool = &state.db;
3104 let input = form.url.trim().to_string();
3105 if input.is_empty() {
3106 return Ok(Redirect::to("/").into_response());
3107 }
3108
3109 let cap = state.config.max_subs_per_did;
3113 if cap > 0 {
3114 match store::count_subscriptions_for_did(pool, &did).await {
3115 Ok(n) if n >= cap => {
3116 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3117 return Ok(Redirect::to(&format!(
3118 "/?flash={}",
3119 qenc(&format!(
3120 "Subscription limit reached ({cap}). Remove a feed before adding another."
3121 ))
3122 ))
3123 .into_response());
3124 }
3125 Ok(_) => {}
3126 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3127 }
3128 }
3129
3130 let is_at_uri = input
3135 .get(..crate::atproto::AT_URI_PREFIX.len())
3136 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3137 let publication_url = if is_at_uri {
3138 if !state.config.standard_site {
3139 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3140 return Ok(
3141 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3142 .into_response(),
3143 );
3144 }
3145 match publication_url_from_paste(&state, &input).await {
3146 Ok(url) => Some(url),
3147 Err(flash) => {
3148 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3149 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3150 }
3151 }
3152 } else {
3153 None
3154 };
3155
3156 if let feed::FeedPrivacy::Private(reason) =
3157 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3158 {
3159 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3160 return Ok(
3161 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3162 );
3163 }
3164
3165 let resolved = match publication_url {
3166 Some(url) => Ok(url),
3167 None => resolve_feed_url(&state.config, &input).await,
3168 };
3169 let feed_url = match resolved {
3170 Ok(u) => u,
3171 Err(err) => {
3172 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3173 return Ok(Redirect::to(&format!(
3174 "/?flash={}",
3175 qenc("Couldn't find a feed at that URL")
3176 ))
3177 .into_response());
3178 }
3179 };
3180
3181 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3185 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3186 return Ok(
3187 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3188 );
3189 }
3190
3191 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3196 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3197 return Ok(
3198 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3199 .into_response(),
3200 );
3201 }
3202
3203 let feeds_cap = state.config.max_feeds_global;
3207 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3208 match store::count_feeds(pool).await {
3209 Ok(n) if n >= feeds_cap => {
3210 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3211 return Ok(Redirect::to(&format!(
3212 "/?flash={}",
3213 qenc(
3214 "This instance is at its feed capacity right now. Please try again later."
3215 )
3216 ))
3217 .into_response());
3218 }
3219 Ok(_) => {}
3220 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3221 }
3222 }
3223
3224 store::upsert_feed(
3225 pool,
3226 &store::NewFeed {
3227 url: feed_url.clone(),
3228 ..Default::default()
3229 },
3230 )
3231 .await?;
3232
3233 if let Ok(client) = feed::build_client() {
3234 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3235 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3236 Ok(outcome) => {
3237 info!(feed = %feed_url, ?outcome, "polled new subscription");
3238 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3242 }
3243 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3244 }
3245 }
3246 }
3247
3248 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3249 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3250 sub.title = feed_row.title.clone();
3251 sub.site_url = feed_row.site_url.clone();
3252 }
3253 sub.folder = form
3254 .folder
3255 .map(|f| f.trim().to_string())
3256 .filter(|f| !f.is_empty());
3257
3258 match state.repo().add_subscription(&did, &sub).await {
3259 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3260 Err(err) => {
3261 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3262 }
3263 }
3264
3265 Ok(Redirect::to("/").into_response())
3266}
3267
3268async fn delete_subscription(
3270 State(state): State<AppState>,
3271 headers: HeaderMap,
3272 Path(rkey): Path<String>,
3273) -> Result<Response, WebError> {
3274 let did = match current_did(&state, &headers).await {
3275 Some(d) => d,
3276 None => return Ok(Redirect::to("/login").into_response()),
3277 };
3278 match state.repo().remove_subscription(&did, &rkey).await {
3279 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3280 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3281 }
3282 Ok(Redirect::to("/").into_response())
3283}
3284
3285#[derive(Debug, Deserialize)]
3287struct RenameSubForm {
3288 url: String,
3289 #[serde(default)]
3290 title: Option<String>,
3291 #[serde(default)]
3292 site_url: Option<String>,
3293 #[serde(default)]
3294 folder: Option<String>,
3295}
3296
3297async fn rename_subscription(
3300 State(state): State<AppState>,
3301 headers: HeaderMap,
3302 Path(rkey): Path<String>,
3303 Form(form): Form<RenameSubForm>,
3304) -> Result<Response, WebError> {
3305 let did = match current_did(&state, &headers).await {
3306 Some(d) => d,
3307 None => return Ok(Redirect::to("/login").into_response()),
3308 };
3309 let feed_url = form.url.trim().to_string();
3310
3311 if feed_url.is_empty() {
3315 return Ok(Redirect::to("/").into_response());
3316 }
3317
3318 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3343 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3344 Err(err) => {
3345 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3346 return Ok(Redirect::to(&format!(
3347 "/?flash={}",
3348 qenc("Could not reach your PDS — nothing was renamed or moved.")
3349 ))
3350 .into_response());
3351 }
3352 };
3353 let Some(existing) = existing else {
3354 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3358 return Ok(Redirect::to(&format!(
3359 "/?flash={}",
3360 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3361 ))
3362 .into_response());
3363 };
3364
3365 let url_changed = existing.url.trim() != feed_url;
3384
3385 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3398 if url_changed && !storable {
3399 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3400 return Ok(
3401 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3402 .into_response(),
3403 );
3404 }
3405
3406 if url_changed {
3412 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3413 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3414 return Ok(
3415 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3416 );
3417 }
3418 }
3419
3420 let feeds_cap = state.config.max_feeds_global;
3425 if url_changed
3426 && feeds_cap > 0
3427 && store::get_feed_by_url(&state.db, &feed_url)
3428 .await?
3429 .is_none()
3430 {
3431 match store::count_feeds(&state.db).await {
3432 Ok(n) if n >= feeds_cap => {
3433 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3434 return Ok(Redirect::to(&format!(
3435 "/?flash={}",
3436 qenc(
3437 "This instance is at its feed capacity right now. Please try again later."
3438 )
3439 ))
3440 .into_response());
3441 }
3442 Ok(_) => {}
3443 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3444 }
3445 }
3446
3447 let mut sub = existing;
3448 sub.url = feed_url;
3449 sub.title = form
3450 .title
3451 .map(|t| t.trim().to_string())
3452 .filter(|t| !t.is_empty());
3453 sub.folder = form
3454 .folder
3455 .map(|f| f.trim().to_string())
3456 .filter(|f| !f.is_empty());
3457 match form
3465 .site_url
3466 .map(|t| t.trim().to_string())
3467 .filter(|t| !t.is_empty())
3468 {
3469 Some(site) => sub.site_url = Some(site),
3470 None if url_changed => sub.site_url = None,
3471 None => {}
3472 }
3473 if url_changed {
3474 sub.fetch_hint = None;
3475 }
3476
3477 let cache_write =
3492 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3493 if !cache_write {
3494 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3495 } else if let Err(err) = store::upsert_feed(
3496 &state.db,
3497 &store::NewFeed {
3498 url: sub.url.clone(),
3499 title: sub.title.clone(),
3500 site_url: sub.site_url.clone(),
3501 ..Default::default()
3502 },
3503 )
3504 .await
3505 {
3506 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3509 }
3510
3511 match state.repo().update_subscription(&did, &rkey, &sub).await {
3519 Ok(res) => {
3520 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3521 Ok(Redirect::to("/").into_response())
3522 }
3523 Err(err) => {
3524 warn!(%err, %did, %rkey, "PDS subscription update failed");
3525 Ok(Redirect::to(&format!(
3526 "/?flash={}",
3527 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3528 ))
3529 .into_response())
3530 }
3531 }
3532}
3533
3534#[derive(Debug, Deserialize)]
3540struct FolderForm {
3541 name: String,
3542}
3543
3544async fn create_folder(
3546 State(state): State<AppState>,
3547 headers: HeaderMap,
3548 Form(form): Form<FolderForm>,
3549) -> Result<Response, WebError> {
3550 let did = match current_did(&state, &headers).await {
3551 Some(d) => d,
3552 None => return Ok(Redirect::to("/login").into_response()),
3553 };
3554 let name = form.name.trim();
3555 if name.is_empty() {
3556 return Ok(Redirect::to("/").into_response());
3557 }
3558 let folder = Folder::new(name.to_string(), now_rfc3339());
3559 match state.repo().add_folder(&did, &folder).await {
3560 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3561 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3562 }
3563 Ok(Redirect::to("/").into_response())
3564}
3565
3566async fn rename_folder(
3568 State(state): State<AppState>,
3569 headers: HeaderMap,
3570 Path(rkey): Path<String>,
3571 Form(form): Form<FolderForm>,
3572) -> Result<Response, WebError> {
3573 let did = match current_did(&state, &headers).await {
3574 Some(d) => d,
3575 None => return Ok(Redirect::to("/login").into_response()),
3576 };
3577 let name = form.name.trim();
3578 if name.is_empty() {
3579 return Ok(Redirect::to("/").into_response());
3580 }
3581 let folder = Folder::new(name.to_string(), now_rfc3339());
3582 match state.repo().rename_folder(&did, &rkey, &folder).await {
3583 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3584 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3585 }
3586 Ok(Redirect::to("/").into_response())
3587}
3588
3589async fn delete_folder(
3592 State(state): State<AppState>,
3593 headers: HeaderMap,
3594 Path(rkey): Path<String>,
3595) -> Result<Response, WebError> {
3596 let did = match current_did(&state, &headers).await {
3597 Some(d) => d,
3598 None => return Ok(Redirect::to("/login").into_response()),
3599 };
3600 match state.repo().remove_folder(&did, &rkey).await {
3601 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3602 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3603 }
3604 Ok(Redirect::to("/").into_response())
3605}
3606
3607async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3611 let parsed =
3612 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3613
3614 let client = feed::build_client()?;
3615 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3619 let final_url = resp.url().clone();
3620 let content_type = resp
3621 .headers()
3622 .get(axum::http::header::CONTENT_TYPE)
3623 .and_then(|v| v.to_str().ok())
3624 .unwrap_or("")
3625 .to_ascii_lowercase();
3626 let raw = crate::net::read_capped(resp).await?;
3629 let body = String::from_utf8_lossy(&raw).into_owned();
3630
3631 let looks_like_feed = content_type.contains("xml")
3632 || content_type.contains("rss")
3633 || content_type.contains("atom")
3634 || content_type.contains("application/feed+json")
3635 || {
3636 let head = body.trim_start();
3637 head.starts_with("<?xml")
3638 || head.starts_with("<rss")
3639 || head.starts_with("<feed")
3640 || head.contains("<rss")
3641 || head.contains("<feed")
3642 };
3643 if looks_like_feed {
3644 return Ok(final_url.to_string());
3645 }
3646
3647 match feed::discover_feed(&body, Some(&final_url)) {
3648 Some(u) => Ok(u.to_string()),
3649 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3650 }
3651}
3652
3653#[derive(Debug, Deserialize, Default)]
3659struct LoginQuery {
3660 #[serde(default)]
3661 handle: Option<String>,
3662 #[serde(default)]
3663 error: Option<String>,
3664 #[serde(default)]
3665 flash: Option<String>,
3666}
3667
3668async fn login_form(
3676 State(state): State<AppState>,
3677 headers: HeaderMap,
3678 Query(q): Query<LoginQuery>,
3679) -> Response {
3680 if let Some(handle) = q
3681 .handle
3682 .map(|h| h.trim().to_string())
3683 .filter(|h| !h.is_empty())
3684 {
3685 if !may_start_oauth(&state, &headers, &handle).await {
3686 return Redirect::to("/beta/redeem").into_response();
3687 }
3688 return start_oauth(&state, &handle).await;
3689 }
3690 render(&LoginTemplate {
3691 repo_url: REPO_URL,
3692 error: q.error.unwrap_or_default(),
3693 flash: q.flash.unwrap_or_default(),
3694 })
3695}
3696
3697async fn login_submit(
3700 State(state): State<AppState>,
3701 headers: HeaderMap,
3702 Form(form): Form<LoginForm>,
3703) -> Response {
3704 let handle = form.handle.trim();
3705 if handle.is_empty() {
3706 return login_error("Enter your atproto handle.");
3707 }
3708 if !may_start_oauth(&state, &headers, handle).await {
3709 return Redirect::to("/beta/redeem").into_response();
3710 }
3711 start_oauth(&state, handle).await
3712}
3713
3714async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3732 may_start_oauth_with(state, headers, handle, |h| async move {
3736 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3737 .await
3738 .ok()
3739 })
3740 .await
3741}
3742
3743async fn may_start_oauth_with<F, Fut>(
3749 state: &AppState,
3750 headers: &HeaderMap,
3751 handle: &str,
3752 resolve: F,
3753) -> bool
3754where
3755 F: FnOnce(String) -> Fut,
3756 Fut: std::future::Future<Output = Option<String>>,
3757{
3758 if let Some(did) = current_did(state, headers).await {
3760 if store::has_beta_access(&state.db, &did)
3761 .await
3762 .unwrap_or(false)
3763 {
3764 return true;
3765 }
3766 }
3767 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3769 return true;
3770 }
3771 match resolve(handle.to_string()).await {
3775 Some(did) => store::has_beta_access(&state.db, &did)
3776 .await
3777 .unwrap_or(false),
3778 None => {
3779 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3780 false
3781 }
3782 }
3783}
3784
3785async fn start_oauth(state: &AppState, handle: &str) -> Response {
3807 match state.config.repo_backend {
3808 crate::metrics::Backend::Sidecar => {
3809 let url = state.sidecar.login_url(handle, None);
3810 info!(%handle, "redirecting to OAuth sidecar login");
3811 Redirect::to(&url).into_response()
3812 }
3813 crate::metrics::Backend::Rust => {
3814 let Some(runtime) = state.oauth.as_deref() else {
3815 warn!("the rust backend is live but its OAuth runtime is absent");
3816 return login_error("Login is not available right now.");
3817 };
3818 match crate::oauth::login::start(
3819 runtime,
3820 &state.http,
3821 &state.db,
3822 handle,
3823 crate::store::now_unix(),
3824 )
3825 .await
3826 {
3827 Ok(started) => {
3828 info!(%handle, "pushed authorization request; redirecting to the PDS");
3829 let mut resp = Redirect::to(&started.authorize_url).into_response();
3830 set_cookie(
3831 &mut resp,
3832 &cookie::sign_value(
3833 OAUTH_BINDING_COOKIE,
3834 &started.binding_token,
3835 &state.config.cookie_secret,
3836 OAUTH_BINDING_MAX_AGE_SECS,
3837 ),
3838 );
3839 resp
3840 }
3841 Err(err) => {
3842 warn!(%err, %handle, "could not start the OAuth login");
3845 login_error("Could not start login for that handle.")
3846 }
3847 }
3848 }
3849 }
3850}
3851
3852fn clear_binding_cookie(resp: &mut Response) {
3856 set_cookie(
3857 resp,
3858 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3859 );
3860}
3861
3862#[derive(Debug, Deserialize)]
3864struct LoginForm {
3865 handle: String,
3866}
3867
3868#[derive(Debug, Deserialize, Default)]
3877struct CallbackQuery {
3878 #[serde(default)]
3880 session_id: Option<String>,
3881 #[serde(default)]
3883 code: Option<String>,
3884 #[serde(default)]
3885 state: Option<String>,
3886 #[serde(default)]
3887 iss: Option<String>,
3888 #[serde(default)]
3891 response: Option<String>,
3892 #[serde(default)]
3893 error: Option<String>,
3894 #[serde(default)]
3895 error_description: Option<String>,
3896}
3897
3898async fn oauth_callback(
3905 State(state): State<AppState>,
3906 headers: HeaderMap,
3907 Query(q): Query<CallbackQuery>,
3908) -> Response {
3909 let sidecar_shape =
3939 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3940 let sidecar_handoff = sidecar_shape
3941 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3942 if let Some(err) = q.error.clone() {
3943 let slug = crate::oauth::flow::known_error_slug(&err);
3959 warn!(
3960 error = slug,
3961 desc_len = q.error_description.as_deref().map_or(0, str::len),
3962 "OAuth callback returned an error"
3963 );
3964 if sidecar_handoff || state.oauth.is_none() {
3965 return login_error(&format!("Login failed: {slug}"));
3966 }
3967 }
3970
3971 let session = if sidecar_handoff {
3974 let session_id = q.session_id.clone().unwrap_or_default();
3975 match state.sidecar.resolve_session(&session_id).await {
3976 Ok(Some(s)) => s,
3977 Ok(None) => {
3978 warn!("OAuth callback session_id did not resolve (expired/unknown)");
3979 return login_error("Login session expired — please try again.");
3980 }
3981 Err(err) => {
3982 warn!(%err, "failed to resolve OAuth session via the sidecar");
3983 return login_error("Login failed talking to the auth service.");
3984 }
3985 }
3986 } else {
3987 let Some(runtime) = state.oauth.as_deref() else {
3988 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3989 return login_error("Login failed: this login could not be completed.");
3990 };
3991 let params = crate::oauth::flow::CallbackParams {
3992 code: q.code.clone(),
3993 state: q.state.clone(),
3994 iss: q.iss.clone(),
3995 error: q.error.clone(),
3999 error_description: q.error_description.clone(),
4000 response: q.response.clone(),
4001 };
4002 let binding =
4003 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4004 match crate::oauth::login::complete(
4005 runtime,
4006 &state.http,
4007 &state.db,
4008 ¶ms,
4009 binding.as_deref(),
4010 crate::store::now_unix(),
4011 )
4012 .await
4013 {
4014 Ok(done) => crate::atproto::SidecarSession {
4015 did: done.did,
4016 handle: done.handle,
4017 },
4018 Err(err) => {
4019 warn!(%err, "could not complete the OAuth callback");
4022 let mut resp = login_error("Login failed — please try again.");
4023 clear_binding_cookie(&mut resp);
4024 return resp;
4025 }
4026 }
4027 };
4028
4029 let mut clear_invite = false;
4032 if !store::has_beta_access(&state.db, &session.did)
4033 .await
4034 .unwrap_or(false)
4035 {
4036 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4038 Some(c) => c,
4039 None => {
4040 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4041 return Redirect::to("/beta/redeem").into_response();
4042 }
4043 };
4044 match store::redeem_code(
4045 &state.db,
4046 &code,
4047 &session.did,
4048 session.handle.as_deref(),
4049 state.config.beta_cap,
4050 )
4051 .await
4052 {
4053 Ok(Ok(())) => {
4054 clear_invite = true;
4055 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4056 }
4057 Ok(Err(policy)) => {
4058 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4059 let mut resp = redeem_bounce(&policy).into_response();
4060 clear_invite_cookie(&mut resp);
4062 return resp;
4063 }
4064 Err(err) => {
4065 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4066 return login_error("Login failed while confirming your invite.");
4067 }
4068 }
4069 }
4070
4071 let sid = state.sessions.create(Session {
4074 did: session.did.clone(),
4075 handle: session.handle.clone(),
4076 });
4077 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4078 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4079
4080 let mut resp = Redirect::to("/").into_response();
4081 set_cookie(&mut resp, &cookie);
4082 clear_binding_cookie(&mut resp);
4083 if clear_invite {
4084 clear_invite_cookie(&mut resp);
4085 }
4086 resp
4087}
4088
4089const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4107
4108async fn flush_before_revoke(state: &AppState, did: &str) {
4121 match tokio::time::timeout(
4122 SIGN_OUT_FLUSH_BUDGET,
4123 crate::readstate::flush_did(state, did),
4124 )
4125 .await
4126 {
4127 Ok(Ok(())) => {}
4128 Ok(Err(err)) => {
4129 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4130 }
4131 Err(_) => warn!(
4132 %did,
4133 budget = ?SIGN_OUT_FLUSH_BUDGET,
4134 "sign-out: final read-state flush timed out; it will park until next sign-in"
4135 ),
4136 }
4137}
4138
4139async fn revoke_everywhere(state: &AppState, did: &str) {
4140 let sidecar_started = std::time::Instant::now();
4150 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4151 Ok(res) => {
4152 info!(%did, revoked = res.revoked, "sidecar session revoked");
4153 true
4154 }
4155 Err(err) => {
4156 warn!(%did, %err, "sidecar revoke failed; continuing");
4157 false
4158 }
4159 };
4160 state.metrics.record(
4161 crate::metrics::Backend::Sidecar,
4162 "oauth_revoke",
4163 sidecar_started.elapsed().as_micros() as u64,
4164 sidecar_ok,
4165 );
4166
4167 if let Some(runtime) = state.oauth.as_deref() {
4168 let revoke_started = std::time::Instant::now();
4169 let outcome = crate::oauth::revoke::sign_out_discovering(
4170 runtime,
4171 &state.http,
4172 &state.db,
4173 did,
4174 crate::store::now_unix(),
4175 )
4176 .await;
4177 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4188 state.metrics.record(
4189 crate::metrics::Backend::Rust,
4190 "oauth_revoke",
4191 revoke_started.elapsed().as_micros() as u64,
4192 revoke_ok,
4193 );
4194 match outcome {
4195 crate::oauth::revoke::Revocation::Revoked => {
4196 info!(%did, "rust OAuth session revoked at the PDS")
4197 }
4198 crate::oauth::revoke::Revocation::NoSession => {}
4199 crate::oauth::revoke::Revocation::Failed(reason) => {
4200 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4201 }
4202 }
4203 }
4204}
4205
4206async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4216 if let Some(user) = current_session(&state, &headers).await {
4217 if let Some(sid) = user.sid {
4220 state.sessions.remove(&sid);
4221 flush_before_revoke(&state, &user.did).await;
4223 revoke_everywhere(&state, &user.did).await;
4224 }
4225 }
4226 let mut resp = Redirect::to("/login").into_response();
4227 set_cookie(
4228 &mut resp,
4229 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4230 );
4231 resp
4232}
4233
4234#[derive(Debug, Deserialize)]
4237struct DeleteAccountForm {
4238 #[serde(default)]
4239 confirm: String,
4240}
4241
4242const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4244
4245async fn account_delete(
4260 State(state): State<AppState>,
4261 headers: HeaderMap,
4262 Form(form): Form<DeleteAccountForm>,
4263) -> Result<Response, WebError> {
4264 let user = match current_session(&state, &headers).await {
4265 Some(u) => u,
4266 None => return Ok(Redirect::to("/login").into_response()),
4267 };
4268 let did = user.did.clone();
4269
4270 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4272 return Ok(Redirect::to(&format!(
4273 "/manage?flash={}",
4274 qenc("Type DELETE to confirm — nothing was deleted.")
4275 ))
4276 .into_response());
4277 }
4278
4279 let counts = store::purge_did_data(&state.db, &did).await?;
4281 info!(
4282 %did,
4283 total = counts.total(),
4284 entry_state = counts.entry_state,
4285 read_cursor = counts.read_cursor,
4286 sub_ref = counts.sub_ref,
4287 beta_access = counts.beta_access,
4288 invite_codes = counts.invite_codes,
4289 "account/delete: local rows purged"
4290 );
4291
4292 revoke_everywhere(&state, &did).await;
4295
4296 if let Some(sid) = user.sid {
4298 state.sessions.remove(&sid);
4299 }
4300 let mut resp = Redirect::to(&format!(
4301 "/login?flash={}",
4302 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4303 ))
4304 .into_response();
4305 set_cookie(
4306 &mut resp,
4307 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4308 );
4309 Ok(resp)
4310}
4311
4312fn login_error(msg: &str) -> Response {
4314 render(&LoginTemplate {
4315 repo_url: REPO_URL,
4316 error: msg.to_string(),
4317 flash: String::new(),
4318 })
4319}
4320
4321#[derive(Debug, Deserialize)]
4327struct RedeemForm {
4328 code: String,
4329}
4330
4331async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4334 let full = store::count_beta_access(&state.db)
4335 .await
4336 .map(|n| n >= state.config.beta_cap)
4337 .unwrap_or(false);
4338 render(&BetaRedeemTemplate {
4339 repo_url: REPO_URL,
4340 error: String::new(),
4341 capacity_full: full,
4342 })
4343}
4344
4345async fn beta_redeem_submit(
4355 State(state): State<AppState>,
4356 Form(form): Form<RedeemForm>,
4357) -> Response {
4358 let code = form.code.trim().to_uppercase();
4359 if code.is_empty() {
4360 return render(&BetaRedeemTemplate {
4361 repo_url: REPO_URL,
4362 error: "Enter your invite code.".to_string(),
4363 capacity_full: false,
4364 });
4365 }
4366
4367 match preflight_code(&state, &code).await {
4368 Ok(()) => {
4369 let cookie = sign_invite(&code, &state.config.cookie_secret);
4370 let mut resp = Redirect::to("/login").into_response();
4371 set_cookie(&mut resp, &cookie);
4372 info!("invite code preflight OK; reserving intent + redirecting to /login");
4373 resp
4374 }
4375 Err(policy) => {
4376 warn!(?policy, "invite code preflight rejected");
4377 redeem_bounce(&policy)
4378 }
4379 }
4380}
4381
4382async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4388 let count = match store::count_beta_access(&state.db).await {
4396 Ok(n) => n,
4397 Err(err) => {
4398 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4399 return Err(store::RedeemError::CapacityFull);
4400 }
4401 };
4402 if count >= state.config.beta_cap {
4403 return Err(store::RedeemError::CapacityFull);
4404 }
4405 let row = sqlx::query_as::<_, (String, i64)>(
4407 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4408 )
4409 .bind(code)
4410 .fetch_optional(&state.db)
4411 .await
4412 .ok()
4413 .flatten();
4414 let (status, expires_at) = match row {
4415 Some(r) => r,
4416 None => return Err(store::RedeemError::NotFound),
4417 };
4418 let now = chrono::Utc::now().timestamp();
4419 match status.as_str() {
4420 "active" if expires_at >= now => Ok(()),
4421 "active" => Err(store::RedeemError::Expired),
4422 "expired" => Err(store::RedeemError::Expired),
4423 _ => Err(store::RedeemError::AlreadyRedeemed),
4425 }
4426}
4427
4428fn redeem_bounce(policy: &store::RedeemError) -> Response {
4431 use store::RedeemError::*;
4432 let (msg, capacity_full) = match policy {
4433 NotFound => ("That invite code isn't valid.", false),
4434 Expired => ("That invite code has expired.", false),
4435 AlreadyRedeemed => ("That invite code has already been used.", false),
4436 CapacityFull => ("", true),
4437 };
4438 render(&BetaRedeemTemplate {
4439 repo_url: REPO_URL,
4440 error: msg.to_string(),
4441 capacity_full,
4442 })
4443}
4444
4445#[derive(Debug, Deserialize, Default)]
4447struct MintQuery {
4448 #[serde(default)]
4449 n: Option<u32>,
4450}
4451
4452async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4465 let Some(runtime) = state.oauth.as_deref() else {
4466 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4468 };
4469 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4470}
4471
4472async fn oauth_jwks(State(state): State<AppState>) -> Response {
4479 let Some(runtime) = state.oauth.as_deref() else {
4480 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4481 };
4482 match runtime.client_key.as_ref() {
4483 Some(key) => match key.jwks_document() {
4484 Ok(doc) => axum::Json(doc).into_response(),
4485 Err(err) => {
4486 warn!(%err, "could not render the client JWKS");
4487 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4488 }
4489 },
4490 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4491 }
4492}
4493
4494const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4496
4497async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4506 let did = match current_did(&state, &headers).await {
4507 Some(d) => d,
4508 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4509 };
4510 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4511 warn!(%did, "admin metrics denied: not an admin-seed DID");
4512 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4513 }
4514
4515 if let Err(err) =
4520 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4521 {
4522 warn!(%err, "could not flush repo timings before rendering");
4523 }
4524 let rows = match crate::metrics::persisted_rows(&state.db).await {
4525 Ok(rows) => rows,
4526 Err(err) => {
4527 warn!(%err, "could not read persisted repo timings");
4528 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4529 }
4530 };
4531
4532 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4538 Ok(n) => n.to_string(),
4539 Err(err) => {
4540 warn!(%err, "could not count parked read-state DIDs");
4541 "unknown".to_string()
4542 }
4543 };
4544 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4551 Ok(f) => f,
4552 Err(err) => {
4553 warn!(%err, "could not list failing feeds");
4554 Vec::new()
4555 }
4556 };
4557 let mut failing_block = String::new();
4558 if !failing.is_empty() {
4559 failing_block.push_str("\nfailing feeds (worst first)\n");
4560 for f in &failing {
4561 failing_block.push_str(&format!(
4562 " {:>4}x {:<8} {}\n {}\n",
4563 f.consecutive_errors,
4564 f.kind.as_deref().unwrap_or("unknown"),
4565 f.url,
4566 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4567 ));
4568 }
4569 }
4570
4571 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4576 Ok(n) => n,
4577 Err(err) => {
4578 warn!(%err, "could not count unpollable feeds");
4579 -1
4580 }
4581 };
4582 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4583
4584 let body = format!(
4585 "live backend: {}\nparked read-state DIDs: {}\n\
4586 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4587 state.config.repo_backend.as_str(),
4588 parked,
4589 cached,
4590 state.config.max_feeds_global,
4591 unpollable,
4592 crate::metrics::render(&rows),
4593 failing_block,
4594 );
4595 (StatusCode::OK, body).into_response()
4596}
4597
4598async fn admin_mint_invites(
4602 State(state): State<AppState>,
4603 headers: HeaderMap,
4604 Query(q): Query<MintQuery>,
4605) -> Response {
4606 let did = match current_did(&state, &headers).await {
4609 Some(d) => d,
4610 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4611 };
4612 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4613 warn!(%did, "admin mint denied: not an admin-seed DID");
4614 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4615 }
4616
4617 let n = q.n.unwrap_or(1).clamp(1, 100);
4618 let mut codes = Vec::with_capacity(n as usize);
4619 for _ in 0..n {
4620 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4621 Ok(code) => codes.push(code),
4622 Err(err) => {
4623 warn!(%err, %did, "admin mint_code failed");
4624 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4625 }
4626 }
4627 }
4628 info!(%did, count = codes.len(), "admin minted invite codes");
4629 let mut body = codes.join("\n");
4630 body.push('\n');
4631 (StatusCode::OK, body).into_response()
4632}
4633
4634#[derive(Debug, Deserialize)]
4640struct ClaimQuery {
4641 t: Option<String>,
4643}
4644
4645async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4664 let token = match q.t {
4665 Some(t) if !t.is_empty() => t,
4666 _ => {
4667 warn!("claim link with no token");
4668 return redeem_bounce(&store::RedeemError::NotFound);
4669 }
4670 };
4671
4672 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4675 Some(c) => c,
4676 None => {
4677 warn!("claim token invalid (bad signature / malformed)");
4678 return redeem_bounce(&store::RedeemError::NotFound);
4679 }
4680 };
4681
4682 match preflight_code(&state, &code).await {
4686 Ok(()) => {
4687 let cookie = sign_invite(&code, &state.config.cookie_secret);
4688 let mut resp = Redirect::to("/login").into_response();
4689 set_cookie(&mut resp, &cookie);
4690 info!("claim token preflight OK; reserving intent + redirecting to /login");
4691 resp
4692 }
4693 Err(policy) => {
4694 warn!(?policy, "claim token preflight rejected");
4695 redeem_bounce(&policy)
4696 }
4697 }
4698}
4699
4700#[derive(Debug, Default, Deserialize)]
4707struct BotClaimRequest {
4708 #[serde(default)]
4711 did: Option<String>,
4712 #[serde(default)]
4714 #[allow(dead_code)]
4715 handle: Option<String>,
4716}
4717
4718#[derive(Debug, serde::Serialize)]
4720struct BotClaimResponse {
4721 status: &'static str,
4727 code: String,
4731 token: String,
4734 url: String,
4737}
4738
4739async fn bot_mint_claim(
4767 State(state): State<AppState>,
4768 headers: HeaderMap,
4769 body: axum::body::Bytes,
4770) -> Response {
4771 let bot_secret = match state.config.bot_secret.as_deref() {
4773 Some(s) => s,
4774 None => {
4775 warn!(
4776 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4777 );
4778 return (
4779 StatusCode::SERVICE_UNAVAILABLE,
4780 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4781 )
4782 .into_response();
4783 }
4784 };
4785
4786 let presented = headers
4788 .get("x-bot-secret")
4789 .and_then(|v| v.to_str().ok())
4790 .unwrap_or("");
4791 if !bot_secret_matches(presented, bot_secret) {
4792 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4793 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4794 }
4795
4796 let req: BotClaimRequest = if body.is_empty() {
4799 BotClaimRequest::default()
4800 } else {
4801 match serde_json::from_slice(&body) {
4802 Ok(r) => r,
4803 Err(err) => {
4804 warn!(%err, "POST /bot/claims: bad JSON body");
4805 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4806 }
4807 }
4808 };
4809 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4810
4811 if let Some(did) = follower_did {
4813 match store::has_beta_access(&state.db, did).await {
4815 Ok(true) => {
4816 info!("bot mint: DID already holds beta access; already_seated");
4817 return bot_claim_json(BotClaimResponse {
4818 status: "already_seated",
4819 code: String::new(),
4820 token: String::new(),
4821 url: String::new(),
4822 });
4823 }
4824 Ok(false) => {}
4825 Err(err) => {
4826 warn!(%err, "bot mint: has_beta_access failed");
4828 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4829 }
4830 }
4831 match store::find_active_code_for_did(&state.db, did).await {
4834 Ok(Some(code)) => {
4835 info!("bot mint: existing outstanding claim for DID; returning same code");
4836 let token = sign_claim_token(&code, &state.config.cookie_secret);
4837 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4838 return bot_claim_json(BotClaimResponse {
4839 status: "existing",
4840 code,
4841 token,
4842 url,
4843 });
4844 }
4845 Ok(None) => {}
4846 Err(err) => {
4847 warn!(%err, "bot mint: find_active_code_for_did failed");
4848 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4849 }
4850 }
4851 }
4852
4853 let granted = match store::count_beta_access(&state.db).await {
4856 Ok(n) => n,
4857 Err(err) => {
4858 warn!(%err, "bot mint: count_beta_access failed; failing closed");
4859 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4860 }
4861 };
4862 let outstanding = match store::count_active_codes(&state.db).await {
4863 Ok(n) => n,
4864 Err(err) => {
4865 warn!(%err, "bot mint: count_active_codes failed; failing closed");
4866 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4867 }
4868 };
4869 if granted + outstanding >= state.config.beta_cap {
4870 info!(
4871 granted,
4872 outstanding,
4873 cap = state.config.beta_cap,
4874 "bot mint refused: at capacity"
4875 );
4876 return (
4877 StatusCode::CONFLICT,
4878 [(header::CONTENT_TYPE, "application/json")],
4879 "{\"error\":\"full\"}\n",
4880 )
4881 .into_response();
4882 }
4883
4884 let bot_did = state
4887 .config
4888 .admin_seed_dids()
4889 .first()
4890 .cloned()
4891 .unwrap_or_else(|| "did:bot:featherreader".to_string());
4892 let minted = match follower_did {
4893 Some(did) => {
4894 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4895 }
4896 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4897 };
4898 let code = match minted {
4899 Ok(c) => c,
4900 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4907 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4908 Ok(Some(code)) => {
4909 info!("bot mint: lost the mint race; returning the concurrently-minted code");
4910 let token = sign_claim_token(&code, &state.config.cookie_secret);
4911 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4912 return bot_claim_json(BotClaimResponse {
4913 status: "existing",
4914 code,
4915 token,
4916 url,
4917 });
4918 }
4919 Ok(None) => {
4923 warn!("bot mint: conflict but no active code found on recovery");
4924 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4925 }
4926 Err(err) => {
4927 warn!(%err, "bot mint: recovery lookup after conflict failed");
4928 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4929 }
4930 }
4931 }
4932 Err(err) => {
4933 warn!(%err, "bot mint_code failed");
4934 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4935 }
4936 };
4937 let token = sign_claim_token(&code, &state.config.cookie_secret);
4938 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4939 info!("bot minted a claim code + token");
4940
4941 bot_claim_json(BotClaimResponse {
4942 status: "minted",
4943 code,
4944 token,
4945 url,
4946 })
4947}
4948
4949fn bot_claim_json(resp: BotClaimResponse) -> Response {
4952 match serde_json::to_string(&resp) {
4953 Ok(body) => (
4954 StatusCode::OK,
4955 [(header::CONTENT_TYPE, "application/json")],
4956 body,
4957 )
4958 .into_response(),
4959 Err(err) => {
4960 warn!(%err, "serializing bot claim response failed");
4961 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4962 }
4963 }
4964}
4965
4966fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4971 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4972}
4973
4974fn sign_invite(code: &str, secret: &str) -> String {
4983 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4984}
4985
4986fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4991 cookie::verify_value(headers, INVITE_COOKIE, secret)
4992}
4993
4994const CLAIM_TOKEN_LABEL: &str = "claim-token";
4998
4999fn sign_claim_token(code: &str, secret: &str) -> String {
5011 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5012}
5013
5014fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5019 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5020}
5021
5022fn clear_invite_cookie(resp: &mut Response) {
5025 set_cookie(
5026 resp,
5027 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5028 );
5029}
5030
5031async fn import_opml(
5044 State(state): State<AppState>,
5045 headers: HeaderMap,
5046 mut multipart: Multipart,
5047) -> Result<Response, WebError> {
5048 let did = match current_did(&state, &headers).await {
5049 Some(d) => d,
5050 None => return Ok(Redirect::to("/login").into_response()),
5051 };
5052 let pool = &state.db;
5053
5054 let mut opml_text = String::new();
5060 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5061 let name = field.name().unwrap_or("").to_string();
5062 if name == "opml" || name == "file" {
5063 let bytes = field.bytes().await.map_err(multipart_response)?;
5064 if !bytes.is_empty() {
5065 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5066 if name == "file" {
5067 break;
5068 }
5069 }
5070 }
5071 }
5072
5073 let feeds =
5078 match opml::parse_opml(&opml_text) {
5079 Ok(feeds) => feeds,
5080 Err(err) => {
5081 warn!(%err, %did, "OPML import could not parse the uploaded file");
5082 return Ok(Redirect::to(&format!(
5083 "/?flash={}",
5084 qenc("That file could not be read as OPML. Export it again from your other reader?")
5085 ))
5086 .into_response());
5087 }
5088 };
5089 if feeds.is_empty() {
5090 info!(%did, "OPML import found no feeds");
5091 return Ok(
5092 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5093 .into_response(),
5094 );
5095 }
5096
5097 let now = now_rfc3339();
5100 let mut folder_uris: std::collections::HashMap<String, String> =
5101 std::collections::HashMap::new();
5102 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5104 for (rkey, folder) in existing {
5105 folder_uris
5106 .entry(folder.name.clone())
5107 .or_insert_with(|| folder_uri(&did, &rkey));
5108 }
5109 }
5110 let mut wanted_folders: Vec<String> = feeds
5111 .iter()
5112 .filter_map(|f| f.folder.clone())
5113 .filter(|n| !n.is_empty())
5114 .collect();
5115 wanted_folders.sort();
5116 wanted_folders.dedup();
5117 for name in wanted_folders {
5118 if folder_uris.contains_key(&name) {
5119 continue;
5120 }
5121 let folder = Folder::new(name.clone(), now.clone());
5122 match state.repo().add_folder(&did, &folder).await {
5123 Ok(rkey) => {
5124 folder_uris.insert(name, folder_uri(&did, &rkey));
5125 }
5126 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5127 }
5128 }
5129
5130 let sub_cap = state.config.max_subs_per_did;
5139 let mut headroom: Option<i64> = if sub_cap > 0 {
5140 let existing = store::count_subscriptions_for_did(pool, &did)
5141 .await
5142 .unwrap_or(0);
5143 Some((sub_cap - existing).max(0))
5144 } else {
5145 None
5146 };
5147 let mut trimmed_over_cap: usize = 0;
5148
5149 let feeds_cap = state.config.max_feeds_global;
5156 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5157 let existing = store::count_feeds(pool).await.unwrap_or(0);
5158 Some((feeds_cap - existing).max(0))
5159 } else {
5160 None
5161 };
5162 let mut trimmed_over_global: usize = 0;
5163
5164 let mut subs = Vec::with_capacity(feeds.len());
5165 let mut skipped_private: Vec<String> = Vec::new();
5166 let mut uncached: usize = 0;
5169 let mut skipped_unsupported: usize = 0;
5175 for f in &feeds {
5176 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5184 info!(
5185 %did,
5186 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5187 );
5188 skipped_unsupported += 1;
5189 continue;
5190 }
5191 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5192 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5193 let label = f
5195 .title
5196 .clone()
5197 .filter(|t| !t.trim().is_empty())
5198 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5199 skipped_private.push(label);
5200 continue;
5201 }
5202
5203 if let Some(h) = headroom.as_mut() {
5206 if *h <= 0 {
5207 trimmed_over_cap += 1;
5208 continue;
5209 }
5210 }
5211
5212 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5217 Ok(existing) => existing.is_none(),
5218 Err(err) => {
5221 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5222 false
5223 }
5224 };
5225 if is_new {
5226 if let Some(g) = global_headroom.as_mut() {
5227 if *g <= 0 {
5228 trimmed_over_global += 1;
5229 continue;
5230 }
5231 *g -= 1;
5232 }
5233 }
5234
5235 if let Some(h) = headroom.as_mut() {
5238 *h -= 1;
5239 }
5240
5241 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5242 sub.title = f.title.clone();
5243 sub.site_url = f.site_url.clone();
5244 sub.folder = f
5245 .folder
5246 .as_ref()
5247 .and_then(|name| folder_uris.get(name).cloned());
5248 subs.push(sub);
5249 if let Err(err) = store::upsert_feed(
5255 pool,
5256 &store::NewFeed {
5257 url: f.feed_url.clone(),
5258 title: f.title.clone(),
5259 site_url: f.site_url.clone(),
5260 ..Default::default()
5261 },
5262 )
5263 .await
5264 {
5265 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5266 it will not be polled");
5267 uncached += 1;
5268 }
5269 }
5270
5271 let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5278 Ok(rkeys) => {
5279 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5280 true
5281 }
5282 Err(err) => {
5283 warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5284 false
5285 }
5286 };
5287 if !pds_written {
5288 return Ok(Redirect::to(&format!(
5289 "/?flash={}",
5290 qenc(
5291 "Could not save those subscriptions to your PDS, so nothing was imported. \
5292 Try again in a moment."
5293 )
5294 ))
5295 .into_response());
5296 }
5297
5298 let mut flash = format!("Imported {} feeds", subs.len());
5300 if uncached > 0 {
5301 flash.push_str(&format!(
5302 ". {uncached} of them could not be cached locally and may not update until the next import."
5303 ));
5304 }
5305 if trimmed_over_cap > 0 {
5306 flash.push_str(&format!(
5307 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5308 ));
5309 }
5310 if trimmed_over_global > 0 {
5311 flash.push_str(&format!(
5312 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5313 ));
5314 }
5315 if !skipped_private.is_empty() {
5316 flash.push_str(&format!(
5317 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5318 skipped_private.len(),
5319 skipped_private.join(", ")
5320 ));
5321 }
5322 if skipped_unsupported > 0 {
5323 flash.push_str(&format!(
5326 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5327 ));
5328 }
5329 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5330}
5331
5332fn private_feed_label(url: &str) -> String {
5335 url::Url::parse(url)
5336 .ok()
5337 .and_then(|u| u.host_str().map(str::to_string))
5338 .unwrap_or_else(|| "a private feed".to_string())
5339}
5340
5341async fn export_opml(
5343 State(state): State<AppState>,
5344 headers: HeaderMap,
5345) -> Result<Response, WebError> {
5346 let did = match current_did(&state, &headers).await {
5347 Some(d) => d,
5348 None => return Ok(Redirect::to("/login").into_response()),
5349 };
5350
5351 let subs = match state.repo().list_subscriptions_sorted(&did).await {
5358 Ok(subs) => subs,
5359 Err(err) => {
5360 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5361 return Ok(Redirect::to(&format!(
5362 "/manage?flash={}",
5363 qenc(EXPORT_INCOMPLETE_REFUSAL)
5364 ))
5365 .into_response());
5366 }
5367 };
5368 let folders = match state.repo().list_folders_sorted(&did).await {
5369 Ok(folders) => folders,
5370 Err(err) => {
5371 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5372 return Ok(Redirect::to(&format!(
5373 "/manage?flash={}",
5374 qenc(EXPORT_INCOMPLETE_REFUSAL)
5375 ))
5376 .into_response());
5377 }
5378 };
5379 let folder_pairs: Vec<(String, Folder)> = folders
5382 .into_iter()
5383 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5384 .collect();
5385
5386 let body = opml::to_opml(&subs, &folder_pairs);
5387 let mut resp = (StatusCode::OK, body).into_response();
5388 resp.headers_mut().insert(
5389 header::CONTENT_TYPE,
5390 "text/x-opml; charset=utf-8".parse().unwrap(),
5391 );
5392 resp.headers_mut().insert(
5393 header::CONTENT_DISPOSITION,
5394 "attachment; filename=\"featherreader-subscriptions.opml\""
5395 .parse()
5396 .unwrap(),
5397 );
5398 Ok(resp)
5399}
5400
5401fn set_cookie(resp: &mut Response, cookie: &str) {
5407 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5408 resp.headers_mut()
5409 .append(axum::http::header::SET_COOKIE, value);
5410 }
5411}
5412
5413fn is_htmx(headers: &HeaderMap) -> bool {
5415 headers
5416 .get("HX-Request")
5417 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5418}
5419
5420fn is_reader_request(headers: &HeaderMap) -> bool {
5426 headers
5427 .get("X-FR-Reader")
5428 .is_some_and(|v| v.as_bytes() == b"1")
5429}
5430
5431mod cookie {
5436 use super::{HeaderMap, SESSION_COOKIE};
5437
5438 pub fn sign_session(sid: &str, secret: &str) -> String {
5440 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5441 }
5442
5443 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5445 verify_value(headers, SESSION_COOKIE, secret)
5446 }
5447
5448 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5454 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5455 msg.extend_from_slice(name.as_bytes());
5456 msg.push(0);
5457 msg.extend_from_slice(value.as_bytes());
5458 msg
5459 }
5460
5461 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5467 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5468 let b64 = b64url_encode(value.as_bytes());
5469 format!(
5470 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5471 )
5472 }
5473
5474 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5477 let raw = cookie_value(headers, name)?;
5478 let (b64, sig) = raw.split_once('.')?;
5479 let bytes = b64url_decode(b64)?;
5480 let value = String::from_utf8(bytes).ok()?;
5481 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5482 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5483 Some(value)
5484 } else {
5485 None
5486 }
5487 }
5488
5489 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5495 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5496 let b64 = b64url_encode(value.as_bytes());
5497 format!("{b64}.{sig}")
5498 }
5499
5500 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5503 let (b64, sig) = token.split_once('.')?;
5504 let bytes = b64url_decode(b64)?;
5505 let value = String::from_utf8(bytes).ok()?;
5506 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5507 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5508 Some(value)
5509 } else {
5510 None
5511 }
5512 }
5513
5514 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5516 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5517 for part in header.split(';') {
5518 let part = part.trim();
5519 if let Some((k, v)) = part.split_once('=') {
5520 if k == name {
5521 return Some(v.to_string());
5522 }
5523 }
5524 }
5525 None
5526 }
5527
5528 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5532 if a.len() != b.len() {
5533 return false;
5534 }
5535 let mut diff = 0u8;
5536 for (x, y) in a.iter().zip(b.iter()) {
5537 diff |= x ^ y;
5538 }
5539 diff == 0
5540 }
5541
5542 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5545
5546 fn b64url_encode(input: &[u8]) -> String {
5547 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5548 for chunk in input.chunks(3) {
5549 let b = [
5550 chunk[0],
5551 *chunk.get(1).unwrap_or(&0),
5552 *chunk.get(2).unwrap_or(&0),
5553 ];
5554 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5555 out.push(B64[((n >> 18) & 63) as usize] as char);
5556 out.push(B64[((n >> 12) & 63) as usize] as char);
5557 if chunk.len() > 1 {
5558 out.push(B64[((n >> 6) & 63) as usize] as char);
5559 }
5560 if chunk.len() > 2 {
5561 out.push(B64[(n & 63) as usize] as char);
5562 }
5563 }
5564 out
5565 }
5566
5567 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5568 fn val(c: u8) -> Option<u32> {
5569 match c {
5570 b'A'..=b'Z' => Some((c - b'A') as u32),
5571 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5572 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5573 b'-' => Some(62),
5574 b'_' => Some(63),
5575 _ => None,
5576 }
5577 }
5578 let bytes = input.as_bytes();
5579 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5580 for chunk in bytes.chunks(4) {
5581 let mut n = 0u32;
5582 let mut valid = 0;
5583 for (i, &c) in chunk.iter().enumerate() {
5584 n |= val(c)? << (18 - 6 * i);
5585 valid += 1;
5586 }
5587 out.push((n >> 16) as u8);
5588 if valid > 2 {
5589 out.push((n >> 8) as u8);
5590 }
5591 if valid > 3 {
5592 out.push(n as u8);
5593 }
5594 }
5595 Some(out)
5596 }
5597
5598 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5602 const BLOCK: usize = 64;
5603 let mut k = [0u8; BLOCK];
5604 if key.len() > BLOCK {
5605 let d = sha256(key);
5606 k[..32].copy_from_slice(&d);
5607 } else {
5608 k[..key.len()].copy_from_slice(key);
5609 }
5610 let mut ipad = [0x36u8; BLOCK];
5611 let mut opad = [0x5cu8; BLOCK];
5612 for i in 0..BLOCK {
5613 ipad[i] ^= k[i];
5614 opad[i] ^= k[i];
5615 }
5616 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5617 inner.extend_from_slice(&ipad);
5618 inner.extend_from_slice(msg);
5619 let inner_hash = sha256(&inner);
5620 let mut outer = Vec::with_capacity(BLOCK + 32);
5621 outer.extend_from_slice(&opad);
5622 outer.extend_from_slice(&inner_hash);
5623 let mac = sha256(&outer);
5624 let mut hex = String::with_capacity(64);
5625 for b in mac {
5626 hex.push_str(&format!("{b:02x}"));
5627 }
5628 hex
5629 }
5630
5631 fn sha256(data: &[u8]) -> [u8; 32] {
5633 const K: [u32; 64] = [
5634 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5635 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5636 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5637 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5638 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5639 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5640 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5641 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5642 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5643 0xc67178f2,
5644 ];
5645 let mut h: [u32; 8] = [
5646 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5647 0x5be0cd19,
5648 ];
5649
5650 let bit_len = (data.len() as u64) * 8;
5651 let mut msg = data.to_vec();
5652 msg.push(0x80);
5653 while msg.len() % 64 != 56 {
5654 msg.push(0);
5655 }
5656 msg.extend_from_slice(&bit_len.to_be_bytes());
5657
5658 for block in msg.chunks(64) {
5659 let mut w = [0u32; 64];
5660 for i in 0..16 {
5661 w[i] = u32::from_be_bytes([
5662 block[i * 4],
5663 block[i * 4 + 1],
5664 block[i * 4 + 2],
5665 block[i * 4 + 3],
5666 ]);
5667 }
5668 for i in 16..64 {
5669 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5670 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5671 w[i] = w[i - 16]
5672 .wrapping_add(s0)
5673 .wrapping_add(w[i - 7])
5674 .wrapping_add(s1);
5675 }
5676 let mut a = h;
5677 for i in 0..64 {
5678 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5679 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5680 let t1 = a[7]
5681 .wrapping_add(s1)
5682 .wrapping_add(ch)
5683 .wrapping_add(K[i])
5684 .wrapping_add(w[i]);
5685 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5686 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5687 let t2 = s0.wrapping_add(maj);
5688 a[7] = a[6];
5689 a[6] = a[5];
5690 a[5] = a[4];
5691 a[4] = a[3].wrapping_add(t1);
5692 a[3] = a[2];
5693 a[2] = a[1];
5694 a[1] = a[0];
5695 a[0] = t1.wrapping_add(t2);
5696 }
5697 for i in 0..8 {
5698 h[i] = h[i].wrapping_add(a[i]);
5699 }
5700 }
5701
5702 let mut out = [0u8; 32];
5703 for (i, word) in h.iter().enumerate() {
5704 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5705 }
5706 out
5707 }
5708
5709 #[cfg(test)]
5710 mod tests {
5711 use super::*;
5712
5713 #[test]
5714 fn sha256_known_vector() {
5715 let d = sha256(b"abc");
5716 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5717 assert_eq!(
5718 hex,
5719 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5720 );
5721 }
5722
5723 #[test]
5724 fn hmac_known_vector() {
5725 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5726 assert_eq!(
5727 mac,
5728 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5729 );
5730 }
5731
5732 #[test]
5733 fn sign_verify_round_trips() {
5734 let secret = "test-secret";
5735 let sid = "9f2c-opaque-session-id";
5736 let cookie = sign_session(sid, secret);
5737 let pair = cookie.split(';').next().unwrap().to_string();
5738 let mut headers = HeaderMap::new();
5739 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5740 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5741 assert!(verify_session(&headers, "other-secret").is_none());
5743 }
5744
5745 #[test]
5746 fn forged_and_tampered_cookies_are_rejected() {
5747 let secret = "test-secret";
5748
5749 let forged = format!(
5752 "{SESSION_COOKIE}={}.{}",
5753 b64url_encode(b"attacker-chosen-sid"),
5754 "deadbeef".repeat(8) );
5756 let mut headers = HeaderMap::new();
5757 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5758 assert!(verify_session(&headers, secret).is_none());
5759
5760 let cookie = sign_session("real-sid", secret);
5763 let pair = cookie.split(';').next().unwrap();
5764 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5765 let tampered = format!(
5766 "{SESSION_COOKIE}={}.{}",
5767 b64url_encode(b"different-sid"),
5768 sig
5769 );
5770 let mut headers2 = HeaderMap::new();
5771 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5772 assert!(verify_session(&headers2, secret).is_none());
5773 }
5774
5775 #[test]
5776 fn b64url_round_trips() {
5777 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5778 let enc = b64url_encode(s.as_bytes());
5779 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5780 }
5781 }
5782 }
5783}
5784
5785async fn get_entry_by_id(
5801 pool: &store::Pool,
5802 did: &str,
5803 id: i64,
5804) -> anyhow::Result<Option<store::Entry>> {
5805 let entry = sqlx::query_as::<_, store::Entry>(
5806 r#"
5807 SELECT e.* FROM entries e
5808 WHERE e.id = ?2
5809 AND EXISTS (
5810 SELECT 1 FROM sub_ref sr
5811 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5812 )
5813 "#,
5814 )
5815 .bind(did)
5816 .bind(id)
5817 .fetch_optional(pool)
5818 .await?;
5819 Ok(entry)
5820}
5821
5822async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5824 let read: Option<bool> =
5825 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5826 .bind(did)
5827 .bind(entry_id)
5828 .fetch_optional(pool)
5829 .await?
5830 .flatten();
5831 Ok(read.unwrap_or(false))
5832}
5833
5834async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5836 let starred: Option<bool> =
5837 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5838 .bind(did)
5839 .bind(entry_id)
5840 .fetch_optional(pool)
5841 .await?
5842 .flatten();
5843 Ok(starred.unwrap_or(false))
5844}
5845
5846async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5848 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5849 .bind(feed_id)
5850 .fetch_optional(pool)
5851 .await
5852 {
5853 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5854 _ => String::new(),
5855 }
5856}
5857
5858async fn build_entry_row(
5861 pool: &store::Pool,
5862 did: &str,
5863 id: i64,
5864 read: Option<bool>,
5865) -> anyhow::Result<Option<EntryRow>> {
5866 let entry = match get_entry_by_id(pool, did, id).await? {
5867 Some(e) => e,
5868 None => return Ok(None),
5869 };
5870 let read = match read {
5871 Some(r) => r,
5872 None => entry_is_read(pool, did, id).await?,
5873 };
5874 let starred = entry_is_starred(pool, did, id).await?;
5875 Ok(Some(EntryRow {
5876 id: entry.id,
5877 title: entry
5878 .title
5879 .clone()
5880 .filter(|t| !t.trim().is_empty())
5881 .unwrap_or_else(|| "(untitled)".to_string()),
5882 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5883 published: display_date(entry.published.as_deref()),
5884 read,
5885 starred,
5886 link: SafeLink::entry(id, ""),
5887 cached: true,
5888 rkey: String::new(),
5889 }))
5890}
5891
5892fn now_rfc3339() -> String {
5894 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5895}
5896
5897#[cfg(test)]
5898mod tests {
5899 use super::*;
5900
5901 #[test]
5902 fn qenc_encodes_reserved() {
5903 assert_eq!(qenc("a b"), "a%20b");
5904 assert_eq!(
5905 qenc("https://example.com/feed.xml"),
5906 "https%3A%2F%2Fexample.com%2Ffeed.xml"
5907 );
5908 assert_eq!(
5909 qenc("at://did:plc:x/c/r"),
5910 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5911 );
5912 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5914 }
5915
5916 #[test]
5917 fn folder_uri_shape() {
5918 assert_eq!(
5919 folder_uri("did:plc:abc", "3kfolder"),
5920 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5921 );
5922 }
5923
5924 #[test]
5927 fn private_feeds_are_classified_private_across_providers() {
5928 for url in [
5932 "https://author.substack.com/feed/private/deadbeefcafe1234",
5933 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5934 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5935 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5936 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5937 "https://user:pass@example.com/feed",
5938 ] {
5939 assert!(
5940 feed::classify_feed_privacy(url).is_private(),
5941 "expected private: {url}"
5942 );
5943 }
5944 }
5945
5946 #[test]
5947 fn public_feeds_stay_public() {
5948 for url in [
5949 "https://author.substack.com/feed",
5950 "https://wordpress.example.com/feed/",
5951 "https://example.com/rss.xml",
5952 "https://example.org/atom.xml",
5953 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5955 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5956 ] {
5957 assert!(
5958 !feed::classify_feed_privacy(url).is_private(),
5959 "expected public: {url}"
5960 );
5961 }
5962 }
5963
5964 #[test]
5965 fn private_feed_label_is_public_safe_host_only() {
5966 let label =
5968 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5969 assert_eq!(label, "author.substack.com");
5970 assert!(!label.contains("deadbeefcafe1234token"));
5971 assert!(!label.contains("/private/"));
5972 assert_eq!(private_feed_label("not a url"), "a private feed");
5974 }
5975
5976 #[test]
5977 fn refusal_message_promises_nothing_stored() {
5978 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5979 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5980 }
5981
5982 #[test]
5983 fn scope_query_preserves_context() {
5984 let q = EntryQuery {
5985 feed: Some("https://example.com/feed.xml".to_string()),
5986 folder: None,
5987 view: Some("all".to_string()),
5988 };
5989 let s = scope_query(&q);
5990 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5991 assert!(s.contains("view=all"));
5992
5993 let q2 = EntryQuery {
5995 feed: None,
5996 folder: None,
5997 view: Some("unread".to_string()),
5998 };
5999 assert_eq!(scope_query(&q2), "");
6000 }
6001
6002 use axum::body::Body;
6005 use axum::http::Request;
6006 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
6012 let db = store::init_url("sqlite::memory:").await.unwrap();
6013 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6014 store::ensure_seed(&db, &dids).await.unwrap();
6015 let config = Config {
6016 allowed_dids: dids,
6017 cookie_secret: "test-cookie-secret-000".to_string(),
6018 beta_cap: 3,
6019 ..Config::default()
6020 };
6021 AppState::new(config, db).unwrap()
6022 }
6023
6024 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6027 let sid = state.sessions.create(Session {
6028 did: did.to_string(),
6029 handle: handle.map(str::to_string),
6030 });
6031 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6032 sc.split(';').next().unwrap().to_string()
6033 }
6034
6035 #[test]
6039 fn the_rate_limit_map_is_bounded() {
6040 let rl = RateLimiter::shared();
6041 let now = Instant::now();
6042 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6043 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6046 rl.check_at(ip, now + Duration::from_millis(i as u64));
6047 }
6048 let len = rl.inner.lock().unwrap().buckets.len();
6049 assert!(
6050 len <= MAX_RATE_BUCKETS,
6051 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6052 );
6053 }
6054
6055 #[test]
6062 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6063 let rl = RateLimiter::shared();
6064 let base = Instant::now();
6065 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6066 let at = |n: u64| base + Duration::from_nanos(n);
6071
6072 for i in 0..(RATE_BURST as u64) {
6074 assert!(rl.check_at(attacker, at(i)));
6075 }
6076 assert!(
6077 !rl.check_at(attacker, at(RATE_BURST as u64)),
6078 "burst was not exhausted; the rest of this test proves nothing"
6079 );
6080
6081 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6084 let t = at(100 + i as u64 * 2);
6085 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6086 rl.check_at(ip, t);
6087 assert!(
6088 !rl.check_at(attacker, t),
6089 "the attacker got a token back after evictions at i={i}"
6090 );
6091 }
6092 }
6093
6094 #[test]
6097 fn the_idle_sweep_does_not_run_on_every_request() {
6098 let rl = RateLimiter::shared();
6099 let start = Instant::now();
6100 let a: IpAddr = "198.51.100.1".parse().unwrap();
6101 let b: IpAddr = "198.51.100.2".parse().unwrap();
6102
6103 rl.check_at(a, start);
6104 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6107 assert!(
6108 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6109 "an idle bucket survived a sweep that was due"
6110 );
6111
6112 let before = rl.inner.lock().unwrap().last_sweep;
6115 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6116 assert_eq!(
6117 rl.inner.lock().unwrap().last_sweep,
6118 before,
6119 "the sweep ran again within the interval"
6120 );
6121 }
6122
6123 #[test]
6124 fn rate_limited_paths_match_expected() {
6125 use axum::http::Method;
6126 assert!(is_rate_limited_path("/login", &Method::GET));
6127 assert!(is_rate_limited_path("/login", &Method::POST));
6128 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6129 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6130 assert!(is_rate_limited_path("/opml", &Method::POST));
6131 assert!(is_rate_limited_path("/read-all", &Method::POST));
6132 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6133 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6134 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6135 assert!(!is_rate_limited_path("/", &Method::GET));
6137 assert!(!is_rate_limited_path("/about", &Method::GET));
6138 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6139 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6140 }
6141
6142 #[test]
6143 fn rate_limiter_allows_burst_then_429s() {
6144 let rl = RateLimiter::shared();
6145 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6146 for _ in 0..(RATE_BURST as usize) {
6148 assert!(rl.check(ip));
6149 }
6150 assert!(!rl.check(ip));
6152 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6154 assert!(rl.check(ip2));
6155 }
6156
6157 #[test]
6158 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6159 let mut h = HeaderMap::new();
6163 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6164 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6165 assert_eq!(
6166 client_ip(&h, Some(&sock), None),
6167 Some("203.0.113.55".parse().unwrap()),
6168 "spoofed XFF must not override the socket peer"
6169 );
6170 }
6171
6172 #[test]
6173 fn client_ip_uses_trusted_header_last_hop() {
6174 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6179
6180 let mut h = HeaderMap::new();
6181 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6182 assert_eq!(
6183 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6184 Some("198.51.100.9".parse().unwrap())
6185 );
6186
6187 let mut h2 = HeaderMap::new();
6189 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6190 assert_eq!(
6191 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6192 Some("198.51.100.9".parse().unwrap()),
6193 "must take the right-most (trusted) hop, not the forged left-most"
6194 );
6195
6196 let h3 = HeaderMap::new();
6198 assert_eq!(
6199 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6200 Some("10.0.0.1".parse().unwrap())
6201 );
6202 }
6203
6204 #[test]
6205 fn invite_cookie_round_trips_and_rejects_tamper() {
6206 let secret = "test-cookie-secret-000";
6207 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6208 let pair = sc.split(';').next().unwrap();
6209 let mut h = HeaderMap::new();
6210 h.insert(header::COOKIE, pair.parse().unwrap());
6211 assert_eq!(
6212 invite_cookie_code(&h, secret).as_deref(),
6213 Some("FEATHER-ABCDWXYZ")
6214 );
6215 assert!(invite_cookie_code(&h, "other").is_none());
6217 }
6218
6219 #[tokio::test]
6220 async fn preflight_valid_expired_and_full() {
6221 let state = test_state(&["did:plc:admin"]).await;
6222 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6224 .await
6225 .unwrap();
6226 assert!(preflight_code(&state, &code).await.is_ok());
6227
6228 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6232 .await
6233 .unwrap();
6234 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6235 .bind(chrono::Utc::now().timestamp() - 3600)
6236 .bind(&expired)
6237 .execute(&state.db)
6238 .await
6239 .unwrap();
6240 assert_eq!(
6241 preflight_code(&state, &expired).await,
6242 Err(store::RedeemError::Expired)
6243 );
6244
6245 assert_eq!(
6247 preflight_code(&state, "FEATHER-NOPENOPE").await,
6248 Err(store::RedeemError::NotFound)
6249 );
6250
6251 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6254 .await
6255 .unwrap();
6256 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6257 .await
6258 .unwrap();
6259 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6260 assert_eq!(
6261 preflight_code(&state, &code).await,
6262 Err(store::RedeemError::CapacityFull)
6263 );
6264 }
6265
6266 async fn bot_state(bot_secret: &str) -> AppState {
6270 let db = store::init_url("sqlite::memory:").await.unwrap();
6271 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6272 .await
6273 .unwrap();
6274 let config = Config {
6275 allowed_dids: vec!["did:plc:admin".to_string()],
6276 cookie_secret: "test-cookie-secret-000".to_string(),
6277 beta_cap: 3,
6278 bot_secret: Some(bot_secret.to_string()),
6279 public_url: "https://feather-reader.com".to_string(),
6280 ..Config::default()
6281 };
6282 AppState::new(config, db).unwrap()
6283 }
6284
6285 #[test]
6286 fn claim_token_round_trips_and_rejects_tamper() {
6287 let secret = "test-cookie-secret-000";
6288 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6289 assert!(!token.contains(';'));
6291 assert_eq!(
6292 claim_token_code(&token, secret).as_deref(),
6293 Some("FEATHER-ABCDWXYZ")
6294 );
6295 assert!(claim_token_code(&token, "other").is_none());
6297 let mut bad = token.clone();
6299 bad.push('x');
6300 assert!(claim_token_code(&bad, secret).is_none());
6301 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6307 assert_eq!(
6308 test_b64url_decode(b64).as_deref(),
6309 Some("FEATHER-ABCDWXYZ".as_bytes()),
6310 "the code half of the token is plain base64url, decodable by anyone"
6311 );
6312 }
6313
6314 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6317 fn val(c: u8) -> Option<u32> {
6318 match c {
6319 b'A'..=b'Z' => Some((c - b'A') as u32),
6320 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6321 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6322 b'-' => Some(62),
6323 b'_' => Some(63),
6324 _ => None,
6325 }
6326 }
6327 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6328 for chunk in input.as_bytes().chunks(4) {
6329 let mut n = 0u32;
6330 let mut bits = 0;
6331 for &c in chunk {
6332 n = (n << 6) | val(c)?;
6333 bits += 6;
6334 }
6335 let bytes = bits / 8;
6336 n <<= 24 - bits;
6337 for i in 0..bytes {
6338 out.push((n >> (16 - i * 8)) as u8);
6339 }
6340 }
6341 Some(out)
6342 }
6343
6344 #[tokio::test]
6345 async fn bot_mint_then_claim_grants_a_seat() {
6346 let state = bot_state("bot-secret-abcdef").await;
6347 let app = router(state.clone());
6348
6349 let resp = app
6351 .clone()
6352 .oneshot(
6353 Request::builder()
6354 .method("POST")
6355 .uri("/bot/claims")
6356 .header("x-bot-secret", "bot-secret-abcdef")
6357 .body(Body::empty())
6358 .unwrap(),
6359 )
6360 .await
6361 .unwrap();
6362 assert_eq!(resp.status(), StatusCode::OK);
6363 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6364 .await
6365 .unwrap();
6366 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6367 let token = json["token"].as_str().unwrap().to_string();
6368 let url = json["url"].as_str().unwrap();
6369 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6370 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6372 assert!(!url.contains("FEATHER-"));
6373
6374 let resp = app
6376 .clone()
6377 .oneshot(
6378 Request::builder()
6379 .method("GET")
6380 .uri(format!("/claim?t={}", qenc(&token)))
6381 .body(Body::empty())
6382 .unwrap(),
6383 )
6384 .await
6385 .unwrap();
6386 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6387 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6388 let set_cookie = resp
6389 .headers()
6390 .get(header::SET_COOKIE)
6391 .unwrap()
6392 .to_str()
6393 .unwrap();
6394 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6395
6396 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6399 let out = store::redeem_code(
6400 &state.db,
6401 &code,
6402 "did:plc:follower",
6403 None,
6404 state.config.beta_cap,
6405 )
6406 .await
6407 .unwrap();
6408 assert_eq!(out, Ok(()));
6409 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6410 .await
6411 .unwrap());
6412 }
6413
6414 #[tokio::test]
6415 async fn claim_with_invalid_token_bounces() {
6416 let state = bot_state("bot-secret-abcdef").await;
6417 let app = router(state);
6418 let resp = app
6419 .oneshot(
6420 Request::builder()
6421 .method("GET")
6422 .uri("/claim?t=not-a-real-token")
6423 .body(Body::empty())
6424 .unwrap(),
6425 )
6426 .await
6427 .unwrap();
6428 assert_eq!(resp.status(), StatusCode::OK);
6430 }
6431
6432 #[tokio::test]
6433 async fn claim_with_used_token_is_refused() {
6434 let state = bot_state("bot-secret-abcdef").await;
6435 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6437 .await
6438 .unwrap();
6439 let token = sign_claim_token(&code, &state.config.cookie_secret);
6440 store::redeem_code(
6441 &state.db,
6442 &code,
6443 "did:plc:someone",
6444 None,
6445 state.config.beta_cap,
6446 )
6447 .await
6448 .unwrap()
6449 .unwrap();
6450 let app = router(state);
6451 let resp = app
6452 .oneshot(
6453 Request::builder()
6454 .method("GET")
6455 .uri(format!("/claim?t={}", qenc(&token)))
6456 .body(Body::empty())
6457 .unwrap(),
6458 )
6459 .await
6460 .unwrap();
6461 assert_eq!(resp.status(), StatusCode::OK);
6463 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6464 }
6465
6466 #[tokio::test]
6467 async fn bot_claims_rejects_bad_and_missing_secret() {
6468 let state = bot_state("bot-secret-abcdef").await;
6469 let app = router(state);
6470 let resp = app
6472 .clone()
6473 .oneshot(
6474 Request::builder()
6475 .method("POST")
6476 .uri("/bot/claims")
6477 .header("x-bot-secret", "wrong")
6478 .body(Body::empty())
6479 .unwrap(),
6480 )
6481 .await
6482 .unwrap();
6483 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6484 let resp = app
6486 .oneshot(
6487 Request::builder()
6488 .method("POST")
6489 .uri("/bot/claims")
6490 .body(Body::empty())
6491 .unwrap(),
6492 )
6493 .await
6494 .unwrap();
6495 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6496 }
6497
6498 #[tokio::test]
6499 async fn bot_claims_disabled_when_secret_unset() {
6500 let state = test_state(&["did:plc:admin"]).await;
6502 let app = router(state);
6503 let resp = app
6504 .oneshot(
6505 Request::builder()
6506 .method("POST")
6507 .uri("/bot/claims")
6508 .header("x-bot-secret", "anything")
6509 .body(Body::empty())
6510 .unwrap(),
6511 )
6512 .await
6513 .unwrap();
6514 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6515 }
6516
6517 #[tokio::test]
6518 async fn bot_claims_refuses_at_capacity() {
6519 let state = bot_state("bot-secret-abcdef").await;
6520 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6522 .await
6523 .unwrap();
6524 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6525 .await
6526 .unwrap();
6527 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6528 let app = router(state);
6529 let resp = app
6530 .oneshot(
6531 Request::builder()
6532 .method("POST")
6533 .uri("/bot/claims")
6534 .header("x-bot-secret", "bot-secret-abcdef")
6535 .body(Body::empty())
6536 .unwrap(),
6537 )
6538 .await
6539 .unwrap();
6540 assert_eq!(resp.status(), StatusCode::CONFLICT);
6541 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6542 .await
6543 .unwrap();
6544 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6545 }
6546
6547 #[tokio::test]
6548 async fn bot_claims_counts_outstanding_codes_against_cap() {
6549 let state = bot_state("bot-secret-abcdef").await;
6550 store::mint_code(&state.db, "did:plc:admin", 3600)
6552 .await
6553 .unwrap();
6554 store::mint_code(&state.db, "did:plc:admin", 3600)
6555 .await
6556 .unwrap();
6557 let app = router(state);
6558 let resp = app
6559 .oneshot(
6560 Request::builder()
6561 .method("POST")
6562 .uri("/bot/claims")
6563 .header("x-bot-secret", "bot-secret-abcdef")
6564 .body(Body::empty())
6565 .unwrap(),
6566 )
6567 .await
6568 .unwrap();
6569 assert_eq!(resp.status(), StatusCode::CONFLICT);
6571 }
6572
6573 async fn post_bot_claim_for(
6575 app: &axum::Router,
6576 secret: &str,
6577 did: &str,
6578 ) -> (StatusCode, serde_json::Value) {
6579 let resp = app
6580 .clone()
6581 .oneshot(
6582 Request::builder()
6583 .method("POST")
6584 .uri("/bot/claims")
6585 .header("x-bot-secret", secret)
6586 .header("content-type", "application/json")
6587 .body(Body::from(format!(
6588 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6589 )))
6590 .unwrap(),
6591 )
6592 .await
6593 .unwrap();
6594 let status = resp.status();
6595 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6596 .await
6597 .unwrap();
6598 let json = if bytes.is_empty() {
6599 serde_json::Value::Null
6600 } else {
6601 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6602 };
6603 (status, json)
6604 }
6605
6606 #[tokio::test]
6607 async fn bot_claims_returns_already_seated_for_a_member() {
6608 let state = bot_state("bot-secret-abcdef").await;
6612 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6613 .await
6614 .unwrap();
6615 let app = router(state.clone());
6616 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6617 assert_eq!(status, StatusCode::OK);
6618 assert_eq!(json["status"], "already_seated");
6619 assert_eq!(json["code"], "");
6620 assert_eq!(json["url"], "");
6621 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6623 .await
6624 .unwrap()
6625 .is_none());
6626 }
6627
6628 #[tokio::test]
6629 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6630 let state = bot_state("bot-secret-abcdef").await;
6634 let app = router(state.clone());
6635
6636 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6637 assert_eq!(s1, StatusCode::OK);
6638 assert_eq!(j1["status"], "minted");
6639 let code1 = j1["code"].as_str().unwrap().to_string();
6640
6641 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6642 assert_eq!(s2, StatusCode::OK);
6643 assert_eq!(j2["status"], "existing");
6644 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6645 assert_eq!(j2["url"], j1["url"], "same url returned");
6646
6647 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6649 }
6650
6651 #[tokio::test]
6652 async fn bot_claims_records_intended_did_at_mint() {
6653 let state = bot_state("bot-secret-abcdef").await;
6655 let app = router(state.clone());
6656 let (status, json) =
6657 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6658 assert_eq!(status, StatusCode::OK);
6659 let code = json["code"].as_str().unwrap();
6660 assert_eq!(
6661 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6662 .await
6663 .unwrap()
6664 .as_deref(),
6665 Some(code)
6666 );
6667 }
6668
6669 #[tokio::test]
6670 async fn bot_claims_concurrent_same_did_never_double_mints() {
6671 let state = bot_state("bot-secret-abcdef").await;
6678 let app = router(state.clone());
6679
6680 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6681 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6682 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6683
6684 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6685 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6686
6687 assert_eq!(
6689 store::count_active_codes(&state.db).await.unwrap(),
6690 1,
6691 "concurrent mints must not create two active codes"
6692 );
6693
6694 let ca = ja["code"].as_str().unwrap_or("");
6696 let cb = jb["code"].as_str().unwrap_or("");
6697 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6698 assert_eq!(ca, cb, "both callers must get the one minted code");
6699 for st in [&ja["status"], &jb["status"]] {
6702 let s = st.as_str().unwrap_or("");
6703 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6704 }
6705 }
6706
6707 #[tokio::test]
6708 async fn bot_claims_rejects_malformed_json_body() {
6709 let state = bot_state("bot-secret-abcdef").await;
6710 let app = router(state);
6711 let resp = app
6712 .oneshot(
6713 Request::builder()
6714 .method("POST")
6715 .uri("/bot/claims")
6716 .header("x-bot-secret", "bot-secret-abcdef")
6717 .header("content-type", "application/json")
6718 .body(Body::from("{not json"))
6719 .unwrap(),
6720 )
6721 .await
6722 .unwrap();
6723 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6724 }
6725
6726 #[tokio::test]
6727 async fn favicon_ico_served_at_root() {
6728 let state = test_state(&[]).await;
6731 let app = router(state);
6732 let resp = app
6733 .oneshot(
6734 Request::builder()
6735 .uri("/favicon.ico")
6736 .body(Body::empty())
6737 .unwrap(),
6738 )
6739 .await
6740 .unwrap();
6741 assert_eq!(resp.status(), StatusCode::OK);
6742 let ct = resp
6743 .headers()
6744 .get(header::CONTENT_TYPE)
6745 .unwrap()
6746 .to_str()
6747 .unwrap();
6748 assert!(
6749 ct.contains("icon") || ct.starts_with("image/"),
6750 "content-type = {ct}"
6751 );
6752 }
6753
6754 #[tokio::test]
6755 async fn login_without_invite_redirects_to_beta_redeem() {
6756 let state = test_state(&[]).await;
6758 let app = router(state);
6759 let resp = app
6760 .oneshot(
6761 Request::builder()
6762 .method("POST")
6763 .uri("/login")
6764 .header("content-type", "application/x-www-form-urlencoded")
6765 .body(Body::from("handle=alice.bsky.social"))
6766 .unwrap(),
6767 )
6768 .await
6769 .unwrap();
6770 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6771 assert_eq!(
6772 resp.headers().get(header::LOCATION).unwrap(),
6773 "/beta/redeem"
6774 );
6775 }
6776
6777 #[tokio::test]
6778 async fn login_with_valid_invite_cookie_starts_oauth() {
6779 let state = test_state(&[]).await;
6780 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6781 let cookie = cookie.split(';').next().unwrap().to_string();
6782 let app = router(state);
6783 let resp = app
6784 .oneshot(
6785 Request::builder()
6786 .method("POST")
6787 .uri("/login")
6788 .header("content-type", "application/x-www-form-urlencoded")
6789 .header(header::COOKIE, cookie)
6790 .body(Body::from("handle=alice.bsky.social"))
6791 .unwrap(),
6792 )
6793 .await
6794 .unwrap();
6795 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6797 let loc = resp
6798 .headers()
6799 .get(header::LOCATION)
6800 .unwrap()
6801 .to_str()
6802 .unwrap();
6803 assert!(loc.contains("/login"), "loc = {loc}");
6804 assert_ne!(loc, "/beta/redeem");
6805 }
6806
6807 async fn resolver_never(_handle: String) -> Option<String> {
6810 None
6811 }
6812
6813 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6815 move |_handle| std::future::ready(Some(did.to_string()))
6816 }
6817
6818 #[tokio::test]
6822 async fn may_start_oauth_honors_seat_via_resolved_handle() {
6823 let state = test_state(&["did:plc:admin"]).await;
6826 let headers = HeaderMap::new();
6827 assert!(
6828 may_start_oauth_with(
6829 &state,
6830 &headers,
6831 "admin.example",
6832 resolver_to("did:plc:admin")
6833 )
6834 .await,
6835 "a handle resolving to a seated DID must pass the gate"
6836 );
6837 }
6838
6839 #[tokio::test]
6843 async fn may_start_oauth_bounces_non_member_handle() {
6844 let state = test_state(&["did:plc:admin"]).await;
6845 let headers = HeaderMap::new();
6846 assert!(
6847 !may_start_oauth_with(
6848 &state,
6849 &headers,
6850 "rando.example",
6851 resolver_to("did:plc:rando")
6852 )
6853 .await,
6854 "a resolved DID with no seat must be bounced"
6855 );
6856 }
6857
6858 #[tokio::test]
6861 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6862 let state = test_state(&["did:plc:admin"]).await;
6863 let headers = HeaderMap::new();
6864 assert!(
6865 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6866 "an unresolvable handle must fail closed"
6867 );
6868 }
6869
6870 #[tokio::test]
6874 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6875 let state = test_state(&[]).await;
6876 let did = "did:plc:member";
6877 store::grant_access(&state.db, did, Some("member.example"), "test", None)
6878 .await
6879 .unwrap();
6880 let cookie = session_cookie(&state, did, Some("member.example"));
6881 let mut headers = HeaderMap::new();
6882 headers.insert(header::COOKIE, cookie.parse().unwrap());
6883 assert!(
6884 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6885 "a seated session cookie must pass without resolution"
6886 );
6887 }
6888
6889 #[tokio::test]
6891 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6892 let state = test_state(&[]).await;
6893 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6894 let cookie = cookie.split(';').next().unwrap().to_string();
6895 let mut headers = HeaderMap::new();
6896 headers.insert(header::COOKIE, cookie.parse().unwrap());
6897 assert!(
6898 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6899 "a valid invite cookie must pass without resolution"
6900 );
6901 }
6902
6903 #[tokio::test]
6904 async fn admin_mint_requires_admin_seed_did() {
6905 let state = test_state(&["did:plc:admin"]).await;
6906 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6908 .await
6909 .unwrap();
6910 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6911 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6913 let app = router(state);
6914
6915 let forbidden = app
6916 .clone()
6917 .oneshot(
6918 Request::builder()
6919 .method("POST")
6920 .uri("/admin/invites?n=2")
6921 .header(header::COOKIE, rando_cookie)
6922 .body(Body::empty())
6923 .unwrap(),
6924 )
6925 .await
6926 .unwrap();
6927 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6928
6929 let ok = app
6930 .oneshot(
6931 Request::builder()
6932 .method("POST")
6933 .uri("/admin/invites?n=2")
6934 .header(header::COOKIE, admin_cookie)
6935 .body(Body::empty())
6936 .unwrap(),
6937 )
6938 .await
6939 .unwrap();
6940 assert_eq!(ok.status(), StatusCode::OK);
6941 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6942 .await
6943 .unwrap();
6944 let body = String::from_utf8(bytes.to_vec()).unwrap();
6945 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6946 assert_eq!(minted.len(), 2);
6947 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6948 }
6949
6950 #[tokio::test]
6951 async fn admin_mint_unauthenticated_is_401() {
6952 let state = test_state(&["did:plc:admin"]).await;
6953 let app = router(state);
6954 let resp = app
6955 .oneshot(
6956 Request::builder()
6957 .method("POST")
6958 .uri("/admin/invites")
6959 .body(Body::empty())
6960 .unwrap(),
6961 )
6962 .await
6963 .unwrap();
6964 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6965 }
6966
6967 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6970 let db = store::init_url("sqlite::memory:").await.unwrap();
6971 store::record_network_stat(
6972 &db,
6973 &store::NetworkStat {
6974 key: store::ADOPTION_STAT_KEY.to_string(),
6975 source: "https://relay1.us-west.bsky.network".to_string(),
6976 value: repos,
6977 truncated,
6978 observed_at: "2026-08-13T04:05:06Z".to_string(),
6979 },
6980 )
6981 .await
6982 .unwrap();
6983 let config = Config {
6984 cookie_secret: "test-cookie-secret-000".to_string(),
6985 show_adoption: true,
6986 ..Config::default()
6987 };
6988 AppState::new(config, db).unwrap()
6989 }
6990
6991 async fn about_body(state: AppState) -> String {
6992 let resp = router(state)
6993 .oneshot(
6994 Request::builder()
6995 .uri("/about")
6996 .body(Body::empty())
6997 .unwrap(),
6998 )
6999 .await
7000 .unwrap();
7001 assert_eq!(resp.status(), StatusCode::OK);
7002 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7003 .await
7004 .unwrap();
7005 String::from_utf8(bytes.to_vec()).unwrap()
7006 }
7007
7008 #[tokio::test]
7010 async fn about_omits_adoption_line_by_default() {
7011 let state = test_state(&[]).await;
7012 assert!(!state.config.show_adoption);
7013 let body = about_body(state).await;
7014 assert!(
7015 !body.contains("atproto network"),
7016 "the adoption line must not render by default"
7017 );
7018 }
7019
7020 #[tokio::test]
7021 async fn about_renders_adoption_line_when_enabled() {
7022 let body = about_body(adoption_state(7_318, false).await).await;
7030 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7033 assert!(
7034 flat.contains("7318 accounts on the atproto network hold"),
7035 "the count did not render in its own sentence: {flat}",
7036 );
7037 assert!(
7038 body.contains("accounts on the atproto network hold"),
7039 "{body}"
7040 );
7041 assert!(
7042 body.contains("2026-08-13"),
7043 "the observation date must render"
7044 );
7045 assert!(
7046 body.contains("lower bound"),
7047 "the non-archival caveat must ride along with the number"
7048 );
7049 assert!(
7050 !body.contains("At least"),
7051 "an untruncated count is exact-ish"
7052 );
7053 }
7054
7055 #[tokio::test]
7057 async fn about_adoption_line_is_singular_at_one() {
7058 let body = about_body(adoption_state(1, false).await).await;
7059 assert!(
7060 body.contains("account on the atproto network holds"),
7061 "{body}"
7062 );
7063 }
7064
7065 #[tokio::test]
7067 async fn about_adoption_line_says_at_least_when_truncated() {
7068 let body = about_body(adoption_state(25_000, true).await).await;
7069 assert!(body.contains("At least"), "{body}");
7070 }
7071
7072 #[tokio::test]
7074 async fn about_omits_line_when_enabled_with_no_observation() {
7075 let db = store::init_url("sqlite::memory:").await.unwrap();
7076 let config = Config {
7077 cookie_secret: "test-cookie-secret-000".to_string(),
7078 show_adoption: true,
7079 ..Config::default()
7080 };
7081 let body = about_body(AppState::new(config, db).unwrap()).await;
7082 assert!(!body.contains("atproto network"));
7083 }
7084
7085 async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7096 let db = store::init_url("sqlite::memory:").await.unwrap();
7097 store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7098 let config = Config {
7099 allowed_dids: vec![did.to_string()],
7100 cookie_secret: "test-cookie-secret-000".to_string(),
7101 beta_cap: 3,
7102 standard_site,
7103 ..Config::default()
7104 };
7105 AppState::new(config, db).unwrap()
7106 }
7107
7108 async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7110 let cookie = session_cookie(&state, did, Some("reader.example"));
7111 let resp = router(state)
7112 .oneshot(
7113 Request::builder()
7114 .uri(path)
7115 .header(header::COOKIE, cookie)
7116 .body(Body::empty())
7117 .unwrap(),
7118 )
7119 .await
7120 .unwrap();
7121 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7122 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7123 .await
7124 .unwrap();
7125 String::from_utf8(bytes.to_vec()).unwrap()
7126 }
7127
7128 async fn public_body(state: AppState, path: &str) -> String {
7130 let resp = router(state)
7131 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7132 .await
7133 .unwrap();
7134 assert_eq!(resp.status(), StatusCode::OK, "{path}");
7135 let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7136 .await
7137 .unwrap();
7138 String::from_utf8(bytes.to_vec()).unwrap()
7139 }
7140
7141 fn feed_url_input(body: &str) -> &str {
7143 let start = body
7144 .find("id=\"feed-url\"")
7145 .and_then(|i| body[..i].rfind("<input"))
7146 .expect("the subscribe form's URL input renders");
7147 let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7148 &body[start..end]
7149 }
7150
7151 #[tokio::test]
7154 async fn manage_hints_at_publications_when_the_flag_is_on() {
7155 let did = "did:plc:reader";
7156 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7157 assert!(
7158 body.contains("at://did:plc:…/site.standard.publication/…"),
7159 "the DID form must be shown: {body}"
7160 );
7161 assert!(
7162 body.contains("at://alice.example.com/site.standard.publication/…"),
7163 "the handle form must be shown: {body}"
7164 );
7165 }
7166
7167 #[tokio::test]
7173 async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7174 let did = "did:plc:reader";
7175 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7176 let input = feed_url_input(&body);
7177 assert!(
7178 input.contains("type=\"text\""),
7179 "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7180 );
7181 assert!(
7182 input.contains("inputmode=\"url\""),
7183 "the URL keyboard is still wanted: {input}"
7184 );
7185 }
7186
7187 #[tokio::test]
7193 async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7194 let did = "did:plc:reader";
7195 let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7196 let input = feed_url_input(&body);
7197 assert!(
7198 input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7199 "the text input must keep a scheme check: {input}"
7200 );
7201 }
7202
7203 #[tokio::test]
7206 async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7207 let did = "did:plc:reader";
7208 let state = standard_site_state(false, did).await;
7209 assert!(!state.config.standard_site);
7210 let body = signed_in_body(state, "/manage", did).await;
7211 assert!(
7212 !body.contains("site.standard.publication"),
7213 "a refused form must not be advertised: {body}"
7214 );
7215 assert!(
7216 !body.contains("standard.site"),
7217 "a refused form must not be advertised: {body}"
7218 );
7219 assert!(
7220 feed_url_input(&body).contains("type=\"url\""),
7221 "with the flag off the input is unchanged"
7222 );
7223 }
7224
7225 #[tokio::test]
7228 async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7229 let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7230 assert!(body.contains("standard.site"), "{body}");
7231 assert!(
7232 body.contains("at://did:plc:…/site.standard.publication/…"),
7233 "the landing page must show the DID form: {body}"
7234 );
7235 assert!(
7236 body.contains("at://alice.example.com/site.standard.publication/…"),
7237 "the landing page must show the handle form: {body}"
7238 );
7239 }
7240
7241 #[tokio::test]
7245 async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7246 let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7247 assert!(body.contains("standard.site"), "{body}");
7248 assert!(
7249 !body.contains("at://did:plc:…/site.standard.publication/…"),
7250 "no paste instructions with the flag off: {body}"
7251 );
7252 assert!(
7253 !body.contains("at://alice.example.com/site.standard.publication/…"),
7254 "no paste instructions with the flag off: {body}"
7255 );
7256 assert!(
7257 body.contains("isn't accepting new publication subscriptions"),
7258 "the page must say the form is closed here: {body}"
7259 );
7260 }
7261
7262 #[tokio::test]
7264 async fn about_describes_publications_and_how_to_subscribe_when_on() {
7265 let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7266 assert!(body.contains("site.standard.publication"), "{body}");
7267 assert!(body.contains("site.standard.document"), "{body}");
7268 assert!(
7269 body.contains("at://did:plc:…/site.standard.publication/…"),
7270 "{body}"
7271 );
7272 assert!(
7273 body.contains("at://alice.example.com/site.standard.publication/…"),
7274 "{body}"
7275 );
7276 }
7277
7278 #[tokio::test]
7280 async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7281 let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7282 assert!(body.contains("site.standard.publication"), "{body}");
7283 assert!(
7284 !body.contains("at://did:plc:…/site.standard.publication/…"),
7285 "no paste instructions with the flag off: {body}"
7286 );
7287 assert!(
7288 !body.contains("at://alice.example.com/site.standard.publication/…"),
7289 "no paste instructions with the flag off: {body}"
7290 );
7291 assert!(
7292 body.contains("isn't accepting new publication subscriptions"),
7293 "{body}"
7294 );
7295 }
7296
7297 #[tokio::test]
7298 async fn cache_control_public_on_about_no_store_on_authed() {
7299 let state = test_state(&["did:plc:admin"]).await;
7300 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7301 let app = router(state);
7302
7303 let about = app
7305 .clone()
7306 .oneshot(
7307 Request::builder()
7308 .uri("/about")
7309 .body(Body::empty())
7310 .unwrap(),
7311 )
7312 .await
7313 .unwrap();
7314 assert_eq!(
7315 about.headers().get(header::CACHE_CONTROL).unwrap(),
7316 "public, max-age=300"
7317 );
7318 assert_eq!(
7324 about.headers()["content-security-policy"],
7325 EXPECTED_CSP,
7326 "the CSP is not the policy the router promises"
7327 );
7328 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7329
7330 for path in ["/privacy", "/terms"] {
7332 let resp = app
7333 .clone()
7334 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7335 .await
7336 .unwrap();
7337 assert_eq!(resp.status(), StatusCode::OK);
7338 assert_eq!(
7339 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7340 "public, max-age=300",
7341 "{path} should be publicly cacheable"
7342 );
7343 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7345 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7346 }
7347
7348 let login = app
7350 .clone()
7351 .oneshot(
7352 Request::builder()
7353 .uri("/login")
7354 .body(Body::empty())
7355 .unwrap(),
7356 )
7357 .await
7358 .unwrap();
7359 assert_eq!(
7360 login.headers().get(header::CACHE_CONTROL).unwrap(),
7361 "public, max-age=300"
7362 );
7363
7364 let home = app
7366 .oneshot(
7367 Request::builder()
7368 .uri("/")
7369 .header(header::COOKIE, admin_cookie)
7370 .body(Body::empty())
7371 .unwrap(),
7372 )
7373 .await
7374 .unwrap();
7375 assert_eq!(
7376 home.headers().get(header::CACHE_CONTROL).unwrap(),
7377 "no-store"
7378 );
7379 }
7380
7381 #[tokio::test]
7382 async fn beta_redeem_page_renders() {
7383 let state = test_state(&[]).await;
7384 let app = router(state);
7385 let resp = app
7386 .oneshot(
7387 Request::builder()
7388 .uri("/beta/redeem")
7389 .body(Body::empty())
7390 .unwrap(),
7391 )
7392 .await
7393 .unwrap();
7394 assert_eq!(resp.status(), StatusCode::OK);
7395 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7396 .await
7397 .unwrap();
7398 let html = String::from_utf8(bytes.to_vec()).unwrap();
7399 assert!(html.contains("Invite code"));
7400 assert!(html.contains("/beta/redeem"));
7401 }
7402
7403 #[tokio::test]
7404 async fn rate_limit_returns_429_after_burst() {
7405 let db = store::init_url("sqlite::memory:").await.unwrap();
7408 store::ensure_seed(&db, &[]).await.unwrap();
7409 let config = Config {
7410 cookie_secret: "test-cookie-secret-000".to_string(),
7411 beta_cap: 3,
7412 trusted_ip_header: Some("cf-connecting-ip".to_string()),
7413 ..Config::default()
7414 };
7415 let state = AppState::new(config, db).unwrap();
7416 let app = router(state);
7417 let mut saw_429 = false;
7421 for _ in 0..(RATE_BURST as usize + 5) {
7422 let resp = app
7423 .clone()
7424 .oneshot(
7425 Request::builder()
7426 .method("POST")
7427 .uri("/beta/redeem")
7428 .header("content-type", "application/x-www-form-urlencoded")
7429 .header("cf-connecting-ip", "203.0.113.200")
7430 .body(Body::from("code=FEATHER-NOPENOPE"))
7431 .unwrap(),
7432 )
7433 .await
7434 .unwrap();
7435 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7436 saw_429 = true;
7437 break;
7438 }
7439 }
7440 assert!(saw_429, "expected a 429 after exhausting the burst");
7441 }
7442
7443 #[tokio::test]
7456 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7457 let state = test_state(&[]).await;
7458 assert!(
7459 state.config.trusted_ip_header.is_none(),
7460 "no proxy header is trusted here"
7461 );
7462 let app = router(state);
7463 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7464 let mut saw_429 = false;
7465 for i in 0..(RATE_BURST as usize + 5) {
7466 let forged = format!("10.9.8.{}", i % 250);
7467 let resp = app
7468 .clone()
7469 .oneshot(
7470 Request::builder()
7471 .method("POST")
7472 .uri("/beta/redeem")
7473 .header("content-type", "application/x-www-form-urlencoded")
7474 .header("x-forwarded-for", forged)
7475 .extension(axum::extract::ConnectInfo(peer))
7476 .body(Body::from("code=FEATHER-NOPENOPE"))
7477 .unwrap(),
7478 )
7479 .await
7480 .unwrap();
7481 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7482 saw_429 = true;
7483 break;
7484 }
7485 }
7486 assert!(
7487 saw_429,
7488 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7489 );
7490 }
7491
7492 #[tokio::test]
7501 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7502 let did = "did:plc:privateadder";
7503 let state = test_state_with_caps(did, 0, 0).await;
7504 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7505 let port: u16 = base
7506 .trim_end_matches('/')
7507 .rsplit(':')
7508 .next()
7509 .unwrap()
7510 .parse()
7511 .unwrap();
7512 crate::net::test_host_override(
7513 "private-add.test",
7514 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7515 );
7516 let cookie = session_cookie(&state, did, None);
7517 let resp = router(state.clone())
7518 .oneshot(
7519 Request::builder()
7520 .method("POST")
7521 .uri("/subscriptions")
7522 .header(header::COOKIE, cookie)
7523 .header("content-type", "application/x-www-form-urlencoded")
7524 .body(Body::from(format!(
7525 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7526 )))
7527 .unwrap(),
7528 )
7529 .await
7530 .unwrap();
7531 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7532 let loc = resp
7533 .headers()
7534 .get(header::LOCATION)
7535 .unwrap()
7536 .to_str()
7537 .unwrap();
7538 assert!(loc.contains("Private"), "not refused as private: {loc}");
7539 assert_eq!(
7540 hits.load(std::sync::atomic::Ordering::SeqCst),
7541 0,
7542 "the private feed was FETCHED before being refused"
7543 );
7544 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7545 }
7546
7547 #[tokio::test]
7552 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7553 let did = "did:plc:renamer4";
7554 let (sidecar, bodies) = spawn_logging_sidecar().await;
7555 let state = test_state_with_sidecar(&[did], &sidecar).await;
7556 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7557 let opml = format!(
7558 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7559 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7560 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7561 </body></opml>"
7562 );
7563 let (ct, body) = opml_multipart(opml.as_bytes());
7564 let cookie = session_cookie(&state, did, None);
7565 let resp = router(state.clone())
7566 .oneshot(
7567 Request::builder()
7568 .method("POST")
7569 .uri("/opml")
7570 .header(header::COOKIE, cookie)
7571 .header("content-type", ct)
7572 .body(Body::from(body))
7573 .unwrap(),
7574 )
7575 .await
7576 .unwrap();
7577 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7578 let loc = resp
7579 .headers()
7580 .get(header::LOCATION)
7581 .unwrap()
7582 .to_str()
7583 .unwrap();
7584 assert!(
7585 loc.contains("skipped%20as%20private"),
7586 "not reported as skipped: {loc}"
7587 );
7588 assert!(store::get_feed_by_url(&state.db, tokened)
7589 .await
7590 .unwrap()
7591 .is_none());
7592 let sent = bodies.lock().unwrap().join("\n");
7593 assert!(
7594 sent.contains("public.example"),
7595 "the public feed was not written: {sent}"
7596 );
7597 assert!(
7598 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7599 "the secret was PUBLISHED to the PDS: {sent}"
7600 );
7601 }
7602
7603 #[tokio::test]
7607 async fn get_login_without_a_seat_is_refused() {
7608 let state = test_state(&[]).await;
7609 let resp = router(state)
7610 .oneshot(
7611 Request::builder()
7612 .method("GET")
7613 .uri("/login?handle=alice.bsky.social")
7614 .body(Body::empty())
7615 .unwrap(),
7616 )
7617 .await
7618 .unwrap();
7619 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7620 assert_eq!(
7621 resp.headers().get(header::LOCATION).unwrap(),
7622 "/beta/redeem"
7623 );
7624 }
7625
7626 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7630 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7631 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7632 let addr = listener.local_addr().unwrap();
7633 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7634 let sink = log.clone();
7635 tokio::spawn(async move {
7636 loop {
7637 let Ok((mut sock, _)) = listener.accept().await else {
7638 break;
7639 };
7640 let mut raw: Vec<u8> = Vec::new();
7641 let mut chunk = [0u8; 4096];
7642 let text = loop {
7643 let Ok(n) = sock.read(&mut chunk).await else {
7644 break String::new();
7645 };
7646 if n == 0 {
7647 break String::from_utf8_lossy(&raw).to_string();
7648 }
7649 raw.extend_from_slice(&chunk[..n]);
7650 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7651 continue;
7652 };
7653 let (head, body) = raw.split_at(split + 4);
7654 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7655 let (k, v) = l.split_once(':')?;
7656 k.eq_ignore_ascii_case("content-length")
7657 .then(|| v.trim().parse::<usize>().ok())?
7658 });
7659 if want.is_none_or(|w| body.len() >= w) {
7660 break String::from_utf8_lossy(&raw).to_string();
7661 }
7662 };
7663 let path = text
7664 .lines()
7665 .next()
7666 .and_then(|l| l.split_whitespace().nth(1))
7667 .unwrap_or("")
7668 .to_string();
7669 let body_text = text
7670 .split_once("\r\n\r\n")
7671 .map(|(_, b)| b)
7672 .unwrap_or("")
7673 .to_string();
7674 sink.lock().unwrap().push(format!("{path} {body_text}"));
7675 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();
7676 let resp = format!(
7677 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7678 body.len(),
7679 body
7680 );
7681 let _ = sock.write_all(resp.as_bytes()).await;
7682 let _ = sock.flush().await;
7683 }
7684 });
7685 (format!("http://{addr}"), log)
7686 }
7687
7688 #[tokio::test]
7697 async fn signing_out_flushes_before_it_revokes_through_the_route() {
7698 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7699 let (sidecar, log) = spawn_logging_sidecar().await;
7700 let state = test_state_with_sidecar(&[did], &sidecar).await;
7701 crate::store::upsert_cursor(
7702 &state.db,
7703 &crate::store::ReadCursor {
7704 did: did.to_string(),
7705 feed_url: "https://example.com/feed.xml".into(),
7706 read_through: None,
7707 read_ids: "[\"1\"]".into(),
7708 unread_ids: "[]".into(),
7709 dirty: true,
7710 pds_created: false,
7711 updated_at: "2026-09-13T21:22:40Z".into(),
7712 },
7713 )
7714 .await
7715 .unwrap();
7716 let cookie = session_cookie(&state, did, None);
7717 let resp = router(state.clone())
7718 .oneshot(
7719 Request::builder()
7720 .method("POST")
7721 .uri("/logout")
7722 .header(header::COOKIE, cookie)
7723 .body(Body::empty())
7724 .unwrap(),
7725 )
7726 .await
7727 .unwrap();
7728 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7729
7730 let entries = log.lock().unwrap().clone();
7731 let flush = entries
7732 .iter()
7733 .position(|e| e.starts_with("/internal/repo "));
7734 let revoke = entries
7735 .iter()
7736 .position(|e| e.starts_with("/internal/revoke "));
7737 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7738 assert!(
7739 flush.is_some(),
7740 "sign-out did not attempt a flush before revoking: {entries:?}"
7741 );
7742 assert!(
7743 flush < revoke,
7744 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7745 );
7746 }
7747
7748 const EXPECTED_CSP: &str = "default-src 'self'; \
7752 script-src 'self'; \
7753 style-src 'self' 'unsafe-inline'; \
7754 img-src 'self' https: data:; \
7755 font-src 'self'; \
7756 connect-src 'self'; \
7757 form-action 'self'; \
7758 base-uri 'self'; \
7759 frame-ancestors 'none'; \
7760 object-src 'none'";
7761
7762 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7765 let boundary = "----featherreadertestboundary";
7766 let mut body = Vec::new();
7767 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7768 body.extend_from_slice(
7769 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7770 );
7771 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7772 body.extend_from_slice(payload);
7773 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7774 (format!("multipart/form-data; boundary={boundary}"), body)
7775 }
7776
7777 #[tokio::test]
7778 async fn opml_import_oversize_upload_returns_413() {
7779 let state = test_state(&["did:plc:admin"]).await;
7780 let cookie = session_cookie(&state, "did:plc:admin", None);
7781 let app = router(state);
7782
7783 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7785 let (content_type, body) = opml_multipart(&payload);
7786
7787 let resp = app
7788 .oneshot(
7789 Request::builder()
7790 .method("POST")
7791 .uri("/opml")
7792 .header("content-type", content_type)
7793 .header(header::COOKIE, cookie)
7794 .body(Body::from(body))
7795 .unwrap(),
7796 )
7797 .await
7798 .unwrap();
7799 assert_eq!(
7800 resp.status(),
7801 StatusCode::PAYLOAD_TOO_LARGE,
7802 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7803 );
7804 }
7805
7806 #[tokio::test]
7817 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7818 let state = test_state(&["did:plc:admin"]).await;
7819 let cookie = session_cookie(&state, "did:plc:admin", None);
7820 let app = router(state);
7821
7822 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7824 let (content_type, body) = opml_multipart(&payload);
7825
7826 let resp = app
7827 .oneshot(
7828 Request::builder()
7829 .method("POST")
7830 .uri("/opml")
7831 .header("content-type", content_type)
7832 .header(header::COOKIE, cookie)
7833 .body(Body::from(body))
7834 .unwrap(),
7835 )
7836 .await
7837 .unwrap();
7838 assert_eq!(
7839 resp.status(),
7840 StatusCode::PAYLOAD_TOO_LARGE,
7841 "a payload over the route's cap but under the framework's was accepted — \
7842 the route's own DefaultBodyLimit layer is not doing anything"
7843 );
7844 }
7845
7846 #[tokio::test]
7847 async fn opml_import_under_limit_upload_is_accepted() {
7848 let state = test_state(&["did:plc:admin"]).await;
7849 let cookie = session_cookie(&state, "did:plc:admin", None);
7850 let db = state.db.clone();
7851 let app = router(state);
7852
7853 let opml = br#"<?xml version="1.0"?>
7856<opml version="2.0"><body>
7857 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7858</body></opml>"#;
7859 let (content_type, body) = opml_multipart(opml);
7860
7861 let resp = app
7862 .oneshot(
7863 Request::builder()
7864 .method("POST")
7865 .uri("/opml")
7866 .header("content-type", content_type)
7867 .header(header::COOKIE, cookie)
7868 .body(Body::from(body))
7869 .unwrap(),
7870 )
7871 .await
7872 .unwrap();
7873 assert_eq!(
7880 resp.status(),
7881 StatusCode::SEE_OTHER,
7882 "an under-cap OPML upload was not accepted (status {})",
7883 resp.status(),
7884 );
7885 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7889 .bind("https://example.com/feed.xml")
7890 .fetch_one(&db)
7891 .await
7892 .unwrap();
7893 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7894 let location = resp
7895 .headers()
7896 .get(header::LOCATION)
7897 .and_then(|v| v.to_str().ok())
7898 .unwrap_or_default()
7899 .to_string();
7900 assert!(
7901 !location.starts_with("/login"),
7902 "the import bounced to login instead of being accepted: {location}",
7903 );
7904 }
7905
7906 #[tokio::test]
7907 async fn opml_import_logged_out_redirects_to_login() {
7908 let state = test_state(&["did:plc:admin"]).await;
7911 let app = router(state);
7912
7913 let opml = b"<opml version=\"2.0\"><body></body></opml>";
7914 let (content_type, body) = opml_multipart(opml);
7915
7916 let resp = app
7917 .oneshot(
7918 Request::builder()
7919 .method("POST")
7920 .uri("/opml")
7921 .header("content-type", content_type)
7922 .body(Body::from(body))
7923 .unwrap(),
7924 )
7925 .await
7926 .unwrap();
7927 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7928 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7929 }
7930
7931 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7938 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7939 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7940 let addr = listener.local_addr().unwrap();
7941 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7942 tokio::spawn(async move {
7943 let (mut sock, _) = listener.accept().await.unwrap();
7944 let mut buf = vec![0u8; 4096];
7945 let n = sock.read(&mut buf).await.unwrap();
7946 let req = String::from_utf8_lossy(&buf[..n]).to_string();
7947 let did = req
7949 .split("\r\n\r\n")
7950 .nth(1)
7951 .and_then(|body| {
7952 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7953 v.get("did")?.as_str().map(str::to_string)
7954 })
7955 .unwrap_or_default();
7956 let is_revoke = req.starts_with("POST /internal/revoke");
7957 let body = serde_json::json!({
7958 "ok": true, "did": did, "revoked": true, "hadSession": true
7959 })
7960 .to_string();
7961 let resp = format!(
7962 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7963 body.len(),
7964 body
7965 );
7966 sock.write_all(resp.as_bytes()).await.unwrap();
7967 sock.flush().await.unwrap();
7968 let _ = tx.send(if is_revoke { did } else { String::new() });
7969 });
7970 (format!("http://{addr}"), rx)
7971 }
7972
7973 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7975 let defaults = Config::default();
7976 test_state_with_sidecar_and(
7977 allowed,
7978 sidecar_url,
7979 defaults.standard_site,
7980 defaults.max_feeds_global,
7981 )
7982 .await
7983 }
7984
7985 async fn test_state_with_sidecar_and(
7988 allowed: &[&str],
7989 sidecar_url: &str,
7990 standard_site: bool,
7991 max_feeds_global: i64,
7992 ) -> AppState {
7993 let db = store::init_url("sqlite::memory:").await.unwrap();
7994 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7995 store::ensure_seed(&db, &dids).await.unwrap();
7996 let mut config = Config {
7997 allowed_dids: dids,
7998 cookie_secret: "test-cookie-secret-000".to_string(),
7999 beta_cap: 3,
8000 standard_site,
8001 max_feeds_global,
8002 ..Config::default()
8003 };
8004 config.sidecar.public_url = sidecar_url.to_string();
8005 config.sidecar.internal_url = sidecar_url.to_string();
8006 AppState::new(config, db).unwrap()
8007 }
8008
8009 #[tokio::test]
8012 async fn account_delete_purges_rows_and_triggers_revoke() {
8013 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8014 let did = "did:plc:leaver";
8015 let state = test_state_with_sidecar(&[], &sidecar_url).await;
8016
8017 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8019 .await
8020 .unwrap();
8021 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8022 store::mint_code(&state.db, did, 3600).await.unwrap();
8023 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8024
8025 let cookie = session_cookie(&state, did, Some("leaver.example"));
8026 let app = router(state.clone());
8027
8028 let resp = app
8029 .oneshot(
8030 Request::builder()
8031 .method("POST")
8032 .uri("/account/delete")
8033 .header(header::COOKIE, cookie)
8034 .header("content-type", "application/x-www-form-urlencoded")
8035 .body(Body::from("confirm=DELETE"))
8036 .unwrap(),
8037 )
8038 .await
8039 .unwrap();
8040
8041 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8043 assert!(resp
8044 .headers()
8045 .get(header::LOCATION)
8046 .unwrap()
8047 .to_str()
8048 .unwrap()
8049 .starts_with("/login"));
8050 let set_cookie = resp
8051 .headers()
8052 .get(header::SET_COOKIE)
8053 .unwrap()
8054 .to_str()
8055 .unwrap();
8056 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8057
8058 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8065 .await
8066 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8067 .unwrap();
8068 assert_eq!(
8069 revoked_did, did,
8070 "sidecar revoke must fire for the caller DID"
8071 );
8072
8073 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8075 let codes: i64 =
8076 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8077 .bind(did)
8078 .fetch_one(&state.db)
8079 .await
8080 .unwrap();
8081 assert_eq!(codes, 0);
8082 }
8083
8084 #[tokio::test]
8087 async fn account_delete_without_confirm_is_a_noop() {
8088 let did = "did:plc:staying";
8089 let state = test_state(&[]).await;
8090 store::grant_access(&state.db, did, None, "test", None)
8091 .await
8092 .unwrap();
8093 let cookie = session_cookie(&state, did, None);
8094 let app = router(state.clone());
8095
8096 let resp = app
8097 .oneshot(
8098 Request::builder()
8099 .method("POST")
8100 .uri("/account/delete")
8101 .header(header::COOKIE, cookie)
8102 .header("content-type", "application/x-www-form-urlencoded")
8103 .body(Body::from("confirm=nope"))
8104 .unwrap(),
8105 )
8106 .await
8107 .unwrap();
8108
8109 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8110 assert!(resp
8111 .headers()
8112 .get(header::LOCATION)
8113 .unwrap()
8114 .to_str()
8115 .unwrap()
8116 .starts_with("/manage"));
8117 assert!(store::has_beta_access(&state.db, did).await.unwrap());
8119 }
8120
8121 #[tokio::test]
8128 async fn pds_outage_does_not_widen_cross_did_access() {
8129 let did_a = "did:plc:aaaa";
8130 let state = test_state(&[]).await;
8131 store::grant_access(&state.db, did_a, None, "test", None)
8132 .await
8133 .unwrap();
8134
8135 let feed_a = store::upsert_feed(
8138 &state.db,
8139 &store::NewFeed {
8140 url: "https://a.example/feed.xml".to_string(),
8141 title: Some("A".to_string()),
8142 ..Default::default()
8143 },
8144 )
8145 .await
8146 .unwrap();
8147 let feed_b = store::upsert_feed(
8148 &state.db,
8149 &store::NewFeed {
8150 url: "https://b.example/feed.xml".to_string(),
8151 title: Some("B".to_string()),
8152 ..Default::default()
8153 },
8154 )
8155 .await
8156 .unwrap();
8157 store::insert_entries(
8158 &state.db,
8159 feed_b,
8160 &[store::NewEntry {
8161 guid: "b-1".to_string(),
8162 url: Some("https://b.example/1".to_string()),
8163 title: Some("B one".to_string()),
8164 published: Some("2026-07-11T00:00:00Z".to_string()),
8165 content_html: Some("<p>secret B body</p>".to_string()),
8166 ..Default::default()
8167 }],
8168 0,
8169 )
8170 .await
8171 .unwrap();
8172 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8174 .await
8175 .unwrap();
8176 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8179 .await
8180 .unwrap();
8181 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8182 .await
8183 .unwrap()[0]
8184 .id;
8185 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8186 .await
8187 .unwrap();
8188
8189 let cookie = session_cookie(&state, did_a, None);
8190 let app = router(state.clone());
8191
8192 let get_b = app
8194 .clone()
8195 .oneshot(
8196 Request::builder()
8197 .method("GET")
8198 .uri(format!("/entries/{b_entry_id}"))
8199 .header(header::COOKIE, cookie.clone())
8200 .body(Body::empty())
8201 .unwrap(),
8202 )
8203 .await
8204 .unwrap();
8205 assert_eq!(
8206 get_b.status(),
8207 StatusCode::NOT_FOUND,
8208 "A must not read B's entry during a PDS outage"
8209 );
8210
8211 let read_b = app
8213 .oneshot(
8214 Request::builder()
8215 .method("POST")
8216 .uri(format!("/entries/{b_entry_id}/read"))
8217 .header(header::COOKIE, cookie)
8218 .header("content-type", "application/x-www-form-urlencoded")
8219 .body(Body::from("read=true"))
8220 .unwrap(),
8221 )
8222 .await
8223 .unwrap();
8224 assert_eq!(
8225 read_b.status(),
8226 StatusCode::NOT_FOUND,
8227 "A must not mark B's entry read during a PDS outage"
8228 );
8229
8230 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8232 .bind(did_a)
8233 .fetch_all(&state.db)
8234 .await
8235 .unwrap();
8236 assert_eq!(
8237 a_feed_ids,
8238 vec![feed_a],
8239 "outage fallback must not add feeds A never subscribed to"
8240 );
8241 let es_count: i64 =
8243 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
8244 .bind(did_a)
8245 .bind(b_entry_id)
8246 .fetch_one(&state.db)
8247 .await
8248 .unwrap();
8249 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
8250 }
8251
8252 #[tokio::test]
8266 async fn a_logout_with_no_session_counts_as_success() {
8267 let did = "did:plc:aaaa";
8268 let state = test_state(&[]).await;
8269 assert!(
8270 state.oauth.is_some(),
8271 "meaningless without an oauth runtime; the revoke arm would be skipped",
8272 );
8273
8274 revoke_everywhere(&state, did).await;
8275 let rows = state.metrics.snapshot();
8276 let find = |b: crate::metrics::Backend| {
8277 rows.iter()
8278 .find(|r| r.op == "oauth_revoke" && r.backend == b)
8279 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
8280 };
8281
8282 let rust = find(crate::metrics::Backend::Rust);
8284 assert_eq!(
8285 rust.stats.err_count, 0,
8286 "NoSession was counted as a failure; logout is idempotent",
8287 );
8288 assert_eq!(rust.stats.ok_count, 1);
8289
8290 let sidecar = find(crate::metrics::Backend::Sidecar);
8294 assert_eq!(
8295 sidecar.stats.err_count, 1,
8296 "a failed sidecar revoke was not counted",
8297 );
8298 }
8299
8300 #[tokio::test]
8310 async fn a_failed_rust_revoke_counts_as_an_error() {
8311 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8312 let state = test_state(&[]).await;
8313 let runtime = state.oauth.as_deref().expect("oauth runtime");
8314 crate::oauth::store::put_session(
8315 &state.db,
8316 &runtime.codec,
8317 &crate::oauth::store::OAuthSession {
8318 sub: did.into(),
8319 issuer: "https://auth.invalid".into(),
8320 aud: "https://pds.invalid".into(),
8321 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
8322 .to_jwk_json()
8323 .unwrap(),
8324 access_token: "at".into(),
8325 refresh_token: "rt".into(),
8326 token_type: "DPoP".into(),
8327 granted_scope: "atproto".into(),
8328 expires_at: Some(crate::store::now_unix() + 3600),
8329 },
8330 )
8331 .await
8332 .unwrap();
8333
8334 revoke_everywhere(&state, did).await;
8335
8336 let rows = state.metrics.snapshot();
8337 let rust = rows
8338 .iter()
8339 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
8340 .expect("no rust oauth_revoke row");
8341 assert_eq!(
8342 rust.stats.err_count, 1,
8343 "an unreachable PDS must count as a revocation failure",
8344 );
8345 assert_eq!(rust.stats.ok_count, 0);
8346 }
8347
8348 #[test]
8364 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
8365 for hostile in [
8366 "javascript:alert(1)",
8367 "JavaScript:alert(1)",
8368 " javascript:alert(1)",
8369 "data:text/html;base64,PHNjcmlwdD4=",
8370 "vbscript:msgbox(1)",
8371 "file:///etc/passwd",
8372 "//evil.example/path",
8376 ] {
8377 let link = SafeLink::external(hostile);
8378 assert!(
8379 link.is_empty(),
8380 "{hostile:?} produced a non-empty href: {link}",
8381 );
8382 assert!(
8383 !link.to_string().to_ascii_lowercase().contains("script"),
8384 "{hostile:?} leaked into the rendered link",
8385 );
8386 }
8387
8388 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8391 let link = SafeLink::external(good);
8392 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8393 assert_eq!(link.to_string(), good);
8394 }
8395 }
8396
8397 #[tokio::test]
8412 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8413 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8414 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8415 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8416 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8417
8418 let resp = router(state)
8419 .oneshot(
8420 Request::builder()
8421 .uri("/?view=starred")
8422 .body(Body::empty())
8423 .unwrap(),
8424 )
8425 .await
8426 .unwrap();
8427 assert_eq!(resp.status(), StatusCode::OK);
8428 let body = String::from_utf8(
8429 axum::body::to_bytes(resp.into_body(), usize::MAX)
8430 .await
8431 .unwrap()
8432 .to_vec(),
8433 )
8434 .unwrap();
8435
8436 assert!(
8439 !body.to_ascii_lowercase().contains("javascript:"),
8440 "the hostile scheme reached the rendered page",
8441 );
8442 assert!(
8445 body.contains("unusable link"),
8446 "the row was dropped instead of rendering without an anchor",
8447 );
8448 }
8449
8450 #[tokio::test]
8467 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8468 let did = "did:plc:readerhref";
8469 let state = test_state(&[]).await;
8470 store::grant_access(&state.db, did, None, "test", None)
8471 .await
8472 .unwrap();
8473 let feed = store::upsert_feed(
8474 &state.db,
8475 &store::NewFeed {
8476 url: "https://href.example/feed.xml".to_string(),
8477 title: Some("Href".to_string()),
8478 ..Default::default()
8479 },
8480 )
8481 .await
8482 .unwrap();
8483 store::insert_entries(
8485 &state.db,
8486 feed,
8487 &[
8488 store::NewEntry {
8489 guid: "hostile-1".to_string(),
8490 url: Some("javascript:alert(1)".to_string()),
8491 title: Some("Hostile entry".to_string()),
8492 published: Some("2026-07-11T00:00:00Z".to_string()),
8493 ..Default::default()
8494 },
8495 store::NewEntry {
8496 guid: "benign-1".to_string(),
8497 url: Some("https://href.example/post".to_string()),
8498 title: Some("Benign entry".to_string()),
8499 published: Some("2026-07-10T00:00:00Z".to_string()),
8500 ..Default::default()
8501 },
8502 ],
8503 0,
8504 )
8505 .await
8506 .unwrap();
8507 store::replace_sub_refs(&state.db, did, &[feed])
8508 .await
8509 .unwrap();
8510 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8511 let id_of = |guid: &str| {
8512 rows.iter()
8513 .find(|r| r.guid == guid)
8514 .unwrap_or_else(|| panic!("{guid} was not inserted"))
8515 .id
8516 };
8517
8518 let cookie = session_cookie(&state, did, None);
8519 let app = router(state.clone());
8520
8521 let render = |id: i64| {
8522 let app = app.clone();
8523 let cookie = cookie.clone();
8524 async move {
8525 let resp = app
8526 .oneshot(
8527 Request::builder()
8528 .method("GET")
8529 .uri(format!("/entries/{id}"))
8530 .header(header::COOKIE, cookie)
8531 .body(Body::empty())
8532 .unwrap(),
8533 )
8534 .await
8535 .unwrap();
8536 assert_eq!(resp.status(), StatusCode::OK);
8537 String::from_utf8(
8538 axum::body::to_bytes(resp.into_body(), usize::MAX)
8539 .await
8540 .unwrap()
8541 .to_vec(),
8542 )
8543 .unwrap()
8544 }
8545 };
8546
8547 let hostile = render(id_of("hostile-1")).await;
8548 assert!(
8551 hostile.contains("Hostile entry"),
8552 "the reader did not render the entry: {hostile}",
8553 );
8554 assert!(
8555 !hostile.to_ascii_lowercase().contains("javascript:"),
8556 "the hostile scheme reached the reader page: {hostile}",
8557 );
8558 assert!(
8562 !hostile.contains("actionbar-open"),
8563 "the action bar rendered an open-original link for a refused URL: {hostile}",
8564 );
8565 assert!(
8566 !hostile.contains("Original \u{2197}"),
8567 "the byline rendered an original link for a refused URL: {hostile}",
8568 );
8569
8570 let benign = render(id_of("benign-1")).await;
8573 assert!(
8574 benign.contains("Benign entry"),
8575 "the reader did not render the benign entry: {benign}",
8576 );
8577 assert_eq!(
8581 benign
8582 .matches(r#"href="https://href.example/post""#)
8583 .count(),
8584 2,
8585 "entry.html has two `href`s for the entry URL — the byline link and \
8586 the action-bar button — and this render produced a different \
8587 number: {benign}",
8588 );
8589 assert!(
8590 benign.contains("actionbar-open"),
8591 "a legitimate entry lost its open-original button: {benign}",
8592 );
8593 assert!(
8594 benign.contains("Original \u{2197}"),
8595 "a legitimate entry lost its byline link: {benign}",
8596 );
8597 }
8598
8599 #[tokio::test]
8619 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8620 let did_a = "did:plc:aaaa";
8621 let state = test_state(&[]).await;
8622 store::grant_access(&state.db, did_a, None, "test", None)
8623 .await
8624 .unwrap();
8625
8626 let feed_a = store::upsert_feed(
8627 &state.db,
8628 &store::NewFeed {
8629 url: "https://a.example/feed.xml".to_string(),
8630 title: Some("A".to_string()),
8631 ..Default::default()
8632 },
8633 )
8634 .await
8635 .unwrap();
8636 let _feed_b = store::upsert_feed(
8637 &state.db,
8638 &store::NewFeed {
8639 url: "https://b.example/feed.xml".to_string(),
8640 title: Some("B".to_string()),
8641 ..Default::default()
8642 },
8643 )
8644 .await
8645 .unwrap();
8646 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8649 .await
8650 .unwrap();
8651
8652 assert!(
8657 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8658 "this test is only meaningful on the outage path; the repo answered",
8659 );
8660
8661 let resolved = resolve_subscriptions(&state, did_a).await;
8662
8663 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8664 assert_eq!(
8665 urls,
8666 vec!["https://a.example/feed.xml"],
8667 "the outage fallback must return the caller's OWN subscriptions only; \
8668 any other feed here is cross-tenant read access granted by an outage",
8669 );
8670 }
8671
8672 async fn test_state_with_caps(
8675 did: &str,
8676 max_subs_per_did: i64,
8677 max_feeds_global: i64,
8678 ) -> AppState {
8679 let db = store::init_url("sqlite::memory:").await.unwrap();
8680 let config = Config {
8681 cookie_secret: "test-cookie-secret-000".to_string(),
8682 beta_cap: 100,
8683 max_subs_per_did,
8684 max_feeds_global,
8685 ..Config::default()
8686 };
8687 store::grant_access(&db, did, None, "test", None)
8688 .await
8689 .unwrap();
8690 AppState::new(config, db).unwrap()
8691 }
8692
8693 fn opml_with_feeds(n: usize) -> String {
8695 let mut outlines = String::new();
8696 for i in 0..n {
8697 outlines.push_str(&format!(
8698 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8699 ));
8700 }
8701 format!(
8702 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8703 )
8704 }
8705
8706 #[tokio::test]
8711 async fn opml_import_enforces_global_feeds_ceiling() {
8712 let did = "did:plc:importer";
8713 let state = test_state_with_caps(did, 0, 3).await;
8715 let cookie = session_cookie(&state, did, None);
8716 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8717 let app = router(state.clone());
8718
8719 let resp = app
8720 .oneshot(
8721 Request::builder()
8722 .method("POST")
8723 .uri("/opml")
8724 .header(header::COOKIE, cookie)
8725 .header("content-type", ct)
8726 .body(Body::from(body))
8727 .unwrap(),
8728 )
8729 .await
8730 .unwrap();
8731 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8732
8733 let feeds = store::count_feeds(&state.db).await.unwrap();
8734 assert!(
8735 feeds <= 3,
8736 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8737 );
8738 }
8739
8740 #[tokio::test]
8749 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8750 let did = "did:plc:typoist";
8751 let state = test_state_with_caps(did, 0, 0).await;
8752 let cookie = session_cookie(&state, did, None);
8753 for input in [
8754 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8755 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8756 ] {
8757 let resp = router(state.clone())
8758 .oneshot(
8759 Request::builder()
8760 .method("POST")
8761 .uri("/subscriptions")
8762 .header(header::COOKIE, cookie.clone())
8763 .header("content-type", "application/x-www-form-urlencoded")
8764 .body(Body::from(format!("url={input}")))
8765 .unwrap(),
8766 )
8767 .await
8768 .unwrap();
8769 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8770 let loc = resp
8771 .headers()
8772 .get(header::LOCATION)
8773 .unwrap()
8774 .to_str()
8775 .unwrap();
8776 assert!(
8777 loc.contains("kind%20of%20feed"),
8778 "expected the unsupported-feed flash for {input}, got {loc}"
8779 );
8780 assert!(
8781 !loc.contains("Private"),
8782 "a storability refusal was reported as a privacy one for {input}: {loc}"
8783 );
8784 }
8785 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8786 }
8787
8788 #[tokio::test]
8795 async fn opml_import_reports_entries_this_instance_cannot_store() {
8796 let did = "did:plc:renamer4";
8797 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8798 let state = test_state_with_sidecar(&[did], &sidecar).await;
8799 assert!(!state.config.standard_site);
8800 let opml = format!(
8801 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8802 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8803 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8804 </body></opml>"
8805 );
8806 let (ct, body) = opml_multipart(opml.as_bytes());
8807 let cookie = session_cookie(&state, did, None);
8808 let resp = router(state.clone())
8809 .oneshot(
8810 Request::builder()
8811 .method("POST")
8812 .uri("/opml")
8813 .header(header::COOKIE, cookie)
8814 .header("content-type", ct)
8815 .body(Body::from(body))
8816 .unwrap(),
8817 )
8818 .await
8819 .unwrap();
8820 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8821 let loc = resp
8822 .headers()
8823 .get(header::LOCATION)
8824 .unwrap()
8825 .to_str()
8826 .unwrap();
8827 assert!(
8828 loc.contains("Imported%201%20feed"),
8829 "unexpected flash: {loc}"
8830 );
8831 assert!(
8832 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8833 "the dropped entry was not reported: {loc}"
8834 );
8835 assert!(
8837 !loc.contains("site.standard.publication"),
8838 "the URI was echoed: {loc}"
8839 );
8840 }
8841
8842 #[tokio::test]
8845 async fn opml_import_enforces_per_did_cap() {
8846 let did = "did:plc:capped";
8847 let state = test_state_with_caps(did, 2, 0).await;
8849 let existing_a = store::upsert_feed(
8850 &state.db,
8851 &store::NewFeed {
8852 url: "https://have-a.example/feed.xml".to_string(),
8853 ..Default::default()
8854 },
8855 )
8856 .await
8857 .unwrap();
8858 let existing_b = store::upsert_feed(
8859 &state.db,
8860 &store::NewFeed {
8861 url: "https://have-b.example/feed.xml".to_string(),
8862 ..Default::default()
8863 },
8864 )
8865 .await
8866 .unwrap();
8867 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8868 .await
8869 .unwrap();
8870 let before = store::count_feeds(&state.db).await.unwrap();
8871
8872 let cookie = session_cookie(&state, did, None);
8873 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8874 let app = router(state.clone());
8875 let resp = app
8876 .oneshot(
8877 Request::builder()
8878 .method("POST")
8879 .uri("/opml")
8880 .header(header::COOKIE, cookie)
8881 .header("content-type", ct)
8882 .body(Body::from(body))
8883 .unwrap(),
8884 )
8885 .await
8886 .unwrap();
8887 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8888 let after = store::count_feeds(&state.db).await.unwrap();
8890 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8891 }
8892
8893 #[tokio::test]
8896 async fn single_add_enforces_per_did_cap() {
8897 let did = "did:plc:subcapped";
8898 let state = test_state_with_caps(did, 1, 0).await;
8899 let f = store::upsert_feed(
8900 &state.db,
8901 &store::NewFeed {
8902 url: "https://have.example/feed.xml".to_string(),
8903 ..Default::default()
8904 },
8905 )
8906 .await
8907 .unwrap();
8908 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8909 let cookie = session_cookie(&state, did, None);
8910 let app = router(state.clone());
8911 let resp = app
8912 .oneshot(
8913 Request::builder()
8914 .method("POST")
8915 .uri("/subscriptions")
8916 .header(header::COOKIE, cookie)
8917 .header("content-type", "application/x-www-form-urlencoded")
8918 .body(Body::from("url=https://another.example/feed.xml"))
8919 .unwrap(),
8920 )
8921 .await
8922 .unwrap();
8923 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8924 let loc = resp
8925 .headers()
8926 .get(header::LOCATION)
8927 .unwrap()
8928 .to_str()
8929 .unwrap();
8930 assert!(
8931 loc.contains("Subscription%20limit%20reached"),
8932 "expected sub-limit flash, got {loc}"
8933 );
8934 }
8935
8936 #[tokio::test]
8945 async fn the_reader_index_pages_instead_of_rendering_everything() {
8946 let did = "did:plc:pager";
8947 let state = test_state(&[]).await;
8948 store::grant_access(&state.db, did, None, "test", None)
8949 .await
8950 .unwrap();
8951 let feed = store::upsert_feed(
8952 &state.db,
8953 &store::NewFeed {
8954 url: "https://pager.example/feed.xml".to_string(),
8955 title: Some("Pager".to_string()),
8956 ..Default::default()
8957 },
8958 )
8959 .await
8960 .unwrap();
8961 let total = 250_usize;
8962 let entries: Vec<store::NewEntry> = (0..total)
8963 .map(|i| store::NewEntry {
8964 guid: format!("p-{i:04}"),
8965 url: Some(format!("https://pager.example/{i}")),
8966 title: Some(format!("Article {i:04}")),
8967 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8968 content_html: Some("x".repeat(4_000)),
8969 ..Default::default()
8970 })
8971 .collect();
8972 store::insert_entries(&state.db, feed, &entries, 0)
8973 .await
8974 .unwrap();
8975 store::replace_sub_refs(&state.db, did, &[feed])
8976 .await
8977 .unwrap();
8978
8979 let cookie = session_cookie(&state, did, None);
8980 let app = router(state.clone());
8981 let get = |uri: &str| {
8982 let app = app.clone();
8983 let cookie = cookie.clone();
8984 let uri = uri.to_string();
8985 async move {
8986 let resp = app
8987 .oneshot(
8988 Request::builder()
8989 .uri(uri)
8990 .header(header::COOKIE, cookie)
8991 .body(Body::empty())
8992 .unwrap(),
8993 )
8994 .await
8995 .unwrap();
8996 assert_eq!(resp.status(), StatusCode::OK);
8997 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8998 .await
8999 .unwrap();
9000 String::from_utf8(bytes.to_vec()).unwrap()
9001 }
9002 };
9003
9004 let page1 = get("/").await;
9005 let rows1 = page1.matches("<li class=\"entry").count();
9009 assert!(
9010 rows1 <= ENTRIES_PER_PAGE as usize,
9011 "page 1 rendered {rows1} entry links; the list is unbounded"
9012 );
9013 assert!(
9014 rows1 > 0,
9015 "page 1 rendered nothing at all: the page bound swallowed the list"
9016 );
9017 assert!(
9020 page1.contains("250 entries"),
9021 "heading must report the full total, not the page"
9022 );
9023 assert!(
9024 page1.contains("page=2"),
9025 "no way to reach the rest of the list: {}",
9026 &page1[..page1.len().min(400)]
9027 );
9028 assert!(
9030 !page1.contains(&"x".repeat(4_000)),
9031 "the list response carried an article body"
9032 );
9033
9034 let page2 = get("/?page=2").await;
9035 assert!(
9036 page2.matches("<li class=\"entry").count() > 0,
9037 "page 2 rendered no rows at all"
9038 );
9039 assert!(
9040 page2.contains("page=1") || page2.contains("Newer"),
9041 "page 2 offers no way back"
9042 );
9043 let first_title = (0..total)
9045 .map(|i| format!("Article {i:04}"))
9046 .find(|t| page1.contains(t))
9047 .expect("page 1 shows at least one titled article");
9048 assert!(
9049 !page2.contains(&first_title),
9050 "{first_title} appears on both pages"
9051 );
9052
9053 let past_end = get("/?page=999").await;
9059 assert!(
9060 past_end.matches("<li class=\"entry").count() > 0,
9061 "an out-of-range page rendered nothing and offered no way back"
9062 );
9063 assert!(
9064 past_end.contains("page=2"),
9065 "the clamped page offers no pager"
9066 );
9067 }
9068
9069 #[tokio::test]
9076 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9077 let did = "did:plc:reader";
9078 let state = test_state(&[]).await;
9079 store::grant_access(&state.db, did, None, "test", None)
9080 .await
9081 .unwrap();
9082 let feed = store::upsert_feed(
9083 &state.db,
9084 &store::NewFeed {
9085 url: "https://reader.example/feed.xml".to_string(),
9086 title: Some("Reader".to_string()),
9087 ..Default::default()
9088 },
9089 )
9090 .await
9091 .unwrap();
9092 store::insert_entries(
9093 &state.db,
9094 feed,
9095 &[store::NewEntry {
9096 guid: "r-1".to_string(),
9097 url: Some("https://reader.example/1".to_string()),
9098 title: Some("Article".to_string()),
9099 published: Some("2026-07-11T00:00:00Z".to_string()),
9100 content_html: Some("<p>body</p>".to_string()),
9101 ..Default::default()
9102 }],
9103 0,
9104 )
9105 .await
9106 .unwrap();
9107 store::replace_sub_refs(&state.db, did, &[feed])
9108 .await
9109 .unwrap();
9110 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9111
9112 let cookie = session_cookie(&state, did, None);
9113 let app = router(state.clone());
9114
9115 let resp = app
9117 .clone()
9118 .oneshot(
9119 Request::builder()
9120 .method("POST")
9121 .uri(format!("/entries/{entry_id}/read"))
9122 .header(header::COOKIE, cookie.clone())
9123 .header("HX-Request", "true")
9124 .header("X-FR-Reader", "1")
9125 .header("content-type", "application/x-www-form-urlencoded")
9126 .body(Body::from("read=true"))
9127 .unwrap(),
9128 )
9129 .await
9130 .unwrap();
9131 assert_eq!(resp.status(), StatusCode::OK);
9132 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9133 .await
9134 .unwrap();
9135 let html = String::from_utf8(bytes.to_vec()).unwrap();
9136 assert!(
9137 html.contains("hx-swap-oob=\"outerHTML\""),
9138 "reader response must be an OOB swap: {html}"
9139 );
9140 assert!(
9141 html.contains(r#"id="entry-actionbar""#),
9142 "reader response must be the action-bar fragment: {html}"
9143 );
9144 assert!(
9147 html.contains(r#"aria-pressed="true""#),
9148 "read button must show pressed after marking read: {html}"
9149 );
9150 assert!(
9151 html.contains(r#"name="read" value="false""#),
9152 "hidden read value must flip to false so a second tap reverses: {html}"
9153 );
9154
9155 let resp2 = app
9158 .oneshot(
9159 Request::builder()
9160 .method("POST")
9161 .uri(format!("/entries/{entry_id}/read"))
9162 .header(header::COOKIE, cookie)
9163 .header("HX-Request", "true")
9164 .header("X-FR-Reader", "1")
9165 .header("content-type", "application/x-www-form-urlencoded")
9166 .body(Body::from("read=false"))
9167 .unwrap(),
9168 )
9169 .await
9170 .unwrap();
9171 assert_eq!(resp2.status(), StatusCode::OK);
9172 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
9173 .await
9174 .unwrap();
9175 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
9176 assert!(
9177 html2.contains(r#"aria-pressed="false""#),
9178 "read button must show un-pressed after reversing: {html2}"
9179 );
9180 assert!(
9181 html2.contains(r#"name="read" value="true""#),
9182 "hidden read value must flip back to true: {html2}"
9183 );
9184 }
9185
9186 #[tokio::test]
9189 async fn list_mark_read_returns_row_not_oob_actionbar() {
9190 let did = "did:plc:listv";
9191 let state = test_state(&[]).await;
9192 store::grant_access(&state.db, did, None, "test", None)
9193 .await
9194 .unwrap();
9195 let feed = store::upsert_feed(
9196 &state.db,
9197 &store::NewFeed {
9198 url: "https://list.example/feed.xml".to_string(),
9199 title: Some("List".to_string()),
9200 ..Default::default()
9201 },
9202 )
9203 .await
9204 .unwrap();
9205 store::insert_entries(
9206 &state.db,
9207 feed,
9208 &[store::NewEntry {
9209 guid: "l-1".to_string(),
9210 url: Some("https://list.example/1".to_string()),
9211 title: Some("Article".to_string()),
9212 published: Some("2026-07-11T00:00:00Z".to_string()),
9213 ..Default::default()
9214 }],
9215 0,
9216 )
9217 .await
9218 .unwrap();
9219 store::replace_sub_refs(&state.db, did, &[feed])
9220 .await
9221 .unwrap();
9222 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9223
9224 let cookie = session_cookie(&state, did, None);
9225 let app = router(state.clone());
9226
9227 let resp = app
9228 .oneshot(
9229 Request::builder()
9230 .method("POST")
9231 .uri(format!("/entries/{entry_id}/read"))
9232 .header(header::COOKIE, cookie)
9233 .header("HX-Request", "true")
9234 .header("content-type", "application/x-www-form-urlencoded")
9235 .body(Body::from("read=true"))
9236 .unwrap(),
9237 )
9238 .await
9239 .unwrap();
9240 assert_eq!(resp.status(), StatusCode::OK);
9241 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9242 .await
9243 .unwrap();
9244 let html = String::from_utf8(bytes.to_vec()).unwrap();
9245 assert!(
9246 !html.contains("hx-swap-oob"),
9247 "list-view response must NOT be an OOB swap: {html}"
9248 );
9249 assert!(
9254 html.contains(&format!("/entries/{entry_id}")),
9255 "the response is not the row for this entry: {html}",
9256 );
9257 assert!(
9258 html.contains("Article"),
9259 "the row rendered without its title: {html}",
9260 );
9261 assert!(
9278 html.contains("is-read"),
9279 "the row came back without the read state it was just given: {html}",
9280 );
9281 }
9282
9283 #[tokio::test]
9308 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
9309 let did = "did:plc:autodiscovered";
9310 let state = test_state_with_caps(did, 0, 0).await;
9313
9314 let page = r#"<!doctype html><html><head><title>Blog</title>
9315 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
9316 </head><body>hi</body></html>"#;
9317 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
9318 let port: u16 = base
9319 .trim_end_matches('/')
9320 .rsplit(':')
9321 .next()
9322 .unwrap()
9323 .parse()
9324 .unwrap();
9325 crate::net::test_host_override(
9326 "autodiscover-ftp.test",
9327 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9328 );
9329
9330 let cookie = session_cookie(&state, did, None);
9331 let resp = router(state.clone())
9332 .oneshot(
9333 Request::builder()
9334 .method("POST")
9335 .uri("/subscriptions")
9336 .header(header::COOKIE, cookie)
9337 .header("content-type", "application/x-www-form-urlencoded")
9338 .body(Body::from(format!(
9339 "url=http://autodiscover-ftp.test:{port}/"
9340 )))
9341 .unwrap(),
9342 )
9343 .await
9344 .unwrap();
9345 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9346 let loc = resp
9347 .headers()
9348 .get(header::LOCATION)
9349 .unwrap()
9350 .to_str()
9351 .unwrap();
9352 assert_ne!(loc, "/login", "the test never reached the add path");
9353 assert_ne!(loc, "/", "the subscribe succeeded");
9354
9355 assert_eq!(
9356 store::count_feeds(&state.db).await.unwrap(),
9357 0,
9358 "a non-http(s) URL from autodiscovery was stored"
9359 );
9360 assert_eq!(
9361 store::count_subscriptions_for_did(&state.db, did)
9362 .await
9363 .unwrap(),
9364 0
9365 );
9366 }
9367
9368 #[tokio::test]
9373 async fn rename_to_new_url_refused_at_global_feeds_cap() {
9374 let did = "did:plc:renamer4";
9375 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9376 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9378 store::upsert_feed(
9379 &state.db,
9380 &store::NewFeed {
9381 url: "https://existing.example/feed.xml".to_string(),
9382 ..Default::default()
9383 },
9384 )
9385 .await
9386 .unwrap();
9387 let before = store::count_feeds(&state.db).await.unwrap();
9388 assert_eq!(before, 1);
9389
9390 let cookie = session_cookie(&state, did, None);
9391 let resp = router(state.clone())
9392 .oneshot(
9393 Request::builder()
9394 .method("POST")
9395 .uri("/subscriptions/rk-keep/rename")
9396 .header(header::COOKIE, cookie)
9397 .header("content-type", "application/x-www-form-urlencoded")
9398 .body(Body::from(
9400 "url=https://brand-new.example/feed.xml&title=Renamed",
9401 ))
9402 .unwrap(),
9403 )
9404 .await
9405 .unwrap();
9406 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9407 let loc = resp
9408 .headers()
9409 .get(header::LOCATION)
9410 .unwrap()
9411 .to_str()
9412 .unwrap();
9413 assert!(
9414 loc.contains("feed%20capacity"),
9415 "expected the feed-capacity flash, got {loc}"
9416 );
9417 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9419 assert!(
9420 puts.lock().unwrap().is_empty(),
9421 "a refused repoint reached the PDS"
9422 );
9423 }
9424
9425 #[tokio::test]
9432 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9433 let did = "did:plc:renamer4";
9434 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9435 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9436 store::upsert_feed(
9437 &state.db,
9438 &store::NewFeed {
9439 url: "https://existing.example/feed.xml".to_string(),
9440 ..Default::default()
9441 },
9442 )
9443 .await
9444 .unwrap();
9445 let before = store::count_feeds(&state.db).await.unwrap();
9446
9447 let cookie = session_cookie(&state, did, None);
9448 let resp = router(state.clone())
9449 .oneshot(
9450 Request::builder()
9451 .method("POST")
9452 .uri("/subscriptions/rk-keep/rename")
9453 .header(header::COOKIE, cookie)
9454 .header("content-type", "application/x-www-form-urlencoded")
9455 .body(Body::from(
9456 "url=https://existing.example/feed.xml&title=Retitled",
9457 ))
9458 .unwrap(),
9459 )
9460 .await
9461 .unwrap();
9462 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9463 let loc = resp
9464 .headers()
9465 .get(header::LOCATION)
9466 .unwrap()
9467 .to_str()
9468 .unwrap();
9469 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9470 assert_eq!(
9471 puts.lock().unwrap().len(),
9472 1,
9473 "the repoint did not reach the PDS"
9474 );
9475 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9476 }
9477
9478 #[tokio::test]
9480 async fn rename_with_blank_url_writes_nothing() {
9481 let did = "did:plc:renamer3";
9482 let state = test_state_with_caps(did, 0, 0).await;
9483 let before = store::count_feeds(&state.db).await.unwrap();
9484 assert_eq!(before, 0);
9485
9486 let cookie = session_cookie(&state, did, None);
9487 let app = router(state.clone());
9488 let resp = app
9489 .oneshot(
9490 Request::builder()
9491 .method("POST")
9492 .uri("/subscriptions/rkey123/rename")
9493 .header(header::COOKIE, cookie)
9494 .header("content-type", "application/x-www-form-urlencoded")
9495 .body(Body::from("url=%20%20&title=Nope"))
9497 .unwrap(),
9498 )
9499 .await
9500 .unwrap();
9501 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9502 assert_eq!(
9503 resp.headers()
9504 .get(header::LOCATION)
9505 .unwrap()
9506 .to_str()
9507 .unwrap(),
9508 "/",
9509 );
9510 assert_eq!(
9512 store::count_feeds(&state.db).await.unwrap(),
9513 0,
9514 "blank-URL rename wrote a junk feeds row"
9515 );
9516 }
9517
9518 async fn spawn_rename_sidecar(
9527 existing: serde_json::Value,
9528 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9529 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9530 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9531 let addr = listener.local_addr().unwrap();
9532 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9533 let sink = puts.clone();
9534 tokio::spawn(async move {
9535 loop {
9536 let Ok((mut sock, _)) = listener.accept().await else {
9537 break;
9538 };
9539 let mut raw: Vec<u8> = Vec::new();
9540 let mut chunk = [0u8; 4096];
9541 let body_text = loop {
9542 let Ok(n) = sock.read(&mut chunk).await else {
9543 break String::new();
9544 };
9545 if n == 0 {
9546 break String::from_utf8_lossy(&raw).to_string();
9547 }
9548 raw.extend_from_slice(&chunk[..n]);
9549 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9550 continue;
9551 };
9552 let (head, body) = raw.split_at(split + 4);
9553 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9554 let (k, v) = l.split_once(':')?;
9555 k.eq_ignore_ascii_case("content-length")
9556 .then(|| v.trim().parse::<usize>().ok())?
9557 });
9558 if want.is_none_or(|want| body.len() >= want) {
9559 break String::from_utf8_lossy(body).to_string();
9560 }
9561 };
9562
9563 let is_put = body_text.contains("\"action\":\"put\"");
9565 let data = if is_put {
9566 sink.lock().unwrap().push(body_text.clone());
9567 serde_json::json!({
9568 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9569 "cid": "bafyreiafter"
9570 })
9571 } else {
9572 serde_json::json!({ "records": [existing.clone()] })
9573 };
9574 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9575 let resp = format!(
9576 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9577 body.len(),
9578 body
9579 );
9580 let _ = sock.write_all(resp.as_bytes()).await;
9581 let _ = sock.flush().await;
9582 }
9583 });
9584 (format!("http://{addr}"), puts)
9585 }
9586
9587 fn seeded_subscription() -> serde_json::Value {
9589 serde_json::json!({
9590 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9591 "cid": "bafyreibefore",
9592 "value": {
9593 "$type": "community.lexicon.rss.subscription",
9594 "url": "https://example.com/feed.xml",
9595 "title": "Old title",
9596 "siteUrl": "https://example.com/blog",
9597 "fetchHint": "hourly",
9598 "private": false,
9599 "createdAt": "2024-03-01T00:00:00.000Z"
9600 }
9601 })
9602 }
9603
9604 fn seeded_at_uri_subscription() -> serde_json::Value {
9607 seeded_subscription_with_url(AT_URI_SUB)
9608 }
9609 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9611 serde_json::json!({
9612 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9613 "cid": "bafyreibefore",
9614 "value": {
9615 "$type": "community.lexicon.rss.subscription",
9616 "url": url,
9617 "title": "Old title",
9618 "private": false,
9619 "createdAt": "2024-03-01T00:00:00.000Z"
9620 }
9621 })
9622 }
9623 const AT_URI_SUB: &str =
9624 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9625 const AT_URI_SUB_ENC: &str =
9626 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9627
9628 #[tokio::test]
9637 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9638 let did = "did:plc:renamer5";
9639 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9640 let state = test_state_with_sidecar(&[did], &sidecar).await;
9641 assert!(
9642 !state.config.standard_site,
9643 "the flag must be off for this test"
9644 );
9645 let cookie = session_cookie(&state, did, None);
9646 let resp = router(state.clone())
9647 .oneshot(
9648 Request::builder()
9649 .method("POST")
9650 .uri("/subscriptions/rk-keep/rename")
9651 .header(header::COOKIE, cookie)
9652 .header("content-type", "application/x-www-form-urlencoded")
9653 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9654 .unwrap(),
9655 )
9656 .await
9657 .unwrap();
9658 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9659 let loc = resp
9660 .headers()
9661 .get(header::LOCATION)
9662 .unwrap()
9663 .to_str()
9664 .unwrap();
9665 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9666
9667 let bodies = puts.lock().unwrap().clone();
9668 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9669 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9670 assert_eq!(
9671 sent["record"]["title"], "New title",
9672 "the rename did not apply"
9673 );
9674 assert_eq!(
9675 sent["record"]["url"], AT_URI_SUB,
9676 "the rename changed the URL"
9677 );
9678
9679 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9681 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9682 }
9683
9684 #[tokio::test]
9688 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9689 let did = "did:plc:renamer4";
9690 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9691 let state = test_state_with_sidecar(&[did], &sidecar).await;
9692 let cookie = session_cookie(&state, did, None);
9693 let resp = router(state.clone())
9694 .oneshot(
9695 Request::builder()
9696 .method("POST")
9697 .uri("/subscriptions/rk-keep/rename")
9698 .header(header::COOKIE, cookie)
9699 .header("content-type", "application/x-www-form-urlencoded")
9700 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9701 .unwrap(),
9702 )
9703 .await
9704 .unwrap();
9705 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9706 let loc = resp
9707 .headers()
9708 .get(header::LOCATION)
9709 .unwrap()
9710 .to_str()
9711 .unwrap();
9712 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9713 assert!(
9714 !loc.contains("Private"),
9715 "a storability refusal was reported as a privacy one: {loc}"
9716 );
9717 assert!(
9718 puts.lock().unwrap().is_empty(),
9719 "the repoint reached the PDS"
9720 );
9721 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9722 assert_eq!(cached, 0);
9723 }
9724
9725 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9728 let cookie = session_cookie(state, did, None);
9729 let resp = router(state.clone())
9730 .oneshot(
9731 Request::builder()
9732 .method("POST")
9733 .uri("/subscriptions/rk-keep/rename")
9734 .header(header::COOKIE, cookie)
9735 .header("content-type", "application/x-www-form-urlencoded")
9736 .body(Body::from(format!("url={url_enc}&title=New+title")))
9737 .unwrap(),
9738 )
9739 .await
9740 .unwrap();
9741 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9742 resp.headers()
9743 .get(header::LOCATION)
9744 .unwrap()
9745 .to_str()
9746 .unwrap()
9747 .to_string()
9748 }
9749
9750 #[tokio::test]
9760 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9761 let did = "did:plc:renamer5";
9762 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9763 let other_enc =
9764 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9765 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9766 let state = test_state_with_sidecar(&[did], &sidecar).await;
9767 let loc = retitle_unchanged(&state, did, other_enc).await;
9768 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9769 let bodies = puts.lock().unwrap().clone();
9770 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9771 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9772 assert_eq!(sent["record"]["title"], "New title");
9773 assert_eq!(sent["record"]["url"], other);
9774 }
9775
9776 #[tokio::test]
9780 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9781 let did = "did:plc:renamer4";
9782 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9783 let state = test_state_with_sidecar(&[did], &sidecar).await;
9784 let cookie = session_cookie(&state, did, None);
9785 let resp = router(state.clone())
9786 .oneshot(
9787 Request::builder()
9788 .method("POST")
9789 .uri("/subscriptions/rk-keep/rename")
9790 .header(header::COOKIE, cookie)
9791 .header("content-type", "application/x-www-form-urlencoded")
9792 .body(Body::from(
9793 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9794 ))
9795 .unwrap(),
9796 )
9797 .await
9798 .unwrap();
9799 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9800 let loc = resp
9801 .headers()
9802 .get(header::LOCATION)
9803 .unwrap()
9804 .to_str()
9805 .unwrap();
9806 assert!(
9807 loc.contains("Private"),
9808 "the private repoint was not refused: {loc}"
9809 );
9810 assert!(
9811 puts.lock().unwrap().is_empty(),
9812 "a secret-bearing URL reached the PDS"
9813 );
9814 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9817 assert!(store::get_feed_by_url(&state.db, leaked)
9818 .await
9819 .unwrap()
9820 .is_none());
9821 }
9822
9823 #[tokio::test]
9829 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9830 let did = "did:plc:renamer5";
9831 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9832 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9834 store::upsert_feed(
9835 &state.db,
9836 &store::NewFeed {
9837 url: "https://filler.example/feed.xml".to_string(),
9838 ..Default::default()
9839 },
9840 )
9841 .await
9842 .unwrap();
9843 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9844 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9845 assert_eq!(
9846 puts.lock().unwrap().len(),
9847 1,
9848 "the retitle did not reach the PDS"
9849 );
9850 assert_eq!(
9851 store::count_feeds(&state.db).await.unwrap(),
9852 1,
9853 "a row was inserted"
9854 );
9855 }
9856
9857 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
9859 let cookie = session_cookie(state, did, None);
9860 let resp = router(state.clone())
9861 .oneshot(
9862 Request::builder()
9863 .method("POST")
9864 .uri("/subscriptions")
9865 .header(header::COOKIE, cookie)
9866 .header("content-type", "application/x-www-form-urlencoded")
9867 .body(Body::from(format!("url={url_enc}")))
9868 .unwrap(),
9869 )
9870 .await
9871 .unwrap();
9872 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9873 resp.headers()
9874 .get(header::LOCATION)
9875 .unwrap()
9876 .to_str()
9877 .unwrap()
9878 .to_string()
9879 }
9880
9881 async fn serve_resolver(did: &str) -> String {
9883 let base = crate::net::tests::serve_body(
9884 serde_json::json!({ "did": did }).to_string().into_bytes(),
9885 )
9886 .await;
9887 let port: u16 = base
9888 .trim_end_matches('/')
9889 .rsplit(':')
9890 .next()
9891 .unwrap()
9892 .parse()
9893 .unwrap();
9894 let host = format!("resolver-{port}.test");
9895 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
9896 format!("http://{host}:{port}")
9897 }
9898
9899 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
9900 let mut config = (*state.config).clone();
9901 f(&mut config);
9902 state.config = std::sync::Arc::new(config);
9903 state
9904 }
9905
9906 #[tokio::test]
9911 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
9912 let did = "did:plc:renamer5";
9913 let (sidecar, log) = spawn_logging_sidecar().await;
9914 let state = with_config(
9915 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9916 |c| {
9917 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9918 },
9919 );
9920 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
9921 assert_eq!(loc, "/", "the paste was refused: {loc}");
9922 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
9923 .await
9924 .unwrap()
9925 .expect("no feed row");
9926 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
9927 let sent = log.lock().unwrap().join("\n");
9928 assert!(
9929 sent.contains(AT_URI_SUB),
9930 "the subscription was not written to the PDS: {sent}"
9931 );
9932 }
9933
9934 #[tokio::test]
9938 async fn a0_subscribing_from_the_form_delivers_entries() {
9939 let did = "did:plc:renamer5";
9940 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9941 let site = AT_URI_SUB;
9942 let (plc, _) = crate::standard_site::tests::serve_repo(
9943 author,
9944 vec![
9945 (
9946 lexicon::nsid::STANDARD_PUBLICATION,
9947 "3lab2c4d5e6f7g8h",
9948 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
9949 ),
9950 (
9951 lexicon::nsid::STANDARD_DOCUMENT,
9952 "3l2a0frmaaa2a",
9953 serde_json::json!({ "title": "From the form", "path": "/f",
9954 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
9955 ),
9956 ],
9957 )
9958 .await;
9959 let (sidecar, _log) = spawn_logging_sidecar().await;
9960 let state = with_config(
9961 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9962 |c| {
9963 c.oauth.plc_directory = plc;
9964 },
9965 );
9966 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
9967 let row = store::get_feed_by_url(&state.db, site)
9968 .await
9969 .unwrap()
9970 .unwrap();
9971 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
9972 .bind(row.id)
9973 .fetch_all(&state.db)
9974 .await
9975 .unwrap();
9976 assert_eq!(
9977 titles,
9978 vec!["From the form".to_string()],
9979 "the first poll stored nothing"
9980 );
9981 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
9982 }
9983
9984 #[tokio::test]
9987 async fn a_handle_form_paste_is_stored_by_its_did() {
9988 let did = "did:plc:renamer5";
9989 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9990 let (sidecar, _log) = spawn_logging_sidecar().await;
9991 let resolver = serve_resolver(author).await;
9992 let state = with_config(
9993 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9994 |c| {
9995 c.resolver_base = resolver;
9996 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9997 },
9998 );
9999 let loc = subscribe(
10000 &state,
10001 did,
10002 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10003 )
10004 .await;
10005 assert_eq!(loc, "/", "the paste was refused: {loc}");
10006 assert!(
10007 store::get_feed_by_url(&state.db, AT_URI_SUB)
10008 .await
10009 .unwrap()
10010 .is_some(),
10011 "not stored by its DID"
10012 );
10013 assert_eq!(
10014 store::count_feeds(&state.db).await.unwrap(),
10015 1,
10016 "the handle form was stored too"
10017 );
10018 }
10019
10020 async fn serve_counting_resolver(
10022 did: &str,
10023 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10024 let (base, hits) = crate::net::tests::serve_body_counted(
10025 serde_json::json!({ "did": did }).to_string().into_bytes(),
10026 )
10027 .await;
10028 let port: u16 = base
10029 .trim_end_matches('/')
10030 .rsplit(':')
10031 .next()
10032 .unwrap()
10033 .parse()
10034 .unwrap();
10035 let host = format!("counting-resolver-{port}.test");
10036 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10037 (format!("http://{host}:{port}"), hits)
10038 }
10039
10040 #[tokio::test]
10044 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10045 let did = "did:plc:renamer5";
10046 let (sidecar, _log) = spawn_logging_sidecar().await;
10047 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10048 let state = with_config(
10049 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10050 |c| {
10051 c.resolver_base = resolver;
10052 c.max_subs_per_did = 1;
10053 },
10054 );
10055 let feed_id = store::upsert_feed(
10056 &state.db,
10057 &store::NewFeed {
10058 url: "https://already.example/feed.xml".into(),
10059 ..Default::default()
10060 },
10061 )
10062 .await
10063 .unwrap();
10064 store::replace_sub_refs(&state.db, did, &[feed_id])
10065 .await
10066 .unwrap();
10067 let loc = subscribe(
10068 &state,
10069 did,
10070 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10071 )
10072 .await;
10073 assert!(
10074 loc.contains("Subscription%20limit"),
10075 "expected the cap flash: {loc}"
10076 );
10077 assert_eq!(
10078 hits.load(std::sync::atomic::Ordering::SeqCst),
10079 0,
10080 "an over-cap paste resolved a handle"
10081 );
10082 }
10083
10084 #[tokio::test]
10088 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10089 let did = "did:plc:renamer5";
10090 let (sidecar, _log) = spawn_logging_sidecar().await;
10091 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10092 let state = with_config(
10093 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10094 |c| {
10095 c.resolver_base = resolver;
10096 },
10097 );
10098 for authority in [
10099 "did%3Aplc%3ATOOSHORT",
10100 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10101 "bad%0Ahandle.example",
10102 ] {
10103 let loc = subscribe(
10104 &state,
10105 did,
10106 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10107 )
10108 .await;
10109 assert!(
10110 loc.contains("kind%20of%20feed"),
10111 "{authority}: expected the unsupported flash: {loc}"
10112 );
10113 }
10114 assert_eq!(
10115 hits.load(std::sync::atomic::Ordering::SeqCst),
10116 0,
10117 "a malformed authority reached the resolver"
10118 );
10119 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10120 }
10121
10122 #[tokio::test]
10124 async fn an_unresolvable_handle_paste_is_refused() {
10125 let did = "did:plc:renamer5";
10126 let (sidecar, _log) = spawn_logging_sidecar().await;
10127 let state = with_config(
10128 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10129 |c| {
10130 c.resolver_base = "http://resolver.nowhere.invalid".into();
10131 },
10132 );
10133 let loc = subscribe(
10134 &state,
10135 did,
10136 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10137 )
10138 .await;
10139 assert!(
10140 loc.contains("resolve%20the%20handle"),
10141 "expected the unresolvable-handle flash: {loc}"
10142 );
10143 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10144 }
10145
10146 #[tokio::test]
10148 async fn a_non_publication_at_uri_paste_is_refused() {
10149 let did = "did:plc:renamer5";
10150 let (sidecar, _log) = spawn_logging_sidecar().await;
10151 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10152 let loc = subscribe(
10153 &state,
10154 did,
10155 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10156 )
10157 .await;
10158 assert!(
10159 loc.contains("kind%20of%20feed"),
10160 "expected the unsupported flash: {loc}"
10161 );
10162 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10163 }
10164
10165 #[tokio::test]
10168 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
10169 let did = "did:plc:renamer5";
10170 let (sidecar, _log) = spawn_logging_sidecar().await;
10171 let state = with_config(
10172 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10173 |c| {
10174 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10175 },
10176 );
10177 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
10178 assert_eq!(loc, "/", "the paste was refused: {loc}");
10179 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
10180 .await
10181 .unwrap()
10182 .is_some());
10183 }
10184
10185 #[tokio::test]
10188 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
10189 let did = "did:plc:renamer5";
10190 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10191 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10192 let opml = format!(
10193 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10194 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10195 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10196 </body></opml>"
10197 );
10198 let (ct, body) = opml_multipart(opml.as_bytes());
10199 let cookie = session_cookie(&state, did, None);
10200 let resp = router(state.clone())
10201 .oneshot(
10202 Request::builder()
10203 .method("POST")
10204 .uri("/opml")
10205 .header(header::COOKIE, cookie)
10206 .header("content-type", ct)
10207 .body(Body::from(body))
10208 .unwrap(),
10209 )
10210 .await
10211 .unwrap();
10212 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10213 let loc = resp
10214 .headers()
10215 .get(header::LOCATION)
10216 .unwrap()
10217 .to_str()
10218 .unwrap();
10219 assert!(
10220 loc.contains("Imported%202%20feeds"),
10221 "unexpected flash: {loc}"
10222 );
10223 assert!(
10224 !loc.contains("skipped"),
10225 "the at:// entry was skipped with the flag on: {loc}"
10226 );
10227 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
10228 assert!(
10229 stored.is_some(),
10230 "the at:// entry was not stored with the flag on"
10231 );
10232 }
10233
10234 #[tokio::test]
10244 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
10245 let did = "did:plc:renamer5";
10246 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
10247 let tokened_enc =
10248 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
10249 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
10250 let state = test_state_with_sidecar(&[did], &sidecar).await;
10251 let loc = retitle_unchanged(&state, did, tokened_enc).await;
10252 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10253 assert_eq!(
10254 puts.lock().unwrap().len(),
10255 1,
10256 "the retitle did not reach the PDS"
10257 );
10258 assert!(
10259 store::get_feed_by_url(&state.db, tokened)
10260 .await
10261 .unwrap()
10262 .is_none(),
10263 "a secret-bearing URL was written to the shared cache by a retitle"
10264 );
10265 }
10266
10267 #[tokio::test]
10272 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
10273 let did = "did:plc:renamer4";
10274 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10275 let state = test_state_with_sidecar(&[did], &sidecar).await;
10276 let cookie = session_cookie(&state, did, None);
10277 let resp = router(state.clone())
10278 .oneshot(
10279 Request::builder()
10280 .method("POST")
10281 .uri("/subscriptions/rk-keep/rename")
10282 .header(header::COOKIE, cookie)
10283 .header("content-type", "application/x-www-form-urlencoded")
10284 .body(Body::from(
10285 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
10286 ))
10287 .unwrap(),
10288 )
10289 .await
10290 .unwrap();
10291 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10292 let loc = resp
10293 .headers()
10294 .get(header::LOCATION)
10295 .unwrap()
10296 .to_str()
10297 .unwrap();
10298 assert!(
10299 loc.contains("kind%20of%20feed"),
10300 "expected the unsupported flash: {loc}"
10301 );
10302 assert!(
10303 !loc.contains("Private"),
10304 "a typo was reported as a paid feed: {loc}"
10305 );
10306 assert!(puts.lock().unwrap().is_empty());
10307 }
10308
10309 #[tokio::test]
10310 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
10311 let did = "did:plc:renamer4";
10312 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10313 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10314 store::upsert_feed(
10315 &state.db,
10316 &store::NewFeed {
10317 url: "https://filler.example/feed.xml".to_string(),
10318 ..Default::default()
10319 },
10320 )
10321 .await
10322 .unwrap();
10323 let cookie = session_cookie(&state, did, None);
10324 let resp = router(state.clone())
10325 .oneshot(
10326 Request::builder()
10327 .method("POST")
10328 .uri("/subscriptions/rk-keep/rename")
10329 .header(header::COOKIE, cookie)
10330 .header("content-type", "application/x-www-form-urlencoded")
10331 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10332 .unwrap(),
10333 )
10334 .await
10335 .unwrap();
10336 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10337 let loc = resp
10338 .headers()
10339 .get(header::LOCATION)
10340 .unwrap()
10341 .to_str()
10342 .unwrap();
10343 assert!(
10344 loc.contains("kind%20of%20feed"),
10345 "expected the unsupported flash: {loc}"
10346 );
10347 assert!(
10348 !loc.contains("capacity"),
10349 "an unacceptable URL was reported as a capacity problem: {loc}"
10350 );
10351 assert!(puts.lock().unwrap().is_empty());
10352 }
10353
10354 #[tokio::test]
10359 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
10360 let did = "did:plc:renamer5";
10361 let padded = format!("{AT_URI_SUB} ");
10362 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
10363 let state = test_state_with_sidecar(&[did], &sidecar).await;
10364 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
10366 assert_eq!(
10367 loc, "/",
10368 "the retitle was treated as a repoint and refused: {loc}"
10369 );
10370 let bodies = puts.lock().unwrap().clone();
10371 assert_eq!(bodies.len(), 1);
10372 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10373 assert_eq!(
10374 sent["record"]["url"], AT_URI_SUB,
10375 "the padding was not normalised away"
10376 );
10377 }
10378
10379 #[tokio::test]
10385 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
10386 let did = "did:plc:renamer5";
10387 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10388 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
10389 store::upsert_feed(
10390 &state.db,
10391 &store::NewFeed {
10392 url: "https://filler.example/feed.xml".to_string(),
10393 ..Default::default()
10394 },
10395 )
10396 .await
10397 .unwrap();
10398 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10399 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10400 assert_eq!(puts.lock().unwrap().len(), 1);
10401 assert_eq!(
10402 store::count_feeds(&state.db).await.unwrap(),
10403 1,
10404 "a retitle inserted a cache row past the ceiling"
10405 );
10406 }
10407
10408 #[tokio::test]
10415 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
10416 let did = "did:plc:typoist";
10417 let state = test_state_with_caps(did, 0, 0).await;
10418 let cookie = session_cookie(&state, did, None);
10419 let resp = router(state.clone())
10420 .oneshot(
10421 Request::builder()
10422 .method("POST")
10423 .uri("/subscriptions")
10424 .header(header::COOKIE, cookie)
10425 .header("content-type", "application/x-www-form-urlencoded")
10426 .body(Body::from(
10427 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10428 ))
10429 .unwrap(),
10430 )
10431 .await
10432 .unwrap();
10433 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10434 let loc = resp
10435 .headers()
10436 .get(header::LOCATION)
10437 .unwrap()
10438 .to_str()
10439 .unwrap();
10440 assert!(
10441 loc.contains("kind%20of%20feed"),
10442 "expected the unsupported flash: {loc}"
10443 );
10444 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
10445 }
10446
10447 #[tokio::test]
10469 async fn renaming_preserves_the_fields_the_form_never_carries() {
10470 let did = "did:plc:renamer4";
10471 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10472 let state = test_state_with_sidecar(&[did], &sidecar).await;
10473 let cookie = session_cookie(&state, did, None);
10474
10475 let resp = router(state.clone())
10476 .oneshot(
10477 Request::builder()
10478 .method("POST")
10479 .uri("/subscriptions/rk-keep/rename")
10480 .header(header::COOKIE, cookie)
10481 .header("content-type", "application/x-www-form-urlencoded")
10482 .body(Body::from(
10484 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
10485 ))
10486 .unwrap(),
10487 )
10488 .await
10489 .unwrap();
10490 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10491
10492 let bodies = puts.lock().unwrap().clone();
10493 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10494 let body = &bodies[0];
10495 assert!(
10497 body.contains("community.lexicon.rss.subscription"),
10498 "captured no usable put body: {body:?}"
10499 );
10500
10501 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
10502 let record = &sent["record"];
10503
10504 assert_eq!(record["title"], "New title", "the rename did not apply");
10506 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
10507
10508 assert_eq!(
10510 record["createdAt"], "2024-03-01T00:00:00.000Z",
10511 "the rename reset createdAt — the reader's subscribe time is gone \
10512 from their own repo, and nothing told them"
10513 );
10514 assert_eq!(
10515 record["siteUrl"], "https://example.com/blog",
10516 "the rename erased siteUrl"
10517 );
10518 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
10519 assert_eq!(record["private"], false, "the rename erased private");
10520 }
10521
10522 #[tokio::test]
10531 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
10532 let did = "did:plc:renamer4";
10533 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10534 let state = test_state_with_sidecar(&[did], &sidecar).await;
10535 let cookie = session_cookie(&state, did, None);
10536
10537 let resp = router(state.clone())
10538 .oneshot(
10539 Request::builder()
10540 .method("POST")
10541 .uri("/subscriptions/rk-keep/rename")
10542 .header(header::COOKIE, cookie)
10543 .header("content-type", "application/x-www-form-urlencoded")
10544 .body(Body::from(
10546 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
10547 ))
10548 .unwrap(),
10549 )
10550 .await
10551 .unwrap();
10552 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10553
10554 let bodies = puts.lock().unwrap().clone();
10555 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10556 assert!(
10557 bodies[0].contains("community.lexicon.rss.subscription"),
10558 "captured no usable put body: {:?}",
10559 bodies[0]
10560 );
10561 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10562 let record = &sent["record"];
10563
10564 assert_eq!(record["url"], "https://other.example/feed.xml");
10565 assert!(
10567 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
10568 "the old feed's site link followed the subscription to a new feed: {record}"
10569 );
10570 assert!(
10571 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
10572 "the old feed's fetch hint followed the subscription to a new feed: {record}"
10573 );
10574 assert_eq!(
10576 record["createdAt"], "2024-03-01T00:00:00.000Z",
10577 "a repoint is still not a new subscription; createdAt must not move"
10578 );
10579 assert_eq!(record["private"], false, "the repoint erased private");
10580 }
10581
10582 #[tokio::test]
10594 async fn renaming_an_unknown_rkey_writes_nothing() {
10595 let did = "did:plc:renamer4";
10596 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10598 let state = test_state_with_sidecar(&[did], &sidecar).await;
10599 let cookie = session_cookie(&state, did, None);
10600
10601 let resp = router(state.clone())
10602 .oneshot(
10603 Request::builder()
10604 .method("POST")
10605 .uri("/subscriptions/rk-does-not-exist/rename")
10607 .header(header::COOKIE, cookie)
10608 .header("content-type", "application/x-www-form-urlencoded")
10609 .body(Body::from(
10610 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
10611 ))
10612 .unwrap(),
10613 )
10614 .await
10615 .unwrap();
10616
10617 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10618 let loc = resp
10619 .headers()
10620 .get(header::LOCATION)
10621 .unwrap()
10622 .to_str()
10623 .unwrap();
10624 assert!(
10625 loc.contains("flash="),
10626 "an unknown rkey redirected as though the rename had worked: {loc}"
10627 );
10628 assert!(
10629 puts.lock().unwrap().is_empty(),
10630 "a rename against an unknown rkey wrote a record — putRecord would \
10631 CREATE it, dated today: {:?}",
10632 puts.lock().unwrap()
10633 );
10634 }
10635
10636 #[tokio::test]
10648 async fn a_client_supplied_site_url_reaches_the_record() {
10649 let did = "did:plc:renamer4";
10650 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10651 let state = test_state_with_sidecar(&[did], &sidecar).await;
10652 let cookie = session_cookie(&state, did, None);
10653
10654 let resp = router(state.clone())
10655 .oneshot(
10656 Request::builder()
10657 .method("POST")
10658 .uri("/subscriptions/rk-keep/rename")
10659 .header(header::COOKIE, cookie)
10660 .header("content-type", "application/x-www-form-urlencoded")
10661 .body(Body::from(
10664 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10665 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10666 ))
10667 .unwrap(),
10668 )
10669 .await
10670 .unwrap();
10671 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10672
10673 let bodies = puts.lock().unwrap().clone();
10674 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10675 assert!(
10676 bodies[0].contains("community.lexicon.rss.subscription"),
10677 "captured no usable put body: {:?}",
10678 bodies[0]
10679 );
10680 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10681 assert_eq!(
10682 sent["record"]["siteUrl"], "https://typed.example/site",
10683 "the client's siteUrl was dropped; the seeded record's survived instead"
10684 );
10685 }
10686
10687 #[tokio::test]
10695 async fn a_rename_whose_read_fails_writes_nothing() {
10696 let did = "did:plc:renamer5";
10697 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10699 let dead = format!("http://{}", listener.local_addr().unwrap());
10700 drop(listener);
10701
10702 let state = test_state_with_sidecar(&[did], &dead).await;
10703 let cookie = session_cookie(&state, did, None);
10704 let before = store::count_feeds(&state.db).await.unwrap();
10705
10706 let resp = router(state.clone())
10707 .oneshot(
10708 Request::builder()
10709 .method("POST")
10710 .uri("/subscriptions/rk-keep/rename")
10711 .header(header::COOKIE, cookie)
10712 .header("content-type", "application/x-www-form-urlencoded")
10713 .body(Body::from(
10714 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10715 ))
10716 .unwrap(),
10717 )
10718 .await
10719 .unwrap();
10720
10721 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10722 let loc = resp
10723 .headers()
10724 .get(header::LOCATION)
10725 .unwrap()
10726 .to_str()
10727 .unwrap();
10728 assert!(
10729 loc.contains("flash="),
10730 "a failed read redirected as though the rename had worked: {loc}"
10731 );
10732 assert_eq!(
10733 store::count_feeds(&state.db).await.unwrap(),
10734 before,
10735 "a rename that could not read the record still wrote to the cache"
10736 );
10737 }
10738
10739 #[test]
10745 fn manage_rename_row_preselects_current_folder() {
10746 let nav = Nav {
10747 handle: "@reader.example".to_string(),
10748 avatar: "RE".to_string(),
10749 view: "unread".to_string(),
10750 scope_qs: String::new(),
10751 folders: Vec::new(),
10752 loose_feeds: Vec::new(),
10753 manage_active: true,
10754 };
10755 let folder_options = vec![
10756 FolderOption {
10757 uri: "at://did:plc:x/app.folder/work".to_string(),
10758 name: "Work".to_string(),
10759 },
10760 FolderOption {
10761 uri: "at://did:plc:x/app.folder/fun".to_string(),
10762 name: "Fun".to_string(),
10763 },
10764 ];
10765 let foldered = FeedView {
10768 rkey: "sub-foldered".to_string(),
10769 url: "https://work.example/feed.xml".to_string(),
10770 title: "Work Feed".to_string(),
10771 unread: 0,
10772 selected: false,
10773 folder: Some("at://did:plc:x/app.folder/work".to_string()),
10774 };
10775 let loose = FeedView {
10776 rkey: "sub-loose".to_string(),
10777 url: "https://loose.example/feed.xml".to_string(),
10778 title: "Loose Feed".to_string(),
10779 unread: 0,
10780 selected: false,
10781 folder: None,
10782 };
10783 let tmpl = ManageTemplate {
10784 version: VERSION,
10785 repo_url: REPO_URL,
10786 kofi_url: KOFI_URL,
10787 flash: String::new(),
10788 alert: String::new(),
10789 nav,
10790 folder_options,
10791 folders: vec![FolderView {
10792 rkey: "folder-work".to_string(),
10793 uri: "at://did:plc:x/app.folder/work".to_string(),
10794 name: "Work".to_string(),
10795 feeds: vec![foldered],
10796 selected: false,
10797 }],
10798 loose_feeds: vec![loose],
10799 standard_site: false,
10800 };
10801 let html = tmpl.render().unwrap();
10802
10803 assert!(
10805 html.contains(
10806 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10807 ),
10808 "foldered feed must pre-select its current folder: {html}"
10809 );
10810 assert!(
10813 html.contains(r#"<option value="" selected>No folder</option>"#),
10814 "loose feed must pre-select 'No folder': {html}"
10815 );
10816 }
10817
10818 #[tokio::test]
10824 async fn the_public_stats_page_exposes_no_user_data() {
10825 let state = test_state(&[]).await;
10826 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10827 .await
10828 .unwrap();
10829
10830 let resp = router(state)
10831 .oneshot(
10832 Request::builder()
10833 .uri("/stats")
10834 .body(Body::empty())
10835 .unwrap(),
10836 )
10837 .await
10838 .unwrap();
10839 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10840
10841 let body = String::from_utf8(
10842 axum::body::to_bytes(resp.into_body(), usize::MAX)
10843 .await
10844 .unwrap()
10845 .to_vec(),
10846 )
10847 .unwrap();
10848
10849 assert!(
10855 !body.contains("did:"),
10856 "the public stats page leaked an identifier"
10857 );
10858 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10859 assert!(
10860 !body.contains(admin_only),
10861 "the public page is showing the admin metrics column {admin_only:?}"
10862 );
10863 }
10864 assert!(body.contains("Feeds tracked"));
10866 assert!(body.contains("Waiting to be polled"));
10867 }
10868
10869 #[tokio::test]
10877 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10878 let state = test_state(&[]).await;
10879 for (url, errors) in [
10881 ("https://ok.example/f.xml", 0),
10882 ("https://flaky.example/f.xml", 2),
10883 ("https://dead.example/f.xml", 9),
10884 ] {
10885 store::upsert_feed(
10886 &state.db,
10887 &store::NewFeed {
10888 url: url.to_string(),
10889 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10892 ..Default::default()
10893 },
10894 )
10895 .await
10896 .unwrap();
10897 for _ in 0..errors {
10898 store::bump_feed_errors(
10899 &state.db,
10900 url,
10901 feed::FailureKind::Fetch,
10902 "connection refused",
10903 )
10904 .await
10905 .unwrap();
10906 }
10907 }
10908
10909 let render_stats = |state: AppState| async move {
10910 let resp = router(state)
10911 .oneshot(
10912 Request::builder()
10913 .uri("/stats")
10914 .body(Body::empty())
10915 .unwrap(),
10916 )
10917 .await
10918 .unwrap();
10919 assert_eq!(resp.status(), StatusCode::OK);
10920 String::from_utf8(
10921 axum::body::to_bytes(resp.into_body(), usize::MAX)
10922 .await
10923 .unwrap()
10924 .to_vec(),
10925 )
10926 .unwrap()
10927 };
10928
10929 state.runtime_health.set_schedulers_enabled(true);
10936 state
10937 .runtime_health
10938 .poll_tick_completed(crate::store::now_unix());
10939
10940 let body = render_stats(state.clone()).await;
10941 assert!(
10942 body.contains("Failing"),
10943 "backoff is still invisible on the public page"
10944 );
10945 assert!(
10949 body.contains("2, 1 badly"),
10950 "expected '2, 1 badly' in the failing row; got:\n{}",
10951 body.split("Failing")
10952 .nth(1)
10953 .unwrap_or("")
10954 .chars()
10955 .take(300)
10956 .collect::<String>()
10957 );
10958 assert!(
10966 !body.contains("the poller is not running")
10967 && !body.contains("the cache is at its size limit")
10968 && !body.contains("has not completed a round"),
10969 "expected the running state; the page reported a stopped one",
10970 );
10971
10972 state.runtime_health.set_watermark(true);
10976 let paused = render_stats(state.clone()).await;
10977 assert!(
10982 paused.contains("the cache is at its size limit"),
10983 "a watermark pause is still invisible on the public page"
10984 );
10985
10986 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10988 assert!(
10989 !paused.contains(leak),
10990 "the public page leaked {leak:?} while reporting failures"
10991 );
10992 }
10993 }
10994
10995 #[tokio::test]
11007 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11008 let admin = "did:plc:adminseed";
11009 let state = test_state(&[admin]).await;
11018 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11019 .await
11020 .unwrap();
11021 let url = "https://broken.example/f.xml";
11022 store::upsert_feed(
11023 &state.db,
11024 &store::NewFeed {
11025 url: url.to_string(),
11026 ..Default::default()
11027 },
11028 )
11029 .await
11030 .unwrap();
11031 store::bump_feed_errors(
11032 &state.db,
11033 url,
11034 feed::FailureKind::Fetch,
11035 "SENTINEL_ADMIN_ONLY",
11036 )
11037 .await
11038 .unwrap();
11039
11040 let get = |state: AppState, cookie: Option<String>| async move {
11041 let mut req = Request::builder().uri("/admin/metrics");
11042 if let Some(c) = cookie {
11043 req = req.header(header::COOKIE, c);
11044 }
11045 let resp = router(state)
11046 .oneshot(req.body(Body::empty()).unwrap())
11047 .await
11048 .unwrap();
11049 let status = resp.status();
11050 let body = String::from_utf8(
11051 axum::body::to_bytes(resp.into_body(), usize::MAX)
11052 .await
11053 .unwrap()
11054 .to_vec(),
11055 )
11056 .unwrap();
11057 (status, body)
11058 };
11059
11060 let (status, body) = get(state.clone(), None).await;
11062 assert_eq!(status, StatusCode::UNAUTHORIZED);
11063 assert!(
11064 !body.contains("SENTINEL_ADMIN_ONLY"),
11065 "leaked to anonymous: {body}"
11066 );
11067
11068 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11070 let (status, body) = get(state.clone(), Some(ordinary)).await;
11071 assert_eq!(
11072 status,
11073 StatusCode::FORBIDDEN,
11074 "a non-admin session was let in"
11075 );
11076 assert!(
11077 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11078 "leaked to a non-admin: {body}",
11079 );
11080
11081 let admin_cookie = session_cookie(&state, admin, None);
11084 let (status, body) = get(state, Some(admin_cookie)).await;
11085 assert_eq!(status, StatusCode::OK);
11086 assert!(
11087 body.contains("SENTINEL_ADMIN_ONLY"),
11088 "admin cannot see it: {body}"
11089 );
11090 }
11091
11092 #[tokio::test]
11109 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11110 let admin = "did:plc:adminseed";
11111 let state = test_state(&[admin]).await;
11112 let url = "https://broken.example/f.xml";
11113 store::upsert_feed(
11114 &state.db,
11115 &store::NewFeed {
11116 url: url.to_string(),
11117 ..Default::default()
11118 },
11119 )
11120 .await
11121 .unwrap();
11122 store::bump_feed_errors(
11123 &state.db,
11124 url,
11125 feed::FailureKind::Fetch,
11126 "SENTINEL_REDIRECT_NO_LOCATION",
11127 )
11128 .await
11129 .unwrap();
11130
11131 let cookie = session_cookie(&state, admin, None);
11132 let resp = router(state.clone())
11133 .oneshot(
11134 Request::builder()
11135 .uri("/admin/metrics")
11136 .header(header::COOKIE, cookie)
11137 .body(Body::empty())
11138 .unwrap(),
11139 )
11140 .await
11141 .unwrap();
11142 assert_eq!(resp.status(), StatusCode::OK);
11143 let admin_body = String::from_utf8(
11144 axum::body::to_bytes(resp.into_body(), usize::MAX)
11145 .await
11146 .unwrap()
11147 .to_vec(),
11148 )
11149 .unwrap();
11150 assert!(
11151 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11152 "the admin page does not carry the failure detail: {admin_body}",
11153 );
11154 assert!(
11155 admin_body.contains("broken.example"),
11156 "the admin page does not name the failing feed: {admin_body}",
11157 );
11158
11159 let resp = router(state)
11161 .oneshot(
11162 Request::builder()
11163 .uri("/stats")
11164 .body(Body::empty())
11165 .unwrap(),
11166 )
11167 .await
11168 .unwrap();
11169 let public = String::from_utf8(
11170 axum::body::to_bytes(resp.into_body(), usize::MAX)
11171 .await
11172 .unwrap()
11173 .to_vec(),
11174 )
11175 .unwrap();
11176 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
11177 assert!(
11178 !public.contains(secret),
11179 "{secret:?} reached the PUBLIC stats page: {public}",
11180 );
11181 }
11182 }
11183
11184 #[tokio::test]
11197 async fn a_successful_direct_poll_clears_a_stale_failure() {
11198 let state = test_state(&[]).await;
11199 let url = "https://recovered.example/f.xml";
11200 store::upsert_feed(
11201 &state.db,
11202 &store::NewFeed {
11203 url: url.to_string(),
11204 ..Default::default()
11205 },
11206 )
11207 .await
11208 .unwrap();
11209 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
11210 .await
11211 .unwrap();
11212 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
11214 .bind(url)
11215 .execute(&state.db)
11216 .await
11217 .unwrap();
11218
11219 feed::settle_poll(
11221 &state.db,
11222 url,
11223 &feed::PollOutcome::NotModified,
11224 state.config.poll_interval,
11225 )
11226 .await;
11227
11228 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11229 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11230 )
11231 .bind(url)
11232 .fetch_one(&state.db)
11233 .await
11234 .unwrap();
11235 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
11236 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
11237 let next = row.2.expect("next_poll was cleared to NULL");
11241 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11245 let delta = parsed
11246 .signed_duration_since(chrono::Utc::now())
11247 .num_seconds();
11248 let cadence = state.config.poll_interval.as_secs() as i64;
11249 assert!(
11250 (cadence - 60..=cadence + 60).contains(&delta),
11251 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
11252 );
11253 }
11254
11255 #[tokio::test]
11261 async fn a_failing_direct_poll_is_recorded() {
11262 let state = test_state(&[]).await;
11263 let url = "https://born-broken.example/f.xml";
11264 store::upsert_feed(
11265 &state.db,
11266 &store::NewFeed {
11267 url: url.to_string(),
11268 ..Default::default()
11269 },
11270 )
11271 .await
11272 .unwrap();
11273
11274 feed::settle_poll(
11275 &state.db,
11276 url,
11277 &feed::PollOutcome::Failed {
11278 backoff: std::time::Duration::from_secs(300),
11279 kind: feed::FailureKind::Parse,
11280 detail: "SENTINEL_BORN_BROKEN".to_string(),
11281 },
11282 state.config.poll_interval,
11283 )
11284 .await;
11285
11286 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11287 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11288 )
11289 .bind(url)
11290 .fetch_one(&state.db)
11291 .await
11292 .unwrap();
11293 assert_eq!(row.0, 1, "a failed first poll was not counted");
11294 assert_eq!(
11295 row.1.as_deref(),
11296 Some("parse"),
11297 "its cause was not recorded"
11298 );
11299 let next = row.2.expect("a failed direct poll left next_poll NULL");
11303 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11304 let delta = parsed
11305 .signed_duration_since(chrono::Utc::now())
11306 .num_seconds();
11307 assert!(
11308 (240..=360).contains(&delta),
11309 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
11310 );
11311 }
11312
11313 #[tokio::test]
11325 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
11326 let state = test_state(&[]).await;
11327 for url in [
11329 "https://legacy1.example/f.xml",
11330 "https://legacy2.example/f.xml",
11331 ] {
11332 store::upsert_feed(
11333 &state.db,
11334 &store::NewFeed {
11335 url: url.to_string(),
11336 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11337 ..Default::default()
11338 },
11339 )
11340 .await
11341 .unwrap();
11342 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
11343 .bind(url)
11344 .execute(&state.db)
11345 .await
11346 .unwrap();
11347 }
11348 store::upsert_feed(
11350 &state.db,
11351 &store::NewFeed {
11352 url: "https://known.example/f.xml".to_string(),
11353 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11354 ..Default::default()
11355 },
11356 )
11357 .await
11358 .unwrap();
11359 store::bump_feed_errors(
11360 &state.db,
11361 "https://known.example/f.xml",
11362 feed::FailureKind::Status,
11363 "SENTINEL",
11364 )
11365 .await
11366 .unwrap();
11367
11368 let now = chrono::Utc::now();
11369 let health = store::poll_health(
11370 &state.db,
11371 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11372 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11373 )
11374 .await
11375 .unwrap();
11376 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
11377 assert_eq!(
11378 counted, health.in_backoff,
11379 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
11380 health.in_backoff, health.failure_kinds,
11381 );
11382 assert!(
11383 health
11384 .failure_kinds
11385 .iter()
11386 .any(|(k, n)| k == "unknown" && *n == 2),
11387 "no unknown bucket for the legacy rows: {:?}",
11388 health.failure_kinds,
11389 );
11390 }
11391
11392 #[tokio::test]
11397 async fn the_failure_breakdown_is_ordered_by_count() {
11398 let state = test_state(&[]).await;
11399 for (url, kind, n) in [
11400 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
11401 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
11402 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
11403 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
11404 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
11405 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
11406 ] {
11407 store::upsert_feed(
11408 &state.db,
11409 &store::NewFeed {
11410 url: url.to_string(),
11411 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11412 ..Default::default()
11413 },
11414 )
11415 .await
11416 .unwrap();
11417 for _ in 0..n {
11418 store::bump_feed_errors(&state.db, url, kind, "d")
11419 .await
11420 .unwrap();
11421 }
11422 }
11423 let now = chrono::Utc::now();
11424 let health = store::poll_health(
11425 &state.db,
11426 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11427 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11428 )
11429 .await
11430 .unwrap();
11431 let labels: Vec<&str> = health
11432 .failure_kinds
11433 .iter()
11434 .map(|(k, _)| k.as_str())
11435 .collect();
11436 assert_eq!(
11437 labels,
11438 ["fetch", "status", "parse"],
11439 "not ordered by count, descending: {:?}",
11440 health.failure_kinds,
11441 );
11442 }
11443
11444 #[tokio::test]
11456 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
11457 let state = test_state(&[]).await;
11458 for (url, kind, detail, errors) in [
11459 (
11465 "https://a.example/f.xml",
11466 feed::FailureKind::Fetch,
11467 "SENTINEL_CONNREFUSED",
11468 3,
11469 ),
11470 (
11471 "https://b.example/f.xml",
11472 feed::FailureKind::Fetch,
11473 "SENTINEL_DNSFAIL",
11474 2,
11475 ),
11476 (
11477 "https://c.example/f.xml",
11478 feed::FailureKind::Status,
11479 "SENTINEL_404",
11480 1,
11481 ),
11482 (
11483 "https://d.example/f.xml",
11484 feed::FailureKind::Parse,
11485 "SENTINEL_UNPARSEABLE",
11486 1,
11487 ),
11488 ] {
11489 store::upsert_feed(
11490 &state.db,
11491 &store::NewFeed {
11492 url: url.to_string(),
11493 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11494 ..Default::default()
11495 },
11496 )
11497 .await
11498 .unwrap();
11499 for _ in 0..errors {
11500 store::bump_feed_errors(&state.db, url, kind, detail)
11501 .await
11502 .unwrap();
11503 }
11504 }
11505
11506 let resp = router(state.clone())
11507 .oneshot(
11508 Request::builder()
11509 .uri("/stats")
11510 .body(Body::empty())
11511 .unwrap(),
11512 )
11513 .await
11514 .unwrap();
11515 assert_eq!(resp.status(), StatusCode::OK);
11516 let body = String::from_utf8(
11517 axum::body::to_bytes(resp.into_body(), usize::MAX)
11518 .await
11519 .unwrap()
11520 .to_vec(),
11521 )
11522 .unwrap();
11523
11524 assert!(
11526 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
11527 "the cause histogram did not render: {body}",
11528 );
11529
11530 for secret in [
11533 "a.example",
11534 "b.example",
11535 "c.example",
11536 "d.example",
11537 "SENTINEL_CONNREFUSED",
11538 "SENTINEL_DNSFAIL",
11539 "SENTINEL_404",
11540 "SENTINEL_UNPARSEABLE",
11541 ] {
11542 assert!(
11543 !body.contains(secret),
11544 "{secret:?} reached the PUBLIC stats page: {body}",
11545 );
11546 }
11547 }
11548
11549 #[tokio::test]
11552 async fn health_checks_the_database_and_reports_the_loops() {
11553 let state = test_state(&[]).await;
11554 let body_of = |state: AppState| async move {
11555 let resp = router(state)
11556 .oneshot(
11557 Request::builder()
11558 .uri("/health")
11559 .body(Body::empty())
11560 .unwrap(),
11561 )
11562 .await
11563 .unwrap();
11564 let status = resp.status();
11565 let body = String::from_utf8(
11566 axum::body::to_bytes(resp.into_body(), usize::MAX)
11567 .await
11568 .unwrap()
11569 .to_vec(),
11570 )
11571 .unwrap();
11572 (status, body)
11573 };
11574
11575 state
11578 .runtime_health
11579 .set_started_at(chrono::Utc::now().timestamp());
11580
11581 let (status, body) = body_of(state.clone()).await;
11582 assert_eq!(status, StatusCode::OK);
11583 assert!(
11584 body.contains("db: ok"),
11585 "health did not probe the DB: {body}"
11586 );
11587 assert!(
11588 body.contains("uptime:"),
11589 "no uptime — the first thing anyone asks about a container that may \
11590 be restarting: {body}"
11591 );
11592 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
11593 assert!(body.contains("polling-paused: no"), "{body}");
11594 assert!(body.contains("backend:"), "{body}");
11595 assert!(body.contains("oauth-runtime:"), "{body}");
11596
11597 state.runtime_health.set_watermark(true);
11602 state.runtime_health.set_schedulers_enabled(true);
11603 let (status, body) = body_of(state.clone()).await;
11604 assert_eq!(
11605 status,
11606 StatusCode::OK,
11607 "a watermark pause must not fail the liveness check: {body}"
11608 );
11609 assert!(body.contains("polling-paused: yes"), "{body}");
11610 assert!(
11613 body.contains("poller: not-yet-ticked"),
11614 "a never-ticked poller must say so: {body}"
11615 );
11616
11617 let stale_after = health_tick_stale_secs(configured_poll_tick());
11619 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
11620 state.runtime_health.poll_tick_completed(long_ago);
11621 let (status, body) = body_of(state.clone()).await;
11622 assert_eq!(
11623 status,
11624 StatusCode::OK,
11625 "a stale poller must not 503: {body}"
11626 );
11627 assert!(body.contains("poller: stale"), "{body}");
11628
11629 state.runtime_health.poll_tick_completed(0); state
11637 .runtime_health
11638 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
11639 let (status, body) = body_of(state.clone()).await;
11640 assert_eq!(status, StatusCode::OK);
11641 assert!(
11642 body.contains("poller: stale never-ticked"),
11643 "a poller that never ticked long after boot still reads as benign: {body}"
11644 );
11645
11646 state.db.close().await;
11649 let (status, body) = body_of(state.clone()).await;
11650 assert_eq!(
11651 status,
11652 StatusCode::SERVICE_UNAVAILABLE,
11653 "an unreachable database must fail the check: {body}"
11654 );
11655 assert!(body.starts_with("FAIL"), "{body}");
11656 assert!(
11660 !body.contains("PoolClosed") && !body.contains("sqlx"),
11661 "health leaked the raw database error to an unauthenticated caller: {body}"
11662 );
11663 }
11664
11665 #[test]
11671 fn the_stale_threshold_follows_the_poll_tick() {
11672 assert_eq!(
11675 health_tick_stale_secs(Duration::from_secs(60)),
11676 HEALTH_TICK_STALE_FLOOR_SECS
11677 );
11678 let slow = Duration::from_secs(30 * 60);
11681 assert!(
11682 health_tick_stale_secs(slow) > slow.as_secs() as i64,
11683 "a 30-minute tick must not be stale after one interval"
11684 );
11685 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11686 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11688 }
11689
11690 #[tokio::test]
11696 async fn stats_does_not_call_a_stopped_poller_running() {
11697 let state = test_state(&[]).await;
11698 let render = |state: AppState| async move {
11699 let resp = router(state)
11700 .oneshot(
11701 Request::builder()
11702 .uri("/stats")
11703 .body(Body::empty())
11704 .unwrap(),
11705 )
11706 .await
11707 .unwrap();
11708 assert_eq!(resp.status(), StatusCode::OK);
11709 String::from_utf8(
11710 axum::body::to_bytes(resp.into_body(), usize::MAX)
11711 .await
11712 .unwrap()
11713 .to_vec(),
11714 )
11715 .unwrap()
11716 };
11717
11718 let body = render(state.clone()).await;
11720 assert!(
11721 body.contains("the poller is not running on this instance"),
11722 "a disabled poller renders as healthy"
11723 );
11724
11725 state.runtime_health.set_schedulers_enabled(true);
11727 let body = render(state.clone()).await;
11728 assert!(
11729 body.contains("no poll has finished since this instance booted"),
11730 "a poller that has not ticked renders as healthy"
11731 );
11732
11733 state
11735 .runtime_health
11736 .poll_tick_completed(chrono::Utc::now().timestamp());
11737 let body = render(state.clone()).await;
11738 assert!(
11739 body.contains("running"),
11740 "a healthy poller must read as running"
11741 );
11742
11743 state.runtime_health.set_watermark(true);
11745 let body = render(state.clone()).await;
11746 assert!(
11747 body.contains("the cache is at its size limit"),
11748 "a watermark pause is hidden once the poller is ticking"
11749 );
11750 }
11751
11752 #[tokio::test]
11763 async fn health_reports_an_unmeasured_database_without_failing() {
11764 use crate::runtime_health::DbProbe;
11765 let state = test_state(&[]).await;
11766
11767 let held = state
11770 .runtime_health
11771 .begin_db_probe()
11772 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11773
11774 let resp = router(state.clone())
11775 .oneshot(
11776 Request::builder()
11777 .uri("/health")
11778 .body(Body::empty())
11779 .unwrap(),
11780 )
11781 .await
11782 .unwrap();
11783 let status = resp.status();
11784 let body = String::from_utf8(
11785 axum::body::to_bytes(resp.into_body(), usize::MAX)
11786 .await
11787 .unwrap()
11788 .to_vec(),
11789 )
11790 .unwrap();
11791 drop(held);
11792
11793 assert_eq!(
11794 status,
11795 StatusCode::OK,
11796 "an unmeasured database failed the check, which an unauthenticated \
11797 caller can cause on demand: {body}"
11798 );
11799 assert!(
11800 body.contains("db: unknown"),
11801 "the unmeasured state must still be REPORTED: {body}"
11802 );
11803 assert!(!body.starts_with("FAIL"), "{body}");
11804 assert!(
11809 !body.starts_with("ok"),
11810 "the unmeasured state is indistinguishable from healthy to a \
11811 body-matching monitor: {body}"
11812 );
11813 assert!(body.starts_with("unknown"), "{body}");
11814
11815 let held = state
11826 .runtime_health
11827 .begin_db_probe()
11828 .unwrap_or_else(|_| panic!("claim"));
11829 state
11830 .runtime_health
11831 .record_for_test(DbProbe::Failed("unavailable".to_string()));
11832 let resp = router(state.clone())
11833 .oneshot(
11834 Request::builder()
11835 .uri("/health")
11836 .body(Body::empty())
11837 .unwrap(),
11838 )
11839 .await
11840 .unwrap();
11841 let status = resp.status();
11842 let body = String::from_utf8(
11843 axum::body::to_bytes(resp.into_body(), usize::MAX)
11844 .await
11845 .unwrap()
11846 .to_vec(),
11847 )
11848 .unwrap();
11849 drop(held);
11850 assert_eq!(
11851 status,
11852 StatusCode::SERVICE_UNAVAILABLE,
11853 "a BORROWED failure verdict must fail the check, not just a freshly \
11854 measured one: {body}"
11855 );
11856 assert!(body.starts_with("FAIL"), "{body}");
11857
11858 state.db.close().await;
11859 let resp = router(state.clone())
11860 .oneshot(
11861 Request::builder()
11862 .uri("/health")
11863 .body(Body::empty())
11864 .unwrap(),
11865 )
11866 .await
11867 .unwrap();
11868 assert_eq!(
11869 resp.status(),
11870 StatusCode::SERVICE_UNAVAILABLE,
11871 "a measured database failure must still fail the check"
11872 );
11873 }
11874
11875 #[tokio::test]
11884 async fn an_abandoned_request_still_records_its_probe() {
11885 use crate::runtime_health::DbProbe;
11886 let state = test_state(&[]).await;
11887 let rh = state.runtime_health.clone();
11888
11889 let app = router(state.clone());
11891 let fut = app.oneshot(
11892 Request::builder()
11893 .uri("/health")
11894 .body(Body::empty())
11895 .unwrap(),
11896 );
11897 let handle = tokio::spawn(fut);
11898 handle.abort();
11899 let _ = handle.await;
11900
11901 for _ in 0..50 {
11904 if rh.begin_db_probe().is_ok() {
11905 break;
11906 }
11907 tokio::time::sleep(Duration::from_millis(20)).await;
11908 }
11909 let resp = router(state.clone())
11910 .oneshot(
11911 Request::builder()
11912 .uri("/health")
11913 .body(Body::empty())
11914 .unwrap(),
11915 )
11916 .await
11917 .unwrap();
11918 let body = String::from_utf8(
11919 axum::body::to_bytes(resp.into_body(), usize::MAX)
11920 .await
11921 .unwrap()
11922 .to_vec(),
11923 )
11924 .unwrap();
11925 assert!(
11926 body.contains("db: ok"),
11927 "after an abandoned request the next caller still reads an \
11928 unmeasured database — the probe was cancelled with it: {body}"
11929 );
11930 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11932 }
11933
11934 #[tokio::test]
11941 async fn the_health_probe_opens_a_real_table() {
11942 use sqlx::Row;
11943 let state = test_state(&[]).await;
11944 let opcodes = |sql: &'static str| {
11946 let db = state.db.clone();
11947 async move {
11948 sqlx::query(sql)
11949 .fetch_all(&db)
11950 .await
11951 .unwrap()
11952 .into_iter()
11953 .map(|r| r.get::<String, _>("opcode"))
11954 .collect::<Vec<String>>()
11955 }
11956 };
11957
11958 let explain: &'static str =
11961 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11962 let probe = opcodes(explain).await;
11963 assert!(
11965 health_db_probe(&state.db).await.is_ok(),
11966 "the probe does not run against the real schema",
11967 );
11968 assert!(
11969 probe.iter().any(|op| op == "OpenRead"),
11970 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11971 );
11972 let bare = opcodes("EXPLAIN SELECT 1").await;
11974 assert!(
11975 !bare.iter().any(|op| op == "OpenRead"),
11976 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11977 );
11978 }
11979
11980 #[test]
11983 fn an_instance_that_has_never_polled_says_so() {
11984 assert_eq!(humanise_ago(None), "never");
11985 assert_eq!(humanise_ago(Some(0)), "0s ago");
11986 assert_eq!(humanise_ago(Some(59)), "59s ago");
11987 assert_eq!(humanise_ago(Some(60)), "1m ago");
11988 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11989 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11990 }
11991
11992 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11995 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11996 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11997 let addr = listener.local_addr().unwrap();
11998 let (url, title) = (saved_url.to_string(), saved_title.to_string());
11999 tokio::spawn(async move {
12000 loop {
12001 let Ok((mut sock, _)) = listener.accept().await else {
12002 break;
12003 };
12004 let mut buf = vec![0u8; 8192];
12005 let Ok(n) = sock.read(&mut buf).await else {
12006 continue;
12007 };
12008 let req = String::from_utf8_lossy(&buf[..n]).to_string();
12009 let wants_saved = req.contains("community.lexicon.rss.saved");
12010 let records = if wants_saved {
12011 serde_json::json!([{
12012 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12013 "cid": "bafy",
12014 "value": {
12015 "$type": "community.lexicon.rss.saved",
12016 "url": url,
12017 "title": title,
12018 "createdAt": "2026-01-01T00:00:00Z"
12019 }
12020 }])
12021 } else {
12022 serde_json::json!([])
12023 };
12024 let body = serde_json::json!({
12025 "ok": true, "data": { "records": records }
12026 })
12027 .to_string();
12028 let resp = format!(
12029 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12030 body.len(), body
12031 );
12032 let _ = sock.write_all(resp.as_bytes()).await;
12033 let _ = sock.flush().await;
12034 }
12035 });
12036 format!("http://{addr}")
12037 }
12038
12039 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12042 let feed = subscribed_feed.to_string();
12043 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12044 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12045 let addr = listener.local_addr().unwrap();
12046 tokio::spawn(async move {
12047 loop {
12048 let Ok((mut sock, _)) = listener.accept().await else {
12049 break;
12050 };
12051 let mut buf = vec![0u8; 8192];
12052 let Ok(read) = sock.read(&mut buf).await else {
12053 continue;
12054 };
12055 let req = String::from_utf8_lossy(&buf[..read]).to_string();
12056 let records = if req.contains("community.lexicon.rss.saved") {
12057 serde_json::Value::Array(
12058 (0..n)
12059 .map(|i| {
12060 serde_json::json!({
12061 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12062 "cid": "bafy",
12063 "value": {
12064 "$type": "community.lexicon.rss.saved",
12065 "url": format!("https://elsewhere.example/{i}"),
12066 "title": format!("Elsewhere {i}"),
12067 "createdAt": "2026-01-01T00:00:00Z"
12068 }
12069 })
12070 })
12071 .collect(),
12072 )
12073 } else if req.contains("community.lexicon.rss.subscription") {
12074 serde_json::json!([{
12079 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12080 "cid": "bafy",
12081 "value": {
12082 "$type": "community.lexicon.rss.subscription",
12083 "url": feed,
12084 "createdAt": "2026-01-01T00:00:00Z"
12085 }
12086 }])
12087 } else {
12088 serde_json::json!([])
12089 };
12090 let body =
12091 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12092 let resp = format!(
12093 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12094 body.len(), body
12095 );
12096 let _ = sock.write_all(resp.as_bytes()).await;
12097 let _ = sock.flush().await;
12098 }
12099 });
12100 format!("http://{addr}")
12101 }
12102
12103 #[tokio::test]
12111 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12112 let did = "did:plc:pagerloop";
12113 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12114 let state = test_state_with_sidecar(&[], &sidecar).await;
12115 store::grant_access(&state.db, did, None, "test", None)
12116 .await
12117 .unwrap();
12118 let feed = store::upsert_feed(
12119 &state.db,
12120 &store::NewFeed {
12121 url: "https://loop.example/feed.xml".to_string(),
12122 title: Some("Loop".to_string()),
12123 ..Default::default()
12124 },
12125 )
12126 .await
12127 .unwrap();
12128 let entries: Vec<store::NewEntry> = (0..250)
12131 .map(|i| store::NewEntry {
12132 guid: format!("s-{i:04}"),
12133 url: Some(format!("https://loop.example/{i}")),
12134 title: Some(format!("Starred {i:04}")),
12135 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12136 ..Default::default()
12137 })
12138 .collect();
12139 store::insert_entries(&state.db, feed, &entries, 0)
12140 .await
12141 .unwrap();
12142 store::replace_sub_refs(&state.db, did, &[feed])
12143 .await
12144 .unwrap();
12145 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12146 .await
12147 .unwrap()
12148 {
12149 store::mark_starred(&state.db, did, row.id, true)
12150 .await
12151 .unwrap();
12152 }
12153
12154 let cookie = session_cookie(&state, did, None);
12155 let app = router(state.clone());
12156 let get = |uri: &str| {
12157 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12158 async move {
12159 let resp = app
12160 .oneshot(
12161 Request::builder()
12162 .uri(uri)
12163 .header(header::COOKIE, cookie)
12164 .body(Body::empty())
12165 .unwrap(),
12166 )
12167 .await
12168 .unwrap();
12169 assert_eq!(resp.status(), StatusCode::OK);
12170 String::from_utf8(
12171 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
12172 .await
12173 .unwrap()
12174 .to_vec(),
12175 )
12176 .unwrap()
12177 }
12178 };
12179
12180 let p3 = get("/?view=starred&page=3").await;
12185 assert!(
12186 p3.contains("Page 3 of 4"),
12187 "the pager and the clamp disagree on the total: {}",
12188 p3.split("pager-pos")
12189 .nth(1)
12190 .unwrap_or("")
12191 .chars()
12192 .take(120)
12193 .collect::<String>()
12194 );
12195 assert!(
12198 p3.contains("Elsewhere 0"),
12199 "page 3 should start the uncached run"
12200 );
12201 assert_eq!(
12202 p3.matches("<li class=\"entry").count(),
12203 ENTRIES_PER_PAGE as usize,
12204 "the boundary page is not full"
12205 );
12206
12207 {
12216 let body = &p3;
12217 assert!(
12218 body.contains("330 entries"),
12219 "the heading must count the whole sequence: {}",
12220 body.split("content-count")
12221 .nth(1)
12222 .unwrap_or("")
12223 .chars()
12224 .take(120)
12225 .collect::<String>()
12226 );
12227 assert!(
12228 body.contains("(80 saved elsewhere)"),
12229 "the heading must say how many of the total the cache cannot show, \
12230 as a whole-list figure and not a per-page one: {}",
12231 body.split("content-count")
12232 .nth(1)
12233 .unwrap_or("")
12234 .chars()
12235 .take(120)
12236 .collect::<String>()
12237 );
12238 assert!(
12239 !body.contains("plus 50") && !body.contains("plus 80"),
12240 "the heading is adding the uncached rows to a total that already \
12241 includes them"
12242 );
12243 }
12244
12245 let p4 = get("/?view=starred&page=4").await;
12246 assert!(
12247 p4.contains("Page 4 of 4"),
12248 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
12249 );
12250 assert_eq!(
12251 p4.matches("<li class=\"entry").count(),
12252 30,
12253 "page 4 should hold the remaining 30 uncached records"
12254 );
12255 assert!(
12256 p4.contains("Elsewhere 79"),
12257 "the LAST saved record is unreachable — it can only be removed from here"
12258 );
12259
12260 assert!(
12262 !p4.contains("Elsewhere 0"),
12263 "an uncached record was rendered on more than one page"
12264 );
12265 let first = get("/?view=starred").await;
12268 assert!(
12269 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
12270 "the heading changed between pages; it describes the list, not the page"
12271 );
12272 assert!(
12273 !first.contains("Elsewhere "),
12274 "uncached saved records leaked onto the first page"
12275 );
12276 }
12277
12278 #[tokio::test]
12285 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
12286 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
12287 let sidecar =
12288 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
12289 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
12290 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
12291
12292 let resp = router(state)
12293 .oneshot(
12294 Request::builder()
12295 .uri("/?view=starred")
12296 .body(Body::empty())
12297 .unwrap(),
12298 )
12299 .await
12300 .unwrap();
12301 assert_eq!(resp.status(), StatusCode::OK);
12302 let body = String::from_utf8(
12303 axum::body::to_bytes(resp.into_body(), usize::MAX)
12304 .await
12305 .unwrap()
12306 .to_vec(),
12307 )
12308 .unwrap();
12309
12310 assert!(
12311 body.contains("Starred elsewhere"),
12312 "the saved record was not rendered at all"
12313 );
12314 assert!(
12315 body.contains("entry-uncached"),
12316 "it was not marked as uncached, so it looks like a normal entry"
12317 );
12318 assert!(
12319 body.contains("https://elsewhere.example/article"),
12320 "the row must link straight to the article"
12321 );
12322 assert!(
12323 !body.contains("/entries/0/"),
12324 "an uncached row must not offer entry actions against a nonexistent id"
12325 );
12326 }
12327
12328 #[test]
12336 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
12337 for hostile in [
12338 "日本語日本語日本",
12339 "é",
12340 "",
12341 "2026",
12342 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
12343 ] {
12344 let out = display_date(Some(hostile));
12345 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
12346 }
12347 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
12348 assert_eq!(display_date(None), "");
12349 }
12350
12351 #[test]
12354 fn the_unsave_route_is_rate_limited() {
12355 use axum::http::Method;
12356 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
12357 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
12359 }
12360
12361 #[tokio::test]
12370 async fn health_reports_a_broken_database() {
12371 let state = test_state(&[]).await;
12372 assert!(
12374 health_db_probe(&state.db).await.is_ok(),
12375 "the fixture was not healthy to begin with",
12376 );
12377
12378 sqlx::query("DROP TABLE feeds")
12379 .execute(&state.db)
12380 .await
12381 .unwrap();
12382
12383 assert!(
12384 health_db_probe(&state.db).await.is_err(),
12385 "the probe reported success against a database missing the table it \
12386 claims to read; `SELECT 1` would do exactly this",
12387 );
12388
12389 let resp = router(state)
12390 .oneshot(
12391 Request::builder()
12392 .uri("/health")
12393 .body(Body::empty())
12394 .unwrap(),
12395 )
12396 .await
12397 .unwrap();
12398 let body = String::from_utf8(
12399 axum::body::to_bytes(resp.into_body(), usize::MAX)
12400 .await
12401 .unwrap()
12402 .to_vec(),
12403 )
12404 .unwrap();
12405 assert!(
12407 body.starts_with("FAIL"),
12408 "/health did not report FAIL for a broken database: {body}",
12409 );
12410 assert!(
12411 !body.contains("db: ok"),
12412 "/health still called the database ok: {body}",
12413 );
12414 }
12415
12416 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
12421 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12422 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12423 let addr = listener.local_addr().unwrap();
12424 tokio::spawn(async move {
12425 loop {
12426 let Ok((mut sock, _)) = listener.accept().await else {
12427 break;
12428 };
12429 let mut buf = vec![0u8; 8192];
12430 let Ok(n) = sock.read(&mut buf).await else {
12431 continue;
12432 };
12433 let req = String::from_utf8_lossy(&buf[..n]).to_string();
12434 let wants = |c: &str| req.contains(c);
12435 if fail_on.is_some_and(wants) {
12436 let body = r#"{"ok":false,"error":"ShortList"}"#;
12437 let resp = format!(
12438 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12439 body.len(),
12440 body
12441 );
12442 let _ = sock.write_all(resp.as_bytes()).await;
12443 let _ = sock.flush().await;
12444 continue;
12445 }
12446 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
12447 serde_json::json!([{
12448 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
12449 "cid": "bafy",
12450 "value": {
12451 "$type": crate::lexicon::nsid::SUBSCRIPTION,
12452 "url": "https://kept.example/feed.xml",
12453 "title": "Kept",
12454 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12459 "createdAt": "2026-01-01T00:00:00Z"
12460 }
12461 }])
12462 } else if wants(crate::lexicon::nsid::FOLDER) {
12463 serde_json::json!([{
12464 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12465 "cid": "bafy",
12466 "value": {
12467 "$type": crate::lexicon::nsid::FOLDER,
12468 "name": "Kept folder",
12469 "createdAt": "2026-01-01T00:00:00Z"
12470 }
12471 }])
12472 } else {
12473 serde_json::json!([])
12474 };
12475 let body =
12476 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12477 let resp = format!(
12478 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12479 body.len(),
12480 body
12481 );
12482 let _ = sock.write_all(resp.as_bytes()).await;
12483 let _ = sock.flush().await;
12484 }
12485 });
12486 format!("http://{addr}")
12487 }
12488
12489 async fn spawn_malformed_sidecar() -> String {
12492 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12493 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12494 let addr = listener.local_addr().unwrap();
12495 tokio::spawn(async move {
12496 loop {
12497 let Ok((mut sock, _)) = listener.accept().await else {
12498 break;
12499 };
12500 let mut buf = vec![0u8; 8192];
12501 let _ = sock.read(&mut buf).await;
12502 let body = serde_json::json!({ "ok": true, "data": { "records": [
12503 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
12504 { "cid": "bafy", "value": {} },
12505 ]}})
12506 .to_string();
12507 let resp = format!(
12508 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12509 body.len(),
12510 body
12511 );
12512 let _ = sock.write_all(resp.as_bytes()).await;
12513 let _ = sock.flush().await;
12514 }
12515 });
12516 format!("http://{addr}")
12517 }
12518
12519 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
12520 let cookie = session_cookie(&state, did, None);
12521 let resp = router(state)
12522 .oneshot(
12523 Request::builder()
12524 .uri(uri)
12525 .header(header::COOKIE, cookie)
12526 .body(Body::empty())
12527 .unwrap(),
12528 )
12529 .await
12530 .unwrap();
12531 let status = resp.status();
12532 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12533 .await
12534 .unwrap();
12535 (status, String::from_utf8_lossy(&body).to_string())
12536 }
12537
12538 #[tokio::test]
12544 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
12545 let did = "did:plc:displayer";
12546 let state = test_state(&[did]).await;
12547 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
12548 let feed_id = store::upsert_feed(
12549 &state.db,
12550 &store::NewFeed {
12551 url: url.into(),
12552 title: Some("Quiet Journal".into()),
12553 ..Default::default()
12554 },
12555 )
12556 .await
12557 .unwrap();
12558 store::replace_sub_refs(&state.db, did, &[feed_id])
12559 .await
12560 .unwrap();
12561 store::insert_entries(
12562 &state.db,
12563 feed_id,
12564 &[store::NewEntry {
12565 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
12566 .into(),
12567 url: Some("https://quiet.example/no-summary".into()),
12568 title: Some("A title-only article".into()),
12569 published: Some("2026-07-11T00:00:00Z".into()),
12570 content_html: None,
12571 ..Default::default()
12572 }],
12573 0,
12574 )
12575 .await
12576 .unwrap();
12577 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
12578 assert_eq!(status, StatusCode::OK);
12579 assert!(
12580 list.contains("A title-only article"),
12581 "the entry is missing from the list"
12582 );
12583
12584 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
12585 .bind(feed_id)
12586 .fetch_one(&state.db)
12587 .await
12588 .unwrap();
12589 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
12590 assert_eq!(
12591 status,
12592 StatusCode::OK,
12593 "the article page failed for an entry with no body"
12594 );
12595 assert!(page.contains("A title-only article"));
12596 assert!(
12597 page.contains("https://quiet.example/no-summary"),
12598 "no link to the original"
12599 );
12600 assert!(
12601 page.contains(r#"<time datetime=""#),
12602 "no date on the article page"
12603 );
12604 }
12605
12606 #[tokio::test]
12611 async fn a_malformed_subscription_record_raises_an_alert() {
12612 let did = "did:plc:alerted";
12613 for page in ["/", "/manage"] {
12614 let sidecar = spawn_malformed_sidecar().await;
12615 let state = test_state_with_sidecar(&[did], &sidecar).await;
12616 let (status, body) = page_body(state, did, page).await;
12617 assert_eq!(status, StatusCode::OK, "{page} did not render");
12618 assert!(
12619 body.contains(r#"role="alert""#) && body.contains("could not be read"),
12620 "{page} rendered no alert for a refused subscription list"
12621 );
12622 assert!(
12623 body.contains("1 record(s) in your subscription list"),
12624 "{page} gave the generic alert, not the malformed-record one"
12625 );
12626 }
12627 }
12628
12629 #[tokio::test]
12631 async fn a_healthy_subscription_listing_raises_no_alert() {
12632 let did = "did:plc:exporter";
12633 let sidecar = spawn_export_sidecar(None).await;
12634 let state = test_state_with_sidecar(&[did], &sidecar).await;
12635 let (status, body) = page_body(state, did, "/").await;
12636 assert_eq!(status, StatusCode::OK);
12637 assert!(
12638 !body.contains("could not be read"),
12639 "a healthy listing raised an alert"
12640 );
12641 }
12642
12643 async fn export_opml_response(
12645 fail_on: Option<&'static str>,
12646 ) -> (StatusCode, HeaderMap, String) {
12647 let did = "did:plc:exporter";
12648 let sidecar = spawn_export_sidecar(fail_on).await;
12649 let state = test_state_with_sidecar(&[did], &sidecar).await;
12650 let cookie = session_cookie(&state, did, None);
12651 let resp = router(state)
12652 .oneshot(
12653 Request::builder()
12654 .uri("/opml/export")
12655 .header(header::COOKIE, cookie)
12656 .body(Body::empty())
12657 .unwrap(),
12658 )
12659 .await
12660 .unwrap();
12661 let status = resp.status();
12662 let headers = resp.headers().clone();
12663 let body = String::from_utf8_lossy(
12664 &axum::body::to_bytes(resp.into_body(), usize::MAX)
12665 .await
12666 .unwrap(),
12667 )
12668 .to_string();
12669 (status, headers, body)
12670 }
12671
12672 #[tokio::test]
12685 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
12686 let (status, headers, body) =
12687 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
12688
12689 assert_ne!(
12690 status,
12691 StatusCode::OK,
12692 "a failed subscription walk answered 200: {body}",
12693 );
12694 assert!(
12695 !headers.contains_key(header::CONTENT_DISPOSITION),
12696 "a failed subscription walk still offered a download: {headers:?}",
12697 );
12698 assert!(
12699 !body.contains("<opml"),
12700 "a failed subscription walk still served an OPML document: {body}",
12701 );
12702 }
12703
12704 #[tokio::test]
12708 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
12709 let (status, headers, body) =
12710 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
12711
12712 assert_ne!(
12713 status,
12714 StatusCode::OK,
12715 "a failed folder walk answered 200: {body}",
12716 );
12717 assert!(
12718 !headers.contains_key(header::CONTENT_DISPOSITION),
12719 "a failed folder walk still offered a download: {headers:?}",
12720 );
12721 assert!(
12722 !body.contains("<opml"),
12723 "a failed folder walk still served an OPML document: {body}",
12724 );
12725 }
12726
12727 #[tokio::test]
12730 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
12731 let (status, headers, body) = export_opml_response(None).await;
12732
12733 assert_eq!(
12734 status,
12735 StatusCode::OK,
12736 "a healthy export did not answer 200"
12737 );
12738 assert_eq!(
12739 headers
12740 .get(header::CONTENT_DISPOSITION)
12741 .and_then(|v| v.to_str().ok()),
12742 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
12743 "a healthy export did not offer the download",
12744 );
12745 assert!(
12746 body.contains("https://kept.example/feed.xml"),
12747 "the exported OPML lost the subscription: {body}",
12748 );
12749 assert!(
12750 body.contains("Kept folder"),
12751 "the exported OPML lost the folder: {body}",
12752 );
12753 }
12754}