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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-10 02:00:20 +00:00
parent 44623098ae
commit aecad18d22
4 changed files with 170 additions and 7 deletions

View File

@@ -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?".

View File

@@ -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(

View File

@@ -184,19 +184,56 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, 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<String, crate::config::Feed>,
) -> Result<String, String> {
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<E: Into<anyhow::Error>> From<E> 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<String>) -> 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<String>,
folder: Option<Option<String>>,
keywords: Option<Vec<String>>,
allow_explicit: Option<bool>,
@@ -305,10 +376,22 @@ async fn patch_feed(
Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> {
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)
}

View File

@@ -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){
<span class="hint">Blank means no limit. The rest wait for the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new episodes automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow episodes marked explicit</label>
<div class="field"><label>Feed URL</label><span class="hint" style="overflow-wrap:anywhere">${esc(f.url)}</span></div>
<div class="field"><label>Feed URL</label>
<div class="inline">
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false">
<button type="button" class="btn" id="scopy">Copy</button>
</div>
<span class="hint">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.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="ssave">Save</button></div>`);
$('#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),