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 CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
116 script-src 'self'; \
117 style-src 'self' 'unsafe-inline'; \
118 img-src 'self' https: data:; \
119 font-src 'self'; \
120 connect-src 'self'; \
121 form-action 'self'; \
122 base-uri 'self'; \
123 frame-ancestors 'none'; \
124 object-src 'none'";
125
126#[derive(Clone, Debug)]
133struct CurrentUser {
134 did: String,
135 handle: Option<String>,
136 sid: Option<String>,
139}
140
141async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
152 if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
153 if let Some(session) = state.sessions.get(&sid) {
154 if store::has_beta_access(&state.db, &session.did)
155 .await
156 .unwrap_or(false)
157 {
158 return Some(CurrentUser {
159 did: session.did,
160 handle: session.handle,
161 sid: Some(sid),
162 });
163 }
164 state.sessions.remove(&sid);
167 }
168 }
169 if let Some(did) = state.config.dev_did.clone() {
172 if store::has_beta_access(&state.db, &did)
173 .await
174 .unwrap_or(false)
175 {
176 return Some(CurrentUser {
177 did,
178 handle: None,
179 sid: None,
180 });
181 }
182 }
183 None
184}
185
186async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
188 current_session(state, headers).await.map(|u| u.did)
189}
190
191pub fn router(state: AppState) -> Router {
197 let limiter = RateLimiter::shared();
201 let rl_state = RateLimitState {
205 limiter,
206 trusted_header: state.config.trusted_ip_header.clone(),
207 };
208
209 Router::new()
210 .route("/health", get(health))
211 .route("/about", get(about))
212 .route("/manage", get(manage))
213 .route("/", get(index))
214 .route("/entries/{id}", get(entry_view))
215 .route("/entries/{id}/read", post(mark_read))
216 .route("/entries/{id}/star", post(toggle_star))
217 .route("/read-all", post(mark_all_read))
218 .route("/subscriptions", post(add_subscription))
219 .route("/subscriptions/{rkey}/delete", post(delete_subscription))
220 .route("/subscriptions/{rkey}/rename", post(rename_subscription))
221 .route("/folders", post(create_folder))
222 .route("/folders/{rkey}/rename", post(rename_folder))
223 .route("/folders/{rkey}/delete", post(delete_folder))
224 .route(
227 "/opml",
228 post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
229 )
230 .route("/opml/export", get(export_opml))
231 .route("/login", get(login_form).post(login_submit))
232 .route(
233 "/beta/redeem",
234 get(beta_redeem_form).post(beta_redeem_submit),
235 )
236 .route("/admin/invites", post(admin_mint_invites))
237 .route("/account/delete", post(account_delete))
238 .route("/oauth/callback", get(oauth_callback))
239 .route("/logout", post(logout))
240 .nest_service("/static", ServeDir::new("static"))
241 .layer(middleware::from_fn(cache_control))
246 .layer(middleware::from_fn_with_state(rl_state, rate_limit))
249 .layer(TraceLayer::new_for_http())
250 .layer(static_header_layer(
254 "content-security-policy",
255 CONTENT_SECURITY_POLICY,
256 ))
257 .layer(static_header_layer("x-content-type-options", "nosniff"))
258 .layer(static_header_layer(
259 "referrer-policy",
260 "strict-origin-when-cross-origin",
261 ))
262 .layer(static_header_layer("x-frame-options", "DENY"))
263 .with_state(state)
264}
265
266const OPML_BODY_LIMIT: usize = 2 * 1024 * 1024;
269
270fn static_header_layer(
274 name: &'static str,
275 value: &'static str,
276) -> SetResponseHeaderLayer<header::HeaderValue> {
277 SetResponseHeaderLayer::overriding(
278 header::HeaderName::from_static(name),
279 header::HeaderValue::from_static(value),
280 )
281}
282
283fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
291 use axum::http::Method;
292 if method != Method::POST && !(method == Method::GET && path == "/login") {
293 return false;
294 }
295 match path {
296 "/login" | "/beta/redeem" | "/subscriptions" | "/opml" | "/read-all" | "/admin/invites"
297 | "/account/delete" => true,
298 p => p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")),
300 }
301}
302
303#[derive(Clone)]
306struct RateLimitState {
307 limiter: RateLimiter,
308 trusted_header: Option<String>,
311}
312
313#[derive(Clone)]
318struct RateLimiter {
319 inner: std::sync::Arc<Mutex<HashMap<IpAddr, Bucket>>>,
320}
321
322struct Bucket {
324 tokens: f64,
325 last: Instant,
326}
327
328const RATE_BURST: f64 = 20.0;
330const RATE_REFILL_PER_SEC: f64 = 1.0;
332const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
334
335impl RateLimiter {
336 fn shared() -> Self {
338 Self {
339 inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
340 }
341 }
342
343 fn check(&self, ip: IpAddr) -> bool {
346 let now = Instant::now();
347 let mut map = match self.inner.lock() {
348 Ok(m) => m,
349 Err(p) => p.into_inner(),
351 };
352 map.retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
354
355 let bucket = map.entry(ip).or_insert(Bucket {
356 tokens: RATE_BURST,
357 last: now,
358 });
359 let elapsed = now.duration_since(bucket.last).as_secs_f64();
360 bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
361 bucket.last = now;
362 if bucket.tokens >= 1.0 {
363 bucket.tokens -= 1.0;
364 true
365 } else {
366 false
367 }
368 }
369}
370
371fn client_ip(
392 headers: &HeaderMap,
393 conn: Option<&SocketAddr>,
394 trusted_header: Option<&str>,
395) -> Option<IpAddr> {
396 if let Some(name) = trusted_header {
397 if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
398 if let Some(last) = raw.split(',').next_back() {
401 if let Ok(ip) = last.trim().parse::<IpAddr>() {
402 return Some(ip);
403 }
404 }
405 }
406 }
408 conn.map(|s| s.ip())
409}
410
411async fn rate_limit(
416 State(rl): State<RateLimitState>,
417 req: axum::extract::Request,
418 next: Next,
419) -> Response {
420 let path = req.uri().path().to_string();
421 let method = req.method().clone();
422 if is_rate_limited_path(&path, &method) {
423 let conn = req
424 .extensions()
425 .get::<ConnectInfo<SocketAddr>>()
426 .map(|c| c.0);
427 let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
428 if let Some(ip) = ip {
429 if !rl.limiter.check(ip) {
430 warn!(%ip, %path, "rate limit exceeded");
431 return (
432 StatusCode::TOO_MANY_REQUESTS,
433 [(header::RETRY_AFTER, "1")],
434 "rate limit exceeded\n",
435 )
436 .into_response();
437 }
438 }
439 }
440 next.run(req).await
441}
442
443async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
453 let path = req.uri().path().to_string();
454 let is_login_landing = path == "/login"
457 && req.method() == axum::http::Method::GET
458 && !req.uri().query().unwrap_or("").contains("handle=");
459 let public = is_login_landing || path == "/about" || path.starts_with("/static/");
460
461 let mut resp = next.run(req).await;
462 if resp.headers().contains_key(header::CACHE_CONTROL) {
463 return resp;
464 }
465 let value = if public {
466 "public, max-age=300"
467 } else {
468 "no-store"
469 };
470 if let Ok(hv) = header::HeaderValue::from_str(value) {
471 resp.headers_mut().insert(header::CACHE_CONTROL, hv);
472 }
473 resp
474}
475
476async fn health() -> impl IntoResponse {
482 (StatusCode::OK, format!("ok featherreader/{VERSION}\n"))
483}
484
485async fn about() -> Response {
489 render(&AboutTemplate {
490 version: VERSION,
491 repo_url: REPO_URL,
492 kofi_url: KOFI_URL,
493 })
494}
495
496struct FeedView {
503 rkey: String,
505 url: String,
507 title: String,
508 unread: i64,
509 selected: bool,
511 folder: Option<String>,
516}
517
518struct FolderView {
520 rkey: String,
522 uri: String,
524 name: String,
525 feeds: Vec<FeedView>,
526 selected: bool,
528}
529
530struct EntryRow {
532 id: i64,
533 title: String,
534 feed_title: String,
535 published: String,
536 read: bool,
537 starred: bool,
538 link: String,
541}
542
543struct FolderOption {
545 uri: String,
546 name: String,
547}
548
549struct Nav {
554 handle: String,
556 avatar: String,
558 view: String,
560 scope_qs: String,
563 folders: Vec<FolderView>,
566 loose_feeds: Vec<FeedView>,
567 manage_active: bool,
569}
570
571#[derive(Template)]
573#[template(path = "index.html")]
574struct IndexTemplate {
575 version: &'static str,
576 repo_url: &'static str,
577 kofi_url: &'static str,
578 flash: String,
579 nav: Nav,
581 entries: Vec<EntryRow>,
583 heading: String,
585 feed_scope: Option<String>,
587}
588
589#[derive(Template)]
591#[template(path = "manage.html")]
592struct ManageTemplate {
593 version: &'static str,
594 repo_url: &'static str,
595 kofi_url: &'static str,
596 flash: String,
597 nav: Nav,
598 folder_options: Vec<FolderOption>,
600 folders: Vec<FolderView>,
602 loose_feeds: Vec<FeedView>,
603}
604
605#[derive(Template)]
607#[template(path = "about.html")]
608struct AboutTemplate {
609 version: &'static str,
610 repo_url: &'static str,
611 kofi_url: &'static str,
612}
613
614#[derive(Template)]
616#[template(path = "entry.html")]
617struct EntryTemplate {
618 version: &'static str,
619 repo_url: &'static str,
620 kofi_url: &'static str,
621 nav: Nav,
622 id: i64,
623 title: String,
624 feed_title: String,
625 author: Option<String>,
626 published: String,
627 url: Option<String>,
628 content_html: Option<String>,
629 read: bool,
630 starred: bool,
631 back_qs: String,
633 prev_id: Option<i64>,
635 next_id: Option<i64>,
636 oob: bool,
638}
639
640#[derive(Template)]
642#[template(path = "entry_row.html")]
643struct EntryRowTemplate {
644 e: EntryRow,
645}
646
647#[derive(Template)]
652#[template(path = "entry_actionbar.html")]
653struct EntryActionBarTemplate {
654 id: i64,
655 read: bool,
656 starred: bool,
657 oob: bool,
659}
660
661#[derive(Template)]
663#[template(path = "login.html")]
664struct LoginTemplate {
665 repo_url: &'static str,
666 error: String,
667 flash: String,
670}
671
672#[derive(Template)]
674#[template(path = "beta_redeem.html")]
675struct BetaRedeemTemplate {
676 repo_url: &'static str,
677 error: String,
678 capacity_full: bool,
681}
682
683fn render<T: Template>(tmpl: &T) -> Response {
690 match tmpl.render() {
691 Ok(body) => Html(body).into_response(),
692 Err(err) => {
693 warn!(%err, "template render failed");
694 (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
695 }
696 }
697}
698
699struct WebError {
704 err: anyhow::Error,
705 status: StatusCode,
706}
707
708impl<E: Into<anyhow::Error>> From<E> for WebError {
709 fn from(err: E) -> Self {
710 WebError {
711 err: err.into(),
712 status: StatusCode::INTERNAL_SERVER_ERROR,
713 }
714 }
715}
716
717impl WebError {
718 fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
720 WebError {
721 err: err.into(),
722 status,
723 }
724 }
725}
726
727impl IntoResponse for WebError {
728 fn into_response(self) -> Response {
729 warn!(error = %self.err, status = %self.status, "request failed");
730 let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
731 "internal error"
732 } else {
733 self.status.canonical_reason().unwrap_or("error")
734 };
735 (self.status, body).into_response()
736 }
737}
738
739fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
744 let status = err.status();
745 WebError::with_status(err, status)
746}
747
748fn display_title(title: Option<&str>, url: &str) -> String {
751 if let Some(t) = title {
752 let t = t.trim();
753 if !t.is_empty() {
754 return t.to_string();
755 }
756 }
757 url::Url::parse(url)
758 .ok()
759 .and_then(|u| u.host_str().map(str::to_string))
760 .unwrap_or_else(|| url.to_string())
761}
762
763fn display_handle(handle: Option<&str>, did: &str) -> String {
766 match handle {
767 Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
768 _ => did.rsplit(':').next().unwrap_or(did).to_string(),
769 }
770}
771
772fn avatar_initials(handle: Option<&str>, did: &str) -> String {
774 let source = handle
775 .map(|h| h.trim().trim_start_matches('@'))
776 .filter(|h| !h.is_empty())
777 .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
778 let letters: String = source
779 .chars()
780 .filter(|c| c.is_alphanumeric())
781 .take(2)
782 .collect::<String>()
783 .to_lowercase();
784 if letters.is_empty() {
785 "fr".to_string()
786 } else {
787 letters
788 }
789}
790
791fn display_date(published: Option<&str>) -> String {
794 match published {
795 Some(p) if p.len() >= 10 => p[..10].to_string(),
796 Some(p) => p.to_string(),
797 None => String::new(),
798 }
799}
800
801fn qenc(s: &str) -> String {
805 let mut out = String::with_capacity(s.len() * 3);
806 for b in s.bytes() {
807 match b {
808 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
809 out.push(b as char)
810 }
811 _ => out.push_str(&format!("%{b:02X}")),
812 }
813 }
814 out
815}
816
817#[derive(Debug, Deserialize, Default)]
823struct IndexQuery {
824 #[serde(default)]
826 feed: Option<String>,
827 #[serde(default)]
829 folder: Option<String>,
830 #[serde(default)]
832 view: Option<String>,
833 #[serde(default)]
835 flash: Option<String>,
836}
837
838struct ResolvedSub {
841 rkey: String,
842 sub: Subscription,
843 feed: Option<store::Feed>,
844}
845
846async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
850 let pool = &state.db;
851 let subs = match state.sidecar.list_subscriptions_sorted(did).await {
852 Ok(s) => s,
853 Err(err) => {
854 warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
855 let feeds = store::feeds_for_did(pool, did).await.unwrap_or_default();
862 return feeds
863 .into_iter()
864 .map(|f| ResolvedSub {
865 rkey: String::new(),
866 sub: Subscription::new(f.url.clone(), now_rfc3339()),
867 feed: Some(f),
868 })
869 .collect();
870 }
871 };
872
873 let mut out = Vec::with_capacity(subs.len());
874 for (rkey, sub) in subs {
875 let feed = match store::get_feed_by_url(pool, &sub.url).await {
876 Ok(Some(f)) => Some(f),
877 Ok(None) => {
878 let _ = store::upsert_feed(
880 pool,
881 &store::NewFeed {
882 url: sub.url.clone(),
883 title: sub.title.clone(),
884 site_url: sub.site_url.clone(),
885 ..Default::default()
886 },
887 )
888 .await;
889 store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
890 }
891 Err(err) => {
892 warn!(%err, url = %sub.url, "get_feed_by_url failed");
893 None
894 }
895 };
896 out.push(ResolvedSub { rkey, sub, feed });
897 }
898 sync_sub_refs(pool, did, &out).await;
902 out
903}
904
905async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
909 let feed_ids: Vec<i64> = subs
910 .iter()
911 .filter_map(|s| s.feed.as_ref().map(|f| f.id))
912 .collect();
913 if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
914 warn!(%err, %did, "failed to sync sub_ref projection");
915 }
916}
917
918async fn index(
921 State(state): State<AppState>,
922 headers: HeaderMap,
923 Query(q): Query<IndexQuery>,
924) -> Result<Response, WebError> {
925 let user = match current_session(&state, &headers).await {
926 Some(u) => u,
927 None => return Ok(Redirect::to("/login").into_response()),
928 };
929 let did = user.did.clone();
930 let pool = &state.db;
931
932 let subs = resolve_subscriptions(&state, &did).await;
933
934 let unread = store::get_unread_for_did(pool, &did).await?;
936 let starred = store::get_starred_for_did(pool, &did).await?;
937 let starred_ids: std::collections::HashSet<i64> = starred.iter().map(|e| e.id).collect();
938
939 let view = match q.view.as_deref() {
941 Some("all") => "all",
942 Some("starred") => "starred",
943 _ => "unread",
944 }
945 .to_string();
946
947 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
949
950 let feed_url_by_id = |id: i64| -> Option<String> {
952 subs.iter()
953 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
954 .map(|s| s.sub.url.clone())
955 };
956 let feed_title_by_id = |id: i64| -> String {
957 subs.iter()
958 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
959 .map(|s| {
960 display_title(
961 s.sub
962 .title
963 .as_deref()
964 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
965 &s.sub.url,
966 )
967 })
968 .unwrap_or_default()
969 };
970
971 let in_scope = |feed_id: i64| -> bool {
972 match &scope_urls {
973 None => true,
974 Some(urls) => feed_url_by_id(feed_id)
975 .map(|u| urls.contains(&u))
976 .unwrap_or(false),
977 }
978 };
979
980 let source = match view.as_str() {
982 "all" => {
983 let mut all = Vec::new();
985 for s in &subs {
986 if let Some(f) = &s.feed {
987 if in_scope(f.id) {
988 let mut es = store::entries_for_feed(pool, &did, f.id)
989 .await
990 .unwrap_or_default();
991 all.append(&mut es);
992 }
993 }
994 }
995 all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
996 all
997 }
998 "starred" => starred
999 .iter()
1000 .filter(|e| in_scope(e.feed_id))
1001 .cloned()
1002 .collect(),
1003 _ => unread
1004 .iter()
1005 .filter(|e| in_scope(e.feed_id))
1006 .cloned()
1007 .collect(),
1008 };
1009
1010 let entry_scope_qs = {
1012 let mut parts = Vec::new();
1013 if let Some(f) = q.feed.as_deref() {
1014 parts.push(format!("feed={}", qenc(f)));
1015 }
1016 if let Some(f) = q.folder.as_deref() {
1017 parts.push(format!("folder={}", qenc(f)));
1018 }
1019 if view != "unread" {
1020 parts.push(format!("view={}", qenc(&view)));
1021 }
1022 parts.join("&")
1023 };
1024 let entry_link = |id: i64| -> String {
1025 if entry_scope_qs.is_empty() {
1026 format!("/entries/{id}")
1027 } else {
1028 format!("/entries/{id}?{entry_scope_qs}")
1029 }
1030 };
1031
1032 let entries: Vec<EntryRow> = source
1033 .iter()
1034 .map(|e| EntryRow {
1035 id: e.id,
1036 title: e
1037 .title
1038 .clone()
1039 .filter(|t| !t.trim().is_empty())
1040 .unwrap_or_else(|| "(untitled)".to_string()),
1041 feed_title: feed_title_by_id(e.feed_id),
1042 published: display_date(e.published.as_deref()),
1043 read: view != "unread" && !unread.iter().any(|u| u.id == e.id),
1044 starred: starred_ids.contains(&e.id),
1045 link: entry_link(e.id),
1046 })
1047 .collect();
1048
1049 let selected_feed = q.feed.as_deref();
1050 let selected_folder = q.folder.as_deref();
1051
1052 let (folder_views, loose_feeds, _folder_options) =
1054 build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
1055
1056 let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
1058 let name = subs
1059 .iter()
1060 .find(|s| s.sub.url == feed_url)
1061 .map(|s| {
1062 display_title(
1063 s.sub
1064 .title
1065 .as_deref()
1066 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1067 &s.sub.url,
1068 )
1069 })
1070 .unwrap_or_else(|| display_title(None, feed_url));
1071 (name, format!("feed={}", qenc(feed_url)))
1072 } else if let Some(folder_uri) = selected_folder {
1073 let name = folder_views
1074 .iter()
1075 .find(|f| f.uri == folder_uri)
1076 .map(|f| f.name.clone())
1077 .unwrap_or_else(|| "Folder".to_string());
1078 (name, format!("folder={}", qenc(folder_uri)))
1079 } else {
1080 let h = match view.as_str() {
1081 "all" => "All",
1082 "starred" => "Starred",
1083 _ => "Unread",
1084 };
1085 (h.to_string(), String::new())
1086 };
1087
1088 let feed_scope = selected_feed.map(str::to_string);
1089 let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
1090
1091 let tmpl = IndexTemplate {
1092 version: VERSION,
1093 repo_url: REPO_URL,
1094 kofi_url: KOFI_URL,
1095 flash: q.flash.unwrap_or_default(),
1096 nav,
1097 entries,
1098 heading,
1099 feed_scope,
1100 };
1101 Ok(render(&tmpl))
1102}
1103
1104#[derive(Debug, Deserialize, Default)]
1106struct ManageQuery {
1107 #[serde(default)]
1108 flash: Option<String>,
1109}
1110
1111async fn manage(
1116 State(state): State<AppState>,
1117 headers: HeaderMap,
1118 Query(q): Query<ManageQuery>,
1119) -> Result<Response, WebError> {
1120 let user = match current_session(&state, &headers).await {
1121 Some(u) => u,
1122 None => return Ok(Redirect::to("/login").into_response()),
1123 };
1124 let did = user.did.clone();
1125
1126 let subs = resolve_subscriptions(&state, &did).await;
1127 let (folder_views, loose_feeds, folder_options) =
1128 build_sidebar(&state, &did, &subs, None, None).await;
1129
1130 let nav = build_nav(
1132 &user,
1133 "unread",
1134 String::new(),
1135 folder_views.iter().map(clone_folder_view).collect(),
1136 loose_feeds.iter().map(clone_feed_view).collect(),
1137 true,
1138 );
1139
1140 let tmpl = ManageTemplate {
1141 version: VERSION,
1142 repo_url: REPO_URL,
1143 kofi_url: KOFI_URL,
1144 flash: q.flash.unwrap_or_default(),
1145 nav,
1146 folder_options,
1147 folders: folder_views,
1148 loose_feeds,
1149 };
1150 Ok(render(&tmpl))
1151}
1152
1153fn clone_feed_view(f: &FeedView) -> FeedView {
1156 FeedView {
1157 rkey: f.rkey.clone(),
1158 url: f.url.clone(),
1159 title: f.title.clone(),
1160 unread: f.unread,
1161 selected: f.selected,
1162 folder: f.folder.clone(),
1163 }
1164}
1165
1166fn clone_folder_view(f: &FolderView) -> FolderView {
1167 FolderView {
1168 rkey: f.rkey.clone(),
1169 uri: f.uri.clone(),
1170 name: f.name.clone(),
1171 feeds: f.feeds.iter().map(clone_feed_view).collect(),
1172 selected: f.selected,
1173 }
1174}
1175
1176fn scope_urls_for(
1181 subs: &[ResolvedSub],
1182 feed: Option<&str>,
1183 folder: Option<&str>,
1184) -> Option<Vec<String>> {
1185 if let Some(feed_url) = feed {
1186 Some(vec![feed_url.to_string()])
1187 } else {
1188 folder.map(|folder_uri| {
1189 subs.iter()
1190 .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
1191 .map(|s| s.sub.url.clone())
1192 .collect()
1193 })
1194 }
1195}
1196
1197fn folder_uri(did: &str, rkey: &str) -> String {
1199 format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
1200}
1201
1202async fn build_sidebar(
1206 state: &AppState,
1207 did: &str,
1208 subs: &[ResolvedSub],
1209 selected_feed: Option<&str>,
1210 selected_folder: Option<&str>,
1211) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
1212 let pool = &state.db;
1213 let unread = store::get_unread_for_did(pool, did)
1214 .await
1215 .unwrap_or_default();
1216 let folders = state
1217 .sidecar
1218 .list_folders_sorted(did)
1219 .await
1220 .unwrap_or_default();
1221
1222 let unread_count = |feed_id: Option<i64>| -> i64 {
1223 match feed_id {
1224 Some(id) => unread.iter().filter(|e| e.feed_id == id).count() as i64,
1225 None => 0,
1226 }
1227 };
1228 let mk_feed_view = |s: &ResolvedSub| FeedView {
1229 rkey: s.rkey.clone(),
1230 url: s.sub.url.clone(),
1231 title: display_title(
1232 s.sub
1233 .title
1234 .as_deref()
1235 .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1236 &s.sub.url,
1237 ),
1238 unread: unread_count(s.feed.as_ref().map(|f| f.id)),
1239 selected: selected_feed == Some(s.sub.url.as_str()),
1240 folder: s.sub.folder.clone(),
1241 };
1242
1243 let mut folder_views = Vec::with_capacity(folders.len());
1244 for (rkey, folder) in &folders {
1245 let uri = folder_uri(did, rkey);
1246 let feeds: Vec<FeedView> = subs
1247 .iter()
1248 .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
1249 .map(mk_feed_view)
1250 .collect();
1251 folder_views.push(FolderView {
1252 rkey: rkey.clone(),
1253 uri: uri.clone(),
1254 name: folder.name.clone(),
1255 feeds,
1256 selected: selected_folder == Some(uri.as_str()),
1257 });
1258 }
1259
1260 let known_uris: std::collections::HashSet<String> =
1261 folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
1262 let loose_feeds: Vec<FeedView> = subs
1263 .iter()
1264 .filter(|s| {
1265 s.sub
1266 .folder
1267 .as_deref()
1268 .map(|f| !known_uris.contains(f))
1269 .unwrap_or(true)
1270 })
1271 .map(mk_feed_view)
1272 .collect();
1273
1274 let folder_options: Vec<FolderOption> = folders
1275 .iter()
1276 .map(|(rkey, folder)| FolderOption {
1277 name: folder.name.clone(),
1278 uri: folder_uri(did, rkey),
1279 })
1280 .collect();
1281
1282 (folder_views, loose_feeds, folder_options)
1283}
1284
1285fn build_nav(
1287 user: &CurrentUser,
1288 view: &str,
1289 scope_qs: String,
1290 folders: Vec<FolderView>,
1291 loose_feeds: Vec<FeedView>,
1292 manage_active: bool,
1293) -> Nav {
1294 Nav {
1295 handle: display_handle(user.handle.as_deref(), &user.did),
1296 avatar: avatar_initials(user.handle.as_deref(), &user.did),
1297 view: view.to_string(),
1298 scope_qs,
1299 folders,
1300 loose_feeds,
1301 manage_active,
1302 }
1303}
1304
1305#[derive(Debug, Deserialize, Default)]
1312struct EntryQuery {
1313 #[serde(default)]
1314 feed: Option<String>,
1315 #[serde(default)]
1316 folder: Option<String>,
1317 #[serde(default)]
1318 view: Option<String>,
1319}
1320
1321async fn entry_view(
1324 State(state): State<AppState>,
1325 headers: HeaderMap,
1326 Path(id): Path<i64>,
1327 Query(q): Query<EntryQuery>,
1328) -> Result<Response, WebError> {
1329 let user = match current_session(&state, &headers).await {
1330 Some(u) => u,
1331 None => return Ok(Redirect::to("/login").into_response()),
1332 };
1333 let did = user.did.clone();
1334 let pool = &state.db;
1335
1336 let subs = resolve_subscriptions(&state, &did).await;
1340
1341 let entry = match get_entry_by_id(pool, &did, id).await? {
1342 Some(e) => e,
1343 None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1344 };
1345
1346 let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
1347
1348 let read = entry_is_read(pool, &did, id).await?;
1349 let starred = entry_is_starred(pool, &did, id).await?;
1350
1351 let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
1354
1355 let back_qs = scope_query(&q);
1356
1357 let (folder_views, loose_feeds, _) =
1358 build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
1359 let nav_view = match q.view.as_deref() {
1360 Some("all") => "all",
1361 Some("starred") => "starred",
1362 _ => "unread",
1363 };
1364 let nav = build_nav(
1365 &user,
1366 nav_view,
1367 back_qs.clone(),
1368 folder_views,
1369 loose_feeds,
1370 false,
1371 );
1372
1373 let tmpl = EntryTemplate {
1374 version: VERSION,
1375 repo_url: REPO_URL,
1376 kofi_url: KOFI_URL,
1377 nav,
1378 id: entry.id,
1379 title: entry
1380 .title
1381 .clone()
1382 .filter(|t| !t.trim().is_empty())
1383 .unwrap_or_else(|| "(untitled)".to_string()),
1384 feed_title,
1385 author: entry.author.clone().filter(|a| !a.trim().is_empty()),
1386 published: display_date(entry.published.as_deref()),
1387 url: entry.url.clone().filter(|u| !u.trim().is_empty()),
1388 content_html: entry.content_html.clone(),
1389 read,
1390 starred,
1391 back_qs,
1392 prev_id,
1393 next_id,
1394 oob: false,
1395 };
1396 Ok(render(&tmpl))
1397}
1398
1399async fn neighbors_in_scope(
1402 state: &AppState,
1403 did: &str,
1404 q: &EntryQuery,
1405 current: i64,
1406) -> (Option<i64>, Option<i64>) {
1407 let idx_q = IndexQuery {
1408 feed: q.feed.clone(),
1409 folder: q.folder.clone(),
1410 view: q.view.clone(),
1411 flash: None,
1412 };
1413 let ids = list_entry_ids(state, did, &idx_q).await;
1414 let pos = ids.iter().position(|&x| x == current);
1415 match pos {
1416 Some(p) => {
1417 let prev = if p > 0 { Some(ids[p - 1]) } else { None };
1418 let next = ids.get(p + 1).copied();
1419 (prev, next)
1420 }
1421 None => (None, None),
1422 }
1423}
1424
1425async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
1428 let pool = &state.db;
1429 let subs = resolve_subscriptions(state, did).await;
1430
1431 let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1432 let feed_url_by_id = |id: i64| -> Option<String> {
1433 subs.iter()
1434 .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1435 .map(|s| s.sub.url.clone())
1436 };
1437 let in_scope = |feed_id: i64| -> bool {
1438 match &scope_urls {
1439 None => true,
1440 Some(urls) => feed_url_by_id(feed_id)
1441 .map(|u| urls.contains(&u))
1442 .unwrap_or(false),
1443 }
1444 };
1445
1446 let view = q.view.as_deref().unwrap_or("unread");
1447 let entries = match view {
1448 "all" => {
1449 let mut all = Vec::new();
1450 for s in &subs {
1451 if let Some(f) = &s.feed {
1452 if in_scope(f.id) {
1453 let mut es = store::entries_for_feed(pool, did, f.id)
1454 .await
1455 .unwrap_or_default();
1456 all.append(&mut es);
1457 }
1458 }
1459 }
1460 all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1461 all
1462 }
1463 "starred" => store::get_starred_for_did(pool, did)
1464 .await
1465 .unwrap_or_default()
1466 .into_iter()
1467 .filter(|e| in_scope(e.feed_id))
1468 .collect(),
1469 _ => store::get_unread_for_did(pool, did)
1470 .await
1471 .unwrap_or_default()
1472 .into_iter()
1473 .filter(|e| in_scope(e.feed_id))
1474 .collect(),
1475 };
1476 entries.into_iter().map(|e| e.id).collect()
1477}
1478
1479fn scope_query(q: &EntryQuery) -> String {
1481 let mut parts = Vec::new();
1482 if let Some(f) = q.feed.as_deref() {
1483 parts.push(format!("feed={}", qenc(f)));
1484 }
1485 if let Some(f) = q.folder.as_deref() {
1486 parts.push(format!("folder={}", qenc(f)));
1487 }
1488 if let Some(v) = q.view.as_deref() {
1489 if v != "unread" {
1490 parts.push(format!("view={}", qenc(v)));
1491 }
1492 }
1493 parts.join("&")
1494}
1495
1496#[derive(Debug, Deserialize)]
1502struct ReadForm {
1503 #[serde(default)]
1504 read: Option<String>,
1505}
1506
1507async fn mark_read(
1509 State(state): State<AppState>,
1510 Path(id): Path<i64>,
1511 headers: HeaderMap,
1512 Form(form): Form<ReadForm>,
1513) -> Result<Response, WebError> {
1514 let did = match current_did(&state, &headers).await {
1515 Some(d) => d,
1516 None => return Ok(Redirect::to("/login").into_response()),
1517 };
1518 let pool = &state.db;
1519
1520 let read = matches!(
1521 form.read.as_deref(),
1522 Some("true") | Some("1") | Some("on") | None
1523 );
1524
1525 resolve_subscriptions(&state, &did).await;
1530 if !store::mark_read(pool, &did, id, read).await? {
1531 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1532 }
1533
1534 if !is_htmx(&headers) {
1535 return Ok(Redirect::to("/").into_response());
1536 }
1537
1538 if is_reader_request(&headers) {
1542 let starred = entry_is_starred(pool, &did, id).await?;
1543 return Ok(render(&EntryActionBarTemplate {
1544 id,
1545 read,
1546 starred,
1547 oob: true,
1548 }));
1549 }
1550
1551 let row = build_entry_row(pool, &did, id, Some(read)).await?;
1552 match row {
1553 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1554 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1555 }
1556}
1557
1558#[derive(Debug, Deserialize)]
1564struct StarForm {
1565 #[serde(default)]
1566 starred: Option<String>,
1567}
1568
1569async fn toggle_star(
1575 State(state): State<AppState>,
1576 Path(id): Path<i64>,
1577 headers: HeaderMap,
1578 Form(form): Form<StarForm>,
1579) -> Result<Response, WebError> {
1580 let did = match current_did(&state, &headers).await {
1581 Some(d) => d,
1582 None => return Ok(Redirect::to("/login").into_response()),
1583 };
1584 let pool = &state.db;
1585
1586 let starred = matches!(
1587 form.starred.as_deref(),
1588 Some("true") | Some("1") | Some("on") | None
1589 );
1590
1591 resolve_subscriptions(&state, &did).await;
1595 if !store::mark_starred(pool, &did, id, starred).await? {
1596 return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1597 }
1598
1599 if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
1602 let entry_url = entry.url.clone().unwrap_or_default();
1603 if !entry_url.is_empty() {
1604 if starred {
1605 let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
1606 saved.title = entry.title.clone();
1607 saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
1608 saved.entry_id = Some(entry.guid.clone());
1609 match state.sidecar.add_saved(&did, &saved).await {
1610 Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
1611 Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
1612 }
1613 } else {
1614 match state.sidecar.list_saved(&did).await {
1616 Ok(records) => {
1617 for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
1618 if let Err(err) = state.sidecar.remove_saved(&did, rkey).await {
1619 warn!(%err, %did, %rkey, "PDS saved delete failed");
1620 }
1621 }
1622 }
1623 Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
1624 }
1625 }
1626 }
1627 }
1628
1629 if !is_htmx(&headers) {
1630 return Ok(Redirect::to("/").into_response());
1631 }
1632
1633 if is_reader_request(&headers) {
1635 let read = entry_is_read(pool, &did, id).await?;
1636 return Ok(render(&EntryActionBarTemplate {
1637 id,
1638 read,
1639 starred,
1640 oob: true,
1641 }));
1642 }
1643
1644 let row = build_entry_row(pool, &did, id, None).await?;
1645 match row {
1646 Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1647 None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1648 }
1649}
1650
1651async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
1653 sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
1654 .bind(feed_id)
1655 .fetch_optional(pool)
1656 .await
1657 .ok()
1658 .flatten()
1659}
1660
1661#[derive(Debug, Deserialize, Default)]
1668struct ReadAllQuery {
1669 #[serde(default)]
1670 feed: Option<String>,
1671}
1672
1673async fn mark_all_read(
1676 State(state): State<AppState>,
1677 headers: HeaderMap,
1678 Query(q): Query<ReadAllQuery>,
1679) -> Result<Response, WebError> {
1680 let did = match current_did(&state, &headers).await {
1681 Some(d) => d,
1682 None => return Ok(Redirect::to("/login").into_response()),
1683 };
1684 let pool = &state.db;
1685
1686 resolve_subscriptions(&state, &did).await;
1689
1690 if let Some(feed_url) = q.feed.as_deref() {
1691 if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
1692 store::mark_feed_read(pool, &did, feed.id, true).await?;
1693 }
1694 return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
1695 }
1696
1697 let unread = store::get_unread_for_did(pool, &did).await?;
1699 for e in &unread {
1700 store::mark_read(pool, &did, e.id, true).await?;
1701 }
1702 Ok(Redirect::to("/").into_response())
1703}
1704
1705const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
1715 FeatherReader stores your subscriptions in your public PDS, so it supports public \
1716 feeds for now — private-feed support arrives when atproto's private data \
1717 (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
1718
1719#[derive(Debug, Deserialize)]
1721struct SubscribeForm {
1722 url: String,
1723 #[serde(default)]
1725 folder: Option<String>,
1726}
1727
1728async fn add_subscription(
1730 State(state): State<AppState>,
1731 headers: HeaderMap,
1732 Form(form): Form<SubscribeForm>,
1733) -> Result<Response, WebError> {
1734 let did = match current_did(&state, &headers).await {
1735 Some(d) => d,
1736 None => return Ok(Redirect::to("/login").into_response()),
1737 };
1738 let pool = &state.db;
1739 let input = form.url.trim().to_string();
1740 if input.is_empty() {
1741 return Ok(Redirect::to("/").into_response());
1742 }
1743
1744 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
1748 info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
1749 return Ok(
1750 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1751 );
1752 }
1753
1754 let cap = state.config.max_subs_per_did;
1758 if cap > 0 {
1759 match store::count_subscriptions_for_did(pool, &did).await {
1760 Ok(n) if n >= cap => {
1761 info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
1762 return Ok(Redirect::to(&format!(
1763 "/?flash={}",
1764 qenc(&format!(
1765 "Subscription limit reached ({cap}). Remove a feed before adding another."
1766 ))
1767 ))
1768 .into_response());
1769 }
1770 Ok(_) => {}
1771 Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
1772 }
1773 }
1774
1775 let feed_url = match resolve_feed_url(&state.config, &input).await {
1776 Ok(u) => u,
1777 Err(err) => {
1778 warn!(%err, url = %input, "could not resolve a feed from the given URL");
1779 return Ok(Redirect::to(&format!(
1780 "/?flash={}",
1781 qenc("Couldn't find a feed at that URL")
1782 ))
1783 .into_response());
1784 }
1785 };
1786
1787 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1791 info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
1792 return Ok(
1793 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1794 );
1795 }
1796
1797 let feeds_cap = state.config.max_feeds_global;
1801 if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
1802 match store::count_feeds(pool).await {
1803 Ok(n) if n >= feeds_cap => {
1804 warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
1805 return Ok(Redirect::to(&format!(
1806 "/?flash={}",
1807 qenc(
1808 "This instance is at its feed capacity right now. Please try again later."
1809 )
1810 ))
1811 .into_response());
1812 }
1813 Ok(_) => {}
1814 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1815 }
1816 }
1817
1818 store::upsert_feed(
1819 pool,
1820 &store::NewFeed {
1821 url: feed_url.clone(),
1822 ..Default::default()
1823 },
1824 )
1825 .await?;
1826
1827 if let Ok(client) = feed::build_client() {
1828 if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
1829 match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
1830 {
1831 Ok(outcome) => info!(feed = %feed_url, ?outcome, "polled new subscription"),
1832 Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
1833 }
1834 }
1835 }
1836
1837 let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
1838 if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
1839 sub.title = feed_row.title.clone();
1840 sub.site_url = feed_row.site_url.clone();
1841 }
1842 sub.folder = form
1843 .folder
1844 .map(|f| f.trim().to_string())
1845 .filter(|f| !f.is_empty());
1846
1847 match state.sidecar.add_subscription(&did, &sub).await {
1848 Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
1849 Err(err) => {
1850 warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
1851 }
1852 }
1853
1854 Ok(Redirect::to("/").into_response())
1855}
1856
1857async fn delete_subscription(
1859 State(state): State<AppState>,
1860 headers: HeaderMap,
1861 Path(rkey): Path<String>,
1862) -> Result<Response, WebError> {
1863 let did = match current_did(&state, &headers).await {
1864 Some(d) => d,
1865 None => return Ok(Redirect::to("/login").into_response()),
1866 };
1867 match state.sidecar.remove_subscription(&did, &rkey).await {
1868 Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
1869 Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
1870 }
1871 Ok(Redirect::to("/").into_response())
1872}
1873
1874#[derive(Debug, Deserialize)]
1876struct RenameSubForm {
1877 url: String,
1878 #[serde(default)]
1879 title: Option<String>,
1880 #[serde(default)]
1881 site_url: Option<String>,
1882 #[serde(default)]
1883 folder: Option<String>,
1884}
1885
1886async fn rename_subscription(
1889 State(state): State<AppState>,
1890 headers: HeaderMap,
1891 Path(rkey): Path<String>,
1892 Form(form): Form<RenameSubForm>,
1893) -> Result<Response, WebError> {
1894 let did = match current_did(&state, &headers).await {
1895 Some(d) => d,
1896 None => return Ok(Redirect::to("/login").into_response()),
1897 };
1898 let feed_url = form.url.trim().to_string();
1899
1900 if feed_url.is_empty() {
1904 return Ok(Redirect::to("/").into_response());
1905 }
1906
1907 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1913 info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
1914 return Ok(
1915 Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1916 );
1917 }
1918
1919 let feeds_cap = state.config.max_feeds_global;
1924 if feeds_cap > 0
1925 && store::get_feed_by_url(&state.db, &feed_url)
1926 .await?
1927 .is_none()
1928 {
1929 match store::count_feeds(&state.db).await {
1930 Ok(n) if n >= feeds_cap => {
1931 warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
1932 return Ok(Redirect::to(&format!(
1933 "/?flash={}",
1934 qenc(
1935 "This instance is at its feed capacity right now. Please try again later."
1936 )
1937 ))
1938 .into_response());
1939 }
1940 Ok(_) => {}
1941 Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1942 }
1943 }
1944
1945 let mut sub = Subscription::new(feed_url, now_rfc3339());
1946 sub.title = form
1947 .title
1948 .map(|t| t.trim().to_string())
1949 .filter(|t| !t.is_empty());
1950 sub.site_url = form
1951 .site_url
1952 .map(|t| t.trim().to_string())
1953 .filter(|t| !t.is_empty());
1954 sub.folder = form
1955 .folder
1956 .map(|f| f.trim().to_string())
1957 .filter(|f| !f.is_empty());
1958
1959 let _ = store::upsert_feed(
1961 &state.db,
1962 &store::NewFeed {
1963 url: sub.url.clone(),
1964 title: sub.title.clone(),
1965 site_url: sub.site_url.clone(),
1966 ..Default::default()
1967 },
1968 )
1969 .await;
1970
1971 match state.sidecar.update_subscription(&did, &rkey, &sub).await {
1972 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription"),
1973 Err(err) => warn!(%err, %did, %rkey, "PDS subscription update failed"),
1974 }
1975 Ok(Redirect::to("/").into_response())
1976}
1977
1978#[derive(Debug, Deserialize)]
1984struct FolderForm {
1985 name: String,
1986}
1987
1988async fn create_folder(
1990 State(state): State<AppState>,
1991 headers: HeaderMap,
1992 Form(form): Form<FolderForm>,
1993) -> Result<Response, WebError> {
1994 let did = match current_did(&state, &headers).await {
1995 Some(d) => d,
1996 None => return Ok(Redirect::to("/login").into_response()),
1997 };
1998 let name = form.name.trim();
1999 if name.is_empty() {
2000 return Ok(Redirect::to("/").into_response());
2001 }
2002 let folder = Folder::new(name.to_string(), now_rfc3339());
2003 match state.sidecar.add_folder(&did, &folder).await {
2004 Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
2005 Err(err) => warn!(%err, %did, "PDS folder create failed"),
2006 }
2007 Ok(Redirect::to("/").into_response())
2008}
2009
2010async fn rename_folder(
2012 State(state): State<AppState>,
2013 headers: HeaderMap,
2014 Path(rkey): Path<String>,
2015 Form(form): Form<FolderForm>,
2016) -> Result<Response, WebError> {
2017 let did = match current_did(&state, &headers).await {
2018 Some(d) => d,
2019 None => return Ok(Redirect::to("/login").into_response()),
2020 };
2021 let name = form.name.trim();
2022 if name.is_empty() {
2023 return Ok(Redirect::to("/").into_response());
2024 }
2025 let folder = Folder::new(name.to_string(), now_rfc3339());
2026 match state.sidecar.rename_folder(&did, &rkey, &folder).await {
2027 Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
2028 Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
2029 }
2030 Ok(Redirect::to("/").into_response())
2031}
2032
2033async fn delete_folder(
2036 State(state): State<AppState>,
2037 headers: HeaderMap,
2038 Path(rkey): Path<String>,
2039) -> Result<Response, WebError> {
2040 let did = match current_did(&state, &headers).await {
2041 Some(d) => d,
2042 None => return Ok(Redirect::to("/login").into_response()),
2043 };
2044 match state.sidecar.remove_folder(&did, &rkey).await {
2045 Ok(()) => info!(%did, %rkey, "deleted folder record"),
2046 Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
2047 }
2048 Ok(Redirect::to("/").into_response())
2049}
2050
2051async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
2055 let parsed =
2056 url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
2057
2058 let client = feed::build_client()?;
2059 let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
2063 let final_url = resp.url().clone();
2064 let content_type = resp
2065 .headers()
2066 .get(axum::http::header::CONTENT_TYPE)
2067 .and_then(|v| v.to_str().ok())
2068 .unwrap_or("")
2069 .to_ascii_lowercase();
2070 let raw = crate::net::read_capped(resp).await?;
2073 let body = String::from_utf8_lossy(&raw).into_owned();
2074
2075 let looks_like_feed = content_type.contains("xml")
2076 || content_type.contains("rss")
2077 || content_type.contains("atom")
2078 || content_type.contains("application/feed+json")
2079 || {
2080 let head = body.trim_start();
2081 head.starts_with("<?xml")
2082 || head.starts_with("<rss")
2083 || head.starts_with("<feed")
2084 || head.contains("<rss")
2085 || head.contains("<feed")
2086 };
2087 if looks_like_feed {
2088 return Ok(final_url.to_string());
2089 }
2090
2091 match feed::discover_feed(&body, Some(&final_url)) {
2092 Some(u) => Ok(u.to_string()),
2093 None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
2094 }
2095}
2096
2097#[derive(Debug, Deserialize, Default)]
2103struct LoginQuery {
2104 #[serde(default)]
2105 handle: Option<String>,
2106 #[serde(default)]
2107 error: Option<String>,
2108 #[serde(default)]
2109 flash: Option<String>,
2110}
2111
2112async fn login_form(
2119 State(state): State<AppState>,
2120 headers: HeaderMap,
2121 Query(q): Query<LoginQuery>,
2122) -> Response {
2123 if let Some(handle) = q
2124 .handle
2125 .map(|h| h.trim().to_string())
2126 .filter(|h| !h.is_empty())
2127 {
2128 if !may_start_oauth(&state, &headers).await {
2129 return Redirect::to("/beta/redeem").into_response();
2130 }
2131 return start_oauth(&state, &handle);
2132 }
2133 render(&LoginTemplate {
2134 repo_url: REPO_URL,
2135 error: q.error.unwrap_or_default(),
2136 flash: q.flash.unwrap_or_default(),
2137 })
2138}
2139
2140async fn login_submit(
2143 State(state): State<AppState>,
2144 headers: HeaderMap,
2145 Form(form): Form<LoginForm>,
2146) -> Response {
2147 let handle = form.handle.trim();
2148 if handle.is_empty() {
2149 return login_error("Enter your atproto handle.");
2150 }
2151 if !may_start_oauth(&state, &headers).await {
2152 return Redirect::to("/beta/redeem").into_response();
2153 }
2154 start_oauth(&state, handle)
2155}
2156
2157async fn may_start_oauth(state: &AppState, headers: &HeaderMap) -> bool {
2163 if let Some(did) = current_did(state, headers).await {
2165 if store::has_beta_access(&state.db, &did)
2166 .await
2167 .unwrap_or(false)
2168 {
2169 return true;
2170 }
2171 }
2172 invite_cookie_code(headers, &state.config.cookie_secret).is_some()
2174}
2175
2176fn start_oauth(state: &AppState, handle: &str) -> Response {
2178 let url = state.sidecar.login_url(handle, None);
2179 info!(%handle, "redirecting to OAuth sidecar login");
2180 Redirect::to(&url).into_response()
2181}
2182
2183#[derive(Debug, Deserialize)]
2185struct LoginForm {
2186 handle: String,
2187}
2188
2189#[derive(Debug, Deserialize, Default)]
2191struct CallbackQuery {
2192 #[serde(default)]
2193 session_id: Option<String>,
2194 #[serde(default)]
2195 error: Option<String>,
2196 #[serde(default)]
2197 error_description: Option<String>,
2198}
2199
2200async fn oauth_callback(
2207 State(state): State<AppState>,
2208 headers: HeaderMap,
2209 Query(q): Query<CallbackQuery>,
2210) -> Response {
2211 if let Some(err) = q.error {
2212 let desc = q.error_description.unwrap_or_default();
2213 warn!(error = %err, desc = %desc, "OAuth callback returned an error");
2214 return login_error(&format!("Login failed: {err}"));
2215 }
2216
2217 let session_id = match q.session_id {
2218 Some(s) if !s.is_empty() => s,
2219 _ => return login_error("Login failed: the callback carried no session."),
2220 };
2221
2222 let session = match state.sidecar.resolve_session(&session_id).await {
2223 Ok(Some(s)) => s,
2224 Ok(None) => {
2225 warn!("OAuth callback session_id did not resolve (expired/unknown)");
2226 return login_error("Login session expired — please try again.");
2227 }
2228 Err(err) => {
2229 warn!(%err, "failed to resolve OAuth session via the sidecar");
2230 return login_error("Login failed talking to the auth service.");
2231 }
2232 };
2233
2234 let mut clear_invite = false;
2237 if !store::has_beta_access(&state.db, &session.did)
2238 .await
2239 .unwrap_or(false)
2240 {
2241 let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
2243 Some(c) => c,
2244 None => {
2245 warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
2246 return Redirect::to("/beta/redeem").into_response();
2247 }
2248 };
2249 match store::redeem_code(
2250 &state.db,
2251 &code,
2252 &session.did,
2253 session.handle.as_deref(),
2254 state.config.beta_cap,
2255 )
2256 .await
2257 {
2258 Ok(Ok(())) => {
2259 clear_invite = true;
2260 info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
2261 }
2262 Ok(Err(policy)) => {
2263 warn!(did = %session.did, ?policy, "invite redeem failed at callback");
2264 let mut resp = redeem_bounce(&policy).into_response();
2265 clear_invite_cookie(&mut resp);
2267 return resp;
2268 }
2269 Err(err) => {
2270 warn!(%err, did = %session.did, "invite redeem infra error at callback");
2271 return login_error("Login failed while confirming your invite.");
2272 }
2273 }
2274 }
2275
2276 let sid = state.sessions.create(Session {
2279 did: session.did.clone(),
2280 handle: session.handle.clone(),
2281 });
2282 let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
2283 info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
2284
2285 let mut resp = Redirect::to("/").into_response();
2286 set_cookie(&mut resp, &cookie);
2287 if clear_invite {
2288 clear_invite_cookie(&mut resp);
2289 }
2290 resp
2291}
2292
2293async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
2303 if let Some(user) = current_session(&state, &headers).await {
2304 if let Some(sid) = user.sid {
2307 state.sessions.remove(&sid);
2308 match state.sidecar.revoke_session(&user.did).await {
2309 Ok(res) => {
2310 info!(did = %user.did, revoked = res.revoked, "logout: sidecar session revoked");
2311 }
2312 Err(err) => {
2313 warn!(did = %user.did, %err, "logout: sidecar revoke failed; clearing cookie anyway");
2314 }
2315 }
2316 }
2317 }
2318 let mut resp = Redirect::to("/login").into_response();
2319 set_cookie(
2320 &mut resp,
2321 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2322 );
2323 resp
2324}
2325
2326#[derive(Debug, Deserialize)]
2329struct DeleteAccountForm {
2330 #[serde(default)]
2331 confirm: String,
2332}
2333
2334const DELETE_CONFIRM_PHRASE: &str = "DELETE";
2336
2337async fn account_delete(
2352 State(state): State<AppState>,
2353 headers: HeaderMap,
2354 Form(form): Form<DeleteAccountForm>,
2355) -> Result<Response, WebError> {
2356 let user = match current_session(&state, &headers).await {
2357 Some(u) => u,
2358 None => return Ok(Redirect::to("/login").into_response()),
2359 };
2360 let did = user.did.clone();
2361
2362 if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
2364 return Ok(Redirect::to(&format!(
2365 "/manage?flash={}",
2366 qenc("Type DELETE to confirm — nothing was deleted.")
2367 ))
2368 .into_response());
2369 }
2370
2371 let counts = store::purge_did_data(&state.db, &did).await?;
2373 info!(
2374 %did,
2375 total = counts.total(),
2376 entry_state = counts.entry_state,
2377 read_cursor = counts.read_cursor,
2378 sub_ref = counts.sub_ref,
2379 beta_access = counts.beta_access,
2380 invite_codes = counts.invite_codes,
2381 "account/delete: local rows purged"
2382 );
2383
2384 match state.sidecar.revoke_session(&did).await {
2387 Ok(res) => info!(%did, revoked = res.revoked, "account/delete: sidecar session revoked"),
2388 Err(err) => {
2389 warn!(%did, %err, "account/delete: sidecar revoke failed; local data already purged")
2390 }
2391 }
2392
2393 if let Some(sid) = user.sid {
2395 state.sessions.remove(&sid);
2396 }
2397 let mut resp = Redirect::to(&format!(
2398 "/login?flash={}",
2399 qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
2400 ))
2401 .into_response();
2402 set_cookie(
2403 &mut resp,
2404 &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2405 );
2406 Ok(resp)
2407}
2408
2409fn login_error(msg: &str) -> Response {
2411 render(&LoginTemplate {
2412 repo_url: REPO_URL,
2413 error: msg.to_string(),
2414 flash: String::new(),
2415 })
2416}
2417
2418#[derive(Debug, Deserialize)]
2424struct RedeemForm {
2425 code: String,
2426}
2427
2428async fn beta_redeem_form(State(state): State<AppState>) -> Response {
2431 let full = store::count_beta_access(&state.db)
2432 .await
2433 .map(|n| n >= state.config.beta_cap)
2434 .unwrap_or(false);
2435 render(&BetaRedeemTemplate {
2436 repo_url: REPO_URL,
2437 error: String::new(),
2438 capacity_full: full,
2439 })
2440}
2441
2442async fn beta_redeem_submit(
2452 State(state): State<AppState>,
2453 Form(form): Form<RedeemForm>,
2454) -> Response {
2455 let code = form.code.trim().to_uppercase();
2456 if code.is_empty() {
2457 return render(&BetaRedeemTemplate {
2458 repo_url: REPO_URL,
2459 error: "Enter your invite code.".to_string(),
2460 capacity_full: false,
2461 });
2462 }
2463
2464 match preflight_code(&state, &code).await {
2465 Ok(()) => {
2466 let cookie = sign_invite(&code, &state.config.cookie_secret);
2467 let mut resp = Redirect::to("/login").into_response();
2468 set_cookie(&mut resp, &cookie);
2469 info!("invite code preflight OK; reserving intent + redirecting to /login");
2470 resp
2471 }
2472 Err(policy) => {
2473 warn!(?policy, "invite code preflight rejected");
2474 redeem_bounce(&policy)
2475 }
2476 }
2477}
2478
2479async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
2485 let count = store::count_beta_access(&state.db).await.unwrap_or(0);
2487 if count >= state.config.beta_cap {
2488 return Err(store::RedeemError::CapacityFull);
2489 }
2490 let row = sqlx::query_as::<_, (String, i64)>(
2492 "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
2493 )
2494 .bind(code)
2495 .fetch_optional(&state.db)
2496 .await
2497 .ok()
2498 .flatten();
2499 let (status, expires_at) = match row {
2500 Some(r) => r,
2501 None => return Err(store::RedeemError::NotFound),
2502 };
2503 let now = chrono::Utc::now().timestamp();
2504 match status.as_str() {
2505 "active" if expires_at >= now => Ok(()),
2506 "active" => Err(store::RedeemError::Expired),
2507 "expired" => Err(store::RedeemError::Expired),
2508 _ => Err(store::RedeemError::AlreadyRedeemed),
2510 }
2511}
2512
2513fn redeem_bounce(policy: &store::RedeemError) -> Response {
2516 use store::RedeemError::*;
2517 let (msg, capacity_full) = match policy {
2518 NotFound => ("That invite code isn't valid.", false),
2519 Expired => ("That invite code has expired.", false),
2520 AlreadyRedeemed => ("That invite code has already been used.", false),
2521 CapacityFull => ("", true),
2522 };
2523 render(&BetaRedeemTemplate {
2524 repo_url: REPO_URL,
2525 error: msg.to_string(),
2526 capacity_full,
2527 })
2528}
2529
2530#[derive(Debug, Deserialize, Default)]
2532struct MintQuery {
2533 #[serde(default)]
2534 n: Option<u32>,
2535}
2536
2537async fn admin_mint_invites(
2543 State(state): State<AppState>,
2544 headers: HeaderMap,
2545 Query(q): Query<MintQuery>,
2546) -> Response {
2547 let did = match current_did(&state, &headers).await {
2550 Some(d) => d,
2551 None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
2552 };
2553 if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
2554 warn!(%did, "admin mint denied: not an admin-seed DID");
2555 return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
2556 }
2557
2558 let n = q.n.unwrap_or(1).clamp(1, 100);
2559 let mut codes = Vec::with_capacity(n as usize);
2560 for _ in 0..n {
2561 match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
2562 Ok(code) => codes.push(code),
2563 Err(err) => {
2564 warn!(%err, %did, "admin mint_code failed");
2565 return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2566 }
2567 }
2568 }
2569 info!(%did, count = codes.len(), "admin minted invite codes");
2570 let mut body = codes.join("\n");
2571 body.push('\n');
2572 (StatusCode::OK, body).into_response()
2573}
2574
2575fn sign_invite(code: &str, secret: &str) -> String {
2584 cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
2585}
2586
2587fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
2592 cookie::verify_value(headers, INVITE_COOKIE, secret)
2593}
2594
2595fn clear_invite_cookie(resp: &mut Response) {
2598 set_cookie(
2599 resp,
2600 &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2601 );
2602}
2603
2604async fn import_opml(
2617 State(state): State<AppState>,
2618 headers: HeaderMap,
2619 mut multipart: Multipart,
2620) -> Result<Response, WebError> {
2621 let did = match current_did(&state, &headers).await {
2622 Some(d) => d,
2623 None => return Ok(Redirect::to("/login").into_response()),
2624 };
2625 let pool = &state.db;
2626
2627 let mut opml_text = String::new();
2633 while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
2634 let name = field.name().unwrap_or("").to_string();
2635 if name == "opml" || name == "file" {
2636 let bytes = field.bytes().await.map_err(multipart_response)?;
2637 if !bytes.is_empty() {
2638 opml_text = String::from_utf8_lossy(&bytes).into_owned();
2639 if name == "file" {
2640 break;
2641 }
2642 }
2643 }
2644 }
2645
2646 let feeds = opml::parse_opml(&opml_text).unwrap_or_default();
2647 if feeds.is_empty() {
2648 info!(%did, "OPML import found no feeds");
2649 return Ok(
2650 Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
2651 .into_response(),
2652 );
2653 }
2654
2655 let now = now_rfc3339();
2658 let mut folder_uris: std::collections::HashMap<String, String> =
2659 std::collections::HashMap::new();
2660 if let Ok(existing) = state.sidecar.list_folders_sorted(&did).await {
2662 for (rkey, folder) in existing {
2663 folder_uris
2664 .entry(folder.name.clone())
2665 .or_insert_with(|| folder_uri(&did, &rkey));
2666 }
2667 }
2668 let mut wanted_folders: Vec<String> = feeds
2669 .iter()
2670 .filter_map(|f| f.folder.clone())
2671 .filter(|n| !n.is_empty())
2672 .collect();
2673 wanted_folders.sort();
2674 wanted_folders.dedup();
2675 for name in wanted_folders {
2676 if folder_uris.contains_key(&name) {
2677 continue;
2678 }
2679 let folder = Folder::new(name.clone(), now.clone());
2680 match state.sidecar.add_folder(&did, &folder).await {
2681 Ok(rkey) => {
2682 folder_uris.insert(name, folder_uri(&did, &rkey));
2683 }
2684 Err(err) => warn!(%err, %did, "OPML folder create failed"),
2685 }
2686 }
2687
2688 let sub_cap = state.config.max_subs_per_did;
2697 let mut headroom: Option<i64> = if sub_cap > 0 {
2698 let existing = store::count_subscriptions_for_did(pool, &did)
2699 .await
2700 .unwrap_or(0);
2701 Some((sub_cap - existing).max(0))
2702 } else {
2703 None
2704 };
2705 let mut trimmed_over_cap: usize = 0;
2706
2707 let feeds_cap = state.config.max_feeds_global;
2714 let mut global_headroom: Option<i64> = if feeds_cap > 0 {
2715 let existing = store::count_feeds(pool).await.unwrap_or(0);
2716 Some((feeds_cap - existing).max(0))
2717 } else {
2718 None
2719 };
2720 let mut trimmed_over_global: usize = 0;
2721
2722 let mut subs = Vec::with_capacity(feeds.len());
2723 let mut skipped_private: Vec<String> = Vec::new();
2724 for f in &feeds {
2725 if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
2726 info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
2727 let label = f
2729 .title
2730 .clone()
2731 .filter(|t| !t.trim().is_empty())
2732 .unwrap_or_else(|| private_feed_label(&f.feed_url));
2733 skipped_private.push(label);
2734 continue;
2735 }
2736
2737 if let Some(h) = headroom.as_mut() {
2740 if *h <= 0 {
2741 trimmed_over_cap += 1;
2742 continue;
2743 }
2744 }
2745
2746 let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
2751 Ok(existing) => existing.is_none(),
2752 Err(err) => {
2755 warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
2756 false
2757 }
2758 };
2759 if is_new {
2760 if let Some(g) = global_headroom.as_mut() {
2761 if *g <= 0 {
2762 trimmed_over_global += 1;
2763 continue;
2764 }
2765 *g -= 1;
2766 }
2767 }
2768
2769 if let Some(h) = headroom.as_mut() {
2772 *h -= 1;
2773 }
2774
2775 let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
2776 sub.title = f.title.clone();
2777 sub.site_url = f.site_url.clone();
2778 sub.folder = f
2779 .folder
2780 .as_ref()
2781 .and_then(|name| folder_uris.get(name).cloned());
2782 subs.push(sub);
2783 let _ = store::upsert_feed(
2784 pool,
2785 &store::NewFeed {
2786 url: f.feed_url.clone(),
2787 title: f.title.clone(),
2788 site_url: f.site_url.clone(),
2789 ..Default::default()
2790 },
2791 )
2792 .await;
2793 }
2794
2795 match state.sidecar.add_subscriptions_bulk(&did, &subs).await {
2796 Ok(rkeys) => {
2797 info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)")
2798 }
2799 Err(err) => warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)"),
2800 }
2801
2802 let mut flash = format!("Imported {} feeds", subs.len());
2804 if trimmed_over_cap > 0 {
2805 flash.push_str(&format!(
2806 ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
2807 ));
2808 }
2809 if trimmed_over_global > 0 {
2810 flash.push_str(&format!(
2811 ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
2812 ));
2813 }
2814 if !skipped_private.is_empty() {
2815 flash.push_str(&format!(
2816 ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
2817 skipped_private.len(),
2818 skipped_private.join(", ")
2819 ));
2820 }
2821 Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
2822}
2823
2824fn private_feed_label(url: &str) -> String {
2827 url::Url::parse(url)
2828 .ok()
2829 .and_then(|u| u.host_str().map(str::to_string))
2830 .unwrap_or_else(|| "a private feed".to_string())
2831}
2832
2833async fn export_opml(
2835 State(state): State<AppState>,
2836 headers: HeaderMap,
2837) -> Result<Response, WebError> {
2838 let did = match current_did(&state, &headers).await {
2839 Some(d) => d,
2840 None => return Ok(Redirect::to("/login").into_response()),
2841 };
2842
2843 let subs = state
2844 .sidecar
2845 .list_subscriptions_sorted(&did)
2846 .await
2847 .unwrap_or_default();
2848 let folders = state
2849 .sidecar
2850 .list_folders_sorted(&did)
2851 .await
2852 .unwrap_or_default();
2853 let folder_pairs: Vec<(String, Folder)> = folders
2856 .into_iter()
2857 .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
2858 .collect();
2859
2860 let body = opml::to_opml(&subs, &folder_pairs);
2861 let mut resp = (StatusCode::OK, body).into_response();
2862 resp.headers_mut().insert(
2863 header::CONTENT_TYPE,
2864 "text/x-opml; charset=utf-8".parse().unwrap(),
2865 );
2866 resp.headers_mut().insert(
2867 header::CONTENT_DISPOSITION,
2868 "attachment; filename=\"featherreader-subscriptions.opml\""
2869 .parse()
2870 .unwrap(),
2871 );
2872 Ok(resp)
2873}
2874
2875fn set_cookie(resp: &mut Response, cookie: &str) {
2881 if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
2882 resp.headers_mut()
2883 .append(axum::http::header::SET_COOKIE, value);
2884 }
2885}
2886
2887fn is_htmx(headers: &HeaderMap) -> bool {
2889 headers
2890 .get("HX-Request")
2891 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
2892}
2893
2894fn is_reader_request(headers: &HeaderMap) -> bool {
2900 headers
2901 .get("X-FR-Reader")
2902 .is_some_and(|v| v.as_bytes() == b"1")
2903}
2904
2905mod cookie {
2910 use super::{HeaderMap, SESSION_COOKIE};
2911
2912 pub fn sign_session(sid: &str, secret: &str) -> String {
2914 sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
2915 }
2916
2917 pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
2919 verify_value(headers, SESSION_COOKIE, secret)
2920 }
2921
2922 pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
2926 let sig = hmac_sha256_hex(secret.as_bytes(), value.as_bytes());
2927 let b64 = b64url_encode(value.as_bytes());
2928 format!(
2929 "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
2930 )
2931 }
2932
2933 pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
2936 let raw = cookie_value(headers, name)?;
2937 let (b64, sig) = raw.split_once('.')?;
2938 let bytes = b64url_decode(b64)?;
2939 let value = String::from_utf8(bytes).ok()?;
2940 let expected = hmac_sha256_hex(secret.as_bytes(), value.as_bytes());
2941 if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
2942 Some(value)
2943 } else {
2944 None
2945 }
2946 }
2947
2948 fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
2950 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
2951 for part in header.split(';') {
2952 let part = part.trim();
2953 if let Some((k, v)) = part.split_once('=') {
2954 if k == name {
2955 return Some(v.to_string());
2956 }
2957 }
2958 }
2959 None
2960 }
2961
2962 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2964 if a.len() != b.len() {
2965 return false;
2966 }
2967 let mut diff = 0u8;
2968 for (x, y) in a.iter().zip(b.iter()) {
2969 diff |= x ^ y;
2970 }
2971 diff == 0
2972 }
2973
2974 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
2977
2978 fn b64url_encode(input: &[u8]) -> String {
2979 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
2980 for chunk in input.chunks(3) {
2981 let b = [
2982 chunk[0],
2983 *chunk.get(1).unwrap_or(&0),
2984 *chunk.get(2).unwrap_or(&0),
2985 ];
2986 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
2987 out.push(B64[((n >> 18) & 63) as usize] as char);
2988 out.push(B64[((n >> 12) & 63) as usize] as char);
2989 if chunk.len() > 1 {
2990 out.push(B64[((n >> 6) & 63) as usize] as char);
2991 }
2992 if chunk.len() > 2 {
2993 out.push(B64[(n & 63) as usize] as char);
2994 }
2995 }
2996 out
2997 }
2998
2999 fn b64url_decode(input: &str) -> Option<Vec<u8>> {
3000 fn val(c: u8) -> Option<u32> {
3001 match c {
3002 b'A'..=b'Z' => Some((c - b'A') as u32),
3003 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
3004 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
3005 b'-' => Some(62),
3006 b'_' => Some(63),
3007 _ => None,
3008 }
3009 }
3010 let bytes = input.as_bytes();
3011 let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
3012 for chunk in bytes.chunks(4) {
3013 let mut n = 0u32;
3014 let mut valid = 0;
3015 for (i, &c) in chunk.iter().enumerate() {
3016 n |= val(c)? << (18 - 6 * i);
3017 valid += 1;
3018 }
3019 out.push((n >> 16) as u8);
3020 if valid > 2 {
3021 out.push((n >> 8) as u8);
3022 }
3023 if valid > 3 {
3024 out.push(n as u8);
3025 }
3026 }
3027 Some(out)
3028 }
3029
3030 fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
3034 const BLOCK: usize = 64;
3035 let mut k = [0u8; BLOCK];
3036 if key.len() > BLOCK {
3037 let d = sha256(key);
3038 k[..32].copy_from_slice(&d);
3039 } else {
3040 k[..key.len()].copy_from_slice(key);
3041 }
3042 let mut ipad = [0x36u8; BLOCK];
3043 let mut opad = [0x5cu8; BLOCK];
3044 for i in 0..BLOCK {
3045 ipad[i] ^= k[i];
3046 opad[i] ^= k[i];
3047 }
3048 let mut inner = Vec::with_capacity(BLOCK + msg.len());
3049 inner.extend_from_slice(&ipad);
3050 inner.extend_from_slice(msg);
3051 let inner_hash = sha256(&inner);
3052 let mut outer = Vec::with_capacity(BLOCK + 32);
3053 outer.extend_from_slice(&opad);
3054 outer.extend_from_slice(&inner_hash);
3055 let mac = sha256(&outer);
3056 let mut hex = String::with_capacity(64);
3057 for b in mac {
3058 hex.push_str(&format!("{b:02x}"));
3059 }
3060 hex
3061 }
3062
3063 fn sha256(data: &[u8]) -> [u8; 32] {
3065 const K: [u32; 64] = [
3066 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
3067 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
3068 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
3069 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
3070 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
3071 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
3072 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
3073 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
3074 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
3075 0xc67178f2,
3076 ];
3077 let mut h: [u32; 8] = [
3078 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
3079 0x5be0cd19,
3080 ];
3081
3082 let bit_len = (data.len() as u64) * 8;
3083 let mut msg = data.to_vec();
3084 msg.push(0x80);
3085 while msg.len() % 64 != 56 {
3086 msg.push(0);
3087 }
3088 msg.extend_from_slice(&bit_len.to_be_bytes());
3089
3090 for block in msg.chunks(64) {
3091 let mut w = [0u32; 64];
3092 for i in 0..16 {
3093 w[i] = u32::from_be_bytes([
3094 block[i * 4],
3095 block[i * 4 + 1],
3096 block[i * 4 + 2],
3097 block[i * 4 + 3],
3098 ]);
3099 }
3100 for i in 16..64 {
3101 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
3102 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
3103 w[i] = w[i - 16]
3104 .wrapping_add(s0)
3105 .wrapping_add(w[i - 7])
3106 .wrapping_add(s1);
3107 }
3108 let mut a = h;
3109 for i in 0..64 {
3110 let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
3111 let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
3112 let t1 = a[7]
3113 .wrapping_add(s1)
3114 .wrapping_add(ch)
3115 .wrapping_add(K[i])
3116 .wrapping_add(w[i]);
3117 let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
3118 let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
3119 let t2 = s0.wrapping_add(maj);
3120 a[7] = a[6];
3121 a[6] = a[5];
3122 a[5] = a[4];
3123 a[4] = a[3].wrapping_add(t1);
3124 a[3] = a[2];
3125 a[2] = a[1];
3126 a[1] = a[0];
3127 a[0] = t1.wrapping_add(t2);
3128 }
3129 for i in 0..8 {
3130 h[i] = h[i].wrapping_add(a[i]);
3131 }
3132 }
3133
3134 let mut out = [0u8; 32];
3135 for (i, word) in h.iter().enumerate() {
3136 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
3137 }
3138 out
3139 }
3140
3141 #[cfg(test)]
3142 mod tests {
3143 use super::*;
3144
3145 #[test]
3146 fn sha256_known_vector() {
3147 let d = sha256(b"abc");
3148 let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
3149 assert_eq!(
3150 hex,
3151 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
3152 );
3153 }
3154
3155 #[test]
3156 fn hmac_known_vector() {
3157 let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
3158 assert_eq!(
3159 mac,
3160 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
3161 );
3162 }
3163
3164 #[test]
3165 fn sign_verify_round_trips() {
3166 let secret = "test-secret";
3167 let sid = "9f2c-opaque-session-id";
3168 let cookie = sign_session(sid, secret);
3169 let pair = cookie.split(';').next().unwrap().to_string();
3170 let mut headers = HeaderMap::new();
3171 headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
3172 assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
3173 assert!(verify_session(&headers, "other-secret").is_none());
3175 }
3176
3177 #[test]
3178 fn forged_and_tampered_cookies_are_rejected() {
3179 let secret = "test-secret";
3180
3181 let forged = format!(
3184 "{SESSION_COOKIE}={}.{}",
3185 b64url_encode(b"attacker-chosen-sid"),
3186 "deadbeef".repeat(8) );
3188 let mut headers = HeaderMap::new();
3189 headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
3190 assert!(verify_session(&headers, secret).is_none());
3191
3192 let cookie = sign_session("real-sid", secret);
3195 let pair = cookie.split(';').next().unwrap();
3196 let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
3197 let tampered = format!(
3198 "{SESSION_COOKIE}={}.{}",
3199 b64url_encode(b"different-sid"),
3200 sig
3201 );
3202 let mut headers2 = HeaderMap::new();
3203 headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
3204 assert!(verify_session(&headers2, secret).is_none());
3205 }
3206
3207 #[test]
3208 fn b64url_round_trips() {
3209 for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
3210 let enc = b64url_encode(s.as_bytes());
3211 assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
3212 }
3213 }
3214 }
3215}
3216
3217async fn get_entry_by_id(
3229 pool: &store::Pool,
3230 did: &str,
3231 id: i64,
3232) -> anyhow::Result<Option<store::Entry>> {
3233 let entry = sqlx::query_as::<_, store::Entry>(
3234 r#"
3235 SELECT e.* FROM entries e
3236 WHERE e.id = ?2
3237 AND EXISTS (
3238 SELECT 1 FROM sub_ref sr
3239 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3240 )
3241 "#,
3242 )
3243 .bind(did)
3244 .bind(id)
3245 .fetch_optional(pool)
3246 .await?;
3247 Ok(entry)
3248}
3249
3250async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3252 let read: Option<bool> =
3253 sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3254 .bind(did)
3255 .bind(entry_id)
3256 .fetch_optional(pool)
3257 .await?
3258 .flatten();
3259 Ok(read.unwrap_or(false))
3260}
3261
3262async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3264 let starred: Option<bool> =
3265 sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3266 .bind(did)
3267 .bind(entry_id)
3268 .fetch_optional(pool)
3269 .await?
3270 .flatten();
3271 Ok(starred.unwrap_or(false))
3272}
3273
3274async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
3276 match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
3277 .bind(feed_id)
3278 .fetch_optional(pool)
3279 .await
3280 {
3281 Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
3282 _ => String::new(),
3283 }
3284}
3285
3286async fn build_entry_row(
3289 pool: &store::Pool,
3290 did: &str,
3291 id: i64,
3292 read: Option<bool>,
3293) -> anyhow::Result<Option<EntryRow>> {
3294 let entry = match get_entry_by_id(pool, did, id).await? {
3295 Some(e) => e,
3296 None => return Ok(None),
3297 };
3298 let read = match read {
3299 Some(r) => r,
3300 None => entry_is_read(pool, did, id).await?,
3301 };
3302 let starred = entry_is_starred(pool, did, id).await?;
3303 Ok(Some(EntryRow {
3304 id: entry.id,
3305 title: entry
3306 .title
3307 .clone()
3308 .filter(|t| !t.trim().is_empty())
3309 .unwrap_or_else(|| "(untitled)".to_string()),
3310 feed_title: feed_title_by_entry(pool, entry.feed_id).await,
3311 published: display_date(entry.published.as_deref()),
3312 read,
3313 starred,
3314 link: format!("/entries/{id}"),
3315 }))
3316}
3317
3318fn now_rfc3339() -> String {
3320 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3321}
3322
3323#[cfg(test)]
3324mod tests {
3325 use super::*;
3326
3327 #[test]
3328 fn qenc_encodes_reserved() {
3329 assert_eq!(qenc("a b"), "a%20b");
3330 assert_eq!(
3331 qenc("https://example.com/feed.xml"),
3332 "https%3A%2F%2Fexample.com%2Ffeed.xml"
3333 );
3334 assert_eq!(
3335 qenc("at://did:plc:x/c/r"),
3336 "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
3337 );
3338 assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
3340 }
3341
3342 #[test]
3343 fn folder_uri_shape() {
3344 assert_eq!(
3345 folder_uri("did:plc:abc", "3kfolder"),
3346 "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
3347 );
3348 }
3349
3350 #[test]
3353 fn private_feeds_are_classified_private_across_providers() {
3354 for url in [
3358 "https://author.substack.com/feed/private/deadbeefcafe1234",
3359 "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
3360 "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
3361 "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
3362 "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
3363 "https://user:pass@example.com/feed",
3364 ] {
3365 assert!(
3366 feed::classify_feed_privacy(url).is_private(),
3367 "expected private: {url}"
3368 );
3369 }
3370 }
3371
3372 #[test]
3373 fn public_feeds_stay_public() {
3374 for url in [
3375 "https://author.substack.com/feed",
3376 "https://wordpress.example.com/feed/",
3377 "https://example.com/rss.xml",
3378 "https://example.org/atom.xml",
3379 "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
3381 "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
3382 ] {
3383 assert!(
3384 !feed::classify_feed_privacy(url).is_private(),
3385 "expected public: {url}"
3386 );
3387 }
3388 }
3389
3390 #[test]
3391 fn private_feed_label_is_public_safe_host_only() {
3392 let label =
3394 private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
3395 assert_eq!(label, "author.substack.com");
3396 assert!(!label.contains("deadbeefcafe1234token"));
3397 assert!(!label.contains("/private/"));
3398 assert_eq!(private_feed_label("not a url"), "a private feed");
3400 }
3401
3402 #[test]
3403 fn refusal_message_promises_nothing_stored() {
3404 assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
3405 assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
3406 }
3407
3408 #[test]
3409 fn scope_query_preserves_context() {
3410 let q = EntryQuery {
3411 feed: Some("https://example.com/feed.xml".to_string()),
3412 folder: None,
3413 view: Some("all".to_string()),
3414 };
3415 let s = scope_query(&q);
3416 assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
3417 assert!(s.contains("view=all"));
3418
3419 let q2 = EntryQuery {
3421 feed: None,
3422 folder: None,
3423 view: Some("unread".to_string()),
3424 };
3425 assert_eq!(scope_query(&q2), "");
3426 }
3427
3428 use axum::body::Body;
3431 use axum::http::Request;
3432 use tower::ServiceExt; async fn test_state(allowed: &[&str]) -> AppState {
3438 let db = store::init_url("sqlite::memory:").await.unwrap();
3439 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
3440 store::ensure_seed(&db, &dids).await.unwrap();
3441 let config = Config {
3442 allowed_dids: dids,
3443 cookie_secret: "test-cookie-secret-000".to_string(),
3444 beta_cap: 3,
3445 ..Config::default()
3446 };
3447 AppState::new(config, db).unwrap()
3448 }
3449
3450 fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
3453 let sid = state.sessions.create(Session {
3454 did: did.to_string(),
3455 handle: handle.map(str::to_string),
3456 });
3457 let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
3458 sc.split(';').next().unwrap().to_string()
3459 }
3460
3461 #[test]
3462 fn rate_limited_paths_match_expected() {
3463 use axum::http::Method;
3464 assert!(is_rate_limited_path("/login", &Method::GET));
3465 assert!(is_rate_limited_path("/login", &Method::POST));
3466 assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
3467 assert!(is_rate_limited_path("/subscriptions", &Method::POST));
3468 assert!(is_rate_limited_path("/opml", &Method::POST));
3469 assert!(is_rate_limited_path("/read-all", &Method::POST));
3470 assert!(is_rate_limited_path("/admin/invites", &Method::POST));
3471 assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
3472 assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
3473 assert!(!is_rate_limited_path("/", &Method::GET));
3475 assert!(!is_rate_limited_path("/about", &Method::GET));
3476 assert!(!is_rate_limited_path("/entries/42", &Method::GET));
3477 assert!(!is_rate_limited_path("/login", &Method::HEAD));
3478 }
3479
3480 #[test]
3481 fn rate_limiter_allows_burst_then_429s() {
3482 let rl = RateLimiter::shared();
3483 let ip: IpAddr = "203.0.113.7".parse().unwrap();
3484 for _ in 0..(RATE_BURST as usize) {
3486 assert!(rl.check(ip));
3487 }
3488 assert!(!rl.check(ip));
3490 let ip2: IpAddr = "203.0.113.8".parse().unwrap();
3492 assert!(rl.check(ip2));
3493 }
3494
3495 #[test]
3496 fn client_ip_ignores_spoofed_xff_without_trusted_header() {
3497 let mut h = HeaderMap::new();
3501 h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
3502 let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
3503 assert_eq!(
3504 client_ip(&h, Some(&sock), None),
3505 Some("203.0.113.55".parse().unwrap()),
3506 "spoofed XFF must not override the socket peer"
3507 );
3508 }
3509
3510 #[test]
3511 fn client_ip_uses_trusted_header_last_hop() {
3512 let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
3517
3518 let mut h = HeaderMap::new();
3519 h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
3520 assert_eq!(
3521 client_ip(&h, Some(&sock), Some("fly-client-ip")),
3522 Some("198.51.100.9".parse().unwrap())
3523 );
3524
3525 let mut h2 = HeaderMap::new();
3527 h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
3528 assert_eq!(
3529 client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
3530 Some("198.51.100.9".parse().unwrap()),
3531 "must take the right-most (trusted) hop, not the forged left-most"
3532 );
3533
3534 let h3 = HeaderMap::new();
3536 assert_eq!(
3537 client_ip(&h3, Some(&sock), Some("fly-client-ip")),
3538 Some("10.0.0.1".parse().unwrap())
3539 );
3540 }
3541
3542 #[test]
3543 fn invite_cookie_round_trips_and_rejects_tamper() {
3544 let secret = "test-cookie-secret-000";
3545 let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
3546 let pair = sc.split(';').next().unwrap();
3547 let mut h = HeaderMap::new();
3548 h.insert(header::COOKIE, pair.parse().unwrap());
3549 assert_eq!(
3550 invite_cookie_code(&h, secret).as_deref(),
3551 Some("FEATHER-ABCDWXYZ")
3552 );
3553 assert!(invite_cookie_code(&h, "other").is_none());
3555 }
3556
3557 #[tokio::test]
3558 async fn preflight_valid_expired_and_full() {
3559 let state = test_state(&["did:plc:admin"]).await;
3560 let code = store::mint_code(&state.db, "did:plc:admin", 3600)
3562 .await
3563 .unwrap();
3564 assert!(preflight_code(&state, &code).await.is_ok());
3565
3566 let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
3570 .await
3571 .unwrap();
3572 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
3573 .bind(chrono::Utc::now().timestamp() - 3600)
3574 .bind(&expired)
3575 .execute(&state.db)
3576 .await
3577 .unwrap();
3578 assert_eq!(
3579 preflight_code(&state, &expired).await,
3580 Err(store::RedeemError::Expired)
3581 );
3582
3583 assert_eq!(
3585 preflight_code(&state, "FEATHER-NOPENOPE").await,
3586 Err(store::RedeemError::NotFound)
3587 );
3588
3589 store::grant_access(&state.db, "did:plc:b", None, "admin", None)
3592 .await
3593 .unwrap();
3594 store::grant_access(&state.db, "did:plc:c", None, "admin", None)
3595 .await
3596 .unwrap();
3597 assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
3598 assert_eq!(
3599 preflight_code(&state, &code).await,
3600 Err(store::RedeemError::CapacityFull)
3601 );
3602 }
3603
3604 #[tokio::test]
3605 async fn login_without_invite_redirects_to_beta_redeem() {
3606 let state = test_state(&[]).await;
3608 let app = router(state);
3609 let resp = app
3610 .oneshot(
3611 Request::builder()
3612 .method("POST")
3613 .uri("/login")
3614 .header("content-type", "application/x-www-form-urlencoded")
3615 .body(Body::from("handle=alice.bsky.social"))
3616 .unwrap(),
3617 )
3618 .await
3619 .unwrap();
3620 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3621 assert_eq!(
3622 resp.headers().get(header::LOCATION).unwrap(),
3623 "/beta/redeem"
3624 );
3625 }
3626
3627 #[tokio::test]
3628 async fn login_with_valid_invite_cookie_starts_oauth() {
3629 let state = test_state(&[]).await;
3630 let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
3631 let cookie = cookie.split(';').next().unwrap().to_string();
3632 let app = router(state);
3633 let resp = app
3634 .oneshot(
3635 Request::builder()
3636 .method("POST")
3637 .uri("/login")
3638 .header("content-type", "application/x-www-form-urlencoded")
3639 .header(header::COOKIE, cookie)
3640 .body(Body::from("handle=alice.bsky.social"))
3641 .unwrap(),
3642 )
3643 .await
3644 .unwrap();
3645 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3647 let loc = resp
3648 .headers()
3649 .get(header::LOCATION)
3650 .unwrap()
3651 .to_str()
3652 .unwrap();
3653 assert!(loc.contains("/login"), "loc = {loc}");
3654 assert_ne!(loc, "/beta/redeem");
3655 }
3656
3657 #[tokio::test]
3658 async fn admin_mint_requires_admin_seed_did() {
3659 let state = test_state(&["did:plc:admin"]).await;
3660 store::grant_access(&state.db, "did:plc:rando", None, "test", None)
3662 .await
3663 .unwrap();
3664 let rando_cookie = session_cookie(&state, "did:plc:rando", None);
3665 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3667 let app = router(state);
3668
3669 let forbidden = app
3670 .clone()
3671 .oneshot(
3672 Request::builder()
3673 .method("POST")
3674 .uri("/admin/invites?n=2")
3675 .header(header::COOKIE, rando_cookie)
3676 .body(Body::empty())
3677 .unwrap(),
3678 )
3679 .await
3680 .unwrap();
3681 assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
3682
3683 let ok = app
3684 .oneshot(
3685 Request::builder()
3686 .method("POST")
3687 .uri("/admin/invites?n=2")
3688 .header(header::COOKIE, admin_cookie)
3689 .body(Body::empty())
3690 .unwrap(),
3691 )
3692 .await
3693 .unwrap();
3694 assert_eq!(ok.status(), StatusCode::OK);
3695 let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
3696 .await
3697 .unwrap();
3698 let body = String::from_utf8(bytes.to_vec()).unwrap();
3699 let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
3700 assert_eq!(minted.len(), 2);
3701 assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
3702 }
3703
3704 #[tokio::test]
3705 async fn admin_mint_unauthenticated_is_401() {
3706 let state = test_state(&["did:plc:admin"]).await;
3707 let app = router(state);
3708 let resp = app
3709 .oneshot(
3710 Request::builder()
3711 .method("POST")
3712 .uri("/admin/invites")
3713 .body(Body::empty())
3714 .unwrap(),
3715 )
3716 .await
3717 .unwrap();
3718 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
3719 }
3720
3721 #[tokio::test]
3722 async fn cache_control_public_on_about_no_store_on_authed() {
3723 let state = test_state(&["did:plc:admin"]).await;
3724 let admin_cookie = session_cookie(&state, "did:plc:admin", None);
3725 let app = router(state);
3726
3727 let about = app
3729 .clone()
3730 .oneshot(
3731 Request::builder()
3732 .uri("/about")
3733 .body(Body::empty())
3734 .unwrap(),
3735 )
3736 .await
3737 .unwrap();
3738 assert_eq!(
3739 about.headers().get(header::CACHE_CONTROL).unwrap(),
3740 "public, max-age=300"
3741 );
3742 assert!(about.headers().contains_key("content-security-policy"));
3744 assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
3745
3746 let login = app
3748 .clone()
3749 .oneshot(
3750 Request::builder()
3751 .uri("/login")
3752 .body(Body::empty())
3753 .unwrap(),
3754 )
3755 .await
3756 .unwrap();
3757 assert_eq!(
3758 login.headers().get(header::CACHE_CONTROL).unwrap(),
3759 "public, max-age=300"
3760 );
3761
3762 let home = app
3764 .oneshot(
3765 Request::builder()
3766 .uri("/")
3767 .header(header::COOKIE, admin_cookie)
3768 .body(Body::empty())
3769 .unwrap(),
3770 )
3771 .await
3772 .unwrap();
3773 assert_eq!(
3774 home.headers().get(header::CACHE_CONTROL).unwrap(),
3775 "no-store"
3776 );
3777 }
3778
3779 #[tokio::test]
3780 async fn beta_redeem_page_renders() {
3781 let state = test_state(&[]).await;
3782 let app = router(state);
3783 let resp = app
3784 .oneshot(
3785 Request::builder()
3786 .uri("/beta/redeem")
3787 .body(Body::empty())
3788 .unwrap(),
3789 )
3790 .await
3791 .unwrap();
3792 assert_eq!(resp.status(), StatusCode::OK);
3793 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
3794 .await
3795 .unwrap();
3796 let html = String::from_utf8(bytes.to_vec()).unwrap();
3797 assert!(html.contains("Invite code"));
3798 assert!(html.contains("/beta/redeem"));
3799 }
3800
3801 #[tokio::test]
3802 async fn rate_limit_returns_429_after_burst() {
3803 let db = store::init_url("sqlite::memory:").await.unwrap();
3806 store::ensure_seed(&db, &[]).await.unwrap();
3807 let config = Config {
3808 cookie_secret: "test-cookie-secret-000".to_string(),
3809 beta_cap: 3,
3810 trusted_ip_header: Some("cf-connecting-ip".to_string()),
3811 ..Config::default()
3812 };
3813 let state = AppState::new(config, db).unwrap();
3814 let app = router(state);
3815 let mut saw_429 = false;
3819 for _ in 0..(RATE_BURST as usize + 5) {
3820 let resp = app
3821 .clone()
3822 .oneshot(
3823 Request::builder()
3824 .method("POST")
3825 .uri("/beta/redeem")
3826 .header("content-type", "application/x-www-form-urlencoded")
3827 .header("cf-connecting-ip", "203.0.113.200")
3828 .body(Body::from("code=FEATHER-NOPENOPE"))
3829 .unwrap(),
3830 )
3831 .await
3832 .unwrap();
3833 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
3834 saw_429 = true;
3835 break;
3836 }
3837 }
3838 assert!(saw_429, "expected a 429 after exhausting the burst");
3839 }
3840
3841 #[tokio::test]
3842 async fn rate_limit_ignores_spoofed_xff_rotation() {
3843 let state = test_state(&[]).await;
3852 let app = router(state);
3853 for i in 0..(RATE_BURST as usize + 5) {
3854 let forged = format!("10.9.8.{}", i % 250);
3855 let resp = app
3856 .clone()
3857 .oneshot(
3858 Request::builder()
3859 .method("POST")
3860 .uri("/beta/redeem")
3861 .header("content-type", "application/x-www-form-urlencoded")
3862 .header("x-forwarded-for", forged)
3863 .body(Body::from("code=FEATHER-NOPENOPE"))
3864 .unwrap(),
3865 )
3866 .await
3867 .unwrap();
3868 assert_ne!(
3869 resp.status(),
3870 StatusCode::TOO_MANY_REQUESTS,
3871 "untrusted XFF must not be used as the rate-limit key"
3872 );
3873 }
3874 }
3875
3876 fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
3881 let boundary = "----featherreadertestboundary";
3882 let mut body = Vec::new();
3883 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
3884 body.extend_from_slice(
3885 b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
3886 );
3887 body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
3888 body.extend_from_slice(payload);
3889 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
3890 (format!("multipart/form-data; boundary={boundary}"), body)
3891 }
3892
3893 #[tokio::test]
3894 async fn opml_import_oversize_upload_returns_413() {
3895 let state = test_state(&["did:plc:admin"]).await;
3896 let cookie = session_cookie(&state, "did:plc:admin", None);
3897 let app = router(state);
3898
3899 let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
3901 let (content_type, body) = opml_multipart(&payload);
3902
3903 let resp = app
3904 .oneshot(
3905 Request::builder()
3906 .method("POST")
3907 .uri("/opml")
3908 .header("content-type", content_type)
3909 .header(header::COOKIE, cookie)
3910 .body(Body::from(body))
3911 .unwrap(),
3912 )
3913 .await
3914 .unwrap();
3915 assert_eq!(
3916 resp.status(),
3917 StatusCode::PAYLOAD_TOO_LARGE,
3918 "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
3919 );
3920 }
3921
3922 #[tokio::test]
3923 async fn opml_import_under_limit_upload_is_not_413() {
3924 let state = test_state(&["did:plc:admin"]).await;
3925 let cookie = session_cookie(&state, "did:plc:admin", None);
3926 let app = router(state);
3927
3928 let opml = br#"<?xml version="1.0"?>
3931<opml version="2.0"><body>
3932 <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
3933</body></opml>"#;
3934 let (content_type, body) = opml_multipart(opml);
3935
3936 let resp = app
3937 .oneshot(
3938 Request::builder()
3939 .method("POST")
3940 .uri("/opml")
3941 .header("content-type", content_type)
3942 .header(header::COOKIE, cookie)
3943 .body(Body::from(body))
3944 .unwrap(),
3945 )
3946 .await
3947 .unwrap();
3948 assert_ne!(
3949 resp.status(),
3950 StatusCode::PAYLOAD_TOO_LARGE,
3951 "an under-cap OPML upload must not be rejected as too large"
3952 );
3953 }
3954
3955 #[tokio::test]
3956 async fn opml_import_logged_out_redirects_to_login() {
3957 let state = test_state(&["did:plc:admin"]).await;
3960 let app = router(state);
3961
3962 let opml = b"<opml version=\"2.0\"><body></body></opml>";
3963 let (content_type, body) = opml_multipart(opml);
3964
3965 let resp = app
3966 .oneshot(
3967 Request::builder()
3968 .method("POST")
3969 .uri("/opml")
3970 .header("content-type", content_type)
3971 .body(Body::from(body))
3972 .unwrap(),
3973 )
3974 .await
3975 .unwrap();
3976 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
3977 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
3978 }
3979
3980 async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
3987 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3988 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3989 let addr = listener.local_addr().unwrap();
3990 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
3991 tokio::spawn(async move {
3992 let (mut sock, _) = listener.accept().await.unwrap();
3993 let mut buf = vec![0u8; 4096];
3994 let n = sock.read(&mut buf).await.unwrap();
3995 let req = String::from_utf8_lossy(&buf[..n]).to_string();
3996 let did = req
3998 .split("\r\n\r\n")
3999 .nth(1)
4000 .and_then(|body| {
4001 let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
4002 v.get("did")?.as_str().map(str::to_string)
4003 })
4004 .unwrap_or_default();
4005 let is_revoke = req.starts_with("POST /internal/revoke");
4006 let body = serde_json::json!({
4007 "ok": true, "did": did, "revoked": true, "hadSession": true
4008 })
4009 .to_string();
4010 let resp = format!(
4011 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
4012 body.len(),
4013 body
4014 );
4015 sock.write_all(resp.as_bytes()).await.unwrap();
4016 sock.flush().await.unwrap();
4017 let _ = tx.send(if is_revoke { did } else { String::new() });
4018 });
4019 (format!("http://{addr}"), rx)
4020 }
4021
4022 async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
4024 let db = store::init_url("sqlite::memory:").await.unwrap();
4025 let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
4026 store::ensure_seed(&db, &dids).await.unwrap();
4027 let mut config = Config {
4028 allowed_dids: dids,
4029 cookie_secret: "test-cookie-secret-000".to_string(),
4030 beta_cap: 3,
4031 ..Config::default()
4032 };
4033 config.sidecar.public_url = sidecar_url.to_string();
4034 AppState::new(config, db).unwrap()
4035 }
4036
4037 #[tokio::test]
4040 async fn account_delete_purges_rows_and_triggers_revoke() {
4041 let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
4042 let did = "did:plc:leaver";
4043 let state = test_state_with_sidecar(&[], &sidecar_url).await;
4044
4045 store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
4047 .await
4048 .unwrap();
4049 store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
4050 store::mint_code(&state.db, did, 3600).await.unwrap();
4051 assert!(store::has_beta_access(&state.db, did).await.unwrap());
4052
4053 let cookie = session_cookie(&state, did, Some("leaver.example"));
4054 let app = router(state.clone());
4055
4056 let resp = app
4057 .oneshot(
4058 Request::builder()
4059 .method("POST")
4060 .uri("/account/delete")
4061 .header(header::COOKIE, cookie)
4062 .header("content-type", "application/x-www-form-urlencoded")
4063 .body(Body::from("confirm=DELETE"))
4064 .unwrap(),
4065 )
4066 .await
4067 .unwrap();
4068
4069 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4071 assert!(resp
4072 .headers()
4073 .get(header::LOCATION)
4074 .unwrap()
4075 .to_str()
4076 .unwrap()
4077 .starts_with("/login"));
4078 let set_cookie = resp
4079 .headers()
4080 .get(header::SET_COOKIE)
4081 .unwrap()
4082 .to_str()
4083 .unwrap();
4084 assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
4085
4086 let revoked_did = revoke_rx.await.unwrap();
4088 assert_eq!(
4089 revoked_did, did,
4090 "sidecar revoke must fire for the caller DID"
4091 );
4092
4093 assert!(!store::has_beta_access(&state.db, did).await.unwrap());
4095 let codes: i64 =
4096 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
4097 .bind(did)
4098 .fetch_one(&state.db)
4099 .await
4100 .unwrap();
4101 assert_eq!(codes, 0);
4102 }
4103
4104 #[tokio::test]
4107 async fn account_delete_without_confirm_is_a_noop() {
4108 let did = "did:plc:staying";
4109 let state = test_state(&[]).await;
4110 store::grant_access(&state.db, did, None, "test", None)
4111 .await
4112 .unwrap();
4113 let cookie = session_cookie(&state, did, None);
4114 let app = router(state.clone());
4115
4116 let resp = app
4117 .oneshot(
4118 Request::builder()
4119 .method("POST")
4120 .uri("/account/delete")
4121 .header(header::COOKIE, cookie)
4122 .header("content-type", "application/x-www-form-urlencoded")
4123 .body(Body::from("confirm=nope"))
4124 .unwrap(),
4125 )
4126 .await
4127 .unwrap();
4128
4129 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4130 assert!(resp
4131 .headers()
4132 .get(header::LOCATION)
4133 .unwrap()
4134 .to_str()
4135 .unwrap()
4136 .starts_with("/manage"));
4137 assert!(store::has_beta_access(&state.db, did).await.unwrap());
4139 }
4140
4141 #[tokio::test]
4148 async fn pds_outage_does_not_widen_cross_did_access() {
4149 let did_a = "did:plc:aaaa";
4150 let state = test_state(&[]).await;
4151 store::grant_access(&state.db, did_a, None, "test", None)
4152 .await
4153 .unwrap();
4154
4155 let feed_a = store::upsert_feed(
4158 &state.db,
4159 &store::NewFeed {
4160 url: "https://a.example/feed.xml".to_string(),
4161 title: Some("A".to_string()),
4162 ..Default::default()
4163 },
4164 )
4165 .await
4166 .unwrap();
4167 let feed_b = store::upsert_feed(
4168 &state.db,
4169 &store::NewFeed {
4170 url: "https://b.example/feed.xml".to_string(),
4171 title: Some("B".to_string()),
4172 ..Default::default()
4173 },
4174 )
4175 .await
4176 .unwrap();
4177 store::insert_entries(
4178 &state.db,
4179 feed_b,
4180 &[store::NewEntry {
4181 guid: "b-1".to_string(),
4182 url: Some("https://b.example/1".to_string()),
4183 title: Some("B one".to_string()),
4184 published: Some("2026-07-11T00:00:00Z".to_string()),
4185 content_html: Some("<p>secret B body</p>".to_string()),
4186 ..Default::default()
4187 }],
4188 0,
4189 )
4190 .await
4191 .unwrap();
4192 store::replace_sub_refs(&state.db, did_a, &[feed_a])
4194 .await
4195 .unwrap();
4196 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
4199 .await
4200 .unwrap();
4201 let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
4202 .await
4203 .unwrap()[0]
4204 .id;
4205 store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
4206 .await
4207 .unwrap();
4208
4209 let cookie = session_cookie(&state, did_a, None);
4210 let app = router(state.clone());
4211
4212 let get_b = app
4214 .clone()
4215 .oneshot(
4216 Request::builder()
4217 .method("GET")
4218 .uri(format!("/entries/{b_entry_id}"))
4219 .header(header::COOKIE, cookie.clone())
4220 .body(Body::empty())
4221 .unwrap(),
4222 )
4223 .await
4224 .unwrap();
4225 assert_eq!(
4226 get_b.status(),
4227 StatusCode::NOT_FOUND,
4228 "A must not read B's entry during a PDS outage"
4229 );
4230
4231 let read_b = app
4233 .oneshot(
4234 Request::builder()
4235 .method("POST")
4236 .uri(format!("/entries/{b_entry_id}/read"))
4237 .header(header::COOKIE, cookie)
4238 .header("content-type", "application/x-www-form-urlencoded")
4239 .body(Body::from("read=true"))
4240 .unwrap(),
4241 )
4242 .await
4243 .unwrap();
4244 assert_eq!(
4245 read_b.status(),
4246 StatusCode::NOT_FOUND,
4247 "A must not mark B's entry read during a PDS outage"
4248 );
4249
4250 let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
4252 .bind(did_a)
4253 .fetch_all(&state.db)
4254 .await
4255 .unwrap();
4256 assert_eq!(
4257 a_feed_ids,
4258 vec![feed_a],
4259 "outage fallback must not add feeds A never subscribed to"
4260 );
4261 let es_count: i64 =
4263 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
4264 .bind(did_a)
4265 .bind(b_entry_id)
4266 .fetch_one(&state.db)
4267 .await
4268 .unwrap();
4269 assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
4270 }
4271
4272 async fn test_state_with_caps(
4275 did: &str,
4276 max_subs_per_did: i64,
4277 max_feeds_global: i64,
4278 ) -> AppState {
4279 let db = store::init_url("sqlite::memory:").await.unwrap();
4280 let config = Config {
4281 cookie_secret: "test-cookie-secret-000".to_string(),
4282 beta_cap: 100,
4283 max_subs_per_did,
4284 max_feeds_global,
4285 ..Config::default()
4286 };
4287 store::grant_access(&db, did, None, "test", None)
4288 .await
4289 .unwrap();
4290 AppState::new(config, db).unwrap()
4291 }
4292
4293 fn opml_with_feeds(n: usize) -> String {
4295 let mut outlines = String::new();
4296 for i in 0..n {
4297 outlines.push_str(&format!(
4298 "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
4299 ));
4300 }
4301 format!(
4302 "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
4303 )
4304 }
4305
4306 #[tokio::test]
4311 async fn opml_import_enforces_global_feeds_ceiling() {
4312 let did = "did:plc:importer";
4313 let state = test_state_with_caps(did, 0, 3).await;
4315 let cookie = session_cookie(&state, did, None);
4316 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4317 let app = router(state.clone());
4318
4319 let resp = app
4320 .oneshot(
4321 Request::builder()
4322 .method("POST")
4323 .uri("/opml")
4324 .header(header::COOKIE, cookie)
4325 .header("content-type", ct)
4326 .body(Body::from(body))
4327 .unwrap(),
4328 )
4329 .await
4330 .unwrap();
4331 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4332
4333 let feeds = store::count_feeds(&state.db).await.unwrap();
4334 assert!(
4335 feeds <= 3,
4336 "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
4337 );
4338 }
4339
4340 #[tokio::test]
4343 async fn opml_import_enforces_per_did_cap() {
4344 let did = "did:plc:capped";
4345 let state = test_state_with_caps(did, 2, 0).await;
4347 let existing_a = store::upsert_feed(
4348 &state.db,
4349 &store::NewFeed {
4350 url: "https://have-a.example/feed.xml".to_string(),
4351 ..Default::default()
4352 },
4353 )
4354 .await
4355 .unwrap();
4356 let existing_b = store::upsert_feed(
4357 &state.db,
4358 &store::NewFeed {
4359 url: "https://have-b.example/feed.xml".to_string(),
4360 ..Default::default()
4361 },
4362 )
4363 .await
4364 .unwrap();
4365 store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
4366 .await
4367 .unwrap();
4368 let before = store::count_feeds(&state.db).await.unwrap();
4369
4370 let cookie = session_cookie(&state, did, None);
4371 let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
4372 let app = router(state.clone());
4373 let resp = app
4374 .oneshot(
4375 Request::builder()
4376 .method("POST")
4377 .uri("/opml")
4378 .header(header::COOKIE, cookie)
4379 .header("content-type", ct)
4380 .body(Body::from(body))
4381 .unwrap(),
4382 )
4383 .await
4384 .unwrap();
4385 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4386 let after = store::count_feeds(&state.db).await.unwrap();
4388 assert_eq!(after, before, "over-cap DID imported new feeds anyway");
4389 }
4390
4391 #[tokio::test]
4394 async fn single_add_enforces_per_did_cap() {
4395 let did = "did:plc:subcapped";
4396 let state = test_state_with_caps(did, 1, 0).await;
4397 let f = store::upsert_feed(
4398 &state.db,
4399 &store::NewFeed {
4400 url: "https://have.example/feed.xml".to_string(),
4401 ..Default::default()
4402 },
4403 )
4404 .await
4405 .unwrap();
4406 store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
4407 let cookie = session_cookie(&state, did, None);
4408 let app = router(state.clone());
4409 let resp = app
4410 .oneshot(
4411 Request::builder()
4412 .method("POST")
4413 .uri("/subscriptions")
4414 .header(header::COOKIE, cookie)
4415 .header("content-type", "application/x-www-form-urlencoded")
4416 .body(Body::from("url=https://another.example/feed.xml"))
4417 .unwrap(),
4418 )
4419 .await
4420 .unwrap();
4421 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4422 let loc = resp
4423 .headers()
4424 .get(header::LOCATION)
4425 .unwrap()
4426 .to_str()
4427 .unwrap();
4428 assert!(
4429 loc.contains("Subscription%20limit%20reached"),
4430 "expected sub-limit flash, got {loc}"
4431 );
4432 }
4433
4434 #[tokio::test]
4441 async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
4442 let did = "did:plc:reader";
4443 let state = test_state(&[]).await;
4444 store::grant_access(&state.db, did, None, "test", None)
4445 .await
4446 .unwrap();
4447 let feed = store::upsert_feed(
4448 &state.db,
4449 &store::NewFeed {
4450 url: "https://reader.example/feed.xml".to_string(),
4451 title: Some("Reader".to_string()),
4452 ..Default::default()
4453 },
4454 )
4455 .await
4456 .unwrap();
4457 store::insert_entries(
4458 &state.db,
4459 feed,
4460 &[store::NewEntry {
4461 guid: "r-1".to_string(),
4462 url: Some("https://reader.example/1".to_string()),
4463 title: Some("Article".to_string()),
4464 published: Some("2026-07-11T00:00:00Z".to_string()),
4465 content_html: Some("<p>body</p>".to_string()),
4466 ..Default::default()
4467 }],
4468 0,
4469 )
4470 .await
4471 .unwrap();
4472 store::replace_sub_refs(&state.db, did, &[feed])
4473 .await
4474 .unwrap();
4475 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4476
4477 let cookie = session_cookie(&state, did, None);
4478 let app = router(state.clone());
4479
4480 let resp = app
4482 .clone()
4483 .oneshot(
4484 Request::builder()
4485 .method("POST")
4486 .uri(format!("/entries/{entry_id}/read"))
4487 .header(header::COOKIE, cookie.clone())
4488 .header("HX-Request", "true")
4489 .header("X-FR-Reader", "1")
4490 .header("content-type", "application/x-www-form-urlencoded")
4491 .body(Body::from("read=true"))
4492 .unwrap(),
4493 )
4494 .await
4495 .unwrap();
4496 assert_eq!(resp.status(), StatusCode::OK);
4497 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4498 .await
4499 .unwrap();
4500 let html = String::from_utf8(bytes.to_vec()).unwrap();
4501 assert!(
4502 html.contains("hx-swap-oob=\"outerHTML\""),
4503 "reader response must be an OOB swap: {html}"
4504 );
4505 assert!(
4506 html.contains(r#"id="entry-actionbar""#),
4507 "reader response must be the action-bar fragment: {html}"
4508 );
4509 assert!(
4512 html.contains(r#"aria-pressed="true""#),
4513 "read button must show pressed after marking read: {html}"
4514 );
4515 assert!(
4516 html.contains(r#"name="read" value="false""#),
4517 "hidden read value must flip to false so a second tap reverses: {html}"
4518 );
4519
4520 let resp2 = app
4523 .oneshot(
4524 Request::builder()
4525 .method("POST")
4526 .uri(format!("/entries/{entry_id}/read"))
4527 .header(header::COOKIE, cookie)
4528 .header("HX-Request", "true")
4529 .header("X-FR-Reader", "1")
4530 .header("content-type", "application/x-www-form-urlencoded")
4531 .body(Body::from("read=false"))
4532 .unwrap(),
4533 )
4534 .await
4535 .unwrap();
4536 assert_eq!(resp2.status(), StatusCode::OK);
4537 let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
4538 .await
4539 .unwrap();
4540 let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
4541 assert!(
4542 html2.contains(r#"aria-pressed="false""#),
4543 "read button must show un-pressed after reversing: {html2}"
4544 );
4545 assert!(
4546 html2.contains(r#"name="read" value="true""#),
4547 "hidden read value must flip back to true: {html2}"
4548 );
4549 }
4550
4551 #[tokio::test]
4554 async fn list_mark_read_returns_row_not_oob_actionbar() {
4555 let did = "did:plc:listv";
4556 let state = test_state(&[]).await;
4557 store::grant_access(&state.db, did, None, "test", None)
4558 .await
4559 .unwrap();
4560 let feed = store::upsert_feed(
4561 &state.db,
4562 &store::NewFeed {
4563 url: "https://list.example/feed.xml".to_string(),
4564 title: Some("List".to_string()),
4565 ..Default::default()
4566 },
4567 )
4568 .await
4569 .unwrap();
4570 store::insert_entries(
4571 &state.db,
4572 feed,
4573 &[store::NewEntry {
4574 guid: "l-1".to_string(),
4575 url: Some("https://list.example/1".to_string()),
4576 title: Some("Article".to_string()),
4577 published: Some("2026-07-11T00:00:00Z".to_string()),
4578 ..Default::default()
4579 }],
4580 0,
4581 )
4582 .await
4583 .unwrap();
4584 store::replace_sub_refs(&state.db, did, &[feed])
4585 .await
4586 .unwrap();
4587 let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
4588
4589 let cookie = session_cookie(&state, did, None);
4590 let app = router(state.clone());
4591
4592 let resp = app
4593 .oneshot(
4594 Request::builder()
4595 .method("POST")
4596 .uri(format!("/entries/{entry_id}/read"))
4597 .header(header::COOKIE, cookie)
4598 .header("HX-Request", "true")
4599 .header("content-type", "application/x-www-form-urlencoded")
4600 .body(Body::from("read=true"))
4601 .unwrap(),
4602 )
4603 .await
4604 .unwrap();
4605 assert_eq!(resp.status(), StatusCode::OK);
4606 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
4607 .await
4608 .unwrap();
4609 let html = String::from_utf8(bytes.to_vec()).unwrap();
4610 assert!(
4611 !html.contains("hx-swap-oob"),
4612 "list-view response must NOT be an OOB swap: {html}"
4613 );
4614 }
4615
4616 #[tokio::test]
4625 async fn rename_to_new_url_refused_at_global_feeds_cap() {
4626 let did = "did:plc:renamer";
4627 let state = test_state_with_caps(did, 0, 1).await;
4629 store::upsert_feed(
4630 &state.db,
4631 &store::NewFeed {
4632 url: "https://existing.example/feed.xml".to_string(),
4633 ..Default::default()
4634 },
4635 )
4636 .await
4637 .unwrap();
4638 let before = store::count_feeds(&state.db).await.unwrap();
4639 assert_eq!(before, 1);
4640
4641 let cookie = session_cookie(&state, did, None);
4642 let app = router(state.clone());
4643 let resp = app
4644 .oneshot(
4645 Request::builder()
4646 .method("POST")
4647 .uri("/subscriptions/rkey123/rename")
4648 .header(header::COOKIE, cookie)
4649 .header("content-type", "application/x-www-form-urlencoded")
4650 .body(Body::from(
4652 "url=https://brand-new.example/feed.xml&title=Renamed",
4653 ))
4654 .unwrap(),
4655 )
4656 .await
4657 .unwrap();
4658 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4659 let loc = resp
4660 .headers()
4661 .get(header::LOCATION)
4662 .unwrap()
4663 .to_str()
4664 .unwrap();
4665 assert!(
4666 loc.contains("feed%20capacity"),
4667 "expected the feed-capacity flash, got {loc}"
4668 );
4669 let after = store::count_feeds(&state.db).await.unwrap();
4671 assert_eq!(
4672 after, before,
4673 "rename inflated the shared cache past the cap"
4674 );
4675 }
4676
4677 #[tokio::test]
4680 async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
4681 let did = "did:plc:renamer2";
4682 let state = test_state_with_caps(did, 0, 1).await;
4683 store::upsert_feed(
4684 &state.db,
4685 &store::NewFeed {
4686 url: "https://existing.example/feed.xml".to_string(),
4687 ..Default::default()
4688 },
4689 )
4690 .await
4691 .unwrap();
4692 let before = store::count_feeds(&state.db).await.unwrap();
4693
4694 let cookie = session_cookie(&state, did, None);
4695 let app = router(state.clone());
4696 let resp = app
4697 .oneshot(
4698 Request::builder()
4699 .method("POST")
4700 .uri("/subscriptions/rkey123/rename")
4701 .header(header::COOKIE, cookie)
4702 .header("content-type", "application/x-www-form-urlencoded")
4703 .body(Body::from(
4704 "url=https://existing.example/feed.xml&title=Retitled",
4705 ))
4706 .unwrap(),
4707 )
4708 .await
4709 .unwrap();
4710 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4711 let loc = resp
4712 .headers()
4713 .get(header::LOCATION)
4714 .unwrap()
4715 .to_str()
4716 .unwrap();
4717 assert_eq!(
4718 loc, "/",
4719 "retitle of an existing feed must succeed, got {loc}"
4720 );
4721 assert_eq!(
4722 store::count_feeds(&state.db).await.unwrap(),
4723 before,
4724 "retitle must not add a feeds row"
4725 );
4726 }
4727
4728 #[tokio::test]
4731 async fn rename_with_blank_url_writes_nothing() {
4732 let did = "did:plc:renamer3";
4733 let state = test_state_with_caps(did, 0, 0).await;
4734 let before = store::count_feeds(&state.db).await.unwrap();
4735 assert_eq!(before, 0);
4736
4737 let cookie = session_cookie(&state, did, None);
4738 let app = router(state.clone());
4739 let resp = app
4740 .oneshot(
4741 Request::builder()
4742 .method("POST")
4743 .uri("/subscriptions/rkey123/rename")
4744 .header(header::COOKIE, cookie)
4745 .header("content-type", "application/x-www-form-urlencoded")
4746 .body(Body::from("url=%20%20&title=Nope"))
4748 .unwrap(),
4749 )
4750 .await
4751 .unwrap();
4752 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4753 assert_eq!(
4754 resp.headers()
4755 .get(header::LOCATION)
4756 .unwrap()
4757 .to_str()
4758 .unwrap(),
4759 "/",
4760 );
4761 assert_eq!(
4763 store::count_feeds(&state.db).await.unwrap(),
4764 0,
4765 "blank-URL rename wrote a junk feeds row"
4766 );
4767 }
4768
4769 #[test]
4775 fn manage_rename_row_preselects_current_folder() {
4776 let nav = Nav {
4777 handle: "@reader.example".to_string(),
4778 avatar: "RE".to_string(),
4779 view: "unread".to_string(),
4780 scope_qs: String::new(),
4781 folders: Vec::new(),
4782 loose_feeds: Vec::new(),
4783 manage_active: true,
4784 };
4785 let folder_options = vec![
4786 FolderOption {
4787 uri: "at://did:plc:x/app.folder/work".to_string(),
4788 name: "Work".to_string(),
4789 },
4790 FolderOption {
4791 uri: "at://did:plc:x/app.folder/fun".to_string(),
4792 name: "Fun".to_string(),
4793 },
4794 ];
4795 let foldered = FeedView {
4798 rkey: "sub-foldered".to_string(),
4799 url: "https://work.example/feed.xml".to_string(),
4800 title: "Work Feed".to_string(),
4801 unread: 0,
4802 selected: false,
4803 folder: Some("at://did:plc:x/app.folder/work".to_string()),
4804 };
4805 let loose = FeedView {
4806 rkey: "sub-loose".to_string(),
4807 url: "https://loose.example/feed.xml".to_string(),
4808 title: "Loose Feed".to_string(),
4809 unread: 0,
4810 selected: false,
4811 folder: None,
4812 };
4813 let tmpl = ManageTemplate {
4814 version: VERSION,
4815 repo_url: REPO_URL,
4816 kofi_url: KOFI_URL,
4817 flash: String::new(),
4818 nav,
4819 folder_options,
4820 folders: vec![FolderView {
4821 rkey: "folder-work".to_string(),
4822 uri: "at://did:plc:x/app.folder/work".to_string(),
4823 name: "Work".to_string(),
4824 feeds: vec![foldered],
4825 selected: false,
4826 }],
4827 loose_feeds: vec![loose],
4828 };
4829 let html = tmpl.render().unwrap();
4830
4831 assert!(
4833 html.contains(
4834 r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
4835 ),
4836 "foldered feed must pre-select its current folder: {html}"
4837 );
4838 assert!(
4841 html.contains(r#"<option value="" selected>No folder</option>"#),
4842 "loose feed must pre-select 'No folder': {html}"
4843 );
4844 }
4845}