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:
24
PROGRESS.md
24
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
|
## 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?".
|
Reported as "where did Session Zero go, and why do parts share a number?".
|
||||||
|
|||||||
11
src/db.rs
11
src/db.rs
@@ -231,6 +231,17 @@ impl Db {
|
|||||||
Ok(())
|
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<()> {
|
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
100
src/web.rs
100
src/web.rs
@@ -184,19 +184,56 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
|
|||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns anyhow errors into a 500 with a readable body.
|
/// Validates a replacement feed URL: present, parseable, and not already subscribed under
|
||||||
pub struct ApiError(anyhow::Error);
|
/// 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 {
|
impl<E: Into<anyhow::Error>> From<E> for ApiError {
|
||||||
fn from(e: E) -> Self {
|
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 {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
tracing::warn!(error = ?self.0, "api error");
|
if self.status.is_server_error() {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{:#}", self.0)).into_response()
|
tracing::warn!(error = ?self.error, "api error");
|
||||||
|
}
|
||||||
|
(self.status, format!("{:#}", self.error)).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +250,39 @@ mod tests {
|
|||||||
assert!(constant_time_eq("", ""));
|
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]
|
#[test]
|
||||||
fn generated_tokens_are_32_hex_chars_and_not_repeated() {
|
fn generated_tokens_are_32_hex_chars_and_not_repeated() {
|
||||||
let a = generate_token();
|
let a = generate_token();
|
||||||
@@ -292,6 +362,7 @@ async fn add_feed(
|
|||||||
/// Only the fields that are present are changed.
|
/// Only the fields that are present are changed.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FeedPatch {
|
struct FeedPatch {
|
||||||
|
url: Option<String>,
|
||||||
folder: Option<Option<String>>,
|
folder: Option<Option<String>>,
|
||||||
keywords: Option<Vec<String>>,
|
keywords: Option<Vec<String>>,
|
||||||
allow_explicit: Option<bool>,
|
allow_explicit: Option<bool>,
|
||||||
@@ -305,10 +376,22 @@ async fn patch_feed(
|
|||||||
Json(body): Json<FeedPatch>,
|
Json(body): Json<FeedPatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let mut cfg = (*state.ctx.cfg()).clone();
|
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
|
let feed = cfg
|
||||||
.feeds
|
.feeds
|
||||||
.get_mut(&id)
|
.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 {
|
if let Some(v) = body.folder {
|
||||||
feed.folder = v.filter(|s| !s.trim().is_empty());
|
feed.folder = v.filter(|s| !s.trim().is_empty());
|
||||||
@@ -327,6 +410,11 @@ async fn patch_feed(
|
|||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
cfg.save(&state.config_path)?;
|
||||||
state.ctx.reload_cfg(&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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 label{font-size:12px;color:var(--dim)}
|
||||||
.field .hint{font-size:11.5px;color:var(--faint)}
|
.field .hint{font-size:11.5px;color:var(--faint)}
|
||||||
.check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px}
|
.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)}
|
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
||||||
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
.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}
|
#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);
|
if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status);
|
||||||
return r.status===204?null:r.json().catch(()=>null);
|
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){
|
function toast(msg,bad){
|
||||||
const t=document.createElement('div');
|
const t=document.createElement('div');
|
||||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
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>
|
<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="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>
|
<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>
|
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
||||||
<button class="btn primary" id="ssave">Save</button></div>`);
|
<button class="btn primary" id="ssave">Save</button></div>`);
|
||||||
|
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||||
$('#ssave').onclick=async()=>{
|
$('#ssave').onclick=async()=>{
|
||||||
const max=$('#smax').value;
|
const max=$('#smax').value;
|
||||||
try{
|
try{
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({
|
||||||
|
url:$('#surl').value.trim(),
|
||||||
folder:$('#sfolder').value.trim()||null,
|
folder:$('#sfolder').value.trim()||null,
|
||||||
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
|
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
|
||||||
max_new_per_check:max===''?null:Number(max),
|
max_new_per_check:max===''?null:Number(max),
|
||||||
|
|||||||
Reference in New Issue
Block a user