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::{feed, store, AppState, Session, VERSION};
74
75#[path = "opml.rs"]
80mod opml;
81
82const SESSION_COOKIE: &str = "fr_session";
84
85const INVITE_COOKIE: &str = "fr_invite";
93
94const INVITE_TTL_SECS: i64 = 1800;
97
98const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
101
102const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
104
105const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
107
108const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
124 script-src 'self'; \
125 style-src 'self' 'unsafe-inline'; \
126 img-src 'self' https: data:; \
127 font-src 'self'; \
128 connect-src 'self'; \
129 form-action 'self'; \
130 base-uri 'self'; \
131 frame-ancestors 'none'; \
132 object-src 'none'";
133
134#[derive(Clone, Debug)]
141struct CurrentUser {
142 did: String,
143 handle: Option<String>,
144 sid: Option<String>,
147}
148
149async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
160 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
161 if let Some(session) = state.sessions.get(&sid) {
162 if store::has_beta_access(&state.db, &session.did)
163 .await
164 .unwrap_or(false)
165 {
166 return Some(CurrentUser {
167 did: session.did,
168 handle: session.handle,
169 sid: Some(sid),
170 });
171 }
172 state.sessions.remove(&sid);
175 }
176 }
177 if let Some(did) = state.config.dev_did.clone() {
180 if store::has_beta_access(&state.db, &did)
181 .await
182 .unwrap_or(false)
183 {
184 return Some(CurrentUser {
185 did,
186 handle: None,
187 sid: None,
188 });
189 }
190 }
191 None
192}
193
194async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
196 current_session(state, headers).await.map(|u| u.did)
197}
198
199pub fn router(state: AppState) -> Router {
205 let limiter = RateLimiter::shared();
209 let rl_state = RateLimitState {
213 limiter,
214 trusted_header: state.config.trusted_ip_header.clone(),
215 };
216
217 Router::new()
218 .route("/health", get(health))
219 .route("/about", get(about))
220 .route("/manage", get(manage))
221 .route("/", get(index))
222 .route("/entries/{id}", get(entry_view))
223 .route("/entries/{id}/read", post(mark_read))
224 .route("/entries/{id}/star", post(toggle_star))
225 .route("/read-all", post(mark_all_read))
226 .route("/subscriptions", post(add_subscription))
227 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
228 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
229 .route("/folders", post(create_folder))
230 .route("/folders/{rkey}/rename", post(rename_folder))
231 .route("/folders/{rkey}/delete", post(delete_folder))
232 .route(
235 "/opml",
236 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
237 )
238 .route("/opml/export", get(export_opml))
239 .route("/login", get(login_form).post(login_submit))
240 .route(
241 "/beta/redeem",
242 get(beta_redeem_form).post(beta_redeem_submit),
243 )
244 .route("/claim", get(claim))
247 .route("/bot/claims", post(bot_mint_claim))
250 .route("/admin/invites", post(admin_mint_invites))
251 .route("/account/delete", post(account_delete))
252 .route("/oauth/callback", get(oauth_callback))
253 .route("/logout", post(logout))
254 .nest_service("/static", ServeDir::new("static"))
255 .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
259 .layer(middleware::from_fn(cache_control))
264 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
267 .layer(TraceLayer::new_for_http())
268 .layer(static_header_layer(
272 "content-security-policy",
273 CONTENT_SECURITY_POLICY,
274 ))
275 .layer(static_header_layer("x-content-type-options", "nosniff"))
276 .layer(static_header_layer(
277 "referrer-policy",
278 "strict-origin-when-cross-origin",
279 ))
280 .layer(static_header_layer("x-frame-options", "DENY"))
281 .with_state(state)
282}
283
284const OPML_BODY_LIMIT: usize = 2 * 1024 * 1024;
287
288fn static_header_layer(
292 name: &'static str,
293 value: &'static str,
294) -> SetResponseHeaderLayer<header::HeaderValue> {
295 SetResponseHeaderLayer::overriding(
296 header::HeaderName::from_static(name),
297 header::HeaderValue::from_static(value),
298 )
299}
300
301fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
309 use axum::http::Method;
310 if method != Method::POST && !(method == Method::GET && (path == "/login" || path == "/claim"))
314 {
315 return false;
316 }
317 match path {
318 "/login" | "/claim" | "/beta/redeem" | "/subscriptions" | "/opml" | "/read-all"
319 | "/admin/invites" | "/bot/claims" | "/account/delete" | "/folders" => true,
320 p => {
323 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
324 || p.starts_with("/subscriptions/")
325 || p.starts_with("/folders/")
326 }
327 }
328}
329
330#[derive(Clone)]
333struct RateLimitState {
334 limiter: RateLimiter,
335 trusted_header: Option<String>,
338}
339
340#[derive(Clone)]
345struct RateLimiter {
346 inner: std::sync::Arc<Mutex<HashMap<IpAddr, Bucket>>>,
347}
348
349struct Bucket {
351 tokens: f64,
352 last: Instant,
353}
354
355const RATE_BURST: f64 = 20.0;
357const RATE_REFILL_PER_SEC: f64 = 1.0;
359const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
361
362impl RateLimiter {
363 fn shared() -> Self {
365 Self {
366 inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
367 }
368 }
369
370 fn check(&self, ip: IpAddr) -> bool {
373 let now = Instant::now();
374 let mut map = match self.inner.lock() {
375 Ok(m) => m,
376 Err(p) => p.into_inner(),
378 };
379 map.retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
381
382 let bucket = map.entry(ip).or_insert(Bucket {
383 tokens: RATE_BURST,
384 last: now,
385 });
386 let elapsed = now.duration_since(bucket.last).as_secs_f64();
387 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
388 bucket.last = now;
389 if bucket.tokens >= 1.0 {
390 bucket.tokens -= 1.0;
391 true
392 } else {
393 false
394 }
395 }
396}
397
398fn client_ip(
419 headers: &HeaderMap,
420 conn: Option<&SocketAddr>,
421 trusted_header: Option<&str>,
422) -> Option<IpAddr> {
423 if let Some(name) = trusted_header {
424 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
425 if let Some(last) = raw.split(',').next_back() {
428 if let Ok(ip) = last.trim().parse::<IpAddr>() {
429 return Some(ip);
430 }
431 }
432 }
433 }
435 conn.map(|s| s.ip())
436}
437
438async fn rate_limit(
443 State(rl): State<RateLimitState>,
444 req: axum::extract::Request,
445 next: Next,
446) -> Response {
447 let path = req.uri().path().to_string();
448 let method = req.method().clone();
449 if is_rate_limited_path(&path, &method) {
450 let conn = req
451 .extensions()
452 .get::<ConnectInfo<SocketAddr>>()
453 .map(|c| c.0);
454 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
455 if let Some(ip) = ip {
461 if !rl.limiter.check(ip) {
462 warn!(%ip, %path, "rate limit exceeded");
463 return (
464 StatusCode::TOO_MANY_REQUESTS,
465 [(header::RETRY_AFTER, "1")],
466 "rate limit exceeded\n",
467 )
468 .into_response();
469 }
470 }
471 }
472 next.run(req).await
473}
474
475async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
485 let path = req.uri().path().to_string();
486 let is_login_landing = path == "/login"
489 && req.method() == axum::http::Method::GET
490 && !req.uri().query().unwrap_or("").contains("handle=");
491 let public = is_login_landing || path == "/about" || path.starts_with("/static/");
492
493 let mut resp = next.run(req).await;
494 if resp.headers().contains_key(header::CACHE_CONTROL) {
495 return resp;
496 }
497 let value = if public {
498 "public, max-age=300"
499 } else {
500 "no-store"
501 };
502 if let Ok(hv) = header::HeaderValue::from_str(value) {
503 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
504 }
505 resp
506}
507
508async fn health() -> impl IntoResponse {
514 (StatusCode::OK, format!("ok featherreader/{VERSION}\n"))
515}
516
517async fn about() -> Response {
521 render(&AboutTemplate {
522 version: VERSION,
523 repo_url: REPO_URL,
524 kofi_url: KOFI_URL,
525 })
526}
527
528struct FeedView {
535 rkey: String,
537 url: String,
539 title: String,
540 unread: i64,
541 selected: bool,
543 folder: Option<String>,
548}
549
550struct FolderView {
552 rkey: String,
554 uri: String,
556 name: String,
557 feeds: Vec<FeedView>,
558 selected: bool,
560}
561
562struct EntryRow {
564 id: i64,
565 title: String,
566 feed_title: String,
567 published: String,
568 read: bool,
569 starred: bool,
570 link: String,
573}
574
575struct FolderOption {
577 uri: String,
578 name: String,
579}
580
581struct Nav {
586 handle: String,
588 avatar: String,
590 view: String,
592 scope_qs: String,
595 folders: Vec<FolderView>,
598 loose_feeds: Vec<FeedView>,
599 manage_active: bool,
601}
602
603#[derive(Template)]
605#[template(path = "index.html")]
606struct IndexTemplate {
607 version: &'static str,
608 repo_url: &'static str,
609 kofi_url: &'static str,
610 flash: String,
611 nav: Nav,
613 entries: Vec<EntryRow>,
615 heading: String,
617 feed_scope: Option<String>,
619}
620
621#[derive(Template)]
623#[template(path = "manage.html")]
624struct ManageTemplate {
625 version: &'static str,
626 repo_url: &'static str,
627 kofi_url: &'static str,
628 flash: String,
629 nav: Nav,
630 folder_options: Vec<FolderOption>,
632 folders: Vec<FolderView>,
634 loose_feeds: Vec<FeedView>,
635}
636
637#[derive(Template)]
639#[template(path = "about.html")]
640struct AboutTemplate {
641 version: &'static str,
642 repo_url: &'static str,
643 kofi_url: &'static str,
644}
645
646#[derive(Template)]
649#[template(path = "landing.html")]
650struct LandingTemplate {
651 version: &'static str,
652 repo_url: &'static str,
653 crates_url: &'static str,
654 kofi_url: &'static str,
655}
656
657#[derive(Template)]
659#[template(path = "entry.html")]
660struct EntryTemplate {
661 version: &'static str,
662 repo_url: &'static str,
663 kofi_url: &'static str,
664 nav: Nav,
665 id: i64,
666 title: String,
667 feed_title: String,
668 author: Option<String>,
669 published: String,
670 url: Option<String>,
671 content_html: Option<String>,
672 read: bool,
673 starred: bool,
674 back_qs: String,
676 prev_id: Option<i64>,
678 next_id: Option<i64>,
679 oob: bool,
681}
682
683#[derive(Template)]
685#[template(path = "entry_row.html")]
686struct EntryRowTemplate {
687 e: EntryRow,
688}
689
690#[derive(Template)]
695#[template(path = "entry_actionbar.html")]
696struct EntryActionBarTemplate {
697 id: i64,
698 read: bool,
699 starred: bool,
700 oob: bool,
702}
703
704#[derive(Template)]
706#[template(path = "login.html")]
707struct LoginTemplate {
708 repo_url: &'static str,
709 error: String,
710 flash: String,
713}
714
715#[derive(Template)]
717#[template(path = "beta_redeem.html")]
718struct BetaRedeemTemplate {
719 repo_url: &'static str,
720 error: String,
721 capacity_full: bool,
724}
725
726fn render<T: Template>(tmpl: &T) -> Response {
733 match tmpl.render() {
734 Ok(body) => Html(body).into_response(),
735 Err(err) => {
736 warn!(%err, "template render failed");
737 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
738 }
739 }
740}
741
742struct WebError {
747 err: anyhow::Error,
748 status: StatusCode,
749}
750
751impl<E: Into<anyhow::Error>> From<E> for WebError {
752 fn from(err: E) -> Self {
753 WebError {
754 err: err.into(),
755 status: StatusCode::INTERNAL_SERVER_ERROR,
756 }
757 }
758}
759
760impl WebError {
761 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
763 WebError {
764 err: err.into(),
765 status,
766 }
767 }
768}
769
770impl IntoResponse for WebError {
771 fn into_response(self) -> Response {
772 warn!(error = %self.err, status = %self.status, "request failed");
773 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
774 "internal error"
775 } else {
776 self.status.canonical_reason().unwrap_or("error")
777 };
778 (self.status, body).into_response()
779 }
780}
781
782fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
787 let status = err.status();
788 WebError::with_status(err, status)
789}
790
791fn display_title(title: Option<&str>, url: &str) -> String {
794 if let Some(t) = title {
795 let t = t.trim();
796 if !t.is_empty() {
797 return t.to_string();
798 }
799 }
800 url::Url::parse(url)
801 .ok()
802 .and_then(|u| u.host_str().map(str::to_string))
803 .unwrap_or_else(|| url.to_string())
804}
805
806fn display_handle(handle: Option<&str>, did: &str) -> String {
809 match handle {
810 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
811 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
812 }
813}
814
815fn avatar_initials(handle: Option<&str>, did: &str) -> String {
817 let source = handle
818 .map(|h| h.trim().trim_start_matches('@'))
819 .filter(|h| !h.is_empty())
820 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
821 let letters: String = source
822 .chars()
823 .filter(|c| c.is_alphanumeric())
824 .take(2)
825 .collect::<String>()
826 .to_lowercase();
827 if letters.is_empty() {
828 "fr".to_string()
829 } else {
830 letters
831 }
832}
833
834fn display_date(published: Option<&str>) -> String {
837 match published {
838 Some(p) if p.len() >= 10 => p[..10].to_string(),
839 Some(p) => p.to_string(),
840 None => String::new(),
841 }
842}
843
844fn qenc(s: &str) -> String {
848 let mut out = String::with_capacity(s.len() * 3);
849 for b in s.bytes() {
850 match b {
851 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
852 out.push(b as char)
853 }
854 _ => out.push_str(&format!("%{b:02X}")),
855 }
856 }
857 out
858}
859
860#[derive(Debug, Deserialize, Default)]
866struct IndexQuery {
867 #[serde(default)]
869 feed: Option<String>,
870 #[serde(default)]
872 folder: Option<String>,
873 #[serde(default)]
875 view: Option<String>,
876 #[serde(default)]
878 flash: Option<String>,
879}
880
881struct ResolvedSub {
884 rkey: String,
885 sub: Subscription,
886 feed: Option<store::Feed>,
887}
888
889async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
893 let pool = &state.db;
894 let subs = match state.sidecar.list_subscriptions_sorted(did).await {
895 Ok(s) => s,
896 Err(err) => {
897 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
898 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_default();
905 return feeds
906 .into_iter()
907 .map(|f| ResolvedSub {
908 rkey: String::new(),
909 sub: Subscription::new(f.url.clone(), now_rfc3339()),
910 feed: Some(f),
911 })
912 .collect();
913 }
914 };
915
916 let mut out = Vec::with_capacity(subs.len());
917 for (rkey, sub) in subs {
918 let feed = match store::get_feed_by_url(pool, &sub.url).await {
919 Ok(Some(f)) => Some(f),
920 Ok(None) => {
921 let _ = store::upsert_feed(
923 pool,
924 &store::NewFeed {
925 url: sub.url.clone(),
926 title: sub.title.clone(),
927 site_url: sub.site_url.clone(),
928 ..Default::default()
929 },
930 )
931 .await;
932 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
933 }
934 Err(err) => {
935 warn!(%err, url = %sub.url, "get_feed_by_url failed");
936 None
937 }
938 };
939 out.push(ResolvedSub { rkey, sub, feed });
940 }
941 sync_sub_refs(pool, did, &out).await;
945 out
946}
947
948async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
952 let feed_ids: Vec<i64> = subs
953 .iter()
954 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
955 .collect();
956 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
957 warn!(%err, %did, "failed to sync sub_ref projection");
958 }
959}
960
961async fn index(
964 State(state): State<AppState>,
965 headers: HeaderMap,
966 Query(q): Query<IndexQuery>,
967) -> Result<Response, WebError> {
968 let user = match current_session(&state, &headers).await {
969 Some(u) => u,
970 None => {
973 return Ok(render(&LandingTemplate {
974 version: VERSION,
975 repo_url: REPO_URL,
976 crates_url: CRATES_URL,
977 kofi_url: KOFI_URL,
978 }))
979 }
980 };
981 let did = user.did.clone();
982 let pool = &state.db;
983
984 let subs = resolve_subscriptions(&state, &did).await;
985
986 let unread = store::get_unread_for_did(pool, &did).await?;
988 let starred = store::get_starred_for_did(pool, &did).await?;
989 let starred_ids: std::collections::HashSet<i64> = starred.iter().map(|e| e.id).collect();
990
991 let view = match q.view.as_deref() {
993 Some("all") => "all",
994 Some("starred") => "starred",
995 _ => "unread",
996 }
997 .to_string();
998
999 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1001
1002 let feed_url_by_id = |id: i64| -> Option<String> {
1004 subs.iter()
1005 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1006 .map(|s| s.sub.url.clone())
1007 };
1008 let feed_title_by_id = |id: i64| -> String {
1009 subs.iter()
1010 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1011 .map(|s| {
1012 display_title(
1013 s.sub
1014 .title
1015 .as_deref()
1016 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1017 &s.sub.url,
1018 )
1019 })
1020 .unwrap_or_default()
1021 };
1022
1023 let in_scope = |feed_id: i64| -> bool {
1024 match &scope_urls {
1025 None => true,
1026 Some(urls) => feed_url_by_id(feed_id)
1027 .map(|u| urls.contains(&u))
1028 .unwrap_or(false),
1029 }
1030 };
1031
1032 let source = match view.as_str() {
1034 "all" => {
1035 let mut all = Vec::new();
1037 for s in &subs {
1038 if let Some(f) = &s.feed {
1039 if in_scope(f.id) {
1040 let mut es = store::entries_for_feed(pool, &did, f.id)
1041 .await
1042 .unwrap_or_default();
1043 all.append(&mut es);
1044 }
1045 }
1046 }
1047 all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1048 all
1049 }
1050 "starred" => starred
1051 .iter()
1052 .filter(|e| in_scope(e.feed_id))
1053 .cloned()
1054 .collect(),
1055 _ => unread
1056 .iter()
1057 .filter(|e| in_scope(e.feed_id))
1058 .cloned()
1059 .collect(),
1060 };
1061
1062 let entry_scope_qs = {
1064 let mut parts = Vec::new();
1065 if let Some(f) = q.feed.as_deref() {
1066 parts.push(format!("feed={}", qenc(f)));
1067 }
1068 if let Some(f) = q.folder.as_deref() {
1069 parts.push(format!("folder={}", qenc(f)));
1070 }
1071 if view != "unread" {
1072 parts.push(format!("view={}", qenc(&view)));
1073 }
1074 parts.join("&")
1075 };
1076 let entry_link = |id: i64| -> String {
1077 if entry_scope_qs.is_empty() {
1078 format!("/entries/{id}")
1079 } else {
1080 format!("/entries/{id}?{entry_scope_qs}")
1081 }
1082 };
1083
1084 let entries: Vec<EntryRow> = source
1085 .iter()
1086 .map(|e| EntryRow {
1087 id: e.id,
1088 title: e
1089 .title
1090 .clone()
1091 .filter(|t| !t.trim().is_empty())
1092 .unwrap_or_else(|| "(untitled)".to_string()),
1093 feed_title: feed_title_by_id(e.feed_id),
1094 published: display_date(e.published.as_deref()),
1095 read: view != "unread" && !unread.iter().any(|u| u.id == e.id),
1096 starred: starred_ids.contains(&e.id),
1097 link: entry_link(e.id),
1098 })
1099 .collect();
1100
1101 let selected_feed = q.feed.as_deref();
1102 let selected_folder = q.folder.as_deref();
1103
1104 let (folder_views, loose_feeds, _folder_options) =
1106 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
1107
1108 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
1110 let name = subs
1111 .iter()
1112 .find(|s| s.sub.url == feed_url)
1113 .map(|s| {
1114 display_title(
1115 s.sub
1116 .title
1117 .as_deref()
1118 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1119 &s.sub.url,
1120 )
1121 })
1122 .unwrap_or_else(|| display_title(None, feed_url));
1123 (name, format!("feed={}", qenc(feed_url)))
1124 } else if let Some(folder_uri) = selected_folder {
1125 let name = folder_views
1126 .iter()
1127 .find(|f| f.uri == folder_uri)
1128 .map(|f| f.name.clone())
1129 .unwrap_or_else(|| "Folder".to_string());
1130 (name, format!("folder={}", qenc(folder_uri)))
1131 } else {
1132 let h = match view.as_str() {
1133 "all" => "All",
1134 "starred" => "Starred",
1135 _ => "Unread",
1136 };
1137 (h.to_string(), String::new())
1138 };
1139
1140 let feed_scope = selected_feed.map(str::to_string);
1141 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
1142
1143 let tmpl = IndexTemplate {
1144 version: VERSION,
1145 repo_url: REPO_URL,
1146 kofi_url: KOFI_URL,
1147 flash: q.flash.unwrap_or_default(),
1148 nav,
1149 entries,
1150 heading,
1151 feed_scope,
1152 };
1153 Ok(render(&tmpl))
1154}
1155
1156#[derive(Debug, Deserialize, Default)]
1158struct ManageQuery {
1159 #[serde(default)]
1160 flash: Option<String>,
1161}
1162
1163async fn manage(
1168 State(state): State<AppState>,
1169 headers: HeaderMap,
1170 Query(q): Query<ManageQuery>,
1171) -> Result<Response, WebError> {
1172 let user = match current_session(&state, &headers).await {
1173 Some(u) => u,
1174 None => return Ok(Redirect::to("/login").into_response()),
1175 };
1176 let did = user.did.clone();
1177
1178 let subs = resolve_subscriptions(&state, &did).await;
1179 let (folder_views, loose_feeds, folder_options) =
1180 build_sidebar(&state, &did, &subs, None, None).await;
1181
1182 let nav = build_nav(
1184 &user,
1185 "unread",
1186 String::new(),
1187 folder_views.iter().map(clone_folder_view).collect(),
1188 loose_feeds.iter().map(clone_feed_view).collect(),
1189 true,
1190 );
1191
1192 let tmpl = ManageTemplate {
1193 version: VERSION,
1194 repo_url: REPO_URL,
1195 kofi_url: KOFI_URL,
1196 flash: q.flash.unwrap_or_default(),
1197 nav,
1198 folder_options,
1199 folders: folder_views,
1200 loose_feeds,
1201 };
1202 Ok(render(&tmpl))
1203}
1204
1205fn clone_feed_view(f: &FeedView) -> FeedView {
1208 FeedView {
1209 rkey: f.rkey.clone(),
1210 url: f.url.clone(),
1211 title: f.title.clone(),
1212 unread: f.unread,
1213 selected: f.selected,
1214 folder: f.folder.clone(),
1215 }
1216}
1217
1218fn clone_folder_view(f: &FolderView) -> FolderView {
1219 FolderView {
1220 rkey: f.rkey.clone(),
1221 uri: f.uri.clone(),
1222 name: f.name.clone(),
1223 feeds: f.feeds.iter().map(clone_feed_view).collect(),
1224 selected: f.selected,
1225 }
1226}
1227
1228fn scope_urls_for(
1233 subs: &[ResolvedSub],
1234 feed: Option<&str>,
1235 folder: Option<&str>,
1236) -> Option<Vec<String>> {
1237 if let Some(feed_url) = feed {
1238 Some(vec![feed_url.to_string()])
1239 } else {
1240 folder.map(|folder_uri| {
1241 subs.iter()
1242 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
1243 .map(|s| s.sub.url.clone())
1244 .collect()
1245 })
1246 }
1247}
1248
1249fn folder_uri(did: &str, rkey: &str) -> String {
1251 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
1252}
1253
1254async fn build_sidebar(
1258 state: &AppState,
1259 did: &str,
1260 subs: &[ResolvedSub],
1261 selected_feed: Option<&str>,
1262 selected_folder: Option<&str>,
1263) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
1264 let pool = &state.db;
1265 let unread = store::get_unread_for_did(pool, did)
1266 .await
1267 .unwrap_or_default();
1268 let folders = state
1269 .sidecar
1270 .list_folders_sorted(did)
1271 .await
1272 .unwrap_or_default();
1273
1274 let unread_count = |feed_id: Option<i64>| -> i64 {
1275 match feed_id {
1276 Some(id) => unread.iter().filter(|e| e.feed_id == id).count() as i64,
1277 None => 0,
1278 }
1279 };
1280 let mk_feed_view = |s: &ResolvedSub| FeedView {
1281 rkey: s.rkey.clone(),
1282 url: s.sub.url.clone(),
1283 title: display_title(
1284 s.sub
1285 .title
1286 .as_deref()
1287 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1288 &s.sub.url,
1289 ),
1290 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
1291 selected: selected_feed == Some(s.sub.url.as_str()),
1292 folder: s.sub.folder.clone(),
1293 };
1294
1295 let mut folder_views = Vec::with_capacity(folders.len());
1296 for (rkey, folder) in &folders {
1297 let uri = folder_uri(did, rkey);
1298 let feeds: Vec<FeedView> = subs
1299 .iter()
1300 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
1301 .map(mk_feed_view)
1302 .collect();
1303 folder_views.push(FolderView {
1304 rkey: rkey.clone(),
1305 uri: uri.clone(),
1306 name: folder.name.clone(),
1307 feeds,
1308 selected: selected_folder == Some(uri.as_str()),
1309 });
1310 }
1311
1312 let known_uris: std::collections::HashSet<String> =
1313 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
1314 let loose_feeds: Vec<FeedView> = subs
1315 .iter()
1316 .filter(|s| {
1317 s.sub
1318 .folder
1319 .as_deref()
1320 .map(|f| !known_uris.contains(f))
1321 .unwrap_or(true)
1322 })
1323 .map(mk_feed_view)
1324 .collect();
1325
1326 let folder_options: Vec<FolderOption> = folders
1327 .iter()
1328 .map(|(rkey, folder)| FolderOption {
1329 name: folder.name.clone(),
1330 uri: folder_uri(did, rkey),
1331 })
1332 .collect();
1333
1334 (folder_views, loose_feeds, folder_options)
1335}
1336
1337fn build_nav(
1339 user: &CurrentUser,
1340 view: &str,
1341 scope_qs: String,
1342 folders: Vec<FolderView>,
1343 loose_feeds: Vec<FeedView>,
1344 manage_active: bool,
1345) -> Nav {
1346 Nav {
1347 handle: display_handle(user.handle.as_deref(), &user.did),
1348 avatar: avatar_initials(user.handle.as_deref(), &user.did),
1349 view: view.to_string(),
1350 scope_qs,
1351 folders,
1352 loose_feeds,
1353 manage_active,
1354 }
1355}
1356
1357#[derive(Debug, Deserialize, Default)]
1364struct EntryQuery {
1365 #[serde(default)]
1366 feed: Option<String>,
1367 #[serde(default)]
1368 folder: Option<String>,
1369 #[serde(default)]
1370 view: Option<String>,
1371}
1372
1373async fn entry_view(
1376 State(state): State<AppState>,
1377 headers: HeaderMap,
1378 Path(id): Path<i64>,
1379 Query(q): Query<EntryQuery>,
1380) -> Result<Response, WebError> {
1381 let user = match current_session(&state, &headers).await {
1382 Some(u) => u,
1383 None => return Ok(Redirect::to("/login").into_response()),
1384 };
1385 let did = user.did.clone();
1386 let pool = &state.db;
1387
1388 let subs = resolve_subscriptions(&state, &did).await;
1392
1393 let entry = match get_entry_by_id(pool, &did, id).await? {
1394 Some(e) => e,
1395 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1396 };
1397
1398 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
1399
1400 let read = entry_is_read(pool, &did, id).await?;
1401 let starred = entry_is_starred(pool, &did, id).await?;
1402
1403 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
1406
1407 let back_qs = scope_query(&q);
1408
1409 let (folder_views, loose_feeds, _) =
1410 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
1411 let nav_view = match q.view.as_deref() {
1412 Some("all") => "all",
1413 Some("starred") => "starred",
1414 _ => "unread",
1415 };
1416 let nav = build_nav(
1417 &user,
1418 nav_view,
1419 back_qs.clone(),
1420 folder_views,
1421 loose_feeds,
1422 false,
1423 );
1424
1425 let tmpl = EntryTemplate {
1426 version: VERSION,
1427 repo_url: REPO_URL,
1428 kofi_url: KOFI_URL,
1429 nav,
1430 id: entry.id,
1431 title: entry
1432 .title
1433 .clone()
1434 .filter(|t| !t.trim().is_empty())
1435 .unwrap_or_else(|| "(untitled)".to_string()),
1436 feed_title,
1437 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
1438 published: display_date(entry.published.as_deref()),
1439 url: entry.url.clone().filter(|u| !u.trim().is_empty()),
1440 content_html: entry.content_html.clone(),
1441 read,
1442 starred,
1443 back_qs,
1444 prev_id,
1445 next_id,
1446 oob: false,
1447 };
1448 Ok(render(&tmpl))
1449}
1450
1451async fn neighbors_in_scope(
1454 state: &AppState,
1455 did: &str,
1456 q: &EntryQuery,
1457 current: i64,
1458) -> (Option<i64>, Option<i64>) {
1459 let idx_q = IndexQuery {
1460 feed: q.feed.clone(),
1461 folder: q.folder.clone(),
1462 view: q.view.clone(),
1463 flash: None,
1464 };
1465 let ids = list_entry_ids(state, did, &idx_q).await;
1466 let pos = ids.iter().position(|&x| x == current);
1467 match pos {
1468 Some(p) => {
1469 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
1470 let next = ids.get(p + 1).copied();
1471 (prev, next)
1472 }
1473 None => (None, None),
1474 }
1475}
1476
1477async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
1480 let pool = &state.db;
1481 let subs = resolve_subscriptions(state, did).await;
1482
1483 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1484 let feed_url_by_id = |id: i64| -> Option<String> {
1485 subs.iter()
1486 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1487 .map(|s| s.sub.url.clone())
1488 };
1489 let in_scope = |feed_id: i64| -> bool {
1490 match &scope_urls {
1491 None => true,
1492 Some(urls) => feed_url_by_id(feed_id)
1493 .map(|u| urls.contains(&u))
1494 .unwrap_or(false),
1495 }
1496 };
1497
1498 let view = q.view.as_deref().unwrap_or("unread");
1499 let entries = match view {
1500 "all" => {
1501 let mut all = Vec::new();
1502 for s in &subs {
1503 if let Some(f) = &s.feed {
1504 if in_scope(f.id) {
1505 let mut es = store::entries_for_feed(pool, did, f.id)
1506 .await
1507 .unwrap_or_default();
1508 all.append(&mut es);
1509 }
1510 }
1511 }
1512 all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1513 all
1514 }
1515 "starred" => store::get_starred_for_did(pool, did)
1516 .await
1517 .unwrap_or_default()
1518 .into_iter()
1519 .filter(|e| in_scope(e.feed_id))
1520 .collect(),
1521 _ => store::get_unread_for_did(pool, did)
1522 .await
1523 .unwrap_or_default()
1524 .into_iter()
1525 .filter(|e| in_scope(e.feed_id))
1526 .collect(),
1527 };
1528 entries.into_iter().map(|e| e.id).collect()
1529}
1530
1531fn scope_query(q: &EntryQuery) -> String {
1533 let mut parts = Vec::new();
1534 if let Some(f) = q.feed.as_deref() {
1535 parts.push(format!("feed={}", qenc(f)));
1536 }
1537 if let Some(f) = q.folder.as_deref() {
1538 parts.push(format!("folder={}", qenc(f)));
1539 }
1540 if let Some(v) = q.view.as_deref() {
1541 if v != "unread" {
1542 parts.push(format!("view={}", qenc(v)));
1543 }
1544 }
1545 parts.join("&")
1546}
1547
1548#[derive(Debug, Deserialize)]
1554struct ReadForm {
1555 #[serde(default)]
1556 read: Option<String>,
1557}
1558
1559async fn mark_read(
1561 State(state): State<AppState>,
1562 Path(id): Path<i64>,
1563 headers: HeaderMap,
1564 Form(form): Form<ReadForm>,
1565) -> Result<Response, WebError> {
1566 let did = match current_did(&state, &headers).await {
1567 Some(d) => d,
1568 None => return Ok(Redirect::to("/login").into_response()),
1569 };
1570 let pool = &state.db;
1571
1572 let read = matches!(
1573 form.read.as_deref(),
1574 Some("true") | Some("1") | Some("on") | None
1575 );
1576
1577 resolve_subscriptions(&state, &did).await;
1582 if !store::mark_read(pool, &did, id, read).await? {
1583 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1584 }
1585
1586 if !is_htmx(&headers) {
1587 return Ok(Redirect::to("/").into_response());
1588 }
1589
1590 if is_reader_request(&headers) {
1594 let starred = entry_is_starred(pool, &did, id).await?;
1595 return Ok(render(&EntryActionBarTemplate {
1596 id,
1597 read,
1598 starred,
1599 oob: true,
1600 }));
1601 }
1602
1603 let row = build_entry_row(pool, &did, id, Some(read)).await?;
1604 match row {
1605 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1606 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1607 }
1608}
1609
1610#[derive(Debug, Deserialize)]
1616struct StarForm {
1617 #[serde(default)]
1618 starred: Option<String>,
1619}
1620
1621async fn toggle_star(
1627 State(state): State<AppState>,
1628 Path(id): Path<i64>,
1629 headers: HeaderMap,
1630 Form(form): Form<StarForm>,
1631) -> Result<Response, WebError> {
1632 let did = match current_did(&state, &headers).await {
1633 Some(d) => d,
1634 None => return Ok(Redirect::to("/login").into_response()),
1635 };
1636 let pool = &state.db;
1637
1638 let starred = matches!(
1639 form.starred.as_deref(),
1640 Some("true") | Some("1") | Some("on") | None
1641 );
1642
1643 resolve_subscriptions(&state, &did).await;
1647 if !store::mark_starred(pool, &did, id, starred).await? {
1648 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1649 }
1650
1651 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
1654 let entry_url = entry.url.clone().unwrap_or_default();
1655 if !entry_url.is_empty() {
1656 if starred {
1657 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
1658 saved.title = entry.title.clone();
1659 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
1660 saved.entry_id = Some(entry.guid.clone());
1661 match state.sidecar.add_saved(&did, &saved).await {
1662 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
1663 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
1664 }
1665 } else {
1666 match state.sidecar.list_saved(&did).await {
1668 Ok(records) => {
1669 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
1670 if let Err(err) = state.sidecar.remove_saved(&did, rkey).await {
1671 warn!(%err, %did, %rkey, "PDS saved delete failed");
1672 }
1673 }
1674 }
1675 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
1676 }
1677 }
1678 }
1679 }
1680
1681 if !is_htmx(&headers) {
1682 return Ok(Redirect::to("/").into_response());
1683 }
1684
1685 if is_reader_request(&headers) {
1687 let read = entry_is_read(pool, &did, id).await?;
1688 return Ok(render(&EntryActionBarTemplate {
1689 id,
1690 read,
1691 starred,
1692 oob: true,
1693 }));
1694 }
1695
1696 let row = build_entry_row(pool, &did, id, None).await?;
1697 match row {
1698 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1699 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1700 }
1701}
1702
1703async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
1705 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
1706 .bind(feed_id)
1707 .fetch_optional(pool)
1708 .await
1709 .ok()
1710 .flatten()
1711}
1712
1713#[derive(Debug, Deserialize, Default)]
1720struct ReadAllQuery {
1721 #[serde(default)]
1722 feed: Option<String>,
1723}
1724
1725async fn mark_all_read(
1728 State(state): State<AppState>,
1729 headers: HeaderMap,
1730 Query(q): Query<ReadAllQuery>,
1731) -> Result<Response, WebError> {
1732 let did = match current_did(&state, &headers).await {
1733 Some(d) => d,
1734 None => return Ok(Redirect::to("/login").into_response()),
1735 };
1736 let pool = &state.db;
1737
1738 resolve_subscriptions(&state, &did).await;
1741
1742 if let Some(feed_url) = q.feed.as_deref() {
1743 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
1744 store::mark_feed_read(pool, &did, feed.id, true).await?;
1745 }
1746 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
1747 }
1748
1749 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
1754 store::mark_feed_read(pool, &did, feed_id, true).await?;
1755 }
1756 Ok(Redirect::to("/").into_response())
1757}
1758
1759const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
1769 FeatherReader stores your subscriptions in your public PDS, so it supports public \
1770 feeds for now — private-feed support arrives when atproto's private data \
1771 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
1772
1773#[derive(Debug, Deserialize)]
1775struct SubscribeForm {
1776 url: String,
1777 #[serde(default)]
1779 folder: Option<String>,
1780}
1781
1782async fn add_subscription(
1784 State(state): State<AppState>,
1785 headers: HeaderMap,
1786 Form(form): Form<SubscribeForm>,
1787) -> Result<Response, WebError> {
1788 let did = match current_did(&state, &headers).await {
1789 Some(d) => d,
1790 None => return Ok(Redirect::to("/login").into_response()),
1791 };
1792 let pool = &state.db;
1793 let input = form.url.trim().to_string();
1794 if input.is_empty() {
1795 return Ok(Redirect::to("/").into_response());
1796 }
1797
1798 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
1802 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
1803 return Ok(
1804 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1805 );
1806 }
1807
1808 let cap = state.config.max_subs_per_did;
1812 if cap > 0 {
1813 match store::count_subscriptions_for_did(pool, &did).await {
1814 Ok(n) if n >= cap => {
1815 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
1816 return Ok(Redirect::to(&format!(
1817 "/?flash={}",
1818 qenc(&format!(
1819 "Subscription limit reached ({cap}). Remove a feed before adding another."
1820 ))
1821 ))
1822 .into_response());
1823 }
1824 Ok(_) => {}
1825 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
1826 }
1827 }
1828
1829 let feed_url = match resolve_feed_url(&state.config, &input).await {
1830 Ok(u) => u,
1831 Err(err) => {
1832 warn!(%err, url = %input, "could not resolve a feed from the given URL");
1833 return Ok(Redirect::to(&format!(
1834 "/?flash={}",
1835 qenc("Couldn't find a feed at that URL")
1836 ))
1837 .into_response());
1838 }
1839 };
1840
1841 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1845 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
1846 return Ok(
1847 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1848 );
1849 }
1850
1851 let feeds_cap = state.config.max_feeds_global;
1855 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
1856 match store::count_feeds(pool).await {
1857 Ok(n) if n >= feeds_cap => {
1858 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
1859 return Ok(Redirect::to(&format!(
1860 "/?flash={}",
1861 qenc(
1862 "This instance is at its feed capacity right now. Please try again later."
1863 )
1864 ))
1865 .into_response());
1866 }
1867 Ok(_) => {}
1868 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1869 }
1870 }
1871
1872 store::upsert_feed(
1873 pool,
1874 &store::NewFeed {
1875 url: feed_url.clone(),
1876 ..Default::default()
1877 },
1878 )
1879 .await?;
1880
1881 if let Ok(client) = feed::build_client() {
1882 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
1883 match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
1884 {
1885 Ok(outcome) => info!(feed = %feed_url, ?outcome, "polled new subscription"),
1886 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
1887 }
1888 }
1889 }
1890
1891 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
1892 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
1893 sub.title = feed_row.title.clone();
1894 sub.site_url = feed_row.site_url.clone();
1895 }
1896 sub.folder = form
1897 .folder
1898 .map(|f| f.trim().to_string())
1899 .filter(|f| !f.is_empty());
1900
1901 match state.sidecar.add_subscription(&did, &sub).await {
1902 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
1903 Err(err) => {
1904 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
1905 }
1906 }
1907
1908 Ok(Redirect::to("/").into_response())
1909}
1910
1911async fn delete_subscription(
1913 State(state): State<AppState>,
1914 headers: HeaderMap,
1915 Path(rkey): Path<String>,
1916) -> Result<Response, WebError> {
1917 let did = match current_did(&state, &headers).await {
1918 Some(d) => d,
1919 None => return Ok(Redirect::to("/login").into_response()),
1920 };
1921 match state.sidecar.remove_subscription(&did, &rkey).await {
1922 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
1923 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
1924 }
1925 Ok(Redirect::to("/").into_response())
1926}
1927
1928#[derive(Debug, Deserialize)]
1930struct RenameSubForm {
1931 url: String,
1932 #[serde(default)]
1933 title: Option<String>,
1934 #[serde(default)]
1935 site_url: Option<String>,
1936 #[serde(default)]
1937 folder: Option<String>,
1938}
1939
1940async fn rename_subscription(
1943 State(state): State<AppState>,
1944 headers: HeaderMap,
1945 Path(rkey): Path<String>,
1946 Form(form): Form<RenameSubForm>,
1947) -> Result<Response, WebError> {
1948 let did = match current_did(&state, &headers).await {
1949 Some(d) => d,
1950 None => return Ok(Redirect::to("/login").into_response()),
1951 };
1952 let feed_url = form.url.trim().to_string();
1953
1954 if feed_url.is_empty() {
1958 return Ok(Redirect::to("/").into_response());
1959 }
1960
1961 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1967 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
1968 return Ok(
1969 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1970 );
1971 }
1972
1973 let feeds_cap = state.config.max_feeds_global;
1978 if feeds_cap > 0
1979 && store::get_feed_by_url(&state.db, &feed_url)
1980 .await?
1981 .is_none()
1982 {
1983 match store::count_feeds(&state.db).await {
1984 Ok(n) if n >= feeds_cap => {
1985 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
1986 return Ok(Redirect::to(&format!(
1987 "/?flash={}",
1988 qenc(
1989 "This instance is at its feed capacity right now. Please try again later."
1990 )
1991 ))
1992 .into_response());
1993 }
1994 Ok(_) => {}
1995 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1996 }
1997 }
1998
1999 let mut sub = Subscription::new(feed_url, now_rfc3339());
2000 sub.title = form
2001 .title
2002 .map(|t| t.trim().to_string())
2003 .filter(|t| !t.is_empty());
2004 sub.site_url = form
2005 .site_url
2006 .map(|t| t.trim().to_string())
2007 .filter(|t| !t.is_empty());
2008 sub.folder = form
2009 .folder
2010 .map(|f| f.trim().to_string())
2011 .filter(|f| !f.is_empty());
2012
2013 let _ = store::upsert_feed(
2015 &state.db,
2016 &store::NewFeed {
2017 url: sub.url.clone(),
2018 title: sub.title.clone(),
2019 site_url: sub.site_url.clone(),
2020 ..Default::default()
2021 },
2022 )
2023 .await;
2024
2025 match state.sidecar.update_subscription(&did, &rkey, &sub).await {
2026 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription"),
2027 Err(err) => warn!(%err, %did, %rkey, "PDS subscription update failed"),
2028 }
2029 Ok(Redirect::to("/").into_response())
2030}
2031
2032#[derive(Debug, Deserialize)]
2038struct FolderForm {
2039 name: String,
2040}
2041
2042async fn create_folder(
2044 State(state): State<AppState>,
2045 headers: HeaderMap,
2046 Form(form): Form<FolderForm>,
2047) -> Result<Response, WebError> {
2048 let did = match current_did(&state, &headers).await {
2049 Some(d) => d,
2050 None => return Ok(Redirect::to("/login").into_response()),
2051 };
2052 let name = form.name.trim();
2053 if name.is_empty() {
2054 return Ok(Redirect::to("/").into_response());
2055 }
2056 let folder = Folder::new(name.to_string(), now_rfc3339());
2057 match state.sidecar.add_folder(&did, &folder).await {
2058 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
2059 Err(err) => warn!(%err, %did, "PDS folder create failed"),
2060 }
2061 Ok(Redirect::to("/").into_response())
2062}
2063
2064async fn rename_folder(
2066 State(state): State<AppState>,
2067 headers: HeaderMap,
2068 Path(rkey): Path<String>,
2069 Form(form): Form<FolderForm>,
2070) -> Result<Response, WebError> {
2071 let did = match current_did(&state, &headers).await {
2072 Some(d) => d,
2073 None => return Ok(Redirect::to("/login").into_response()),
2074 };
2075 let name = form.name.trim();
2076 if name.is_empty() {
2077 return Ok(Redirect::to("/").into_response());
2078 }
2079 let folder = Folder::new(name.to_string(), now_rfc3339());
2080 match state.sidecar.rename_folder(&did, &rkey, &folder).await {
2081 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
2082 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
2083 }
2084 Ok(Redirect::to("/").into_response())
2085}
2086
2087async fn delete_folder(
2090 State(state): State<AppState>,
2091 headers: HeaderMap,
2092 Path(rkey): Path<String>,
2093) -> Result<Response, WebError> {
2094 let did = match current_did(&state, &headers).await {
2095 Some(d) => d,
2096 None => return Ok(Redirect::to("/login").into_response()),
2097 };
2098 match state.sidecar.remove_folder(&did, &rkey).await {
2099 Ok(()) => info!(%did, %rkey, "deleted folder record"),
2100 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
2101 }
2102 Ok(Redirect::to("/").into_response())
2103}
2104
2105async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
2109 let parsed =
2110 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
2111
2112 let client = feed::build_client()?;
2113 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
2117 let final_url = resp.url().clone();
2118 let content_type = resp
2119 .headers()
2120 .get(axum::http::header::CONTENT_TYPE)
2121 .and_then(|v| v.to_str().ok())
2122 .unwrap_or("")
2123 .to_ascii_lowercase();
2124 let raw = crate::net::read_capped(resp).await?;
2127 let body = String::from_utf8_lossy(&raw).into_owned();
2128
2129 let looks_like_feed = content_type.contains("xml")
2130 || content_type.contains("rss")
2131 || content_type.contains("atom")
2132 || content_type.contains("application/feed+json")
2133 || {
2134 let head = body.trim_start();
2135 head.starts_with("<?xml")
2136 || head.starts_with("<rss")
2137 || head.starts_with("<feed")
2138 || head.contains("<rss")
2139 || head.contains("<feed")
2140 };
2141 if looks_like_feed {
2142 return Ok(final_url.to_string());
2143 }
2144
2145 match feed::discover_feed(&body, Some(&final_url)) {
2146 Some(u) => Ok(u.to_string()),
2147 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
2148 }
2149}
2150
2151#[derive(Debug, Deserialize, Default)]
2157struct LoginQuery {
2158 #[serde(default)]
2159 handle: Option<String>,
2160 #[serde(default)]
2161 error: Option<String>,
2162 #[serde(default)]
2163 flash: Option<String>,
2164}
2165
2166async fn login_form(
2174 State(state): State<AppState>,
2175 headers: HeaderMap,
2176 Query(q): Query<LoginQuery>,
2177) -> Response {
2178 if let Some(handle) = q
2179 .handle
2180 .map(|h| h.trim().to_string())
2181 .filter(|h| !h.is_empty())
2182 {
2183 if !may_start_oauth(&state, &headers, &handle).await {
2184 return Redirect::to("/beta/redeem").into_response();
2185 }
2186 return start_oauth(&state, &handle);
2187 }
2188 render(&LoginTemplate {
2189 repo_url: REPO_URL,
2190 error: q.error.unwrap_or_default(),
2191 flash: q.flash.unwrap_or_default(),
2192 })
2193}
2194
2195async fn login_submit(
2198 State(state): State<AppState>,
2199 headers: HeaderMap,
2200 Form(form): Form<LoginForm>,
2201) -> Response {
2202 let handle = form.handle.trim();
2203 if handle.is_empty() {
2204 return login_error("Enter your atproto handle.");
2205 }
2206 if !may_start_oauth(&state, &headers, handle).await {
2207 return Redirect::to("/beta/redeem").into_response();
2208 }
2209 start_oauth(&state, handle)
2210}
2211
2212async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
2230 may_start_oauth_with(state, headers, handle, |h| async move {
2234 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
2235 .await
2236 .ok()
2237 })
2238 .await
2239}
2240
2241async fn may_start_oauth_with<F, Fut>(
2247 state: &AppState,
2248 headers: &HeaderMap,
2249 handle: &str,
2250 resolve: F,
2251) -> bool
2252where
2253 F: FnOnce(String) -> Fut,
2254 Fut: std::future::Future<Output = Option<String>>,
2255{
2256 if let Some(did) = current_did(state, headers).await {
2258 if store::has_beta_access(&state.db, &did)
2259 .await
2260 .unwrap_or(false)
2261 {
2262 return true;
2263 }
2264 }
2265 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
2267 return true;
2268 }
2269 match resolve(handle.to_string()).await {
2273 Some(did) => store::has_beta_access(&state.db, &did)
2274 .await
2275 .unwrap_or(false),
2276 None => {
2277 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
2278 false
2279 }
2280 }
2281}
2282
2283fn start_oauth(state: &AppState, handle: &str) -> Response {
2285 let url = state.sidecar.login_url(handle, None);
2286 info!(%handle, "redirecting to OAuth sidecar login");
2287 Redirect::to(&url).into_response()
2288}
2289
2290#[derive(Debug, Deserialize)]
2292struct LoginForm {
2293 handle: String,
2294}
2295
2296#[derive(Debug, Deserialize, Default)]
2298struct CallbackQuery {
2299 #[serde(default)]
2300 session_id: Option<String>,
2301 #[serde(default)]
2302 error: Option<String>,
2303 #[serde(default)]
2304 error_description: Option<String>,
2305}
2306
2307async fn oauth_callback(
2314 State(state): State<AppState>,
2315 headers: HeaderMap,
2316 Query(q): Query<CallbackQuery>,
2317) -> Response {
2318 if let Some(err) = q.error {
2319 let desc = q.error_description.unwrap_or_default();
2320 warn!(error = %err, desc = %desc, "OAuth callback returned an error");
2321 return login_error(&format!("Login failed: {err}"));
2322 }
2323
2324 let session_id = match q.session_id {
2325 Some(s) if !s.is_empty() => s,
2326 _ => return login_error("Login failed: the callback carried no session."),
2327 };
2328
2329 let session = match state.sidecar.resolve_session(&session_id).await {
2330 Ok(Some(s)) => s,
2331 Ok(None) => {
2332 warn!("OAuth callback session_id did not resolve (expired/unknown)");
2333 return login_error("Login session expired — please try again.");
2334 }
2335 Err(err) => {
2336 warn!(%err, "failed to resolve OAuth session via the sidecar");
2337 return login_error("Login failed talking to the auth service.");
2338 }
2339 };
2340
2341 let mut clear_invite = false;
2344 if !store::has_beta_access(&state.db, &session.did)
2345 .await
2346 .unwrap_or(false)
2347 {
2348 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
2350 Some(c) => c,
2351 None => {
2352 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
2353 return Redirect::to("/beta/redeem").into_response();
2354 }
2355 };
2356 match store::redeem_code(
2357 &state.db,
2358 &code,
2359 &session.did,
2360 session.handle.as_deref(),
2361 state.config.beta_cap,
2362 )
2363 .await
2364 {
2365 Ok(Ok(())) => {
2366 clear_invite = true;
2367 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
2368 }
2369 Ok(Err(policy)) => {
2370 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
2371 let mut resp = redeem_bounce(&policy).into_response();
2372 clear_invite_cookie(&mut resp);
2374 return resp;
2375 }
2376 Err(err) => {
2377 warn!(%err, did = %session.did, "invite redeem infra error at callback");
2378 return login_error("Login failed while confirming your invite.");
2379 }
2380 }
2381 }
2382
2383 let sid = state.sessions.create(Session {
2386 did: session.did.clone(),
2387 handle: session.handle.clone(),
2388 });
2389 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
2390 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
2391
2392 let mut resp = Redirect::to("/").into_response();
2393 set_cookie(&mut resp, &cookie);
2394 if clear_invite {
2395 clear_invite_cookie(&mut resp);
2396 }
2397 resp
2398}
2399
2400async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
2410 if let Some(user) = current_session(&state, &headers).await {
2411 if let Some(sid) = user.sid {
2414 state.sessions.remove(&sid);
2415 match state.sidecar.revoke_session(&user.did).await {
2416 Ok(res) => {
2417 info!(did = %user.did, revoked = res.revoked, "logout: sidecar session revoked");
2418 }
2419 Err(err) => {
2420 warn!(did = %user.did, %err, "logout: sidecar revoke failed; clearing cookie anyway");
2421 }
2422 }
2423 }
2424 }
2425 let mut resp = Redirect::to("/login").into_response();
2426 set_cookie(
2427 &mut resp,
2428 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2429 );
2430 resp
2431}
2432
2433#[derive(Debug, Deserialize)]
2436struct DeleteAccountForm {
2437 #[serde(default)]
2438 confirm: String,
2439}
2440
2441const DELETE_CONFIRM_PHRASE: &str = "DELETE";
2443
2444async fn account_delete(
2459 State(state): State<AppState>,
2460 headers: HeaderMap,
2461 Form(form): Form<DeleteAccountForm>,
2462) -> Result<Response, WebError> {
2463 let user = match current_session(&state, &headers).await {
2464 Some(u) => u,
2465 None => return Ok(Redirect::to("/login").into_response()),
2466 };
2467 let did = user.did.clone();
2468
2469 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
2471 return Ok(Redirect::to(&format!(
2472 "/manage?flash={}",
2473 qenc("Type DELETE to confirm — nothing was deleted.")
2474 ))
2475 .into_response());
2476 }
2477
2478 let counts = store::purge_did_data(&state.db, &did).await?;
2480 info!(
2481 %did,
2482 total = counts.total(),
2483 entry_state = counts.entry_state,
2484 read_cursor = counts.read_cursor,
2485 sub_ref = counts.sub_ref,
2486 beta_access = counts.beta_access,
2487 invite_codes = counts.invite_codes,
2488 "account/delete: local rows purged"
2489 );
2490
2491 match state.sidecar.revoke_session(&did).await {
2494 Ok(res) => info!(%did, revoked = res.revoked, "account/delete: sidecar session revoked"),
2495 Err(err) => {
2496 warn!(%did, %err, "account/delete: sidecar revoke failed; local data already purged")
2497 }
2498 }
2499
2500 if let Some(sid) = user.sid {
2502 state.sessions.remove(&sid);
2503 }
2504 let mut resp = Redirect::to(&format!(
2505 "/login?flash={}",
2506 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
2507 ))
2508 .into_response();
2509 set_cookie(
2510 &mut resp,
2511 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2512 );
2513 Ok(resp)
2514}
2515
2516fn login_error(msg: &str) -> Response {
2518 render(&LoginTemplate {
2519 repo_url: REPO_URL,
2520 error: msg.to_string(),
2521 flash: String::new(),
2522 })
2523}
2524
2525#[derive(Debug, Deserialize)]
2531struct RedeemForm {
2532 code: String,
2533}
2534
2535async fn beta_redeem_form(State(state): State<AppState>) -> Response {
2538 let full = store::count_beta_access(&state.db)
2539 .await
2540 .map(|n| n >= state.config.beta_cap)
2541 .unwrap_or(false);
2542 render(&BetaRedeemTemplate {
2543 repo_url: REPO_URL,
2544 error: String::new(),
2545 capacity_full: full,
2546 })
2547}
2548
2549async fn beta_redeem_submit(
2559 State(state): State<AppState>,
2560 Form(form): Form<RedeemForm>,
2561) -> Response {
2562 let code = form.code.trim().to_uppercase();
2563 if code.is_empty() {
2564 return render(&BetaRedeemTemplate {
2565 repo_url: REPO_URL,
2566 error: "Enter your invite code.".to_string(),
2567 capacity_full: false,
2568 });
2569 }
2570
2571 match preflight_code(&state, &code).await {
2572 Ok(()) => {
2573 let cookie = sign_invite(&code, &state.config.cookie_secret);
2574 let mut resp = Redirect::to("/login").into_response();
2575 set_cookie(&mut resp, &cookie);
2576 info!("invite code preflight OK; reserving intent + redirecting to /login");
2577 resp
2578 }
2579 Err(policy) => {
2580 warn!(?policy, "invite code preflight rejected");
2581 redeem_bounce(&policy)
2582 }
2583 }
2584}
2585
2586async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
2592 let count = match store::count_beta_access(&state.db).await {
2600 Ok(n) => n,
2601 Err(err) => {
2602 warn!(%err, "preflight_code: count_beta_access failed; failing closed");
2603 return Err(store::RedeemError::CapacityFull);
2604 }
2605 };
2606 if count >= state.config.beta_cap {
2607 return Err(store::RedeemError::CapacityFull);
2608 }
2609 let row = sqlx::query_as::<_, (String, i64)>(
2611 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
2612 )
2613 .bind(code)
2614 .fetch_optional(&state.db)
2615 .await
2616 .ok()
2617 .flatten();
2618 let (status, expires_at) = match row {
2619 Some(r) => r,
2620 None => return Err(store::RedeemError::NotFound),
2621 };
2622 let now = chrono::Utc::now().timestamp();
2623 match status.as_str() {
2624 "active" if expires_at >= now => Ok(()),
2625 "active" => Err(store::RedeemError::Expired),
2626 "expired" => Err(store::RedeemError::Expired),
2627 _ => Err(store::RedeemError::AlreadyRedeemed),
2629 }
2630}
2631
2632fn redeem_bounce(policy: &store::RedeemError) -> Response {
2635 use store::RedeemError::*;
2636 let (msg, capacity_full) = match policy {
2637 NotFound => ("That invite code isn't valid.", false),
2638 Expired => ("That invite code has expired.", false),
2639 AlreadyRedeemed => ("That invite code has already been used.", false),
2640 CapacityFull => ("", true),
2641 };
2642 render(&BetaRedeemTemplate {
2643 repo_url: REPO_URL,
2644 error: msg.to_string(),
2645 capacity_full,
2646 })
2647}
2648
2649#[derive(Debug, Deserialize, Default)]
2651struct MintQuery {
2652 #[serde(default)]
2653 n: Option<u32>,
2654}
2655
2656async fn admin_mint_invites(
2662 State(state): State<AppState>,
2663 headers: HeaderMap,
2664 Query(q): Query<MintQuery>,
2665) -> Response {
2666 let did = match current_did(&state, &headers).await {
2669 Some(d) => d,
2670 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
2671 };
2672 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
2673 warn!(%did, "admin mint denied: not an admin-seed DID");
2674 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
2675 }
2676
2677 let n = q.n.unwrap_or(1).clamp(1, 100);
2678 let mut codes = Vec::with_capacity(n as usize);
2679 for _ in 0..n {
2680 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
2681 Ok(code) => codes.push(code),
2682 Err(err) => {
2683 warn!(%err, %did, "admin mint_code failed");
2684 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2685 }
2686 }
2687 }
2688 info!(%did, count = codes.len(), "admin minted invite codes");
2689 let mut body = codes.join("\n");
2690 body.push('\n');
2691 (StatusCode::OK, body).into_response()
2692}
2693
2694#[derive(Debug, Deserialize)]
2700struct ClaimQuery {
2701 t: Option<String>,
2703}
2704
2705async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
2724 let token = match q.t {
2725 Some(t) if !t.is_empty() => t,
2726 _ => {
2727 warn!("claim link with no token");
2728 return redeem_bounce(&store::RedeemError::NotFound);
2729 }
2730 };
2731
2732 let code = match claim_token_code(&token, &state.config.cookie_secret) {
2735 Some(c) => c,
2736 None => {
2737 warn!("claim token invalid (bad signature / malformed)");
2738 return redeem_bounce(&store::RedeemError::NotFound);
2739 }
2740 };
2741
2742 match preflight_code(&state, &code).await {
2746 Ok(()) => {
2747 let cookie = sign_invite(&code, &state.config.cookie_secret);
2748 let mut resp = Redirect::to("/login").into_response();
2749 set_cookie(&mut resp, &cookie);
2750 info!("claim token preflight OK; reserving intent + redirecting to /login");
2751 resp
2752 }
2753 Err(policy) => {
2754 warn!(?policy, "claim token preflight rejected");
2755 redeem_bounce(&policy)
2756 }
2757 }
2758}
2759
2760#[derive(Debug, Default, Deserialize)]
2767struct BotClaimRequest {
2768 #[serde(default)]
2771 did: Option<String>,
2772 #[serde(default)]
2774 #[allow(dead_code)]
2775 handle: Option<String>,
2776}
2777
2778#[derive(Debug, serde::Serialize)]
2780struct BotClaimResponse {
2781 status: &'static str,
2787 code: String,
2791 token: String,
2794 url: String,
2797}
2798
2799async fn bot_mint_claim(
2827 State(state): State<AppState>,
2828 headers: HeaderMap,
2829 body: axum::body::Bytes,
2830) -> Response {
2831 let bot_secret = match state.config.bot_secret.as_deref() {
2833 Some(s) => s,
2834 None => {
2835 warn!(
2836 "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
2837 );
2838 return (
2839 StatusCode::SERVICE_UNAVAILABLE,
2840 "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
2841 )
2842 .into_response();
2843 }
2844 };
2845
2846 let presented = headers
2848 .get("x-bot-secret")
2849 .and_then(|v| v.to_str().ok())
2850 .unwrap_or("");
2851 if !bot_secret_matches(presented, bot_secret) {
2852 warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
2853 return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
2854 }
2855
2856 let req: BotClaimRequest = if body.is_empty() {
2859 BotClaimRequest::default()
2860 } else {
2861 match serde_json::from_slice(&body) {
2862 Ok(r) => r,
2863 Err(err) => {
2864 warn!(%err, "POST /bot/claims: bad JSON body");
2865 return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
2866 }
2867 }
2868 };
2869 let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
2870
2871 if let Some(did) = follower_did {
2873 match store::has_beta_access(&state.db, did).await {
2875 Ok(true) => {
2876 info!("bot mint: DID already holds beta access; already_seated");
2877 return bot_claim_json(BotClaimResponse {
2878 status: "already_seated",
2879 code: String::new(),
2880 token: String::new(),
2881 url: String::new(),
2882 });
2883 }
2884 Ok(false) => {}
2885 Err(err) => {
2886 warn!(%err, "bot mint: has_beta_access failed");
2888 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
2889 }
2890 }
2891 match store::find_active_code_for_did(&state.db, did).await {
2894 Ok(Some(code)) => {
2895 info!("bot mint: existing outstanding claim for DID; returning same code");
2896 let token = sign_claim_token(&code, &state.config.cookie_secret);
2897 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
2898 return bot_claim_json(BotClaimResponse {
2899 status: "existing",
2900 code,
2901 token,
2902 url,
2903 });
2904 }
2905 Ok(None) => {}
2906 Err(err) => {
2907 warn!(%err, "bot mint: find_active_code_for_did failed");
2908 return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
2909 }
2910 }
2911 }
2912
2913 let granted = match store::count_beta_access(&state.db).await {
2916 Ok(n) => n,
2917 Err(err) => {
2918 warn!(%err, "bot mint: count_beta_access failed; failing closed");
2919 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
2920 }
2921 };
2922 let outstanding = match store::count_active_codes(&state.db).await {
2923 Ok(n) => n,
2924 Err(err) => {
2925 warn!(%err, "bot mint: count_active_codes failed; failing closed");
2926 return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
2927 }
2928 };
2929 if granted + outstanding >= state.config.beta_cap {
2930 info!(
2931 granted,
2932 outstanding,
2933 cap = state.config.beta_cap,
2934 "bot mint refused: at capacity"
2935 );
2936 return (
2937 StatusCode::CONFLICT,
2938 [(header::CONTENT_TYPE, "application/json")],
2939 "{\"error\":\"full\"}\n",
2940 )
2941 .into_response();
2942 }
2943
2944 let bot_did = state
2947 .config
2948 .admin_seed_dids()
2949 .first()
2950 .cloned()
2951 .unwrap_or_else(|| "did:bot:featherreader".to_string());
2952 let minted = match follower_did {
2953 Some(did) => {
2954 store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
2955 }
2956 None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
2957 };
2958 let code = match minted {
2959 Ok(c) => c,
2960 Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
2967 match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
2968 Ok(Some(code)) => {
2969 info!("bot mint: lost the mint race; returning the concurrently-minted code");
2970 let token = sign_claim_token(&code, &state.config.cookie_secret);
2971 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
2972 return bot_claim_json(BotClaimResponse {
2973 status: "existing",
2974 code,
2975 token,
2976 url,
2977 });
2978 }
2979 Ok(None) => {
2983 warn!("bot mint: conflict but no active code found on recovery");
2984 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2985 }
2986 Err(err) => {
2987 warn!(%err, "bot mint: recovery lookup after conflict failed");
2988 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2989 }
2990 }
2991 }
2992 Err(err) => {
2993 warn!(%err, "bot mint_code failed");
2994 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2995 }
2996 };
2997 let token = sign_claim_token(&code, &state.config.cookie_secret);
2998 let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
2999 info!("bot minted a claim code + token");
3000
3001 bot_claim_json(BotClaimResponse {
3002 status: "minted",
3003 code,
3004 token,
3005 url,
3006 })
3007}
3008
3009fn bot_claim_json(resp: BotClaimResponse) -> Response {
3012 match serde_json::to_string(&resp) {
3013 Ok(body) => (
3014 StatusCode::OK,
3015 [(header::CONTENT_TYPE, "application/json")],
3016 body,
3017 )
3018 .into_response(),
3019 Err(err) => {
3020 warn!(%err, "serializing bot claim response failed");
3021 (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
3022 }
3023 }
3024}
3025
3026fn bot_secret_matches(presented: &str, expected: &str) -> bool {
3031 cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
3032}
3033
3034fn sign_invite(code: &str, secret: &str) -> String {
3043 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
3044}
3045
3046fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
3051 cookie::verify_value(headers, INVITE_COOKIE, secret)
3052}
3053
3054const CLAIM_TOKEN_LABEL: &str = "claim-token";
3058
3059fn sign_claim_token(code: &str, secret: &str) -> String {
3071 cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
3072}
3073
3074fn claim_token_code(token: &str, secret: &str) -> Option<String> {
3079 cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
3080}
3081
3082fn clear_invite_cookie(resp: &mut Response) {
3085 set_cookie(
3086 resp,
3087 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3088 );
3089}
3090
3091async fn import_opml(
3104 State(state): State<AppState>,
3105 headers: HeaderMap,
3106 mut multipart: Multipart,
3107) -> Result<Response, WebError> {
3108 let did = match current_did(&state, &headers).await {
3109 Some(d) => d,
3110 None => return Ok(Redirect::to("/login").into_response()),
3111 };
3112 let pool = &state.db;
3113
3114 let mut opml_text = String::new();
3120 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
3121 let name = field.name().unwrap_or("").to_string();
3122 if name == "opml" || name == "file" {
3123 let bytes = field.bytes().await.map_err(multipart_response)?;
3124 if !bytes.is_empty() {
3125 opml_text = String::from_utf8_lossy(&bytes).into_owned();
3126 if name == "file" {
3127 break;
3128 }
3129 }
3130 }
3131 }
3132
3133 let feeds = opml::parse_opml(&opml_text).unwrap_or_default();
3134 if feeds.is_empty() {
3135 info!(%did, "OPML import found no feeds");
3136 return Ok(
3137 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
3138 .into_response(),
3139 );
3140 }
3141
3142 let now = now_rfc3339();
3145 let mut folder_uris: std::collections::HashMap<String, String> =
3146 std::collections::HashMap::new();
3147 if let Ok(existing) = state.sidecar.list_folders_sorted(&did).await {
3149 for (rkey, folder) in existing {
3150 folder_uris
3151 .entry(folder.name.clone())
3152 .or_insert_with(|| folder_uri(&did, &rkey));
3153 }
3154 }
3155 let mut wanted_folders: Vec<String> = feeds
3156 .iter()
3157 .filter_map(|f| f.folder.clone())
3158 .filter(|n| !n.is_empty())
3159 .collect();
3160 wanted_folders.sort();
3161 wanted_folders.dedup();
3162 for name in wanted_folders {
3163 if folder_uris.contains_key(&name) {
3164 continue;
3165 }
3166 let folder = Folder::new(name.clone(), now.clone());
3167 match state.sidecar.add_folder(&did, &folder).await {
3168 Ok(rkey) => {
3169 folder_uris.insert(name, folder_uri(&did, &rkey));
3170 }
3171 Err(err) => warn!(%err, %did, "OPML folder create failed"),
3172 }
3173 }
3174
3175 let sub_cap = state.config.max_subs_per_did;
3184 let mut headroom: Option<i64> = if sub_cap > 0 {
3185 let existing = store::count_subscriptions_for_did(pool, &did)
3186 .await
3187 .unwrap_or(0);
3188 Some((sub_cap - existing).max(0))
3189 } else {
3190 None
3191 };
3192 let mut trimmed_over_cap: usize = 0;
3193
3194 let feeds_cap = state.config.max_feeds_global;
3201 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
3202 let existing = store::count_feeds(pool).await.unwrap_or(0);
3203 Some((feeds_cap - existing).max(0))
3204 } else {
3205 None
3206 };
3207 let mut trimmed_over_global: usize = 0;
3208
3209 let mut subs = Vec::with_capacity(feeds.len());
3210 let mut skipped_private: Vec<String> = Vec::new();
3211 for f in &feeds {
3212 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
3213 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
3214 let label = f
3216 .title
3217 .clone()
3218 .filter(|t| !t.trim().is_empty())
3219 .unwrap_or_else(|| private_feed_label(&f.feed_url));
3220 skipped_private.push(label);
3221 continue;
3222 }
3223
3224 if let Some(h) = headroom.as_mut() {
3227 if *h <= 0 {
3228 trimmed_over_cap += 1;
3229 continue;
3230 }
3231 }
3232
3233 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
3238 Ok(existing) => existing.is_none(),
3239 Err(err) => {
3242 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
3243 false
3244 }
3245 };
3246 if is_new {
3247 if let Some(g) = global_headroom.as_mut() {
3248 if *g <= 0 {
3249 trimmed_over_global += 1;
3250 continue;
3251 }
3252 *g -= 1;
3253 }
3254 }
3255
3256 if let Some(h) = headroom.as_mut() {
3259 *h -= 1;
3260 }
3261
3262 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
3263 sub.title = f.title.clone();
3264 sub.site_url = f.site_url.clone();
3265 sub.folder = f
3266 .folder
3267 .as_ref()
3268 .and_then(|name| folder_uris.get(name).cloned());
3269 subs.push(sub);
3270 let _ = store::upsert_feed(
3271 pool,
3272 &store::NewFeed {
3273 url: f.feed_url.clone(),
3274 title: f.title.clone(),
3275 site_url: f.site_url.clone(),
3276 ..Default::default()
3277 },
3278 )
3279 .await;
3280 }
3281
3282 match state.sidecar.add_subscriptions_bulk(&did, &subs).await {
3283 Ok(rkeys) => {
3284 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)")
3285 }
3286 Err(err) => warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)"),
3287 }
3288
3289 let mut flash = format!("Imported {} feeds", subs.len());
3291 if trimmed_over_cap > 0 {
3292 flash.push_str(&format!(
3293 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
3294 ));
3295 }
3296 if trimmed_over_global > 0 {
3297 flash.push_str(&format!(
3298 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
3299 ));
3300 }
3301 if !skipped_private.is_empty() {
3302 flash.push_str(&format!(
3303 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
3304 skipped_private.len(),
3305 skipped_private.join(", ")
3306 ));
3307 }
3308 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
3309}
3310
3311fn private_feed_label(url: &str) -> String {
3314 url::Url::parse(url)
3315 .ok()
3316 .and_then(|u| u.host_str().map(str::to_string))
3317 .unwrap_or_else(|| "a private feed".to_string())
3318}
3319
3320async fn export_opml(
3322 State(state): State<AppState>,
3323 headers: HeaderMap,
3324) -> Result<Response, WebError> {
3325 let did = match current_did(&state, &headers).await {
3326 Some(d) => d,
3327 None => return Ok(Redirect::to("/login").into_response()),
3328 };
3329
3330 let subs = state
3331 .sidecar
3332 .list_subscriptions_sorted(&did)
3333 .await
3334 .unwrap_or_default();
3335 let folders = state
3336 .sidecar
3337 .list_folders_sorted(&did)
3338 .await
3339 .unwrap_or_default();
3340 let folder_pairs: Vec<(String, Folder)> = folders
3343 .into_iter()
3344 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
3345 .collect();
3346
3347 let body = opml::to_opml(&subs, &folder_pairs);
3348 let mut resp = (StatusCode::OK, body).into_response();
3349 resp.headers_mut().insert(
3350 header::CONTENT_TYPE,
3351 "text/x-opml; charset=utf-8".parse().unwrap(),
3352 );
3353 resp.headers_mut().insert(
3354 header::CONTENT_DISPOSITION,
3355 "attachment; filename=\"featherreader-subscriptions.opml\""
3356 .parse()
3357 .unwrap(),
3358 );
3359 Ok(resp)
3360}
3361
3362fn set_cookie(resp: &mut Response, cookie: &str) {
3368 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
3369 resp.headers_mut()
3370 .append(axum::http::header::SET_COOKIE, value);
3371 }
3372}
3373
3374fn is_htmx(headers: &HeaderMap) -> bool {
3376 headers
3377 .get("HX-Request")
3378 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
3379}
3380
3381fn is_reader_request(headers: &HeaderMap) -> bool {
3387 headers
3388 .get("X-FR-Reader")
3389 .is_some_and(|v| v.as_bytes() == b"1")
3390}
3391
3392mod cookie {
3397 use super::{HeaderMap, SESSION_COOKIE};
3398
3399 pub fn sign_session(sid: &str, secret: &str) -> String {
3401 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
3402 }
3403
3404 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
3406 verify_value(headers, SESSION_COOKIE, secret)
3407 }
3408
3409 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
3415 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
3416 msg.extend_from_slice(name.as_bytes());
3417 msg.push(0);
3418 msg.extend_from_slice(value.as_bytes());
3419 msg
3420 }
3421
3422 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
3428 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
3429 let b64 = b64url_encode(value.as_bytes());
3430 format!(
3431 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
3432 )
3433 }
3434
3435 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
3438 let raw = cookie_value(headers, name)?;
3439 let (b64, sig) = raw.split_once('.')?;
3440 let bytes = b64url_decode(b64)?;
3441 let value = String::from_utf8(bytes).ok()?;
3442 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
3443 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
3444 Some(value)
3445 } else {
3446 None
3447 }
3448 }
3449
3450 pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
3456 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
3457 let b64 = b64url_encode(value.as_bytes());
3458 format!("{b64}.{sig}")
3459 }
3460
3461 pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
3464 let (b64, sig) = token.split_once('.')?;
3465 let bytes = b64url_decode(b64)?;
3466 let value = String::from_utf8(bytes).ok()?;
3467 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
3468 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
3469 Some(value)
3470 } else {
3471 None
3472 }
3473 }
3474
3475 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
3477 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
3478 for part in header.split(';') {
3479 let part = part.trim();
3480 if let Some((k, v)) = part.split_once('=') {
3481 if k == name {
3482 return Some(v.to_string());
3483 }
3484 }
3485 }
3486 None
3487 }
3488
3489 pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
3493 if a.len() != b.len() {
3494 return false;
3495 }
3496 let mut diff = 0u8;
3497 for (x, y) in a.iter().zip(b.iter()) {
3498 diff |= x ^ y;
3499 }
3500 diff == 0
3501 }
3502
3503 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3506
3507 fn b64url_encode(input: &[u8]) -> String {
3508 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
3509 for chunk in input.chunks(3) {
3510 let b = [
3511 chunk[0],
3512 *chunk.get(1).unwrap_or(&0),
3513 *chunk.get(2).unwrap_or(&0),
3514 ];
3515 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
3516 out.push(B64[((n >> 18) & 63) as usize] as char);
3517 out.push(B64[((n >> 12) & 63) as usize] as char);
3518 if chunk.len() > 1 {
3519 out.push(B64[((n >> 6) & 63) as usize] as char);
3520 }
3521 if chunk.len() > 2 {
3522 out.push(B64[(n & 63) as usize] as char);
3523 }
3524 }
3525 out
3526 }
3527
3528 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
3529 fn val(c: u8) -> Option<u32> {
3530 match c {
3531 b'A'..=b'Z' => Some((c - b'A') as u32),
3532 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
3533 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
3534 b'-' => Some(62),
3535 b'_' => Some(63),
3536 _ => None,
3537 }
3538 }
3539 let bytes = input.as_bytes();
3540 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
3541 for chunk in bytes.chunks(4) {
3542 let mut n = 0u32;
3543 let mut valid = 0;
3544 for (i, &c) in chunk.iter().enumerate() {
3545 n |= val(c)? << (18 - 6 * i);
3546 valid += 1;
3547 }
3548 out.push((n >> 16) as u8);
3549 if valid > 2 {
3550 out.push((n >> 8) as u8);
3551 }
3552 if valid > 3 {
3553 out.push(n as u8);
3554 }
3555 }
3556 Some(out)
3557 }
3558
3559 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
3563 const BLOCK: usize = 64;
3564 let mut k = [0u8; BLOCK];
3565 if key.len() > BLOCK {
3566 let d = sha256(key);
3567 k[..32].copy_from_slice(&d);
3568 } else {
3569 k[..key.len()].copy_from_slice(key);
3570 }
3571 let mut ipad = [0x36u8; BLOCK];
3572 let mut opad = [0x5cu8; BLOCK];
3573 for i in 0..BLOCK {
3574 ipad[i] ^= k[i];
3575 opad[i] ^= k[i];
3576 }
3577 let mut inner = Vec::with_capacity(BLOCK + msg.len());
3578 inner.extend_from_slice(&ipad);
3579 inner.extend_from_slice(msg);
3580 let inner_hash = sha256(&inner);
3581 let mut outer = Vec::with_capacity(BLOCK + 32);
3582 outer.extend_from_slice(&opad);
3583 outer.extend_from_slice(&inner_hash);
3584 let mac = sha256(&outer);
3585 let mut hex = String::with_capacity(64);
3586 for b in mac {
3587 hex.push_str(&format!("{b:02x}"));
3588 }
3589 hex
3590 }
3591
3592 fn sha256(data: &[u8]) -> [u8; 32] {
3594 const K: [u32; 64] = [
3595 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
3596 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
3597 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
3598 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
3599 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
3600 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
3601 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
3602 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
3603 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
3604 0xc67178f2,
3605 ];
3606 let mut h: [u32; 8] = [
3607 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
3608 0x5be0cd19,
3609 ];
3610
3611 let bit_len = (data.len() as u64) * 8;
3612 let mut msg = data.to_vec();
3613 msg.push(0x80);
3614 while msg.len() % 64 != 56 {
3615 msg.push(0);
3616 }
3617 msg.extend_from_slice(&bit_len.to_be_bytes());
3618
3619 for block in msg.chunks(64) {
3620 let mut w = [0u32; 64];
3621 for i in 0..16 {
3622 w[i] = u32::from_be_bytes([
3623 block[i * 4],
3624 block[i * 4 + 1],
3625 block[i * 4 + 2],
3626 block[i * 4 + 3],
3627 ]);
3628 }
3629 for i in 16..64 {
3630 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
3631 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
3632 w[i] = w[i - 16]
3633 .wrapping_add(s0)
3634 .wrapping_add(w[i - 7])
3635 .wrapping_add(s1);
3636 }
3637 let mut a = h;
3638 for i in 0..64 {
3639 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
3640 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
3641 let t1 = a[7]
3642 .wrapping_add(s1)
3643 .wrapping_add(ch)
3644 .wrapping_add(K[i])
3645 .wrapping_add(w[i]);
3646 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
3647 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
3648 let t2 = s0.wrapping_add(maj);
3649 a[7] = a[6];
3650 a[6] = a[5];
3651 a[5] = a[4];
3652 a[4] = a[3].wrapping_add(t1);
3653 a[3] = a[2];
3654 a[2] = a[1];
3655 a[1] = a[0];
3656 a[0] = t1.wrapping_add(t2);
3657 }
3658 for i in 0..8 {
3659 h[i] = h[i].wrapping_add(a[i]);
3660 }
3661 }
3662
3663 let mut out = [0u8; 32];
3664 for (i, word) in h.iter().enumerate() {
3665 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
3666 }
3667 out
3668 }
3669
3670 #[cfg(test)]
3671 mod tests {
3672 use super::*;
3673
3674 #[test]
3675 fn sha256_known_vector() {
3676 let d = sha256(b"abc");
3677 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
3678 assert_eq!(
3679 hex,
3680 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
3681 );
3682 }
3683
3684 #[test]
3685 fn hmac_known_vector() {
3686 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
3687 assert_eq!(
3688 mac,
3689 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
3690 );
3691 }
3692
3693 #[test]
3694 fn sign_verify_round_trips() {
3695 let secret = "test-secret";
3696 let sid = "9f2c-opaque-session-id";
3697 let cookie = sign_session(sid, secret);
3698 let pair = cookie.split(';').next().unwrap().to_string();
3699 let mut headers = HeaderMap::new();
3700 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
3701 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
3702 assert!(verify_session(&headers, "other-secret").is_none());
3704 }
3705
3706 #[test]
3707 fn forged_and_tampered_cookies_are_rejected() {
3708 let secret = "test-secret";
3709
3710 let forged = format!(
3713 "{SESSION_COOKIE}={}.{}",
3714 b64url_encode(b"attacker-chosen-sid"),
3715 "deadbeef".repeat(8) );
3717 let mut headers = HeaderMap::new();
3718 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
3719 assert!(verify_session(&headers, secret).is_none());
3720
3721 let cookie = sign_session("real-sid", secret);
3724 let pair = cookie.split(';').next().unwrap();
3725 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
3726 let tampered = format!(
3727 "{SESSION_COOKIE}={}.{}",
3728 b64url_encode(b"different-sid"),
3729 sig
3730 );
3731 let mut headers2 = HeaderMap::new();
3732 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
3733 assert!(verify_session(&headers2, secret).is_none());
3734 }
3735
3736 #[test]
3737 fn b64url_round_trips() {
3738 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
3739 let enc = b64url_encode(s.as_bytes());
3740 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
3741 }
3742 }
3743 }
3744}
3745
3746async fn get_entry_by_id(
3758 pool: &store::Pool,
3759 did: &str,
3760 id: i64,
3761) -> anyhow::Result<Option<store::Entry>> {
3762 let entry = sqlx::query_as::<_, store::Entry>(
3763 r#"
3764 SELECT e.* FROM entries e
3765 WHERE e.id = ?2
3766 AND EXISTS (
3767 SELECT 1 FROM sub_ref sr
3768 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3769 )
3770 "#,
3771 )
3772 .bind(did)
3773 .bind(id)
3774 .fetch_optional(pool)
3775 .await?;
3776 Ok(entry)
3777}
3778
3779async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3781 let read: Option<bool> =
3782 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3783 .bind(did)
3784 .bind(entry_id)
3785 .fetch_optional(pool)
3786 .await?
3787 .flatten();
3788 Ok(read.unwrap_or(false))
3789}
3790
3791async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3793 let starred: Option<bool> =
3794 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3795 .bind(did)
3796 .bind(entry_id)
3797 .fetch_optional(pool)
3798 .await?
3799 .flatten();
3800 Ok(starred.unwrap_or(false))
3801}
3802
3803async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
3805 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
3806 .bind(feed_id)
3807 .fetch_optional(pool)
3808 .await
3809 {
3810 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
3811 _ => String::new(),
3812 }
3813}
3814
3815async fn build_entry_row(
3818 pool: &store::Pool,
3819 did: &str,
3820 id: i64,
3821 read: Option<bool>,
3822) -> anyhow::Result<Option<EntryRow>> {
3823 let entry = match get_entry_by_id(pool, did, id).await? {
3824 Some(e) => e,
3825 None => return Ok(None),
3826 };
3827 let read = match read {
3828 Some(r) => r,
3829 None => entry_is_read(pool, did, id).await?,
3830 };
3831 let starred = entry_is_starred(pool, did, id).await?;
3832 Ok(Some(EntryRow {
3833 id: entry.id,
3834 title: entry
3835 .title
3836 .clone()
3837 .filter(|t| !t.trim().is_empty())
3838 .unwrap_or_else(|| "(untitled)".to_string()),
3839 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
3840 published: display_date(entry.published.as_deref()),
3841 read,
3842 starred,
3843 link: format!("/entries/{id}"),
3844 }))
3845}
3846
3847fn now_rfc3339() -> String {
3849 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3850}
3851
3852#[cfg(test)]
3853mod tests {
3854 use super::*;
3855
3856 #[test]
3857 fn qenc_encodes_reserved() {
3858 assert_eq!(qenc("a b"), "a%20b");
3859 assert_eq!(
3860 qenc("https://example.com/feed.xml"),
3861 "https%3A%2F%2Fexample.com%2Ffeed.xml"
3862 );
3863 assert_eq!(
3864 qenc("at://did:plc:x/c/r"),
3865 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
3866 );
3867 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
3869 }
3870
3871 #[test]
3872 fn folder_uri_shape() {
3873 assert_eq!(
3874 folder_uri("did:plc:abc", "3kfolder"),
3875 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
3876 );
3877 }
3878
3879 #[test]
3882 fn private_feeds_are_classified_private_across_providers() {
3883 for url in [
3887 "https://author.substack.com/feed/private/deadbeefcafe1234",
3888 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
3889 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
3890 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
3891 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
3892 "https://user:pass@example.com/feed",
3893 ] {
3894 assert!(
3895 feed::classify_feed_privacy(url).is_private(),
3896 "expected private: {url}"
3897 );
3898 }
3899 }
3900
3901 #[test]
3902 fn public_feeds_stay_public() {
3903 for url in [
3904 "https://author.substack.com/feed",
3905 "https://wordpress.example.com/feed/",
3906 "https://example.com/rss.xml",
3907 "https://example.org/atom.xml",
3908 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
3910 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
3911 ] {
3912 assert!(
3913 !feed::classify_feed_privacy(url).is_private(),
3914 "expected public: {url}"
3915 );
3916 }
3917 }
3918
3919 #[test]
3920 fn private_feed_label_is_public_safe_host_only() {
3921 let label =
3923 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
3924 assert_eq!(label, "author.substack.com");
3925 assert!(!label.contains("deadbeefcafe1234token"));
3926 assert!(!label.contains("/private/"));
3927 assert_eq!(private_feed_label("not a url"), "a private feed");
3929 }
3930
3931 #[test]
3932 fn refusal_message_promises_nothing_stored() {
3933 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
3934 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
3935 }
3936
3937 #[test]
3938 fn scope_query_preserves_context() {
3939 let q = EntryQuery {
3940 feed: Some("https://example.com/feed.xml".to_string()),
3941 folder: None,
3942 view: Some("all".to_string()),
3943 };
3944 let s = scope_query(&q);
3945 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
3946 assert!(s.contains("view=all"));
3947
3948 let q2 = EntryQuery {
3950 feed: None,
3951 folder: None,
3952 view: Some("unread".to_string()),
3953 };
3954 assert_eq!(scope_query(&q2), "");
3955 }
3956
3957 use axum::body::Body;
3960 use axum::http::Request;
3961 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
3967 let db = store::init_url("sqlite::memory:").await.unwrap();
3968 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
3969 store::ensure_seed(&db, &dids).await.unwrap();
3970 let config = Config {
3971 allowed_dids: dids,
3972 cookie_secret: "test-cookie-secret-000".to_string(),
3973 beta_cap: 3,
3974 ..Config::default()
3975 };
3976 AppState::new(config, db).unwrap()
3977 }
3978
3979 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
3982 let sid = state.sessions.create(Session {
3983 did: did.to_string(),
3984 handle: handle.map(str::to_string),
3985 });
3986 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
3987 sc.split(';').next().unwrap().to_string()
3988 }
3989
3990 #[test]
3991 fn rate_limited_paths_match_expected() {
3992 use axum::http::Method;
3993 assert!(is_rate_limited_path("/login", &Method::GET));
3994 assert!(is_rate_limited_path("/login", &Method::POST));
3995 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
3996 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
3997 assert!(is_rate_limited_path("/opml", &Method::POST));
3998 assert!(is_rate_limited_path("/read-all", &Method::POST));
3999 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
4000 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
4001 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
4002 assert!(!is_rate_limited_path("/", &Method::GET));
4004 assert!(!is_rate_limited_path("/about", &Method::GET));
4005 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
4006 assert!(!is_rate_limited_path("/login", &Method::HEAD));
4007 }
4008
4009 #[test]
4010 fn rate_limiter_allows_burst_then_429s() {
4011 let rl = RateLimiter::shared();
4012 let ip: IpAddr = "203.0.113.7".parse().unwrap();
4013 for _ in 0..(RATE_BURST as usize) {
4015 assert!(rl.check(ip));
4016 }
4017 assert!(!rl.check(ip));
4019 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
4021 assert!(rl.check(ip2));
4022 }
4023
4024 #[test]
4025 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
4026 let mut h = HeaderMap::new();
4030 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
4031 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
4032 assert_eq!(
4033 client_ip(&h, Some(&sock), None),
4034 Some("203.0.113.55".parse().unwrap()),
4035 "spoofed XFF must not override the socket peer"
4036 );
4037 }
4038
4039 #[test]
4040 fn client_ip_uses_trusted_header_last_hop() {
4041 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
4046
4047 let mut h = HeaderMap::new();
4048 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
4049 assert_eq!(
4050 client_ip(&h, Some(&sock), Some("fly-client-ip")),
4051 Some("198.51.100.9".parse().unwrap())
4052 );
4053
4054 let mut h2 = HeaderMap::new();
4056 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
4057 assert_eq!(
4058 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
4059 Some("198.51.100.9".parse().unwrap()),
4060 "must take the right-most (trusted) hop, not the forged left-most"
4061 );
4062
4063 let h3 = HeaderMap::new();
4065 assert_eq!(
4066 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
4067 Some("10.0.0.1".parse().unwrap())
4068 );
4069 }
4070
4071 #[test]
4072 fn invite_cookie_round_trips_and_rejects_tamper() {
4073 let secret = "test-cookie-secret-000";
4074 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
4075 let pair = sc.split(';').next().unwrap();
4076 let mut h = HeaderMap::new();
4077 h.insert(header::COOKIE, pair.parse().unwrap());
4078 assert_eq!(
4079 invite_cookie_code(&h, secret).as_deref(),
4080 Some("FEATHER-ABCDWXYZ")
4081 );
4082 assert!(invite_cookie_code(&h, "other").is_none());
4084 }
4085
4086 #[tokio::test]
4087 async fn preflight_valid_expired_and_full() {
4088 let state = test_state(&["did:plc:admin"]).await;
4089 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
4091 .await
4092 .unwrap();
4093 assert!(preflight_code(&state, &code).await.is_ok());
4094
4095 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
4099 .await
4100 .unwrap();
4101 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
4102 .bind(chrono::Utc::now().timestamp() - 3600)
4103 .bind(&expired)
4104 .execute(&state.db)
4105 .await
4106 .unwrap();
4107 assert_eq!(
4108 preflight_code(&state, &expired).await,
4109 Err(store::RedeemError::Expired)
4110 );
4111
4112 assert_eq!(
4114 preflight_code(&state, "FEATHER-NOPENOPE").await,
4115 Err(store::RedeemError::NotFound)
4116 );
4117
4118 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
4121 .await
4122 .unwrap();
4123 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
4124 .await
4125 .unwrap();
4126 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
4127 assert_eq!(
4128 preflight_code(&state, &code).await,
4129 Err(store::RedeemError::CapacityFull)
4130 );
4131 }
4132
4133 async fn bot_state(bot_secret: &str) -> AppState {
4137 let db = store::init_url("sqlite::memory:").await.unwrap();
4138 store::ensure_seed(&db, &["did:plc:admin".to_string()])
4139 .await
4140 .unwrap();
4141 let config = Config {
4142 allowed_dids: vec!["did:plc:admin".to_string()],
4143 cookie_secret: "test-cookie-secret-000".to_string(),
4144 beta_cap: 3,
4145 bot_secret: Some(bot_secret.to_string()),
4146 public_url: "https://feather-reader.com".to_string(),
4147 ..Config::default()
4148 };
4149 AppState::new(config, db).unwrap()
4150 }
4151
4152 #[test]
4153 fn claim_token_round_trips_and_rejects_tamper() {
4154 let secret = "test-cookie-secret-000";
4155 let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
4156 assert!(!token.contains(';'));
4158 assert_eq!(
4159 claim_token_code(&token, secret).as_deref(),
4160 Some("FEATHER-ABCDWXYZ")
4161 );
4162 assert!(claim_token_code(&token, "other").is_none());
4164 let mut bad = token.clone();
4166 bad.push('x');
4167 assert!(claim_token_code(&bad, secret).is_none());
4168 let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
4174 assert_eq!(
4175 test_b64url_decode(b64).as_deref(),
4176 Some("FEATHER-ABCDWXYZ".as_bytes()),
4177 "the code half of the token is plain base64url, decodable by anyone"
4178 );
4179 }
4180
4181 fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
4184 fn val(c: u8) -> Option<u32> {
4185 match c {
4186 b'A'..=b'Z' => Some((c - b'A') as u32),
4187 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
4188 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
4189 b'-' => Some(62),
4190 b'_' => Some(63),
4191 _ => None,
4192 }
4193 }
4194 let mut out = Vec::with_capacity(input.len() / 4 * 3);
4195 for chunk in input.as_bytes().chunks(4) {
4196 let mut n = 0u32;
4197 let mut bits = 0;
4198 for &c in chunk {
4199 n = (n << 6) | val(c)?;
4200 bits += 6;
4201 }
4202 let bytes = bits / 8;
4203 n <<= 24 - bits;
4204 for i in 0..bytes {
4205 out.push((n >> (16 - i * 8)) as u8);
4206 }
4207 }
4208 Some(out)
4209 }
4210
4211 #[tokio::test]
4212 async fn bot_mint_then_claim_grants_a_seat() {
4213 let state = bot_state("bot-secret-abcdef").await;
4214 let app = router(state.clone());
4215
4216 let resp = app
4218 .clone()
4219 .oneshot(
4220 Request::builder()
4221 .method("POST")
4222 .uri("/bot/claims")
4223 .header("x-bot-secret", "bot-secret-abcdef")
4224 .body(Body::empty())
4225 .unwrap(),
4226 )
4227 .await
4228 .unwrap();
4229 assert_eq!(resp.status(), StatusCode::OK);
4230 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4231 .await
4232 .unwrap();
4233 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
4234 let token = json["token"].as_str().unwrap().to_string();
4235 let url = json["url"].as_str().unwrap();
4236 assert!(url.starts_with("https://feather-reader.com/claim?t="));
4237 assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
4239 assert!(!url.contains("FEATHER-"));
4240
4241 let resp = app
4243 .clone()
4244 .oneshot(
4245 Request::builder()
4246 .method("GET")
4247 .uri(format!("/claim?t={}", qenc(&token)))
4248 .body(Body::empty())
4249 .unwrap(),
4250 )
4251 .await
4252 .unwrap();
4253 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4254 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
4255 let set_cookie = resp
4256 .headers()
4257 .get(header::SET_COOKIE)
4258 .unwrap()
4259 .to_str()
4260 .unwrap();
4261 assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
4262
4263 let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
4266 let out = store::redeem_code(
4267 &state.db,
4268 &code,
4269 "did:plc:follower",
4270 None,
4271 state.config.beta_cap,
4272 )
4273 .await
4274 .unwrap();
4275 assert_eq!(out, Ok(()));
4276 assert!(store::has_beta_access(&state.db, "did:plc:follower")
4277 .await
4278 .unwrap());
4279 }
4280
4281 #[tokio::test]
4282 async fn claim_with_invalid_token_bounces() {
4283 let state = bot_state("bot-secret-abcdef").await;
4284 let app = router(state);
4285 let resp = app
4286 .oneshot(
4287 Request::builder()
4288 .method("GET")
4289 .uri("/claim?t=not-a-real-token")
4290 .body(Body::empty())
4291 .unwrap(),
4292 )
4293 .await
4294 .unwrap();
4295 assert_eq!(resp.status(), StatusCode::OK);
4297 }
4298
4299 #[tokio::test]
4300 async fn claim_with_used_token_is_refused() {
4301 let state = bot_state("bot-secret-abcdef").await;
4302 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
4304 .await
4305 .unwrap();
4306 let token = sign_claim_token(&code, &state.config.cookie_secret);
4307 store::redeem_code(
4308 &state.db,
4309 &code,
4310 "did:plc:someone",
4311 None,
4312 state.config.beta_cap,
4313 )
4314 .await
4315 .unwrap()
4316 .unwrap();
4317 let app = router(state);
4318 let resp = app
4319 .oneshot(
4320 Request::builder()
4321 .method("GET")
4322 .uri(format!("/claim?t={}", qenc(&token)))
4323 .body(Body::empty())
4324 .unwrap(),
4325 )
4326 .await
4327 .unwrap();
4328 assert_eq!(resp.status(), StatusCode::OK);
4330 assert!(resp.headers().get(header::SET_COOKIE).is_none());
4331 }
4332
4333 #[tokio::test]
4334 async fn bot_claims_rejects_bad_and_missing_secret() {
4335 let state = bot_state("bot-secret-abcdef").await;
4336 let app = router(state);
4337 let resp = app
4339 .clone()
4340 .oneshot(
4341 Request::builder()
4342 .method("POST")
4343 .uri("/bot/claims")
4344 .header("x-bot-secret", "wrong")
4345 .body(Body::empty())
4346 .unwrap(),
4347 )
4348 .await
4349 .unwrap();
4350 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
4351 let resp = app
4353 .oneshot(
4354 Request::builder()
4355 .method("POST")
4356 .uri("/bot/claims")
4357 .body(Body::empty())
4358 .unwrap(),
4359 )
4360 .await
4361 .unwrap();
4362 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
4363 }
4364
4365 #[tokio::test]
4366 async fn bot_claims_disabled_when_secret_unset() {
4367 let state = test_state(&["did:plc:admin"]).await;
4369 let app = router(state);
4370 let resp = app
4371 .oneshot(
4372 Request::builder()
4373 .method("POST")
4374 .uri("/bot/claims")
4375 .header("x-bot-secret", "anything")
4376 .body(Body::empty())
4377 .unwrap(),
4378 )
4379 .await
4380 .unwrap();
4381 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
4382 }
4383
4384 #[tokio::test]
4385 async fn bot_claims_refuses_at_capacity() {
4386 let state = bot_state("bot-secret-abcdef").await;
4387 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
4389 .await
4390 .unwrap();
4391 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
4392 .await
4393 .unwrap();
4394 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
4395 let app = router(state);
4396 let resp = app
4397 .oneshot(
4398 Request::builder()
4399 .method("POST")
4400 .uri("/bot/claims")
4401 .header("x-bot-secret", "bot-secret-abcdef")
4402 .body(Body::empty())
4403 .unwrap(),
4404 )
4405 .await
4406 .unwrap();
4407 assert_eq!(resp.status(), StatusCode::CONFLICT);
4408 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4409 .await
4410 .unwrap();
4411 assert!(String::from_utf8_lossy(&bytes).contains("full"));
4412 }
4413
4414 #[tokio::test]
4415 async fn bot_claims_counts_outstanding_codes_against_cap() {
4416 let state = bot_state("bot-secret-abcdef").await;
4417 store::mint_code(&state.db, "did:plc:admin", 3600)
4419 .await
4420 .unwrap();
4421 store::mint_code(&state.db, "did:plc:admin", 3600)
4422 .await
4423 .unwrap();
4424 let app = router(state);
4425 let resp = app
4426 .oneshot(
4427 Request::builder()
4428 .method("POST")
4429 .uri("/bot/claims")
4430 .header("x-bot-secret", "bot-secret-abcdef")
4431 .body(Body::empty())
4432 .unwrap(),
4433 )
4434 .await
4435 .unwrap();
4436 assert_eq!(resp.status(), StatusCode::CONFLICT);
4438 }
4439
4440 async fn post_bot_claim_for(
4442 app: &axum::Router,
4443 secret: &str,
4444 did: &str,
4445 ) -> (StatusCode, serde_json::Value) {
4446 let resp = app
4447 .clone()
4448 .oneshot(
4449 Request::builder()
4450 .method("POST")
4451 .uri("/bot/claims")
4452 .header("x-bot-secret", secret)
4453 .header("content-type", "application/json")
4454 .body(Body::from(format!(
4455 "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
4456 )))
4457 .unwrap(),
4458 )
4459 .await
4460 .unwrap();
4461 let status = resp.status();
4462 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4463 .await
4464 .unwrap();
4465 let json = if bytes.is_empty() {
4466 serde_json::Value::Null
4467 } else {
4468 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
4469 };
4470 (status, json)
4471 }
4472
4473 #[tokio::test]
4474 async fn bot_claims_returns_already_seated_for_a_member() {
4475 let state = bot_state("bot-secret-abcdef").await;
4479 store::grant_access(&state.db, "did:plc:member", None, "admin", None)
4480 .await
4481 .unwrap();
4482 let app = router(state.clone());
4483 let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
4484 assert_eq!(status, StatusCode::OK);
4485 assert_eq!(json["status"], "already_seated");
4486 assert_eq!(json["code"], "");
4487 assert_eq!(json["url"], "");
4488 assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
4490 .await
4491 .unwrap()
4492 .is_none());
4493 }
4494
4495 #[tokio::test]
4496 async fn bot_claims_is_idempotent_per_did_returns_same_code() {
4497 let state = bot_state("bot-secret-abcdef").await;
4501 let app = router(state.clone());
4502
4503 let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
4504 assert_eq!(s1, StatusCode::OK);
4505 assert_eq!(j1["status"], "minted");
4506 let code1 = j1["code"].as_str().unwrap().to_string();
4507
4508 let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
4509 assert_eq!(s2, StatusCode::OK);
4510 assert_eq!(j2["status"], "existing");
4511 assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
4512 assert_eq!(j2["url"], j1["url"], "same url returned");
4513
4514 assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
4516 }
4517
4518 #[tokio::test]
4519 async fn bot_claims_records_intended_did_at_mint() {
4520 let state = bot_state("bot-secret-abcdef").await;
4522 let app = router(state.clone());
4523 let (status, json) =
4524 post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
4525 assert_eq!(status, StatusCode::OK);
4526 let code = json["code"].as_str().unwrap();
4527 assert_eq!(
4528 store::find_active_code_for_did(&state.db, "did:plc:follower2")
4529 .await
4530 .unwrap()
4531 .as_deref(),
4532 Some(code)
4533 );
4534 }
4535
4536 #[tokio::test]
4537 async fn bot_claims_concurrent_same_did_never_double_mints() {
4538 let state = bot_state("bot-secret-abcdef").await;
4545 let app = router(state.clone());
4546
4547 let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
4548 let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
4549 let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
4550
4551 assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
4552 assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
4553
4554 assert_eq!(
4556 store::count_active_codes(&state.db).await.unwrap(),
4557 1,
4558 "concurrent mints must not create two active codes"
4559 );
4560
4561 let ca = ja["code"].as_str().unwrap_or("");
4563 let cb = jb["code"].as_str().unwrap_or("");
4564 assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
4565 assert_eq!(ca, cb, "both callers must get the one minted code");
4566 for st in [&ja["status"], &jb["status"]] {
4569 let s = st.as_str().unwrap_or("");
4570 assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
4571 }
4572 }
4573
4574 #[tokio::test]
4575 async fn bot_claims_rejects_malformed_json_body() {
4576 let state = bot_state("bot-secret-abcdef").await;
4577 let app = router(state);
4578 let resp = app
4579 .oneshot(
4580 Request::builder()
4581 .method("POST")
4582 .uri("/bot/claims")
4583 .header("x-bot-secret", "bot-secret-abcdef")
4584 .header("content-type", "application/json")
4585 .body(Body::from("{not json"))
4586 .unwrap(),
4587 )
4588 .await
4589 .unwrap();
4590 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4591 }
4592
4593 #[tokio::test]
4594 async fn favicon_ico_served_at_root() {
4595 let state = test_state(&[]).await;
4598 let app = router(state);
4599 let resp = app
4600 .oneshot(
4601 Request::builder()
4602 .uri("/favicon.ico")
4603 .body(Body::empty())
4604 .unwrap(),
4605 )
4606 .await
4607 .unwrap();
4608 assert_eq!(resp.status(), StatusCode::OK);
4609 let ct = resp
4610 .headers()
4611 .get(header::CONTENT_TYPE)
4612 .unwrap()
4613 .to_str()
4614 .unwrap();
4615 assert!(
4616 ct.contains("icon") || ct.starts_with("image/"),
4617 "content-type = {ct}"
4618 );
4619 }
4620
4621 #[tokio::test]
4622 async fn login_without_invite_redirects_to_beta_redeem() {
4623 let state = test_state(&[]).await;
4625 let app = router(state);
4626 let resp = app
4627 .oneshot(
4628 Request::builder()
4629 .method("POST")
4630 .uri("/login")
4631 .header("content-type", "application/x-www-form-urlencoded")
4632 .body(Body::from("handle=alice.bsky.social"))
4633 .unwrap(),
4634 )
4635 .await
4636 .unwrap();
4637 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4638 assert_eq!(
4639 resp.headers().get(header::LOCATION).unwrap(),
4640 "/beta/redeem"
4641 );
4642 }
4643
4644 #[tokio::test]
4645 async fn login_with_valid_invite_cookie_starts_oauth() {
4646 let state = test_state(&[]).await;
4647 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
4648 let cookie = cookie.split(';').next().unwrap().to_string();
4649 let app = router(state);
4650 let resp = app
4651 .oneshot(
4652 Request::builder()
4653 .method("POST")
4654 .uri("/login")
4655 .header("content-type", "application/x-www-form-urlencoded")
4656 .header(header::COOKIE, cookie)
4657 .body(Body::from("handle=alice.bsky.social"))
4658 .unwrap(),
4659 )
4660 .await
4661 .unwrap();
4662 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4664 let loc = resp
4665 .headers()
4666 .get(header::LOCATION)
4667 .unwrap()
4668 .to_str()
4669 .unwrap();
4670 assert!(loc.contains("/login"), "loc = {loc}");
4671 assert_ne!(loc, "/beta/redeem");
4672 }
4673
4674 async fn resolver_never(_handle: String) -> Option<String> {
4677 None
4678 }
4679
4680 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
4682 move |_handle| std::future::ready(Some(did.to_string()))
4683 }
4684
4685 #[tokio::test]
4689 async fn may_start_oauth_honors_seat_via_resolved_handle() {
4690 let state = test_state(&["did:plc:admin"]).await;
4693 let headers = HeaderMap::new();
4694 assert!(
4695 may_start_oauth_with(
4696 &state,
4697 &headers,
4698 "admin.example",
4699 resolver_to("did:plc:admin")
4700 )
4701 .await,
4702 "a handle resolving to a seated DID must pass the gate"
4703 );
4704 }
4705
4706 #[tokio::test]
4710 async fn may_start_oauth_bounces_non_member_handle() {
4711 let state = test_state(&["did:plc:admin"]).await;
4712 let headers = HeaderMap::new();
4713 assert!(
4714 !may_start_oauth_with(
4715 &state,
4716 &headers,
4717 "rando.example",
4718 resolver_to("did:plc:rando")
4719 )
4720 .await,
4721 "a resolved DID with no seat must be bounced"
4722 );
4723 }
4724
4725 #[tokio::test]
4728 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
4729 let state = test_state(&["did:plc:admin"]).await;
4730 let headers = HeaderMap::new();
4731 assert!(
4732 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
4733 "an unresolvable handle must fail closed"
4734 );
4735 }
4736
4737 #[tokio::test]
4741 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
4742 let state = test_state(&[]).await;
4743 let did = "did:plc:member";
4744 store::grant_access(&state.db, did, Some("member.example"), "test", None)
4745 .await
4746 .unwrap();
4747 let cookie = session_cookie(&state, did, Some("member.example"));
4748 let mut headers = HeaderMap::new();
4749 headers.insert(header::COOKIE, cookie.parse().unwrap());
4750 assert!(
4751 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
4752 "a seated session cookie must pass without resolution"
4753 );
4754 }
4755
4756 #[tokio::test]
4758 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
4759 let state = test_state(&[]).await;
4760 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
4761 let cookie = cookie.split(';').next().unwrap().to_string();
4762 let mut headers = HeaderMap::new();
4763 headers.insert(header::COOKIE, cookie.parse().unwrap());
4764 assert!(
4765 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
4766 "a valid invite cookie must pass without resolution"
4767 );
4768 }
4769
4770 #[tokio::test]
4771 async fn admin_mint_requires_admin_seed_did() {
4772 let state = test_state(&["did:plc:admin"]).await;
4773 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
4775 .await
4776 .unwrap();
4777 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
4778 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
4780 let app = router(state);
4781
4782 let forbidden = app
4783 .clone()
4784 .oneshot(
4785 Request::builder()
4786 .method("POST")
4787 .uri("/admin/invites?n=2")
4788 .header(header::COOKIE, rando_cookie)
4789 .body(Body::empty())
4790 .unwrap(),
4791 )
4792 .await
4793 .unwrap();
4794 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
4795
4796 let ok = app
4797 .oneshot(
4798 Request::builder()
4799 .method("POST")
4800 .uri("/admin/invites?n=2")
4801 .header(header::COOKIE, admin_cookie)
4802 .body(Body::empty())
4803 .unwrap(),
4804 )
4805 .await
4806 .unwrap();
4807 assert_eq!(ok.status(), StatusCode::OK);
4808 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
4809 .await
4810 .unwrap();
4811 let body = String::from_utf8(bytes.to_vec()).unwrap();
4812 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
4813 assert_eq!(minted.len(), 2);
4814 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
4815 }
4816
4817 #[tokio::test]
4818 async fn admin_mint_unauthenticated_is_401() {
4819 let state = test_state(&["did:plc:admin"]).await;
4820 let app = router(state);
4821 let resp = app
4822 .oneshot(
4823 Request::builder()
4824 .method("POST")
4825 .uri("/admin/invites")
4826 .body(Body::empty())
4827 .unwrap(),
4828 )
4829 .await
4830 .unwrap();
4831 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
4832 }
4833
4834 #[tokio::test]
4835 async fn cache_control_public_on_about_no_store_on_authed() {
4836 let state = test_state(&["did:plc:admin"]).await;
4837 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
4838 let app = router(state);
4839
4840 let about = app
4842 .clone()
4843 .oneshot(
4844 Request::builder()
4845 .uri("/about")
4846 .body(Body::empty())
4847 .unwrap(),
4848 )
4849 .await
4850 .unwrap();
4851 assert_eq!(
4852 about.headers().get(header::CACHE_CONTROL).unwrap(),
4853 "public, max-age=300"
4854 );
4855 assert!(about.headers().contains_key("content-security-policy"));
4857 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
4858
4859 let login = app
4861 .clone()
4862 .oneshot(
4863 Request::builder()
4864 .uri("/login")
4865 .body(Body::empty())
4866 .unwrap(),
4867 )
4868 .await
4869 .unwrap();
4870 assert_eq!(
4871 login.headers().get(header::CACHE_CONTROL).unwrap(),
4872 "public, max-age=300"
4873 );
4874
4875 let home = app
4877 .oneshot(
4878 Request::builder()
4879 .uri("/")
4880 .header(header::COOKIE, admin_cookie)
4881 .body(Body::empty())
4882 .unwrap(),
4883 )
4884 .await
4885 .unwrap();
4886 assert_eq!(
4887 home.headers().get(header::CACHE_CONTROL).unwrap(),
4888 "no-store"
4889 );
4890 }
4891
4892 #[tokio::test]
4893 async fn beta_redeem_page_renders() {
4894 let state = test_state(&[]).await;
4895 let app = router(state);
4896 let resp = app
4897 .oneshot(
4898 Request::builder()
4899 .uri("/beta/redeem")
4900 .body(Body::empty())
4901 .unwrap(),
4902 )
4903 .await
4904 .unwrap();
4905 assert_eq!(resp.status(), StatusCode::OK);
4906 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
4907 .await
4908 .unwrap();
4909 let html = String::from_utf8(bytes.to_vec()).unwrap();
4910 assert!(html.contains("Invite code"));
4911 assert!(html.contains("/beta/redeem"));
4912 }
4913
4914 #[tokio::test]
4915 async fn rate_limit_returns_429_after_burst() {
4916 let db = store::init_url("sqlite::memory:").await.unwrap();
4919 store::ensure_seed(&db, &[]).await.unwrap();
4920 let config = Config {
4921 cookie_secret: "test-cookie-secret-000".to_string(),
4922 beta_cap: 3,
4923 trusted_ip_header: Some("cf-connecting-ip".to_string()),
4924 ..Config::default()
4925 };
4926 let state = AppState::new(config, db).unwrap();
4927 let app = router(state);
4928 let mut saw_429 = false;
4932 for _ in 0..(RATE_BURST as usize + 5) {
4933 let resp = app
4934 .clone()
4935 .oneshot(
4936 Request::builder()
4937 .method("POST")
4938 .uri("/beta/redeem")
4939 .header("content-type", "application/x-www-form-urlencoded")
4940 .header("cf-connecting-ip", "203.0.113.200")
4941 .body(Body::from("code=FEATHER-NOPENOPE"))
4942 .unwrap(),
4943 )
4944 .await
4945 .unwrap();
4946 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
4947 saw_429 = true;
4948 break;
4949 }
4950 }
4951 assert!(saw_429, "expected a 429 after exhausting the burst");
4952 }
4953
4954 #[tokio::test]
4955 async fn rate_limit_ignores_spoofed_xff_rotation() {
4956 let state = test_state(&[]).await;
4965 let app = router(state);
4966 for i in 0..(RATE_BURST as usize + 5) {
4967 let forged = format!("10.9.8.{}", i % 250);
4968 let resp = app
4969 .clone()
4970 .oneshot(
4971 Request::builder()
4972 .method("POST")
4973 .uri("/beta/redeem")
4974 .header("content-type", "application/x-www-form-urlencoded")
4975 .header("x-forwarded-for", forged)
4976 .body(Body::from("code=FEATHER-NOPENOPE"))
4977 .unwrap(),
4978 )
4979 .await
4980 .unwrap();
4981 assert_ne!(
4982 resp.status(),
4983 StatusCode::TOO_MANY_REQUESTS,
4984 "untrusted XFF must not be used as the rate-limit key"
4985 );
4986 }
4987 }
4988
4989 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
4994 let boundary = "----featherreadertestboundary";
4995 let mut body = Vec::new();
4996 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
4997 body.extend_from_slice(
4998 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
4999 );
5000 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
5001 body.extend_from_slice(payload);
5002 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
5003 (format!("multipart/form-data; boundary={boundary}"), body)
5004 }
5005
5006 #[tokio::test]
5007 async fn opml_import_oversize_upload_returns_413() {
5008 let state = test_state(&["did:plc:admin"]).await;
5009 let cookie = session_cookie(&state, "did:plc:admin", None);
5010 let app = router(state);
5011
5012 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
5014 let (content_type, body) = opml_multipart(&payload);
5015
5016 let resp = app
5017 .oneshot(
5018 Request::builder()
5019 .method("POST")
5020 .uri("/opml")
5021 .header("content-type", content_type)
5022 .header(header::COOKIE, cookie)
5023 .body(Body::from(body))
5024 .unwrap(),
5025 )
5026 .await
5027 .unwrap();
5028 assert_eq!(
5029 resp.status(),
5030 StatusCode::PAYLOAD_TOO_LARGE,
5031 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
5032 );
5033 }
5034
5035 #[tokio::test]
5036 async fn opml_import_under_limit_upload_is_not_413() {
5037 let state = test_state(&["did:plc:admin"]).await;
5038 let cookie = session_cookie(&state, "did:plc:admin", None);
5039 let app = router(state);
5040
5041 let opml = br#"<?xml version="1.0"?>
5044<opml version="2.0"><body>
5045 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
5046</body></opml>"#;
5047 let (content_type, body) = opml_multipart(opml);
5048
5049 let resp = app
5050 .oneshot(
5051 Request::builder()
5052 .method("POST")
5053 .uri("/opml")
5054 .header("content-type", content_type)
5055 .header(header::COOKIE, cookie)
5056 .body(Body::from(body))
5057 .unwrap(),
5058 )
5059 .await
5060 .unwrap();
5061 assert_ne!(
5062 resp.status(),
5063 StatusCode::PAYLOAD_TOO_LARGE,
5064 "an under-cap OPML upload must not be rejected as too large"
5065 );
5066 }
5067
5068 #[tokio::test]
5069 async fn opml_import_logged_out_redirects_to_login() {
5070 let state = test_state(&["did:plc:admin"]).await;
5073 let app = router(state);
5074
5075 let opml = b"<opml version=\"2.0\"><body></body></opml>";
5076 let (content_type, body) = opml_multipart(opml);
5077
5078 let resp = app
5079 .oneshot(
5080 Request::builder()
5081 .method("POST")
5082 .uri("/opml")
5083 .header("content-type", content_type)
5084 .body(Body::from(body))
5085 .unwrap(),
5086 )
5087 .await
5088 .unwrap();
5089 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5090 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
5091 }
5092
5093 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
5100 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5101 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5102 let addr = listener.local_addr().unwrap();
5103 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
5104 tokio::spawn(async move {
5105 let (mut sock, _) = listener.accept().await.unwrap();
5106 let mut buf = vec![0u8; 4096];
5107 let n = sock.read(&mut buf).await.unwrap();
5108 let req = String::from_utf8_lossy(&buf[..n]).to_string();
5109 let did = req
5111 .split("\r\n\r\n")
5112 .nth(1)
5113 .and_then(|body| {
5114 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
5115 v.get("did")?.as_str().map(str::to_string)
5116 })
5117 .unwrap_or_default();
5118 let is_revoke = req.starts_with("POST /internal/revoke");
5119 let body = serde_json::json!({
5120 "ok": true, "did": did, "revoked": true, "hadSession": true
5121 })
5122 .to_string();
5123 let resp = format!(
5124 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
5125 body.len(),
5126 body
5127 );
5128 sock.write_all(resp.as_bytes()).await.unwrap();
5129 sock.flush().await.unwrap();
5130 let _ = tx.send(if is_revoke { did } else { String::new() });
5131 });
5132 (format!("http://{addr}"), rx)
5133 }
5134
5135 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
5137 let db = store::init_url("sqlite::memory:").await.unwrap();
5138 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5139 store::ensure_seed(&db, &dids).await.unwrap();
5140 let mut config = Config {
5141 allowed_dids: dids,
5142 cookie_secret: "test-cookie-secret-000".to_string(),
5143 beta_cap: 3,
5144 ..Config::default()
5145 };
5146 config.sidecar.public_url = sidecar_url.to_string();
5147 config.sidecar.internal_url = sidecar_url.to_string();
5150 AppState::new(config, db).unwrap()
5151 }
5152
5153 #[tokio::test]
5156 async fn account_delete_purges_rows_and_triggers_revoke() {
5157 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
5158 let did = "did:plc:leaver";
5159 let state = test_state_with_sidecar(&[], &sidecar_url).await;
5160
5161 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
5163 .await
5164 .unwrap();
5165 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
5166 store::mint_code(&state.db, did, 3600).await.unwrap();
5167 assert!(store::has_beta_access(&state.db, did).await.unwrap());
5168
5169 let cookie = session_cookie(&state, did, Some("leaver.example"));
5170 let app = router(state.clone());
5171
5172 let resp = app
5173 .oneshot(
5174 Request::builder()
5175 .method("POST")
5176 .uri("/account/delete")
5177 .header(header::COOKIE, cookie)
5178 .header("content-type", "application/x-www-form-urlencoded")
5179 .body(Body::from("confirm=DELETE"))
5180 .unwrap(),
5181 )
5182 .await
5183 .unwrap();
5184
5185 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5187 assert!(resp
5188 .headers()
5189 .get(header::LOCATION)
5190 .unwrap()
5191 .to_str()
5192 .unwrap()
5193 .starts_with("/login"));
5194 let set_cookie = resp
5195 .headers()
5196 .get(header::SET_COOKIE)
5197 .unwrap()
5198 .to_str()
5199 .unwrap();
5200 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
5201
5202 let revoked_did = revoke_rx.await.unwrap();
5204 assert_eq!(
5205 revoked_did, did,
5206 "sidecar revoke must fire for the caller DID"
5207 );
5208
5209 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
5211 let codes: i64 =
5212 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
5213 .bind(did)
5214 .fetch_one(&state.db)
5215 .await
5216 .unwrap();
5217 assert_eq!(codes, 0);
5218 }
5219
5220 #[tokio::test]
5223 async fn account_delete_without_confirm_is_a_noop() {
5224 let did = "did:plc:staying";
5225 let state = test_state(&[]).await;
5226 store::grant_access(&state.db, did, None, "test", None)
5227 .await
5228 .unwrap();
5229 let cookie = session_cookie(&state, did, None);
5230 let app = router(state.clone());
5231
5232 let resp = app
5233 .oneshot(
5234 Request::builder()
5235 .method("POST")
5236 .uri("/account/delete")
5237 .header(header::COOKIE, cookie)
5238 .header("content-type", "application/x-www-form-urlencoded")
5239 .body(Body::from("confirm=nope"))
5240 .unwrap(),
5241 )
5242 .await
5243 .unwrap();
5244
5245 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5246 assert!(resp
5247 .headers()
5248 .get(header::LOCATION)
5249 .unwrap()
5250 .to_str()
5251 .unwrap()
5252 .starts_with("/manage"));
5253 assert!(store::has_beta_access(&state.db, did).await.unwrap());
5255 }
5256
5257 #[tokio::test]
5264 async fn pds_outage_does_not_widen_cross_did_access() {
5265 let did_a = "did:plc:aaaa";
5266 let state = test_state(&[]).await;
5267 store::grant_access(&state.db, did_a, None, "test", None)
5268 .await
5269 .unwrap();
5270
5271 let feed_a = store::upsert_feed(
5274 &state.db,
5275 &store::NewFeed {
5276 url: "https://a.example/feed.xml".to_string(),
5277 title: Some("A".to_string()),
5278 ..Default::default()
5279 },
5280 )
5281 .await
5282 .unwrap();
5283 let feed_b = store::upsert_feed(
5284 &state.db,
5285 &store::NewFeed {
5286 url: "https://b.example/feed.xml".to_string(),
5287 title: Some("B".to_string()),
5288 ..Default::default()
5289 },
5290 )
5291 .await
5292 .unwrap();
5293 store::insert_entries(
5294 &state.db,
5295 feed_b,
5296 &[store::NewEntry {
5297 guid: "b-1".to_string(),
5298 url: Some("https://b.example/1".to_string()),
5299 title: Some("B one".to_string()),
5300 published: Some("2026-07-11T00:00:00Z".to_string()),
5301 content_html: Some("<p>secret B body</p>".to_string()),
5302 ..Default::default()
5303 }],
5304 0,
5305 )
5306 .await
5307 .unwrap();
5308 store::replace_sub_refs(&state.db, did_a, &[feed_a])
5310 .await
5311 .unwrap();
5312 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
5315 .await
5316 .unwrap();
5317 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
5318 .await
5319 .unwrap()[0]
5320 .id;
5321 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
5322 .await
5323 .unwrap();
5324
5325 let cookie = session_cookie(&state, did_a, None);
5326 let app = router(state.clone());
5327
5328 let get_b = app
5330 .clone()
5331 .oneshot(
5332 Request::builder()
5333 .method("GET")
5334 .uri(format!("/entries/{b_entry_id}"))
5335 .header(header::COOKIE, cookie.clone())
5336 .body(Body::empty())
5337 .unwrap(),
5338 )
5339 .await
5340 .unwrap();
5341 assert_eq!(
5342 get_b.status(),
5343 StatusCode::NOT_FOUND,
5344 "A must not read B's entry during a PDS outage"
5345 );
5346
5347 let read_b = app
5349 .oneshot(
5350 Request::builder()
5351 .method("POST")
5352 .uri(format!("/entries/{b_entry_id}/read"))
5353 .header(header::COOKIE, cookie)
5354 .header("content-type", "application/x-www-form-urlencoded")
5355 .body(Body::from("read=true"))
5356 .unwrap(),
5357 )
5358 .await
5359 .unwrap();
5360 assert_eq!(
5361 read_b.status(),
5362 StatusCode::NOT_FOUND,
5363 "A must not mark B's entry read during a PDS outage"
5364 );
5365
5366 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
5368 .bind(did_a)
5369 .fetch_all(&state.db)
5370 .await
5371 .unwrap();
5372 assert_eq!(
5373 a_feed_ids,
5374 vec![feed_a],
5375 "outage fallback must not add feeds A never subscribed to"
5376 );
5377 let es_count: i64 =
5379 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5380 .bind(did_a)
5381 .bind(b_entry_id)
5382 .fetch_one(&state.db)
5383 .await
5384 .unwrap();
5385 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
5386 }
5387
5388 async fn test_state_with_caps(
5391 did: &str,
5392 max_subs_per_did: i64,
5393 max_feeds_global: i64,
5394 ) -> AppState {
5395 let db = store::init_url("sqlite::memory:").await.unwrap();
5396 let config = Config {
5397 cookie_secret: "test-cookie-secret-000".to_string(),
5398 beta_cap: 100,
5399 max_subs_per_did,
5400 max_feeds_global,
5401 ..Config::default()
5402 };
5403 store::grant_access(&db, did, None, "test", None)
5404 .await
5405 .unwrap();
5406 AppState::new(config, db).unwrap()
5407 }
5408
5409 fn opml_with_feeds(n: usize) -> String {
5411 let mut outlines = String::new();
5412 for i in 0..n {
5413 outlines.push_str(&format!(
5414 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
5415 ));
5416 }
5417 format!(
5418 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
5419 )
5420 }
5421
5422 #[tokio::test]
5427 async fn opml_import_enforces_global_feeds_ceiling() {
5428 let did = "did:plc:importer";
5429 let state = test_state_with_caps(did, 0, 3).await;
5431 let cookie = session_cookie(&state, did, None);
5432 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
5433 let app = router(state.clone());
5434
5435 let resp = app
5436 .oneshot(
5437 Request::builder()
5438 .method("POST")
5439 .uri("/opml")
5440 .header(header::COOKIE, cookie)
5441 .header("content-type", ct)
5442 .body(Body::from(body))
5443 .unwrap(),
5444 )
5445 .await
5446 .unwrap();
5447 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5448
5449 let feeds = store::count_feeds(&state.db).await.unwrap();
5450 assert!(
5451 feeds <= 3,
5452 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
5453 );
5454 }
5455
5456 #[tokio::test]
5459 async fn opml_import_enforces_per_did_cap() {
5460 let did = "did:plc:capped";
5461 let state = test_state_with_caps(did, 2, 0).await;
5463 let existing_a = store::upsert_feed(
5464 &state.db,
5465 &store::NewFeed {
5466 url: "https://have-a.example/feed.xml".to_string(),
5467 ..Default::default()
5468 },
5469 )
5470 .await
5471 .unwrap();
5472 let existing_b = store::upsert_feed(
5473 &state.db,
5474 &store::NewFeed {
5475 url: "https://have-b.example/feed.xml".to_string(),
5476 ..Default::default()
5477 },
5478 )
5479 .await
5480 .unwrap();
5481 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
5482 .await
5483 .unwrap();
5484 let before = store::count_feeds(&state.db).await.unwrap();
5485
5486 let cookie = session_cookie(&state, did, None);
5487 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
5488 let app = router(state.clone());
5489 let resp = app
5490 .oneshot(
5491 Request::builder()
5492 .method("POST")
5493 .uri("/opml")
5494 .header(header::COOKIE, cookie)
5495 .header("content-type", ct)
5496 .body(Body::from(body))
5497 .unwrap(),
5498 )
5499 .await
5500 .unwrap();
5501 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5502 let after = store::count_feeds(&state.db).await.unwrap();
5504 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
5505 }
5506
5507 #[tokio::test]
5510 async fn single_add_enforces_per_did_cap() {
5511 let did = "did:plc:subcapped";
5512 let state = test_state_with_caps(did, 1, 0).await;
5513 let f = store::upsert_feed(
5514 &state.db,
5515 &store::NewFeed {
5516 url: "https://have.example/feed.xml".to_string(),
5517 ..Default::default()
5518 },
5519 )
5520 .await
5521 .unwrap();
5522 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
5523 let cookie = session_cookie(&state, did, None);
5524 let app = router(state.clone());
5525 let resp = app
5526 .oneshot(
5527 Request::builder()
5528 .method("POST")
5529 .uri("/subscriptions")
5530 .header(header::COOKIE, cookie)
5531 .header("content-type", "application/x-www-form-urlencoded")
5532 .body(Body::from("url=https://another.example/feed.xml"))
5533 .unwrap(),
5534 )
5535 .await
5536 .unwrap();
5537 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5538 let loc = resp
5539 .headers()
5540 .get(header::LOCATION)
5541 .unwrap()
5542 .to_str()
5543 .unwrap();
5544 assert!(
5545 loc.contains("Subscription%20limit%20reached"),
5546 "expected sub-limit flash, got {loc}"
5547 );
5548 }
5549
5550 #[tokio::test]
5557 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
5558 let did = "did:plc:reader";
5559 let state = test_state(&[]).await;
5560 store::grant_access(&state.db, did, None, "test", None)
5561 .await
5562 .unwrap();
5563 let feed = store::upsert_feed(
5564 &state.db,
5565 &store::NewFeed {
5566 url: "https://reader.example/feed.xml".to_string(),
5567 title: Some("Reader".to_string()),
5568 ..Default::default()
5569 },
5570 )
5571 .await
5572 .unwrap();
5573 store::insert_entries(
5574 &state.db,
5575 feed,
5576 &[store::NewEntry {
5577 guid: "r-1".to_string(),
5578 url: Some("https://reader.example/1".to_string()),
5579 title: Some("Article".to_string()),
5580 published: Some("2026-07-11T00:00:00Z".to_string()),
5581 content_html: Some("<p>body</p>".to_string()),
5582 ..Default::default()
5583 }],
5584 0,
5585 )
5586 .await
5587 .unwrap();
5588 store::replace_sub_refs(&state.db, did, &[feed])
5589 .await
5590 .unwrap();
5591 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
5592
5593 let cookie = session_cookie(&state, did, None);
5594 let app = router(state.clone());
5595
5596 let resp = app
5598 .clone()
5599 .oneshot(
5600 Request::builder()
5601 .method("POST")
5602 .uri(format!("/entries/{entry_id}/read"))
5603 .header(header::COOKIE, cookie.clone())
5604 .header("HX-Request", "true")
5605 .header("X-FR-Reader", "1")
5606 .header("content-type", "application/x-www-form-urlencoded")
5607 .body(Body::from("read=true"))
5608 .unwrap(),
5609 )
5610 .await
5611 .unwrap();
5612 assert_eq!(resp.status(), StatusCode::OK);
5613 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
5614 .await
5615 .unwrap();
5616 let html = String::from_utf8(bytes.to_vec()).unwrap();
5617 assert!(
5618 html.contains("hx-swap-oob=\"outerHTML\""),
5619 "reader response must be an OOB swap: {html}"
5620 );
5621 assert!(
5622 html.contains(r#"id="entry-actionbar""#),
5623 "reader response must be the action-bar fragment: {html}"
5624 );
5625 assert!(
5628 html.contains(r#"aria-pressed="true""#),
5629 "read button must show pressed after marking read: {html}"
5630 );
5631 assert!(
5632 html.contains(r#"name="read" value="false""#),
5633 "hidden read value must flip to false so a second tap reverses: {html}"
5634 );
5635
5636 let resp2 = app
5639 .oneshot(
5640 Request::builder()
5641 .method("POST")
5642 .uri(format!("/entries/{entry_id}/read"))
5643 .header(header::COOKIE, cookie)
5644 .header("HX-Request", "true")
5645 .header("X-FR-Reader", "1")
5646 .header("content-type", "application/x-www-form-urlencoded")
5647 .body(Body::from("read=false"))
5648 .unwrap(),
5649 )
5650 .await
5651 .unwrap();
5652 assert_eq!(resp2.status(), StatusCode::OK);
5653 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
5654 .await
5655 .unwrap();
5656 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
5657 assert!(
5658 html2.contains(r#"aria-pressed="false""#),
5659 "read button must show un-pressed after reversing: {html2}"
5660 );
5661 assert!(
5662 html2.contains(r#"name="read" value="true""#),
5663 "hidden read value must flip back to true: {html2}"
5664 );
5665 }
5666
5667 #[tokio::test]
5670 async fn list_mark_read_returns_row_not_oob_actionbar() {
5671 let did = "did:plc:listv";
5672 let state = test_state(&[]).await;
5673 store::grant_access(&state.db, did, None, "test", None)
5674 .await
5675 .unwrap();
5676 let feed = store::upsert_feed(
5677 &state.db,
5678 &store::NewFeed {
5679 url: "https://list.example/feed.xml".to_string(),
5680 title: Some("List".to_string()),
5681 ..Default::default()
5682 },
5683 )
5684 .await
5685 .unwrap();
5686 store::insert_entries(
5687 &state.db,
5688 feed,
5689 &[store::NewEntry {
5690 guid: "l-1".to_string(),
5691 url: Some("https://list.example/1".to_string()),
5692 title: Some("Article".to_string()),
5693 published: Some("2026-07-11T00:00:00Z".to_string()),
5694 ..Default::default()
5695 }],
5696 0,
5697 )
5698 .await
5699 .unwrap();
5700 store::replace_sub_refs(&state.db, did, &[feed])
5701 .await
5702 .unwrap();
5703 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
5704
5705 let cookie = session_cookie(&state, did, None);
5706 let app = router(state.clone());
5707
5708 let resp = app
5709 .oneshot(
5710 Request::builder()
5711 .method("POST")
5712 .uri(format!("/entries/{entry_id}/read"))
5713 .header(header::COOKIE, cookie)
5714 .header("HX-Request", "true")
5715 .header("content-type", "application/x-www-form-urlencoded")
5716 .body(Body::from("read=true"))
5717 .unwrap(),
5718 )
5719 .await
5720 .unwrap();
5721 assert_eq!(resp.status(), StatusCode::OK);
5722 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
5723 .await
5724 .unwrap();
5725 let html = String::from_utf8(bytes.to_vec()).unwrap();
5726 assert!(
5727 !html.contains("hx-swap-oob"),
5728 "list-view response must NOT be an OOB swap: {html}"
5729 );
5730 }
5731
5732 #[tokio::test]
5741 async fn rename_to_new_url_refused_at_global_feeds_cap() {
5742 let did = "did:plc:renamer";
5743 let state = test_state_with_caps(did, 0, 1).await;
5745 store::upsert_feed(
5746 &state.db,
5747 &store::NewFeed {
5748 url: "https://existing.example/feed.xml".to_string(),
5749 ..Default::default()
5750 },
5751 )
5752 .await
5753 .unwrap();
5754 let before = store::count_feeds(&state.db).await.unwrap();
5755 assert_eq!(before, 1);
5756
5757 let cookie = session_cookie(&state, did, None);
5758 let app = router(state.clone());
5759 let resp = app
5760 .oneshot(
5761 Request::builder()
5762 .method("POST")
5763 .uri("/subscriptions/rkey123/rename")
5764 .header(header::COOKIE, cookie)
5765 .header("content-type", "application/x-www-form-urlencoded")
5766 .body(Body::from(
5768 "url=https://brand-new.example/feed.xml&title=Renamed",
5769 ))
5770 .unwrap(),
5771 )
5772 .await
5773 .unwrap();
5774 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5775 let loc = resp
5776 .headers()
5777 .get(header::LOCATION)
5778 .unwrap()
5779 .to_str()
5780 .unwrap();
5781 assert!(
5782 loc.contains("feed%20capacity"),
5783 "expected the feed-capacity flash, got {loc}"
5784 );
5785 let after = store::count_feeds(&state.db).await.unwrap();
5787 assert_eq!(
5788 after, before,
5789 "rename inflated the shared cache past the cap"
5790 );
5791 }
5792
5793 #[tokio::test]
5796 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
5797 let did = "did:plc:renamer2";
5798 let state = test_state_with_caps(did, 0, 1).await;
5799 store::upsert_feed(
5800 &state.db,
5801 &store::NewFeed {
5802 url: "https://existing.example/feed.xml".to_string(),
5803 ..Default::default()
5804 },
5805 )
5806 .await
5807 .unwrap();
5808 let before = store::count_feeds(&state.db).await.unwrap();
5809
5810 let cookie = session_cookie(&state, did, None);
5811 let app = router(state.clone());
5812 let resp = app
5813 .oneshot(
5814 Request::builder()
5815 .method("POST")
5816 .uri("/subscriptions/rkey123/rename")
5817 .header(header::COOKIE, cookie)
5818 .header("content-type", "application/x-www-form-urlencoded")
5819 .body(Body::from(
5820 "url=https://existing.example/feed.xml&title=Retitled",
5821 ))
5822 .unwrap(),
5823 )
5824 .await
5825 .unwrap();
5826 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5827 let loc = resp
5828 .headers()
5829 .get(header::LOCATION)
5830 .unwrap()
5831 .to_str()
5832 .unwrap();
5833 assert_eq!(
5834 loc, "/",
5835 "retitle of an existing feed must succeed, got {loc}"
5836 );
5837 assert_eq!(
5838 store::count_feeds(&state.db).await.unwrap(),
5839 before,
5840 "retitle must not add a feeds row"
5841 );
5842 }
5843
5844 #[tokio::test]
5847 async fn rename_with_blank_url_writes_nothing() {
5848 let did = "did:plc:renamer3";
5849 let state = test_state_with_caps(did, 0, 0).await;
5850 let before = store::count_feeds(&state.db).await.unwrap();
5851 assert_eq!(before, 0);
5852
5853 let cookie = session_cookie(&state, did, None);
5854 let app = router(state.clone());
5855 let resp = app
5856 .oneshot(
5857 Request::builder()
5858 .method("POST")
5859 .uri("/subscriptions/rkey123/rename")
5860 .header(header::COOKIE, cookie)
5861 .header("content-type", "application/x-www-form-urlencoded")
5862 .body(Body::from("url=%20%20&title=Nope"))
5864 .unwrap(),
5865 )
5866 .await
5867 .unwrap();
5868 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5869 assert_eq!(
5870 resp.headers()
5871 .get(header::LOCATION)
5872 .unwrap()
5873 .to_str()
5874 .unwrap(),
5875 "/",
5876 );
5877 assert_eq!(
5879 store::count_feeds(&state.db).await.unwrap(),
5880 0,
5881 "blank-URL rename wrote a junk feeds row"
5882 );
5883 }
5884
5885 #[test]
5891 fn manage_rename_row_preselects_current_folder() {
5892 let nav = Nav {
5893 handle: "@reader.example".to_string(),
5894 avatar: "RE".to_string(),
5895 view: "unread".to_string(),
5896 scope_qs: String::new(),
5897 folders: Vec::new(),
5898 loose_feeds: Vec::new(),
5899 manage_active: true,
5900 };
5901 let folder_options = vec![
5902 FolderOption {
5903 uri: "at://did:plc:x/app.folder/work".to_string(),
5904 name: "Work".to_string(),
5905 },
5906 FolderOption {
5907 uri: "at://did:plc:x/app.folder/fun".to_string(),
5908 name: "Fun".to_string(),
5909 },
5910 ];
5911 let foldered = FeedView {
5914 rkey: "sub-foldered".to_string(),
5915 url: "https://work.example/feed.xml".to_string(),
5916 title: "Work Feed".to_string(),
5917 unread: 0,
5918 selected: false,
5919 folder: Some("at://did:plc:x/app.folder/work".to_string()),
5920 };
5921 let loose = FeedView {
5922 rkey: "sub-loose".to_string(),
5923 url: "https://loose.example/feed.xml".to_string(),
5924 title: "Loose Feed".to_string(),
5925 unread: 0,
5926 selected: false,
5927 folder: None,
5928 };
5929 let tmpl = ManageTemplate {
5930 version: VERSION,
5931 repo_url: REPO_URL,
5932 kofi_url: KOFI_URL,
5933 flash: String::new(),
5934 nav,
5935 folder_options,
5936 folders: vec![FolderView {
5937 rkey: "folder-work".to_string(),
5938 uri: "at://did:plc:x/app.folder/work".to_string(),
5939 name: "Work".to_string(),
5940 feeds: vec![foldered],
5941 selected: false,
5942 }],
5943 loose_feeds: vec![loose],
5944 };
5945 let html = tmpl.render().unwrap();
5946
5947 assert!(
5949 html.contains(
5950 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
5951 ),
5952 "foldered feed must pre-select its current folder: {html}"
5953 );
5954 assert!(
5957 html.contains(r#"<option value="" selected>No folder</option>"#),
5958 "loose feed must pre-select 'No folder': {html}"
5959 );
5960 }
5961}