1use std::collections::HashMap;
46use std::net::IpAddr;
47use std::sync::Mutex;
48use std::time::{Duration, Instant};
49
50use askama::Template;
51use axum::{
52 extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
53 http::{header, HeaderMap, StatusCode},
54 middleware::{self, Next},
55 response::{Html, IntoResponse, Redirect, Response},
56 routing::{get, post},
57 Form, Router,
58};
59use serde::Deserialize;
60use std::net::SocketAddr;
61use tower_http::services::ServeDir;
62use tower_http::set_header::SetResponseHeaderLayer;
63use tower_http::trace::TraceLayer;
64use tracing::{info, warn};
65
66use crate::config::Config;
67use crate::lexicon::{self, Folder, Saved, Subscription};
68use crate::{feed, store, AppState, Session, VERSION};
69
70#[path = "opml.rs"]
75mod opml;
76
77const SESSION_COOKIE: &str = "fr_session";
79
80const INVITE_COOKIE: &str = "fr_invite";
88
89const INVITE_TTL_SECS: i64 = 1800;
92
93const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
96
97const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
99
100const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
102
103const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
119 script-src 'self'; \
120 style-src 'self' 'unsafe-inline'; \
121 img-src 'self' https: data:; \
122 font-src 'self'; \
123 connect-src 'self'; \
124 form-action 'self'; \
125 base-uri 'self'; \
126 frame-ancestors 'none'; \
127 object-src 'none'";
128
129#[derive(Clone, Debug)]
136struct CurrentUser {
137 did: String,
138 handle: Option<String>,
139 sid: Option<String>,
142}
143
144async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
155 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
156 if let Some(session) = state.sessions.get(&sid) {
157 if store::has_beta_access(&state.db, &session.did)
158 .await
159 .unwrap_or(false)
160 {
161 return Some(CurrentUser {
162 did: session.did,
163 handle: session.handle,
164 sid: Some(sid),
165 });
166 }
167 state.sessions.remove(&sid);
170 }
171 }
172 if let Some(did) = state.config.dev_did.clone() {
175 if store::has_beta_access(&state.db, &did)
176 .await
177 .unwrap_or(false)
178 {
179 return Some(CurrentUser {
180 did,
181 handle: None,
182 sid: None,
183 });
184 }
185 }
186 None
187}
188
189async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
191 current_session(state, headers).await.map(|u| u.did)
192}
193
194pub fn router(state: AppState) -> Router {
200 let limiter = RateLimiter::shared();
204 let rl_state = RateLimitState {
208 limiter,
209 trusted_header: state.config.trusted_ip_header.clone(),
210 };
211
212 Router::new()
213 .route("/health", get(health))
214 .route("/about", get(about))
215 .route("/manage", get(manage))
216 .route("/", get(index))
217 .route("/entries/{id}", get(entry_view))
218 .route("/entries/{id}/read", post(mark_read))
219 .route("/entries/{id}/star", post(toggle_star))
220 .route("/read-all", post(mark_all_read))
221 .route("/subscriptions", post(add_subscription))
222 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
223 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
224 .route("/folders", post(create_folder))
225 .route("/folders/{rkey}/rename", post(rename_folder))
226 .route("/folders/{rkey}/delete", post(delete_folder))
227 .route(
230 "/opml",
231 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
232 )
233 .route("/opml/export", get(export_opml))
234 .route("/login", get(login_form).post(login_submit))
235 .route(
236 "/beta/redeem",
237 get(beta_redeem_form).post(beta_redeem_submit),
238 )
239 .route("/admin/invites", post(admin_mint_invites))
240 .route("/account/delete", post(account_delete))
241 .route("/oauth/callback", get(oauth_callback))
242 .route("/logout", post(logout))
243 .nest_service("/static", ServeDir::new("static"))
244 .layer(middleware::from_fn(cache_control))
249 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
252 .layer(TraceLayer::new_for_http())
253 .layer(static_header_layer(
257 "content-security-policy",
258 CONTENT_SECURITY_POLICY,
259 ))
260 .layer(static_header_layer("x-content-type-options", "nosniff"))
261 .layer(static_header_layer(
262 "referrer-policy",
263 "strict-origin-when-cross-origin",
264 ))
265 .layer(static_header_layer("x-frame-options", "DENY"))
266 .with_state(state)
267}
268
269const OPML_BODY_LIMIT: usize = 2 * 1024 * 1024;
272
273fn static_header_layer(
277 name: &'static str,
278 value: &'static str,
279) -> SetResponseHeaderLayer<header::HeaderValue> {
280 SetResponseHeaderLayer::overriding(
281 header::HeaderName::from_static(name),
282 header::HeaderValue::from_static(value),
283 )
284}
285
286fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
294 use axum::http::Method;
295 if method != Method::POST && !(method == Method::GET && path == "/login") {
296 return false;
297 }
298 match path {
299 "/login" | "/beta/redeem" | "/subscriptions" | "/opml" | "/read-all" | "/admin/invites"
300 | "/account/delete" | "/folders" => true,
301 p => {
304 (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
305 || p.starts_with("/subscriptions/")
306 || p.starts_with("/folders/")
307 }
308 }
309}
310
311#[derive(Clone)]
314struct RateLimitState {
315 limiter: RateLimiter,
316 trusted_header: Option<String>,
319}
320
321#[derive(Clone)]
326struct RateLimiter {
327 inner: std::sync::Arc<Mutex<HashMap<IpAddr, Bucket>>>,
328}
329
330struct Bucket {
332 tokens: f64,
333 last: Instant,
334}
335
336const RATE_BURST: f64 = 20.0;
338const RATE_REFILL_PER_SEC: f64 = 1.0;
340const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
342
343impl RateLimiter {
344 fn shared() -> Self {
346 Self {
347 inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
348 }
349 }
350
351 fn check(&self, ip: IpAddr) -> bool {
354 let now = Instant::now();
355 let mut map = match self.inner.lock() {
356 Ok(m) => m,
357 Err(p) => p.into_inner(),
359 };
360 map.retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
362
363 let bucket = map.entry(ip).or_insert(Bucket {
364 tokens: RATE_BURST,
365 last: now,
366 });
367 let elapsed = now.duration_since(bucket.last).as_secs_f64();
368 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
369 bucket.last = now;
370 if bucket.tokens >= 1.0 {
371 bucket.tokens -= 1.0;
372 true
373 } else {
374 false
375 }
376 }
377}
378
379fn client_ip(
400 headers: &HeaderMap,
401 conn: Option<&SocketAddr>,
402 trusted_header: Option<&str>,
403) -> Option<IpAddr> {
404 if let Some(name) = trusted_header {
405 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
406 if let Some(last) = raw.split(',').next_back() {
409 if let Ok(ip) = last.trim().parse::<IpAddr>() {
410 return Some(ip);
411 }
412 }
413 }
414 }
416 conn.map(|s| s.ip())
417}
418
419async fn rate_limit(
424 State(rl): State<RateLimitState>,
425 req: axum::extract::Request,
426 next: Next,
427) -> Response {
428 let path = req.uri().path().to_string();
429 let method = req.method().clone();
430 if is_rate_limited_path(&path, &method) {
431 let conn = req
432 .extensions()
433 .get::<ConnectInfo<SocketAddr>>()
434 .map(|c| c.0);
435 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
436 if let Some(ip) = ip {
442 if !rl.limiter.check(ip) {
443 warn!(%ip, %path, "rate limit exceeded");
444 return (
445 StatusCode::TOO_MANY_REQUESTS,
446 [(header::RETRY_AFTER, "1")],
447 "rate limit exceeded\n",
448 )
449 .into_response();
450 }
451 }
452 }
453 next.run(req).await
454}
455
456async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
466 let path = req.uri().path().to_string();
467 let is_login_landing = path == "/login"
470 && req.method() == axum::http::Method::GET
471 && !req.uri().query().unwrap_or("").contains("handle=");
472 let public = is_login_landing || path == "/about" || path.starts_with("/static/");
473
474 let mut resp = next.run(req).await;
475 if resp.headers().contains_key(header::CACHE_CONTROL) {
476 return resp;
477 }
478 let value = if public {
479 "public, max-age=300"
480 } else {
481 "no-store"
482 };
483 if let Ok(hv) = header::HeaderValue::from_str(value) {
484 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
485 }
486 resp
487}
488
489async fn health() -> impl IntoResponse {
495 (StatusCode::OK, format!("ok featherreader/{VERSION}\n"))
496}
497
498async fn about() -> Response {
502 render(&AboutTemplate {
503 version: VERSION,
504 repo_url: REPO_URL,
505 kofi_url: KOFI_URL,
506 })
507}
508
509struct FeedView {
516 rkey: String,
518 url: String,
520 title: String,
521 unread: i64,
522 selected: bool,
524 folder: Option<String>,
529}
530
531struct FolderView {
533 rkey: String,
535 uri: String,
537 name: String,
538 feeds: Vec<FeedView>,
539 selected: bool,
541}
542
543struct EntryRow {
545 id: i64,
546 title: String,
547 feed_title: String,
548 published: String,
549 read: bool,
550 starred: bool,
551 link: String,
554}
555
556struct FolderOption {
558 uri: String,
559 name: String,
560}
561
562struct Nav {
567 handle: String,
569 avatar: String,
571 view: String,
573 scope_qs: String,
576 folders: Vec<FolderView>,
579 loose_feeds: Vec<FeedView>,
580 manage_active: bool,
582}
583
584#[derive(Template)]
586#[template(path = "index.html")]
587struct IndexTemplate {
588 version: &'static str,
589 repo_url: &'static str,
590 kofi_url: &'static str,
591 flash: String,
592 nav: Nav,
594 entries: Vec<EntryRow>,
596 heading: String,
598 feed_scope: Option<String>,
600}
601
602#[derive(Template)]
604#[template(path = "manage.html")]
605struct ManageTemplate {
606 version: &'static str,
607 repo_url: &'static str,
608 kofi_url: &'static str,
609 flash: String,
610 nav: Nav,
611 folder_options: Vec<FolderOption>,
613 folders: Vec<FolderView>,
615 loose_feeds: Vec<FeedView>,
616}
617
618#[derive(Template)]
620#[template(path = "about.html")]
621struct AboutTemplate {
622 version: &'static str,
623 repo_url: &'static str,
624 kofi_url: &'static str,
625}
626
627#[derive(Template)]
630#[template(path = "landing.html")]
631struct LandingTemplate {
632 version: &'static str,
633 repo_url: &'static str,
634 crates_url: &'static str,
635 kofi_url: &'static str,
636}
637
638#[derive(Template)]
640#[template(path = "entry.html")]
641struct EntryTemplate {
642 version: &'static str,
643 repo_url: &'static str,
644 kofi_url: &'static str,
645 nav: Nav,
646 id: i64,
647 title: String,
648 feed_title: String,
649 author: Option<String>,
650 published: String,
651 url: Option<String>,
652 content_html: Option<String>,
653 read: bool,
654 starred: bool,
655 back_qs: String,
657 prev_id: Option<i64>,
659 next_id: Option<i64>,
660 oob: bool,
662}
663
664#[derive(Template)]
666#[template(path = "entry_row.html")]
667struct EntryRowTemplate {
668 e: EntryRow,
669}
670
671#[derive(Template)]
676#[template(path = "entry_actionbar.html")]
677struct EntryActionBarTemplate {
678 id: i64,
679 read: bool,
680 starred: bool,
681 oob: bool,
683}
684
685#[derive(Template)]
687#[template(path = "login.html")]
688struct LoginTemplate {
689 repo_url: &'static str,
690 error: String,
691 flash: String,
694}
695
696#[derive(Template)]
698#[template(path = "beta_redeem.html")]
699struct BetaRedeemTemplate {
700 repo_url: &'static str,
701 error: String,
702 capacity_full: bool,
705}
706
707fn render<T: Template>(tmpl: &T) -> Response {
714 match tmpl.render() {
715 Ok(body) => Html(body).into_response(),
716 Err(err) => {
717 warn!(%err, "template render failed");
718 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
719 }
720 }
721}
722
723struct WebError {
728 err: anyhow::Error,
729 status: StatusCode,
730}
731
732impl<E: Into<anyhow::Error>> From<E> for WebError {
733 fn from(err: E) -> Self {
734 WebError {
735 err: err.into(),
736 status: StatusCode::INTERNAL_SERVER_ERROR,
737 }
738 }
739}
740
741impl WebError {
742 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
744 WebError {
745 err: err.into(),
746 status,
747 }
748 }
749}
750
751impl IntoResponse for WebError {
752 fn into_response(self) -> Response {
753 warn!(error = %self.err, status = %self.status, "request failed");
754 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
755 "internal error"
756 } else {
757 self.status.canonical_reason().unwrap_or("error")
758 };
759 (self.status, body).into_response()
760 }
761}
762
763fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
768 let status = err.status();
769 WebError::with_status(err, status)
770}
771
772fn display_title(title: Option<&str>, url: &str) -> String {
775 if let Some(t) = title {
776 let t = t.trim();
777 if !t.is_empty() {
778 return t.to_string();
779 }
780 }
781 url::Url::parse(url)
782 .ok()
783 .and_then(|u| u.host_str().map(str::to_string))
784 .unwrap_or_else(|| url.to_string())
785}
786
787fn display_handle(handle: Option<&str>, did: &str) -> String {
790 match handle {
791 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
792 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
793 }
794}
795
796fn avatar_initials(handle: Option<&str>, did: &str) -> String {
798 let source = handle
799 .map(|h| h.trim().trim_start_matches('@'))
800 .filter(|h| !h.is_empty())
801 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
802 let letters: String = source
803 .chars()
804 .filter(|c| c.is_alphanumeric())
805 .take(2)
806 .collect::<String>()
807 .to_lowercase();
808 if letters.is_empty() {
809 "fr".to_string()
810 } else {
811 letters
812 }
813}
814
815fn display_date(published: Option<&str>) -> String {
818 match published {
819 Some(p) if p.len() >= 10 => p[..10].to_string(),
820 Some(p) => p.to_string(),
821 None => String::new(),
822 }
823}
824
825fn qenc(s: &str) -> String {
829 let mut out = String::with_capacity(s.len() * 3);
830 for b in s.bytes() {
831 match b {
832 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
833 out.push(b as char)
834 }
835 _ => out.push_str(&format!("%{b:02X}")),
836 }
837 }
838 out
839}
840
841#[derive(Debug, Deserialize, Default)]
847struct IndexQuery {
848 #[serde(default)]
850 feed: Option<String>,
851 #[serde(default)]
853 folder: Option<String>,
854 #[serde(default)]
856 view: Option<String>,
857 #[serde(default)]
859 flash: Option<String>,
860}
861
862struct ResolvedSub {
865 rkey: String,
866 sub: Subscription,
867 feed: Option<store::Feed>,
868}
869
870async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
874 let pool = &state.db;
875 let subs = match state.sidecar.list_subscriptions_sorted(did).await {
876 Ok(s) => s,
877 Err(err) => {
878 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
879 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_default();
886 return feeds
887 .into_iter()
888 .map(|f| ResolvedSub {
889 rkey: String::new(),
890 sub: Subscription::new(f.url.clone(), now_rfc3339()),
891 feed: Some(f),
892 })
893 .collect();
894 }
895 };
896
897 let mut out = Vec::with_capacity(subs.len());
898 for (rkey, sub) in subs {
899 let feed = match store::get_feed_by_url(pool, &sub.url).await {
900 Ok(Some(f)) => Some(f),
901 Ok(None) => {
902 let _ = store::upsert_feed(
904 pool,
905 &store::NewFeed {
906 url: sub.url.clone(),
907 title: sub.title.clone(),
908 site_url: sub.site_url.clone(),
909 ..Default::default()
910 },
911 )
912 .await;
913 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
914 }
915 Err(err) => {
916 warn!(%err, url = %sub.url, "get_feed_by_url failed");
917 None
918 }
919 };
920 out.push(ResolvedSub { rkey, sub, feed });
921 }
922 sync_sub_refs(pool, did, &out).await;
926 out
927}
928
929async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
933 let feed_ids: Vec<i64> = subs
934 .iter()
935 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
936 .collect();
937 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
938 warn!(%err, %did, "failed to sync sub_ref projection");
939 }
940}
941
942async fn index(
945 State(state): State<AppState>,
946 headers: HeaderMap,
947 Query(q): Query<IndexQuery>,
948) -> Result<Response, WebError> {
949 let user = match current_session(&state, &headers).await {
950 Some(u) => u,
951 None => {
954 return Ok(render(&LandingTemplate {
955 version: VERSION,
956 repo_url: REPO_URL,
957 crates_url: CRATES_URL,
958 kofi_url: KOFI_URL,
959 }))
960 }
961 };
962 let did = user.did.clone();
963 let pool = &state.db;
964
965 let subs = resolve_subscriptions(&state, &did).await;
966
967 let unread = store::get_unread_for_did(pool, &did).await?;
969 let starred = store::get_starred_for_did(pool, &did).await?;
970 let starred_ids: std::collections::HashSet<i64> = starred.iter().map(|e| e.id).collect();
971
972 let view = match q.view.as_deref() {
974 Some("all") => "all",
975 Some("starred") => "starred",
976 _ => "unread",
977 }
978 .to_string();
979
980 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
982
983 let feed_url_by_id = |id: i64| -> Option<String> {
985 subs.iter()
986 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
987 .map(|s| s.sub.url.clone())
988 };
989 let feed_title_by_id = |id: i64| -> String {
990 subs.iter()
991 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
992 .map(|s| {
993 display_title(
994 s.sub
995 .title
996 .as_deref()
997 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
998 &s.sub.url,
999 )
1000 })
1001 .unwrap_or_default()
1002 };
1003
1004 let in_scope = |feed_id: i64| -> bool {
1005 match &scope_urls {
1006 None => true,
1007 Some(urls) => feed_url_by_id(feed_id)
1008 .map(|u| urls.contains(&u))
1009 .unwrap_or(false),
1010 }
1011 };
1012
1013 let source = match view.as_str() {
1015 "all" => {
1016 let mut all = Vec::new();
1018 for s in &subs {
1019 if let Some(f) = &s.feed {
1020 if in_scope(f.id) {
1021 let mut es = store::entries_for_feed(pool, &did, f.id)
1022 .await
1023 .unwrap_or_default();
1024 all.append(&mut es);
1025 }
1026 }
1027 }
1028 all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1029 all
1030 }
1031 "starred" => starred
1032 .iter()
1033 .filter(|e| in_scope(e.feed_id))
1034 .cloned()
1035 .collect(),
1036 _ => unread
1037 .iter()
1038 .filter(|e| in_scope(e.feed_id))
1039 .cloned()
1040 .collect(),
1041 };
1042
1043 let entry_scope_qs = {
1045 let mut parts = Vec::new();
1046 if let Some(f) = q.feed.as_deref() {
1047 parts.push(format!("feed={}", qenc(f)));
1048 }
1049 if let Some(f) = q.folder.as_deref() {
1050 parts.push(format!("folder={}", qenc(f)));
1051 }
1052 if view != "unread" {
1053 parts.push(format!("view={}", qenc(&view)));
1054 }
1055 parts.join("&")
1056 };
1057 let entry_link = |id: i64| -> String {
1058 if entry_scope_qs.is_empty() {
1059 format!("/entries/{id}")
1060 } else {
1061 format!("/entries/{id}?{entry_scope_qs}")
1062 }
1063 };
1064
1065 let entries: Vec<EntryRow> = source
1066 .iter()
1067 .map(|e| EntryRow {
1068 id: e.id,
1069 title: e
1070 .title
1071 .clone()
1072 .filter(|t| !t.trim().is_empty())
1073 .unwrap_or_else(|| "(untitled)".to_string()),
1074 feed_title: feed_title_by_id(e.feed_id),
1075 published: display_date(e.published.as_deref()),
1076 read: view != "unread" && !unread.iter().any(|u| u.id == e.id),
1077 starred: starred_ids.contains(&e.id),
1078 link: entry_link(e.id),
1079 })
1080 .collect();
1081
1082 let selected_feed = q.feed.as_deref();
1083 let selected_folder = q.folder.as_deref();
1084
1085 let (folder_views, loose_feeds, _folder_options) =
1087 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
1088
1089 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
1091 let name = subs
1092 .iter()
1093 .find(|s| s.sub.url == feed_url)
1094 .map(|s| {
1095 display_title(
1096 s.sub
1097 .title
1098 .as_deref()
1099 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1100 &s.sub.url,
1101 )
1102 })
1103 .unwrap_or_else(|| display_title(None, feed_url));
1104 (name, format!("feed={}", qenc(feed_url)))
1105 } else if let Some(folder_uri) = selected_folder {
1106 let name = folder_views
1107 .iter()
1108 .find(|f| f.uri == folder_uri)
1109 .map(|f| f.name.clone())
1110 .unwrap_or_else(|| "Folder".to_string());
1111 (name, format!("folder={}", qenc(folder_uri)))
1112 } else {
1113 let h = match view.as_str() {
1114 "all" => "All",
1115 "starred" => "Starred",
1116 _ => "Unread",
1117 };
1118 (h.to_string(), String::new())
1119 };
1120
1121 let feed_scope = selected_feed.map(str::to_string);
1122 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
1123
1124 let tmpl = IndexTemplate {
1125 version: VERSION,
1126 repo_url: REPO_URL,
1127 kofi_url: KOFI_URL,
1128 flash: q.flash.unwrap_or_default(),
1129 nav,
1130 entries,
1131 heading,
1132 feed_scope,
1133 };
1134 Ok(render(&tmpl))
1135}
1136
1137#[derive(Debug, Deserialize, Default)]
1139struct ManageQuery {
1140 #[serde(default)]
1141 flash: Option<String>,
1142}
1143
1144async fn manage(
1149 State(state): State<AppState>,
1150 headers: HeaderMap,
1151 Query(q): Query<ManageQuery>,
1152) -> Result<Response, WebError> {
1153 let user = match current_session(&state, &headers).await {
1154 Some(u) => u,
1155 None => return Ok(Redirect::to("/login").into_response()),
1156 };
1157 let did = user.did.clone();
1158
1159 let subs = resolve_subscriptions(&state, &did).await;
1160 let (folder_views, loose_feeds, folder_options) =
1161 build_sidebar(&state, &did, &subs, None, None).await;
1162
1163 let nav = build_nav(
1165 &user,
1166 "unread",
1167 String::new(),
1168 folder_views.iter().map(clone_folder_view).collect(),
1169 loose_feeds.iter().map(clone_feed_view).collect(),
1170 true,
1171 );
1172
1173 let tmpl = ManageTemplate {
1174 version: VERSION,
1175 repo_url: REPO_URL,
1176 kofi_url: KOFI_URL,
1177 flash: q.flash.unwrap_or_default(),
1178 nav,
1179 folder_options,
1180 folders: folder_views,
1181 loose_feeds,
1182 };
1183 Ok(render(&tmpl))
1184}
1185
1186fn clone_feed_view(f: &FeedView) -> FeedView {
1189 FeedView {
1190 rkey: f.rkey.clone(),
1191 url: f.url.clone(),
1192 title: f.title.clone(),
1193 unread: f.unread,
1194 selected: f.selected,
1195 folder: f.folder.clone(),
1196 }
1197}
1198
1199fn clone_folder_view(f: &FolderView) -> FolderView {
1200 FolderView {
1201 rkey: f.rkey.clone(),
1202 uri: f.uri.clone(),
1203 name: f.name.clone(),
1204 feeds: f.feeds.iter().map(clone_feed_view).collect(),
1205 selected: f.selected,
1206 }
1207}
1208
1209fn scope_urls_for(
1214 subs: &[ResolvedSub],
1215 feed: Option<&str>,
1216 folder: Option<&str>,
1217) -> Option<Vec<String>> {
1218 if let Some(feed_url) = feed {
1219 Some(vec![feed_url.to_string()])
1220 } else {
1221 folder.map(|folder_uri| {
1222 subs.iter()
1223 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
1224 .map(|s| s.sub.url.clone())
1225 .collect()
1226 })
1227 }
1228}
1229
1230fn folder_uri(did: &str, rkey: &str) -> String {
1232 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
1233}
1234
1235async fn build_sidebar(
1239 state: &AppState,
1240 did: &str,
1241 subs: &[ResolvedSub],
1242 selected_feed: Option<&str>,
1243 selected_folder: Option<&str>,
1244) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
1245 let pool = &state.db;
1246 let unread = store::get_unread_for_did(pool, did)
1247 .await
1248 .unwrap_or_default();
1249 let folders = state
1250 .sidecar
1251 .list_folders_sorted(did)
1252 .await
1253 .unwrap_or_default();
1254
1255 let unread_count = |feed_id: Option<i64>| -> i64 {
1256 match feed_id {
1257 Some(id) => unread.iter().filter(|e| e.feed_id == id).count() as i64,
1258 None => 0,
1259 }
1260 };
1261 let mk_feed_view = |s: &ResolvedSub| FeedView {
1262 rkey: s.rkey.clone(),
1263 url: s.sub.url.clone(),
1264 title: display_title(
1265 s.sub
1266 .title
1267 .as_deref()
1268 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1269 &s.sub.url,
1270 ),
1271 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
1272 selected: selected_feed == Some(s.sub.url.as_str()),
1273 folder: s.sub.folder.clone(),
1274 };
1275
1276 let mut folder_views = Vec::with_capacity(folders.len());
1277 for (rkey, folder) in &folders {
1278 let uri = folder_uri(did, rkey);
1279 let feeds: Vec<FeedView> = subs
1280 .iter()
1281 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
1282 .map(mk_feed_view)
1283 .collect();
1284 folder_views.push(FolderView {
1285 rkey: rkey.clone(),
1286 uri: uri.clone(),
1287 name: folder.name.clone(),
1288 feeds,
1289 selected: selected_folder == Some(uri.as_str()),
1290 });
1291 }
1292
1293 let known_uris: std::collections::HashSet<String> =
1294 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
1295 let loose_feeds: Vec<FeedView> = subs
1296 .iter()
1297 .filter(|s| {
1298 s.sub
1299 .folder
1300 .as_deref()
1301 .map(|f| !known_uris.contains(f))
1302 .unwrap_or(true)
1303 })
1304 .map(mk_feed_view)
1305 .collect();
1306
1307 let folder_options: Vec<FolderOption> = folders
1308 .iter()
1309 .map(|(rkey, folder)| FolderOption {
1310 name: folder.name.clone(),
1311 uri: folder_uri(did, rkey),
1312 })
1313 .collect();
1314
1315 (folder_views, loose_feeds, folder_options)
1316}
1317
1318fn build_nav(
1320 user: &CurrentUser,
1321 view: &str,
1322 scope_qs: String,
1323 folders: Vec<FolderView>,
1324 loose_feeds: Vec<FeedView>,
1325 manage_active: bool,
1326) -> Nav {
1327 Nav {
1328 handle: display_handle(user.handle.as_deref(), &user.did),
1329 avatar: avatar_initials(user.handle.as_deref(), &user.did),
1330 view: view.to_string(),
1331 scope_qs,
1332 folders,
1333 loose_feeds,
1334 manage_active,
1335 }
1336}
1337
1338#[derive(Debug, Deserialize, Default)]
1345struct EntryQuery {
1346 #[serde(default)]
1347 feed: Option<String>,
1348 #[serde(default)]
1349 folder: Option<String>,
1350 #[serde(default)]
1351 view: Option<String>,
1352}
1353
1354async fn entry_view(
1357 State(state): State<AppState>,
1358 headers: HeaderMap,
1359 Path(id): Path<i64>,
1360 Query(q): Query<EntryQuery>,
1361) -> Result<Response, WebError> {
1362 let user = match current_session(&state, &headers).await {
1363 Some(u) => u,
1364 None => return Ok(Redirect::to("/login").into_response()),
1365 };
1366 let did = user.did.clone();
1367 let pool = &state.db;
1368
1369 let subs = resolve_subscriptions(&state, &did).await;
1373
1374 let entry = match get_entry_by_id(pool, &did, id).await? {
1375 Some(e) => e,
1376 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1377 };
1378
1379 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
1380
1381 let read = entry_is_read(pool, &did, id).await?;
1382 let starred = entry_is_starred(pool, &did, id).await?;
1383
1384 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
1387
1388 let back_qs = scope_query(&q);
1389
1390 let (folder_views, loose_feeds, _) =
1391 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
1392 let nav_view = match q.view.as_deref() {
1393 Some("all") => "all",
1394 Some("starred") => "starred",
1395 _ => "unread",
1396 };
1397 let nav = build_nav(
1398 &user,
1399 nav_view,
1400 back_qs.clone(),
1401 folder_views,
1402 loose_feeds,
1403 false,
1404 );
1405
1406 let tmpl = EntryTemplate {
1407 version: VERSION,
1408 repo_url: REPO_URL,
1409 kofi_url: KOFI_URL,
1410 nav,
1411 id: entry.id,
1412 title: entry
1413 .title
1414 .clone()
1415 .filter(|t| !t.trim().is_empty())
1416 .unwrap_or_else(|| "(untitled)".to_string()),
1417 feed_title,
1418 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
1419 published: display_date(entry.published.as_deref()),
1420 url: entry.url.clone().filter(|u| !u.trim().is_empty()),
1421 content_html: entry.content_html.clone(),
1422 read,
1423 starred,
1424 back_qs,
1425 prev_id,
1426 next_id,
1427 oob: false,
1428 };
1429 Ok(render(&tmpl))
1430}
1431
1432async fn neighbors_in_scope(
1435 state: &AppState,
1436 did: &str,
1437 q: &EntryQuery,
1438 current: i64,
1439) -> (Option<i64>, Option<i64>) {
1440 let idx_q = IndexQuery {
1441 feed: q.feed.clone(),
1442 folder: q.folder.clone(),
1443 view: q.view.clone(),
1444 flash: None,
1445 };
1446 let ids = list_entry_ids(state, did, &idx_q).await;
1447 let pos = ids.iter().position(|&x| x == current);
1448 match pos {
1449 Some(p) => {
1450 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
1451 let next = ids.get(p + 1).copied();
1452 (prev, next)
1453 }
1454 None => (None, None),
1455 }
1456}
1457
1458async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
1461 let pool = &state.db;
1462 let subs = resolve_subscriptions(state, did).await;
1463
1464 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1465 let feed_url_by_id = |id: i64| -> Option<String> {
1466 subs.iter()
1467 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1468 .map(|s| s.sub.url.clone())
1469 };
1470 let in_scope = |feed_id: i64| -> bool {
1471 match &scope_urls {
1472 None => true,
1473 Some(urls) => feed_url_by_id(feed_id)
1474 .map(|u| urls.contains(&u))
1475 .unwrap_or(false),
1476 }
1477 };
1478
1479 let view = q.view.as_deref().unwrap_or("unread");
1480 let entries = match view {
1481 "all" => {
1482 let mut all = Vec::new();
1483 for s in &subs {
1484 if let Some(f) = &s.feed {
1485 if in_scope(f.id) {
1486 let mut es = store::entries_for_feed(pool, did, f.id)
1487 .await
1488 .unwrap_or_default();
1489 all.append(&mut es);
1490 }
1491 }
1492 }
1493 all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1494 all
1495 }
1496 "starred" => store::get_starred_for_did(pool, did)
1497 .await
1498 .unwrap_or_default()
1499 .into_iter()
1500 .filter(|e| in_scope(e.feed_id))
1501 .collect(),
1502 _ => store::get_unread_for_did(pool, did)
1503 .await
1504 .unwrap_or_default()
1505 .into_iter()
1506 .filter(|e| in_scope(e.feed_id))
1507 .collect(),
1508 };
1509 entries.into_iter().map(|e| e.id).collect()
1510}
1511
1512fn scope_query(q: &EntryQuery) -> String {
1514 let mut parts = Vec::new();
1515 if let Some(f) = q.feed.as_deref() {
1516 parts.push(format!("feed={}", qenc(f)));
1517 }
1518 if let Some(f) = q.folder.as_deref() {
1519 parts.push(format!("folder={}", qenc(f)));
1520 }
1521 if let Some(v) = q.view.as_deref() {
1522 if v != "unread" {
1523 parts.push(format!("view={}", qenc(v)));
1524 }
1525 }
1526 parts.join("&")
1527}
1528
1529#[derive(Debug, Deserialize)]
1535struct ReadForm {
1536 #[serde(default)]
1537 read: Option<String>,
1538}
1539
1540async fn mark_read(
1542 State(state): State<AppState>,
1543 Path(id): Path<i64>,
1544 headers: HeaderMap,
1545 Form(form): Form<ReadForm>,
1546) -> Result<Response, WebError> {
1547 let did = match current_did(&state, &headers).await {
1548 Some(d) => d,
1549 None => return Ok(Redirect::to("/login").into_response()),
1550 };
1551 let pool = &state.db;
1552
1553 let read = matches!(
1554 form.read.as_deref(),
1555 Some("true") | Some("1") | Some("on") | None
1556 );
1557
1558 resolve_subscriptions(&state, &did).await;
1563 if !store::mark_read(pool, &did, id, read).await? {
1564 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1565 }
1566
1567 if !is_htmx(&headers) {
1568 return Ok(Redirect::to("/").into_response());
1569 }
1570
1571 if is_reader_request(&headers) {
1575 let starred = entry_is_starred(pool, &did, id).await?;
1576 return Ok(render(&EntryActionBarTemplate {
1577 id,
1578 read,
1579 starred,
1580 oob: true,
1581 }));
1582 }
1583
1584 let row = build_entry_row(pool, &did, id, Some(read)).await?;
1585 match row {
1586 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1587 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1588 }
1589}
1590
1591#[derive(Debug, Deserialize)]
1597struct StarForm {
1598 #[serde(default)]
1599 starred: Option<String>,
1600}
1601
1602async fn toggle_star(
1608 State(state): State<AppState>,
1609 Path(id): Path<i64>,
1610 headers: HeaderMap,
1611 Form(form): Form<StarForm>,
1612) -> Result<Response, WebError> {
1613 let did = match current_did(&state, &headers).await {
1614 Some(d) => d,
1615 None => return Ok(Redirect::to("/login").into_response()),
1616 };
1617 let pool = &state.db;
1618
1619 let starred = matches!(
1620 form.starred.as_deref(),
1621 Some("true") | Some("1") | Some("on") | None
1622 );
1623
1624 resolve_subscriptions(&state, &did).await;
1628 if !store::mark_starred(pool, &did, id, starred).await? {
1629 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1630 }
1631
1632 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
1635 let entry_url = entry.url.clone().unwrap_or_default();
1636 if !entry_url.is_empty() {
1637 if starred {
1638 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
1639 saved.title = entry.title.clone();
1640 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
1641 saved.entry_id = Some(entry.guid.clone());
1642 match state.sidecar.add_saved(&did, &saved).await {
1643 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
1644 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
1645 }
1646 } else {
1647 match state.sidecar.list_saved(&did).await {
1649 Ok(records) => {
1650 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
1651 if let Err(err) = state.sidecar.remove_saved(&did, rkey).await {
1652 warn!(%err, %did, %rkey, "PDS saved delete failed");
1653 }
1654 }
1655 }
1656 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
1657 }
1658 }
1659 }
1660 }
1661
1662 if !is_htmx(&headers) {
1663 return Ok(Redirect::to("/").into_response());
1664 }
1665
1666 if is_reader_request(&headers) {
1668 let read = entry_is_read(pool, &did, id).await?;
1669 return Ok(render(&EntryActionBarTemplate {
1670 id,
1671 read,
1672 starred,
1673 oob: true,
1674 }));
1675 }
1676
1677 let row = build_entry_row(pool, &did, id, None).await?;
1678 match row {
1679 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1680 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1681 }
1682}
1683
1684async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
1686 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
1687 .bind(feed_id)
1688 .fetch_optional(pool)
1689 .await
1690 .ok()
1691 .flatten()
1692}
1693
1694#[derive(Debug, Deserialize, Default)]
1701struct ReadAllQuery {
1702 #[serde(default)]
1703 feed: Option<String>,
1704}
1705
1706async fn mark_all_read(
1709 State(state): State<AppState>,
1710 headers: HeaderMap,
1711 Query(q): Query<ReadAllQuery>,
1712) -> Result<Response, WebError> {
1713 let did = match current_did(&state, &headers).await {
1714 Some(d) => d,
1715 None => return Ok(Redirect::to("/login").into_response()),
1716 };
1717 let pool = &state.db;
1718
1719 resolve_subscriptions(&state, &did).await;
1722
1723 if let Some(feed_url) = q.feed.as_deref() {
1724 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
1725 store::mark_feed_read(pool, &did, feed.id, true).await?;
1726 }
1727 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
1728 }
1729
1730 for feed_id in store::subscribed_feed_ids(pool, &did).await? {
1735 store::mark_feed_read(pool, &did, feed_id, true).await?;
1736 }
1737 Ok(Redirect::to("/").into_response())
1738}
1739
1740const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
1750 FeatherReader stores your subscriptions in your public PDS, so it supports public \
1751 feeds for now — private-feed support arrives when atproto's private data \
1752 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
1753
1754#[derive(Debug, Deserialize)]
1756struct SubscribeForm {
1757 url: String,
1758 #[serde(default)]
1760 folder: Option<String>,
1761}
1762
1763async fn add_subscription(
1765 State(state): State<AppState>,
1766 headers: HeaderMap,
1767 Form(form): Form<SubscribeForm>,
1768) -> Result<Response, WebError> {
1769 let did = match current_did(&state, &headers).await {
1770 Some(d) => d,
1771 None => return Ok(Redirect::to("/login").into_response()),
1772 };
1773 let pool = &state.db;
1774 let input = form.url.trim().to_string();
1775 if input.is_empty() {
1776 return Ok(Redirect::to("/").into_response());
1777 }
1778
1779 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
1783 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
1784 return Ok(
1785 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1786 );
1787 }
1788
1789 let cap = state.config.max_subs_per_did;
1793 if cap > 0 {
1794 match store::count_subscriptions_for_did(pool, &did).await {
1795 Ok(n) if n >= cap => {
1796 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
1797 return Ok(Redirect::to(&format!(
1798 "/?flash={}",
1799 qenc(&format!(
1800 "Subscription limit reached ({cap}). Remove a feed before adding another."
1801 ))
1802 ))
1803 .into_response());
1804 }
1805 Ok(_) => {}
1806 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
1807 }
1808 }
1809
1810 let feed_url = match resolve_feed_url(&state.config, &input).await {
1811 Ok(u) => u,
1812 Err(err) => {
1813 warn!(%err, url = %input, "could not resolve a feed from the given URL");
1814 return Ok(Redirect::to(&format!(
1815 "/?flash={}",
1816 qenc("Couldn't find a feed at that URL")
1817 ))
1818 .into_response());
1819 }
1820 };
1821
1822 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1826 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
1827 return Ok(
1828 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1829 );
1830 }
1831
1832 let feeds_cap = state.config.max_feeds_global;
1836 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
1837 match store::count_feeds(pool).await {
1838 Ok(n) if n >= feeds_cap => {
1839 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
1840 return Ok(Redirect::to(&format!(
1841 "/?flash={}",
1842 qenc(
1843 "This instance is at its feed capacity right now. Please try again later."
1844 )
1845 ))
1846 .into_response());
1847 }
1848 Ok(_) => {}
1849 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1850 }
1851 }
1852
1853 store::upsert_feed(
1854 pool,
1855 &store::NewFeed {
1856 url: feed_url.clone(),
1857 ..Default::default()
1858 },
1859 )
1860 .await?;
1861
1862 if let Ok(client) = feed::build_client() {
1863 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
1864 match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
1865 {
1866 Ok(outcome) => info!(feed = %feed_url, ?outcome, "polled new subscription"),
1867 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
1868 }
1869 }
1870 }
1871
1872 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
1873 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
1874 sub.title = feed_row.title.clone();
1875 sub.site_url = feed_row.site_url.clone();
1876 }
1877 sub.folder = form
1878 .folder
1879 .map(|f| f.trim().to_string())
1880 .filter(|f| !f.is_empty());
1881
1882 match state.sidecar.add_subscription(&did, &sub).await {
1883 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
1884 Err(err) => {
1885 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
1886 }
1887 }
1888
1889 Ok(Redirect::to("/").into_response())
1890}
1891
1892async fn delete_subscription(
1894 State(state): State<AppState>,
1895 headers: HeaderMap,
1896 Path(rkey): Path<String>,
1897) -> Result<Response, WebError> {
1898 let did = match current_did(&state, &headers).await {
1899 Some(d) => d,
1900 None => return Ok(Redirect::to("/login").into_response()),
1901 };
1902 match state.sidecar.remove_subscription(&did, &rkey).await {
1903 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
1904 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
1905 }
1906 Ok(Redirect::to("/").into_response())
1907}
1908
1909#[derive(Debug, Deserialize)]
1911struct RenameSubForm {
1912 url: String,
1913 #[serde(default)]
1914 title: Option<String>,
1915 #[serde(default)]
1916 site_url: Option<String>,
1917 #[serde(default)]
1918 folder: Option<String>,
1919}
1920
1921async fn rename_subscription(
1924 State(state): State<AppState>,
1925 headers: HeaderMap,
1926 Path(rkey): Path<String>,
1927 Form(form): Form<RenameSubForm>,
1928) -> Result<Response, WebError> {
1929 let did = match current_did(&state, &headers).await {
1930 Some(d) => d,
1931 None => return Ok(Redirect::to("/login").into_response()),
1932 };
1933 let feed_url = form.url.trim().to_string();
1934
1935 if feed_url.is_empty() {
1939 return Ok(Redirect::to("/").into_response());
1940 }
1941
1942 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1948 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
1949 return Ok(
1950 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1951 );
1952 }
1953
1954 let feeds_cap = state.config.max_feeds_global;
1959 if feeds_cap > 0
1960 && store::get_feed_by_url(&state.db, &feed_url)
1961 .await?
1962 .is_none()
1963 {
1964 match store::count_feeds(&state.db).await {
1965 Ok(n) if n >= feeds_cap => {
1966 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
1967 return Ok(Redirect::to(&format!(
1968 "/?flash={}",
1969 qenc(
1970 "This instance is at its feed capacity right now. Please try again later."
1971 )
1972 ))
1973 .into_response());
1974 }
1975 Ok(_) => {}
1976 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1977 }
1978 }
1979
1980 let mut sub = Subscription::new(feed_url, now_rfc3339());
1981 sub.title = form
1982 .title
1983 .map(|t| t.trim().to_string())
1984 .filter(|t| !t.is_empty());
1985 sub.site_url = form
1986 .site_url
1987 .map(|t| t.trim().to_string())
1988 .filter(|t| !t.is_empty());
1989 sub.folder = form
1990 .folder
1991 .map(|f| f.trim().to_string())
1992 .filter(|f| !f.is_empty());
1993
1994 let _ = store::upsert_feed(
1996 &state.db,
1997 &store::NewFeed {
1998 url: sub.url.clone(),
1999 title: sub.title.clone(),
2000 site_url: sub.site_url.clone(),
2001 ..Default::default()
2002 },
2003 )
2004 .await;
2005
2006 match state.sidecar.update_subscription(&did, &rkey, &sub).await {
2007 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription"),
2008 Err(err) => warn!(%err, %did, %rkey, "PDS subscription update failed"),
2009 }
2010 Ok(Redirect::to("/").into_response())
2011}
2012
2013#[derive(Debug, Deserialize)]
2019struct FolderForm {
2020 name: String,
2021}
2022
2023async fn create_folder(
2025 State(state): State<AppState>,
2026 headers: HeaderMap,
2027 Form(form): Form<FolderForm>,
2028) -> Result<Response, WebError> {
2029 let did = match current_did(&state, &headers).await {
2030 Some(d) => d,
2031 None => return Ok(Redirect::to("/login").into_response()),
2032 };
2033 let name = form.name.trim();
2034 if name.is_empty() {
2035 return Ok(Redirect::to("/").into_response());
2036 }
2037 let folder = Folder::new(name.to_string(), now_rfc3339());
2038 match state.sidecar.add_folder(&did, &folder).await {
2039 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
2040 Err(err) => warn!(%err, %did, "PDS folder create failed"),
2041 }
2042 Ok(Redirect::to("/").into_response())
2043}
2044
2045async fn rename_folder(
2047 State(state): State<AppState>,
2048 headers: HeaderMap,
2049 Path(rkey): Path<String>,
2050 Form(form): Form<FolderForm>,
2051) -> Result<Response, WebError> {
2052 let did = match current_did(&state, &headers).await {
2053 Some(d) => d,
2054 None => return Ok(Redirect::to("/login").into_response()),
2055 };
2056 let name = form.name.trim();
2057 if name.is_empty() {
2058 return Ok(Redirect::to("/").into_response());
2059 }
2060 let folder = Folder::new(name.to_string(), now_rfc3339());
2061 match state.sidecar.rename_folder(&did, &rkey, &folder).await {
2062 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
2063 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
2064 }
2065 Ok(Redirect::to("/").into_response())
2066}
2067
2068async fn delete_folder(
2071 State(state): State<AppState>,
2072 headers: HeaderMap,
2073 Path(rkey): Path<String>,
2074) -> Result<Response, WebError> {
2075 let did = match current_did(&state, &headers).await {
2076 Some(d) => d,
2077 None => return Ok(Redirect::to("/login").into_response()),
2078 };
2079 match state.sidecar.remove_folder(&did, &rkey).await {
2080 Ok(()) => info!(%did, %rkey, "deleted folder record"),
2081 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
2082 }
2083 Ok(Redirect::to("/").into_response())
2084}
2085
2086async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
2090 let parsed =
2091 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
2092
2093 let client = feed::build_client()?;
2094 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
2098 let final_url = resp.url().clone();
2099 let content_type = resp
2100 .headers()
2101 .get(axum::http::header::CONTENT_TYPE)
2102 .and_then(|v| v.to_str().ok())
2103 .unwrap_or("")
2104 .to_ascii_lowercase();
2105 let raw = crate::net::read_capped(resp).await?;
2108 let body = String::from_utf8_lossy(&raw).into_owned();
2109
2110 let looks_like_feed = content_type.contains("xml")
2111 || content_type.contains("rss")
2112 || content_type.contains("atom")
2113 || content_type.contains("application/feed+json")
2114 || {
2115 let head = body.trim_start();
2116 head.starts_with("<?xml")
2117 || head.starts_with("<rss")
2118 || head.starts_with("<feed")
2119 || head.contains("<rss")
2120 || head.contains("<feed")
2121 };
2122 if looks_like_feed {
2123 return Ok(final_url.to_string());
2124 }
2125
2126 match feed::discover_feed(&body, Some(&final_url)) {
2127 Some(u) => Ok(u.to_string()),
2128 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
2129 }
2130}
2131
2132#[derive(Debug, Deserialize, Default)]
2138struct LoginQuery {
2139 #[serde(default)]
2140 handle: Option<String>,
2141 #[serde(default)]
2142 error: Option<String>,
2143 #[serde(default)]
2144 flash: Option<String>,
2145}
2146
2147async fn login_form(
2155 State(state): State<AppState>,
2156 headers: HeaderMap,
2157 Query(q): Query<LoginQuery>,
2158) -> Response {
2159 if let Some(handle) = q
2160 .handle
2161 .map(|h| h.trim().to_string())
2162 .filter(|h| !h.is_empty())
2163 {
2164 if !may_start_oauth(&state, &headers, &handle).await {
2165 return Redirect::to("/beta/redeem").into_response();
2166 }
2167 return start_oauth(&state, &handle);
2168 }
2169 render(&LoginTemplate {
2170 repo_url: REPO_URL,
2171 error: q.error.unwrap_or_default(),
2172 flash: q.flash.unwrap_or_default(),
2173 })
2174}
2175
2176async fn login_submit(
2179 State(state): State<AppState>,
2180 headers: HeaderMap,
2181 Form(form): Form<LoginForm>,
2182) -> Response {
2183 let handle = form.handle.trim();
2184 if handle.is_empty() {
2185 return login_error("Enter your atproto handle.");
2186 }
2187 if !may_start_oauth(&state, &headers, handle).await {
2188 return Redirect::to("/beta/redeem").into_response();
2189 }
2190 start_oauth(&state, handle)
2191}
2192
2193async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
2211 may_start_oauth_with(state, headers, handle, |h| async move {
2215 crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
2216 .await
2217 .ok()
2218 })
2219 .await
2220}
2221
2222async fn may_start_oauth_with<F, Fut>(
2228 state: &AppState,
2229 headers: &HeaderMap,
2230 handle: &str,
2231 resolve: F,
2232) -> bool
2233where
2234 F: FnOnce(String) -> Fut,
2235 Fut: std::future::Future<Output = Option<String>>,
2236{
2237 if let Some(did) = current_did(state, headers).await {
2239 if store::has_beta_access(&state.db, &did)
2240 .await
2241 .unwrap_or(false)
2242 {
2243 return true;
2244 }
2245 }
2246 if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
2248 return true;
2249 }
2250 match resolve(handle.to_string()).await {
2254 Some(did) => store::has_beta_access(&state.db, &did)
2255 .await
2256 .unwrap_or(false),
2257 None => {
2258 warn!(%handle, "handle resolution failed in pre-handshake beta gate");
2259 false
2260 }
2261 }
2262}
2263
2264fn start_oauth(state: &AppState, handle: &str) -> Response {
2266 let url = state.sidecar.login_url(handle, None);
2267 info!(%handle, "redirecting to OAuth sidecar login");
2268 Redirect::to(&url).into_response()
2269}
2270
2271#[derive(Debug, Deserialize)]
2273struct LoginForm {
2274 handle: String,
2275}
2276
2277#[derive(Debug, Deserialize, Default)]
2279struct CallbackQuery {
2280 #[serde(default)]
2281 session_id: Option<String>,
2282 #[serde(default)]
2283 error: Option<String>,
2284 #[serde(default)]
2285 error_description: Option<String>,
2286}
2287
2288async fn oauth_callback(
2295 State(state): State<AppState>,
2296 headers: HeaderMap,
2297 Query(q): Query<CallbackQuery>,
2298) -> Response {
2299 if let Some(err) = q.error {
2300 let desc = q.error_description.unwrap_or_default();
2301 warn!(error = %err, desc = %desc, "OAuth callback returned an error");
2302 return login_error(&format!("Login failed: {err}"));
2303 }
2304
2305 let session_id = match q.session_id {
2306 Some(s) if !s.is_empty() => s,
2307 _ => return login_error("Login failed: the callback carried no session."),
2308 };
2309
2310 let session = match state.sidecar.resolve_session(&session_id).await {
2311 Ok(Some(s)) => s,
2312 Ok(None) => {
2313 warn!("OAuth callback session_id did not resolve (expired/unknown)");
2314 return login_error("Login session expired — please try again.");
2315 }
2316 Err(err) => {
2317 warn!(%err, "failed to resolve OAuth session via the sidecar");
2318 return login_error("Login failed talking to the auth service.");
2319 }
2320 };
2321
2322 let mut clear_invite = false;
2325 if !store::has_beta_access(&state.db, &session.did)
2326 .await
2327 .unwrap_or(false)
2328 {
2329 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
2331 Some(c) => c,
2332 None => {
2333 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
2334 return Redirect::to("/beta/redeem").into_response();
2335 }
2336 };
2337 match store::redeem_code(
2338 &state.db,
2339 &code,
2340 &session.did,
2341 session.handle.as_deref(),
2342 state.config.beta_cap,
2343 )
2344 .await
2345 {
2346 Ok(Ok(())) => {
2347 clear_invite = true;
2348 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
2349 }
2350 Ok(Err(policy)) => {
2351 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
2352 let mut resp = redeem_bounce(&policy).into_response();
2353 clear_invite_cookie(&mut resp);
2355 return resp;
2356 }
2357 Err(err) => {
2358 warn!(%err, did = %session.did, "invite redeem infra error at callback");
2359 return login_error("Login failed while confirming your invite.");
2360 }
2361 }
2362 }
2363
2364 let sid = state.sessions.create(Session {
2367 did: session.did.clone(),
2368 handle: session.handle.clone(),
2369 });
2370 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
2371 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
2372
2373 let mut resp = Redirect::to("/").into_response();
2374 set_cookie(&mut resp, &cookie);
2375 if clear_invite {
2376 clear_invite_cookie(&mut resp);
2377 }
2378 resp
2379}
2380
2381async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
2391 if let Some(user) = current_session(&state, &headers).await {
2392 if let Some(sid) = user.sid {
2395 state.sessions.remove(&sid);
2396 match state.sidecar.revoke_session(&user.did).await {
2397 Ok(res) => {
2398 info!(did = %user.did, revoked = res.revoked, "logout: sidecar session revoked");
2399 }
2400 Err(err) => {
2401 warn!(did = %user.did, %err, "logout: sidecar revoke failed; clearing cookie anyway");
2402 }
2403 }
2404 }
2405 }
2406 let mut resp = Redirect::to("/login").into_response();
2407 set_cookie(
2408 &mut resp,
2409 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2410 );
2411 resp
2412}
2413
2414#[derive(Debug, Deserialize)]
2417struct DeleteAccountForm {
2418 #[serde(default)]
2419 confirm: String,
2420}
2421
2422const DELETE_CONFIRM_PHRASE: &str = "DELETE";
2424
2425async fn account_delete(
2440 State(state): State<AppState>,
2441 headers: HeaderMap,
2442 Form(form): Form<DeleteAccountForm>,
2443) -> Result<Response, WebError> {
2444 let user = match current_session(&state, &headers).await {
2445 Some(u) => u,
2446 None => return Ok(Redirect::to("/login").into_response()),
2447 };
2448 let did = user.did.clone();
2449
2450 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
2452 return Ok(Redirect::to(&format!(
2453 "/manage?flash={}",
2454 qenc("Type DELETE to confirm — nothing was deleted.")
2455 ))
2456 .into_response());
2457 }
2458
2459 let counts = store::purge_did_data(&state.db, &did).await?;
2461 info!(
2462 %did,
2463 total = counts.total(),
2464 entry_state = counts.entry_state,
2465 read_cursor = counts.read_cursor,
2466 sub_ref = counts.sub_ref,
2467 beta_access = counts.beta_access,
2468 invite_codes = counts.invite_codes,
2469 "account/delete: local rows purged"
2470 );
2471
2472 match state.sidecar.revoke_session(&did).await {
2475 Ok(res) => info!(%did, revoked = res.revoked, "account/delete: sidecar session revoked"),
2476 Err(err) => {
2477 warn!(%did, %err, "account/delete: sidecar revoke failed; local data already purged")
2478 }
2479 }
2480
2481 if let Some(sid) = user.sid {
2483 state.sessions.remove(&sid);
2484 }
2485 let mut resp = Redirect::to(&format!(
2486 "/login?flash={}",
2487 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
2488 ))
2489 .into_response();
2490 set_cookie(
2491 &mut resp,
2492 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2493 );
2494 Ok(resp)
2495}
2496
2497fn login_error(msg: &str) -> Response {
2499 render(&LoginTemplate {
2500 repo_url: REPO_URL,
2501 error: msg.to_string(),
2502 flash: String::new(),
2503 })
2504}
2505
2506#[derive(Debug, Deserialize)]
2512struct RedeemForm {
2513 code: String,
2514}
2515
2516async fn beta_redeem_form(State(state): State<AppState>) -> Response {
2519 let full = store::count_beta_access(&state.db)
2520 .await
2521 .map(|n| n >= state.config.beta_cap)
2522 .unwrap_or(false);
2523 render(&BetaRedeemTemplate {
2524 repo_url: REPO_URL,
2525 error: String::new(),
2526 capacity_full: full,
2527 })
2528}
2529
2530async fn beta_redeem_submit(
2540 State(state): State<AppState>,
2541 Form(form): Form<RedeemForm>,
2542) -> Response {
2543 let code = form.code.trim().to_uppercase();
2544 if code.is_empty() {
2545 return render(&BetaRedeemTemplate {
2546 repo_url: REPO_URL,
2547 error: "Enter your invite code.".to_string(),
2548 capacity_full: false,
2549 });
2550 }
2551
2552 match preflight_code(&state, &code).await {
2553 Ok(()) => {
2554 let cookie = sign_invite(&code, &state.config.cookie_secret);
2555 let mut resp = Redirect::to("/login").into_response();
2556 set_cookie(&mut resp, &cookie);
2557 info!("invite code preflight OK; reserving intent + redirecting to /login");
2558 resp
2559 }
2560 Err(policy) => {
2561 warn!(?policy, "invite code preflight rejected");
2562 redeem_bounce(&policy)
2563 }
2564 }
2565}
2566
2567async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
2573 let count = store::count_beta_access(&state.db).await.unwrap_or(0);
2575 if count >= state.config.beta_cap {
2576 return Err(store::RedeemError::CapacityFull);
2577 }
2578 let row = sqlx::query_as::<_, (String, i64)>(
2580 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
2581 )
2582 .bind(code)
2583 .fetch_optional(&state.db)
2584 .await
2585 .ok()
2586 .flatten();
2587 let (status, expires_at) = match row {
2588 Some(r) => r,
2589 None => return Err(store::RedeemError::NotFound),
2590 };
2591 let now = chrono::Utc::now().timestamp();
2592 match status.as_str() {
2593 "active" if expires_at >= now => Ok(()),
2594 "active" => Err(store::RedeemError::Expired),
2595 "expired" => Err(store::RedeemError::Expired),
2596 _ => Err(store::RedeemError::AlreadyRedeemed),
2598 }
2599}
2600
2601fn redeem_bounce(policy: &store::RedeemError) -> Response {
2604 use store::RedeemError::*;
2605 let (msg, capacity_full) = match policy {
2606 NotFound => ("That invite code isn't valid.", false),
2607 Expired => ("That invite code has expired.", false),
2608 AlreadyRedeemed => ("That invite code has already been used.", false),
2609 CapacityFull => ("", true),
2610 };
2611 render(&BetaRedeemTemplate {
2612 repo_url: REPO_URL,
2613 error: msg.to_string(),
2614 capacity_full,
2615 })
2616}
2617
2618#[derive(Debug, Deserialize, Default)]
2620struct MintQuery {
2621 #[serde(default)]
2622 n: Option<u32>,
2623}
2624
2625async fn admin_mint_invites(
2631 State(state): State<AppState>,
2632 headers: HeaderMap,
2633 Query(q): Query<MintQuery>,
2634) -> Response {
2635 let did = match current_did(&state, &headers).await {
2638 Some(d) => d,
2639 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
2640 };
2641 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
2642 warn!(%did, "admin mint denied: not an admin-seed DID");
2643 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
2644 }
2645
2646 let n = q.n.unwrap_or(1).clamp(1, 100);
2647 let mut codes = Vec::with_capacity(n as usize);
2648 for _ in 0..n {
2649 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
2650 Ok(code) => codes.push(code),
2651 Err(err) => {
2652 warn!(%err, %did, "admin mint_code failed");
2653 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2654 }
2655 }
2656 }
2657 info!(%did, count = codes.len(), "admin minted invite codes");
2658 let mut body = codes.join("\n");
2659 body.push('\n');
2660 (StatusCode::OK, body).into_response()
2661}
2662
2663fn sign_invite(code: &str, secret: &str) -> String {
2672 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
2673}
2674
2675fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
2680 cookie::verify_value(headers, INVITE_COOKIE, secret)
2681}
2682
2683fn clear_invite_cookie(resp: &mut Response) {
2686 set_cookie(
2687 resp,
2688 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2689 );
2690}
2691
2692async fn import_opml(
2705 State(state): State<AppState>,
2706 headers: HeaderMap,
2707 mut multipart: Multipart,
2708) -> Result<Response, WebError> {
2709 let did = match current_did(&state, &headers).await {
2710 Some(d) => d,
2711 None => return Ok(Redirect::to("/login").into_response()),
2712 };
2713 let pool = &state.db;
2714
2715 let mut opml_text = String::new();
2721 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
2722 let name = field.name().unwrap_or("").to_string();
2723 if name == "opml" || name == "file" {
2724 let bytes = field.bytes().await.map_err(multipart_response)?;
2725 if !bytes.is_empty() {
2726 opml_text = String::from_utf8_lossy(&bytes).into_owned();
2727 if name == "file" {
2728 break;
2729 }
2730 }
2731 }
2732 }
2733
2734 let feeds = opml::parse_opml(&opml_text).unwrap_or_default();
2735 if feeds.is_empty() {
2736 info!(%did, "OPML import found no feeds");
2737 return Ok(
2738 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
2739 .into_response(),
2740 );
2741 }
2742
2743 let now = now_rfc3339();
2746 let mut folder_uris: std::collections::HashMap<String, String> =
2747 std::collections::HashMap::new();
2748 if let Ok(existing) = state.sidecar.list_folders_sorted(&did).await {
2750 for (rkey, folder) in existing {
2751 folder_uris
2752 .entry(folder.name.clone())
2753 .or_insert_with(|| folder_uri(&did, &rkey));
2754 }
2755 }
2756 let mut wanted_folders: Vec<String> = feeds
2757 .iter()
2758 .filter_map(|f| f.folder.clone())
2759 .filter(|n| !n.is_empty())
2760 .collect();
2761 wanted_folders.sort();
2762 wanted_folders.dedup();
2763 for name in wanted_folders {
2764 if folder_uris.contains_key(&name) {
2765 continue;
2766 }
2767 let folder = Folder::new(name.clone(), now.clone());
2768 match state.sidecar.add_folder(&did, &folder).await {
2769 Ok(rkey) => {
2770 folder_uris.insert(name, folder_uri(&did, &rkey));
2771 }
2772 Err(err) => warn!(%err, %did, "OPML folder create failed"),
2773 }
2774 }
2775
2776 let sub_cap = state.config.max_subs_per_did;
2785 let mut headroom: Option<i64> = if sub_cap > 0 {
2786 let existing = store::count_subscriptions_for_did(pool, &did)
2787 .await
2788 .unwrap_or(0);
2789 Some((sub_cap - existing).max(0))
2790 } else {
2791 None
2792 };
2793 let mut trimmed_over_cap: usize = 0;
2794
2795 let feeds_cap = state.config.max_feeds_global;
2802 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
2803 let existing = store::count_feeds(pool).await.unwrap_or(0);
2804 Some((feeds_cap - existing).max(0))
2805 } else {
2806 None
2807 };
2808 let mut trimmed_over_global: usize = 0;
2809
2810 let mut subs = Vec::with_capacity(feeds.len());
2811 let mut skipped_private: Vec<String> = Vec::new();
2812 for f in &feeds {
2813 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
2814 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
2815 let label = f
2817 .title
2818 .clone()
2819 .filter(|t| !t.trim().is_empty())
2820 .unwrap_or_else(|| private_feed_label(&f.feed_url));
2821 skipped_private.push(label);
2822 continue;
2823 }
2824
2825 if let Some(h) = headroom.as_mut() {
2828 if *h <= 0 {
2829 trimmed_over_cap += 1;
2830 continue;
2831 }
2832 }
2833
2834 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
2839 Ok(existing) => existing.is_none(),
2840 Err(err) => {
2843 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
2844 false
2845 }
2846 };
2847 if is_new {
2848 if let Some(g) = global_headroom.as_mut() {
2849 if *g <= 0 {
2850 trimmed_over_global += 1;
2851 continue;
2852 }
2853 *g -= 1;
2854 }
2855 }
2856
2857 if let Some(h) = headroom.as_mut() {
2860 *h -= 1;
2861 }
2862
2863 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
2864 sub.title = f.title.clone();
2865 sub.site_url = f.site_url.clone();
2866 sub.folder = f
2867 .folder
2868 .as_ref()
2869 .and_then(|name| folder_uris.get(name).cloned());
2870 subs.push(sub);
2871 let _ = store::upsert_feed(
2872 pool,
2873 &store::NewFeed {
2874 url: f.feed_url.clone(),
2875 title: f.title.clone(),
2876 site_url: f.site_url.clone(),
2877 ..Default::default()
2878 },
2879 )
2880 .await;
2881 }
2882
2883 match state.sidecar.add_subscriptions_bulk(&did, &subs).await {
2884 Ok(rkeys) => {
2885 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)")
2886 }
2887 Err(err) => warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)"),
2888 }
2889
2890 let mut flash = format!("Imported {} feeds", subs.len());
2892 if trimmed_over_cap > 0 {
2893 flash.push_str(&format!(
2894 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
2895 ));
2896 }
2897 if trimmed_over_global > 0 {
2898 flash.push_str(&format!(
2899 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
2900 ));
2901 }
2902 if !skipped_private.is_empty() {
2903 flash.push_str(&format!(
2904 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
2905 skipped_private.len(),
2906 skipped_private.join(", ")
2907 ));
2908 }
2909 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
2910}
2911
2912fn private_feed_label(url: &str) -> String {
2915 url::Url::parse(url)
2916 .ok()
2917 .and_then(|u| u.host_str().map(str::to_string))
2918 .unwrap_or_else(|| "a private feed".to_string())
2919}
2920
2921async fn export_opml(
2923 State(state): State<AppState>,
2924 headers: HeaderMap,
2925) -> Result<Response, WebError> {
2926 let did = match current_did(&state, &headers).await {
2927 Some(d) => d,
2928 None => return Ok(Redirect::to("/login").into_response()),
2929 };
2930
2931 let subs = state
2932 .sidecar
2933 .list_subscriptions_sorted(&did)
2934 .await
2935 .unwrap_or_default();
2936 let folders = state
2937 .sidecar
2938 .list_folders_sorted(&did)
2939 .await
2940 .unwrap_or_default();
2941 let folder_pairs: Vec<(String, Folder)> = folders
2944 .into_iter()
2945 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
2946 .collect();
2947
2948 let body = opml::to_opml(&subs, &folder_pairs);
2949 let mut resp = (StatusCode::OK, body).into_response();
2950 resp.headers_mut().insert(
2951 header::CONTENT_TYPE,
2952 "text/x-opml; charset=utf-8".parse().unwrap(),
2953 );
2954 resp.headers_mut().insert(
2955 header::CONTENT_DISPOSITION,
2956 "attachment; filename=\"featherreader-subscriptions.opml\""
2957 .parse()
2958 .unwrap(),
2959 );
2960 Ok(resp)
2961}
2962
2963fn set_cookie(resp: &mut Response, cookie: &str) {
2969 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
2970 resp.headers_mut()
2971 .append(axum::http::header::SET_COOKIE, value);
2972 }
2973}
2974
2975fn is_htmx(headers: &HeaderMap) -> bool {
2977 headers
2978 .get("HX-Request")
2979 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
2980}
2981
2982fn is_reader_request(headers: &HeaderMap) -> bool {
2988 headers
2989 .get("X-FR-Reader")
2990 .is_some_and(|v| v.as_bytes() == b"1")
2991}
2992
2993mod cookie {
2998 use super::{HeaderMap, SESSION_COOKIE};
2999
3000 pub fn sign_session(sid: &str, secret: &str) -> String {
3002 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
3003 }
3004
3005 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
3007 verify_value(headers, SESSION_COOKIE, secret)
3008 }
3009
3010 fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
3016 let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
3017 msg.extend_from_slice(name.as_bytes());
3018 msg.push(0);
3019 msg.extend_from_slice(value.as_bytes());
3020 msg
3021 }
3022
3023 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
3029 let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
3030 let b64 = b64url_encode(value.as_bytes());
3031 format!(
3032 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
3033 )
3034 }
3035
3036 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
3039 let raw = cookie_value(headers, name)?;
3040 let (b64, sig) = raw.split_once('.')?;
3041 let bytes = b64url_decode(b64)?;
3042 let value = String::from_utf8(bytes).ok()?;
3043 let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
3044 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
3045 Some(value)
3046 } else {
3047 None
3048 }
3049 }
3050
3051 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
3053 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
3054 for part in header.split(';') {
3055 let part = part.trim();
3056 if let Some((k, v)) = part.split_once('=') {
3057 if k == name {
3058 return Some(v.to_string());
3059 }
3060 }
3061 }
3062 None
3063 }
3064
3065 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
3067 if a.len() != b.len() {
3068 return false;
3069 }
3070 let mut diff = 0u8;
3071 for (x, y) in a.iter().zip(b.iter()) {
3072 diff |= x ^ y;
3073 }
3074 diff == 0
3075 }
3076
3077 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3080
3081 fn b64url_encode(input: &[u8]) -> String {
3082 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
3083 for chunk in input.chunks(3) {
3084 let b = [
3085 chunk[0],
3086 *chunk.get(1).unwrap_or(&0),
3087 *chunk.get(2).unwrap_or(&0),
3088 ];
3089 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
3090 out.push(B64[((n >> 18) & 63) as usize] as char);
3091 out.push(B64[((n >> 12) & 63) as usize] as char);
3092 if chunk.len() > 1 {
3093 out.push(B64[((n >> 6) & 63) as usize] as char);
3094 }
3095 if chunk.len() > 2 {
3096 out.push(B64[(n & 63) as usize] as char);
3097 }
3098 }
3099 out
3100 }
3101
3102 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
3103 fn val(c: u8) -> Option<u32> {
3104 match c {
3105 b'A'..=b'Z' => Some((c - b'A') as u32),
3106 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
3107 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
3108 b'-' => Some(62),
3109 b'_' => Some(63),
3110 _ => None,
3111 }
3112 }
3113 let bytes = input.as_bytes();
3114 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
3115 for chunk in bytes.chunks(4) {
3116 let mut n = 0u32;
3117 let mut valid = 0;
3118 for (i, &c) in chunk.iter().enumerate() {
3119 n |= val(c)? << (18 - 6 * i);
3120 valid += 1;
3121 }
3122 out.push((n >> 16) as u8);
3123 if valid > 2 {
3124 out.push((n >> 8) as u8);
3125 }
3126 if valid > 3 {
3127 out.push(n as u8);
3128 }
3129 }
3130 Some(out)
3131 }
3132
3133 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
3137 const BLOCK: usize = 64;
3138 let mut k = [0u8; BLOCK];
3139 if key.len() > BLOCK {
3140 let d = sha256(key);
3141 k[..32].copy_from_slice(&d);
3142 } else {
3143 k[..key.len()].copy_from_slice(key);
3144 }
3145 let mut ipad = [0x36u8; BLOCK];
3146 let mut opad = [0x5cu8; BLOCK];
3147 for i in 0..BLOCK {
3148 ipad[i] ^= k[i];
3149 opad[i] ^= k[i];
3150 }
3151 let mut inner = Vec::with_capacity(BLOCK + msg.len());
3152 inner.extend_from_slice(&ipad);
3153 inner.extend_from_slice(msg);
3154 let inner_hash = sha256(&inner);
3155 let mut outer = Vec::with_capacity(BLOCK + 32);
3156 outer.extend_from_slice(&opad);
3157 outer.extend_from_slice(&inner_hash);
3158 let mac = sha256(&outer);
3159 let mut hex = String::with_capacity(64);
3160 for b in mac {
3161 hex.push_str(&format!("{b:02x}"));
3162 }
3163 hex
3164 }
3165
3166 fn sha256(data: &[u8]) -> [u8; 32] {
3168 const K: [u32; 64] = [
3169 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
3170 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
3171 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
3172 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
3173 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
3174 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
3175 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
3176 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
3177 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
3178 0xc67178f2,
3179 ];
3180 let mut h: [u32; 8] = [
3181 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
3182 0x5be0cd19,
3183 ];
3184
3185 let bit_len = (data.len() as u64) * 8;
3186 let mut msg = data.to_vec();
3187 msg.push(0x80);
3188 while msg.len() % 64 != 56 {
3189 msg.push(0);
3190 }
3191 msg.extend_from_slice(&bit_len.to_be_bytes());
3192
3193 for block in msg.chunks(64) {
3194 let mut w = [0u32; 64];
3195 for i in 0..16 {
3196 w[i] = u32::from_be_bytes([
3197 block[i * 4],
3198 block[i * 4 + 1],
3199 block[i * 4 + 2],
3200 block[i * 4 + 3],
3201 ]);
3202 }
3203 for i in 16..64 {
3204 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
3205 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
3206 w[i] = w[i - 16]
3207 .wrapping_add(s0)
3208 .wrapping_add(w[i - 7])
3209 .wrapping_add(s1);
3210 }
3211 let mut a = h;
3212 for i in 0..64 {
3213 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
3214 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
3215 let t1 = a[7]
3216 .wrapping_add(s1)
3217 .wrapping_add(ch)
3218 .wrapping_add(K[i])
3219 .wrapping_add(w[i]);
3220 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
3221 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
3222 let t2 = s0.wrapping_add(maj);
3223 a[7] = a[6];
3224 a[6] = a[5];
3225 a[5] = a[4];
3226 a[4] = a[3].wrapping_add(t1);
3227 a[3] = a[2];
3228 a[2] = a[1];
3229 a[1] = a[0];
3230 a[0] = t1.wrapping_add(t2);
3231 }
3232 for i in 0..8 {
3233 h[i] = h[i].wrapping_add(a[i]);
3234 }
3235 }
3236
3237 let mut out = [0u8; 32];
3238 for (i, word) in h.iter().enumerate() {
3239 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
3240 }
3241 out
3242 }
3243
3244 #[cfg(test)]
3245 mod tests {
3246 use super::*;
3247
3248 #[test]
3249 fn sha256_known_vector() {
3250 let d = sha256(b"abc");
3251 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
3252 assert_eq!(
3253 hex,
3254 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
3255 );
3256 }
3257
3258 #[test]
3259 fn hmac_known_vector() {
3260 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
3261 assert_eq!(
3262 mac,
3263 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
3264 );
3265 }
3266
3267 #[test]
3268 fn sign_verify_round_trips() {
3269 let secret = "test-secret";
3270 let sid = "9f2c-opaque-session-id";
3271 let cookie = sign_session(sid, secret);
3272 let pair = cookie.split(';').next().unwrap().to_string();
3273 let mut headers = HeaderMap::new();
3274 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
3275 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
3276 assert!(verify_session(&headers, "other-secret").is_none());
3278 }
3279
3280 #[test]
3281 fn forged_and_tampered_cookies_are_rejected() {
3282 let secret = "test-secret";
3283
3284 let forged = format!(
3287 "{SESSION_COOKIE}={}.{}",
3288 b64url_encode(b"attacker-chosen-sid"),
3289 "deadbeef".repeat(8) );
3291 let mut headers = HeaderMap::new();
3292 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
3293 assert!(verify_session(&headers, secret).is_none());
3294
3295 let cookie = sign_session("real-sid", secret);
3298 let pair = cookie.split(';').next().unwrap();
3299 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
3300 let tampered = format!(
3301 "{SESSION_COOKIE}={}.{}",
3302 b64url_encode(b"different-sid"),
3303 sig
3304 );
3305 let mut headers2 = HeaderMap::new();
3306 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
3307 assert!(verify_session(&headers2, secret).is_none());
3308 }
3309
3310 #[test]
3311 fn b64url_round_trips() {
3312 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
3313 let enc = b64url_encode(s.as_bytes());
3314 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
3315 }
3316 }
3317 }
3318}
3319
3320async fn get_entry_by_id(
3332 pool: &store::Pool,
3333 did: &str,
3334 id: i64,
3335) -> anyhow::Result<Option<store::Entry>> {
3336 let entry = sqlx::query_as::<_, store::Entry>(
3337 r#"
3338 SELECT e.* FROM entries e
3339 WHERE e.id = ?2
3340 AND EXISTS (
3341 SELECT 1 FROM sub_ref sr
3342 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3343 )
3344 "#,
3345 )
3346 .bind(did)
3347 .bind(id)
3348 .fetch_optional(pool)
3349 .await?;
3350 Ok(entry)
3351}
3352
3353async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3355 let read: Option<bool> =
3356 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3357 .bind(did)
3358 .bind(entry_id)
3359 .fetch_optional(pool)
3360 .await?
3361 .flatten();
3362 Ok(read.unwrap_or(false))
3363}
3364
3365async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3367 let starred: Option<bool> =
3368 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3369 .bind(did)
3370 .bind(entry_id)
3371 .fetch_optional(pool)
3372 .await?
3373 .flatten();
3374 Ok(starred.unwrap_or(false))
3375}
3376
3377async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
3379 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
3380 .bind(feed_id)
3381 .fetch_optional(pool)
3382 .await
3383 {
3384 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
3385 _ => String::new(),
3386 }
3387}
3388
3389async fn build_entry_row(
3392 pool: &store::Pool,
3393 did: &str,
3394 id: i64,
3395 read: Option<bool>,
3396) -> anyhow::Result<Option<EntryRow>> {
3397 let entry = match get_entry_by_id(pool, did, id).await? {
3398 Some(e) => e,
3399 None => return Ok(None),
3400 };
3401 let read = match read {
3402 Some(r) => r,
3403 None => entry_is_read(pool, did, id).await?,
3404 };
3405 let starred = entry_is_starred(pool, did, id).await?;
3406 Ok(Some(EntryRow {
3407 id: entry.id,
3408 title: entry
3409 .title
3410 .clone()
3411 .filter(|t| !t.trim().is_empty())
3412 .unwrap_or_else(|| "(untitled)".to_string()),
3413 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
3414 published: display_date(entry.published.as_deref()),
3415 read,
3416 starred,
3417 link: format!("/entries/{id}"),
3418 }))
3419}
3420
3421fn now_rfc3339() -> String {
3423 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3424}
3425
3426#[cfg(test)]
3427mod tests {
3428 use super::*;
3429
3430 #[test]
3431 fn qenc_encodes_reserved() {
3432 assert_eq!(qenc("a b"), "a%20b");
3433 assert_eq!(
3434 qenc("https://example.com/feed.xml"),
3435 "https%3A%2F%2Fexample.com%2Ffeed.xml"
3436 );
3437 assert_eq!(
3438 qenc("at://did:plc:x/c/r"),
3439 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
3440 );
3441 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
3443 }
3444
3445 #[test]
3446 fn folder_uri_shape() {
3447 assert_eq!(
3448 folder_uri("did:plc:abc", "3kfolder"),
3449 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
3450 );
3451 }
3452
3453 #[test]
3456 fn private_feeds_are_classified_private_across_providers() {
3457 for url in [
3461 "https://author.substack.com/feed/private/deadbeefcafe1234",
3462 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
3463 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
3464 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
3465 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
3466 "https://user:pass@example.com/feed",
3467 ] {
3468 assert!(
3469 feed::classify_feed_privacy(url).is_private(),
3470 "expected private: {url}"
3471 );
3472 }
3473 }
3474
3475 #[test]
3476 fn public_feeds_stay_public() {
3477 for url in [
3478 "https://author.substack.com/feed",
3479 "https://wordpress.example.com/feed/",
3480 "https://example.com/rss.xml",
3481 "https://example.org/atom.xml",
3482 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
3484 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
3485 ] {
3486 assert!(
3487 !feed::classify_feed_privacy(url).is_private(),
3488 "expected public: {url}"
3489 );
3490 }
3491 }
3492
3493 #[test]
3494 fn private_feed_label_is_public_safe_host_only() {
3495 let label =
3497 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
3498 assert_eq!(label, "author.substack.com");
3499 assert!(!label.contains("deadbeefcafe1234token"));
3500 assert!(!label.contains("/private/"));
3501 assert_eq!(private_feed_label("not a url"), "a private feed");
3503 }
3504
3505 #[test]
3506 fn refusal_message_promises_nothing_stored() {
3507 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
3508 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
3509 }
3510
3511 #[test]
3512 fn scope_query_preserves_context() {
3513 let q = EntryQuery {
3514 feed: Some("https://example.com/feed.xml".to_string()),
3515 folder: None,
3516 view: Some("all".to_string()),
3517 };
3518 let s = scope_query(&q);
3519 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
3520 assert!(s.contains("view=all"));
3521
3522 let q2 = EntryQuery {
3524 feed: None,
3525 folder: None,
3526 view: Some("unread".to_string()),
3527 };
3528 assert_eq!(scope_query(&q2), "");
3529 }
3530
3531 use axum::body::Body;
3534 use axum::http::Request;
3535 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
3541 let db = store::init_url("sqlite::memory:").await.unwrap();
3542 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
3543 store::ensure_seed(&db, &dids).await.unwrap();
3544 let config = Config {
3545 allowed_dids: dids,
3546 cookie_secret: "test-cookie-secret-000".to_string(),
3547 beta_cap: 3,
3548 ..Config::default()
3549 };
3550 AppState::new(config, db).unwrap()
3551 }
3552
3553 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
3556 let sid = state.sessions.create(Session {
3557 did: did.to_string(),
3558 handle: handle.map(str::to_string),
3559 });
3560 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
3561 sc.split(';').next().unwrap().to_string()
3562 }
3563
3564 #[test]
3565 fn rate_limited_paths_match_expected() {
3566 use axum::http::Method;
3567 assert!(is_rate_limited_path("/login", &Method::GET));
3568 assert!(is_rate_limited_path("/login", &Method::POST));
3569 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
3570 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
3571 assert!(is_rate_limited_path("/opml", &Method::POST));
3572 assert!(is_rate_limited_path("/read-all", &Method::POST));
3573 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
3574 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
3575 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
3576 assert!(!is_rate_limited_path("/", &Method::GET));
3578 assert!(!is_rate_limited_path("/about", &Method::GET));
3579 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
3580 assert!(!is_rate_limited_path("/login", &Method::HEAD));
3581 }
3582
3583 #[test]
3584 fn rate_limiter_allows_burst_then_429s() {
3585 let rl = RateLimiter::shared();
3586 let ip: IpAddr = "203.0.113.7".parse().unwrap();
3587 for _ in 0..(RATE_BURST as usize) {
3589 assert!(rl.check(ip));
3590 }
3591 assert!(!rl.check(ip));
3593 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
3595 assert!(rl.check(ip2));
3596 }
3597
3598 #[test]
3599 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
3600 let mut h = HeaderMap::new();
3604 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
3605 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
3606 assert_eq!(
3607 client_ip(&h, Some(&sock), None),
3608 Some("203.0.113.55".parse().unwrap()),
3609 "spoofed XFF must not override the socket peer"
3610 );
3611 }
3612
3613 #[test]
3614 fn client_ip_uses_trusted_header_last_hop() {
3615 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
3620
3621 let mut h = HeaderMap::new();
3622 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
3623 assert_eq!(
3624 client_ip(&h, Some(&sock), Some("fly-client-ip")),
3625 Some("198.51.100.9".parse().unwrap())
3626 );
3627
3628 let mut h2 = HeaderMap::new();
3630 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
3631 assert_eq!(
3632 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
3633 Some("198.51.100.9".parse().unwrap()),
3634 "must take the right-most (trusted) hop, not the forged left-most"
3635 );
3636
3637 let h3 = HeaderMap::new();
3639 assert_eq!(
3640 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
3641 Some("10.0.0.1".parse().unwrap())
3642 );
3643 }
3644
3645 #[test]
3646 fn invite_cookie_round_trips_and_rejects_tamper() {
3647 let secret = "test-cookie-secret-000";
3648 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
3649 let pair = sc.split(';').next().unwrap();
3650 let mut h = HeaderMap::new();
3651 h.insert(header::COOKIE, pair.parse().unwrap());
3652 assert_eq!(
3653 invite_cookie_code(&h, secret).as_deref(),
3654 Some("FEATHER-ABCDWXYZ")
3655 );
3656 assert!(invite_cookie_code(&h, "other").is_none());
3658 }
3659
3660 #[tokio::test]
3661 async fn preflight_valid_expired_and_full() {
3662 let state = test_state(&["did:plc:admin"]).await;
3663 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
3665 .await
3666 .unwrap();
3667 assert!(preflight_code(&state, &code).await.is_ok());
3668
3669 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
3673 .await
3674 .unwrap();
3675 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
3676 .bind(chrono::Utc::now().timestamp() - 3600)
3677 .bind(&expired)
3678 .execute(&state.db)
3679 .await
3680 .unwrap();
3681 assert_eq!(
3682 preflight_code(&state, &expired).await,
3683 Err(store::RedeemError::Expired)
3684 );
3685
3686 assert_eq!(
3688 preflight_code(&state, "FEATHER-NOPENOPE").await,
3689 Err(store::RedeemError::NotFound)
3690 );
3691
3692 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
3695 .await
3696 .unwrap();
3697 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
3698 .await
3699 .unwrap();
3700 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
3701 assert_eq!(
3702 preflight_code(&state, &code).await,
3703 Err(store::RedeemError::CapacityFull)
3704 );
3705 }
3706
3707 #[tokio::test]
3708 async fn login_without_invite_redirects_to_beta_redeem() {
3709 let state = test_state(&[]).await;
3711 let app = router(state);
3712 let resp = app
3713 .oneshot(
3714 Request::builder()
3715 .method("POST")
3716 .uri("/login")
3717 .header("content-type", "application/x-www-form-urlencoded")
3718 .body(Body::from("handle=alice.bsky.social"))
3719 .unwrap(),
3720 )
3721 .await
3722 .unwrap();
3723 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3724 assert_eq!(
3725 resp.headers().get(header::LOCATION).unwrap(),
3726 "/beta/redeem"
3727 );
3728 }
3729
3730 #[tokio::test]
3731 async fn login_with_valid_invite_cookie_starts_oauth() {
3732 let state = test_state(&[]).await;
3733 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
3734 let cookie = cookie.split(';').next().unwrap().to_string();
3735 let app = router(state);
3736 let resp = app
3737 .oneshot(
3738 Request::builder()
3739 .method("POST")
3740 .uri("/login")
3741 .header("content-type", "application/x-www-form-urlencoded")
3742 .header(header::COOKIE, cookie)
3743 .body(Body::from("handle=alice.bsky.social"))
3744 .unwrap(),
3745 )
3746 .await
3747 .unwrap();
3748 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3750 let loc = resp
3751 .headers()
3752 .get(header::LOCATION)
3753 .unwrap()
3754 .to_str()
3755 .unwrap();
3756 assert!(loc.contains("/login"), "loc = {loc}");
3757 assert_ne!(loc, "/beta/redeem");
3758 }
3759
3760 async fn resolver_never(_handle: String) -> Option<String> {
3763 None
3764 }
3765
3766 fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
3768 move |_handle| std::future::ready(Some(did.to_string()))
3769 }
3770
3771 #[tokio::test]
3775 async fn may_start_oauth_honors_seat_via_resolved_handle() {
3776 let state = test_state(&["did:plc:admin"]).await;
3779 let headers = HeaderMap::new();
3780 assert!(
3781 may_start_oauth_with(
3782 &state,
3783 &headers,
3784 "admin.example",
3785 resolver_to("did:plc:admin")
3786 )
3787 .await,
3788 "a handle resolving to a seated DID must pass the gate"
3789 );
3790 }
3791
3792 #[tokio::test]
3796 async fn may_start_oauth_bounces_non_member_handle() {
3797 let state = test_state(&["did:plc:admin"]).await;
3798 let headers = HeaderMap::new();
3799 assert!(
3800 !may_start_oauth_with(
3801 &state,
3802 &headers,
3803 "rando.example",
3804 resolver_to("did:plc:rando")
3805 )
3806 .await,
3807 "a resolved DID with no seat must be bounced"
3808 );
3809 }
3810
3811 #[tokio::test]
3814 async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
3815 let state = test_state(&["did:plc:admin"]).await;
3816 let headers = HeaderMap::new();
3817 assert!(
3818 !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
3819 "an unresolvable handle must fail closed"
3820 );
3821 }
3822
3823 #[tokio::test]
3827 async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
3828 let state = test_state(&[]).await;
3829 let did = "did:plc:member";
3830 store::grant_access(&state.db, did, Some("member.example"), "test", None)
3831 .await
3832 .unwrap();
3833 let cookie = session_cookie(&state, did, Some("member.example"));
3834 let mut headers = HeaderMap::new();
3835 headers.insert(header::COOKIE, cookie.parse().unwrap());
3836 assert!(
3837 may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
3838 "a seated session cookie must pass without resolution"
3839 );
3840 }
3841
3842 #[tokio::test]
3844 async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
3845 let state = test_state(&[]).await;
3846 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
3847 let cookie = cookie.split(';').next().unwrap().to_string();
3848 let mut headers = HeaderMap::new();
3849 headers.insert(header::COOKIE, cookie.parse().unwrap());
3850 assert!(
3851 may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
3852 "a valid invite cookie must pass without resolution"
3853 );
3854 }
3855
3856 #[tokio::test]
3857 async fn admin_mint_requires_admin_seed_did() {
3858 let state = test_state(&["did:plc:admin"]).await;
3859 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
3861 .await
3862 .unwrap();
3863 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
3864 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3866 let app = router(state);
3867
3868 let forbidden = app
3869 .clone()
3870 .oneshot(
3871 Request::builder()
3872 .method("POST")
3873 .uri("/admin/invites?n=2")
3874 .header(header::COOKIE, rando_cookie)
3875 .body(Body::empty())
3876 .unwrap(),
3877 )
3878 .await
3879 .unwrap();
3880 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
3881
3882 let ok = app
3883 .oneshot(
3884 Request::builder()
3885 .method("POST")
3886 .uri("/admin/invites?n=2")
3887 .header(header::COOKIE, admin_cookie)
3888 .body(Body::empty())
3889 .unwrap(),
3890 )
3891 .await
3892 .unwrap();
3893 assert_eq!(ok.status(), StatusCode::OK);
3894 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
3895 .await
3896 .unwrap();
3897 let body = String::from_utf8(bytes.to_vec()).unwrap();
3898 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
3899 assert_eq!(minted.len(), 2);
3900 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
3901 }
3902
3903 #[tokio::test]
3904 async fn admin_mint_unauthenticated_is_401() {
3905 let state = test_state(&["did:plc:admin"]).await;
3906 let app = router(state);
3907 let resp = app
3908 .oneshot(
3909 Request::builder()
3910 .method("POST")
3911 .uri("/admin/invites")
3912 .body(Body::empty())
3913 .unwrap(),
3914 )
3915 .await
3916 .unwrap();
3917 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
3918 }
3919
3920 #[tokio::test]
3921 async fn cache_control_public_on_about_no_store_on_authed() {
3922 let state = test_state(&["did:plc:admin"]).await;
3923 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3924 let app = router(state);
3925
3926 let about = app
3928 .clone()
3929 .oneshot(
3930 Request::builder()
3931 .uri("/about")
3932 .body(Body::empty())
3933 .unwrap(),
3934 )
3935 .await
3936 .unwrap();
3937 assert_eq!(
3938 about.headers().get(header::CACHE_CONTROL).unwrap(),
3939 "public, max-age=300"
3940 );
3941 assert!(about.headers().contains_key("content-security-policy"));
3943 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
3944
3945 let login = app
3947 .clone()
3948 .oneshot(
3949 Request::builder()
3950 .uri("/login")
3951 .body(Body::empty())
3952 .unwrap(),
3953 )
3954 .await
3955 .unwrap();
3956 assert_eq!(
3957 login.headers().get(header::CACHE_CONTROL).unwrap(),
3958 "public, max-age=300"
3959 );
3960
3961 let home = app
3963 .oneshot(
3964 Request::builder()
3965 .uri("/")
3966 .header(header::COOKIE, admin_cookie)
3967 .body(Body::empty())
3968 .unwrap(),
3969 )
3970 .await
3971 .unwrap();
3972 assert_eq!(
3973 home.headers().get(header::CACHE_CONTROL).unwrap(),
3974 "no-store"
3975 );
3976 }
3977
3978 #[tokio::test]
3979 async fn beta_redeem_page_renders() {
3980 let state = test_state(&[]).await;
3981 let app = router(state);
3982 let resp = app
3983 .oneshot(
3984 Request::builder()
3985 .uri("/beta/redeem")
3986 .body(Body::empty())
3987 .unwrap(),
3988 )
3989 .await
3990 .unwrap();
3991 assert_eq!(resp.status(), StatusCode::OK);
3992 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
3993 .await
3994 .unwrap();
3995 let html = String::from_utf8(bytes.to_vec()).unwrap();
3996 assert!(html.contains("Invite code"));
3997 assert!(html.contains("/beta/redeem"));
3998 }
3999
4000 #[tokio::test]
4001 async fn rate_limit_returns_429_after_burst() {
4002 let db = store::init_url("sqlite::memory:").await.unwrap();
4005 store::ensure_seed(&db, &[]).await.unwrap();
4006 let config = Config {
4007 cookie_secret: "test-cookie-secret-000".to_string(),
4008 beta_cap: 3,
4009 trusted_ip_header: Some("cf-connecting-ip".to_string()),
4010 ..Config::default()
4011 };
4012 let state = AppState::new(config, db).unwrap();
4013 let app = router(state);
4014 let mut saw_429 = false;
4018 for _ in 0..(RATE_BURST as usize + 5) {
4019 let resp = app
4020 .clone()
4021 .oneshot(
4022 Request::builder()
4023 .method("POST")
4024 .uri("/beta/redeem")
4025 .header("content-type", "application/x-www-form-urlencoded")
4026 .header("cf-connecting-ip", "203.0.113.200")
4027 .body(Body::from("code=FEATHER-NOPENOPE"))
4028 .unwrap(),
4029 )
4030 .await
4031 .unwrap();
4032 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
4033 saw_429 = true;
4034 break;
4035 }
4036 }
4037 assert!(saw_429, "expected a 429 after exhausting the burst");
4038 }
4039
4040 #[tokio::test]
4041 async fn rate_limit_ignores_spoofed_xff_rotation() {
4042 let state = test_state(&[]).await;
4051 let app = router(state);
4052 for i in 0..(RATE_BURST as usize + 5) {
4053 let forged = format!("10.9.8.{}", i % 250);
4054 let resp = app
4055 .clone()
4056 .oneshot(
4057 Request::builder()
4058 .method("POST")
4059 .uri("/beta/redeem")
4060 .header("content-type", "application/x-www-form-urlencoded")
4061 .header("x-forwarded-for", forged)
4062 .body(Body::from("code=FEATHER-NOPENOPE"))
4063 .unwrap(),
4064 )
4065 .await
4066 .unwrap();
4067 assert_ne!(
4068 resp.status(),
4069 StatusCode::TOO_MANY_REQUESTS,
4070 "untrusted XFF must not be used as the rate-limit key"
4071 );
4072 }
4073 }
4074
4075 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
4080 let boundary = "----featherreadertestboundary";
4081 let mut body = Vec::new();
4082 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
4083 body.extend_from_slice(
4084 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
4085 );
4086 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
4087 body.extend_from_slice(payload);
4088 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
4089 (format!("multipart/form-data; boundary={boundary}"), body)
4090 }
4091
4092 #[tokio::test]
4093 async fn opml_import_oversize_upload_returns_413() {
4094 let state = test_state(&["did:plc:admin"]).await;
4095 let cookie = session_cookie(&state, "did:plc:admin", None);
4096 let app = router(state);
4097
4098 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
4100 let (content_type, body) = opml_multipart(&payload);
4101
4102 let resp = app
4103 .oneshot(
4104 Request::builder()
4105 .method("POST")
4106 .uri("/opml")
4107 .header("content-type", content_type)
4108 .header(header::COOKIE, cookie)
4109 .body(Body::from(body))
4110 .unwrap(),
4111 )
4112 .await
4113 .unwrap();
4114 assert_eq!(
4115 resp.status(),
4116 StatusCode::PAYLOAD_TOO_LARGE,
4117 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
4118 );
4119 }
4120
4121 #[tokio::test]
4122 async fn opml_import_under_limit_upload_is_not_413() {
4123 let state = test_state(&["did:plc:admin"]).await;
4124 let cookie = session_cookie(&state, "did:plc:admin", None);
4125 let app = router(state);
4126
4127 let opml = br#"<?xml version="1.0"?>
4130<opml version="2.0"><body>
4131 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
4132</body></opml>"#;
4133 let (content_type, body) = opml_multipart(opml);
4134
4135 let resp = app
4136 .oneshot(
4137 Request::builder()
4138 .method("POST")
4139 .uri("/opml")
4140 .header("content-type", content_type)
4141 .header(header::COOKIE, cookie)
4142 .body(Body::from(body))
4143 .unwrap(),
4144 )
4145 .await
4146 .unwrap();
4147 assert_ne!(
4148 resp.status(),
4149 StatusCode::PAYLOAD_TOO_LARGE,
4150 "an under-cap OPML upload must not be rejected as too large"
4151 );
4152 }
4153
4154 #[tokio::test]
4155 async fn opml_import_logged_out_redirects_to_login() {
4156 let state = test_state(&["did:plc:admin"]).await;
4159 let app = router(state);
4160
4161 let opml = b"<opml version=\"2.0\"><body></body></opml>";
4162 let (content_type, body) = opml_multipart(opml);
4163
4164 let resp = app
4165 .oneshot(
4166 Request::builder()
4167 .method("POST")
4168 .uri("/opml")
4169 .header("content-type", content_type)
4170 .body(Body::from(body))
4171 .unwrap(),
4172 )
4173 .await
4174 .unwrap();
4175 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4176 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
4177 }
4178
4179 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
4186 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4187 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4188 let addr = listener.local_addr().unwrap();
4189 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
4190 tokio::spawn(async move {
4191 let (mut sock, _) = listener.accept().await.unwrap();
4192 let mut buf = vec![0u8; 4096];
4193 let n = sock.read(&mut buf).await.unwrap();
4194 let req = String::from_utf8_lossy(&buf[..n]).to_string();
4195 let did = req
4197 .split("\r\n\r\n")
4198 .nth(1)
4199 .and_then(|body| {
4200 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
4201 v.get("did")?.as_str().map(str::to_string)
4202 })
4203 .unwrap_or_default();
4204 let is_revoke = req.starts_with("POST /internal/revoke");
4205 let body = serde_json::json!({
4206 "ok": true, "did": did, "revoked": true, "hadSession": true
4207 })
4208 .to_string();
4209 let resp = format!(
4210 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
4211 body.len(),
4212 body
4213 );
4214 sock.write_all(resp.as_bytes()).await.unwrap();
4215 sock.flush().await.unwrap();
4216 let _ = tx.send(if is_revoke { did } else { String::new() });
4217 });
4218 (format!("http://{addr}"), rx)
4219 }
4220
4221 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
4223 let db = store::init_url("sqlite::memory:").await.unwrap();
4224 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
4225 store::ensure_seed(&db, &dids).await.unwrap();
4226 let mut config = Config {
4227 allowed_dids: dids,
4228 cookie_secret: "test-cookie-secret-000".to_string(),
4229 beta_cap: 3,
4230 ..Config::default()
4231 };
4232 config.sidecar.public_url = sidecar_url.to_string();
4233 config.sidecar.internal_url = sidecar_url.to_string();
4236 AppState::new(config, db).unwrap()
4237 }
4238
4239 #[tokio::test]
4242 async fn account_delete_purges_rows_and_triggers_revoke() {
4243 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
4244 let did = "did:plc:leaver";
4245 let state = test_state_with_sidecar(&[], &sidecar_url).await;
4246
4247 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
4249 .await
4250 .unwrap();
4251 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
4252 store::mint_code(&state.db, did, 3600).await.unwrap();
4253 assert!(store::has_beta_access(&state.db, did).await.unwrap());
4254
4255 let cookie = session_cookie(&state, did, Some("leaver.example"));
4256 let app = router(state.clone());
4257
4258 let resp = app
4259 .oneshot(
4260 Request::builder()
4261 .method("POST")
4262 .uri("/account/delete")
4263 .header(header::COOKIE, cookie)
4264 .header("content-type", "application/x-www-form-urlencoded")
4265 .body(Body::from("confirm=DELETE"))
4266 .unwrap(),
4267 )
4268 .await
4269 .unwrap();
4270
4271 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4273 assert!(resp
4274 .headers()
4275 .get(header::LOCATION)
4276 .unwrap()
4277 .to_str()
4278 .unwrap()
4279 .starts_with("/login"));
4280 let set_cookie = resp
4281 .headers()
4282 .get(header::SET_COOKIE)
4283 .unwrap()
4284 .to_str()
4285 .unwrap();
4286 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
4287
4288 let revoked_did = revoke_rx.await.unwrap();
4290 assert_eq!(
4291 revoked_did, did,
4292 "sidecar revoke must fire for the caller DID"
4293 );
4294
4295 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
4297 let codes: i64 =
4298 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
4299 .bind(did)
4300 .fetch_one(&state.db)
4301 .await
4302 .unwrap();
4303 assert_eq!(codes, 0);
4304 }
4305
4306 #[tokio::test]
4309 async fn account_delete_without_confirm_is_a_noop() {
4310 let did = "did:plc:staying";
4311 let state = test_state(&[]).await;
4312 store::grant_access(&state.db, did, None, "test", None)
4313 .await
4314 .unwrap();
4315 let cookie = session_cookie(&state, did, None);
4316 let app = router(state.clone());
4317
4318 let resp = app
4319 .oneshot(
4320 Request::builder()
4321 .method("POST")
4322 .uri("/account/delete")
4323 .header(header::COOKIE, cookie)
4324 .header("content-type", "application/x-www-form-urlencoded")
4325 .body(Body::from("confirm=nope"))
4326 .unwrap(),
4327 )
4328 .await
4329 .unwrap();
4330
4331 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4332 assert!(resp
4333 .headers()
4334 .get(header::LOCATION)
4335 .unwrap()
4336 .to_str()
4337 .unwrap()
4338 .starts_with("/manage"));
4339 assert!(store::has_beta_access(&state.db, did).await.unwrap());
4341 }
4342
4343 #[tokio::test]
4350 async fn pds_outage_does_not_widen_cross_did_access() {
4351 let did_a = "did:plc:aaaa";
4352 let state = test_state(&[]).await;
4353 store::grant_access(&state.db, did_a, None, "test", None)
4354 .await
4355 .unwrap();
4356
4357 let feed_a = store::upsert_feed(
4360 &state.db,
4361 &store::NewFeed {
4362 url: "https://a.example/feed.xml".to_string(),
4363 title: Some("A".to_string()),
4364 ..Default::default()
4365 },
4366 )
4367 .await
4368 .unwrap();
4369 let feed_b = store::upsert_feed(
4370 &state.db,
4371 &store::NewFeed {
4372 url: "https://b.example/feed.xml".to_string(),
4373 title: Some("B".to_string()),
4374 ..Default::default()
4375 },
4376 )
4377 .await
4378 .unwrap();
4379 store::insert_entries(
4380 &state.db,
4381 feed_b,
4382 &[store::NewEntry {
4383 guid: "b-1".to_string(),
4384 url: Some("https://b.example/1".to_string()),
4385 title: Some("B one".to_string()),
4386 published: Some("2026-07-11T00:00:00Z".to_string()),
4387 content_html: Some("<p>secret B body</p>".to_string()),
4388 ..Default::default()
4389 }],
4390 0,
4391 )
4392 .await
4393 .unwrap();
4394 store::replace_sub_refs(&state.db, did_a, &[feed_a])
4396 .await
4397 .unwrap();
4398 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
4401 .await
4402 .unwrap();
4403 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
4404 .await
4405 .unwrap()[0]
4406 .id;
4407 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
4408 .await
4409 .unwrap();
4410
4411 let cookie = session_cookie(&state, did_a, None);
4412 let app = router(state.clone());
4413
4414 let get_b = app
4416 .clone()
4417 .oneshot(
4418 Request::builder()
4419 .method("GET")
4420 .uri(format!("/entries/{b_entry_id}"))
4421 .header(header::COOKIE, cookie.clone())
4422 .body(Body::empty())
4423 .unwrap(),
4424 )
4425 .await
4426 .unwrap();
4427 assert_eq!(
4428 get_b.status(),
4429 StatusCode::NOT_FOUND,
4430 "A must not read B's entry during a PDS outage"
4431 );
4432
4433 let read_b = app
4435 .oneshot(
4436 Request::builder()
4437 .method("POST")
4438 .uri(format!("/entries/{b_entry_id}/read"))
4439 .header(header::COOKIE, cookie)
4440 .header("content-type", "application/x-www-form-urlencoded")
4441 .body(Body::from("read=true"))
4442 .unwrap(),
4443 )
4444 .await
4445 .unwrap();
4446 assert_eq!(
4447 read_b.status(),
4448 StatusCode::NOT_FOUND,
4449 "A must not mark B's entry read during a PDS outage"
4450 );
4451
4452 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
4454 .bind(did_a)
4455 .fetch_all(&state.db)
4456 .await
4457 .unwrap();
4458 assert_eq!(
4459 a_feed_ids,
4460 vec![feed_a],
4461 "outage fallback must not add feeds A never subscribed to"
4462 );
4463 let es_count: i64 =
4465 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
4466 .bind(did_a)
4467 .bind(b_entry_id)
4468 .fetch_one(&state.db)
4469 .await
4470 .unwrap();
4471 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
4472 }
4473
4474 async fn test_state_with_caps(
4477 did: &str,
4478 max_subs_per_did: i64,
4479 max_feeds_global: i64,
4480 ) -> AppState {
4481 let db = store::init_url("sqlite::memory:").await.unwrap();
4482 let config = Config {
4483 cookie_secret: "test-cookie-secret-000".to_string(),
4484 beta_cap: 100,
4485 max_subs_per_did,
4486 max_feeds_global,
4487 ..Config::default()
4488 };
4489 store::grant_access(&db, did, None, "test", None)
4490 .await
4491 .unwrap();
4492 AppState::new(config, db).unwrap()
4493 }
4494
4495 fn opml_with_feeds(n: usize) -> String {
4497 let mut outlines = String::new();
4498 for i in 0..n {
4499 outlines.push_str(&format!(
4500 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
4501 ));
4502 }
4503 format!(
4504 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
4505 )
4506 }
4507
4508 #[tokio::test]
4513 async fn opml_import_enforces_global_feeds_ceiling() {
4514 let did = "did:plc:importer";
4515 let state = test_state_with_caps(did, 0, 3).await;
4517 let cookie = session_cookie(&state, did, None);
4518 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4519 let app = router(state.clone());
4520
4521 let resp = app
4522 .oneshot(
4523 Request::builder()
4524 .method("POST")
4525 .uri("/opml")
4526 .header(header::COOKIE, cookie)
4527 .header("content-type", ct)
4528 .body(Body::from(body))
4529 .unwrap(),
4530 )
4531 .await
4532 .unwrap();
4533 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4534
4535 let feeds = store::count_feeds(&state.db).await.unwrap();
4536 assert!(
4537 feeds <= 3,
4538 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
4539 );
4540 }
4541
4542 #[tokio::test]
4545 async fn opml_import_enforces_per_did_cap() {
4546 let did = "did:plc:capped";
4547 let state = test_state_with_caps(did, 2, 0).await;
4549 let existing_a = store::upsert_feed(
4550 &state.db,
4551 &store::NewFeed {
4552 url: "https://have-a.example/feed.xml".to_string(),
4553 ..Default::default()
4554 },
4555 )
4556 .await
4557 .unwrap();
4558 let existing_b = store::upsert_feed(
4559 &state.db,
4560 &store::NewFeed {
4561 url: "https://have-b.example/feed.xml".to_string(),
4562 ..Default::default()
4563 },
4564 )
4565 .await
4566 .unwrap();
4567 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
4568 .await
4569 .unwrap();
4570 let before = store::count_feeds(&state.db).await.unwrap();
4571
4572 let cookie = session_cookie(&state, did, None);
4573 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4574 let app = router(state.clone());
4575 let resp = app
4576 .oneshot(
4577 Request::builder()
4578 .method("POST")
4579 .uri("/opml")
4580 .header(header::COOKIE, cookie)
4581 .header("content-type", ct)
4582 .body(Body::from(body))
4583 .unwrap(),
4584 )
4585 .await
4586 .unwrap();
4587 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4588 let after = store::count_feeds(&state.db).await.unwrap();
4590 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
4591 }
4592
4593 #[tokio::test]
4596 async fn single_add_enforces_per_did_cap() {
4597 let did = "did:plc:subcapped";
4598 let state = test_state_with_caps(did, 1, 0).await;
4599 let f = store::upsert_feed(
4600 &state.db,
4601 &store::NewFeed {
4602 url: "https://have.example/feed.xml".to_string(),
4603 ..Default::default()
4604 },
4605 )
4606 .await
4607 .unwrap();
4608 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
4609 let cookie = session_cookie(&state, did, None);
4610 let app = router(state.clone());
4611 let resp = app
4612 .oneshot(
4613 Request::builder()
4614 .method("POST")
4615 .uri("/subscriptions")
4616 .header(header::COOKIE, cookie)
4617 .header("content-type", "application/x-www-form-urlencoded")
4618 .body(Body::from("url=https://another.example/feed.xml"))
4619 .unwrap(),
4620 )
4621 .await
4622 .unwrap();
4623 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4624 let loc = resp
4625 .headers()
4626 .get(header::LOCATION)
4627 .unwrap()
4628 .to_str()
4629 .unwrap();
4630 assert!(
4631 loc.contains("Subscription%20limit%20reached"),
4632 "expected sub-limit flash, got {loc}"
4633 );
4634 }
4635
4636 #[tokio::test]
4643 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
4644 let did = "did:plc:reader";
4645 let state = test_state(&[]).await;
4646 store::grant_access(&state.db, did, None, "test", None)
4647 .await
4648 .unwrap();
4649 let feed = store::upsert_feed(
4650 &state.db,
4651 &store::NewFeed {
4652 url: "https://reader.example/feed.xml".to_string(),
4653 title: Some("Reader".to_string()),
4654 ..Default::default()
4655 },
4656 )
4657 .await
4658 .unwrap();
4659 store::insert_entries(
4660 &state.db,
4661 feed,
4662 &[store::NewEntry {
4663 guid: "r-1".to_string(),
4664 url: Some("https://reader.example/1".to_string()),
4665 title: Some("Article".to_string()),
4666 published: Some("2026-07-11T00:00:00Z".to_string()),
4667 content_html: Some("<p>body</p>".to_string()),
4668 ..Default::default()
4669 }],
4670 0,
4671 )
4672 .await
4673 .unwrap();
4674 store::replace_sub_refs(&state.db, did, &[feed])
4675 .await
4676 .unwrap();
4677 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4678
4679 let cookie = session_cookie(&state, did, None);
4680 let app = router(state.clone());
4681
4682 let resp = app
4684 .clone()
4685 .oneshot(
4686 Request::builder()
4687 .method("POST")
4688 .uri(format!("/entries/{entry_id}/read"))
4689 .header(header::COOKIE, cookie.clone())
4690 .header("HX-Request", "true")
4691 .header("X-FR-Reader", "1")
4692 .header("content-type", "application/x-www-form-urlencoded")
4693 .body(Body::from("read=true"))
4694 .unwrap(),
4695 )
4696 .await
4697 .unwrap();
4698 assert_eq!(resp.status(), StatusCode::OK);
4699 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4700 .await
4701 .unwrap();
4702 let html = String::from_utf8(bytes.to_vec()).unwrap();
4703 assert!(
4704 html.contains("hx-swap-oob=\"outerHTML\""),
4705 "reader response must be an OOB swap: {html}"
4706 );
4707 assert!(
4708 html.contains(r#"id="entry-actionbar""#),
4709 "reader response must be the action-bar fragment: {html}"
4710 );
4711 assert!(
4714 html.contains(r#"aria-pressed="true""#),
4715 "read button must show pressed after marking read: {html}"
4716 );
4717 assert!(
4718 html.contains(r#"name="read" value="false""#),
4719 "hidden read value must flip to false so a second tap reverses: {html}"
4720 );
4721
4722 let resp2 = app
4725 .oneshot(
4726 Request::builder()
4727 .method("POST")
4728 .uri(format!("/entries/{entry_id}/read"))
4729 .header(header::COOKIE, cookie)
4730 .header("HX-Request", "true")
4731 .header("X-FR-Reader", "1")
4732 .header("content-type", "application/x-www-form-urlencoded")
4733 .body(Body::from("read=false"))
4734 .unwrap(),
4735 )
4736 .await
4737 .unwrap();
4738 assert_eq!(resp2.status(), StatusCode::OK);
4739 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
4740 .await
4741 .unwrap();
4742 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
4743 assert!(
4744 html2.contains(r#"aria-pressed="false""#),
4745 "read button must show un-pressed after reversing: {html2}"
4746 );
4747 assert!(
4748 html2.contains(r#"name="read" value="true""#),
4749 "hidden read value must flip back to true: {html2}"
4750 );
4751 }
4752
4753 #[tokio::test]
4756 async fn list_mark_read_returns_row_not_oob_actionbar() {
4757 let did = "did:plc:listv";
4758 let state = test_state(&[]).await;
4759 store::grant_access(&state.db, did, None, "test", None)
4760 .await
4761 .unwrap();
4762 let feed = store::upsert_feed(
4763 &state.db,
4764 &store::NewFeed {
4765 url: "https://list.example/feed.xml".to_string(),
4766 title: Some("List".to_string()),
4767 ..Default::default()
4768 },
4769 )
4770 .await
4771 .unwrap();
4772 store::insert_entries(
4773 &state.db,
4774 feed,
4775 &[store::NewEntry {
4776 guid: "l-1".to_string(),
4777 url: Some("https://list.example/1".to_string()),
4778 title: Some("Article".to_string()),
4779 published: Some("2026-07-11T00:00:00Z".to_string()),
4780 ..Default::default()
4781 }],
4782 0,
4783 )
4784 .await
4785 .unwrap();
4786 store::replace_sub_refs(&state.db, did, &[feed])
4787 .await
4788 .unwrap();
4789 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4790
4791 let cookie = session_cookie(&state, did, None);
4792 let app = router(state.clone());
4793
4794 let resp = app
4795 .oneshot(
4796 Request::builder()
4797 .method("POST")
4798 .uri(format!("/entries/{entry_id}/read"))
4799 .header(header::COOKIE, cookie)
4800 .header("HX-Request", "true")
4801 .header("content-type", "application/x-www-form-urlencoded")
4802 .body(Body::from("read=true"))
4803 .unwrap(),
4804 )
4805 .await
4806 .unwrap();
4807 assert_eq!(resp.status(), StatusCode::OK);
4808 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4809 .await
4810 .unwrap();
4811 let html = String::from_utf8(bytes.to_vec()).unwrap();
4812 assert!(
4813 !html.contains("hx-swap-oob"),
4814 "list-view response must NOT be an OOB swap: {html}"
4815 );
4816 }
4817
4818 #[tokio::test]
4827 async fn rename_to_new_url_refused_at_global_feeds_cap() {
4828 let did = "did:plc:renamer";
4829 let state = test_state_with_caps(did, 0, 1).await;
4831 store::upsert_feed(
4832 &state.db,
4833 &store::NewFeed {
4834 url: "https://existing.example/feed.xml".to_string(),
4835 ..Default::default()
4836 },
4837 )
4838 .await
4839 .unwrap();
4840 let before = store::count_feeds(&state.db).await.unwrap();
4841 assert_eq!(before, 1);
4842
4843 let cookie = session_cookie(&state, did, None);
4844 let app = router(state.clone());
4845 let resp = app
4846 .oneshot(
4847 Request::builder()
4848 .method("POST")
4849 .uri("/subscriptions/rkey123/rename")
4850 .header(header::COOKIE, cookie)
4851 .header("content-type", "application/x-www-form-urlencoded")
4852 .body(Body::from(
4854 "url=https://brand-new.example/feed.xml&title=Renamed",
4855 ))
4856 .unwrap(),
4857 )
4858 .await
4859 .unwrap();
4860 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4861 let loc = resp
4862 .headers()
4863 .get(header::LOCATION)
4864 .unwrap()
4865 .to_str()
4866 .unwrap();
4867 assert!(
4868 loc.contains("feed%20capacity"),
4869 "expected the feed-capacity flash, got {loc}"
4870 );
4871 let after = store::count_feeds(&state.db).await.unwrap();
4873 assert_eq!(
4874 after, before,
4875 "rename inflated the shared cache past the cap"
4876 );
4877 }
4878
4879 #[tokio::test]
4882 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
4883 let did = "did:plc:renamer2";
4884 let state = test_state_with_caps(did, 0, 1).await;
4885 store::upsert_feed(
4886 &state.db,
4887 &store::NewFeed {
4888 url: "https://existing.example/feed.xml".to_string(),
4889 ..Default::default()
4890 },
4891 )
4892 .await
4893 .unwrap();
4894 let before = store::count_feeds(&state.db).await.unwrap();
4895
4896 let cookie = session_cookie(&state, did, None);
4897 let app = router(state.clone());
4898 let resp = app
4899 .oneshot(
4900 Request::builder()
4901 .method("POST")
4902 .uri("/subscriptions/rkey123/rename")
4903 .header(header::COOKIE, cookie)
4904 .header("content-type", "application/x-www-form-urlencoded")
4905 .body(Body::from(
4906 "url=https://existing.example/feed.xml&title=Retitled",
4907 ))
4908 .unwrap(),
4909 )
4910 .await
4911 .unwrap();
4912 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4913 let loc = resp
4914 .headers()
4915 .get(header::LOCATION)
4916 .unwrap()
4917 .to_str()
4918 .unwrap();
4919 assert_eq!(
4920 loc, "/",
4921 "retitle of an existing feed must succeed, got {loc}"
4922 );
4923 assert_eq!(
4924 store::count_feeds(&state.db).await.unwrap(),
4925 before,
4926 "retitle must not add a feeds row"
4927 );
4928 }
4929
4930 #[tokio::test]
4933 async fn rename_with_blank_url_writes_nothing() {
4934 let did = "did:plc:renamer3";
4935 let state = test_state_with_caps(did, 0, 0).await;
4936 let before = store::count_feeds(&state.db).await.unwrap();
4937 assert_eq!(before, 0);
4938
4939 let cookie = session_cookie(&state, did, None);
4940 let app = router(state.clone());
4941 let resp = app
4942 .oneshot(
4943 Request::builder()
4944 .method("POST")
4945 .uri("/subscriptions/rkey123/rename")
4946 .header(header::COOKIE, cookie)
4947 .header("content-type", "application/x-www-form-urlencoded")
4948 .body(Body::from("url=%20%20&title=Nope"))
4950 .unwrap(),
4951 )
4952 .await
4953 .unwrap();
4954 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4955 assert_eq!(
4956 resp.headers()
4957 .get(header::LOCATION)
4958 .unwrap()
4959 .to_str()
4960 .unwrap(),
4961 "/",
4962 );
4963 assert_eq!(
4965 store::count_feeds(&state.db).await.unwrap(),
4966 0,
4967 "blank-URL rename wrote a junk feeds row"
4968 );
4969 }
4970
4971 #[test]
4977 fn manage_rename_row_preselects_current_folder() {
4978 let nav = Nav {
4979 handle: "@reader.example".to_string(),
4980 avatar: "RE".to_string(),
4981 view: "unread".to_string(),
4982 scope_qs: String::new(),
4983 folders: Vec::new(),
4984 loose_feeds: Vec::new(),
4985 manage_active: true,
4986 };
4987 let folder_options = vec![
4988 FolderOption {
4989 uri: "at://did:plc:x/app.folder/work".to_string(),
4990 name: "Work".to_string(),
4991 },
4992 FolderOption {
4993 uri: "at://did:plc:x/app.folder/fun".to_string(),
4994 name: "Fun".to_string(),
4995 },
4996 ];
4997 let foldered = FeedView {
5000 rkey: "sub-foldered".to_string(),
5001 url: "https://work.example/feed.xml".to_string(),
5002 title: "Work Feed".to_string(),
5003 unread: 0,
5004 selected: false,
5005 folder: Some("at://did:plc:x/app.folder/work".to_string()),
5006 };
5007 let loose = FeedView {
5008 rkey: "sub-loose".to_string(),
5009 url: "https://loose.example/feed.xml".to_string(),
5010 title: "Loose Feed".to_string(),
5011 unread: 0,
5012 selected: false,
5013 folder: None,
5014 };
5015 let tmpl = ManageTemplate {
5016 version: VERSION,
5017 repo_url: REPO_URL,
5018 kofi_url: KOFI_URL,
5019 flash: String::new(),
5020 nav,
5021 folder_options,
5022 folders: vec![FolderView {
5023 rkey: "folder-work".to_string(),
5024 uri: "at://did:plc:x/app.folder/work".to_string(),
5025 name: "Work".to_string(),
5026 feeds: vec![foldered],
5027 selected: false,
5028 }],
5029 loose_feeds: vec![loose],
5030 };
5031 let html = tmpl.render().unwrap();
5032
5033 assert!(
5035 html.contains(
5036 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
5037 ),
5038 "foldered feed must pre-select its current folder: {html}"
5039 );
5040 assert!(
5043 html.contains(r#"<option value="" selected>No folder</option>"#),
5044 "loose feed must pre-select 'No folder': {html}"
5045 );
5046 }
5047}