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 })
956}
957
958async fn unsave_record(
973 State(state): State<AppState>,
974 headers: HeaderMap,
975 Path(rkey): Path<String>,
976) -> Response {
977 let Some(did) = current_did(&state, &headers).await else {
978 return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
979 };
980
981 let identity = match state.repo().list_saved(&did).await {
986 Ok(records) => records
987 .into_iter()
988 .find(|(k, _)| *k == rkey)
989 .map(|(_, rec)| (rec.url, rec.entry_id)),
990 Err(err) => {
991 warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
992 a local star for the same article may survive");
993 None
994 }
995 };
996
997 match state.repo().remove_saved(&did, &rkey).await {
998 Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
999 Err(err) => {
1000 warn!(%err, %did, %rkey, "could not remove the saved record");
1001 return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1002 }
1003 }
1004
1005 if let Some((url, guid)) = identity {
1009 match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1010 Ok(0) => {}
1011 Ok(n) => {
1012 info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1013 }
1014 Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1015 }
1016 }
1017 if is_htmx(&headers) {
1019 return (StatusCode::OK, "").into_response();
1020 }
1021 Redirect::to("/?view=starred").into_response()
1022}
1023
1024fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1037 if !rh.schedulers_enabled() {
1038 return "off";
1039 }
1040 match rh.secs_since_poll_tick(now_unix) {
1043 None => {
1044 match rh.uptime_secs(now_unix) {
1047 Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1048 _ => "starting",
1049 }
1050 }
1051 Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1052 _ if rh.watermark_paused() => "paused",
1053 _ => "running",
1054 }
1055}
1056
1057async fn stats(State(state): State<AppState>) -> Response {
1059 let now = chrono::Utc::now();
1060 let health = match store::poll_health(
1061 &state.db,
1062 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1063 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1064 )
1065 .await
1066 {
1067 Ok(health) => health,
1068 Err(err) => {
1069 warn!(%err, "could not compute poll health");
1070 return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1071 }
1072 };
1073
1074 let polled_pct = if health.feeds_tracked == 0 {
1077 100
1078 } else {
1079 health.polled_last_hour * 100 / health.feeds_tracked
1080 };
1081
1082 render(&StatsTemplate {
1083 version: VERSION,
1084 repo_url: REPO_URL,
1085 kofi_url: KOFI_URL,
1086 feeds_tracked: health.feeds_tracked,
1087 polled_last_hour: health.polled_last_hour,
1088 polled_pct,
1089 overdue: health.overdue,
1090 last_poll: humanise_ago(health.last_poll_secs_ago),
1091 oldest_poll: if health.never_polled > 0 {
1092 "never".to_string()
1093 } else {
1094 humanise_ago(health.oldest_poll_secs_ago)
1095 },
1096 never_polled: health.never_polled,
1097 poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1098 in_backoff: health.in_backoff,
1107 badly_broken: health.badly_broken,
1108 failure_kinds: health.failure_kinds,
1109 fetching: fetching_state(&state.runtime_health, now.timestamp()),
1110 })
1111}
1112
1113fn humanise_ago(secs: Option<i64>) -> String {
1118 let Some(secs) = secs else {
1119 return "never".to_string();
1120 };
1121 match secs {
1122 s if s < 60 => format!("{s}s ago"),
1123 s if s < 3600 => format!("{}m ago", s / 60),
1124 s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1125 }
1126}
1127
1128async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1136 match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1137 Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1139 repos: stat.value,
1140 truncated: stat.truncated,
1141 observed_on: stat
1142 .observed_at
1143 .split('T')
1144 .next()
1145 .unwrap_or_default()
1146 .to_string(),
1147 }),
1148 Ok(_) => None,
1149 Err(err) => {
1150 warn!(%err, "about: adoption stat read failed; omitting the line");
1151 None
1152 }
1153 }
1154}
1155
1156async fn privacy() -> Response {
1160 render(&PrivacyTemplate {
1161 version: VERSION,
1162 repo_url: REPO_URL,
1163 kofi_url: KOFI_URL,
1164 })
1165}
1166
1167async fn terms() -> Response {
1171 render(&TermsTemplate {
1172 version: VERSION,
1173 repo_url: REPO_URL,
1174 kofi_url: KOFI_URL,
1175 })
1176}
1177
1178struct FeedView {
1185 rkey: String,
1187 url: String,
1189 title: String,
1190 unread: i64,
1191 selected: bool,
1193 folder: Option<String>,
1198}
1199
1200struct FolderView {
1202 rkey: String,
1204 uri: String,
1206 name: String,
1207 feeds: Vec<FeedView>,
1208 selected: bool,
1210}
1211
1212struct EntryRow {
1214 id: i64,
1215 title: String,
1216 feed_title: String,
1217 published: String,
1218 read: bool,
1219 starred: bool,
1220 link: SafeLink,
1223 cached: bool,
1231 rkey: String,
1233}
1234
1235struct FolderOption {
1237 uri: String,
1238 name: String,
1239}
1240
1241struct Nav {
1246 handle: String,
1248 avatar: String,
1250 view: String,
1252 scope_qs: String,
1255 folders: Vec<FolderView>,
1258 loose_feeds: Vec<FeedView>,
1259 manage_active: bool,
1261}
1262
1263#[derive(Template)]
1265#[template(path = "index.html")]
1266struct IndexTemplate {
1267 version: &'static str,
1268 repo_url: &'static str,
1269 kofi_url: &'static str,
1270 flash: String,
1271 alert: String,
1274 nav: Nav,
1276 entries: Vec<EntryRow>,
1278 heading: String,
1280 feed_scope: Option<String>,
1282 total: i64,
1290 uncached_total: i64,
1298 page: i64,
1300 page_count: i64,
1302 prev_href: Option<String>,
1304 next_href: Option<String>,
1306}
1307
1308#[derive(Template)]
1310#[template(path = "manage.html")]
1311struct ManageTemplate {
1312 version: &'static str,
1313 repo_url: &'static str,
1314 kofi_url: &'static str,
1315 flash: String,
1316 alert: String,
1318 nav: Nav,
1319 folder_options: Vec<FolderOption>,
1321 folders: Vec<FolderView>,
1323 loose_feeds: Vec<FeedView>,
1324}
1325
1326struct AdoptionLine {
1331 repos: i64,
1333 truncated: bool,
1335 observed_on: String,
1337}
1338
1339#[derive(Template)]
1342#[template(path = "about.html")]
1343struct AboutTemplate {
1344 version: &'static str,
1345 repo_url: &'static str,
1346 kofi_url: &'static str,
1347 adoption: Option<AdoptionLine>,
1348}
1349
1350#[derive(Template)]
1362#[template(path = "stats.html")]
1363struct StatsTemplate {
1364 version: &'static str,
1365 repo_url: &'static str,
1366 kofi_url: &'static str,
1367 feeds_tracked: i64,
1368 polled_last_hour: i64,
1369 polled_pct: i64,
1370 overdue: i64,
1371 last_poll: String,
1372 oldest_poll: String,
1373 never_polled: i64,
1374 poll_interval_mins: i64,
1375 in_backoff: i64,
1377 badly_broken: i64,
1381 failure_kinds: Vec<(String, i64)>,
1383 fetching: &'static str,
1387}
1388
1389#[derive(Template)]
1393#[template(path = "privacy.html")]
1394struct PrivacyTemplate {
1395 version: &'static str,
1396 repo_url: &'static str,
1397 kofi_url: &'static str,
1398}
1399
1400#[derive(Template)]
1403#[template(path = "terms.html")]
1404struct TermsTemplate {
1405 version: &'static str,
1406 repo_url: &'static str,
1407 kofi_url: &'static str,
1408}
1409
1410#[derive(Template)]
1413#[template(path = "landing.html")]
1414struct LandingTemplate {
1415 version: &'static str,
1416 repo_url: &'static str,
1417 crates_url: &'static str,
1418 kofi_url: &'static str,
1419}
1420
1421#[derive(Template)]
1423#[template(path = "entry.html")]
1424struct EntryTemplate {
1425 version: &'static str,
1426 repo_url: &'static str,
1427 kofi_url: &'static str,
1428 nav: Nav,
1429 id: i64,
1430 title: String,
1431 feed_title: String,
1432 author: Option<String>,
1433 published: String,
1434 url: Option<SafeLink>,
1444 content_html: Option<String>,
1445 read: bool,
1446 starred: bool,
1447 back_qs: String,
1449 prev_id: Option<i64>,
1451 next_id: Option<i64>,
1452 oob: bool,
1454}
1455
1456#[derive(Template)]
1458#[template(path = "entry_row.html")]
1459struct EntryRowTemplate {
1460 e: EntryRow,
1461}
1462
1463#[derive(Template)]
1468#[template(path = "entry_actionbar.html")]
1469struct EntryActionBarTemplate {
1470 id: i64,
1471 read: bool,
1472 starred: bool,
1473 oob: bool,
1475}
1476
1477#[derive(Template)]
1479#[template(path = "login.html")]
1480struct LoginTemplate {
1481 repo_url: &'static str,
1482 error: String,
1483 flash: String,
1486}
1487
1488#[derive(Template)]
1490#[template(path = "beta_redeem.html")]
1491struct BetaRedeemTemplate {
1492 repo_url: &'static str,
1493 error: String,
1494 capacity_full: bool,
1497}
1498
1499fn render<T: Template>(tmpl: &T) -> Response {
1506 match tmpl.render() {
1507 Ok(body) => Html(body).into_response(),
1508 Err(err) => {
1509 warn!(%err, "template render failed");
1510 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1511 }
1512 }
1513}
1514
1515struct WebError {
1520 err: anyhow::Error,
1521 status: StatusCode,
1522}
1523
1524impl<E: Into<anyhow::Error>> From<E> for WebError {
1525 fn from(err: E) -> Self {
1526 WebError {
1527 err: err.into(),
1528 status: StatusCode::INTERNAL_SERVER_ERROR,
1529 }
1530 }
1531}
1532
1533impl WebError {
1534 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1536 WebError {
1537 err: err.into(),
1538 status,
1539 }
1540 }
1541}
1542
1543impl IntoResponse for WebError {
1544 fn into_response(self) -> Response {
1545 warn!(error = %self.err, status = %self.status, "request failed");
1546 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1547 "internal error"
1548 } else {
1549 self.status.canonical_reason().unwrap_or("error")
1550 };
1551 (self.status, body).into_response()
1552 }
1553}
1554
1555fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1560 let status = err.status();
1561 WebError::with_status(err, status)
1562}
1563
1564fn display_title(title: Option<&str>, url: &str) -> String {
1567 if let Some(t) = title {
1568 let t = t.trim();
1569 if !t.is_empty() {
1570 return t.to_string();
1571 }
1572 }
1573 url::Url::parse(url)
1574 .ok()
1575 .and_then(|u| u.host_str().map(str::to_string))
1576 .unwrap_or_else(|| url.to_string())
1577}
1578
1579fn display_handle(handle: Option<&str>, did: &str) -> String {
1582 match handle {
1583 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1584 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1585 }
1586}
1587
1588fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1590 let source = handle
1591 .map(|h| h.trim().trim_start_matches('@'))
1592 .filter(|h| !h.is_empty())
1593 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1594 let letters: String = source
1595 .chars()
1596 .filter(|c| c.is_alphanumeric())
1597 .take(2)
1598 .collect::<String>()
1599 .to_lowercase();
1600 if letters.is_empty() {
1601 "fr".to_string()
1602 } else {
1603 letters
1604 }
1605}
1606
1607fn display_date(published: Option<&str>) -> String {
1610 match published {
1619 Some(p) => p.chars().take(10).collect(),
1620 None => String::new(),
1621 }
1622}
1623
1624fn qenc(s: &str) -> String {
1628 let mut out = String::with_capacity(s.len() * 3);
1629 for b in s.bytes() {
1630 match b {
1631 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1632 out.push(b as char)
1633 }
1634 _ => out.push_str(&format!("%{b:02X}")),
1635 }
1636 }
1637 out
1638}
1639
1640#[derive(Debug, Deserialize, Default)]
1646struct IndexQuery {
1647 #[serde(default)]
1649 feed: Option<String>,
1650 #[serde(default)]
1652 folder: Option<String>,
1653 #[serde(default)]
1655 view: Option<String>,
1656 #[serde(default)]
1658 page: Option<u32>,
1659 #[serde(default)]
1661 flash: Option<String>,
1662}
1663
1664const ENTRIES_PER_PAGE: i64 = 100;
1672
1673fn page_count_for(total: i64) -> i64 {
1676 ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1677}
1678
1679const PREV_NEXT_MAX: i64 = 5_000;
1686
1687const STARRED_IDENTITY_MAX: i64 = 20_000;
1695
1696const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1710
1711struct ResolvedSub {
1714 rkey: String,
1715 sub: Subscription,
1716 feed: Option<store::Feed>,
1717}
1718
1719async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1723 resolve_subscriptions_noting(state, did).await.0
1724}
1725
1726fn subscriptions_alert(err: &anyhow::Error) -> String {
1733 match err.downcast_ref::<crate::atproto::MalformedRecords>() {
1734 Some(m) => format!(
1735 "{} record(s) in your subscription list could not be read, so it was not \
1736 refreshed. Showing your last-known subscriptions; nothing was removed.",
1737 m.count
1738 ),
1739 None => "Your subscription list could not be read from your PDS just now. \
1740 Showing your last-known subscriptions."
1741 .to_string(),
1742 }
1743}
1744
1745async fn resolve_subscriptions_noting(
1748 state: &AppState,
1749 did: &str,
1750) -> (Vec<ResolvedSub>, Option<String>) {
1751 let pool = &state.db;
1752 let subs = match state.repo().list_subscriptions_sorted(did).await {
1753 Ok(s) => s,
1754 Err(err) => {
1755 let alert = subscriptions_alert(&err);
1756 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
1757 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
1770 warn!(%err, %did, "the PDS is unreachable AND the local subscription \
1771 projection could not be read; rendering an EMPTY \
1772 feed list, which is not the same as having none");
1773 Vec::new()
1774 });
1775 let cached = feeds
1776 .into_iter()
1777 .map(|f| ResolvedSub {
1778 rkey: String::new(),
1779 sub: Subscription::new(f.url.clone(), now_rfc3339()),
1780 feed: Some(f),
1781 })
1782 .collect();
1783 return (cached, Some(alert));
1784 }
1785 };
1786
1787 let mut out = Vec::with_capacity(subs.len());
1803 for (rkey, sub) in subs {
1804 let feed = match store::get_feed_by_url(pool, &sub.url).await {
1805 Ok(Some(f)) => Some(f),
1806 Ok(None) => {
1807 if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
1818 || feed::classify_feed_privacy(&sub.url).is_private()
1819 {
1820 warn!(
1821 %did,
1822 "skipping cache row for a subscription URL that is private or not http(s)"
1823 );
1824 out.push(ResolvedSub {
1825 rkey,
1826 sub,
1827 feed: None,
1828 });
1829 continue;
1830 }
1831 if let Err(err) = store::upsert_feed(
1839 pool,
1840 &store::NewFeed {
1841 url: sub.url.clone(),
1842 title: sub.title.clone(),
1843 site_url: sub.site_url.clone(),
1844 ..Default::default()
1845 },
1846 )
1847 .await
1848 {
1849 warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
1850 it will not be polled");
1851 }
1852 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
1853 }
1854 Err(err) => {
1855 warn!(%err, url = %sub.url, "get_feed_by_url failed");
1856 None
1857 }
1858 };
1859 out.push(ResolvedSub { rkey, sub, feed });
1860 }
1861 sync_sub_refs(pool, did, &out).await;
1865 (out, None)
1866}
1867
1868async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
1872 let feed_ids: Vec<i64> = subs
1873 .iter()
1874 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
1875 .collect();
1876 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
1877 warn!(%err, %did, "failed to sync sub_ref projection");
1878 }
1879}
1880
1881async fn index(
1884 State(state): State<AppState>,
1885 headers: HeaderMap,
1886 Query(q): Query<IndexQuery>,
1887) -> Result<Response, WebError> {
1888 let user = match current_session(&state, &headers).await {
1889 Some(u) => u,
1890 None => {
1893 return Ok(render(&LandingTemplate {
1894 version: VERSION,
1895 repo_url: REPO_URL,
1896 crates_url: CRATES_URL,
1897 kofi_url: KOFI_URL,
1898 }))
1899 }
1900 };
1901 let did = user.did.clone();
1902 let pool = &state.db;
1903
1904 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
1905
1906 let view = match q.view.as_deref() {
1908 Some("all") => "all",
1909 Some("starred") => "starred",
1910 _ => "unread",
1911 }
1912 .to_string();
1913 let list_view = list_view_of(q.view.as_deref());
1914
1915 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1917 let scope_ids = scoped_feed_ids(&subs, &scope_urls);
1922
1923 let feed_title_by_id = |id: i64| -> String {
1924 subs.iter()
1925 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1926 .map(|s| {
1927 display_title(
1928 s.sub
1929 .title
1930 .as_deref()
1931 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1932 &s.sub.url,
1933 )
1934 })
1935 .unwrap_or_default()
1936 };
1937
1938 let mut uncached: Vec<EntryRow> = Vec::new();
1951 if view == "starred" {
1952 let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
1981 Ok(store::StarredIdentities::All(rows)) => Some(rows),
1982 Ok(store::StarredIdentities::Truncated) => {
1987 warn!(
1988 %did,
1989 cap = STARRED_IDENTITY_MAX,
1990 "cached-starred set exceeded its cap; suppressing uncached saved rows \
1991 rather than rendering record-deleting buttons for cached articles"
1992 );
1993 None
1994 }
1995 Err(err) => {
1996 warn!(%err, %did, "cached-starred identity lookup failed; \
1997 suppressing uncached saved rows this render");
1998 None
1999 }
2000 };
2001 let identities_ok = identities.is_some();
2008 let identities = identities.unwrap_or_default();
2009 let cached_urls: std::collections::HashSet<&str> = identities
2010 .iter()
2011 .filter_map(|(url, _)| url.as_deref())
2012 .collect();
2013 let cached_guids: std::collections::HashSet<&str> =
2014 identities.iter().map(|(_, guid)| guid.as_str()).collect();
2015
2016 let mut uncached_dropped = 0usize;
2029 match state.repo().list_saved_sorted(&did).await {
2030 Ok(saved) if identities_ok => {
2031 for (rkey, item) in saved {
2032 let known = cached_urls.contains(item.url.as_str())
2033 || item
2034 .entry_id
2035 .as_deref()
2036 .is_some_and(|g| cached_guids.contains(g));
2037 if known {
2038 continue;
2039 }
2040 if let Some(urls) = &scope_urls {
2044 match item.feed_url.as_deref() {
2045 Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2046 _ => continue,
2050 }
2051 }
2052 let link = SafeLink::external(&item.url);
2078 if link.is_empty() {
2079 warn!(
2080 %did, %rkey,
2081 "a saved record has an unusable URL; rendering it without a link \
2082 so it can still be removed"
2083 );
2084 }
2085
2086 if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2100 uncached_dropped += 1;
2101 continue;
2102 }
2103 if let Some(feed_url) = item.feed_url.as_deref() {
2104 if subs.iter().any(|s| s.sub.url == feed_url) {
2105 let stale_before = (chrono::Utc::now()
2109 - chrono::Duration::from_std(state.config.poll_interval)
2110 .unwrap_or_else(|_| chrono::Duration::hours(1)))
2111 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2112 if let Err(err) =
2113 store::mark_feed_due(pool, feed_url, &stale_before).await
2114 {
2115 tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2116 }
2117 }
2118 }
2119 uncached.push(EntryRow {
2120 id: 0,
2121 title: item
2122 .title
2123 .clone()
2124 .filter(|t| !t.trim().is_empty())
2125 .unwrap_or_else(|| {
2133 if link.is_empty() {
2134 format!("Saved item {rkey}")
2135 } else {
2136 item.url.clone()
2137 }
2138 }),
2139 feed_title: item.feed_url.clone().unwrap_or_default(),
2140 published: display_date(Some(&item.created_at)),
2141 read: false,
2142 starred: true,
2143 link,
2147 cached: false,
2148 rkey,
2149 });
2150 }
2151 }
2152 Ok(_) => {}
2154 Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2155 }
2156 if uncached_dropped > 0 {
2157 warn!(
2158 %did,
2159 dropped = uncached_dropped,
2160 cap = MAX_UNCACHED_SAVED_ROWS,
2161 "more saved records than this instance will hold in one response; the \
2162 rest are not reachable from here"
2163 );
2164 }
2165 }
2166
2167 let total_cached =
2183 store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2184 let uncached_len = uncached.len();
2185 let total = total_cached + uncached_len as i64;
2186 let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2191 let offset = (page - 1) * ENTRIES_PER_PAGE;
2192 let source = store::list_entries(
2195 pool,
2196 &did,
2197 list_view,
2198 scope_ids.as_deref(),
2199 ENTRIES_PER_PAGE,
2200 offset,
2201 )
2202 .await?;
2203 let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2223 let cached_here = cached_allotment.min(source.len());
2224 let source = if uncached_len == 0 {
2229 &source[..]
2230 } else {
2231 &source[..cached_here]
2232 };
2233 let uncached_page: Vec<EntryRow> = {
2234 let skip = (offset - total_cached).max(0) as usize;
2235 let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2236 uncached.into_iter().skip(skip).take(take).collect()
2237 };
2238 let uncached_total = uncached_len as i64;
2241
2242 let entry_scope_qs = {
2244 let mut parts = Vec::new();
2245 if let Some(f) = q.feed.as_deref() {
2246 parts.push(format!("feed={}", qenc(f)));
2247 }
2248 if let Some(f) = q.folder.as_deref() {
2249 parts.push(format!("folder={}", qenc(f)));
2250 }
2251 if view != "unread" {
2252 parts.push(format!("view={}", qenc(&view)));
2253 }
2254 parts.join("&")
2255 };
2256 let entries: Vec<EntryRow> = source
2257 .iter()
2258 .map(|e| EntryRow {
2259 id: e.id,
2260 title: e
2261 .title
2262 .clone()
2263 .filter(|t| !t.trim().is_empty())
2264 .unwrap_or_else(|| "(untitled)".to_string()),
2265 feed_title: feed_title_by_id(e.feed_id),
2266 published: display_date(e.published.as_deref()),
2267 read: e.read,
2272 starred: e.starred,
2273 link: SafeLink::entry(e.id, &entry_scope_qs),
2274 cached: true,
2275 rkey: String::new(),
2276 })
2277 .collect();
2278
2279 let mut entries = entries;
2281 entries.extend(uncached_page);
2282 let entries = entries;
2283
2284 let selected_feed = q.feed.as_deref();
2285 let selected_folder = q.folder.as_deref();
2286
2287 let (folder_views, loose_feeds, _folder_options) =
2289 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2290
2291 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2293 let name = subs
2294 .iter()
2295 .find(|s| s.sub.url == feed_url)
2296 .map(|s| {
2297 display_title(
2298 s.sub
2299 .title
2300 .as_deref()
2301 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2302 &s.sub.url,
2303 )
2304 })
2305 .unwrap_or_else(|| display_title(None, feed_url));
2306 (name, format!("feed={}", qenc(feed_url)))
2307 } else if let Some(folder_uri) = selected_folder {
2308 let name = folder_views
2309 .iter()
2310 .find(|f| f.uri == folder_uri)
2311 .map(|f| f.name.clone())
2312 .unwrap_or_else(|| "Folder".to_string());
2313 (name, format!("folder={}", qenc(folder_uri)))
2314 } else {
2315 let h = match view.as_str() {
2316 "all" => "All",
2317 "starred" => "Starred",
2318 _ => "Unread",
2319 };
2320 (h.to_string(), String::new())
2321 };
2322
2323 let feed_scope = selected_feed.map(str::to_string);
2324 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2325
2326 let page_href = |n: i64| -> String {
2330 let mut parts = Vec::new();
2331 if !entry_scope_qs.is_empty() {
2332 parts.push(entry_scope_qs.clone());
2333 }
2334 if n > 1 {
2335 parts.push(format!("page={n}"));
2336 }
2337 if parts.is_empty() {
2338 "/".to_string()
2339 } else {
2340 format!("/?{}", parts.join("&"))
2341 }
2342 };
2343 let prev_href = (page > 1).then(|| page_href(page - 1));
2344 let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2345
2346 let tmpl = IndexTemplate {
2347 version: VERSION,
2348 repo_url: REPO_URL,
2349 kofi_url: KOFI_URL,
2350 flash: q.flash.unwrap_or_default(),
2351 alert: alert.unwrap_or_default(),
2352 nav,
2353 entries,
2354 heading,
2355 feed_scope,
2356 total,
2357 uncached_total,
2360 page,
2361 page_count: page_count_for(total),
2362 prev_href,
2363 next_href,
2364 };
2365 Ok(render(&tmpl))
2366}
2367
2368#[derive(Debug, Deserialize, Default)]
2370struct ManageQuery {
2371 #[serde(default)]
2372 flash: Option<String>,
2373}
2374
2375async fn manage(
2380 State(state): State<AppState>,
2381 headers: HeaderMap,
2382 Query(q): Query<ManageQuery>,
2383) -> Result<Response, WebError> {
2384 let user = match current_session(&state, &headers).await {
2385 Some(u) => u,
2386 None => return Ok(Redirect::to("/login").into_response()),
2387 };
2388 let did = user.did.clone();
2389
2390 let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2391 let (folder_views, loose_feeds, folder_options) =
2392 build_sidebar(&state, &did, &subs, None, None).await;
2393
2394 let nav = build_nav(
2396 &user,
2397 "unread",
2398 String::new(),
2399 folder_views.iter().map(clone_folder_view).collect(),
2400 loose_feeds.iter().map(clone_feed_view).collect(),
2401 true,
2402 );
2403
2404 let tmpl = ManageTemplate {
2405 version: VERSION,
2406 repo_url: REPO_URL,
2407 kofi_url: KOFI_URL,
2408 flash: q.flash.unwrap_or_default(),
2409 alert: alert.unwrap_or_default(),
2410 nav,
2411 folder_options,
2412 folders: folder_views,
2413 loose_feeds,
2414 };
2415 Ok(render(&tmpl))
2416}
2417
2418fn clone_feed_view(f: &FeedView) -> FeedView {
2421 FeedView {
2422 rkey: f.rkey.clone(),
2423 url: f.url.clone(),
2424 title: f.title.clone(),
2425 unread: f.unread,
2426 selected: f.selected,
2427 folder: f.folder.clone(),
2428 }
2429}
2430
2431fn clone_folder_view(f: &FolderView) -> FolderView {
2432 FolderView {
2433 rkey: f.rkey.clone(),
2434 uri: f.uri.clone(),
2435 name: f.name.clone(),
2436 feeds: f.feeds.iter().map(clone_feed_view).collect(),
2437 selected: f.selected,
2438 }
2439}
2440
2441fn scope_urls_for(
2446 subs: &[ResolvedSub],
2447 feed: Option<&str>,
2448 folder: Option<&str>,
2449) -> Option<Vec<String>> {
2450 if let Some(feed_url) = feed {
2451 Some(vec![feed_url.to_string()])
2452 } else {
2453 folder.map(|folder_uri| {
2454 subs.iter()
2455 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2456 .map(|s| s.sub.url.clone())
2457 .collect()
2458 })
2459 }
2460}
2461
2462fn folder_uri(did: &str, rkey: &str) -> String {
2464 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2465}
2466
2467async fn build_sidebar(
2471 state: &AppState,
2472 did: &str,
2473 subs: &[ResolvedSub],
2474 selected_feed: Option<&str>,
2475 selected_folder: Option<&str>,
2476) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2477 let pool = &state.db;
2478 let unread_counts = store::unread_counts_by_feed(pool, did)
2483 .await
2484 .unwrap_or_else(|err| {
2485 warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2486 Default::default()
2487 });
2488 let folders = state
2489 .repo()
2490 .list_folders_sorted(did)
2491 .await
2492 .unwrap_or_default();
2493
2494 let unread_count = |feed_id: Option<i64>| -> i64 {
2495 feed_id
2496 .and_then(|id| unread_counts.get(&id).copied())
2497 .unwrap_or(0)
2498 };
2499 let mk_feed_view = |s: &ResolvedSub| FeedView {
2500 rkey: s.rkey.clone(),
2501 url: s.sub.url.clone(),
2502 title: display_title(
2503 s.sub
2504 .title
2505 .as_deref()
2506 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2507 &s.sub.url,
2508 ),
2509 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2510 selected: selected_feed == Some(s.sub.url.as_str()),
2511 folder: s.sub.folder.clone(),
2512 };
2513
2514 let mut folder_views = Vec::with_capacity(folders.len());
2515 for (rkey, folder) in &folders {
2516 let uri = folder_uri(did, rkey);
2517 let feeds: Vec<FeedView> = subs
2518 .iter()
2519 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2520 .map(mk_feed_view)
2521 .collect();
2522 folder_views.push(FolderView {
2523 rkey: rkey.clone(),
2524 uri: uri.clone(),
2525 name: folder.name.clone(),
2526 feeds,
2527 selected: selected_folder == Some(uri.as_str()),
2528 });
2529 }
2530
2531 let known_uris: std::collections::HashSet<String> =
2532 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2533 let loose_feeds: Vec<FeedView> = subs
2534 .iter()
2535 .filter(|s| {
2536 s.sub
2537 .folder
2538 .as_deref()
2539 .map(|f| !known_uris.contains(f))
2540 .unwrap_or(true)
2541 })
2542 .map(mk_feed_view)
2543 .collect();
2544
2545 let folder_options: Vec<FolderOption> = folders
2546 .iter()
2547 .map(|(rkey, folder)| FolderOption {
2548 name: folder.name.clone(),
2549 uri: folder_uri(did, rkey),
2550 })
2551 .collect();
2552
2553 (folder_views, loose_feeds, folder_options)
2554}
2555
2556fn build_nav(
2558 user: &CurrentUser,
2559 view: &str,
2560 scope_qs: String,
2561 folders: Vec<FolderView>,
2562 loose_feeds: Vec<FeedView>,
2563 manage_active: bool,
2564) -> Nav {
2565 Nav {
2566 handle: display_handle(user.handle.as_deref(), &user.did),
2567 avatar: avatar_initials(user.handle.as_deref(), &user.did),
2568 view: view.to_string(),
2569 scope_qs,
2570 folders,
2571 loose_feeds,
2572 manage_active,
2573 }
2574}
2575
2576#[derive(Debug, Deserialize, Default)]
2583struct EntryQuery {
2584 #[serde(default)]
2585 feed: Option<String>,
2586 #[serde(default)]
2587 folder: Option<String>,
2588 #[serde(default)]
2589 view: Option<String>,
2590}
2591
2592async fn entry_view(
2595 State(state): State<AppState>,
2596 headers: HeaderMap,
2597 Path(id): Path<i64>,
2598 Query(q): Query<EntryQuery>,
2599) -> Result<Response, WebError> {
2600 let user = match current_session(&state, &headers).await {
2601 Some(u) => u,
2602 None => return Ok(Redirect::to("/login").into_response()),
2603 };
2604 let did = user.did.clone();
2605 let pool = &state.db;
2606
2607 let subs = resolve_subscriptions(&state, &did).await;
2611
2612 let entry = match get_entry_by_id(pool, &did, id).await? {
2613 Some(e) => e,
2614 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2615 };
2616
2617 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2618
2619 let read = entry_is_read(pool, &did, id).await?;
2620 let starred = entry_is_starred(pool, &did, id).await?;
2621
2622 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2625
2626 let back_qs = scope_query(&q);
2627
2628 let (folder_views, loose_feeds, _) =
2629 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2630 let nav_view = match q.view.as_deref() {
2631 Some("all") => "all",
2632 Some("starred") => "starred",
2633 _ => "unread",
2634 };
2635 let nav = build_nav(
2636 &user,
2637 nav_view,
2638 back_qs.clone(),
2639 folder_views,
2640 loose_feeds,
2641 false,
2642 );
2643
2644 let tmpl = EntryTemplate {
2645 version: VERSION,
2646 repo_url: REPO_URL,
2647 kofi_url: KOFI_URL,
2648 nav,
2649 id: entry.id,
2650 title: entry
2651 .title
2652 .clone()
2653 .filter(|t| !t.trim().is_empty())
2654 .unwrap_or_else(|| "(untitled)".to_string()),
2655 feed_title,
2656 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2657 published: display_date(entry.published.as_deref()),
2658 url: entry.url.as_deref().and_then(SafeLink::external_opt),
2659 content_html: entry.content_html.clone(),
2660 read,
2661 starred,
2662 back_qs,
2663 prev_id,
2664 next_id,
2665 oob: false,
2666 };
2667 Ok(render(&tmpl))
2668}
2669
2670async fn neighbors_in_scope(
2673 state: &AppState,
2674 did: &str,
2675 q: &EntryQuery,
2676 current: i64,
2677) -> (Option<i64>, Option<i64>) {
2678 let idx_q = IndexQuery {
2679 feed: q.feed.clone(),
2680 folder: q.folder.clone(),
2681 view: q.view.clone(),
2682 page: None,
2684 flash: None,
2685 };
2686 let ids = list_entry_ids(state, did, &idx_q).await;
2687 let pos = ids.iter().position(|&x| x == current);
2688 match pos {
2689 Some(p) => {
2690 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2691 let next = ids.get(p + 1).copied();
2692 (prev, next)
2693 }
2694 None => (None, None),
2695 }
2696}
2697
2698async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2701 let pool = &state.db;
2702 let subs = resolve_subscriptions(state, did).await;
2703
2704 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2705
2706 store::list_entry_ids(
2712 pool,
2713 did,
2714 list_view_of(q.view.as_deref()),
2715 scoped_feed_ids(&subs, &scope_urls).as_deref(),
2716 PREV_NEXT_MAX,
2717 )
2718 .await
2719 .unwrap_or_else(|err| {
2720 warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2721 Vec::new()
2722 })
2723}
2724
2725fn list_view_of(view: Option<&str>) -> store::ListView {
2728 match view {
2729 Some("all") => store::ListView::All,
2730 Some("starred") => store::ListView::Starred,
2731 _ => store::ListView::Unread,
2732 }
2733}
2734
2735fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2741 let urls = scope_urls.as_ref()?;
2742 Some(
2743 subs.iter()
2744 .filter(|s| urls.contains(&s.sub.url))
2745 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2746 .collect(),
2747 )
2748}
2749
2750fn scope_query(q: &EntryQuery) -> String {
2752 let mut parts = Vec::new();
2753 if let Some(f) = q.feed.as_deref() {
2754 parts.push(format!("feed={}", qenc(f)));
2755 }
2756 if let Some(f) = q.folder.as_deref() {
2757 parts.push(format!("folder={}", qenc(f)));
2758 }
2759 if let Some(v) = q.view.as_deref() {
2760 if v != "unread" {
2761 parts.push(format!("view={}", qenc(v)));
2762 }
2763 }
2764 parts.join("&")
2765}
2766
2767#[derive(Debug, Deserialize)]
2773struct ReadForm {
2774 #[serde(default)]
2775 read: Option<String>,
2776}
2777
2778async fn mark_read(
2780 State(state): State<AppState>,
2781 Path(id): Path<i64>,
2782 headers: HeaderMap,
2783 Form(form): Form<ReadForm>,
2784) -> Result<Response, WebError> {
2785 let did = match current_did(&state, &headers).await {
2786 Some(d) => d,
2787 None => return Ok(Redirect::to("/login").into_response()),
2788 };
2789 let pool = &state.db;
2790
2791 let read = matches!(
2792 form.read.as_deref(),
2793 Some("true") | Some("1") | Some("on") | None
2794 );
2795
2796 resolve_subscriptions(&state, &did).await;
2801 if !store::mark_read(pool, &did, id, read).await? {
2802 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2803 }
2804
2805 if !is_htmx(&headers) {
2806 return Ok(Redirect::to("/").into_response());
2807 }
2808
2809 if is_reader_request(&headers) {
2813 let starred = entry_is_starred(pool, &did, id).await?;
2814 return Ok(render(&EntryActionBarTemplate {
2815 id,
2816 read,
2817 starred,
2818 oob: true,
2819 }));
2820 }
2821
2822 let row = build_entry_row(pool, &did, id, Some(read)).await?;
2823 match row {
2824 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2825 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2826 }
2827}
2828
2829#[derive(Debug, Deserialize)]
2835struct StarForm {
2836 #[serde(default)]
2837 starred: Option<String>,
2838}
2839
2840async fn toggle_star(
2846 State(state): State<AppState>,
2847 Path(id): Path<i64>,
2848 headers: HeaderMap,
2849 Form(form): Form<StarForm>,
2850) -> Result<Response, WebError> {
2851 let did = match current_did(&state, &headers).await {
2852 Some(d) => d,
2853 None => return Ok(Redirect::to("/login").into_response()),
2854 };
2855 let pool = &state.db;
2856
2857 let starred = matches!(
2858 form.starred.as_deref(),
2859 Some("true") | Some("1") | Some("on") | None
2860 );
2861
2862 resolve_subscriptions(&state, &did).await;
2866 if !store::mark_starred(pool, &did, id, starred).await? {
2867 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2868 }
2869
2870 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2873 let entry_url = entry.url.clone().unwrap_or_default();
2874 if !entry_url.is_empty() {
2875 if starred {
2876 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2877 saved.title = entry.title.clone();
2878 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2879 saved.entry_id = Some(entry.guid.clone());
2880 match state.repo().add_saved(&did, &saved).await {
2881 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2882 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2883 }
2884 } else {
2885 match state.repo().list_saved(&did).await {
2887 Ok(records) => {
2888 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2889 if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2890 warn!(%err, %did, %rkey, "PDS saved delete failed");
2891 }
2892 }
2893 }
2894 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2895 }
2896 }
2897 }
2898 }
2899
2900 if !is_htmx(&headers) {
2901 return Ok(Redirect::to("/").into_response());
2902 }
2903
2904 if is_reader_request(&headers) {
2906 let read = entry_is_read(pool, &did, id).await?;
2907 return Ok(render(&EntryActionBarTemplate {
2908 id,
2909 read,
2910 starred,
2911 oob: true,
2912 }));
2913 }
2914
2915 let row = build_entry_row(pool, &did, id, None).await?;
2916 match row {
2917 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2918 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2919 }
2920}
2921
2922async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2924 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2925 .bind(feed_id)
2926 .fetch_optional(pool)
2927 .await
2928 .ok()
2929 .flatten()
2930}
2931
2932#[derive(Debug, Deserialize, Default)]
2939struct ReadAllQuery {
2940 #[serde(default)]
2941 feed: Option<String>,
2942}
2943
2944async fn mark_all_read(
2947 State(state): State<AppState>,
2948 headers: HeaderMap,
2949 Query(q): Query<ReadAllQuery>,
2950) -> Result<Response, WebError> {
2951 let did = match current_did(&state, &headers).await {
2952 Some(d) => d,
2953 None => return Ok(Redirect::to("/login").into_response()),
2954 };
2955 let pool = &state.db;
2956
2957 resolve_subscriptions(&state, &did).await;
2960
2961 if let Some(feed_url) = q.feed.as_deref() {
2962 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2963 store::mark_feed_read(pool, &did, feed.id, true).await?;
2964 }
2965 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2966 }
2967
2968 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2973 store::mark_feed_read(pool, &did, feed_id, true).await?;
2974 }
2975 Ok(Redirect::to("/").into_response())
2976}
2977
2978const UNSUPPORTED_FEED_URL_REFUSAL: &str =
2988 "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
2989
2990const EXPORT_INCOMPLETE_REFUSAL: &str =
2997 "Could not read your subscriptions in full, so nothing was exported. Your \
2998 feeds are unchanged — try again, and if it keeps failing the list may be \
2999 larger than this reader can page through.";
3000
3001const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3007 FeatherReader stores your subscriptions in your public PDS, so it supports public \
3008 feeds for now — private-feed support arrives when atproto's private data \
3009 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3010
3011#[derive(Debug, Deserialize)]
3013struct SubscribeForm {
3014 url: String,
3015 #[serde(default)]
3017 folder: Option<String>,
3018}
3019
3020async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3030 let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3031 let canonical = format!(
3032 "{}{}",
3033 crate::atproto::AT_URI_PREFIX,
3034 &input[crate::atproto::AT_URI_PREFIX.len()..]
3035 );
3036 let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3037 if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3038 return Err(unsupported());
3039 }
3040 let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3041 uri.authority.clone()
3042 } else {
3043 let handle =
3044 crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3049 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3050 .await
3051 .map_err(|err| {
3052 warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3053 format!("Couldn't resolve the handle {} to an account.", uri.authority)
3054 })?
3055 };
3056 let url = format!(
3057 "{}{did}/{}/{}",
3058 crate::atproto::AT_URI_PREFIX,
3059 uri.collection,
3060 uri.rkey
3061 );
3062 if !feed::is_storable_feed_url(&url, true) {
3063 return Err(unsupported());
3064 }
3065 Ok(url)
3066}
3067
3068async fn add_subscription(
3070 State(state): State<AppState>,
3071 headers: HeaderMap,
3072 Form(form): Form<SubscribeForm>,
3073) -> Result<Response, WebError> {
3074 let did = match current_did(&state, &headers).await {
3075 Some(d) => d,
3076 None => return Ok(Redirect::to("/login").into_response()),
3077 };
3078 let pool = &state.db;
3079 let input = form.url.trim().to_string();
3080 if input.is_empty() {
3081 return Ok(Redirect::to("/").into_response());
3082 }
3083
3084 let cap = state.config.max_subs_per_did;
3088 if cap > 0 {
3089 match store::count_subscriptions_for_did(pool, &did).await {
3090 Ok(n) if n >= cap => {
3091 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3092 return Ok(Redirect::to(&format!(
3093 "/?flash={}",
3094 qenc(&format!(
3095 "Subscription limit reached ({cap}). Remove a feed before adding another."
3096 ))
3097 ))
3098 .into_response());
3099 }
3100 Ok(_) => {}
3101 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3102 }
3103 }
3104
3105 let is_at_uri = input
3110 .get(..crate::atproto::AT_URI_PREFIX.len())
3111 .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3112 let publication_url = if is_at_uri {
3113 if !state.config.standard_site {
3114 info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3115 return Ok(
3116 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3117 .into_response(),
3118 );
3119 }
3120 match publication_url_from_paste(&state, &input).await {
3121 Ok(url) => Some(url),
3122 Err(flash) => {
3123 info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3124 return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3125 }
3126 }
3127 } else {
3128 None
3129 };
3130
3131 if let feed::FeedPrivacy::Private(reason) =
3132 feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3133 {
3134 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3135 return Ok(
3136 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3137 );
3138 }
3139
3140 let resolved = match publication_url {
3141 Some(url) => Ok(url),
3142 None => resolve_feed_url(&state.config, &input).await,
3143 };
3144 let feed_url = match resolved {
3145 Ok(u) => u,
3146 Err(err) => {
3147 warn!(%err, url = %input, "could not resolve a feed from the given URL");
3148 return Ok(Redirect::to(&format!(
3149 "/?flash={}",
3150 qenc("Couldn't find a feed at that URL")
3151 ))
3152 .into_response());
3153 }
3154 };
3155
3156 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3160 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3161 return Ok(
3162 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3163 );
3164 }
3165
3166 if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3171 info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3172 return Ok(
3173 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3174 .into_response(),
3175 );
3176 }
3177
3178 let feeds_cap = state.config.max_feeds_global;
3182 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3183 match store::count_feeds(pool).await {
3184 Ok(n) if n >= feeds_cap => {
3185 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3186 return Ok(Redirect::to(&format!(
3187 "/?flash={}",
3188 qenc(
3189 "This instance is at its feed capacity right now. Please try again later."
3190 )
3191 ))
3192 .into_response());
3193 }
3194 Ok(_) => {}
3195 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3196 }
3197 }
3198
3199 store::upsert_feed(
3200 pool,
3201 &store::NewFeed {
3202 url: feed_url.clone(),
3203 ..Default::default()
3204 },
3205 )
3206 .await?;
3207
3208 if let Ok(client) = feed::build_client() {
3209 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3210 match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3211 Ok(outcome) => {
3212 info!(feed = %feed_url, ?outcome, "polled new subscription");
3213 feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3217 }
3218 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3219 }
3220 }
3221 }
3222
3223 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3224 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3225 sub.title = feed_row.title.clone();
3226 sub.site_url = feed_row.site_url.clone();
3227 }
3228 sub.folder = form
3229 .folder
3230 .map(|f| f.trim().to_string())
3231 .filter(|f| !f.is_empty());
3232
3233 match state.repo().add_subscription(&did, &sub).await {
3234 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3235 Err(err) => {
3236 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3237 }
3238 }
3239
3240 Ok(Redirect::to("/").into_response())
3241}
3242
3243async fn delete_subscription(
3245 State(state): State<AppState>,
3246 headers: HeaderMap,
3247 Path(rkey): Path<String>,
3248) -> Result<Response, WebError> {
3249 let did = match current_did(&state, &headers).await {
3250 Some(d) => d,
3251 None => return Ok(Redirect::to("/login").into_response()),
3252 };
3253 match state.repo().remove_subscription(&did, &rkey).await {
3254 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3255 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3256 }
3257 Ok(Redirect::to("/").into_response())
3258}
3259
3260#[derive(Debug, Deserialize)]
3262struct RenameSubForm {
3263 url: String,
3264 #[serde(default)]
3265 title: Option<String>,
3266 #[serde(default)]
3267 site_url: Option<String>,
3268 #[serde(default)]
3269 folder: Option<String>,
3270}
3271
3272async fn rename_subscription(
3275 State(state): State<AppState>,
3276 headers: HeaderMap,
3277 Path(rkey): Path<String>,
3278 Form(form): Form<RenameSubForm>,
3279) -> Result<Response, WebError> {
3280 let did = match current_did(&state, &headers).await {
3281 Some(d) => d,
3282 None => return Ok(Redirect::to("/login").into_response()),
3283 };
3284 let feed_url = form.url.trim().to_string();
3285
3286 if feed_url.is_empty() {
3290 return Ok(Redirect::to("/").into_response());
3291 }
3292
3293 let existing = match state.repo().list_subscriptions_sorted(&did).await {
3318 Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3319 Err(err) => {
3320 warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3321 return Ok(Redirect::to(&format!(
3322 "/?flash={}",
3323 qenc("Could not reach your PDS — nothing was renamed or moved.")
3324 ))
3325 .into_response());
3326 }
3327 };
3328 let Some(existing) = existing else {
3329 warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3333 return Ok(Redirect::to(&format!(
3334 "/?flash={}",
3335 qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3336 ))
3337 .into_response());
3338 };
3339
3340 let url_changed = existing.url.trim() != feed_url;
3359
3360 let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3373 if url_changed && !storable {
3374 info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3375 return Ok(
3376 Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3377 .into_response(),
3378 );
3379 }
3380
3381 if url_changed {
3387 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3388 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3389 return Ok(
3390 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3391 );
3392 }
3393 }
3394
3395 let feeds_cap = state.config.max_feeds_global;
3400 if url_changed
3401 && feeds_cap > 0
3402 && store::get_feed_by_url(&state.db, &feed_url)
3403 .await?
3404 .is_none()
3405 {
3406 match store::count_feeds(&state.db).await {
3407 Ok(n) if n >= feeds_cap => {
3408 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3409 return Ok(Redirect::to(&format!(
3410 "/?flash={}",
3411 qenc(
3412 "This instance is at its feed capacity right now. Please try again later."
3413 )
3414 ))
3415 .into_response());
3416 }
3417 Ok(_) => {}
3418 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3419 }
3420 }
3421
3422 let mut sub = existing;
3423 sub.url = feed_url;
3424 sub.title = form
3425 .title
3426 .map(|t| t.trim().to_string())
3427 .filter(|t| !t.is_empty());
3428 sub.folder = form
3429 .folder
3430 .map(|f| f.trim().to_string())
3431 .filter(|f| !f.is_empty());
3432 match form
3440 .site_url
3441 .map(|t| t.trim().to_string())
3442 .filter(|t| !t.is_empty())
3443 {
3444 Some(site) => sub.site_url = Some(site),
3445 None if url_changed => sub.site_url = None,
3446 None => {}
3447 }
3448 if url_changed {
3449 sub.fetch_hint = None;
3450 }
3451
3452 let cache_write =
3467 storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3468 if !cache_write {
3469 info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3470 } else if let Err(err) = store::upsert_feed(
3471 &state.db,
3472 &store::NewFeed {
3473 url: sub.url.clone(),
3474 title: sub.title.clone(),
3475 site_url: sub.site_url.clone(),
3476 ..Default::default()
3477 },
3478 )
3479 .await
3480 {
3481 warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3484 }
3485
3486 match state.repo().update_subscription(&did, &rkey, &sub).await {
3494 Ok(res) => {
3495 info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3496 Ok(Redirect::to("/").into_response())
3497 }
3498 Err(err) => {
3499 warn!(%err, %did, %rkey, "PDS subscription update failed");
3500 Ok(Redirect::to(&format!(
3501 "/?flash={}",
3502 qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3503 ))
3504 .into_response())
3505 }
3506 }
3507}
3508
3509#[derive(Debug, Deserialize)]
3515struct FolderForm {
3516 name: String,
3517}
3518
3519async fn create_folder(
3521 State(state): State<AppState>,
3522 headers: HeaderMap,
3523 Form(form): Form<FolderForm>,
3524) -> Result<Response, WebError> {
3525 let did = match current_did(&state, &headers).await {
3526 Some(d) => d,
3527 None => return Ok(Redirect::to("/login").into_response()),
3528 };
3529 let name = form.name.trim();
3530 if name.is_empty() {
3531 return Ok(Redirect::to("/").into_response());
3532 }
3533 let folder = Folder::new(name.to_string(), now_rfc3339());
3534 match state.repo().add_folder(&did, &folder).await {
3535 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3536 Err(err) => warn!(%err, %did, "PDS folder create failed"),
3537 }
3538 Ok(Redirect::to("/").into_response())
3539}
3540
3541async fn rename_folder(
3543 State(state): State<AppState>,
3544 headers: HeaderMap,
3545 Path(rkey): Path<String>,
3546 Form(form): Form<FolderForm>,
3547) -> Result<Response, WebError> {
3548 let did = match current_did(&state, &headers).await {
3549 Some(d) => d,
3550 None => return Ok(Redirect::to("/login").into_response()),
3551 };
3552 let name = form.name.trim();
3553 if name.is_empty() {
3554 return Ok(Redirect::to("/").into_response());
3555 }
3556 let folder = Folder::new(name.to_string(), now_rfc3339());
3557 match state.repo().rename_folder(&did, &rkey, &folder).await {
3558 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3559 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3560 }
3561 Ok(Redirect::to("/").into_response())
3562}
3563
3564async fn delete_folder(
3567 State(state): State<AppState>,
3568 headers: HeaderMap,
3569 Path(rkey): Path<String>,
3570) -> Result<Response, WebError> {
3571 let did = match current_did(&state, &headers).await {
3572 Some(d) => d,
3573 None => return Ok(Redirect::to("/login").into_response()),
3574 };
3575 match state.repo().remove_folder(&did, &rkey).await {
3576 Ok(()) => info!(%did, %rkey, "deleted folder record"),
3577 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3578 }
3579 Ok(Redirect::to("/").into_response())
3580}
3581
3582async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3586 let parsed =
3587 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3588
3589 let client = feed::build_client()?;
3590 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3594 let final_url = resp.url().clone();
3595 let content_type = resp
3596 .headers()
3597 .get(axum::http::header::CONTENT_TYPE)
3598 .and_then(|v| v.to_str().ok())
3599 .unwrap_or("")
3600 .to_ascii_lowercase();
3601 let raw = crate::net::read_capped(resp).await?;
3604 let body = String::from_utf8_lossy(&raw).into_owned();
3605
3606 let looks_like_feed = content_type.contains("xml")
3607 || content_type.contains("rss")
3608 || content_type.contains("atom")
3609 || content_type.contains("application/feed+json")
3610 || {
3611 let head = body.trim_start();
3612 head.starts_with("<?xml")
3613 || head.starts_with("<rss")
3614 || head.starts_with("<feed")
3615 || head.contains("<rss")
3616 || head.contains("<feed")
3617 };
3618 if looks_like_feed {
3619 return Ok(final_url.to_string());
3620 }
3621
3622 match feed::discover_feed(&body, Some(&final_url)) {
3623 Some(u) => Ok(u.to_string()),
3624 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3625 }
3626}
3627
3628#[derive(Debug, Deserialize, Default)]
3634struct LoginQuery {
3635 #[serde(default)]
3636 handle: Option<String>,
3637 #[serde(default)]
3638 error: Option<String>,
3639 #[serde(default)]
3640 flash: Option<String>,
3641}
3642
3643async fn login_form(
3651 State(state): State<AppState>,
3652 headers: HeaderMap,
3653 Query(q): Query<LoginQuery>,
3654) -> Response {
3655 if let Some(handle) = q
3656 .handle
3657 .map(|h| h.trim().to_string())
3658 .filter(|h| !h.is_empty())
3659 {
3660 if !may_start_oauth(&state, &headers, &handle).await {
3661 return Redirect::to("/beta/redeem").into_response();
3662 }
3663 return start_oauth(&state, &handle).await;
3664 }
3665 render(&LoginTemplate {
3666 repo_url: REPO_URL,
3667 error: q.error.unwrap_or_default(),
3668 flash: q.flash.unwrap_or_default(),
3669 })
3670}
3671
3672async fn login_submit(
3675 State(state): State<AppState>,
3676 headers: HeaderMap,
3677 Form(form): Form<LoginForm>,
3678) -> Response {
3679 let handle = form.handle.trim();
3680 if handle.is_empty() {
3681 return login_error("Enter your atproto handle.");
3682 }
3683 if !may_start_oauth(&state, &headers, handle).await {
3684 return Redirect::to("/beta/redeem").into_response();
3685 }
3686 start_oauth(&state, handle).await
3687}
3688
3689async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3707 may_start_oauth_with(state, headers, handle, |h| async move {
3711 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3712 .await
3713 .ok()
3714 })
3715 .await
3716}
3717
3718async fn may_start_oauth_with<F, Fut>(
3724 state: &AppState,
3725 headers: &HeaderMap,
3726 handle: &str,
3727 resolve: F,
3728) -> bool
3729where
3730 F: FnOnce(String) -> Fut,
3731 Fut: std::future::Future<Output = Option<String>>,
3732{
3733 if let Some(did) = current_did(state, headers).await {
3735 if store::has_beta_access(&state.db, &did)
3736 .await
3737 .unwrap_or(false)
3738 {
3739 return true;
3740 }
3741 }
3742 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3744 return true;
3745 }
3746 match resolve(handle.to_string()).await {
3750 Some(did) => store::has_beta_access(&state.db, &did)
3751 .await
3752 .unwrap_or(false),
3753 None => {
3754 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3755 false
3756 }
3757 }
3758}
3759
3760async fn start_oauth(state: &AppState, handle: &str) -> Response {
3782 match state.config.repo_backend {
3783 crate::metrics::Backend::Sidecar => {
3784 let url = state.sidecar.login_url(handle, None);
3785 info!(%handle, "redirecting to OAuth sidecar login");
3786 Redirect::to(&url).into_response()
3787 }
3788 crate::metrics::Backend::Rust => {
3789 let Some(runtime) = state.oauth.as_deref() else {
3790 warn!("the rust backend is live but its OAuth runtime is absent");
3791 return login_error("Login is not available right now.");
3792 };
3793 match crate::oauth::login::start(
3794 runtime,
3795 &state.http,
3796 &state.db,
3797 handle,
3798 crate::store::now_unix(),
3799 )
3800 .await
3801 {
3802 Ok(started) => {
3803 info!(%handle, "pushed authorization request; redirecting to the PDS");
3804 let mut resp = Redirect::to(&started.authorize_url).into_response();
3805 set_cookie(
3806 &mut resp,
3807 &cookie::sign_value(
3808 OAUTH_BINDING_COOKIE,
3809 &started.binding_token,
3810 &state.config.cookie_secret,
3811 OAUTH_BINDING_MAX_AGE_SECS,
3812 ),
3813 );
3814 resp
3815 }
3816 Err(err) => {
3817 warn!(%err, %handle, "could not start the OAuth login");
3820 login_error("Could not start login for that handle.")
3821 }
3822 }
3823 }
3824 }
3825}
3826
3827fn clear_binding_cookie(resp: &mut Response) {
3831 set_cookie(
3832 resp,
3833 &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3834 );
3835}
3836
3837#[derive(Debug, Deserialize)]
3839struct LoginForm {
3840 handle: String,
3841}
3842
3843#[derive(Debug, Deserialize, Default)]
3852struct CallbackQuery {
3853 #[serde(default)]
3855 session_id: Option<String>,
3856 #[serde(default)]
3858 code: Option<String>,
3859 #[serde(default)]
3860 state: Option<String>,
3861 #[serde(default)]
3862 iss: Option<String>,
3863 #[serde(default)]
3866 response: Option<String>,
3867 #[serde(default)]
3868 error: Option<String>,
3869 #[serde(default)]
3870 error_description: Option<String>,
3871}
3872
3873async fn oauth_callback(
3880 State(state): State<AppState>,
3881 headers: HeaderMap,
3882 Query(q): Query<CallbackQuery>,
3883) -> Response {
3884 let sidecar_shape =
3914 q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3915 let sidecar_handoff = sidecar_shape
3916 && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3917 if let Some(err) = q.error.clone() {
3918 let slug = crate::oauth::flow::known_error_slug(&err);
3934 warn!(
3935 error = slug,
3936 desc_len = q.error_description.as_deref().map_or(0, str::len),
3937 "OAuth callback returned an error"
3938 );
3939 if sidecar_handoff || state.oauth.is_none() {
3940 return login_error(&format!("Login failed: {slug}"));
3941 }
3942 }
3945
3946 let session = if sidecar_handoff {
3949 let session_id = q.session_id.clone().unwrap_or_default();
3950 match state.sidecar.resolve_session(&session_id).await {
3951 Ok(Some(s)) => s,
3952 Ok(None) => {
3953 warn!("OAuth callback session_id did not resolve (expired/unknown)");
3954 return login_error("Login session expired — please try again.");
3955 }
3956 Err(err) => {
3957 warn!(%err, "failed to resolve OAuth session via the sidecar");
3958 return login_error("Login failed talking to the auth service.");
3959 }
3960 }
3961 } else {
3962 let Some(runtime) = state.oauth.as_deref() else {
3963 warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3964 return login_error("Login failed: this login could not be completed.");
3965 };
3966 let params = crate::oauth::flow::CallbackParams {
3967 code: q.code.clone(),
3968 state: q.state.clone(),
3969 iss: q.iss.clone(),
3970 error: q.error.clone(),
3974 error_description: q.error_description.clone(),
3975 response: q.response.clone(),
3976 };
3977 let binding =
3978 cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
3979 match crate::oauth::login::complete(
3980 runtime,
3981 &state.http,
3982 &state.db,
3983 ¶ms,
3984 binding.as_deref(),
3985 crate::store::now_unix(),
3986 )
3987 .await
3988 {
3989 Ok(done) => crate::atproto::SidecarSession {
3990 did: done.did,
3991 handle: done.handle,
3992 },
3993 Err(err) => {
3994 warn!(%err, "could not complete the OAuth callback");
3997 let mut resp = login_error("Login failed — please try again.");
3998 clear_binding_cookie(&mut resp);
3999 return resp;
4000 }
4001 }
4002 };
4003
4004 let mut clear_invite = false;
4007 if !store::has_beta_access(&state.db, &session.did)
4008 .await
4009 .unwrap_or(false)
4010 {
4011 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4013 Some(c) => c,
4014 None => {
4015 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4016 return Redirect::to("/beta/redeem").into_response();
4017 }
4018 };
4019 match store::redeem_code(
4020 &state.db,
4021 &code,
4022 &session.did,
4023 session.handle.as_deref(),
4024 state.config.beta_cap,
4025 )
4026 .await
4027 {
4028 Ok(Ok(())) => {
4029 clear_invite = true;
4030 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4031 }
4032 Ok(Err(policy)) => {
4033 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4034 let mut resp = redeem_bounce(&policy).into_response();
4035 clear_invite_cookie(&mut resp);
4037 return resp;
4038 }
4039 Err(err) => {
4040 warn!(%err, did = %session.did, "invite redeem infra error at callback");
4041 return login_error("Login failed while confirming your invite.");
4042 }
4043 }
4044 }
4045
4046 let sid = state.sessions.create(Session {
4049 did: session.did.clone(),
4050 handle: session.handle.clone(),
4051 });
4052 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4053 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4054
4055 let mut resp = Redirect::to("/").into_response();
4056 set_cookie(&mut resp, &cookie);
4057 clear_binding_cookie(&mut resp);
4058 if clear_invite {
4059 clear_invite_cookie(&mut resp);
4060 }
4061 resp
4062}
4063
4064const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4082
4083async fn flush_before_revoke(state: &AppState, did: &str) {
4096 match tokio::time::timeout(
4097 SIGN_OUT_FLUSH_BUDGET,
4098 crate::readstate::flush_did(state, did),
4099 )
4100 .await
4101 {
4102 Ok(Ok(())) => {}
4103 Ok(Err(err)) => {
4104 warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4105 }
4106 Err(_) => warn!(
4107 %did,
4108 budget = ?SIGN_OUT_FLUSH_BUDGET,
4109 "sign-out: final read-state flush timed out; it will park until next sign-in"
4110 ),
4111 }
4112}
4113
4114async fn revoke_everywhere(state: &AppState, did: &str) {
4115 let sidecar_started = std::time::Instant::now();
4125 let sidecar_ok = match state.sidecar.revoke_session(did).await {
4126 Ok(res) => {
4127 info!(%did, revoked = res.revoked, "sidecar session revoked");
4128 true
4129 }
4130 Err(err) => {
4131 warn!(%did, %err, "sidecar revoke failed; continuing");
4132 false
4133 }
4134 };
4135 state.metrics.record(
4136 crate::metrics::Backend::Sidecar,
4137 "oauth_revoke",
4138 sidecar_started.elapsed().as_micros() as u64,
4139 sidecar_ok,
4140 );
4141
4142 if let Some(runtime) = state.oauth.as_deref() {
4143 let revoke_started = std::time::Instant::now();
4144 let outcome = crate::oauth::revoke::sign_out_discovering(
4145 runtime,
4146 &state.http,
4147 &state.db,
4148 did,
4149 crate::store::now_unix(),
4150 )
4151 .await;
4152 let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4163 state.metrics.record(
4164 crate::metrics::Backend::Rust,
4165 "oauth_revoke",
4166 revoke_started.elapsed().as_micros() as u64,
4167 revoke_ok,
4168 );
4169 match outcome {
4170 crate::oauth::revoke::Revocation::Revoked => {
4171 info!(%did, "rust OAuth session revoked at the PDS")
4172 }
4173 crate::oauth::revoke::Revocation::NoSession => {}
4174 crate::oauth::revoke::Revocation::Failed(reason) => {
4175 warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4176 }
4177 }
4178 }
4179}
4180
4181async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4191 if let Some(user) = current_session(&state, &headers).await {
4192 if let Some(sid) = user.sid {
4195 state.sessions.remove(&sid);
4196 flush_before_revoke(&state, &user.did).await;
4198 revoke_everywhere(&state, &user.did).await;
4199 }
4200 }
4201 let mut resp = Redirect::to("/login").into_response();
4202 set_cookie(
4203 &mut resp,
4204 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4205 );
4206 resp
4207}
4208
4209#[derive(Debug, Deserialize)]
4212struct DeleteAccountForm {
4213 #[serde(default)]
4214 confirm: String,
4215}
4216
4217const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4219
4220async fn account_delete(
4235 State(state): State<AppState>,
4236 headers: HeaderMap,
4237 Form(form): Form<DeleteAccountForm>,
4238) -> Result<Response, WebError> {
4239 let user = match current_session(&state, &headers).await {
4240 Some(u) => u,
4241 None => return Ok(Redirect::to("/login").into_response()),
4242 };
4243 let did = user.did.clone();
4244
4245 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4247 return Ok(Redirect::to(&format!(
4248 "/manage?flash={}",
4249 qenc("Type DELETE to confirm — nothing was deleted.")
4250 ))
4251 .into_response());
4252 }
4253
4254 let counts = store::purge_did_data(&state.db, &did).await?;
4256 info!(
4257 %did,
4258 total = counts.total(),
4259 entry_state = counts.entry_state,
4260 read_cursor = counts.read_cursor,
4261 sub_ref = counts.sub_ref,
4262 beta_access = counts.beta_access,
4263 invite_codes = counts.invite_codes,
4264 "account/delete: local rows purged"
4265 );
4266
4267 revoke_everywhere(&state, &did).await;
4270
4271 if let Some(sid) = user.sid {
4273 state.sessions.remove(&sid);
4274 }
4275 let mut resp = Redirect::to(&format!(
4276 "/login?flash={}",
4277 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4278 ))
4279 .into_response();
4280 set_cookie(
4281 &mut resp,
4282 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4283 );
4284 Ok(resp)
4285}
4286
4287fn login_error(msg: &str) -> Response {
4289 render(&LoginTemplate {
4290 repo_url: REPO_URL,
4291 error: msg.to_string(),
4292 flash: String::new(),
4293 })
4294}
4295
4296#[derive(Debug, Deserialize)]
4302struct RedeemForm {
4303 code: String,
4304}
4305
4306async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4309 let full = store::count_beta_access(&state.db)
4310 .await
4311 .map(|n| n >= state.config.beta_cap)
4312 .unwrap_or(false);
4313 render(&BetaRedeemTemplate {
4314 repo_url: REPO_URL,
4315 error: String::new(),
4316 capacity_full: full,
4317 })
4318}
4319
4320async fn beta_redeem_submit(
4330 State(state): State<AppState>,
4331 Form(form): Form<RedeemForm>,
4332) -> Response {
4333 let code = form.code.trim().to_uppercase();
4334 if code.is_empty() {
4335 return render(&BetaRedeemTemplate {
4336 repo_url: REPO_URL,
4337 error: "Enter your invite code.".to_string(),
4338 capacity_full: false,
4339 });
4340 }
4341
4342 match preflight_code(&state, &code).await {
4343 Ok(()) => {
4344 let cookie = sign_invite(&code, &state.config.cookie_secret);
4345 let mut resp = Redirect::to("/login").into_response();
4346 set_cookie(&mut resp, &cookie);
4347 info!("invite code preflight OK; reserving intent + redirecting to /login");
4348 resp
4349 }
4350 Err(policy) => {
4351 warn!(?policy, "invite code preflight rejected");
4352 redeem_bounce(&policy)
4353 }
4354 }
4355}
4356
4357async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4363 let count = match store::count_beta_access(&state.db).await {
4371 Ok(n) => n,
4372 Err(err) => {
4373 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4374 return Err(store::RedeemError::CapacityFull);
4375 }
4376 };
4377 if count >= state.config.beta_cap {
4378 return Err(store::RedeemError::CapacityFull);
4379 }
4380 let row = sqlx::query_as::<_, (String, i64)>(
4382 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4383 )
4384 .bind(code)
4385 .fetch_optional(&state.db)
4386 .await
4387 .ok()
4388 .flatten();
4389 let (status, expires_at) = match row {
4390 Some(r) => r,
4391 None => return Err(store::RedeemError::NotFound),
4392 };
4393 let now = chrono::Utc::now().timestamp();
4394 match status.as_str() {
4395 "active" if expires_at >= now => Ok(()),
4396 "active" => Err(store::RedeemError::Expired),
4397 "expired" => Err(store::RedeemError::Expired),
4398 _ => Err(store::RedeemError::AlreadyRedeemed),
4400 }
4401}
4402
4403fn redeem_bounce(policy: &store::RedeemError) -> Response {
4406 use store::RedeemError::*;
4407 let (msg, capacity_full) = match policy {
4408 NotFound => ("That invite code isn't valid.", false),
4409 Expired => ("That invite code has expired.", false),
4410 AlreadyRedeemed => ("That invite code has already been used.", false),
4411 CapacityFull => ("", true),
4412 };
4413 render(&BetaRedeemTemplate {
4414 repo_url: REPO_URL,
4415 error: msg.to_string(),
4416 capacity_full,
4417 })
4418}
4419
4420#[derive(Debug, Deserialize, Default)]
4422struct MintQuery {
4423 #[serde(default)]
4424 n: Option<u32>,
4425}
4426
4427async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4440 let Some(runtime) = state.oauth.as_deref() else {
4441 return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4443 };
4444 axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4445}
4446
4447async fn oauth_jwks(State(state): State<AppState>) -> Response {
4454 let Some(runtime) = state.oauth.as_deref() else {
4455 return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4456 };
4457 match runtime.client_key.as_ref() {
4458 Some(key) => match key.jwks_document() {
4459 Ok(doc) => axum::Json(doc).into_response(),
4460 Err(err) => {
4461 warn!(%err, "could not render the client JWKS");
4462 (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4463 }
4464 },
4465 None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4466 }
4467}
4468
4469const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4471
4472async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4481 let did = match current_did(&state, &headers).await {
4482 Some(d) => d,
4483 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4484 };
4485 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4486 warn!(%did, "admin metrics denied: not an admin-seed DID");
4487 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4488 }
4489
4490 if let Err(err) =
4495 crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4496 {
4497 warn!(%err, "could not flush repo timings before rendering");
4498 }
4499 let rows = match crate::metrics::persisted_rows(&state.db).await {
4500 Ok(rows) => rows,
4501 Err(err) => {
4502 warn!(%err, "could not read persisted repo timings");
4503 return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4504 }
4505 };
4506
4507 let parked = match crate::store::parked_readstate_dids(&state.db).await {
4513 Ok(n) => n.to_string(),
4514 Err(err) => {
4515 warn!(%err, "could not count parked read-state DIDs");
4516 "unknown".to_string()
4517 }
4518 };
4519 let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4526 Ok(f) => f,
4527 Err(err) => {
4528 warn!(%err, "could not list failing feeds");
4529 Vec::new()
4530 }
4531 };
4532 let mut failing_block = String::new();
4533 if !failing.is_empty() {
4534 failing_block.push_str("\nfailing feeds (worst first)\n");
4535 for f in &failing {
4536 failing_block.push_str(&format!(
4537 " {:>4}x {:<8} {}\n {}\n",
4538 f.consecutive_errors,
4539 f.kind.as_deref().unwrap_or("unknown"),
4540 f.url,
4541 f.detail.as_deref().unwrap_or("(no detail recorded)"),
4542 ));
4543 }
4544 }
4545
4546 let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4551 Ok(n) => n,
4552 Err(err) => {
4553 warn!(%err, "could not count unpollable feeds");
4554 -1
4555 }
4556 };
4557 let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4558
4559 let body = format!(
4560 "live backend: {}\nparked read-state DIDs: {}\n\
4561 feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4562 state.config.repo_backend.as_str(),
4563 parked,
4564 cached,
4565 state.config.max_feeds_global,
4566 unpollable,
4567 crate::metrics::render(&rows),
4568 failing_block,
4569 );
4570 (StatusCode::OK, body).into_response()
4571}
4572
4573async fn admin_mint_invites(
4577 State(state): State<AppState>,
4578 headers: HeaderMap,
4579 Query(q): Query<MintQuery>,
4580) -> Response {
4581 let did = match current_did(&state, &headers).await {
4584 Some(d) => d,
4585 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4586 };
4587 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4588 warn!(%did, "admin mint denied: not an admin-seed DID");
4589 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4590 }
4591
4592 let n = q.n.unwrap_or(1).clamp(1, 100);
4593 let mut codes = Vec::with_capacity(n as usize);
4594 for _ in 0..n {
4595 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4596 Ok(code) => codes.push(code),
4597 Err(err) => {
4598 warn!(%err, %did, "admin mint_code failed");
4599 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4600 }
4601 }
4602 }
4603 info!(%did, count = codes.len(), "admin minted invite codes");
4604 let mut body = codes.join("\n");
4605 body.push('\n');
4606 (StatusCode::OK, body).into_response()
4607}
4608
4609#[derive(Debug, Deserialize)]
4615struct ClaimQuery {
4616 t: Option<String>,
4618}
4619
4620async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4639 let token = match q.t {
4640 Some(t) if !t.is_empty() => t,
4641 _ => {
4642 warn!("claim link with no token");
4643 return redeem_bounce(&store::RedeemError::NotFound);
4644 }
4645 };
4646
4647 let code = match claim_token_code(&token, &state.config.cookie_secret) {
4650 Some(c) => c,
4651 None => {
4652 warn!("claim token invalid (bad signature / malformed)");
4653 return redeem_bounce(&store::RedeemError::NotFound);
4654 }
4655 };
4656
4657 match preflight_code(&state, &code).await {
4661 Ok(()) => {
4662 let cookie = sign_invite(&code, &state.config.cookie_secret);
4663 let mut resp = Redirect::to("/login").into_response();
4664 set_cookie(&mut resp, &cookie);
4665 info!("claim token preflight OK; reserving intent + redirecting to /login");
4666 resp
4667 }
4668 Err(policy) => {
4669 warn!(?policy, "claim token preflight rejected");
4670 redeem_bounce(&policy)
4671 }
4672 }
4673}
4674
4675#[derive(Debug, Default, Deserialize)]
4682struct BotClaimRequest {
4683 #[serde(default)]
4686 did: Option<String>,
4687 #[serde(default)]
4689 #[allow(dead_code)]
4690 handle: Option<String>,
4691}
4692
4693#[derive(Debug, serde::Serialize)]
4695struct BotClaimResponse {
4696 status: &'static str,
4702 code: String,
4706 token: String,
4709 url: String,
4712}
4713
4714async fn bot_mint_claim(
4742 State(state): State<AppState>,
4743 headers: HeaderMap,
4744 body: axum::body::Bytes,
4745) -> Response {
4746 let bot_secret = match state.config.bot_secret.as_deref() {
4748 Some(s) => s,
4749 None => {
4750 warn!(
4751 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4752 );
4753 return (
4754 StatusCode::SERVICE_UNAVAILABLE,
4755 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4756 )
4757 .into_response();
4758 }
4759 };
4760
4761 let presented = headers
4763 .get("x-bot-secret")
4764 .and_then(|v| v.to_str().ok())
4765 .unwrap_or("");
4766 if !bot_secret_matches(presented, bot_secret) {
4767 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4768 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4769 }
4770
4771 let req: BotClaimRequest = if body.is_empty() {
4774 BotClaimRequest::default()
4775 } else {
4776 match serde_json::from_slice(&body) {
4777 Ok(r) => r,
4778 Err(err) => {
4779 warn!(%err, "POST /bot/claims: bad JSON body");
4780 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4781 }
4782 }
4783 };
4784 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4785
4786 if let Some(did) = follower_did {
4788 match store::has_beta_access(&state.db, did).await {
4790 Ok(true) => {
4791 info!("bot mint: DID already holds beta access; already_seated");
4792 return bot_claim_json(BotClaimResponse {
4793 status: "already_seated",
4794 code: String::new(),
4795 token: String::new(),
4796 url: String::new(),
4797 });
4798 }
4799 Ok(false) => {}
4800 Err(err) => {
4801 warn!(%err, "bot mint: has_beta_access failed");
4803 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4804 }
4805 }
4806 match store::find_active_code_for_did(&state.db, did).await {
4809 Ok(Some(code)) => {
4810 info!("bot mint: existing outstanding claim for DID; returning same code");
4811 let token = sign_claim_token(&code, &state.config.cookie_secret);
4812 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4813 return bot_claim_json(BotClaimResponse {
4814 status: "existing",
4815 code,
4816 token,
4817 url,
4818 });
4819 }
4820 Ok(None) => {}
4821 Err(err) => {
4822 warn!(%err, "bot mint: find_active_code_for_did failed");
4823 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4824 }
4825 }
4826 }
4827
4828 let granted = match store::count_beta_access(&state.db).await {
4831 Ok(n) => n,
4832 Err(err) => {
4833 warn!(%err, "bot mint: count_beta_access failed; failing closed");
4834 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4835 }
4836 };
4837 let outstanding = match store::count_active_codes(&state.db).await {
4838 Ok(n) => n,
4839 Err(err) => {
4840 warn!(%err, "bot mint: count_active_codes failed; failing closed");
4841 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4842 }
4843 };
4844 if granted + outstanding >= state.config.beta_cap {
4845 info!(
4846 granted,
4847 outstanding,
4848 cap = state.config.beta_cap,
4849 "bot mint refused: at capacity"
4850 );
4851 return (
4852 StatusCode::CONFLICT,
4853 [(header::CONTENT_TYPE, "application/json")],
4854 "{\"error\":\"full\"}\n",
4855 )
4856 .into_response();
4857 }
4858
4859 let bot_did = state
4862 .config
4863 .admin_seed_dids()
4864 .first()
4865 .cloned()
4866 .unwrap_or_else(|| "did:bot:featherreader".to_string());
4867 let minted = match follower_did {
4868 Some(did) => {
4869 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4870 }
4871 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4872 };
4873 let code = match minted {
4874 Ok(c) => c,
4875 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4882 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4883 Ok(Some(code)) => {
4884 info!("bot mint: lost the mint race; returning the concurrently-minted code");
4885 let token = sign_claim_token(&code, &state.config.cookie_secret);
4886 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4887 return bot_claim_json(BotClaimResponse {
4888 status: "existing",
4889 code,
4890 token,
4891 url,
4892 });
4893 }
4894 Ok(None) => {
4898 warn!("bot mint: conflict but no active code found on recovery");
4899 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4900 }
4901 Err(err) => {
4902 warn!(%err, "bot mint: recovery lookup after conflict failed");
4903 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4904 }
4905 }
4906 }
4907 Err(err) => {
4908 warn!(%err, "bot mint_code failed");
4909 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4910 }
4911 };
4912 let token = sign_claim_token(&code, &state.config.cookie_secret);
4913 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4914 info!("bot minted a claim code + token");
4915
4916 bot_claim_json(BotClaimResponse {
4917 status: "minted",
4918 code,
4919 token,
4920 url,
4921 })
4922}
4923
4924fn bot_claim_json(resp: BotClaimResponse) -> Response {
4927 match serde_json::to_string(&resp) {
4928 Ok(body) => (
4929 StatusCode::OK,
4930 [(header::CONTENT_TYPE, "application/json")],
4931 body,
4932 )
4933 .into_response(),
4934 Err(err) => {
4935 warn!(%err, "serializing bot claim response failed");
4936 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4937 }
4938 }
4939}
4940
4941fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4946 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4947}
4948
4949fn sign_invite(code: &str, secret: &str) -> String {
4958 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4959}
4960
4961fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4966 cookie::verify_value(headers, INVITE_COOKIE, secret)
4967}
4968
4969const CLAIM_TOKEN_LABEL: &str = "claim-token";
4973
4974fn sign_claim_token(code: &str, secret: &str) -> String {
4986 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
4987}
4988
4989fn claim_token_code(token: &str, secret: &str) -> Option<String> {
4994 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
4995}
4996
4997fn clear_invite_cookie(resp: &mut Response) {
5000 set_cookie(
5001 resp,
5002 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5003 );
5004}
5005
5006async fn import_opml(
5019 State(state): State<AppState>,
5020 headers: HeaderMap,
5021 mut multipart: Multipart,
5022) -> Result<Response, WebError> {
5023 let did = match current_did(&state, &headers).await {
5024 Some(d) => d,
5025 None => return Ok(Redirect::to("/login").into_response()),
5026 };
5027 let pool = &state.db;
5028
5029 let mut opml_text = String::new();
5035 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5036 let name = field.name().unwrap_or("").to_string();
5037 if name == "opml" || name == "file" {
5038 let bytes = field.bytes().await.map_err(multipart_response)?;
5039 if !bytes.is_empty() {
5040 opml_text = String::from_utf8_lossy(&bytes).into_owned();
5041 if name == "file" {
5042 break;
5043 }
5044 }
5045 }
5046 }
5047
5048 let feeds =
5053 match opml::parse_opml(&opml_text) {
5054 Ok(feeds) => feeds,
5055 Err(err) => {
5056 warn!(%err, %did, "OPML import could not parse the uploaded file");
5057 return Ok(Redirect::to(&format!(
5058 "/?flash={}",
5059 qenc("That file could not be read as OPML. Export it again from your other reader?")
5060 ))
5061 .into_response());
5062 }
5063 };
5064 if feeds.is_empty() {
5065 info!(%did, "OPML import found no feeds");
5066 return Ok(
5067 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5068 .into_response(),
5069 );
5070 }
5071
5072 let now = now_rfc3339();
5075 let mut folder_uris: std::collections::HashMap<String, String> =
5076 std::collections::HashMap::new();
5077 if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5079 for (rkey, folder) in existing {
5080 folder_uris
5081 .entry(folder.name.clone())
5082 .or_insert_with(|| folder_uri(&did, &rkey));
5083 }
5084 }
5085 let mut wanted_folders: Vec<String> = feeds
5086 .iter()
5087 .filter_map(|f| f.folder.clone())
5088 .filter(|n| !n.is_empty())
5089 .collect();
5090 wanted_folders.sort();
5091 wanted_folders.dedup();
5092 for name in wanted_folders {
5093 if folder_uris.contains_key(&name) {
5094 continue;
5095 }
5096 let folder = Folder::new(name.clone(), now.clone());
5097 match state.repo().add_folder(&did, &folder).await {
5098 Ok(rkey) => {
5099 folder_uris.insert(name, folder_uri(&did, &rkey));
5100 }
5101 Err(err) => warn!(%err, %did, "OPML folder create failed"),
5102 }
5103 }
5104
5105 let sub_cap = state.config.max_subs_per_did;
5114 let mut headroom: Option<i64> = if sub_cap > 0 {
5115 let existing = store::count_subscriptions_for_did(pool, &did)
5116 .await
5117 .unwrap_or(0);
5118 Some((sub_cap - existing).max(0))
5119 } else {
5120 None
5121 };
5122 let mut trimmed_over_cap: usize = 0;
5123
5124 let feeds_cap = state.config.max_feeds_global;
5131 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5132 let existing = store::count_feeds(pool).await.unwrap_or(0);
5133 Some((feeds_cap - existing).max(0))
5134 } else {
5135 None
5136 };
5137 let mut trimmed_over_global: usize = 0;
5138
5139 let mut subs = Vec::with_capacity(feeds.len());
5140 let mut skipped_private: Vec<String> = Vec::new();
5141 let mut uncached: usize = 0;
5144 let mut skipped_unsupported: usize = 0;
5150 for f in &feeds {
5151 if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5159 info!(
5160 %did,
5161 "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5162 );
5163 skipped_unsupported += 1;
5164 continue;
5165 }
5166 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5167 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5168 let label = f
5170 .title
5171 .clone()
5172 .filter(|t| !t.trim().is_empty())
5173 .unwrap_or_else(|| private_feed_label(&f.feed_url));
5174 skipped_private.push(label);
5175 continue;
5176 }
5177
5178 if let Some(h) = headroom.as_mut() {
5181 if *h <= 0 {
5182 trimmed_over_cap += 1;
5183 continue;
5184 }
5185 }
5186
5187 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5192 Ok(existing) => existing.is_none(),
5193 Err(err) => {
5196 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5197 false
5198 }
5199 };
5200 if is_new {
5201 if let Some(g) = global_headroom.as_mut() {
5202 if *g <= 0 {
5203 trimmed_over_global += 1;
5204 continue;
5205 }
5206 *g -= 1;
5207 }
5208 }
5209
5210 if let Some(h) = headroom.as_mut() {
5213 *h -= 1;
5214 }
5215
5216 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5217 sub.title = f.title.clone();
5218 sub.site_url = f.site_url.clone();
5219 sub.folder = f
5220 .folder
5221 .as_ref()
5222 .and_then(|name| folder_uris.get(name).cloned());
5223 subs.push(sub);
5224 if let Err(err) = store::upsert_feed(
5230 pool,
5231 &store::NewFeed {
5232 url: f.feed_url.clone(),
5233 title: f.title.clone(),
5234 site_url: f.site_url.clone(),
5235 ..Default::default()
5236 },
5237 )
5238 .await
5239 {
5240 warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5241 it will not be polled");
5242 uncached += 1;
5243 }
5244 }
5245
5246 let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5253 Ok(rkeys) => {
5254 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5255 true
5256 }
5257 Err(err) => {
5258 warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5259 false
5260 }
5261 };
5262 if !pds_written {
5263 return Ok(Redirect::to(&format!(
5264 "/?flash={}",
5265 qenc(
5266 "Could not save those subscriptions to your PDS, so nothing was imported. \
5267 Try again in a moment."
5268 )
5269 ))
5270 .into_response());
5271 }
5272
5273 let mut flash = format!("Imported {} feeds", subs.len());
5275 if uncached > 0 {
5276 flash.push_str(&format!(
5277 ". {uncached} of them could not be cached locally and may not update until the next import."
5278 ));
5279 }
5280 if trimmed_over_cap > 0 {
5281 flash.push_str(&format!(
5282 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5283 ));
5284 }
5285 if trimmed_over_global > 0 {
5286 flash.push_str(&format!(
5287 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5288 ));
5289 }
5290 if !skipped_private.is_empty() {
5291 flash.push_str(&format!(
5292 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5293 skipped_private.len(),
5294 skipped_private.join(", ")
5295 ));
5296 }
5297 if skipped_unsupported > 0 {
5298 flash.push_str(&format!(
5301 ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5302 ));
5303 }
5304 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5305}
5306
5307fn private_feed_label(url: &str) -> String {
5310 url::Url::parse(url)
5311 .ok()
5312 .and_then(|u| u.host_str().map(str::to_string))
5313 .unwrap_or_else(|| "a private feed".to_string())
5314}
5315
5316async fn export_opml(
5318 State(state): State<AppState>,
5319 headers: HeaderMap,
5320) -> Result<Response, WebError> {
5321 let did = match current_did(&state, &headers).await {
5322 Some(d) => d,
5323 None => return Ok(Redirect::to("/login").into_response()),
5324 };
5325
5326 let subs = match state.repo().list_subscriptions_sorted(&did).await {
5333 Ok(subs) => subs,
5334 Err(err) => {
5335 tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5336 return Ok(Redirect::to(&format!(
5337 "/manage?flash={}",
5338 qenc(EXPORT_INCOMPLETE_REFUSAL)
5339 ))
5340 .into_response());
5341 }
5342 };
5343 let folders = match state.repo().list_folders_sorted(&did).await {
5344 Ok(folders) => folders,
5345 Err(err) => {
5346 tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5347 return Ok(Redirect::to(&format!(
5348 "/manage?flash={}",
5349 qenc(EXPORT_INCOMPLETE_REFUSAL)
5350 ))
5351 .into_response());
5352 }
5353 };
5354 let folder_pairs: Vec<(String, Folder)> = folders
5357 .into_iter()
5358 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5359 .collect();
5360
5361 let body = opml::to_opml(&subs, &folder_pairs);
5362 let mut resp = (StatusCode::OK, body).into_response();
5363 resp.headers_mut().insert(
5364 header::CONTENT_TYPE,
5365 "text/x-opml; charset=utf-8".parse().unwrap(),
5366 );
5367 resp.headers_mut().insert(
5368 header::CONTENT_DISPOSITION,
5369 "attachment; filename=\"featherreader-subscriptions.opml\""
5370 .parse()
5371 .unwrap(),
5372 );
5373 Ok(resp)
5374}
5375
5376fn set_cookie(resp: &mut Response, cookie: &str) {
5382 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5383 resp.headers_mut()
5384 .append(axum::http::header::SET_COOKIE, value);
5385 }
5386}
5387
5388fn is_htmx(headers: &HeaderMap) -> bool {
5390 headers
5391 .get("HX-Request")
5392 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5393}
5394
5395fn is_reader_request(headers: &HeaderMap) -> bool {
5401 headers
5402 .get("X-FR-Reader")
5403 .is_some_and(|v| v.as_bytes() == b"1")
5404}
5405
5406mod cookie {
5411 use super::{HeaderMap, SESSION_COOKIE};
5412
5413 pub fn sign_session(sid: &str, secret: &str) -> String {
5415 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5416 }
5417
5418 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5420 verify_value(headers, SESSION_COOKIE, secret)
5421 }
5422
5423 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5429 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5430 msg.extend_from_slice(name.as_bytes());
5431 msg.push(0);
5432 msg.extend_from_slice(value.as_bytes());
5433 msg
5434 }
5435
5436 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5442 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5443 let b64 = b64url_encode(value.as_bytes());
5444 format!(
5445 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5446 )
5447 }
5448
5449 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5452 let raw = cookie_value(headers, name)?;
5453 let (b64, sig) = raw.split_once('.')?;
5454 let bytes = b64url_decode(b64)?;
5455 let value = String::from_utf8(bytes).ok()?;
5456 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5457 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5458 Some(value)
5459 } else {
5460 None
5461 }
5462 }
5463
5464 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5470 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5471 let b64 = b64url_encode(value.as_bytes());
5472 format!("{b64}.{sig}")
5473 }
5474
5475 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5478 let (b64, sig) = token.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(label, &value));
5482 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5483 Some(value)
5484 } else {
5485 None
5486 }
5487 }
5488
5489 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5491 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5492 for part in header.split(';') {
5493 let part = part.trim();
5494 if let Some((k, v)) = part.split_once('=') {
5495 if k == name {
5496 return Some(v.to_string());
5497 }
5498 }
5499 }
5500 None
5501 }
5502
5503 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5507 if a.len() != b.len() {
5508 return false;
5509 }
5510 let mut diff = 0u8;
5511 for (x, y) in a.iter().zip(b.iter()) {
5512 diff |= x ^ y;
5513 }
5514 diff == 0
5515 }
5516
5517 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5520
5521 fn b64url_encode(input: &[u8]) -> String {
5522 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5523 for chunk in input.chunks(3) {
5524 let b = [
5525 chunk[0],
5526 *chunk.get(1).unwrap_or(&0),
5527 *chunk.get(2).unwrap_or(&0),
5528 ];
5529 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5530 out.push(B64[((n >> 18) & 63) as usize] as char);
5531 out.push(B64[((n >> 12) & 63) as usize] as char);
5532 if chunk.len() > 1 {
5533 out.push(B64[((n >> 6) & 63) as usize] as char);
5534 }
5535 if chunk.len() > 2 {
5536 out.push(B64[(n & 63) as usize] as char);
5537 }
5538 }
5539 out
5540 }
5541
5542 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5543 fn val(c: u8) -> Option<u32> {
5544 match c {
5545 b'A'..=b'Z' => Some((c - b'A') as u32),
5546 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5547 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5548 b'-' => Some(62),
5549 b'_' => Some(63),
5550 _ => None,
5551 }
5552 }
5553 let bytes = input.as_bytes();
5554 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5555 for chunk in bytes.chunks(4) {
5556 let mut n = 0u32;
5557 let mut valid = 0;
5558 for (i, &c) in chunk.iter().enumerate() {
5559 n |= val(c)? << (18 - 6 * i);
5560 valid += 1;
5561 }
5562 out.push((n >> 16) as u8);
5563 if valid > 2 {
5564 out.push((n >> 8) as u8);
5565 }
5566 if valid > 3 {
5567 out.push(n as u8);
5568 }
5569 }
5570 Some(out)
5571 }
5572
5573 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5577 const BLOCK: usize = 64;
5578 let mut k = [0u8; BLOCK];
5579 if key.len() > BLOCK {
5580 let d = sha256(key);
5581 k[..32].copy_from_slice(&d);
5582 } else {
5583 k[..key.len()].copy_from_slice(key);
5584 }
5585 let mut ipad = [0x36u8; BLOCK];
5586 let mut opad = [0x5cu8; BLOCK];
5587 for i in 0..BLOCK {
5588 ipad[i] ^= k[i];
5589 opad[i] ^= k[i];
5590 }
5591 let mut inner = Vec::with_capacity(BLOCK + msg.len());
5592 inner.extend_from_slice(&ipad);
5593 inner.extend_from_slice(msg);
5594 let inner_hash = sha256(&inner);
5595 let mut outer = Vec::with_capacity(BLOCK + 32);
5596 outer.extend_from_slice(&opad);
5597 outer.extend_from_slice(&inner_hash);
5598 let mac = sha256(&outer);
5599 let mut hex = String::with_capacity(64);
5600 for b in mac {
5601 hex.push_str(&format!("{b:02x}"));
5602 }
5603 hex
5604 }
5605
5606 fn sha256(data: &[u8]) -> [u8; 32] {
5608 const K: [u32; 64] = [
5609 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5610 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5611 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5612 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5613 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5614 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5615 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5616 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5617 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5618 0xc67178f2,
5619 ];
5620 let mut h: [u32; 8] = [
5621 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5622 0x5be0cd19,
5623 ];
5624
5625 let bit_len = (data.len() as u64) * 8;
5626 let mut msg = data.to_vec();
5627 msg.push(0x80);
5628 while msg.len() % 64 != 56 {
5629 msg.push(0);
5630 }
5631 msg.extend_from_slice(&bit_len.to_be_bytes());
5632
5633 for block in msg.chunks(64) {
5634 let mut w = [0u32; 64];
5635 for i in 0..16 {
5636 w[i] = u32::from_be_bytes([
5637 block[i * 4],
5638 block[i * 4 + 1],
5639 block[i * 4 + 2],
5640 block[i * 4 + 3],
5641 ]);
5642 }
5643 for i in 16..64 {
5644 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5645 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5646 w[i] = w[i - 16]
5647 .wrapping_add(s0)
5648 .wrapping_add(w[i - 7])
5649 .wrapping_add(s1);
5650 }
5651 let mut a = h;
5652 for i in 0..64 {
5653 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5654 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5655 let t1 = a[7]
5656 .wrapping_add(s1)
5657 .wrapping_add(ch)
5658 .wrapping_add(K[i])
5659 .wrapping_add(w[i]);
5660 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5661 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5662 let t2 = s0.wrapping_add(maj);
5663 a[7] = a[6];
5664 a[6] = a[5];
5665 a[5] = a[4];
5666 a[4] = a[3].wrapping_add(t1);
5667 a[3] = a[2];
5668 a[2] = a[1];
5669 a[1] = a[0];
5670 a[0] = t1.wrapping_add(t2);
5671 }
5672 for i in 0..8 {
5673 h[i] = h[i].wrapping_add(a[i]);
5674 }
5675 }
5676
5677 let mut out = [0u8; 32];
5678 for (i, word) in h.iter().enumerate() {
5679 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5680 }
5681 out
5682 }
5683
5684 #[cfg(test)]
5685 mod tests {
5686 use super::*;
5687
5688 #[test]
5689 fn sha256_known_vector() {
5690 let d = sha256(b"abc");
5691 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5692 assert_eq!(
5693 hex,
5694 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5695 );
5696 }
5697
5698 #[test]
5699 fn hmac_known_vector() {
5700 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5701 assert_eq!(
5702 mac,
5703 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5704 );
5705 }
5706
5707 #[test]
5708 fn sign_verify_round_trips() {
5709 let secret = "test-secret";
5710 let sid = "9f2c-opaque-session-id";
5711 let cookie = sign_session(sid, secret);
5712 let pair = cookie.split(';').next().unwrap().to_string();
5713 let mut headers = HeaderMap::new();
5714 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5715 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5716 assert!(verify_session(&headers, "other-secret").is_none());
5718 }
5719
5720 #[test]
5721 fn forged_and_tampered_cookies_are_rejected() {
5722 let secret = "test-secret";
5723
5724 let forged = format!(
5727 "{SESSION_COOKIE}={}.{}",
5728 b64url_encode(b"attacker-chosen-sid"),
5729 "deadbeef".repeat(8) );
5731 let mut headers = HeaderMap::new();
5732 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5733 assert!(verify_session(&headers, secret).is_none());
5734
5735 let cookie = sign_session("real-sid", secret);
5738 let pair = cookie.split(';').next().unwrap();
5739 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5740 let tampered = format!(
5741 "{SESSION_COOKIE}={}.{}",
5742 b64url_encode(b"different-sid"),
5743 sig
5744 );
5745 let mut headers2 = HeaderMap::new();
5746 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5747 assert!(verify_session(&headers2, secret).is_none());
5748 }
5749
5750 #[test]
5751 fn b64url_round_trips() {
5752 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5753 let enc = b64url_encode(s.as_bytes());
5754 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5755 }
5756 }
5757 }
5758}
5759
5760async fn get_entry_by_id(
5776 pool: &store::Pool,
5777 did: &str,
5778 id: i64,
5779) -> anyhow::Result<Option<store::Entry>> {
5780 let entry = sqlx::query_as::<_, store::Entry>(
5781 r#"
5782 SELECT e.* FROM entries e
5783 WHERE e.id = ?2
5784 AND EXISTS (
5785 SELECT 1 FROM sub_ref sr
5786 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5787 )
5788 "#,
5789 )
5790 .bind(did)
5791 .bind(id)
5792 .fetch_optional(pool)
5793 .await?;
5794 Ok(entry)
5795}
5796
5797async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5799 let read: Option<bool> =
5800 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5801 .bind(did)
5802 .bind(entry_id)
5803 .fetch_optional(pool)
5804 .await?
5805 .flatten();
5806 Ok(read.unwrap_or(false))
5807}
5808
5809async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5811 let starred: Option<bool> =
5812 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5813 .bind(did)
5814 .bind(entry_id)
5815 .fetch_optional(pool)
5816 .await?
5817 .flatten();
5818 Ok(starred.unwrap_or(false))
5819}
5820
5821async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5823 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5824 .bind(feed_id)
5825 .fetch_optional(pool)
5826 .await
5827 {
5828 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5829 _ => String::new(),
5830 }
5831}
5832
5833async fn build_entry_row(
5836 pool: &store::Pool,
5837 did: &str,
5838 id: i64,
5839 read: Option<bool>,
5840) -> anyhow::Result<Option<EntryRow>> {
5841 let entry = match get_entry_by_id(pool, did, id).await? {
5842 Some(e) => e,
5843 None => return Ok(None),
5844 };
5845 let read = match read {
5846 Some(r) => r,
5847 None => entry_is_read(pool, did, id).await?,
5848 };
5849 let starred = entry_is_starred(pool, did, id).await?;
5850 Ok(Some(EntryRow {
5851 id: entry.id,
5852 title: entry
5853 .title
5854 .clone()
5855 .filter(|t| !t.trim().is_empty())
5856 .unwrap_or_else(|| "(untitled)".to_string()),
5857 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5858 published: display_date(entry.published.as_deref()),
5859 read,
5860 starred,
5861 link: SafeLink::entry(id, ""),
5862 cached: true,
5863 rkey: String::new(),
5864 }))
5865}
5866
5867fn now_rfc3339() -> String {
5869 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5870}
5871
5872#[cfg(test)]
5873mod tests {
5874 use super::*;
5875
5876 #[test]
5877 fn qenc_encodes_reserved() {
5878 assert_eq!(qenc("a b"), "a%20b");
5879 assert_eq!(
5880 qenc("https://example.com/feed.xml"),
5881 "https%3A%2F%2Fexample.com%2Ffeed.xml"
5882 );
5883 assert_eq!(
5884 qenc("at://did:plc:x/c/r"),
5885 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5886 );
5887 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5889 }
5890
5891 #[test]
5892 fn folder_uri_shape() {
5893 assert_eq!(
5894 folder_uri("did:plc:abc", "3kfolder"),
5895 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5896 );
5897 }
5898
5899 #[test]
5902 fn private_feeds_are_classified_private_across_providers() {
5903 for url in [
5907 "https://author.substack.com/feed/private/deadbeefcafe1234",
5908 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5909 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5910 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5911 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5912 "https://user:pass@example.com/feed",
5913 ] {
5914 assert!(
5915 feed::classify_feed_privacy(url).is_private(),
5916 "expected private: {url}"
5917 );
5918 }
5919 }
5920
5921 #[test]
5922 fn public_feeds_stay_public() {
5923 for url in [
5924 "https://author.substack.com/feed",
5925 "https://wordpress.example.com/feed/",
5926 "https://example.com/rss.xml",
5927 "https://example.org/atom.xml",
5928 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5930 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5931 ] {
5932 assert!(
5933 !feed::classify_feed_privacy(url).is_private(),
5934 "expected public: {url}"
5935 );
5936 }
5937 }
5938
5939 #[test]
5940 fn private_feed_label_is_public_safe_host_only() {
5941 let label =
5943 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5944 assert_eq!(label, "author.substack.com");
5945 assert!(!label.contains("deadbeefcafe1234token"));
5946 assert!(!label.contains("/private/"));
5947 assert_eq!(private_feed_label("not a url"), "a private feed");
5949 }
5950
5951 #[test]
5952 fn refusal_message_promises_nothing_stored() {
5953 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5954 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5955 }
5956
5957 #[test]
5958 fn scope_query_preserves_context() {
5959 let q = EntryQuery {
5960 feed: Some("https://example.com/feed.xml".to_string()),
5961 folder: None,
5962 view: Some("all".to_string()),
5963 };
5964 let s = scope_query(&q);
5965 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5966 assert!(s.contains("view=all"));
5967
5968 let q2 = EntryQuery {
5970 feed: None,
5971 folder: None,
5972 view: Some("unread".to_string()),
5973 };
5974 assert_eq!(scope_query(&q2), "");
5975 }
5976
5977 use axum::body::Body;
5980 use axum::http::Request;
5981 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
5987 let db = store::init_url("sqlite::memory:").await.unwrap();
5988 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5989 store::ensure_seed(&db, &dids).await.unwrap();
5990 let config = Config {
5991 allowed_dids: dids,
5992 cookie_secret: "test-cookie-secret-000".to_string(),
5993 beta_cap: 3,
5994 ..Config::default()
5995 };
5996 AppState::new(config, db).unwrap()
5997 }
5998
5999 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6002 let sid = state.sessions.create(Session {
6003 did: did.to_string(),
6004 handle: handle.map(str::to_string),
6005 });
6006 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6007 sc.split(';').next().unwrap().to_string()
6008 }
6009
6010 #[test]
6014 fn the_rate_limit_map_is_bounded() {
6015 let rl = RateLimiter::shared();
6016 let now = Instant::now();
6017 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6018 let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6021 rl.check_at(ip, now + Duration::from_millis(i as u64));
6022 }
6023 let len = rl.inner.lock().unwrap().buckets.len();
6024 assert!(
6025 len <= MAX_RATE_BUCKETS,
6026 "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6027 );
6028 }
6029
6030 #[test]
6037 fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6038 let rl = RateLimiter::shared();
6039 let base = Instant::now();
6040 let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6041 let at = |n: u64| base + Duration::from_nanos(n);
6046
6047 for i in 0..(RATE_BURST as u64) {
6049 assert!(rl.check_at(attacker, at(i)));
6050 }
6051 assert!(
6052 !rl.check_at(attacker, at(RATE_BURST as u64)),
6053 "burst was not exhausted; the rest of this test proves nothing"
6054 );
6055
6056 for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6059 let t = at(100 + i as u64 * 2);
6060 let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6061 rl.check_at(ip, t);
6062 assert!(
6063 !rl.check_at(attacker, t),
6064 "the attacker got a token back after evictions at i={i}"
6065 );
6066 }
6067 }
6068
6069 #[test]
6072 fn the_idle_sweep_does_not_run_on_every_request() {
6073 let rl = RateLimiter::shared();
6074 let start = Instant::now();
6075 let a: IpAddr = "198.51.100.1".parse().unwrap();
6076 let b: IpAddr = "198.51.100.2".parse().unwrap();
6077
6078 rl.check_at(a, start);
6079 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6082 assert!(
6083 !rl.inner.lock().unwrap().buckets.contains_key(&a),
6084 "an idle bucket survived a sweep that was due"
6085 );
6086
6087 let before = rl.inner.lock().unwrap().last_sweep;
6090 rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6091 assert_eq!(
6092 rl.inner.lock().unwrap().last_sweep,
6093 before,
6094 "the sweep ran again within the interval"
6095 );
6096 }
6097
6098 #[test]
6099 fn rate_limited_paths_match_expected() {
6100 use axum::http::Method;
6101 assert!(is_rate_limited_path("/login", &Method::GET));
6102 assert!(is_rate_limited_path("/login", &Method::POST));
6103 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6104 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6105 assert!(is_rate_limited_path("/opml", &Method::POST));
6106 assert!(is_rate_limited_path("/read-all", &Method::POST));
6107 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6108 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6109 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6110 assert!(!is_rate_limited_path("/", &Method::GET));
6112 assert!(!is_rate_limited_path("/about", &Method::GET));
6113 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6114 assert!(!is_rate_limited_path("/login", &Method::HEAD));
6115 }
6116
6117 #[test]
6118 fn rate_limiter_allows_burst_then_429s() {
6119 let rl = RateLimiter::shared();
6120 let ip: IpAddr = "203.0.113.7".parse().unwrap();
6121 for _ in 0..(RATE_BURST as usize) {
6123 assert!(rl.check(ip));
6124 }
6125 assert!(!rl.check(ip));
6127 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6129 assert!(rl.check(ip2));
6130 }
6131
6132 #[test]
6133 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6134 let mut h = HeaderMap::new();
6138 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6139 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6140 assert_eq!(
6141 client_ip(&h, Some(&sock), None),
6142 Some("203.0.113.55".parse().unwrap()),
6143 "spoofed XFF must not override the socket peer"
6144 );
6145 }
6146
6147 #[test]
6148 fn client_ip_uses_trusted_header_last_hop() {
6149 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6154
6155 let mut h = HeaderMap::new();
6156 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6157 assert_eq!(
6158 client_ip(&h, Some(&sock), Some("fly-client-ip")),
6159 Some("198.51.100.9".parse().unwrap())
6160 );
6161
6162 let mut h2 = HeaderMap::new();
6164 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6165 assert_eq!(
6166 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6167 Some("198.51.100.9".parse().unwrap()),
6168 "must take the right-most (trusted) hop, not the forged left-most"
6169 );
6170
6171 let h3 = HeaderMap::new();
6173 assert_eq!(
6174 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6175 Some("10.0.0.1".parse().unwrap())
6176 );
6177 }
6178
6179 #[test]
6180 fn invite_cookie_round_trips_and_rejects_tamper() {
6181 let secret = "test-cookie-secret-000";
6182 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6183 let pair = sc.split(';').next().unwrap();
6184 let mut h = HeaderMap::new();
6185 h.insert(header::COOKIE, pair.parse().unwrap());
6186 assert_eq!(
6187 invite_cookie_code(&h, secret).as_deref(),
6188 Some("FEATHER-ABCDWXYZ")
6189 );
6190 assert!(invite_cookie_code(&h, "other").is_none());
6192 }
6193
6194 #[tokio::test]
6195 async fn preflight_valid_expired_and_full() {
6196 let state = test_state(&["did:plc:admin"]).await;
6197 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6199 .await
6200 .unwrap();
6201 assert!(preflight_code(&state, &code).await.is_ok());
6202
6203 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6207 .await
6208 .unwrap();
6209 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6210 .bind(chrono::Utc::now().timestamp() - 3600)
6211 .bind(&expired)
6212 .execute(&state.db)
6213 .await
6214 .unwrap();
6215 assert_eq!(
6216 preflight_code(&state, &expired).await,
6217 Err(store::RedeemError::Expired)
6218 );
6219
6220 assert_eq!(
6222 preflight_code(&state, "FEATHER-NOPENOPE").await,
6223 Err(store::RedeemError::NotFound)
6224 );
6225
6226 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6229 .await
6230 .unwrap();
6231 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6232 .await
6233 .unwrap();
6234 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6235 assert_eq!(
6236 preflight_code(&state, &code).await,
6237 Err(store::RedeemError::CapacityFull)
6238 );
6239 }
6240
6241 async fn bot_state(bot_secret: &str) -> AppState {
6245 let db = store::init_url("sqlite::memory:").await.unwrap();
6246 store::ensure_seed(&db, &["did:plc:admin".to_string()])
6247 .await
6248 .unwrap();
6249 let config = Config {
6250 allowed_dids: vec!["did:plc:admin".to_string()],
6251 cookie_secret: "test-cookie-secret-000".to_string(),
6252 beta_cap: 3,
6253 bot_secret: Some(bot_secret.to_string()),
6254 public_url: "https://feather-reader.com".to_string(),
6255 ..Config::default()
6256 };
6257 AppState::new(config, db).unwrap()
6258 }
6259
6260 #[test]
6261 fn claim_token_round_trips_and_rejects_tamper() {
6262 let secret = "test-cookie-secret-000";
6263 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6264 assert!(!token.contains(';'));
6266 assert_eq!(
6267 claim_token_code(&token, secret).as_deref(),
6268 Some("FEATHER-ABCDWXYZ")
6269 );
6270 assert!(claim_token_code(&token, "other").is_none());
6272 let mut bad = token.clone();
6274 bad.push('x');
6275 assert!(claim_token_code(&bad, secret).is_none());
6276 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6282 assert_eq!(
6283 test_b64url_decode(b64).as_deref(),
6284 Some("FEATHER-ABCDWXYZ".as_bytes()),
6285 "the code half of the token is plain base64url, decodable by anyone"
6286 );
6287 }
6288
6289 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6292 fn val(c: u8) -> Option<u32> {
6293 match c {
6294 b'A'..=b'Z' => Some((c - b'A') as u32),
6295 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6296 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6297 b'-' => Some(62),
6298 b'_' => Some(63),
6299 _ => None,
6300 }
6301 }
6302 let mut out = Vec::with_capacity(input.len() / 4 * 3);
6303 for chunk in input.as_bytes().chunks(4) {
6304 let mut n = 0u32;
6305 let mut bits = 0;
6306 for &c in chunk {
6307 n = (n << 6) | val(c)?;
6308 bits += 6;
6309 }
6310 let bytes = bits / 8;
6311 n <<= 24 - bits;
6312 for i in 0..bytes {
6313 out.push((n >> (16 - i * 8)) as u8);
6314 }
6315 }
6316 Some(out)
6317 }
6318
6319 #[tokio::test]
6320 async fn bot_mint_then_claim_grants_a_seat() {
6321 let state = bot_state("bot-secret-abcdef").await;
6322 let app = router(state.clone());
6323
6324 let resp = app
6326 .clone()
6327 .oneshot(
6328 Request::builder()
6329 .method("POST")
6330 .uri("/bot/claims")
6331 .header("x-bot-secret", "bot-secret-abcdef")
6332 .body(Body::empty())
6333 .unwrap(),
6334 )
6335 .await
6336 .unwrap();
6337 assert_eq!(resp.status(), StatusCode::OK);
6338 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6339 .await
6340 .unwrap();
6341 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6342 let token = json["token"].as_str().unwrap().to_string();
6343 let url = json["url"].as_str().unwrap();
6344 assert!(url.starts_with("https://feather-reader.com/claim?t="));
6345 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6347 assert!(!url.contains("FEATHER-"));
6348
6349 let resp = app
6351 .clone()
6352 .oneshot(
6353 Request::builder()
6354 .method("GET")
6355 .uri(format!("/claim?t={}", qenc(&token)))
6356 .body(Body::empty())
6357 .unwrap(),
6358 )
6359 .await
6360 .unwrap();
6361 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6362 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6363 let set_cookie = resp
6364 .headers()
6365 .get(header::SET_COOKIE)
6366 .unwrap()
6367 .to_str()
6368 .unwrap();
6369 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6370
6371 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6374 let out = store::redeem_code(
6375 &state.db,
6376 &code,
6377 "did:plc:follower",
6378 None,
6379 state.config.beta_cap,
6380 )
6381 .await
6382 .unwrap();
6383 assert_eq!(out, Ok(()));
6384 assert!(store::has_beta_access(&state.db, "did:plc:follower")
6385 .await
6386 .unwrap());
6387 }
6388
6389 #[tokio::test]
6390 async fn claim_with_invalid_token_bounces() {
6391 let state = bot_state("bot-secret-abcdef").await;
6392 let app = router(state);
6393 let resp = app
6394 .oneshot(
6395 Request::builder()
6396 .method("GET")
6397 .uri("/claim?t=not-a-real-token")
6398 .body(Body::empty())
6399 .unwrap(),
6400 )
6401 .await
6402 .unwrap();
6403 assert_eq!(resp.status(), StatusCode::OK);
6405 }
6406
6407 #[tokio::test]
6408 async fn claim_with_used_token_is_refused() {
6409 let state = bot_state("bot-secret-abcdef").await;
6410 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6412 .await
6413 .unwrap();
6414 let token = sign_claim_token(&code, &state.config.cookie_secret);
6415 store::redeem_code(
6416 &state.db,
6417 &code,
6418 "did:plc:someone",
6419 None,
6420 state.config.beta_cap,
6421 )
6422 .await
6423 .unwrap()
6424 .unwrap();
6425 let app = router(state);
6426 let resp = app
6427 .oneshot(
6428 Request::builder()
6429 .method("GET")
6430 .uri(format!("/claim?t={}", qenc(&token)))
6431 .body(Body::empty())
6432 .unwrap(),
6433 )
6434 .await
6435 .unwrap();
6436 assert_eq!(resp.status(), StatusCode::OK);
6438 assert!(resp.headers().get(header::SET_COOKIE).is_none());
6439 }
6440
6441 #[tokio::test]
6442 async fn bot_claims_rejects_bad_and_missing_secret() {
6443 let state = bot_state("bot-secret-abcdef").await;
6444 let app = router(state);
6445 let resp = app
6447 .clone()
6448 .oneshot(
6449 Request::builder()
6450 .method("POST")
6451 .uri("/bot/claims")
6452 .header("x-bot-secret", "wrong")
6453 .body(Body::empty())
6454 .unwrap(),
6455 )
6456 .await
6457 .unwrap();
6458 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6459 let resp = app
6461 .oneshot(
6462 Request::builder()
6463 .method("POST")
6464 .uri("/bot/claims")
6465 .body(Body::empty())
6466 .unwrap(),
6467 )
6468 .await
6469 .unwrap();
6470 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6471 }
6472
6473 #[tokio::test]
6474 async fn bot_claims_disabled_when_secret_unset() {
6475 let state = test_state(&["did:plc:admin"]).await;
6477 let app = router(state);
6478 let resp = app
6479 .oneshot(
6480 Request::builder()
6481 .method("POST")
6482 .uri("/bot/claims")
6483 .header("x-bot-secret", "anything")
6484 .body(Body::empty())
6485 .unwrap(),
6486 )
6487 .await
6488 .unwrap();
6489 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6490 }
6491
6492 #[tokio::test]
6493 async fn bot_claims_refuses_at_capacity() {
6494 let state = bot_state("bot-secret-abcdef").await;
6495 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6497 .await
6498 .unwrap();
6499 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6500 .await
6501 .unwrap();
6502 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6503 let app = router(state);
6504 let resp = app
6505 .oneshot(
6506 Request::builder()
6507 .method("POST")
6508 .uri("/bot/claims")
6509 .header("x-bot-secret", "bot-secret-abcdef")
6510 .body(Body::empty())
6511 .unwrap(),
6512 )
6513 .await
6514 .unwrap();
6515 assert_eq!(resp.status(), StatusCode::CONFLICT);
6516 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6517 .await
6518 .unwrap();
6519 assert!(String::from_utf8_lossy(&bytes).contains("full"));
6520 }
6521
6522 #[tokio::test]
6523 async fn bot_claims_counts_outstanding_codes_against_cap() {
6524 let state = bot_state("bot-secret-abcdef").await;
6525 store::mint_code(&state.db, "did:plc:admin", 3600)
6527 .await
6528 .unwrap();
6529 store::mint_code(&state.db, "did:plc:admin", 3600)
6530 .await
6531 .unwrap();
6532 let app = router(state);
6533 let resp = app
6534 .oneshot(
6535 Request::builder()
6536 .method("POST")
6537 .uri("/bot/claims")
6538 .header("x-bot-secret", "bot-secret-abcdef")
6539 .body(Body::empty())
6540 .unwrap(),
6541 )
6542 .await
6543 .unwrap();
6544 assert_eq!(resp.status(), StatusCode::CONFLICT);
6546 }
6547
6548 async fn post_bot_claim_for(
6550 app: &axum::Router,
6551 secret: &str,
6552 did: &str,
6553 ) -> (StatusCode, serde_json::Value) {
6554 let resp = app
6555 .clone()
6556 .oneshot(
6557 Request::builder()
6558 .method("POST")
6559 .uri("/bot/claims")
6560 .header("x-bot-secret", secret)
6561 .header("content-type", "application/json")
6562 .body(Body::from(format!(
6563 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6564 )))
6565 .unwrap(),
6566 )
6567 .await
6568 .unwrap();
6569 let status = resp.status();
6570 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6571 .await
6572 .unwrap();
6573 let json = if bytes.is_empty() {
6574 serde_json::Value::Null
6575 } else {
6576 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6577 };
6578 (status, json)
6579 }
6580
6581 #[tokio::test]
6582 async fn bot_claims_returns_already_seated_for_a_member() {
6583 let state = bot_state("bot-secret-abcdef").await;
6587 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6588 .await
6589 .unwrap();
6590 let app = router(state.clone());
6591 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6592 assert_eq!(status, StatusCode::OK);
6593 assert_eq!(json["status"], "already_seated");
6594 assert_eq!(json["code"], "");
6595 assert_eq!(json["url"], "");
6596 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6598 .await
6599 .unwrap()
6600 .is_none());
6601 }
6602
6603 #[tokio::test]
6604 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6605 let state = bot_state("bot-secret-abcdef").await;
6609 let app = router(state.clone());
6610
6611 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6612 assert_eq!(s1, StatusCode::OK);
6613 assert_eq!(j1["status"], "minted");
6614 let code1 = j1["code"].as_str().unwrap().to_string();
6615
6616 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6617 assert_eq!(s2, StatusCode::OK);
6618 assert_eq!(j2["status"], "existing");
6619 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6620 assert_eq!(j2["url"], j1["url"], "same url returned");
6621
6622 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6624 }
6625
6626 #[tokio::test]
6627 async fn bot_claims_records_intended_did_at_mint() {
6628 let state = bot_state("bot-secret-abcdef").await;
6630 let app = router(state.clone());
6631 let (status, json) =
6632 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6633 assert_eq!(status, StatusCode::OK);
6634 let code = json["code"].as_str().unwrap();
6635 assert_eq!(
6636 store::find_active_code_for_did(&state.db, "did:plc:follower2")
6637 .await
6638 .unwrap()
6639 .as_deref(),
6640 Some(code)
6641 );
6642 }
6643
6644 #[tokio::test]
6645 async fn bot_claims_concurrent_same_did_never_double_mints() {
6646 let state = bot_state("bot-secret-abcdef").await;
6653 let app = router(state.clone());
6654
6655 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6656 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6657 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6658
6659 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6660 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6661
6662 assert_eq!(
6664 store::count_active_codes(&state.db).await.unwrap(),
6665 1,
6666 "concurrent mints must not create two active codes"
6667 );
6668
6669 let ca = ja["code"].as_str().unwrap_or("");
6671 let cb = jb["code"].as_str().unwrap_or("");
6672 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6673 assert_eq!(ca, cb, "both callers must get the one minted code");
6674 for st in [&ja["status"], &jb["status"]] {
6677 let s = st.as_str().unwrap_or("");
6678 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6679 }
6680 }
6681
6682 #[tokio::test]
6683 async fn bot_claims_rejects_malformed_json_body() {
6684 let state = bot_state("bot-secret-abcdef").await;
6685 let app = router(state);
6686 let resp = app
6687 .oneshot(
6688 Request::builder()
6689 .method("POST")
6690 .uri("/bot/claims")
6691 .header("x-bot-secret", "bot-secret-abcdef")
6692 .header("content-type", "application/json")
6693 .body(Body::from("{not json"))
6694 .unwrap(),
6695 )
6696 .await
6697 .unwrap();
6698 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6699 }
6700
6701 #[tokio::test]
6702 async fn favicon_ico_served_at_root() {
6703 let state = test_state(&[]).await;
6706 let app = router(state);
6707 let resp = app
6708 .oneshot(
6709 Request::builder()
6710 .uri("/favicon.ico")
6711 .body(Body::empty())
6712 .unwrap(),
6713 )
6714 .await
6715 .unwrap();
6716 assert_eq!(resp.status(), StatusCode::OK);
6717 let ct = resp
6718 .headers()
6719 .get(header::CONTENT_TYPE)
6720 .unwrap()
6721 .to_str()
6722 .unwrap();
6723 assert!(
6724 ct.contains("icon") || ct.starts_with("image/"),
6725 "content-type = {ct}"
6726 );
6727 }
6728
6729 #[tokio::test]
6730 async fn login_without_invite_redirects_to_beta_redeem() {
6731 let state = test_state(&[]).await;
6733 let app = router(state);
6734 let resp = app
6735 .oneshot(
6736 Request::builder()
6737 .method("POST")
6738 .uri("/login")
6739 .header("content-type", "application/x-www-form-urlencoded")
6740 .body(Body::from("handle=alice.bsky.social"))
6741 .unwrap(),
6742 )
6743 .await
6744 .unwrap();
6745 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6746 assert_eq!(
6747 resp.headers().get(header::LOCATION).unwrap(),
6748 "/beta/redeem"
6749 );
6750 }
6751
6752 #[tokio::test]
6753 async fn login_with_valid_invite_cookie_starts_oauth() {
6754 let state = test_state(&[]).await;
6755 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6756 let cookie = cookie.split(';').next().unwrap().to_string();
6757 let app = router(state);
6758 let resp = app
6759 .oneshot(
6760 Request::builder()
6761 .method("POST")
6762 .uri("/login")
6763 .header("content-type", "application/x-www-form-urlencoded")
6764 .header(header::COOKIE, cookie)
6765 .body(Body::from("handle=alice.bsky.social"))
6766 .unwrap(),
6767 )
6768 .await
6769 .unwrap();
6770 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6772 let loc = resp
6773 .headers()
6774 .get(header::LOCATION)
6775 .unwrap()
6776 .to_str()
6777 .unwrap();
6778 assert!(loc.contains("/login"), "loc = {loc}");
6779 assert_ne!(loc, "/beta/redeem");
6780 }
6781
6782 async fn resolver_never(_handle: String) -> Option<String> {
6785 None
6786 }
6787
6788 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6790 move |_handle| std::future::ready(Some(did.to_string()))
6791 }
6792
6793 #[tokio::test]
6797 async fn may_start_oauth_honors_seat_via_resolved_handle() {
6798 let state = test_state(&["did:plc:admin"]).await;
6801 let headers = HeaderMap::new();
6802 assert!(
6803 may_start_oauth_with(
6804 &state,
6805 &headers,
6806 "admin.example",
6807 resolver_to("did:plc:admin")
6808 )
6809 .await,
6810 "a handle resolving to a seated DID must pass the gate"
6811 );
6812 }
6813
6814 #[tokio::test]
6818 async fn may_start_oauth_bounces_non_member_handle() {
6819 let state = test_state(&["did:plc:admin"]).await;
6820 let headers = HeaderMap::new();
6821 assert!(
6822 !may_start_oauth_with(
6823 &state,
6824 &headers,
6825 "rando.example",
6826 resolver_to("did:plc:rando")
6827 )
6828 .await,
6829 "a resolved DID with no seat must be bounced"
6830 );
6831 }
6832
6833 #[tokio::test]
6836 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6837 let state = test_state(&["did:plc:admin"]).await;
6838 let headers = HeaderMap::new();
6839 assert!(
6840 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6841 "an unresolvable handle must fail closed"
6842 );
6843 }
6844
6845 #[tokio::test]
6849 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6850 let state = test_state(&[]).await;
6851 let did = "did:plc:member";
6852 store::grant_access(&state.db, did, Some("member.example"), "test", None)
6853 .await
6854 .unwrap();
6855 let cookie = session_cookie(&state, did, Some("member.example"));
6856 let mut headers = HeaderMap::new();
6857 headers.insert(header::COOKIE, cookie.parse().unwrap());
6858 assert!(
6859 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6860 "a seated session cookie must pass without resolution"
6861 );
6862 }
6863
6864 #[tokio::test]
6866 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6867 let state = test_state(&[]).await;
6868 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6869 let cookie = cookie.split(';').next().unwrap().to_string();
6870 let mut headers = HeaderMap::new();
6871 headers.insert(header::COOKIE, cookie.parse().unwrap());
6872 assert!(
6873 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6874 "a valid invite cookie must pass without resolution"
6875 );
6876 }
6877
6878 #[tokio::test]
6879 async fn admin_mint_requires_admin_seed_did() {
6880 let state = test_state(&["did:plc:admin"]).await;
6881 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6883 .await
6884 .unwrap();
6885 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6886 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6888 let app = router(state);
6889
6890 let forbidden = app
6891 .clone()
6892 .oneshot(
6893 Request::builder()
6894 .method("POST")
6895 .uri("/admin/invites?n=2")
6896 .header(header::COOKIE, rando_cookie)
6897 .body(Body::empty())
6898 .unwrap(),
6899 )
6900 .await
6901 .unwrap();
6902 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6903
6904 let ok = app
6905 .oneshot(
6906 Request::builder()
6907 .method("POST")
6908 .uri("/admin/invites?n=2")
6909 .header(header::COOKIE, admin_cookie)
6910 .body(Body::empty())
6911 .unwrap(),
6912 )
6913 .await
6914 .unwrap();
6915 assert_eq!(ok.status(), StatusCode::OK);
6916 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6917 .await
6918 .unwrap();
6919 let body = String::from_utf8(bytes.to_vec()).unwrap();
6920 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6921 assert_eq!(minted.len(), 2);
6922 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6923 }
6924
6925 #[tokio::test]
6926 async fn admin_mint_unauthenticated_is_401() {
6927 let state = test_state(&["did:plc:admin"]).await;
6928 let app = router(state);
6929 let resp = app
6930 .oneshot(
6931 Request::builder()
6932 .method("POST")
6933 .uri("/admin/invites")
6934 .body(Body::empty())
6935 .unwrap(),
6936 )
6937 .await
6938 .unwrap();
6939 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6940 }
6941
6942 async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6945 let db = store::init_url("sqlite::memory:").await.unwrap();
6946 store::record_network_stat(
6947 &db,
6948 &store::NetworkStat {
6949 key: store::ADOPTION_STAT_KEY.to_string(),
6950 source: "https://relay1.us-west.bsky.network".to_string(),
6951 value: repos,
6952 truncated,
6953 observed_at: "2026-08-13T04:05:06Z".to_string(),
6954 },
6955 )
6956 .await
6957 .unwrap();
6958 let config = Config {
6959 cookie_secret: "test-cookie-secret-000".to_string(),
6960 show_adoption: true,
6961 ..Config::default()
6962 };
6963 AppState::new(config, db).unwrap()
6964 }
6965
6966 async fn about_body(state: AppState) -> String {
6967 let resp = router(state)
6968 .oneshot(
6969 Request::builder()
6970 .uri("/about")
6971 .body(Body::empty())
6972 .unwrap(),
6973 )
6974 .await
6975 .unwrap();
6976 assert_eq!(resp.status(), StatusCode::OK);
6977 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
6978 .await
6979 .unwrap();
6980 String::from_utf8(bytes.to_vec()).unwrap()
6981 }
6982
6983 #[tokio::test]
6985 async fn about_omits_adoption_line_by_default() {
6986 let state = test_state(&[]).await;
6987 assert!(!state.config.show_adoption);
6988 let body = about_body(state).await;
6989 assert!(
6990 !body.contains("atproto network"),
6991 "the adoption line must not render by default"
6992 );
6993 }
6994
6995 #[tokio::test]
6996 async fn about_renders_adoption_line_when_enabled() {
6997 let body = about_body(adoption_state(7_318, false).await).await;
7005 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7008 assert!(
7009 flat.contains("7318 accounts on the atproto network hold"),
7010 "the count did not render in its own sentence: {flat}",
7011 );
7012 assert!(
7013 body.contains("accounts on the atproto network hold"),
7014 "{body}"
7015 );
7016 assert!(
7017 body.contains("2026-08-13"),
7018 "the observation date must render"
7019 );
7020 assert!(
7021 body.contains("lower bound"),
7022 "the non-archival caveat must ride along with the number"
7023 );
7024 assert!(
7025 !body.contains("At least"),
7026 "an untruncated count is exact-ish"
7027 );
7028 }
7029
7030 #[tokio::test]
7032 async fn about_adoption_line_is_singular_at_one() {
7033 let body = about_body(adoption_state(1, false).await).await;
7034 assert!(
7035 body.contains("account on the atproto network holds"),
7036 "{body}"
7037 );
7038 }
7039
7040 #[tokio::test]
7042 async fn about_adoption_line_says_at_least_when_truncated() {
7043 let body = about_body(adoption_state(25_000, true).await).await;
7044 assert!(body.contains("At least"), "{body}");
7045 }
7046
7047 #[tokio::test]
7049 async fn about_omits_line_when_enabled_with_no_observation() {
7050 let db = store::init_url("sqlite::memory:").await.unwrap();
7051 let config = Config {
7052 cookie_secret: "test-cookie-secret-000".to_string(),
7053 show_adoption: true,
7054 ..Config::default()
7055 };
7056 let body = about_body(AppState::new(config, db).unwrap()).await;
7057 assert!(!body.contains("atproto network"));
7058 }
7059
7060 #[tokio::test]
7061 async fn cache_control_public_on_about_no_store_on_authed() {
7062 let state = test_state(&["did:plc:admin"]).await;
7063 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7064 let app = router(state);
7065
7066 let about = app
7068 .clone()
7069 .oneshot(
7070 Request::builder()
7071 .uri("/about")
7072 .body(Body::empty())
7073 .unwrap(),
7074 )
7075 .await
7076 .unwrap();
7077 assert_eq!(
7078 about.headers().get(header::CACHE_CONTROL).unwrap(),
7079 "public, max-age=300"
7080 );
7081 assert_eq!(
7087 about.headers()["content-security-policy"],
7088 EXPECTED_CSP,
7089 "the CSP is not the policy the router promises"
7090 );
7091 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7092
7093 for path in ["/privacy", "/terms"] {
7095 let resp = app
7096 .clone()
7097 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7098 .await
7099 .unwrap();
7100 assert_eq!(resp.status(), StatusCode::OK);
7101 assert_eq!(
7102 resp.headers().get(header::CACHE_CONTROL).unwrap(),
7103 "public, max-age=300",
7104 "{path} should be publicly cacheable"
7105 );
7106 assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7108 assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7109 }
7110
7111 let login = app
7113 .clone()
7114 .oneshot(
7115 Request::builder()
7116 .uri("/login")
7117 .body(Body::empty())
7118 .unwrap(),
7119 )
7120 .await
7121 .unwrap();
7122 assert_eq!(
7123 login.headers().get(header::CACHE_CONTROL).unwrap(),
7124 "public, max-age=300"
7125 );
7126
7127 let home = app
7129 .oneshot(
7130 Request::builder()
7131 .uri("/")
7132 .header(header::COOKIE, admin_cookie)
7133 .body(Body::empty())
7134 .unwrap(),
7135 )
7136 .await
7137 .unwrap();
7138 assert_eq!(
7139 home.headers().get(header::CACHE_CONTROL).unwrap(),
7140 "no-store"
7141 );
7142 }
7143
7144 #[tokio::test]
7145 async fn beta_redeem_page_renders() {
7146 let state = test_state(&[]).await;
7147 let app = router(state);
7148 let resp = app
7149 .oneshot(
7150 Request::builder()
7151 .uri("/beta/redeem")
7152 .body(Body::empty())
7153 .unwrap(),
7154 )
7155 .await
7156 .unwrap();
7157 assert_eq!(resp.status(), StatusCode::OK);
7158 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7159 .await
7160 .unwrap();
7161 let html = String::from_utf8(bytes.to_vec()).unwrap();
7162 assert!(html.contains("Invite code"));
7163 assert!(html.contains("/beta/redeem"));
7164 }
7165
7166 #[tokio::test]
7167 async fn rate_limit_returns_429_after_burst() {
7168 let db = store::init_url("sqlite::memory:").await.unwrap();
7171 store::ensure_seed(&db, &[]).await.unwrap();
7172 let config = Config {
7173 cookie_secret: "test-cookie-secret-000".to_string(),
7174 beta_cap: 3,
7175 trusted_ip_header: Some("cf-connecting-ip".to_string()),
7176 ..Config::default()
7177 };
7178 let state = AppState::new(config, db).unwrap();
7179 let app = router(state);
7180 let mut saw_429 = false;
7184 for _ in 0..(RATE_BURST as usize + 5) {
7185 let resp = app
7186 .clone()
7187 .oneshot(
7188 Request::builder()
7189 .method("POST")
7190 .uri("/beta/redeem")
7191 .header("content-type", "application/x-www-form-urlencoded")
7192 .header("cf-connecting-ip", "203.0.113.200")
7193 .body(Body::from("code=FEATHER-NOPENOPE"))
7194 .unwrap(),
7195 )
7196 .await
7197 .unwrap();
7198 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7199 saw_429 = true;
7200 break;
7201 }
7202 }
7203 assert!(saw_429, "expected a 429 after exhausting the burst");
7204 }
7205
7206 #[tokio::test]
7219 async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7220 let state = test_state(&[]).await;
7221 assert!(
7222 state.config.trusted_ip_header.is_none(),
7223 "no proxy header is trusted here"
7224 );
7225 let app = router(state);
7226 let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7227 let mut saw_429 = false;
7228 for i in 0..(RATE_BURST as usize + 5) {
7229 let forged = format!("10.9.8.{}", i % 250);
7230 let resp = app
7231 .clone()
7232 .oneshot(
7233 Request::builder()
7234 .method("POST")
7235 .uri("/beta/redeem")
7236 .header("content-type", "application/x-www-form-urlencoded")
7237 .header("x-forwarded-for", forged)
7238 .extension(axum::extract::ConnectInfo(peer))
7239 .body(Body::from("code=FEATHER-NOPENOPE"))
7240 .unwrap(),
7241 )
7242 .await
7243 .unwrap();
7244 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7245 saw_429 = true;
7246 break;
7247 }
7248 }
7249 assert!(
7250 saw_429,
7251 "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7252 );
7253 }
7254
7255 #[tokio::test]
7264 async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7265 let did = "did:plc:privateadder";
7266 let state = test_state_with_caps(did, 0, 0).await;
7267 let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7268 let port: u16 = base
7269 .trim_end_matches('/')
7270 .rsplit(':')
7271 .next()
7272 .unwrap()
7273 .parse()
7274 .unwrap();
7275 crate::net::test_host_override(
7276 "private-add.test",
7277 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7278 );
7279 let cookie = session_cookie(&state, did, None);
7280 let resp = router(state.clone())
7281 .oneshot(
7282 Request::builder()
7283 .method("POST")
7284 .uri("/subscriptions")
7285 .header(header::COOKIE, cookie)
7286 .header("content-type", "application/x-www-form-urlencoded")
7287 .body(Body::from(format!(
7288 "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7289 )))
7290 .unwrap(),
7291 )
7292 .await
7293 .unwrap();
7294 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7295 let loc = resp
7296 .headers()
7297 .get(header::LOCATION)
7298 .unwrap()
7299 .to_str()
7300 .unwrap();
7301 assert!(loc.contains("Private"), "not refused as private: {loc}");
7302 assert_eq!(
7303 hits.load(std::sync::atomic::Ordering::SeqCst),
7304 0,
7305 "the private feed was FETCHED before being refused"
7306 );
7307 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7308 }
7309
7310 #[tokio::test]
7315 async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7316 let did = "did:plc:renamer4";
7317 let (sidecar, bodies) = spawn_logging_sidecar().await;
7318 let state = test_state_with_sidecar(&[did], &sidecar).await;
7319 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7320 let opml = format!(
7321 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7322 <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7323 <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7324 </body></opml>"
7325 );
7326 let (ct, body) = opml_multipart(opml.as_bytes());
7327 let cookie = session_cookie(&state, did, None);
7328 let resp = router(state.clone())
7329 .oneshot(
7330 Request::builder()
7331 .method("POST")
7332 .uri("/opml")
7333 .header(header::COOKIE, cookie)
7334 .header("content-type", ct)
7335 .body(Body::from(body))
7336 .unwrap(),
7337 )
7338 .await
7339 .unwrap();
7340 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7341 let loc = resp
7342 .headers()
7343 .get(header::LOCATION)
7344 .unwrap()
7345 .to_str()
7346 .unwrap();
7347 assert!(
7348 loc.contains("skipped%20as%20private"),
7349 "not reported as skipped: {loc}"
7350 );
7351 assert!(store::get_feed_by_url(&state.db, tokened)
7352 .await
7353 .unwrap()
7354 .is_none());
7355 let sent = bodies.lock().unwrap().join("\n");
7356 assert!(
7357 sent.contains("public.example"),
7358 "the public feed was not written: {sent}"
7359 );
7360 assert!(
7361 !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7362 "the secret was PUBLISHED to the PDS: {sent}"
7363 );
7364 }
7365
7366 #[tokio::test]
7370 async fn get_login_without_a_seat_is_refused() {
7371 let state = test_state(&[]).await;
7372 let resp = router(state)
7373 .oneshot(
7374 Request::builder()
7375 .method("GET")
7376 .uri("/login?handle=alice.bsky.social")
7377 .body(Body::empty())
7378 .unwrap(),
7379 )
7380 .await
7381 .unwrap();
7382 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7383 assert_eq!(
7384 resp.headers().get(header::LOCATION).unwrap(),
7385 "/beta/redeem"
7386 );
7387 }
7388
7389 async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7393 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7394 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7395 let addr = listener.local_addr().unwrap();
7396 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7397 let sink = log.clone();
7398 tokio::spawn(async move {
7399 loop {
7400 let Ok((mut sock, _)) = listener.accept().await else {
7401 break;
7402 };
7403 let mut raw: Vec<u8> = Vec::new();
7404 let mut chunk = [0u8; 4096];
7405 let text = loop {
7406 let Ok(n) = sock.read(&mut chunk).await else {
7407 break String::new();
7408 };
7409 if n == 0 {
7410 break String::from_utf8_lossy(&raw).to_string();
7411 }
7412 raw.extend_from_slice(&chunk[..n]);
7413 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7414 continue;
7415 };
7416 let (head, body) = raw.split_at(split + 4);
7417 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7418 let (k, v) = l.split_once(':')?;
7419 k.eq_ignore_ascii_case("content-length")
7420 .then(|| v.trim().parse::<usize>().ok())?
7421 });
7422 if want.is_none_or(|w| body.len() >= w) {
7423 break String::from_utf8_lossy(&raw).to_string();
7424 }
7425 };
7426 let path = text
7427 .lines()
7428 .next()
7429 .and_then(|l| l.split_whitespace().nth(1))
7430 .unwrap_or("")
7431 .to_string();
7432 let body_text = text
7433 .split_once("\r\n\r\n")
7434 .map(|(_, b)| b)
7435 .unwrap_or("")
7436 .to_string();
7437 sink.lock().unwrap().push(format!("{path} {body_text}"));
7438 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();
7439 let resp = format!(
7440 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7441 body.len(),
7442 body
7443 );
7444 let _ = sock.write_all(resp.as_bytes()).await;
7445 let _ = sock.flush().await;
7446 }
7447 });
7448 (format!("http://{addr}"), log)
7449 }
7450
7451 #[tokio::test]
7460 async fn signing_out_flushes_before_it_revokes_through_the_route() {
7461 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7462 let (sidecar, log) = spawn_logging_sidecar().await;
7463 let state = test_state_with_sidecar(&[did], &sidecar).await;
7464 crate::store::upsert_cursor(
7465 &state.db,
7466 &crate::store::ReadCursor {
7467 did: did.to_string(),
7468 feed_url: "https://example.com/feed.xml".into(),
7469 read_through: None,
7470 read_ids: "[\"1\"]".into(),
7471 unread_ids: "[]".into(),
7472 dirty: true,
7473 pds_created: false,
7474 updated_at: "2026-09-13T21:22:40Z".into(),
7475 },
7476 )
7477 .await
7478 .unwrap();
7479 let cookie = session_cookie(&state, did, None);
7480 let resp = router(state.clone())
7481 .oneshot(
7482 Request::builder()
7483 .method("POST")
7484 .uri("/logout")
7485 .header(header::COOKIE, cookie)
7486 .body(Body::empty())
7487 .unwrap(),
7488 )
7489 .await
7490 .unwrap();
7491 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7492
7493 let entries = log.lock().unwrap().clone();
7494 let flush = entries
7495 .iter()
7496 .position(|e| e.starts_with("/internal/repo "));
7497 let revoke = entries
7498 .iter()
7499 .position(|e| e.starts_with("/internal/revoke "));
7500 assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7501 assert!(
7502 flush.is_some(),
7503 "sign-out did not attempt a flush before revoking: {entries:?}"
7504 );
7505 assert!(
7506 flush < revoke,
7507 "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7508 );
7509 }
7510
7511 const EXPECTED_CSP: &str = "default-src 'self'; \
7515 script-src 'self'; \
7516 style-src 'self' 'unsafe-inline'; \
7517 img-src 'self' https: data:; \
7518 font-src 'self'; \
7519 connect-src 'self'; \
7520 form-action 'self'; \
7521 base-uri 'self'; \
7522 frame-ancestors 'none'; \
7523 object-src 'none'";
7524
7525 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7528 let boundary = "----featherreadertestboundary";
7529 let mut body = Vec::new();
7530 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7531 body.extend_from_slice(
7532 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7533 );
7534 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7535 body.extend_from_slice(payload);
7536 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7537 (format!("multipart/form-data; boundary={boundary}"), body)
7538 }
7539
7540 #[tokio::test]
7541 async fn opml_import_oversize_upload_returns_413() {
7542 let state = test_state(&["did:plc:admin"]).await;
7543 let cookie = session_cookie(&state, "did:plc:admin", None);
7544 let app = router(state);
7545
7546 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7548 let (content_type, body) = opml_multipart(&payload);
7549
7550 let resp = app
7551 .oneshot(
7552 Request::builder()
7553 .method("POST")
7554 .uri("/opml")
7555 .header("content-type", content_type)
7556 .header(header::COOKIE, cookie)
7557 .body(Body::from(body))
7558 .unwrap(),
7559 )
7560 .await
7561 .unwrap();
7562 assert_eq!(
7563 resp.status(),
7564 StatusCode::PAYLOAD_TOO_LARGE,
7565 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7566 );
7567 }
7568
7569 #[tokio::test]
7580 async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7581 let state = test_state(&["did:plc:admin"]).await;
7582 let cookie = session_cookie(&state, "did:plc:admin", None);
7583 let app = router(state);
7584
7585 let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7587 let (content_type, body) = opml_multipart(&payload);
7588
7589 let resp = app
7590 .oneshot(
7591 Request::builder()
7592 .method("POST")
7593 .uri("/opml")
7594 .header("content-type", content_type)
7595 .header(header::COOKIE, cookie)
7596 .body(Body::from(body))
7597 .unwrap(),
7598 )
7599 .await
7600 .unwrap();
7601 assert_eq!(
7602 resp.status(),
7603 StatusCode::PAYLOAD_TOO_LARGE,
7604 "a payload over the route's cap but under the framework's was accepted — \
7605 the route's own DefaultBodyLimit layer is not doing anything"
7606 );
7607 }
7608
7609 #[tokio::test]
7610 async fn opml_import_under_limit_upload_is_accepted() {
7611 let state = test_state(&["did:plc:admin"]).await;
7612 let cookie = session_cookie(&state, "did:plc:admin", None);
7613 let db = state.db.clone();
7614 let app = router(state);
7615
7616 let opml = br#"<?xml version="1.0"?>
7619<opml version="2.0"><body>
7620 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7621</body></opml>"#;
7622 let (content_type, body) = opml_multipart(opml);
7623
7624 let resp = app
7625 .oneshot(
7626 Request::builder()
7627 .method("POST")
7628 .uri("/opml")
7629 .header("content-type", content_type)
7630 .header(header::COOKIE, cookie)
7631 .body(Body::from(body))
7632 .unwrap(),
7633 )
7634 .await
7635 .unwrap();
7636 assert_eq!(
7643 resp.status(),
7644 StatusCode::SEE_OTHER,
7645 "an under-cap OPML upload was not accepted (status {})",
7646 resp.status(),
7647 );
7648 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7652 .bind("https://example.com/feed.xml")
7653 .fetch_one(&db)
7654 .await
7655 .unwrap();
7656 assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7657 let location = resp
7658 .headers()
7659 .get(header::LOCATION)
7660 .and_then(|v| v.to_str().ok())
7661 .unwrap_or_default()
7662 .to_string();
7663 assert!(
7664 !location.starts_with("/login"),
7665 "the import bounced to login instead of being accepted: {location}",
7666 );
7667 }
7668
7669 #[tokio::test]
7670 async fn opml_import_logged_out_redirects_to_login() {
7671 let state = test_state(&["did:plc:admin"]).await;
7674 let app = router(state);
7675
7676 let opml = b"<opml version=\"2.0\"><body></body></opml>";
7677 let (content_type, body) = opml_multipart(opml);
7678
7679 let resp = app
7680 .oneshot(
7681 Request::builder()
7682 .method("POST")
7683 .uri("/opml")
7684 .header("content-type", content_type)
7685 .body(Body::from(body))
7686 .unwrap(),
7687 )
7688 .await
7689 .unwrap();
7690 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7691 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7692 }
7693
7694 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7701 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7702 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7703 let addr = listener.local_addr().unwrap();
7704 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7705 tokio::spawn(async move {
7706 let (mut sock, _) = listener.accept().await.unwrap();
7707 let mut buf = vec![0u8; 4096];
7708 let n = sock.read(&mut buf).await.unwrap();
7709 let req = String::from_utf8_lossy(&buf[..n]).to_string();
7710 let did = req
7712 .split("\r\n\r\n")
7713 .nth(1)
7714 .and_then(|body| {
7715 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7716 v.get("did")?.as_str().map(str::to_string)
7717 })
7718 .unwrap_or_default();
7719 let is_revoke = req.starts_with("POST /internal/revoke");
7720 let body = serde_json::json!({
7721 "ok": true, "did": did, "revoked": true, "hadSession": true
7722 })
7723 .to_string();
7724 let resp = format!(
7725 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7726 body.len(),
7727 body
7728 );
7729 sock.write_all(resp.as_bytes()).await.unwrap();
7730 sock.flush().await.unwrap();
7731 let _ = tx.send(if is_revoke { did } else { String::new() });
7732 });
7733 (format!("http://{addr}"), rx)
7734 }
7735
7736 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7738 let defaults = Config::default();
7739 test_state_with_sidecar_and(
7740 allowed,
7741 sidecar_url,
7742 defaults.standard_site,
7743 defaults.max_feeds_global,
7744 )
7745 .await
7746 }
7747
7748 async fn test_state_with_sidecar_and(
7751 allowed: &[&str],
7752 sidecar_url: &str,
7753 standard_site: bool,
7754 max_feeds_global: i64,
7755 ) -> AppState {
7756 let db = store::init_url("sqlite::memory:").await.unwrap();
7757 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7758 store::ensure_seed(&db, &dids).await.unwrap();
7759 let mut config = Config {
7760 allowed_dids: dids,
7761 cookie_secret: "test-cookie-secret-000".to_string(),
7762 beta_cap: 3,
7763 standard_site,
7764 max_feeds_global,
7765 ..Config::default()
7766 };
7767 config.sidecar.public_url = sidecar_url.to_string();
7768 config.sidecar.internal_url = sidecar_url.to_string();
7769 AppState::new(config, db).unwrap()
7770 }
7771
7772 #[tokio::test]
7775 async fn account_delete_purges_rows_and_triggers_revoke() {
7776 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
7777 let did = "did:plc:leaver";
7778 let state = test_state_with_sidecar(&[], &sidecar_url).await;
7779
7780 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
7782 .await
7783 .unwrap();
7784 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
7785 store::mint_code(&state.db, did, 3600).await.unwrap();
7786 assert!(store::has_beta_access(&state.db, did).await.unwrap());
7787
7788 let cookie = session_cookie(&state, did, Some("leaver.example"));
7789 let app = router(state.clone());
7790
7791 let resp = app
7792 .oneshot(
7793 Request::builder()
7794 .method("POST")
7795 .uri("/account/delete")
7796 .header(header::COOKIE, cookie)
7797 .header("content-type", "application/x-www-form-urlencoded")
7798 .body(Body::from("confirm=DELETE"))
7799 .unwrap(),
7800 )
7801 .await
7802 .unwrap();
7803
7804 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7806 assert!(resp
7807 .headers()
7808 .get(header::LOCATION)
7809 .unwrap()
7810 .to_str()
7811 .unwrap()
7812 .starts_with("/login"));
7813 let set_cookie = resp
7814 .headers()
7815 .get(header::SET_COOKIE)
7816 .unwrap()
7817 .to_str()
7818 .unwrap();
7819 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
7820
7821 let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
7828 .await
7829 .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
7830 .unwrap();
7831 assert_eq!(
7832 revoked_did, did,
7833 "sidecar revoke must fire for the caller DID"
7834 );
7835
7836 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
7838 let codes: i64 =
7839 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
7840 .bind(did)
7841 .fetch_one(&state.db)
7842 .await
7843 .unwrap();
7844 assert_eq!(codes, 0);
7845 }
7846
7847 #[tokio::test]
7850 async fn account_delete_without_confirm_is_a_noop() {
7851 let did = "did:plc:staying";
7852 let state = test_state(&[]).await;
7853 store::grant_access(&state.db, did, None, "test", None)
7854 .await
7855 .unwrap();
7856 let cookie = session_cookie(&state, did, None);
7857 let app = router(state.clone());
7858
7859 let resp = app
7860 .oneshot(
7861 Request::builder()
7862 .method("POST")
7863 .uri("/account/delete")
7864 .header(header::COOKIE, cookie)
7865 .header("content-type", "application/x-www-form-urlencoded")
7866 .body(Body::from("confirm=nope"))
7867 .unwrap(),
7868 )
7869 .await
7870 .unwrap();
7871
7872 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7873 assert!(resp
7874 .headers()
7875 .get(header::LOCATION)
7876 .unwrap()
7877 .to_str()
7878 .unwrap()
7879 .starts_with("/manage"));
7880 assert!(store::has_beta_access(&state.db, did).await.unwrap());
7882 }
7883
7884 #[tokio::test]
7891 async fn pds_outage_does_not_widen_cross_did_access() {
7892 let did_a = "did:plc:aaaa";
7893 let state = test_state(&[]).await;
7894 store::grant_access(&state.db, did_a, None, "test", None)
7895 .await
7896 .unwrap();
7897
7898 let feed_a = store::upsert_feed(
7901 &state.db,
7902 &store::NewFeed {
7903 url: "https://a.example/feed.xml".to_string(),
7904 title: Some("A".to_string()),
7905 ..Default::default()
7906 },
7907 )
7908 .await
7909 .unwrap();
7910 let feed_b = store::upsert_feed(
7911 &state.db,
7912 &store::NewFeed {
7913 url: "https://b.example/feed.xml".to_string(),
7914 title: Some("B".to_string()),
7915 ..Default::default()
7916 },
7917 )
7918 .await
7919 .unwrap();
7920 store::insert_entries(
7921 &state.db,
7922 feed_b,
7923 &[store::NewEntry {
7924 guid: "b-1".to_string(),
7925 url: Some("https://b.example/1".to_string()),
7926 title: Some("B one".to_string()),
7927 published: Some("2026-07-11T00:00:00Z".to_string()),
7928 content_html: Some("<p>secret B body</p>".to_string()),
7929 ..Default::default()
7930 }],
7931 0,
7932 )
7933 .await
7934 .unwrap();
7935 store::replace_sub_refs(&state.db, did_a, &[feed_a])
7937 .await
7938 .unwrap();
7939 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
7942 .await
7943 .unwrap();
7944 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
7945 .await
7946 .unwrap()[0]
7947 .id;
7948 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
7949 .await
7950 .unwrap();
7951
7952 let cookie = session_cookie(&state, did_a, None);
7953 let app = router(state.clone());
7954
7955 let get_b = app
7957 .clone()
7958 .oneshot(
7959 Request::builder()
7960 .method("GET")
7961 .uri(format!("/entries/{b_entry_id}"))
7962 .header(header::COOKIE, cookie.clone())
7963 .body(Body::empty())
7964 .unwrap(),
7965 )
7966 .await
7967 .unwrap();
7968 assert_eq!(
7969 get_b.status(),
7970 StatusCode::NOT_FOUND,
7971 "A must not read B's entry during a PDS outage"
7972 );
7973
7974 let read_b = app
7976 .oneshot(
7977 Request::builder()
7978 .method("POST")
7979 .uri(format!("/entries/{b_entry_id}/read"))
7980 .header(header::COOKIE, cookie)
7981 .header("content-type", "application/x-www-form-urlencoded")
7982 .body(Body::from("read=true"))
7983 .unwrap(),
7984 )
7985 .await
7986 .unwrap();
7987 assert_eq!(
7988 read_b.status(),
7989 StatusCode::NOT_FOUND,
7990 "A must not mark B's entry read during a PDS outage"
7991 );
7992
7993 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
7995 .bind(did_a)
7996 .fetch_all(&state.db)
7997 .await
7998 .unwrap();
7999 assert_eq!(
8000 a_feed_ids,
8001 vec![feed_a],
8002 "outage fallback must not add feeds A never subscribed to"
8003 );
8004 let es_count: i64 =
8006 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
8007 .bind(did_a)
8008 .bind(b_entry_id)
8009 .fetch_one(&state.db)
8010 .await
8011 .unwrap();
8012 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
8013 }
8014
8015 #[tokio::test]
8029 async fn a_logout_with_no_session_counts_as_success() {
8030 let did = "did:plc:aaaa";
8031 let state = test_state(&[]).await;
8032 assert!(
8033 state.oauth.is_some(),
8034 "meaningless without an oauth runtime; the revoke arm would be skipped",
8035 );
8036
8037 revoke_everywhere(&state, did).await;
8038 let rows = state.metrics.snapshot();
8039 let find = |b: crate::metrics::Backend| {
8040 rows.iter()
8041 .find(|r| r.op == "oauth_revoke" && r.backend == b)
8042 .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
8043 };
8044
8045 let rust = find(crate::metrics::Backend::Rust);
8047 assert_eq!(
8048 rust.stats.err_count, 0,
8049 "NoSession was counted as a failure; logout is idempotent",
8050 );
8051 assert_eq!(rust.stats.ok_count, 1);
8052
8053 let sidecar = find(crate::metrics::Backend::Sidecar);
8057 assert_eq!(
8058 sidecar.stats.err_count, 1,
8059 "a failed sidecar revoke was not counted",
8060 );
8061 }
8062
8063 #[tokio::test]
8073 async fn a_failed_rust_revoke_counts_as_an_error() {
8074 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8075 let state = test_state(&[]).await;
8076 let runtime = state.oauth.as_deref().expect("oauth runtime");
8077 crate::oauth::store::put_session(
8078 &state.db,
8079 &runtime.codec,
8080 &crate::oauth::store::OAuthSession {
8081 sub: did.into(),
8082 issuer: "https://auth.invalid".into(),
8083 aud: "https://pds.invalid".into(),
8084 dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
8085 .to_jwk_json()
8086 .unwrap(),
8087 access_token: "at".into(),
8088 refresh_token: "rt".into(),
8089 token_type: "DPoP".into(),
8090 granted_scope: "atproto".into(),
8091 expires_at: Some(crate::store::now_unix() + 3600),
8092 },
8093 )
8094 .await
8095 .unwrap();
8096
8097 revoke_everywhere(&state, did).await;
8098
8099 let rows = state.metrics.snapshot();
8100 let rust = rows
8101 .iter()
8102 .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
8103 .expect("no rust oauth_revoke row");
8104 assert_eq!(
8105 rust.stats.err_count, 1,
8106 "an unreachable PDS must count as a revocation failure",
8107 );
8108 assert_eq!(rust.stats.ok_count, 0);
8109 }
8110
8111 #[test]
8127 fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
8128 for hostile in [
8129 "javascript:alert(1)",
8130 "JavaScript:alert(1)",
8131 " javascript:alert(1)",
8132 "data:text/html;base64,PHNjcmlwdD4=",
8133 "vbscript:msgbox(1)",
8134 "file:///etc/passwd",
8135 "//evil.example/path",
8139 ] {
8140 let link = SafeLink::external(hostile);
8141 assert!(
8142 link.is_empty(),
8143 "{hostile:?} produced a non-empty href: {link}",
8144 );
8145 assert!(
8146 !link.to_string().to_ascii_lowercase().contains("script"),
8147 "{hostile:?} leaked into the rendered link",
8148 );
8149 }
8150
8151 for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8154 let link = SafeLink::external(good);
8155 assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8156 assert_eq!(link.to_string(), good);
8157 }
8158 }
8159
8160 #[tokio::test]
8175 async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8176 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8177 let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8178 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8179 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8180
8181 let resp = router(state)
8182 .oneshot(
8183 Request::builder()
8184 .uri("/?view=starred")
8185 .body(Body::empty())
8186 .unwrap(),
8187 )
8188 .await
8189 .unwrap();
8190 assert_eq!(resp.status(), StatusCode::OK);
8191 let body = String::from_utf8(
8192 axum::body::to_bytes(resp.into_body(), usize::MAX)
8193 .await
8194 .unwrap()
8195 .to_vec(),
8196 )
8197 .unwrap();
8198
8199 assert!(
8202 !body.to_ascii_lowercase().contains("javascript:"),
8203 "the hostile scheme reached the rendered page",
8204 );
8205 assert!(
8208 body.contains("unusable link"),
8209 "the row was dropped instead of rendering without an anchor",
8210 );
8211 }
8212
8213 #[tokio::test]
8230 async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8231 let did = "did:plc:readerhref";
8232 let state = test_state(&[]).await;
8233 store::grant_access(&state.db, did, None, "test", None)
8234 .await
8235 .unwrap();
8236 let feed = store::upsert_feed(
8237 &state.db,
8238 &store::NewFeed {
8239 url: "https://href.example/feed.xml".to_string(),
8240 title: Some("Href".to_string()),
8241 ..Default::default()
8242 },
8243 )
8244 .await
8245 .unwrap();
8246 store::insert_entries(
8248 &state.db,
8249 feed,
8250 &[
8251 store::NewEntry {
8252 guid: "hostile-1".to_string(),
8253 url: Some("javascript:alert(1)".to_string()),
8254 title: Some("Hostile entry".to_string()),
8255 published: Some("2026-07-11T00:00:00Z".to_string()),
8256 ..Default::default()
8257 },
8258 store::NewEntry {
8259 guid: "benign-1".to_string(),
8260 url: Some("https://href.example/post".to_string()),
8261 title: Some("Benign entry".to_string()),
8262 published: Some("2026-07-10T00:00:00Z".to_string()),
8263 ..Default::default()
8264 },
8265 ],
8266 0,
8267 )
8268 .await
8269 .unwrap();
8270 store::replace_sub_refs(&state.db, did, &[feed])
8271 .await
8272 .unwrap();
8273 let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8274 let id_of = |guid: &str| {
8275 rows.iter()
8276 .find(|r| r.guid == guid)
8277 .unwrap_or_else(|| panic!("{guid} was not inserted"))
8278 .id
8279 };
8280
8281 let cookie = session_cookie(&state, did, None);
8282 let app = router(state.clone());
8283
8284 let render = |id: i64| {
8285 let app = app.clone();
8286 let cookie = cookie.clone();
8287 async move {
8288 let resp = app
8289 .oneshot(
8290 Request::builder()
8291 .method("GET")
8292 .uri(format!("/entries/{id}"))
8293 .header(header::COOKIE, cookie)
8294 .body(Body::empty())
8295 .unwrap(),
8296 )
8297 .await
8298 .unwrap();
8299 assert_eq!(resp.status(), StatusCode::OK);
8300 String::from_utf8(
8301 axum::body::to_bytes(resp.into_body(), usize::MAX)
8302 .await
8303 .unwrap()
8304 .to_vec(),
8305 )
8306 .unwrap()
8307 }
8308 };
8309
8310 let hostile = render(id_of("hostile-1")).await;
8311 assert!(
8314 hostile.contains("Hostile entry"),
8315 "the reader did not render the entry: {hostile}",
8316 );
8317 assert!(
8318 !hostile.to_ascii_lowercase().contains("javascript:"),
8319 "the hostile scheme reached the reader page: {hostile}",
8320 );
8321 assert!(
8325 !hostile.contains("actionbar-open"),
8326 "the action bar rendered an open-original link for a refused URL: {hostile}",
8327 );
8328 assert!(
8329 !hostile.contains("Original \u{2197}"),
8330 "the byline rendered an original link for a refused URL: {hostile}",
8331 );
8332
8333 let benign = render(id_of("benign-1")).await;
8336 assert!(
8337 benign.contains("Benign entry"),
8338 "the reader did not render the benign entry: {benign}",
8339 );
8340 assert_eq!(
8344 benign
8345 .matches(r#"href="https://href.example/post""#)
8346 .count(),
8347 2,
8348 "entry.html has two `href`s for the entry URL — the byline link and \
8349 the action-bar button — and this render produced a different \
8350 number: {benign}",
8351 );
8352 assert!(
8353 benign.contains("actionbar-open"),
8354 "a legitimate entry lost its open-original button: {benign}",
8355 );
8356 assert!(
8357 benign.contains("Original \u{2197}"),
8358 "a legitimate entry lost its byline link: {benign}",
8359 );
8360 }
8361
8362 #[tokio::test]
8382 async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8383 let did_a = "did:plc:aaaa";
8384 let state = test_state(&[]).await;
8385 store::grant_access(&state.db, did_a, None, "test", None)
8386 .await
8387 .unwrap();
8388
8389 let feed_a = store::upsert_feed(
8390 &state.db,
8391 &store::NewFeed {
8392 url: "https://a.example/feed.xml".to_string(),
8393 title: Some("A".to_string()),
8394 ..Default::default()
8395 },
8396 )
8397 .await
8398 .unwrap();
8399 let _feed_b = store::upsert_feed(
8400 &state.db,
8401 &store::NewFeed {
8402 url: "https://b.example/feed.xml".to_string(),
8403 title: Some("B".to_string()),
8404 ..Default::default()
8405 },
8406 )
8407 .await
8408 .unwrap();
8409 store::replace_sub_refs(&state.db, did_a, &[feed_a])
8412 .await
8413 .unwrap();
8414
8415 assert!(
8420 state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8421 "this test is only meaningful on the outage path; the repo answered",
8422 );
8423
8424 let resolved = resolve_subscriptions(&state, did_a).await;
8425
8426 let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8427 assert_eq!(
8428 urls,
8429 vec!["https://a.example/feed.xml"],
8430 "the outage fallback must return the caller's OWN subscriptions only; \
8431 any other feed here is cross-tenant read access granted by an outage",
8432 );
8433 }
8434
8435 async fn test_state_with_caps(
8438 did: &str,
8439 max_subs_per_did: i64,
8440 max_feeds_global: i64,
8441 ) -> AppState {
8442 let db = store::init_url("sqlite::memory:").await.unwrap();
8443 let config = Config {
8444 cookie_secret: "test-cookie-secret-000".to_string(),
8445 beta_cap: 100,
8446 max_subs_per_did,
8447 max_feeds_global,
8448 ..Config::default()
8449 };
8450 store::grant_access(&db, did, None, "test", None)
8451 .await
8452 .unwrap();
8453 AppState::new(config, db).unwrap()
8454 }
8455
8456 fn opml_with_feeds(n: usize) -> String {
8458 let mut outlines = String::new();
8459 for i in 0..n {
8460 outlines.push_str(&format!(
8461 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8462 ));
8463 }
8464 format!(
8465 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8466 )
8467 }
8468
8469 #[tokio::test]
8474 async fn opml_import_enforces_global_feeds_ceiling() {
8475 let did = "did:plc:importer";
8476 let state = test_state_with_caps(did, 0, 3).await;
8478 let cookie = session_cookie(&state, did, None);
8479 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8480 let app = router(state.clone());
8481
8482 let resp = app
8483 .oneshot(
8484 Request::builder()
8485 .method("POST")
8486 .uri("/opml")
8487 .header(header::COOKIE, cookie)
8488 .header("content-type", ct)
8489 .body(Body::from(body))
8490 .unwrap(),
8491 )
8492 .await
8493 .unwrap();
8494 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8495
8496 let feeds = store::count_feeds(&state.db).await.unwrap();
8497 assert!(
8498 feeds <= 3,
8499 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8500 );
8501 }
8502
8503 #[tokio::test]
8512 async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8513 let did = "did:plc:typoist";
8514 let state = test_state_with_caps(did, 0, 0).await;
8515 let cookie = session_cookie(&state, did, None);
8516 for input in [
8517 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8518 "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8519 ] {
8520 let resp = router(state.clone())
8521 .oneshot(
8522 Request::builder()
8523 .method("POST")
8524 .uri("/subscriptions")
8525 .header(header::COOKIE, cookie.clone())
8526 .header("content-type", "application/x-www-form-urlencoded")
8527 .body(Body::from(format!("url={input}")))
8528 .unwrap(),
8529 )
8530 .await
8531 .unwrap();
8532 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8533 let loc = resp
8534 .headers()
8535 .get(header::LOCATION)
8536 .unwrap()
8537 .to_str()
8538 .unwrap();
8539 assert!(
8540 loc.contains("kind%20of%20feed"),
8541 "expected the unsupported-feed flash for {input}, got {loc}"
8542 );
8543 assert!(
8544 !loc.contains("Private"),
8545 "a storability refusal was reported as a privacy one for {input}: {loc}"
8546 );
8547 }
8548 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8549 }
8550
8551 #[tokio::test]
8558 async fn opml_import_reports_entries_this_instance_cannot_store() {
8559 let did = "did:plc:renamer4";
8560 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8561 let state = test_state_with_sidecar(&[did], &sidecar).await;
8562 assert!(!state.config.standard_site);
8563 let opml = format!(
8564 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8565 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8566 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8567 </body></opml>"
8568 );
8569 let (ct, body) = opml_multipart(opml.as_bytes());
8570 let cookie = session_cookie(&state, did, None);
8571 let resp = router(state.clone())
8572 .oneshot(
8573 Request::builder()
8574 .method("POST")
8575 .uri("/opml")
8576 .header(header::COOKIE, cookie)
8577 .header("content-type", ct)
8578 .body(Body::from(body))
8579 .unwrap(),
8580 )
8581 .await
8582 .unwrap();
8583 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8584 let loc = resp
8585 .headers()
8586 .get(header::LOCATION)
8587 .unwrap()
8588 .to_str()
8589 .unwrap();
8590 assert!(
8591 loc.contains("Imported%201%20feed"),
8592 "unexpected flash: {loc}"
8593 );
8594 assert!(
8595 loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8596 "the dropped entry was not reported: {loc}"
8597 );
8598 assert!(
8600 !loc.contains("site.standard.publication"),
8601 "the URI was echoed: {loc}"
8602 );
8603 }
8604
8605 #[tokio::test]
8608 async fn opml_import_enforces_per_did_cap() {
8609 let did = "did:plc:capped";
8610 let state = test_state_with_caps(did, 2, 0).await;
8612 let existing_a = store::upsert_feed(
8613 &state.db,
8614 &store::NewFeed {
8615 url: "https://have-a.example/feed.xml".to_string(),
8616 ..Default::default()
8617 },
8618 )
8619 .await
8620 .unwrap();
8621 let existing_b = store::upsert_feed(
8622 &state.db,
8623 &store::NewFeed {
8624 url: "https://have-b.example/feed.xml".to_string(),
8625 ..Default::default()
8626 },
8627 )
8628 .await
8629 .unwrap();
8630 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8631 .await
8632 .unwrap();
8633 let before = store::count_feeds(&state.db).await.unwrap();
8634
8635 let cookie = session_cookie(&state, did, None);
8636 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8637 let app = router(state.clone());
8638 let resp = app
8639 .oneshot(
8640 Request::builder()
8641 .method("POST")
8642 .uri("/opml")
8643 .header(header::COOKIE, cookie)
8644 .header("content-type", ct)
8645 .body(Body::from(body))
8646 .unwrap(),
8647 )
8648 .await
8649 .unwrap();
8650 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8651 let after = store::count_feeds(&state.db).await.unwrap();
8653 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8654 }
8655
8656 #[tokio::test]
8659 async fn single_add_enforces_per_did_cap() {
8660 let did = "did:plc:subcapped";
8661 let state = test_state_with_caps(did, 1, 0).await;
8662 let f = store::upsert_feed(
8663 &state.db,
8664 &store::NewFeed {
8665 url: "https://have.example/feed.xml".to_string(),
8666 ..Default::default()
8667 },
8668 )
8669 .await
8670 .unwrap();
8671 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8672 let cookie = session_cookie(&state, did, None);
8673 let app = router(state.clone());
8674 let resp = app
8675 .oneshot(
8676 Request::builder()
8677 .method("POST")
8678 .uri("/subscriptions")
8679 .header(header::COOKIE, cookie)
8680 .header("content-type", "application/x-www-form-urlencoded")
8681 .body(Body::from("url=https://another.example/feed.xml"))
8682 .unwrap(),
8683 )
8684 .await
8685 .unwrap();
8686 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8687 let loc = resp
8688 .headers()
8689 .get(header::LOCATION)
8690 .unwrap()
8691 .to_str()
8692 .unwrap();
8693 assert!(
8694 loc.contains("Subscription%20limit%20reached"),
8695 "expected sub-limit flash, got {loc}"
8696 );
8697 }
8698
8699 #[tokio::test]
8708 async fn the_reader_index_pages_instead_of_rendering_everything() {
8709 let did = "did:plc:pager";
8710 let state = test_state(&[]).await;
8711 store::grant_access(&state.db, did, None, "test", None)
8712 .await
8713 .unwrap();
8714 let feed = store::upsert_feed(
8715 &state.db,
8716 &store::NewFeed {
8717 url: "https://pager.example/feed.xml".to_string(),
8718 title: Some("Pager".to_string()),
8719 ..Default::default()
8720 },
8721 )
8722 .await
8723 .unwrap();
8724 let total = 250_usize;
8725 let entries: Vec<store::NewEntry> = (0..total)
8726 .map(|i| store::NewEntry {
8727 guid: format!("p-{i:04}"),
8728 url: Some(format!("https://pager.example/{i}")),
8729 title: Some(format!("Article {i:04}")),
8730 published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8731 content_html: Some("x".repeat(4_000)),
8732 ..Default::default()
8733 })
8734 .collect();
8735 store::insert_entries(&state.db, feed, &entries, 0)
8736 .await
8737 .unwrap();
8738 store::replace_sub_refs(&state.db, did, &[feed])
8739 .await
8740 .unwrap();
8741
8742 let cookie = session_cookie(&state, did, None);
8743 let app = router(state.clone());
8744 let get = |uri: &str| {
8745 let app = app.clone();
8746 let cookie = cookie.clone();
8747 let uri = uri.to_string();
8748 async move {
8749 let resp = app
8750 .oneshot(
8751 Request::builder()
8752 .uri(uri)
8753 .header(header::COOKIE, cookie)
8754 .body(Body::empty())
8755 .unwrap(),
8756 )
8757 .await
8758 .unwrap();
8759 assert_eq!(resp.status(), StatusCode::OK);
8760 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8761 .await
8762 .unwrap();
8763 String::from_utf8(bytes.to_vec()).unwrap()
8764 }
8765 };
8766
8767 let page1 = get("/").await;
8768 let rows1 = page1.matches("<li class=\"entry").count();
8772 assert!(
8773 rows1 <= ENTRIES_PER_PAGE as usize,
8774 "page 1 rendered {rows1} entry links; the list is unbounded"
8775 );
8776 assert!(
8777 rows1 > 0,
8778 "page 1 rendered nothing at all: the page bound swallowed the list"
8779 );
8780 assert!(
8783 page1.contains("250 entries"),
8784 "heading must report the full total, not the page"
8785 );
8786 assert!(
8787 page1.contains("page=2"),
8788 "no way to reach the rest of the list: {}",
8789 &page1[..page1.len().min(400)]
8790 );
8791 assert!(
8793 !page1.contains(&"x".repeat(4_000)),
8794 "the list response carried an article body"
8795 );
8796
8797 let page2 = get("/?page=2").await;
8798 assert!(
8799 page2.matches("<li class=\"entry").count() > 0,
8800 "page 2 rendered no rows at all"
8801 );
8802 assert!(
8803 page2.contains("page=1") || page2.contains("Newer"),
8804 "page 2 offers no way back"
8805 );
8806 let first_title = (0..total)
8808 .map(|i| format!("Article {i:04}"))
8809 .find(|t| page1.contains(t))
8810 .expect("page 1 shows at least one titled article");
8811 assert!(
8812 !page2.contains(&first_title),
8813 "{first_title} appears on both pages"
8814 );
8815
8816 let past_end = get("/?page=999").await;
8822 assert!(
8823 past_end.matches("<li class=\"entry").count() > 0,
8824 "an out-of-range page rendered nothing and offered no way back"
8825 );
8826 assert!(
8827 past_end.contains("page=2"),
8828 "the clamped page offers no pager"
8829 );
8830 }
8831
8832 #[tokio::test]
8839 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
8840 let did = "did:plc:reader";
8841 let state = test_state(&[]).await;
8842 store::grant_access(&state.db, did, None, "test", None)
8843 .await
8844 .unwrap();
8845 let feed = store::upsert_feed(
8846 &state.db,
8847 &store::NewFeed {
8848 url: "https://reader.example/feed.xml".to_string(),
8849 title: Some("Reader".to_string()),
8850 ..Default::default()
8851 },
8852 )
8853 .await
8854 .unwrap();
8855 store::insert_entries(
8856 &state.db,
8857 feed,
8858 &[store::NewEntry {
8859 guid: "r-1".to_string(),
8860 url: Some("https://reader.example/1".to_string()),
8861 title: Some("Article".to_string()),
8862 published: Some("2026-07-11T00:00:00Z".to_string()),
8863 content_html: Some("<p>body</p>".to_string()),
8864 ..Default::default()
8865 }],
8866 0,
8867 )
8868 .await
8869 .unwrap();
8870 store::replace_sub_refs(&state.db, did, &[feed])
8871 .await
8872 .unwrap();
8873 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8874
8875 let cookie = session_cookie(&state, did, None);
8876 let app = router(state.clone());
8877
8878 let resp = app
8880 .clone()
8881 .oneshot(
8882 Request::builder()
8883 .method("POST")
8884 .uri(format!("/entries/{entry_id}/read"))
8885 .header(header::COOKIE, cookie.clone())
8886 .header("HX-Request", "true")
8887 .header("X-FR-Reader", "1")
8888 .header("content-type", "application/x-www-form-urlencoded")
8889 .body(Body::from("read=true"))
8890 .unwrap(),
8891 )
8892 .await
8893 .unwrap();
8894 assert_eq!(resp.status(), StatusCode::OK);
8895 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8896 .await
8897 .unwrap();
8898 let html = String::from_utf8(bytes.to_vec()).unwrap();
8899 assert!(
8900 html.contains("hx-swap-oob=\"outerHTML\""),
8901 "reader response must be an OOB swap: {html}"
8902 );
8903 assert!(
8904 html.contains(r#"id="entry-actionbar""#),
8905 "reader response must be the action-bar fragment: {html}"
8906 );
8907 assert!(
8910 html.contains(r#"aria-pressed="true""#),
8911 "read button must show pressed after marking read: {html}"
8912 );
8913 assert!(
8914 html.contains(r#"name="read" value="false""#),
8915 "hidden read value must flip to false so a second tap reverses: {html}"
8916 );
8917
8918 let resp2 = app
8921 .oneshot(
8922 Request::builder()
8923 .method("POST")
8924 .uri(format!("/entries/{entry_id}/read"))
8925 .header(header::COOKIE, cookie)
8926 .header("HX-Request", "true")
8927 .header("X-FR-Reader", "1")
8928 .header("content-type", "application/x-www-form-urlencoded")
8929 .body(Body::from("read=false"))
8930 .unwrap(),
8931 )
8932 .await
8933 .unwrap();
8934 assert_eq!(resp2.status(), StatusCode::OK);
8935 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
8936 .await
8937 .unwrap();
8938 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
8939 assert!(
8940 html2.contains(r#"aria-pressed="false""#),
8941 "read button must show un-pressed after reversing: {html2}"
8942 );
8943 assert!(
8944 html2.contains(r#"name="read" value="true""#),
8945 "hidden read value must flip back to true: {html2}"
8946 );
8947 }
8948
8949 #[tokio::test]
8952 async fn list_mark_read_returns_row_not_oob_actionbar() {
8953 let did = "did:plc:listv";
8954 let state = test_state(&[]).await;
8955 store::grant_access(&state.db, did, None, "test", None)
8956 .await
8957 .unwrap();
8958 let feed = store::upsert_feed(
8959 &state.db,
8960 &store::NewFeed {
8961 url: "https://list.example/feed.xml".to_string(),
8962 title: Some("List".to_string()),
8963 ..Default::default()
8964 },
8965 )
8966 .await
8967 .unwrap();
8968 store::insert_entries(
8969 &state.db,
8970 feed,
8971 &[store::NewEntry {
8972 guid: "l-1".to_string(),
8973 url: Some("https://list.example/1".to_string()),
8974 title: Some("Article".to_string()),
8975 published: Some("2026-07-11T00:00:00Z".to_string()),
8976 ..Default::default()
8977 }],
8978 0,
8979 )
8980 .await
8981 .unwrap();
8982 store::replace_sub_refs(&state.db, did, &[feed])
8983 .await
8984 .unwrap();
8985 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8986
8987 let cookie = session_cookie(&state, did, None);
8988 let app = router(state.clone());
8989
8990 let resp = app
8991 .oneshot(
8992 Request::builder()
8993 .method("POST")
8994 .uri(format!("/entries/{entry_id}/read"))
8995 .header(header::COOKIE, cookie)
8996 .header("HX-Request", "true")
8997 .header("content-type", "application/x-www-form-urlencoded")
8998 .body(Body::from("read=true"))
8999 .unwrap(),
9000 )
9001 .await
9002 .unwrap();
9003 assert_eq!(resp.status(), StatusCode::OK);
9004 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9005 .await
9006 .unwrap();
9007 let html = String::from_utf8(bytes.to_vec()).unwrap();
9008 assert!(
9009 !html.contains("hx-swap-oob"),
9010 "list-view response must NOT be an OOB swap: {html}"
9011 );
9012 assert!(
9017 html.contains(&format!("/entries/{entry_id}")),
9018 "the response is not the row for this entry: {html}",
9019 );
9020 assert!(
9021 html.contains("Article"),
9022 "the row rendered without its title: {html}",
9023 );
9024 assert!(
9041 html.contains("is-read"),
9042 "the row came back without the read state it was just given: {html}",
9043 );
9044 }
9045
9046 #[tokio::test]
9071 async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
9072 let did = "did:plc:autodiscovered";
9073 let state = test_state_with_caps(did, 0, 0).await;
9076
9077 let page = r#"<!doctype html><html><head><title>Blog</title>
9078 <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
9079 </head><body>hi</body></html>"#;
9080 let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
9081 let port: u16 = base
9082 .trim_end_matches('/')
9083 .rsplit(':')
9084 .next()
9085 .unwrap()
9086 .parse()
9087 .unwrap();
9088 crate::net::test_host_override(
9089 "autodiscover-ftp.test",
9090 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9091 );
9092
9093 let cookie = session_cookie(&state, did, None);
9094 let resp = router(state.clone())
9095 .oneshot(
9096 Request::builder()
9097 .method("POST")
9098 .uri("/subscriptions")
9099 .header(header::COOKIE, cookie)
9100 .header("content-type", "application/x-www-form-urlencoded")
9101 .body(Body::from(format!(
9102 "url=http://autodiscover-ftp.test:{port}/"
9103 )))
9104 .unwrap(),
9105 )
9106 .await
9107 .unwrap();
9108 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9109 let loc = resp
9110 .headers()
9111 .get(header::LOCATION)
9112 .unwrap()
9113 .to_str()
9114 .unwrap();
9115 assert_ne!(loc, "/login", "the test never reached the add path");
9116 assert_ne!(loc, "/", "the subscribe succeeded");
9117
9118 assert_eq!(
9119 store::count_feeds(&state.db).await.unwrap(),
9120 0,
9121 "a non-http(s) URL from autodiscovery was stored"
9122 );
9123 assert_eq!(
9124 store::count_subscriptions_for_did(&state.db, did)
9125 .await
9126 .unwrap(),
9127 0
9128 );
9129 }
9130
9131 #[tokio::test]
9136 async fn rename_to_new_url_refused_at_global_feeds_cap() {
9137 let did = "did:plc:renamer4";
9138 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9139 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9141 store::upsert_feed(
9142 &state.db,
9143 &store::NewFeed {
9144 url: "https://existing.example/feed.xml".to_string(),
9145 ..Default::default()
9146 },
9147 )
9148 .await
9149 .unwrap();
9150 let before = store::count_feeds(&state.db).await.unwrap();
9151 assert_eq!(before, 1);
9152
9153 let cookie = session_cookie(&state, did, None);
9154 let resp = router(state.clone())
9155 .oneshot(
9156 Request::builder()
9157 .method("POST")
9158 .uri("/subscriptions/rk-keep/rename")
9159 .header(header::COOKIE, cookie)
9160 .header("content-type", "application/x-www-form-urlencoded")
9161 .body(Body::from(
9163 "url=https://brand-new.example/feed.xml&title=Renamed",
9164 ))
9165 .unwrap(),
9166 )
9167 .await
9168 .unwrap();
9169 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9170 let loc = resp
9171 .headers()
9172 .get(header::LOCATION)
9173 .unwrap()
9174 .to_str()
9175 .unwrap();
9176 assert!(
9177 loc.contains("feed%20capacity"),
9178 "expected the feed-capacity flash, got {loc}"
9179 );
9180 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9182 assert!(
9183 puts.lock().unwrap().is_empty(),
9184 "a refused repoint reached the PDS"
9185 );
9186 }
9187
9188 #[tokio::test]
9195 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9196 let did = "did:plc:renamer4";
9197 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9198 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9199 store::upsert_feed(
9200 &state.db,
9201 &store::NewFeed {
9202 url: "https://existing.example/feed.xml".to_string(),
9203 ..Default::default()
9204 },
9205 )
9206 .await
9207 .unwrap();
9208 let before = store::count_feeds(&state.db).await.unwrap();
9209
9210 let cookie = session_cookie(&state, did, None);
9211 let resp = router(state.clone())
9212 .oneshot(
9213 Request::builder()
9214 .method("POST")
9215 .uri("/subscriptions/rk-keep/rename")
9216 .header(header::COOKIE, cookie)
9217 .header("content-type", "application/x-www-form-urlencoded")
9218 .body(Body::from(
9219 "url=https://existing.example/feed.xml&title=Retitled",
9220 ))
9221 .unwrap(),
9222 )
9223 .await
9224 .unwrap();
9225 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9226 let loc = resp
9227 .headers()
9228 .get(header::LOCATION)
9229 .unwrap()
9230 .to_str()
9231 .unwrap();
9232 assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9233 assert_eq!(
9234 puts.lock().unwrap().len(),
9235 1,
9236 "the repoint did not reach the PDS"
9237 );
9238 assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9239 }
9240
9241 #[tokio::test]
9243 async fn rename_with_blank_url_writes_nothing() {
9244 let did = "did:plc:renamer3";
9245 let state = test_state_with_caps(did, 0, 0).await;
9246 let before = store::count_feeds(&state.db).await.unwrap();
9247 assert_eq!(before, 0);
9248
9249 let cookie = session_cookie(&state, did, None);
9250 let app = router(state.clone());
9251 let resp = app
9252 .oneshot(
9253 Request::builder()
9254 .method("POST")
9255 .uri("/subscriptions/rkey123/rename")
9256 .header(header::COOKIE, cookie)
9257 .header("content-type", "application/x-www-form-urlencoded")
9258 .body(Body::from("url=%20%20&title=Nope"))
9260 .unwrap(),
9261 )
9262 .await
9263 .unwrap();
9264 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9265 assert_eq!(
9266 resp.headers()
9267 .get(header::LOCATION)
9268 .unwrap()
9269 .to_str()
9270 .unwrap(),
9271 "/",
9272 );
9273 assert_eq!(
9275 store::count_feeds(&state.db).await.unwrap(),
9276 0,
9277 "blank-URL rename wrote a junk feeds row"
9278 );
9279 }
9280
9281 async fn spawn_rename_sidecar(
9290 existing: serde_json::Value,
9291 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9292 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9293 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9294 let addr = listener.local_addr().unwrap();
9295 let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9296 let sink = puts.clone();
9297 tokio::spawn(async move {
9298 loop {
9299 let Ok((mut sock, _)) = listener.accept().await else {
9300 break;
9301 };
9302 let mut raw: Vec<u8> = Vec::new();
9303 let mut chunk = [0u8; 4096];
9304 let body_text = loop {
9305 let Ok(n) = sock.read(&mut chunk).await else {
9306 break String::new();
9307 };
9308 if n == 0 {
9309 break String::from_utf8_lossy(&raw).to_string();
9310 }
9311 raw.extend_from_slice(&chunk[..n]);
9312 let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9313 continue;
9314 };
9315 let (head, body) = raw.split_at(split + 4);
9316 let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9317 let (k, v) = l.split_once(':')?;
9318 k.eq_ignore_ascii_case("content-length")
9319 .then(|| v.trim().parse::<usize>().ok())?
9320 });
9321 if want.is_none_or(|want| body.len() >= want) {
9322 break String::from_utf8_lossy(body).to_string();
9323 }
9324 };
9325
9326 let is_put = body_text.contains("\"action\":\"put\"");
9328 let data = if is_put {
9329 sink.lock().unwrap().push(body_text.clone());
9330 serde_json::json!({
9331 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9332 "cid": "bafyreiafter"
9333 })
9334 } else {
9335 serde_json::json!({ "records": [existing.clone()] })
9336 };
9337 let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9338 let resp = format!(
9339 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9340 body.len(),
9341 body
9342 );
9343 let _ = sock.write_all(resp.as_bytes()).await;
9344 let _ = sock.flush().await;
9345 }
9346 });
9347 (format!("http://{addr}"), puts)
9348 }
9349
9350 fn seeded_subscription() -> serde_json::Value {
9352 serde_json::json!({
9353 "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9354 "cid": "bafyreibefore",
9355 "value": {
9356 "$type": "community.lexicon.rss.subscription",
9357 "url": "https://example.com/feed.xml",
9358 "title": "Old title",
9359 "siteUrl": "https://example.com/blog",
9360 "fetchHint": "hourly",
9361 "private": false,
9362 "createdAt": "2024-03-01T00:00:00.000Z"
9363 }
9364 })
9365 }
9366
9367 fn seeded_at_uri_subscription() -> serde_json::Value {
9370 seeded_subscription_with_url(AT_URI_SUB)
9371 }
9372 fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9374 serde_json::json!({
9375 "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9376 "cid": "bafyreibefore",
9377 "value": {
9378 "$type": "community.lexicon.rss.subscription",
9379 "url": url,
9380 "title": "Old title",
9381 "private": false,
9382 "createdAt": "2024-03-01T00:00:00.000Z"
9383 }
9384 })
9385 }
9386 const AT_URI_SUB: &str =
9387 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9388 const AT_URI_SUB_ENC: &str =
9389 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9390
9391 #[tokio::test]
9400 async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9401 let did = "did:plc:renamer5";
9402 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9403 let state = test_state_with_sidecar(&[did], &sidecar).await;
9404 assert!(
9405 !state.config.standard_site,
9406 "the flag must be off for this test"
9407 );
9408 let cookie = session_cookie(&state, did, None);
9409 let resp = router(state.clone())
9410 .oneshot(
9411 Request::builder()
9412 .method("POST")
9413 .uri("/subscriptions/rk-keep/rename")
9414 .header(header::COOKIE, cookie)
9415 .header("content-type", "application/x-www-form-urlencoded")
9416 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9417 .unwrap(),
9418 )
9419 .await
9420 .unwrap();
9421 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9422 let loc = resp
9423 .headers()
9424 .get(header::LOCATION)
9425 .unwrap()
9426 .to_str()
9427 .unwrap();
9428 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9429
9430 let bodies = puts.lock().unwrap().clone();
9431 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9432 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9433 assert_eq!(
9434 sent["record"]["title"], "New title",
9435 "the rename did not apply"
9436 );
9437 assert_eq!(
9438 sent["record"]["url"], AT_URI_SUB,
9439 "the rename changed the URL"
9440 );
9441
9442 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9444 assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9445 }
9446
9447 #[tokio::test]
9451 async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9452 let did = "did:plc:renamer4";
9453 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9454 let state = test_state_with_sidecar(&[did], &sidecar).await;
9455 let cookie = session_cookie(&state, did, None);
9456 let resp = router(state.clone())
9457 .oneshot(
9458 Request::builder()
9459 .method("POST")
9460 .uri("/subscriptions/rk-keep/rename")
9461 .header(header::COOKIE, cookie)
9462 .header("content-type", "application/x-www-form-urlencoded")
9463 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9464 .unwrap(),
9465 )
9466 .await
9467 .unwrap();
9468 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9469 let loc = resp
9470 .headers()
9471 .get(header::LOCATION)
9472 .unwrap()
9473 .to_str()
9474 .unwrap();
9475 assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9476 assert!(
9477 !loc.contains("Private"),
9478 "a storability refusal was reported as a privacy one: {loc}"
9479 );
9480 assert!(
9481 puts.lock().unwrap().is_empty(),
9482 "the repoint reached the PDS"
9483 );
9484 let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9485 assert_eq!(cached, 0);
9486 }
9487
9488 async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9491 let cookie = session_cookie(state, did, None);
9492 let resp = router(state.clone())
9493 .oneshot(
9494 Request::builder()
9495 .method("POST")
9496 .uri("/subscriptions/rk-keep/rename")
9497 .header(header::COOKIE, cookie)
9498 .header("content-type", "application/x-www-form-urlencoded")
9499 .body(Body::from(format!("url={url_enc}&title=New+title")))
9500 .unwrap(),
9501 )
9502 .await
9503 .unwrap();
9504 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9505 resp.headers()
9506 .get(header::LOCATION)
9507 .unwrap()
9508 .to_str()
9509 .unwrap()
9510 .to_string()
9511 }
9512
9513 #[tokio::test]
9523 async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9524 let did = "did:plc:renamer5";
9525 let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9526 let other_enc =
9527 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9528 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9529 let state = test_state_with_sidecar(&[did], &sidecar).await;
9530 let loc = retitle_unchanged(&state, did, other_enc).await;
9531 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9532 let bodies = puts.lock().unwrap().clone();
9533 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9534 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9535 assert_eq!(sent["record"]["title"], "New title");
9536 assert_eq!(sent["record"]["url"], other);
9537 }
9538
9539 #[tokio::test]
9543 async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9544 let did = "did:plc:renamer4";
9545 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9546 let state = test_state_with_sidecar(&[did], &sidecar).await;
9547 let cookie = session_cookie(&state, did, None);
9548 let resp = router(state.clone())
9549 .oneshot(
9550 Request::builder()
9551 .method("POST")
9552 .uri("/subscriptions/rk-keep/rename")
9553 .header(header::COOKIE, cookie)
9554 .header("content-type", "application/x-www-form-urlencoded")
9555 .body(Body::from(
9556 "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9557 ))
9558 .unwrap(),
9559 )
9560 .await
9561 .unwrap();
9562 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9563 let loc = resp
9564 .headers()
9565 .get(header::LOCATION)
9566 .unwrap()
9567 .to_str()
9568 .unwrap();
9569 assert!(
9570 loc.contains("Private"),
9571 "the private repoint was not refused: {loc}"
9572 );
9573 assert!(
9574 puts.lock().unwrap().is_empty(),
9575 "a secret-bearing URL reached the PDS"
9576 );
9577 let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9580 assert!(store::get_feed_by_url(&state.db, leaked)
9581 .await
9582 .unwrap()
9583 .is_none());
9584 }
9585
9586 #[tokio::test]
9592 async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9593 let did = "did:plc:renamer5";
9594 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9595 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9597 store::upsert_feed(
9598 &state.db,
9599 &store::NewFeed {
9600 url: "https://filler.example/feed.xml".to_string(),
9601 ..Default::default()
9602 },
9603 )
9604 .await
9605 .unwrap();
9606 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9607 assert_eq!(loc, "/", "the retitle was refused: {loc}");
9608 assert_eq!(
9609 puts.lock().unwrap().len(),
9610 1,
9611 "the retitle did not reach the PDS"
9612 );
9613 assert_eq!(
9614 store::count_feeds(&state.db).await.unwrap(),
9615 1,
9616 "a row was inserted"
9617 );
9618 }
9619
9620 async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
9622 let cookie = session_cookie(state, did, None);
9623 let resp = router(state.clone())
9624 .oneshot(
9625 Request::builder()
9626 .method("POST")
9627 .uri("/subscriptions")
9628 .header(header::COOKIE, cookie)
9629 .header("content-type", "application/x-www-form-urlencoded")
9630 .body(Body::from(format!("url={url_enc}")))
9631 .unwrap(),
9632 )
9633 .await
9634 .unwrap();
9635 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9636 resp.headers()
9637 .get(header::LOCATION)
9638 .unwrap()
9639 .to_str()
9640 .unwrap()
9641 .to_string()
9642 }
9643
9644 async fn serve_resolver(did: &str) -> String {
9646 let base = crate::net::tests::serve_body(
9647 serde_json::json!({ "did": did }).to_string().into_bytes(),
9648 )
9649 .await;
9650 let port: u16 = base
9651 .trim_end_matches('/')
9652 .rsplit(':')
9653 .next()
9654 .unwrap()
9655 .parse()
9656 .unwrap();
9657 let host = format!("resolver-{port}.test");
9658 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
9659 format!("http://{host}:{port}")
9660 }
9661
9662 fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
9663 let mut config = (*state.config).clone();
9664 f(&mut config);
9665 state.config = std::sync::Arc::new(config);
9666 state
9667 }
9668
9669 #[tokio::test]
9674 async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
9675 let did = "did:plc:renamer5";
9676 let (sidecar, log) = spawn_logging_sidecar().await;
9677 let state = with_config(
9678 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9679 |c| {
9680 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9681 },
9682 );
9683 let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
9684 assert_eq!(loc, "/", "the paste was refused: {loc}");
9685 let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
9686 .await
9687 .unwrap()
9688 .expect("no feed row");
9689 assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
9690 let sent = log.lock().unwrap().join("\n");
9691 assert!(
9692 sent.contains(AT_URI_SUB),
9693 "the subscription was not written to the PDS: {sent}"
9694 );
9695 }
9696
9697 #[tokio::test]
9701 async fn a0_subscribing_from_the_form_delivers_entries() {
9702 let did = "did:plc:renamer5";
9703 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9704 let site = AT_URI_SUB;
9705 let (plc, _) = crate::standard_site::tests::serve_repo(
9706 author,
9707 vec![
9708 (
9709 lexicon::nsid::STANDARD_PUBLICATION,
9710 "3lab2c4d5e6f7g8h",
9711 serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
9712 ),
9713 (
9714 lexicon::nsid::STANDARD_DOCUMENT,
9715 "3l2a0frmaaa2a",
9716 serde_json::json!({ "title": "From the form", "path": "/f",
9717 "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
9718 ),
9719 ],
9720 )
9721 .await;
9722 let (sidecar, _log) = spawn_logging_sidecar().await;
9723 let state = with_config(
9724 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9725 |c| {
9726 c.oauth.plc_directory = plc;
9727 },
9728 );
9729 assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
9730 let row = store::get_feed_by_url(&state.db, site)
9731 .await
9732 .unwrap()
9733 .unwrap();
9734 let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
9735 .bind(row.id)
9736 .fetch_all(&state.db)
9737 .await
9738 .unwrap();
9739 assert_eq!(
9740 titles,
9741 vec!["From the form".to_string()],
9742 "the first poll stored nothing"
9743 );
9744 assert_eq!(row.title.as_deref(), Some("A0 Journal"));
9745 }
9746
9747 #[tokio::test]
9750 async fn a_handle_form_paste_is_stored_by_its_did() {
9751 let did = "did:plc:renamer5";
9752 let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9753 let (sidecar, _log) = spawn_logging_sidecar().await;
9754 let resolver = serve_resolver(author).await;
9755 let state = with_config(
9756 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9757 |c| {
9758 c.resolver_base = resolver;
9759 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9760 },
9761 );
9762 let loc = subscribe(
9763 &state,
9764 did,
9765 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9766 )
9767 .await;
9768 assert_eq!(loc, "/", "the paste was refused: {loc}");
9769 assert!(
9770 store::get_feed_by_url(&state.db, AT_URI_SUB)
9771 .await
9772 .unwrap()
9773 .is_some(),
9774 "not stored by its DID"
9775 );
9776 assert_eq!(
9777 store::count_feeds(&state.db).await.unwrap(),
9778 1,
9779 "the handle form was stored too"
9780 );
9781 }
9782
9783 async fn serve_counting_resolver(
9785 did: &str,
9786 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
9787 let (base, hits) = crate::net::tests::serve_body_counted(
9788 serde_json::json!({ "did": did }).to_string().into_bytes(),
9789 )
9790 .await;
9791 let port: u16 = base
9792 .trim_end_matches('/')
9793 .rsplit(':')
9794 .next()
9795 .unwrap()
9796 .parse()
9797 .unwrap();
9798 let host = format!("counting-resolver-{port}.test");
9799 crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
9800 (format!("http://{host}:{port}"), hits)
9801 }
9802
9803 #[tokio::test]
9807 async fn an_over_cap_handle_paste_makes_no_outbound_request() {
9808 let did = "did:plc:renamer5";
9809 let (sidecar, _log) = spawn_logging_sidecar().await;
9810 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
9811 let state = with_config(
9812 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9813 |c| {
9814 c.resolver_base = resolver;
9815 c.max_subs_per_did = 1;
9816 },
9817 );
9818 let feed_id = store::upsert_feed(
9819 &state.db,
9820 &store::NewFeed {
9821 url: "https://already.example/feed.xml".into(),
9822 ..Default::default()
9823 },
9824 )
9825 .await
9826 .unwrap();
9827 store::replace_sub_refs(&state.db, did, &[feed_id])
9828 .await
9829 .unwrap();
9830 let loc = subscribe(
9831 &state,
9832 did,
9833 "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9834 )
9835 .await;
9836 assert!(
9837 loc.contains("Subscription%20limit"),
9838 "expected the cap flash: {loc}"
9839 );
9840 assert_eq!(
9841 hits.load(std::sync::atomic::Ordering::SeqCst),
9842 0,
9843 "an over-cap paste resolved a handle"
9844 );
9845 }
9846
9847 #[tokio::test]
9851 async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
9852 let did = "did:plc:renamer5";
9853 let (sidecar, _log) = spawn_logging_sidecar().await;
9854 let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
9855 let state = with_config(
9856 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9857 |c| {
9858 c.resolver_base = resolver;
9859 },
9860 );
9861 for authority in [
9862 "did%3Aplc%3ATOOSHORT",
9863 "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
9864 "bad%0Ahandle.example",
9865 ] {
9866 let loc = subscribe(
9867 &state,
9868 did,
9869 &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
9870 )
9871 .await;
9872 assert!(
9873 loc.contains("kind%20of%20feed"),
9874 "{authority}: expected the unsupported flash: {loc}"
9875 );
9876 }
9877 assert_eq!(
9878 hits.load(std::sync::atomic::Ordering::SeqCst),
9879 0,
9880 "a malformed authority reached the resolver"
9881 );
9882 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9883 }
9884
9885 #[tokio::test]
9887 async fn an_unresolvable_handle_paste_is_refused() {
9888 let did = "did:plc:renamer5";
9889 let (sidecar, _log) = spawn_logging_sidecar().await;
9890 let state = with_config(
9891 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9892 |c| {
9893 c.resolver_base = "http://resolver.nowhere.invalid".into();
9894 },
9895 );
9896 let loc = subscribe(
9897 &state,
9898 did,
9899 "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9900 )
9901 .await;
9902 assert!(
9903 loc.contains("resolve%20the%20handle"),
9904 "expected the unresolvable-handle flash: {loc}"
9905 );
9906 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9907 }
9908
9909 #[tokio::test]
9911 async fn a_non_publication_at_uri_paste_is_refused() {
9912 let did = "did:plc:renamer5";
9913 let (sidecar, _log) = spawn_logging_sidecar().await;
9914 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9915 let loc = subscribe(
9916 &state,
9917 did,
9918 "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
9919 )
9920 .await;
9921 assert!(
9922 loc.contains("kind%20of%20feed"),
9923 "expected the unsupported flash: {loc}"
9924 );
9925 assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9926 }
9927
9928 #[tokio::test]
9931 async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
9932 let did = "did:plc:renamer5";
9933 let (sidecar, _log) = spawn_logging_sidecar().await;
9934 let state = with_config(
9935 test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9936 |c| {
9937 c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9938 },
9939 );
9940 let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
9941 assert_eq!(loc, "/", "the paste was refused: {loc}");
9942 assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
9943 .await
9944 .unwrap()
9945 .is_some());
9946 }
9947
9948 #[tokio::test]
9951 async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
9952 let did = "did:plc:renamer5";
9953 let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9954 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9955 let opml = format!(
9956 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9957 <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9958 <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9959 </body></opml>"
9960 );
9961 let (ct, body) = opml_multipart(opml.as_bytes());
9962 let cookie = session_cookie(&state, did, None);
9963 let resp = router(state.clone())
9964 .oneshot(
9965 Request::builder()
9966 .method("POST")
9967 .uri("/opml")
9968 .header(header::COOKIE, cookie)
9969 .header("content-type", ct)
9970 .body(Body::from(body))
9971 .unwrap(),
9972 )
9973 .await
9974 .unwrap();
9975 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9976 let loc = resp
9977 .headers()
9978 .get(header::LOCATION)
9979 .unwrap()
9980 .to_str()
9981 .unwrap();
9982 assert!(
9983 loc.contains("Imported%202%20feeds"),
9984 "unexpected flash: {loc}"
9985 );
9986 assert!(
9987 !loc.contains("skipped"),
9988 "the at:// entry was skipped with the flag on: {loc}"
9989 );
9990 let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
9991 assert!(
9992 stored.is_some(),
9993 "the at:// entry was not stored with the flag on"
9994 );
9995 }
9996
9997 #[tokio::test]
10007 async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
10008 let did = "did:plc:renamer5";
10009 let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
10010 let tokened_enc =
10011 "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
10012 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
10013 let state = test_state_with_sidecar(&[did], &sidecar).await;
10014 let loc = retitle_unchanged(&state, did, tokened_enc).await;
10015 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10016 assert_eq!(
10017 puts.lock().unwrap().len(),
10018 1,
10019 "the retitle did not reach the PDS"
10020 );
10021 assert!(
10022 store::get_feed_by_url(&state.db, tokened)
10023 .await
10024 .unwrap()
10025 .is_none(),
10026 "a secret-bearing URL was written to the shared cache by a retitle"
10027 );
10028 }
10029
10030 #[tokio::test]
10035 async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
10036 let did = "did:plc:renamer4";
10037 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10038 let state = test_state_with_sidecar(&[did], &sidecar).await;
10039 let cookie = session_cookie(&state, did, None);
10040 let resp = router(state.clone())
10041 .oneshot(
10042 Request::builder()
10043 .method("POST")
10044 .uri("/subscriptions/rk-keep/rename")
10045 .header(header::COOKIE, cookie)
10046 .header("content-type", "application/x-www-form-urlencoded")
10047 .body(Body::from(
10048 "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
10049 ))
10050 .unwrap(),
10051 )
10052 .await
10053 .unwrap();
10054 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10055 let loc = resp
10056 .headers()
10057 .get(header::LOCATION)
10058 .unwrap()
10059 .to_str()
10060 .unwrap();
10061 assert!(
10062 loc.contains("kind%20of%20feed"),
10063 "expected the unsupported flash: {loc}"
10064 );
10065 assert!(
10066 !loc.contains("Private"),
10067 "a typo was reported as a paid feed: {loc}"
10068 );
10069 assert!(puts.lock().unwrap().is_empty());
10070 }
10071
10072 #[tokio::test]
10073 async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
10074 let did = "did:plc:renamer4";
10075 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10076 let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10077 store::upsert_feed(
10078 &state.db,
10079 &store::NewFeed {
10080 url: "https://filler.example/feed.xml".to_string(),
10081 ..Default::default()
10082 },
10083 )
10084 .await
10085 .unwrap();
10086 let cookie = session_cookie(&state, did, None);
10087 let resp = router(state.clone())
10088 .oneshot(
10089 Request::builder()
10090 .method("POST")
10091 .uri("/subscriptions/rk-keep/rename")
10092 .header(header::COOKIE, cookie)
10093 .header("content-type", "application/x-www-form-urlencoded")
10094 .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10095 .unwrap(),
10096 )
10097 .await
10098 .unwrap();
10099 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10100 let loc = resp
10101 .headers()
10102 .get(header::LOCATION)
10103 .unwrap()
10104 .to_str()
10105 .unwrap();
10106 assert!(
10107 loc.contains("kind%20of%20feed"),
10108 "expected the unsupported flash: {loc}"
10109 );
10110 assert!(
10111 !loc.contains("capacity"),
10112 "an unacceptable URL was reported as a capacity problem: {loc}"
10113 );
10114 assert!(puts.lock().unwrap().is_empty());
10115 }
10116
10117 #[tokio::test]
10122 async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
10123 let did = "did:plc:renamer5";
10124 let padded = format!("{AT_URI_SUB} ");
10125 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
10126 let state = test_state_with_sidecar(&[did], &sidecar).await;
10127 let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
10129 assert_eq!(
10130 loc, "/",
10131 "the retitle was treated as a repoint and refused: {loc}"
10132 );
10133 let bodies = puts.lock().unwrap().clone();
10134 assert_eq!(bodies.len(), 1);
10135 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10136 assert_eq!(
10137 sent["record"]["url"], AT_URI_SUB,
10138 "the padding was not normalised away"
10139 );
10140 }
10141
10142 #[tokio::test]
10148 async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
10149 let did = "did:plc:renamer5";
10150 let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10151 let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
10152 store::upsert_feed(
10153 &state.db,
10154 &store::NewFeed {
10155 url: "https://filler.example/feed.xml".to_string(),
10156 ..Default::default()
10157 },
10158 )
10159 .await
10160 .unwrap();
10161 let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10162 assert_eq!(loc, "/", "the retitle was refused: {loc}");
10163 assert_eq!(puts.lock().unwrap().len(), 1);
10164 assert_eq!(
10165 store::count_feeds(&state.db).await.unwrap(),
10166 1,
10167 "a retitle inserted a cache row past the ceiling"
10168 );
10169 }
10170
10171 #[tokio::test]
10178 async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
10179 let did = "did:plc:typoist";
10180 let state = test_state_with_caps(did, 0, 0).await;
10181 let cookie = session_cookie(&state, did, None);
10182 let resp = router(state.clone())
10183 .oneshot(
10184 Request::builder()
10185 .method("POST")
10186 .uri("/subscriptions")
10187 .header(header::COOKIE, cookie)
10188 .header("content-type", "application/x-www-form-urlencoded")
10189 .body(Body::from(
10190 "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10191 ))
10192 .unwrap(),
10193 )
10194 .await
10195 .unwrap();
10196 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10197 let loc = resp
10198 .headers()
10199 .get(header::LOCATION)
10200 .unwrap()
10201 .to_str()
10202 .unwrap();
10203 assert!(
10204 loc.contains("kind%20of%20feed"),
10205 "expected the unsupported flash: {loc}"
10206 );
10207 assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
10208 }
10209
10210 #[tokio::test]
10232 async fn renaming_preserves_the_fields_the_form_never_carries() {
10233 let did = "did:plc:renamer4";
10234 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10235 let state = test_state_with_sidecar(&[did], &sidecar).await;
10236 let cookie = session_cookie(&state, did, None);
10237
10238 let resp = router(state.clone())
10239 .oneshot(
10240 Request::builder()
10241 .method("POST")
10242 .uri("/subscriptions/rk-keep/rename")
10243 .header(header::COOKIE, cookie)
10244 .header("content-type", "application/x-www-form-urlencoded")
10245 .body(Body::from(
10247 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
10248 ))
10249 .unwrap(),
10250 )
10251 .await
10252 .unwrap();
10253 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10254
10255 let bodies = puts.lock().unwrap().clone();
10256 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10257 let body = &bodies[0];
10258 assert!(
10260 body.contains("community.lexicon.rss.subscription"),
10261 "captured no usable put body: {body:?}"
10262 );
10263
10264 let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
10265 let record = &sent["record"];
10266
10267 assert_eq!(record["title"], "New title", "the rename did not apply");
10269 assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
10270
10271 assert_eq!(
10273 record["createdAt"], "2024-03-01T00:00:00.000Z",
10274 "the rename reset createdAt — the reader's subscribe time is gone \
10275 from their own repo, and nothing told them"
10276 );
10277 assert_eq!(
10278 record["siteUrl"], "https://example.com/blog",
10279 "the rename erased siteUrl"
10280 );
10281 assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
10282 assert_eq!(record["private"], false, "the rename erased private");
10283 }
10284
10285 #[tokio::test]
10294 async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
10295 let did = "did:plc:renamer4";
10296 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10297 let state = test_state_with_sidecar(&[did], &sidecar).await;
10298 let cookie = session_cookie(&state, did, None);
10299
10300 let resp = router(state.clone())
10301 .oneshot(
10302 Request::builder()
10303 .method("POST")
10304 .uri("/subscriptions/rk-keep/rename")
10305 .header(header::COOKIE, cookie)
10306 .header("content-type", "application/x-www-form-urlencoded")
10307 .body(Body::from(
10309 "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
10310 ))
10311 .unwrap(),
10312 )
10313 .await
10314 .unwrap();
10315 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10316
10317 let bodies = puts.lock().unwrap().clone();
10318 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10319 assert!(
10320 bodies[0].contains("community.lexicon.rss.subscription"),
10321 "captured no usable put body: {:?}",
10322 bodies[0]
10323 );
10324 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10325 let record = &sent["record"];
10326
10327 assert_eq!(record["url"], "https://other.example/feed.xml");
10328 assert!(
10330 record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
10331 "the old feed's site link followed the subscription to a new feed: {record}"
10332 );
10333 assert!(
10334 record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
10335 "the old feed's fetch hint followed the subscription to a new feed: {record}"
10336 );
10337 assert_eq!(
10339 record["createdAt"], "2024-03-01T00:00:00.000Z",
10340 "a repoint is still not a new subscription; createdAt must not move"
10341 );
10342 assert_eq!(record["private"], false, "the repoint erased private");
10343 }
10344
10345 #[tokio::test]
10357 async fn renaming_an_unknown_rkey_writes_nothing() {
10358 let did = "did:plc:renamer4";
10359 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10361 let state = test_state_with_sidecar(&[did], &sidecar).await;
10362 let cookie = session_cookie(&state, did, None);
10363
10364 let resp = router(state.clone())
10365 .oneshot(
10366 Request::builder()
10367 .method("POST")
10368 .uri("/subscriptions/rk-does-not-exist/rename")
10370 .header(header::COOKIE, cookie)
10371 .header("content-type", "application/x-www-form-urlencoded")
10372 .body(Body::from(
10373 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
10374 ))
10375 .unwrap(),
10376 )
10377 .await
10378 .unwrap();
10379
10380 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10381 let loc = resp
10382 .headers()
10383 .get(header::LOCATION)
10384 .unwrap()
10385 .to_str()
10386 .unwrap();
10387 assert!(
10388 loc.contains("flash="),
10389 "an unknown rkey redirected as though the rename had worked: {loc}"
10390 );
10391 assert!(
10392 puts.lock().unwrap().is_empty(),
10393 "a rename against an unknown rkey wrote a record — putRecord would \
10394 CREATE it, dated today: {:?}",
10395 puts.lock().unwrap()
10396 );
10397 }
10398
10399 #[tokio::test]
10411 async fn a_client_supplied_site_url_reaches_the_record() {
10412 let did = "did:plc:renamer4";
10413 let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10414 let state = test_state_with_sidecar(&[did], &sidecar).await;
10415 let cookie = session_cookie(&state, did, None);
10416
10417 let resp = router(state.clone())
10418 .oneshot(
10419 Request::builder()
10420 .method("POST")
10421 .uri("/subscriptions/rk-keep/rename")
10422 .header(header::COOKIE, cookie)
10423 .header("content-type", "application/x-www-form-urlencoded")
10424 .body(Body::from(
10427 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10428 &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10429 ))
10430 .unwrap(),
10431 )
10432 .await
10433 .unwrap();
10434 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10435
10436 let bodies = puts.lock().unwrap().clone();
10437 assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10438 assert!(
10439 bodies[0].contains("community.lexicon.rss.subscription"),
10440 "captured no usable put body: {:?}",
10441 bodies[0]
10442 );
10443 let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10444 assert_eq!(
10445 sent["record"]["siteUrl"], "https://typed.example/site",
10446 "the client's siteUrl was dropped; the seeded record's survived instead"
10447 );
10448 }
10449
10450 #[tokio::test]
10458 async fn a_rename_whose_read_fails_writes_nothing() {
10459 let did = "did:plc:renamer5";
10460 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10462 let dead = format!("http://{}", listener.local_addr().unwrap());
10463 drop(listener);
10464
10465 let state = test_state_with_sidecar(&[did], &dead).await;
10466 let cookie = session_cookie(&state, did, None);
10467 let before = store::count_feeds(&state.db).await.unwrap();
10468
10469 let resp = router(state.clone())
10470 .oneshot(
10471 Request::builder()
10472 .method("POST")
10473 .uri("/subscriptions/rk-keep/rename")
10474 .header(header::COOKIE, cookie)
10475 .header("content-type", "application/x-www-form-urlencoded")
10476 .body(Body::from(
10477 "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10478 ))
10479 .unwrap(),
10480 )
10481 .await
10482 .unwrap();
10483
10484 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10485 let loc = resp
10486 .headers()
10487 .get(header::LOCATION)
10488 .unwrap()
10489 .to_str()
10490 .unwrap();
10491 assert!(
10492 loc.contains("flash="),
10493 "a failed read redirected as though the rename had worked: {loc}"
10494 );
10495 assert_eq!(
10496 store::count_feeds(&state.db).await.unwrap(),
10497 before,
10498 "a rename that could not read the record still wrote to the cache"
10499 );
10500 }
10501
10502 #[test]
10508 fn manage_rename_row_preselects_current_folder() {
10509 let nav = Nav {
10510 handle: "@reader.example".to_string(),
10511 avatar: "RE".to_string(),
10512 view: "unread".to_string(),
10513 scope_qs: String::new(),
10514 folders: Vec::new(),
10515 loose_feeds: Vec::new(),
10516 manage_active: true,
10517 };
10518 let folder_options = vec![
10519 FolderOption {
10520 uri: "at://did:plc:x/app.folder/work".to_string(),
10521 name: "Work".to_string(),
10522 },
10523 FolderOption {
10524 uri: "at://did:plc:x/app.folder/fun".to_string(),
10525 name: "Fun".to_string(),
10526 },
10527 ];
10528 let foldered = FeedView {
10531 rkey: "sub-foldered".to_string(),
10532 url: "https://work.example/feed.xml".to_string(),
10533 title: "Work Feed".to_string(),
10534 unread: 0,
10535 selected: false,
10536 folder: Some("at://did:plc:x/app.folder/work".to_string()),
10537 };
10538 let loose = FeedView {
10539 rkey: "sub-loose".to_string(),
10540 url: "https://loose.example/feed.xml".to_string(),
10541 title: "Loose Feed".to_string(),
10542 unread: 0,
10543 selected: false,
10544 folder: None,
10545 };
10546 let tmpl = ManageTemplate {
10547 version: VERSION,
10548 repo_url: REPO_URL,
10549 kofi_url: KOFI_URL,
10550 flash: String::new(),
10551 alert: String::new(),
10552 nav,
10553 folder_options,
10554 folders: vec![FolderView {
10555 rkey: "folder-work".to_string(),
10556 uri: "at://did:plc:x/app.folder/work".to_string(),
10557 name: "Work".to_string(),
10558 feeds: vec![foldered],
10559 selected: false,
10560 }],
10561 loose_feeds: vec![loose],
10562 };
10563 let html = tmpl.render().unwrap();
10564
10565 assert!(
10567 html.contains(
10568 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10569 ),
10570 "foldered feed must pre-select its current folder: {html}"
10571 );
10572 assert!(
10575 html.contains(r#"<option value="" selected>No folder</option>"#),
10576 "loose feed must pre-select 'No folder': {html}"
10577 );
10578 }
10579
10580 #[tokio::test]
10586 async fn the_public_stats_page_exposes_no_user_data() {
10587 let state = test_state(&[]).await;
10588 store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10589 .await
10590 .unwrap();
10591
10592 let resp = router(state)
10593 .oneshot(
10594 Request::builder()
10595 .uri("/stats")
10596 .body(Body::empty())
10597 .unwrap(),
10598 )
10599 .await
10600 .unwrap();
10601 assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10602
10603 let body = String::from_utf8(
10604 axum::body::to_bytes(resp.into_body(), usize::MAX)
10605 .await
10606 .unwrap()
10607 .to_vec(),
10608 )
10609 .unwrap();
10610
10611 assert!(
10617 !body.contains("did:"),
10618 "the public stats page leaked an identifier"
10619 );
10620 for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10621 assert!(
10622 !body.contains(admin_only),
10623 "the public page is showing the admin metrics column {admin_only:?}"
10624 );
10625 }
10626 assert!(body.contains("Feeds tracked"));
10628 assert!(body.contains("Waiting to be polled"));
10629 }
10630
10631 #[tokio::test]
10639 async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10640 let state = test_state(&[]).await;
10641 for (url, errors) in [
10643 ("https://ok.example/f.xml", 0),
10644 ("https://flaky.example/f.xml", 2),
10645 ("https://dead.example/f.xml", 9),
10646 ] {
10647 store::upsert_feed(
10648 &state.db,
10649 &store::NewFeed {
10650 url: url.to_string(),
10651 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10654 ..Default::default()
10655 },
10656 )
10657 .await
10658 .unwrap();
10659 for _ in 0..errors {
10660 store::bump_feed_errors(
10661 &state.db,
10662 url,
10663 feed::FailureKind::Fetch,
10664 "connection refused",
10665 )
10666 .await
10667 .unwrap();
10668 }
10669 }
10670
10671 let render_stats = |state: AppState| async move {
10672 let resp = router(state)
10673 .oneshot(
10674 Request::builder()
10675 .uri("/stats")
10676 .body(Body::empty())
10677 .unwrap(),
10678 )
10679 .await
10680 .unwrap();
10681 assert_eq!(resp.status(), StatusCode::OK);
10682 String::from_utf8(
10683 axum::body::to_bytes(resp.into_body(), usize::MAX)
10684 .await
10685 .unwrap()
10686 .to_vec(),
10687 )
10688 .unwrap()
10689 };
10690
10691 state.runtime_health.set_schedulers_enabled(true);
10698 state
10699 .runtime_health
10700 .poll_tick_completed(crate::store::now_unix());
10701
10702 let body = render_stats(state.clone()).await;
10703 assert!(
10704 body.contains("Failing"),
10705 "backoff is still invisible on the public page"
10706 );
10707 assert!(
10711 body.contains("2, 1 badly"),
10712 "expected '2, 1 badly' in the failing row; got:\n{}",
10713 body.split("Failing")
10714 .nth(1)
10715 .unwrap_or("")
10716 .chars()
10717 .take(300)
10718 .collect::<String>()
10719 );
10720 assert!(
10728 !body.contains("the poller is not running")
10729 && !body.contains("the cache is at its size limit")
10730 && !body.contains("has not completed a round"),
10731 "expected the running state; the page reported a stopped one",
10732 );
10733
10734 state.runtime_health.set_watermark(true);
10738 let paused = render_stats(state.clone()).await;
10739 assert!(
10744 paused.contains("the cache is at its size limit"),
10745 "a watermark pause is still invisible on the public page"
10746 );
10747
10748 for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10750 assert!(
10751 !paused.contains(leak),
10752 "the public page leaked {leak:?} while reporting failures"
10753 );
10754 }
10755 }
10756
10757 #[tokio::test]
10769 async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
10770 let admin = "did:plc:adminseed";
10771 let state = test_state(&[admin]).await;
10780 store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
10781 .await
10782 .unwrap();
10783 let url = "https://broken.example/f.xml";
10784 store::upsert_feed(
10785 &state.db,
10786 &store::NewFeed {
10787 url: url.to_string(),
10788 ..Default::default()
10789 },
10790 )
10791 .await
10792 .unwrap();
10793 store::bump_feed_errors(
10794 &state.db,
10795 url,
10796 feed::FailureKind::Fetch,
10797 "SENTINEL_ADMIN_ONLY",
10798 )
10799 .await
10800 .unwrap();
10801
10802 let get = |state: AppState, cookie: Option<String>| async move {
10803 let mut req = Request::builder().uri("/admin/metrics");
10804 if let Some(c) = cookie {
10805 req = req.header(header::COOKIE, c);
10806 }
10807 let resp = router(state)
10808 .oneshot(req.body(Body::empty()).unwrap())
10809 .await
10810 .unwrap();
10811 let status = resp.status();
10812 let body = String::from_utf8(
10813 axum::body::to_bytes(resp.into_body(), usize::MAX)
10814 .await
10815 .unwrap()
10816 .to_vec(),
10817 )
10818 .unwrap();
10819 (status, body)
10820 };
10821
10822 let (status, body) = get(state.clone(), None).await;
10824 assert_eq!(status, StatusCode::UNAUTHORIZED);
10825 assert!(
10826 !body.contains("SENTINEL_ADMIN_ONLY"),
10827 "leaked to anonymous: {body}"
10828 );
10829
10830 let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
10832 let (status, body) = get(state.clone(), Some(ordinary)).await;
10833 assert_eq!(
10834 status,
10835 StatusCode::FORBIDDEN,
10836 "a non-admin session was let in"
10837 );
10838 assert!(
10839 !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
10840 "leaked to a non-admin: {body}",
10841 );
10842
10843 let admin_cookie = session_cookie(&state, admin, None);
10846 let (status, body) = get(state, Some(admin_cookie)).await;
10847 assert_eq!(status, StatusCode::OK);
10848 assert!(
10849 body.contains("SENTINEL_ADMIN_ONLY"),
10850 "admin cannot see it: {body}"
10851 );
10852 }
10853
10854 #[tokio::test]
10871 async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
10872 let admin = "did:plc:adminseed";
10873 let state = test_state(&[admin]).await;
10874 let url = "https://broken.example/f.xml";
10875 store::upsert_feed(
10876 &state.db,
10877 &store::NewFeed {
10878 url: url.to_string(),
10879 ..Default::default()
10880 },
10881 )
10882 .await
10883 .unwrap();
10884 store::bump_feed_errors(
10885 &state.db,
10886 url,
10887 feed::FailureKind::Fetch,
10888 "SENTINEL_REDIRECT_NO_LOCATION",
10889 )
10890 .await
10891 .unwrap();
10892
10893 let cookie = session_cookie(&state, admin, None);
10894 let resp = router(state.clone())
10895 .oneshot(
10896 Request::builder()
10897 .uri("/admin/metrics")
10898 .header(header::COOKIE, cookie)
10899 .body(Body::empty())
10900 .unwrap(),
10901 )
10902 .await
10903 .unwrap();
10904 assert_eq!(resp.status(), StatusCode::OK);
10905 let admin_body = String::from_utf8(
10906 axum::body::to_bytes(resp.into_body(), usize::MAX)
10907 .await
10908 .unwrap()
10909 .to_vec(),
10910 )
10911 .unwrap();
10912 assert!(
10913 admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
10914 "the admin page does not carry the failure detail: {admin_body}",
10915 );
10916 assert!(
10917 admin_body.contains("broken.example"),
10918 "the admin page does not name the failing feed: {admin_body}",
10919 );
10920
10921 let resp = router(state)
10923 .oneshot(
10924 Request::builder()
10925 .uri("/stats")
10926 .body(Body::empty())
10927 .unwrap(),
10928 )
10929 .await
10930 .unwrap();
10931 let public = String::from_utf8(
10932 axum::body::to_bytes(resp.into_body(), usize::MAX)
10933 .await
10934 .unwrap()
10935 .to_vec(),
10936 )
10937 .unwrap();
10938 for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
10939 assert!(
10940 !public.contains(secret),
10941 "{secret:?} reached the PUBLIC stats page: {public}",
10942 );
10943 }
10944 }
10945
10946 #[tokio::test]
10959 async fn a_successful_direct_poll_clears_a_stale_failure() {
10960 let state = test_state(&[]).await;
10961 let url = "https://recovered.example/f.xml";
10962 store::upsert_feed(
10963 &state.db,
10964 &store::NewFeed {
10965 url: url.to_string(),
10966 ..Default::default()
10967 },
10968 )
10969 .await
10970 .unwrap();
10971 store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
10972 .await
10973 .unwrap();
10974 sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
10976 .bind(url)
10977 .execute(&state.db)
10978 .await
10979 .unwrap();
10980
10981 feed::settle_poll(
10983 &state.db,
10984 url,
10985 &feed::PollOutcome::NotModified,
10986 state.config.poll_interval,
10987 )
10988 .await;
10989
10990 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10991 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10992 )
10993 .bind(url)
10994 .fetch_one(&state.db)
10995 .await
10996 .unwrap();
10997 assert_eq!(row.0, 0, "a successful direct poll left the error streak");
10998 assert_eq!(row.1, None, "a successful direct poll left a stale cause");
10999 let next = row.2.expect("next_poll was cleared to NULL");
11003 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11007 let delta = parsed
11008 .signed_duration_since(chrono::Utc::now())
11009 .num_seconds();
11010 let cadence = state.config.poll_interval.as_secs() as i64;
11011 assert!(
11012 (cadence - 60..=cadence + 60).contains(&delta),
11013 "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
11014 );
11015 }
11016
11017 #[tokio::test]
11023 async fn a_failing_direct_poll_is_recorded() {
11024 let state = test_state(&[]).await;
11025 let url = "https://born-broken.example/f.xml";
11026 store::upsert_feed(
11027 &state.db,
11028 &store::NewFeed {
11029 url: url.to_string(),
11030 ..Default::default()
11031 },
11032 )
11033 .await
11034 .unwrap();
11035
11036 feed::settle_poll(
11037 &state.db,
11038 url,
11039 &feed::PollOutcome::Failed {
11040 backoff: std::time::Duration::from_secs(300),
11041 kind: feed::FailureKind::Parse,
11042 detail: "SENTINEL_BORN_BROKEN".to_string(),
11043 },
11044 state.config.poll_interval,
11045 )
11046 .await;
11047
11048 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11049 "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11050 )
11051 .bind(url)
11052 .fetch_one(&state.db)
11053 .await
11054 .unwrap();
11055 assert_eq!(row.0, 1, "a failed first poll was not counted");
11056 assert_eq!(
11057 row.1.as_deref(),
11058 Some("parse"),
11059 "its cause was not recorded"
11060 );
11061 let next = row.2.expect("a failed direct poll left next_poll NULL");
11065 let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11066 let delta = parsed
11067 .signed_duration_since(chrono::Utc::now())
11068 .num_seconds();
11069 assert!(
11070 (240..=360).contains(&delta),
11071 "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
11072 );
11073 }
11074
11075 #[tokio::test]
11087 async fn the_failure_breakdown_accounts_for_every_failing_feed() {
11088 let state = test_state(&[]).await;
11089 for url in [
11091 "https://legacy1.example/f.xml",
11092 "https://legacy2.example/f.xml",
11093 ] {
11094 store::upsert_feed(
11095 &state.db,
11096 &store::NewFeed {
11097 url: url.to_string(),
11098 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11099 ..Default::default()
11100 },
11101 )
11102 .await
11103 .unwrap();
11104 sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
11105 .bind(url)
11106 .execute(&state.db)
11107 .await
11108 .unwrap();
11109 }
11110 store::upsert_feed(
11112 &state.db,
11113 &store::NewFeed {
11114 url: "https://known.example/f.xml".to_string(),
11115 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11116 ..Default::default()
11117 },
11118 )
11119 .await
11120 .unwrap();
11121 store::bump_feed_errors(
11122 &state.db,
11123 "https://known.example/f.xml",
11124 feed::FailureKind::Status,
11125 "SENTINEL",
11126 )
11127 .await
11128 .unwrap();
11129
11130 let now = chrono::Utc::now();
11131 let health = store::poll_health(
11132 &state.db,
11133 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11134 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11135 )
11136 .await
11137 .unwrap();
11138 let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
11139 assert_eq!(
11140 counted, health.in_backoff,
11141 "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
11142 health.in_backoff, health.failure_kinds,
11143 );
11144 assert!(
11145 health
11146 .failure_kinds
11147 .iter()
11148 .any(|(k, n)| k == "unknown" && *n == 2),
11149 "no unknown bucket for the legacy rows: {:?}",
11150 health.failure_kinds,
11151 );
11152 }
11153
11154 #[tokio::test]
11159 async fn the_failure_breakdown_is_ordered_by_count() {
11160 let state = test_state(&[]).await;
11161 for (url, kind, n) in [
11162 ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
11163 ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
11164 ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
11165 ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
11166 ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
11167 ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
11168 ] {
11169 store::upsert_feed(
11170 &state.db,
11171 &store::NewFeed {
11172 url: url.to_string(),
11173 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11174 ..Default::default()
11175 },
11176 )
11177 .await
11178 .unwrap();
11179 for _ in 0..n {
11180 store::bump_feed_errors(&state.db, url, kind, "d")
11181 .await
11182 .unwrap();
11183 }
11184 }
11185 let now = chrono::Utc::now();
11186 let health = store::poll_health(
11187 &state.db,
11188 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11189 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11190 )
11191 .await
11192 .unwrap();
11193 let labels: Vec<&str> = health
11194 .failure_kinds
11195 .iter()
11196 .map(|(k, _)| k.as_str())
11197 .collect();
11198 assert_eq!(
11199 labels,
11200 ["fetch", "status", "parse"],
11201 "not ordered by count, descending: {:?}",
11202 health.failure_kinds,
11203 );
11204 }
11205
11206 #[tokio::test]
11218 async fn stats_groups_failures_by_cause_without_naming_any_feed() {
11219 let state = test_state(&[]).await;
11220 for (url, kind, detail, errors) in [
11221 (
11227 "https://a.example/f.xml",
11228 feed::FailureKind::Fetch,
11229 "SENTINEL_CONNREFUSED",
11230 3,
11231 ),
11232 (
11233 "https://b.example/f.xml",
11234 feed::FailureKind::Fetch,
11235 "SENTINEL_DNSFAIL",
11236 2,
11237 ),
11238 (
11239 "https://c.example/f.xml",
11240 feed::FailureKind::Status,
11241 "SENTINEL_404",
11242 1,
11243 ),
11244 (
11245 "https://d.example/f.xml",
11246 feed::FailureKind::Parse,
11247 "SENTINEL_UNPARSEABLE",
11248 1,
11249 ),
11250 ] {
11251 store::upsert_feed(
11252 &state.db,
11253 &store::NewFeed {
11254 url: url.to_string(),
11255 next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11256 ..Default::default()
11257 },
11258 )
11259 .await
11260 .unwrap();
11261 for _ in 0..errors {
11262 store::bump_feed_errors(&state.db, url, kind, detail)
11263 .await
11264 .unwrap();
11265 }
11266 }
11267
11268 let resp = router(state.clone())
11269 .oneshot(
11270 Request::builder()
11271 .uri("/stats")
11272 .body(Body::empty())
11273 .unwrap(),
11274 )
11275 .await
11276 .unwrap();
11277 assert_eq!(resp.status(), StatusCode::OK);
11278 let body = String::from_utf8(
11279 axum::body::to_bytes(resp.into_body(), usize::MAX)
11280 .await
11281 .unwrap()
11282 .to_vec(),
11283 )
11284 .unwrap();
11285
11286 assert!(
11288 body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
11289 "the cause histogram did not render: {body}",
11290 );
11291
11292 for secret in [
11295 "a.example",
11296 "b.example",
11297 "c.example",
11298 "d.example",
11299 "SENTINEL_CONNREFUSED",
11300 "SENTINEL_DNSFAIL",
11301 "SENTINEL_404",
11302 "SENTINEL_UNPARSEABLE",
11303 ] {
11304 assert!(
11305 !body.contains(secret),
11306 "{secret:?} reached the PUBLIC stats page: {body}",
11307 );
11308 }
11309 }
11310
11311 #[tokio::test]
11314 async fn health_checks_the_database_and_reports_the_loops() {
11315 let state = test_state(&[]).await;
11316 let body_of = |state: AppState| async move {
11317 let resp = router(state)
11318 .oneshot(
11319 Request::builder()
11320 .uri("/health")
11321 .body(Body::empty())
11322 .unwrap(),
11323 )
11324 .await
11325 .unwrap();
11326 let status = resp.status();
11327 let body = String::from_utf8(
11328 axum::body::to_bytes(resp.into_body(), usize::MAX)
11329 .await
11330 .unwrap()
11331 .to_vec(),
11332 )
11333 .unwrap();
11334 (status, body)
11335 };
11336
11337 state
11340 .runtime_health
11341 .set_started_at(chrono::Utc::now().timestamp());
11342
11343 let (status, body) = body_of(state.clone()).await;
11344 assert_eq!(status, StatusCode::OK);
11345 assert!(
11346 body.contains("db: ok"),
11347 "health did not probe the DB: {body}"
11348 );
11349 assert!(
11350 body.contains("uptime:"),
11351 "no uptime — the first thing anyone asks about a container that may \
11352 be restarting: {body}"
11353 );
11354 assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
11355 assert!(body.contains("polling-paused: no"), "{body}");
11356 assert!(body.contains("backend:"), "{body}");
11357 assert!(body.contains("oauth-runtime:"), "{body}");
11358
11359 state.runtime_health.set_watermark(true);
11364 state.runtime_health.set_schedulers_enabled(true);
11365 let (status, body) = body_of(state.clone()).await;
11366 assert_eq!(
11367 status,
11368 StatusCode::OK,
11369 "a watermark pause must not fail the liveness check: {body}"
11370 );
11371 assert!(body.contains("polling-paused: yes"), "{body}");
11372 assert!(
11375 body.contains("poller: not-yet-ticked"),
11376 "a never-ticked poller must say so: {body}"
11377 );
11378
11379 let stale_after = health_tick_stale_secs(configured_poll_tick());
11381 let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
11382 state.runtime_health.poll_tick_completed(long_ago);
11383 let (status, body) = body_of(state.clone()).await;
11384 assert_eq!(
11385 status,
11386 StatusCode::OK,
11387 "a stale poller must not 503: {body}"
11388 );
11389 assert!(body.contains("poller: stale"), "{body}");
11390
11391 state.runtime_health.poll_tick_completed(0); state
11399 .runtime_health
11400 .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
11401 let (status, body) = body_of(state.clone()).await;
11402 assert_eq!(status, StatusCode::OK);
11403 assert!(
11404 body.contains("poller: stale never-ticked"),
11405 "a poller that never ticked long after boot still reads as benign: {body}"
11406 );
11407
11408 state.db.close().await;
11411 let (status, body) = body_of(state.clone()).await;
11412 assert_eq!(
11413 status,
11414 StatusCode::SERVICE_UNAVAILABLE,
11415 "an unreachable database must fail the check: {body}"
11416 );
11417 assert!(body.starts_with("FAIL"), "{body}");
11418 assert!(
11422 !body.contains("PoolClosed") && !body.contains("sqlx"),
11423 "health leaked the raw database error to an unauthenticated caller: {body}"
11424 );
11425 }
11426
11427 #[test]
11433 fn the_stale_threshold_follows_the_poll_tick() {
11434 assert_eq!(
11437 health_tick_stale_secs(Duration::from_secs(60)),
11438 HEALTH_TICK_STALE_FLOOR_SECS
11439 );
11440 let slow = Duration::from_secs(30 * 60);
11443 assert!(
11444 health_tick_stale_secs(slow) > slow.as_secs() as i64,
11445 "a 30-minute tick must not be stale after one interval"
11446 );
11447 assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11448 assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11450 }
11451
11452 #[tokio::test]
11458 async fn stats_does_not_call_a_stopped_poller_running() {
11459 let state = test_state(&[]).await;
11460 let render = |state: AppState| async move {
11461 let resp = router(state)
11462 .oneshot(
11463 Request::builder()
11464 .uri("/stats")
11465 .body(Body::empty())
11466 .unwrap(),
11467 )
11468 .await
11469 .unwrap();
11470 assert_eq!(resp.status(), StatusCode::OK);
11471 String::from_utf8(
11472 axum::body::to_bytes(resp.into_body(), usize::MAX)
11473 .await
11474 .unwrap()
11475 .to_vec(),
11476 )
11477 .unwrap()
11478 };
11479
11480 let body = render(state.clone()).await;
11482 assert!(
11483 body.contains("the poller is not running on this instance"),
11484 "a disabled poller renders as healthy"
11485 );
11486
11487 state.runtime_health.set_schedulers_enabled(true);
11489 let body = render(state.clone()).await;
11490 assert!(
11491 body.contains("no poll has finished since this instance booted"),
11492 "a poller that has not ticked renders as healthy"
11493 );
11494
11495 state
11497 .runtime_health
11498 .poll_tick_completed(chrono::Utc::now().timestamp());
11499 let body = render(state.clone()).await;
11500 assert!(
11501 body.contains("running"),
11502 "a healthy poller must read as running"
11503 );
11504
11505 state.runtime_health.set_watermark(true);
11507 let body = render(state.clone()).await;
11508 assert!(
11509 body.contains("the cache is at its size limit"),
11510 "a watermark pause is hidden once the poller is ticking"
11511 );
11512 }
11513
11514 #[tokio::test]
11525 async fn health_reports_an_unmeasured_database_without_failing() {
11526 use crate::runtime_health::DbProbe;
11527 let state = test_state(&[]).await;
11528
11529 let held = state
11532 .runtime_health
11533 .begin_db_probe()
11534 .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11535
11536 let resp = router(state.clone())
11537 .oneshot(
11538 Request::builder()
11539 .uri("/health")
11540 .body(Body::empty())
11541 .unwrap(),
11542 )
11543 .await
11544 .unwrap();
11545 let status = resp.status();
11546 let body = String::from_utf8(
11547 axum::body::to_bytes(resp.into_body(), usize::MAX)
11548 .await
11549 .unwrap()
11550 .to_vec(),
11551 )
11552 .unwrap();
11553 drop(held);
11554
11555 assert_eq!(
11556 status,
11557 StatusCode::OK,
11558 "an unmeasured database failed the check, which an unauthenticated \
11559 caller can cause on demand: {body}"
11560 );
11561 assert!(
11562 body.contains("db: unknown"),
11563 "the unmeasured state must still be REPORTED: {body}"
11564 );
11565 assert!(!body.starts_with("FAIL"), "{body}");
11566 assert!(
11571 !body.starts_with("ok"),
11572 "the unmeasured state is indistinguishable from healthy to a \
11573 body-matching monitor: {body}"
11574 );
11575 assert!(body.starts_with("unknown"), "{body}");
11576
11577 let held = state
11588 .runtime_health
11589 .begin_db_probe()
11590 .unwrap_or_else(|_| panic!("claim"));
11591 state
11592 .runtime_health
11593 .record_for_test(DbProbe::Failed("unavailable".to_string()));
11594 let resp = router(state.clone())
11595 .oneshot(
11596 Request::builder()
11597 .uri("/health")
11598 .body(Body::empty())
11599 .unwrap(),
11600 )
11601 .await
11602 .unwrap();
11603 let status = resp.status();
11604 let body = String::from_utf8(
11605 axum::body::to_bytes(resp.into_body(), usize::MAX)
11606 .await
11607 .unwrap()
11608 .to_vec(),
11609 )
11610 .unwrap();
11611 drop(held);
11612 assert_eq!(
11613 status,
11614 StatusCode::SERVICE_UNAVAILABLE,
11615 "a BORROWED failure verdict must fail the check, not just a freshly \
11616 measured one: {body}"
11617 );
11618 assert!(body.starts_with("FAIL"), "{body}");
11619
11620 state.db.close().await;
11621 let resp = router(state.clone())
11622 .oneshot(
11623 Request::builder()
11624 .uri("/health")
11625 .body(Body::empty())
11626 .unwrap(),
11627 )
11628 .await
11629 .unwrap();
11630 assert_eq!(
11631 resp.status(),
11632 StatusCode::SERVICE_UNAVAILABLE,
11633 "a measured database failure must still fail the check"
11634 );
11635 }
11636
11637 #[tokio::test]
11646 async fn an_abandoned_request_still_records_its_probe() {
11647 use crate::runtime_health::DbProbe;
11648 let state = test_state(&[]).await;
11649 let rh = state.runtime_health.clone();
11650
11651 let app = router(state.clone());
11653 let fut = app.oneshot(
11654 Request::builder()
11655 .uri("/health")
11656 .body(Body::empty())
11657 .unwrap(),
11658 );
11659 let handle = tokio::spawn(fut);
11660 handle.abort();
11661 let _ = handle.await;
11662
11663 for _ in 0..50 {
11666 if rh.begin_db_probe().is_ok() {
11667 break;
11668 }
11669 tokio::time::sleep(Duration::from_millis(20)).await;
11670 }
11671 let resp = router(state.clone())
11672 .oneshot(
11673 Request::builder()
11674 .uri("/health")
11675 .body(Body::empty())
11676 .unwrap(),
11677 )
11678 .await
11679 .unwrap();
11680 let body = String::from_utf8(
11681 axum::body::to_bytes(resp.into_body(), usize::MAX)
11682 .await
11683 .unwrap()
11684 .to_vec(),
11685 )
11686 .unwrap();
11687 assert!(
11688 body.contains("db: ok"),
11689 "after an abandoned request the next caller still reads an \
11690 unmeasured database — the probe was cancelled with it: {body}"
11691 );
11692 assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11694 }
11695
11696 #[tokio::test]
11703 async fn the_health_probe_opens_a_real_table() {
11704 use sqlx::Row;
11705 let state = test_state(&[]).await;
11706 let opcodes = |sql: &'static str| {
11708 let db = state.db.clone();
11709 async move {
11710 sqlx::query(sql)
11711 .fetch_all(&db)
11712 .await
11713 .unwrap()
11714 .into_iter()
11715 .map(|r| r.get::<String, _>("opcode"))
11716 .collect::<Vec<String>>()
11717 }
11718 };
11719
11720 let explain: &'static str =
11723 Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11724 let probe = opcodes(explain).await;
11725 assert!(
11727 health_db_probe(&state.db).await.is_ok(),
11728 "the probe does not run against the real schema",
11729 );
11730 assert!(
11731 probe.iter().any(|op| op == "OpenRead"),
11732 "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11733 );
11734 let bare = opcodes("EXPLAIN SELECT 1").await;
11736 assert!(
11737 !bare.iter().any(|op| op == "OpenRead"),
11738 "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11739 );
11740 }
11741
11742 #[test]
11745 fn an_instance_that_has_never_polled_says_so() {
11746 assert_eq!(humanise_ago(None), "never");
11747 assert_eq!(humanise_ago(Some(0)), "0s ago");
11748 assert_eq!(humanise_ago(Some(59)), "59s ago");
11749 assert_eq!(humanise_ago(Some(60)), "1m ago");
11750 assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11751 assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11752 }
11753
11754 async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11757 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11758 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11759 let addr = listener.local_addr().unwrap();
11760 let (url, title) = (saved_url.to_string(), saved_title.to_string());
11761 tokio::spawn(async move {
11762 loop {
11763 let Ok((mut sock, _)) = listener.accept().await else {
11764 break;
11765 };
11766 let mut buf = vec![0u8; 8192];
11767 let Ok(n) = sock.read(&mut buf).await else {
11768 continue;
11769 };
11770 let req = String::from_utf8_lossy(&buf[..n]).to_string();
11771 let wants_saved = req.contains("community.lexicon.rss.saved");
11772 let records = if wants_saved {
11773 serde_json::json!([{
11774 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
11775 "cid": "bafy",
11776 "value": {
11777 "$type": "community.lexicon.rss.saved",
11778 "url": url,
11779 "title": title,
11780 "createdAt": "2026-01-01T00:00:00Z"
11781 }
11782 }])
11783 } else {
11784 serde_json::json!([])
11785 };
11786 let body = serde_json::json!({
11787 "ok": true, "data": { "records": records }
11788 })
11789 .to_string();
11790 let resp = format!(
11791 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11792 body.len(), body
11793 );
11794 let _ = sock.write_all(resp.as_bytes()).await;
11795 let _ = sock.flush().await;
11796 }
11797 });
11798 format!("http://{addr}")
11799 }
11800
11801 async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
11804 let feed = subscribed_feed.to_string();
11805 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11806 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11807 let addr = listener.local_addr().unwrap();
11808 tokio::spawn(async move {
11809 loop {
11810 let Ok((mut sock, _)) = listener.accept().await else {
11811 break;
11812 };
11813 let mut buf = vec![0u8; 8192];
11814 let Ok(read) = sock.read(&mut buf).await else {
11815 continue;
11816 };
11817 let req = String::from_utf8_lossy(&buf[..read]).to_string();
11818 let records = if req.contains("community.lexicon.rss.saved") {
11819 serde_json::Value::Array(
11820 (0..n)
11821 .map(|i| {
11822 serde_json::json!({
11823 "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
11824 "cid": "bafy",
11825 "value": {
11826 "$type": "community.lexicon.rss.saved",
11827 "url": format!("https://elsewhere.example/{i}"),
11828 "title": format!("Elsewhere {i}"),
11829 "createdAt": "2026-01-01T00:00:00Z"
11830 }
11831 })
11832 })
11833 .collect(),
11834 )
11835 } else if req.contains("community.lexicon.rss.subscription") {
11836 serde_json::json!([{
11841 "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
11842 "cid": "bafy",
11843 "value": {
11844 "$type": "community.lexicon.rss.subscription",
11845 "url": feed,
11846 "createdAt": "2026-01-01T00:00:00Z"
11847 }
11848 }])
11849 } else {
11850 serde_json::json!([])
11851 };
11852 let body =
11853 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11854 let resp = format!(
11855 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11856 body.len(), body
11857 );
11858 let _ = sock.write_all(resp.as_bytes()).await;
11859 let _ = sock.flush().await;
11860 }
11861 });
11862 format!("http://{addr}")
11863 }
11864
11865 #[tokio::test]
11873 async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
11874 let did = "did:plc:pagerloop";
11875 let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
11876 let state = test_state_with_sidecar(&[], &sidecar).await;
11877 store::grant_access(&state.db, did, None, "test", None)
11878 .await
11879 .unwrap();
11880 let feed = store::upsert_feed(
11881 &state.db,
11882 &store::NewFeed {
11883 url: "https://loop.example/feed.xml".to_string(),
11884 title: Some("Loop".to_string()),
11885 ..Default::default()
11886 },
11887 )
11888 .await
11889 .unwrap();
11890 let entries: Vec<store::NewEntry> = (0..250)
11893 .map(|i| store::NewEntry {
11894 guid: format!("s-{i:04}"),
11895 url: Some(format!("https://loop.example/{i}")),
11896 title: Some(format!("Starred {i:04}")),
11897 published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
11898 ..Default::default()
11899 })
11900 .collect();
11901 store::insert_entries(&state.db, feed, &entries, 0)
11902 .await
11903 .unwrap();
11904 store::replace_sub_refs(&state.db, did, &[feed])
11905 .await
11906 .unwrap();
11907 for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
11908 .await
11909 .unwrap()
11910 {
11911 store::mark_starred(&state.db, did, row.id, true)
11912 .await
11913 .unwrap();
11914 }
11915
11916 let cookie = session_cookie(&state, did, None);
11917 let app = router(state.clone());
11918 let get = |uri: &str| {
11919 let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
11920 async move {
11921 let resp = app
11922 .oneshot(
11923 Request::builder()
11924 .uri(uri)
11925 .header(header::COOKIE, cookie)
11926 .body(Body::empty())
11927 .unwrap(),
11928 )
11929 .await
11930 .unwrap();
11931 assert_eq!(resp.status(), StatusCode::OK);
11932 String::from_utf8(
11933 axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
11934 .await
11935 .unwrap()
11936 .to_vec(),
11937 )
11938 .unwrap()
11939 }
11940 };
11941
11942 let p3 = get("/?view=starred&page=3").await;
11947 assert!(
11948 p3.contains("Page 3 of 4"),
11949 "the pager and the clamp disagree on the total: {}",
11950 p3.split("pager-pos")
11951 .nth(1)
11952 .unwrap_or("")
11953 .chars()
11954 .take(120)
11955 .collect::<String>()
11956 );
11957 assert!(
11960 p3.contains("Elsewhere 0"),
11961 "page 3 should start the uncached run"
11962 );
11963 assert_eq!(
11964 p3.matches("<li class=\"entry").count(),
11965 ENTRIES_PER_PAGE as usize,
11966 "the boundary page is not full"
11967 );
11968
11969 {
11978 let body = &p3;
11979 assert!(
11980 body.contains("330 entries"),
11981 "the heading must count the whole sequence: {}",
11982 body.split("content-count")
11983 .nth(1)
11984 .unwrap_or("")
11985 .chars()
11986 .take(120)
11987 .collect::<String>()
11988 );
11989 assert!(
11990 body.contains("(80 saved elsewhere)"),
11991 "the heading must say how many of the total the cache cannot show, \
11992 as a whole-list figure and not a per-page one: {}",
11993 body.split("content-count")
11994 .nth(1)
11995 .unwrap_or("")
11996 .chars()
11997 .take(120)
11998 .collect::<String>()
11999 );
12000 assert!(
12001 !body.contains("plus 50") && !body.contains("plus 80"),
12002 "the heading is adding the uncached rows to a total that already \
12003 includes them"
12004 );
12005 }
12006
12007 let p4 = get("/?view=starred&page=4").await;
12008 assert!(
12009 p4.contains("Page 4 of 4"),
12010 "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
12011 );
12012 assert_eq!(
12013 p4.matches("<li class=\"entry").count(),
12014 30,
12015 "page 4 should hold the remaining 30 uncached records"
12016 );
12017 assert!(
12018 p4.contains("Elsewhere 79"),
12019 "the LAST saved record is unreachable — it can only be removed from here"
12020 );
12021
12022 assert!(
12024 !p4.contains("Elsewhere 0"),
12025 "an uncached record was rendered on more than one page"
12026 );
12027 let first = get("/?view=starred").await;
12030 assert!(
12031 first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
12032 "the heading changed between pages; it describes the list, not the page"
12033 );
12034 assert!(
12035 !first.contains("Elsewhere "),
12036 "uncached saved records leaked onto the first page"
12037 );
12038 }
12039
12040 #[tokio::test]
12047 async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
12048 let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
12049 let sidecar =
12050 spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
12051 let mut state = test_state_with_sidecar(&[did], &sidecar).await;
12052 std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
12053
12054 let resp = router(state)
12055 .oneshot(
12056 Request::builder()
12057 .uri("/?view=starred")
12058 .body(Body::empty())
12059 .unwrap(),
12060 )
12061 .await
12062 .unwrap();
12063 assert_eq!(resp.status(), StatusCode::OK);
12064 let body = String::from_utf8(
12065 axum::body::to_bytes(resp.into_body(), usize::MAX)
12066 .await
12067 .unwrap()
12068 .to_vec(),
12069 )
12070 .unwrap();
12071
12072 assert!(
12073 body.contains("Starred elsewhere"),
12074 "the saved record was not rendered at all"
12075 );
12076 assert!(
12077 body.contains("entry-uncached"),
12078 "it was not marked as uncached, so it looks like a normal entry"
12079 );
12080 assert!(
12081 body.contains("https://elsewhere.example/article"),
12082 "the row must link straight to the article"
12083 );
12084 assert!(
12085 !body.contains("/entries/0/"),
12086 "an uncached row must not offer entry actions against a nonexistent id"
12087 );
12088 }
12089
12090 #[test]
12098 fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
12099 for hostile in [
12100 "日本語日本語日本",
12101 "é",
12102 "",
12103 "2026",
12104 "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
12105 ] {
12106 let out = display_date(Some(hostile));
12107 assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
12108 }
12109 assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
12110 assert_eq!(display_date(None), "");
12111 }
12112
12113 #[test]
12116 fn the_unsave_route_is_rate_limited() {
12117 use axum::http::Method;
12118 assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
12119 assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
12121 }
12122
12123 #[tokio::test]
12132 async fn health_reports_a_broken_database() {
12133 let state = test_state(&[]).await;
12134 assert!(
12136 health_db_probe(&state.db).await.is_ok(),
12137 "the fixture was not healthy to begin with",
12138 );
12139
12140 sqlx::query("DROP TABLE feeds")
12141 .execute(&state.db)
12142 .await
12143 .unwrap();
12144
12145 assert!(
12146 health_db_probe(&state.db).await.is_err(),
12147 "the probe reported success against a database missing the table it \
12148 claims to read; `SELECT 1` would do exactly this",
12149 );
12150
12151 let resp = router(state)
12152 .oneshot(
12153 Request::builder()
12154 .uri("/health")
12155 .body(Body::empty())
12156 .unwrap(),
12157 )
12158 .await
12159 .unwrap();
12160 let body = String::from_utf8(
12161 axum::body::to_bytes(resp.into_body(), usize::MAX)
12162 .await
12163 .unwrap()
12164 .to_vec(),
12165 )
12166 .unwrap();
12167 assert!(
12169 body.starts_with("FAIL"),
12170 "/health did not report FAIL for a broken database: {body}",
12171 );
12172 assert!(
12173 !body.contains("db: ok"),
12174 "/health still called the database ok: {body}",
12175 );
12176 }
12177
12178 async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
12183 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12184 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12185 let addr = listener.local_addr().unwrap();
12186 tokio::spawn(async move {
12187 loop {
12188 let Ok((mut sock, _)) = listener.accept().await else {
12189 break;
12190 };
12191 let mut buf = vec![0u8; 8192];
12192 let Ok(n) = sock.read(&mut buf).await else {
12193 continue;
12194 };
12195 let req = String::from_utf8_lossy(&buf[..n]).to_string();
12196 let wants = |c: &str| req.contains(c);
12197 if fail_on.is_some_and(wants) {
12198 let body = r#"{"ok":false,"error":"ShortList"}"#;
12199 let resp = format!(
12200 "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12201 body.len(),
12202 body
12203 );
12204 let _ = sock.write_all(resp.as_bytes()).await;
12205 let _ = sock.flush().await;
12206 continue;
12207 }
12208 let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
12209 serde_json::json!([{
12210 "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
12211 "cid": "bafy",
12212 "value": {
12213 "$type": crate::lexicon::nsid::SUBSCRIPTION,
12214 "url": "https://kept.example/feed.xml",
12215 "title": "Kept",
12216 "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12221 "createdAt": "2026-01-01T00:00:00Z"
12222 }
12223 }])
12224 } else if wants(crate::lexicon::nsid::FOLDER) {
12225 serde_json::json!([{
12226 "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12227 "cid": "bafy",
12228 "value": {
12229 "$type": crate::lexicon::nsid::FOLDER,
12230 "name": "Kept folder",
12231 "createdAt": "2026-01-01T00:00:00Z"
12232 }
12233 }])
12234 } else {
12235 serde_json::json!([])
12236 };
12237 let body =
12238 serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12239 let resp = format!(
12240 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12241 body.len(),
12242 body
12243 );
12244 let _ = sock.write_all(resp.as_bytes()).await;
12245 let _ = sock.flush().await;
12246 }
12247 });
12248 format!("http://{addr}")
12249 }
12250
12251 async fn spawn_malformed_sidecar() -> String {
12254 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12255 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12256 let addr = listener.local_addr().unwrap();
12257 tokio::spawn(async move {
12258 loop {
12259 let Ok((mut sock, _)) = listener.accept().await else {
12260 break;
12261 };
12262 let mut buf = vec![0u8; 8192];
12263 let _ = sock.read(&mut buf).await;
12264 let body = serde_json::json!({ "ok": true, "data": { "records": [
12265 { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
12266 { "cid": "bafy", "value": {} },
12267 ]}})
12268 .to_string();
12269 let resp = format!(
12270 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12271 body.len(),
12272 body
12273 );
12274 let _ = sock.write_all(resp.as_bytes()).await;
12275 let _ = sock.flush().await;
12276 }
12277 });
12278 format!("http://{addr}")
12279 }
12280
12281 async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
12282 let cookie = session_cookie(&state, did, None);
12283 let resp = router(state)
12284 .oneshot(
12285 Request::builder()
12286 .uri(uri)
12287 .header(header::COOKIE, cookie)
12288 .body(Body::empty())
12289 .unwrap(),
12290 )
12291 .await
12292 .unwrap();
12293 let status = resp.status();
12294 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12295 .await
12296 .unwrap();
12297 (status, String::from_utf8_lossy(&body).to_string())
12298 }
12299
12300 #[tokio::test]
12306 async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
12307 let did = "did:plc:displayer";
12308 let state = test_state(&[did]).await;
12309 let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
12310 let feed_id = store::upsert_feed(
12311 &state.db,
12312 &store::NewFeed {
12313 url: url.into(),
12314 title: Some("Quiet Journal".into()),
12315 ..Default::default()
12316 },
12317 )
12318 .await
12319 .unwrap();
12320 store::replace_sub_refs(&state.db, did, &[feed_id])
12321 .await
12322 .unwrap();
12323 store::insert_entries(
12324 &state.db,
12325 feed_id,
12326 &[store::NewEntry {
12327 guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
12328 .into(),
12329 url: Some("https://quiet.example/no-summary".into()),
12330 title: Some("A title-only article".into()),
12331 published: Some("2026-07-11T00:00:00Z".into()),
12332 content_html: None,
12333 ..Default::default()
12334 }],
12335 0,
12336 )
12337 .await
12338 .unwrap();
12339 let (status, list) = page_body(state.clone(), did, "/?view=all").await;
12340 assert_eq!(status, StatusCode::OK);
12341 assert!(
12342 list.contains("A title-only article"),
12343 "the entry is missing from the list"
12344 );
12345
12346 let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
12347 .bind(feed_id)
12348 .fetch_one(&state.db)
12349 .await
12350 .unwrap();
12351 let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
12352 assert_eq!(
12353 status,
12354 StatusCode::OK,
12355 "the article page failed for an entry with no body"
12356 );
12357 assert!(page.contains("A title-only article"));
12358 assert!(
12359 page.contains("https://quiet.example/no-summary"),
12360 "no link to the original"
12361 );
12362 assert!(
12363 page.contains(r#"<time datetime=""#),
12364 "no date on the article page"
12365 );
12366 }
12367
12368 #[tokio::test]
12373 async fn a_malformed_subscription_record_raises_an_alert() {
12374 let did = "did:plc:alerted";
12375 for page in ["/", "/manage"] {
12376 let sidecar = spawn_malformed_sidecar().await;
12377 let state = test_state_with_sidecar(&[did], &sidecar).await;
12378 let (status, body) = page_body(state, did, page).await;
12379 assert_eq!(status, StatusCode::OK, "{page} did not render");
12380 assert!(
12381 body.contains(r#"role="alert""#) && body.contains("could not be read"),
12382 "{page} rendered no alert for a refused subscription list"
12383 );
12384 assert!(
12385 body.contains("1 record(s) in your subscription list"),
12386 "{page} gave the generic alert, not the malformed-record one"
12387 );
12388 }
12389 }
12390
12391 #[tokio::test]
12393 async fn a_healthy_subscription_listing_raises_no_alert() {
12394 let did = "did:plc:exporter";
12395 let sidecar = spawn_export_sidecar(None).await;
12396 let state = test_state_with_sidecar(&[did], &sidecar).await;
12397 let (status, body) = page_body(state, did, "/").await;
12398 assert_eq!(status, StatusCode::OK);
12399 assert!(
12400 !body.contains("could not be read"),
12401 "a healthy listing raised an alert"
12402 );
12403 }
12404
12405 async fn export_opml_response(
12407 fail_on: Option<&'static str>,
12408 ) -> (StatusCode, HeaderMap, String) {
12409 let did = "did:plc:exporter";
12410 let sidecar = spawn_export_sidecar(fail_on).await;
12411 let state = test_state_with_sidecar(&[did], &sidecar).await;
12412 let cookie = session_cookie(&state, did, None);
12413 let resp = router(state)
12414 .oneshot(
12415 Request::builder()
12416 .uri("/opml/export")
12417 .header(header::COOKIE, cookie)
12418 .body(Body::empty())
12419 .unwrap(),
12420 )
12421 .await
12422 .unwrap();
12423 let status = resp.status();
12424 let headers = resp.headers().clone();
12425 let body = String::from_utf8_lossy(
12426 &axum::body::to_bytes(resp.into_body(), usize::MAX)
12427 .await
12428 .unwrap(),
12429 )
12430 .to_string();
12431 (status, headers, body)
12432 }
12433
12434 #[tokio::test]
12447 async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
12448 let (status, headers, body) =
12449 export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
12450
12451 assert_ne!(
12452 status,
12453 StatusCode::OK,
12454 "a failed subscription walk answered 200: {body}",
12455 );
12456 assert!(
12457 !headers.contains_key(header::CONTENT_DISPOSITION),
12458 "a failed subscription walk still offered a download: {headers:?}",
12459 );
12460 assert!(
12461 !body.contains("<opml"),
12462 "a failed subscription walk still served an OPML document: {body}",
12463 );
12464 }
12465
12466 #[tokio::test]
12470 async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
12471 let (status, headers, body) =
12472 export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
12473
12474 assert_ne!(
12475 status,
12476 StatusCode::OK,
12477 "a failed folder walk answered 200: {body}",
12478 );
12479 assert!(
12480 !headers.contains_key(header::CONTENT_DISPOSITION),
12481 "a failed folder walk still offered a download: {headers:?}",
12482 );
12483 assert!(
12484 !body.contains("<opml"),
12485 "a failed folder walk still served an OPML document: {body}",
12486 );
12487 }
12488
12489 #[tokio::test]
12492 async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
12493 let (status, headers, body) = export_opml_response(None).await;
12494
12495 assert_eq!(
12496 status,
12497 StatusCode::OK,
12498 "a healthy export did not answer 200"
12499 );
12500 assert_eq!(
12501 headers
12502 .get(header::CONTENT_DISPOSITION)
12503 .and_then(|v| v.to_str().ok()),
12504 Some("attachment; filename=\"featherreader-subscriptions.opml\""),
12505 "a healthy export did not offer the download",
12506 );
12507 assert!(
12508 body.contains("https://kept.example/feed.xml"),
12509 "the exported OPML lost the subscription: {body}",
12510 );
12511 assert!(
12512 body.contains("Kept folder"),
12513 "the exported OPML lost the folder: {body}",
12514 );
12515 }
12516}