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