From aecad18d226b1b1c6bf23cb0305264a85acdf6be Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 02:00:20 +0000 Subject: [PATCH] Make the feed URL editable, with a copy button Entries and history are keyed by feed id, so changing a URL keeps them -- the point being that a feed URL can carry an auth token that gets rotated. Changing it clears the stored ETag/Last-Modified, which belong to the old URL and could otherwise produce a bogus 304. The copy button cannot use navigator.clipboard: that needs a secure context and this is served over plain HTTP on a LAN address. Falls back to execCommand. Invalid input now returns 400 rather than 500. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe --- PROGRESS.md | 24 ++++++++++++ src/db.rs | 11 ++++++ src/web.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++++--- web/index.html | 42 ++++++++++++++++++++- 4 files changed, 170 insertions(+), 7 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index c5b5e8c..d97d6d5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,30 @@ and until now nothing set them. --- +## 2026-09-10 — Feed URL is editable, with a copy button + +The URL in a feed's Settings is now an editable field paired with Copy. Entries and download +history are keyed by feed id, not URL, so changing it keeps everything — which is the point, since +a Patreon feed URL carries an auth token that gets rotated. + +Two things that would otherwise have bitten: + +- **`navigator.clipboard` does not exist here.** It requires a secure context, and this is served + over plain HTTP on a LAN address, so the button would have silently done nothing. Falls back to a + hidden textarea plus `execCommand('copy')`, and reports Copied/Failed either way. +- **Changing the URL clears the stored ETag/Last-Modified.** Those validators belong to the old URL; + carrying them over could yield a bogus 304 against the new one and make a working feed look empty. + +`check_url` is a tested pure function: rejects empty, unparseable and non-http(s) URLs (so +`file:///etc/passwd` is refused), rejects a URL another feed already uses, and allows a feed to keep +its own URL unchanged. Validation failures and unknown feed ids now return **400**, not 500 — bad +input is the caller's mistake, and only genuine server faults are logged as errors. + +Verified live: the three rejection cases return 400 with readable messages, a no-op save returns 204, +and the feed still reports 131 entries / 11 downloaded. `cargo test` 34/34. + +--- + ## 2026-09-10 — Fixed: pressing play made an episode vanish from the list Reported as "where did Session Zero go, and why do parts share a number?". diff --git a/src/db.rs b/src/db.rs index 5b74a77..356ccfe 100644 --- a/src/db.rs +++ b/src/db.rs @@ -231,6 +231,17 @@ impl Db { Ok(()) } + /// Forgets the cached ETag/Last-Modified. Those validators belong to the old URL, so + /// keeping them across a URL change could produce a bogus 304 against the new one. + pub fn clear_validators(&self, feed_id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE feeds SET etag = NULL, last_modified = NULL, last_checked = NULL WHERE id = ?1", + [feed_id], + )?; + Ok(()) + } + pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( diff --git a/src/web.rs b/src/web.rs index 4d8353f..df389e1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -184,19 +184,56 @@ async fn feeds(State(state): State) -> Result>, ApiE Ok(Json(out)) } -/// Turns anyhow errors into a 500 with a readable body. -pub struct ApiError(anyhow::Error); +/// Validates a replacement feed URL: present, parseable, and not already subscribed under +/// a different id. Returns the trimmed URL. +fn check_url( + url: &str, + id: &str, + feeds: &std::collections::BTreeMap, +) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err("the feed URL cannot be empty".into()); + } + match url::Url::parse(url) { + Ok(u) if u.scheme() == "http" || u.scheme() == "https" => {} + Ok(u) => return Err(format!("{:?} is not an http(s) URL", u.scheme())), + Err(e) => return Err(format!("that is not a valid URL: {e}")), + } + if let Some((other, _)) = feeds.iter().find(|(k, f)| k.as_str() != id && f.url == url) { + return Err(format!("{other:?} is already subscribed to that URL")); + } + Ok(url.to_owned()) +} + +/// Turns errors into a response with a readable body. Bad input from the caller is a 400; +/// anything else is a 500, because those are our fault and not the caller's. +pub struct ApiError { + error: anyhow::Error, + status: StatusCode, +} impl> From for ApiError { fn from(e: E) -> Self { - Self(e.into()) + Self { error: e.into(), status: StatusCode::INTERNAL_SERVER_ERROR } + } +} + +impl ApiError { + fn bad_request(msg: impl Into) -> Self { + Self { + error: anyhow::Error::msg(msg.into()), + status: StatusCode::BAD_REQUEST, + } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { - tracing::warn!(error = ?self.0, "api error"); - (StatusCode::INTERNAL_SERVER_ERROR, format!("{:#}", self.0)).into_response() + if self.status.is_server_error() { + tracing::warn!(error = ?self.error, "api error"); + } + (self.status, format!("{:#}", self.error)).into_response() } } @@ -213,6 +250,39 @@ mod tests { assert!(constant_time_eq("", "")); } + fn feed(url: &str) -> crate::config::Feed { + crate::config::Feed { + url: url.into(), folder: None, keywords: vec![], allow_explicit: false, + auto_download: true, max_new_per_check: None, username: None, + password: None, password_env: None, + } + } + + #[test] + fn feed_urls_are_validated_before_being_saved() { + let mut feeds = std::collections::BTreeMap::new(); + feeds.insert("a".to_string(), feed("https://a.example/rss")); + feeds.insert("b".to_string(), feed("https://b.example/rss")); + + // Rotating a token on your own feed is the point of making this editable. + assert_eq!( + check_url(" https://a.example/rss?auth=new ", "a", &feeds).unwrap(), + "https://a.example/rss?auth=new", + "whitespace is trimmed" + ); + // Keeping your own URL unchanged is not a collision with yourself. + assert!(check_url("https://a.example/rss", "a", &feeds).is_ok()); + + assert!(check_url("", "a", &feeds).is_err(), "empty"); + assert!(check_url(" ", "a", &feeds).is_err(), "whitespace only"); + assert!(check_url("not a url", "a", &feeds).is_err(), "unparseable"); + assert!(check_url("file:///etc/passwd", "a", &feeds).is_err(), "not http(s)"); + assert!( + check_url("https://b.example/rss", "a", &feeds).is_err(), + "another feed already has that URL" + ); + } + #[test] fn generated_tokens_are_32_hex_chars_and_not_repeated() { let a = generate_token(); @@ -292,6 +362,7 @@ async fn add_feed( /// Only the fields that are present are changed. #[derive(Deserialize)] struct FeedPatch { + url: Option, folder: Option>, keywords: Option>, allow_explicit: Option, @@ -305,10 +376,22 @@ async fn patch_feed( Json(body): Json, ) -> Result { let mut cfg = (*state.ctx.cfg()).clone(); + + let checked = match &body.url { + Some(u) => Some(check_url(u, &id, &cfg.feeds).map_err(ApiError::bad_request)?), + None => None, + }; + let feed = cfg .feeds .get_mut(&id) - .ok_or_else(|| anyhow::anyhow!("no feed with id {id:?}"))?; + .ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?; + + let mut url_changed = false; + if let Some(url) = checked { + url_changed = url != feed.url; + feed.url = url; + } if let Some(v) = body.folder { feed.folder = v.filter(|s| !s.trim().is_empty()); @@ -327,6 +410,11 @@ async fn patch_feed( } cfg.save(&state.config_path)?; state.ctx.reload_cfg(&state.config_path)?; + if url_changed { + // Refreshing a rotated auth token is the common case; entries and download history + // are keyed by feed id, so they survive the change. + state.ctx.db.clear_validators(&id)?; + } Ok(StatusCode::NO_CONTENT) } diff --git a/web/index.html b/web/index.html index 5c1d875..07f578b 100644 --- a/web/index.html +++ b/web/index.html @@ -197,6 +197,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu .field label{font-size:12px;color:var(--dim)} .field .hint{font-size:11.5px;color:var(--faint)} .check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px} +.inline{display:flex;gap:6px;align-items:stretch} +.inline input{flex:1;min-width:0} +.inline .btn{white-space:nowrap;flex:none} .check input{width:16px;height:16px;accent-color:var(--accent)} .cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px} #toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60} @@ -283,6 +286,35 @@ async function api(url,opts){ if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status); return r.status===204?null:r.json().catch(()=>null); } +// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN +// address it is undefined, so fall back to the old selection-based copy. +async function copyText(text,btn){ + const flash=ok=>{ + if(!btn) return; + const was=btn.textContent; + btn.textContent=ok?'Copied':'Failed'; + setTimeout(()=>btn.textContent=was,1300); + }; + try{ + if(navigator.clipboard&&window.isSecureContext){ + await navigator.clipboard.writeText(text); + }else{ + const ta=document.createElement('textarea'); + ta.value=text; ta.setAttribute('readonly',''); + ta.style.cssText='position:fixed;top:-1000px;opacity:0'; + document.body.appendChild(ta); + ta.select(); ta.setSelectionRange(0,ta.value.length); + const ok=document.execCommand('copy'); + ta.remove(); + if(!ok) throw new Error('copy rejected'); + } + flash(true); + }catch(e){ + flash(false); + toast('Could not copy automatically — select the URL and copy it manually',true); + } +} + function toast(msg,bad){ const t=document.createElement('div'); t.className='toast'+(bad?' bad':''); t.textContent=msg; @@ -623,13 +655,21 @@ function settingsModal(f){ Blank means no limit. The rest wait for the next scan. -
${esc(f.url)}
+
+
+ + +
+ Editing this keeps every episode and download — handy when an auth token + in the URL is rotated. The feed is re-checked from scratch on the next scan.
`); + $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); $('#ssave').onclick=async()=>{ const max=$('#smax').value; try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({ + url:$('#surl').value.trim(), folder:$('#sfolder').value.trim()||null, keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean), max_new_per_check:max===''?null:Number(max),