Never list paid-feed services; scan on add; no "reaped"; slimmer header

- Security: feeds from Patreon, Supercast, Supporting Cast, Glow and
  Memberful are never listed in Popular or the Directory. A Supercast
  feed keeps its key in the URL's path, which the query check missed, so
  it was being listed.
- Adding a feed queues a scan of it, and an OPML import that added feeds
  scans what is due, so items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never
  downloaded: no "reaped" chip, just the Download button. The retention
  summary says "deleted".
- The feed header keeps its title and stats to one line each and wraps
  its buttons; a single feed's table drops the Feed column.
- Tests: adding a feed shows its item without Scan; a deleted file shows
  no "reaped"; paid-feed hosts and acast public ids in the unit test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
2026-09-11 14:47:03 +00:00
parent df9b7645d6
commit d7fac2d0e7
8 changed files with 100 additions and 12 deletions

View File

@@ -24,6 +24,16 @@ The long form, with what was wrong before and how it was found, is in
- Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it, - Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it,
marked Subscribed, and clicking one opens it. marked Subscribed, and clicking one opens it.
- Adding a feed scans it straight away, and an OPML import that added feeds scans them, so their
items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
label, just the Download button. The retention summary says "deleted", not "reaped".
### Security
- Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never
listed in Popular or the Directory. A Supercast feed, which keeps its key in the URL's path
rather than the query, was being listed.
## [0.3.0] - 2026-09-11 ## [0.3.0] - 2026-09-11

View File

@@ -52,6 +52,13 @@ with everything else, so this is built around not doing that:
the URL, or a query key containing `auth`, `token`, `key`, `secret`, `pass`, `sig`, `session`, the URL, or a query key containing `auth`, `token`, `key`, `secret`, `pass`, `sig`, `session`,
`user` or `uid`. It is a heuristic, and a token hidden in the URL's path gets through. A per-feed `user` or `uid`. It is a heuristic, and a token hidden in the URL's path gets through. A per-feed
`unlisted` flag is the upgrade if that happens. `unlisted` flag is the upgrade if that happens.
It happened the same day. The first screenshot of the new Directory listed "Glass Cannon Live!
Ascension (for Ray Slakinski)", a Supercast feed at `feeds.supercast.com/feeds/<key>`. Treating
any long path segment as a key would have hidden public feeds too: acast's show ids look the
same. So paid-feed services are named instead (Patreon, Supercast, Supporting Cast, Glow,
Memberful), and any feed from one of them is private whatever its URL looks like. The per-feed
flag is still the upgrade for a service not on that list.
- Feeds from an OPML are left out. Everyone subscribed to an OPML counts every feed inside it, so - Feeds from an OPML are left out. Everyone subscribed to an OPML counts every feed inside it, so
they would bury everything anyone chose on purpose. they would bury everything anyone chose on purpose.

View File

@@ -76,7 +76,8 @@ included. Directory lists every one of them A to Z. Your own feeds are marked Su
It shows a title, artwork and a count, never a URL or who reads it. Feeds from an It shows a title, artwork and a count, never a URL or who reads it. Feeds from an
OPML subscription are left out, since they come with the OPML. So is anything that looks private: a OPML subscription are left out, since they come with the OPML. So is anything that looks private: a
login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the
query. Those are someone's paid subscriptions, and listing them would let anyone here read what they query, or a feed from a paid-feed service such as Patreon or Supercast, which put the key in the
path. Those are someone's paid subscriptions, and listing them would let anyone here read what they
pay for. pay for.
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none

View File

@@ -62,10 +62,10 @@ impl Event {
Event::DownloadDone { path, .. } => format!(" saved {path}"), Event::DownloadDone { path, .. } => format!(" saved {path}"),
Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"), Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"),
Event::Reaped { path, bytes } => { Event::Reaped { path, bytes } => {
format!("reap {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0) format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
} }
Event::ReapDone { files, bytes } => format!( Event::ReapDone { files, bytes } => format!(
"reaped {files} file(s), {:.1} MB", "deleted {files} old file(s), {:.1} MB",
*bytes as f64 / 1_048_576.0 *bytes as f64 / 1_048_576.0
), ),
Event::Status { feeds, pending, downloaded } => { Event::Status { feeds, pending, downloaded } => {

View File

@@ -539,14 +539,20 @@ async fn feeds(
/// A feed that carries a credential is someone's paid or private subscription. Listing it /// A feed that carries a credential is someone's paid or private subscription. Listing it
/// would let anyone signed in subscribe to it and read what they pay for. /// would let anyone signed in subscribe to it and read what they pay for.
/// ///
/// ponytail: a heuristic. A token hidden in the URL's path gets through; a per-feed /// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
/// `unlisted` flag is the upgrade if that ever happens. /// through; a per-feed `unlisted` flag is the upgrade if that happens again.
fn looks_private(feed: &crate::config::Feed) -> bool { fn looks_private(feed: &crate::config::Feed) -> bool {
if feed.username.is_some() || feed.password.is_some() || feed.password_env.is_some() { if feed.username.is_some() || feed.password.is_some() || feed.password_env.is_some() {
return true; return true;
} }
let Ok(u) = url::Url::parse(&feed.url) else { return true }; let Ok(u) = url::Url::parse(&feed.url) else { return true };
!u.username().is_empty() // Paid-feed services put the subscriber's key in the path as often as in the query, and a
// long path segment alone proves nothing (acast's public show ids look the same). So a
// feed from one of them is private whatever its URL looks like. Supercast is why this
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
let host = u.host_str().unwrap_or("");
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|| !u.username().is_empty()
|| u.password().is_some() || u.password().is_some()
|| u.query_pairs().any(|(k, _)| { || u.query_pairs().any(|(k, _)| {
let k = k.to_ascii_lowercase(); let k = k.to_ascii_lowercase();
@@ -556,6 +562,10 @@ fn looks_private(feed: &crate::config::Feed) -> bool {
}) })
} }
/// Services whose feeds are always one subscriber's own.
const PAID_HOSTS: &[&str] =
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be. /// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
#[derive(Serialize)] #[derive(Serialize)]
struct PopularRow { struct PopularRow {
@@ -714,6 +724,11 @@ mod tests {
// Patreon's shape: the key is a query parameter. // Patreon's shape: the key is a query parameter.
assert!(looks_private(&f("https://www.patreon.com/rss/x?auth=abc123&show=2073588"))); assert!(looks_private(&f("https://www.patreon.com/rss/x?auth=abc123&show=2073588")));
assert!(looks_private(&f("https://example.com/rss?api_key=abc"))); assert!(looks_private(&f("https://example.com/rss?api_key=abc")));
// Paid-feed services put the key in the path; the host gives them away.
assert!(looks_private(&f("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx")));
assert!(looks_private(&f("https://someshow.supportingcast.fm/content/abc123.rss")));
// A long path segment alone is not a key: acast's public show ids look the same.
assert!(!looks_private(&f("https://feeds.acast.com/public/shows/0123456789abcdef01234567")));
assert!(looks_private(&f("https://ray:hunter2@example.com/rss"))); assert!(looks_private(&f("https://ray:hunter2@example.com/rss")));
assert!(looks_private(&f("not a url")), "unparseable is not safe to list"); assert!(looks_private(&f("not a url")), "unparseable is not safe to list");
let mut basic = f("https://example.com/rss"); let mut basic = f("https://example.com/rss");
@@ -882,6 +897,7 @@ async fn add_feed(
{ {
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some(); let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
state.ctx.db.subscribe(user.id, &existing.id)?; state.ctx.db.subscribe(user.id, &existing.id)?;
scan_soon(&state, Some(existing.id.clone())).await;
return Ok(Json( return Ok(Json(
serde_json::json!({ "id": existing.id, "existing": already }), serde_json::json!({ "id": existing.id, "existing": already }),
)); ));
@@ -890,9 +906,20 @@ async fn add_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)?;
state.ctx.db.subscribe(user.id, &id)?; state.ctx.db.subscribe(user.id, &id)?;
scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id, "existing": false }))) Ok(Json(serde_json::json!({ "id": id, "existing": false })))
} }
/// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
/// `None` scans whatever is due, which a feed never checked always is. The add has
/// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) {
let force = feed.is_some();
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
}
}
/// Absent means "leave alone"; JSON `null` means "clear this". /// Absent means "leave alone"; JSON `null` means "clear this".
/// ///
/// That distinction needs `double_option`: serde maps `null` onto the *outer* `None` for a /// That distinction needs `double_option`: serde maps `null` onto the *outer* `None` for a
@@ -1321,6 +1348,9 @@ async fn import_opml(
let doc = opml::OPML::from_str(&body.xml) let doc = opml::OPML::from_str(&body.xml)
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?; .map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id)?; let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id)?;
if added > 0 {
scan_soon(&state, None).await;
}
Ok(Json(serde_json::json!({ "added": added, "already": already }))) Ok(Json(serde_json::json!({ "added": added, "already": already })))
} }

View File

@@ -641,3 +641,28 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await expect(piper.locator('#count')).toContainText('All Subscriptions:'); await expect(piper.locator('#count')).toContainText('All Subscriptions:');
await ctx.close(); await ctx.close();
}); });
test('adding a feed scans it straight away', async ({ page }) => {
await page.locator('#addFeed').click();
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
await page.locator('#nsave').click();
// Nobody pressed Scan. The scheduler's tick is a minute, so this is the add scanning it.
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
});
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
page.on('dialog', d => d.accept());
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
const row = page.locator('.ep', { hasText: 'An Article' });
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
await page.locator('#files button', { hasText: 'Delete' }).click();
// No "reaped", no chip at all: just the way to get it again.
await expect(row).not.toContainText('downloaded');
await expect(row).not.toContainText(/reaped/i);
await row.click();
await expect(page.locator('#files')).not.toContainText(/reaped/i);
await expect(page.locator('#files .btn', { hasText: 'Download' })).toBeVisible();
});

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Fresh Show</title><link>http://127.0.0.1:8792/</link>
<description>Added in the browser suite, and scanned by adding it.</description>
<item><title>Fresh Ep</title><guid>fresh-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -251,6 +251,9 @@ a.btn{text-decoration:none;color:inherit}
.ep .file{display:flex;gap:6px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .file{display:flex;gap:6px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .file .dlbar{flex-basis:100%;margin-top:2px} .ep .file .dlbar{flex-basis:100%;margin-top:2px}
.ep .rowacts{justify-content:flex-end} .ep .rowacts{justify-content:flex-end}
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,112px) minmax(0,92px) 64px}
#split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none} .dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.chip{ .chip{
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700; font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;
@@ -350,8 +353,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
} }
/* The feed's own header, kept to one line so the table starts high, as it did. */ /* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:12px;margin-bottom:10px} .fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none} .fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
.fhead.slim .meta{flex:1 1 260px}
/* One line each, or a long title wraps to three and pushes the table down. */
.fhead.slim h2,.fhead.slim .sub.stat{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fhead.slim h2{font-size:17px} .fhead.slim h2{font-size:17px}
.fhead.slim .sub{margin-bottom:0;font-size:12.5px} .fhead.slim .sub{margin-bottom:0;font-size:12.5px}
.fhead.slim .acts{margin-top:0;flex:none} .fhead.slim .acts{margin-top:0;flex:none}
@@ -387,7 +393,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"} #split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
/* Title and date only; the files follow the item's text in the reader instead. */ /* Title and date only; the files follow the item's text in the reader instead. */
#files,.ephead,.ep .fd,.ep .file,.ep .rowacts{display:none} #files,.ephead,.ep .fd,.ep .file,.ep .rowacts{display:none}
.ep{grid-template-columns:20px 20px minmax(0,1fr) auto} .ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
#grab{display:none} #grab{display:none}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px} #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
body.reading #detail{display:block} body.reading #detail{display:block}
@@ -675,7 +681,7 @@ function renderFeed(){
${artHTML(f.image,name)} ${artHTML(f.image,name)}
<div class="meta"> <div class="meta">
<h2>${esc(name)}</h2> <h2>${esc(name)}</h2>
<div class="sub">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} <div class="sub stat">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}
· every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${ · every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div> f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''} ${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
@@ -708,8 +714,9 @@ function renderFeed(){
const pane=document.createElement('div'); const pane=document.createElement('div');
pane.id='split'; pane.id='split';
if(f) pane.className='one';
pane.innerHTML='<div id="list"><div class="ephead"><span></span><span title="Kept">⚑</span>'+ pane.innerHTML='<div id="list"><div class="ephead"><span></span><span title="Kept">⚑</span>'+
'<span>Item</span><span>Feed</span><span>File</span><span>Published</span><span></span></div>'+ '<span>Item</span><span class="h-fd">Feed</span><span>File</span><span>Published</span><span></span></div>'+
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>'; '<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
box.appendChild(pane); box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%'); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
@@ -823,6 +830,8 @@ function epEl(e){
const has=!!(enc&&enc.path); const has=!!(enc&&enc.path);
const playable=isPlayable(enc); const playable=isPlayable(enc);
const others=e.enclosures.length-1; const others=e.enclosures.length-1;
// A file deleted to save space reads as if it was never downloaded: no chip, just the ⤓.
const gone=!!enc&&enc.state==='reaped';
const el=document.createElement('div'); const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+ el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+
(player.guid===e.guid?' playing':''); (player.guid===e.guid?' playing':'');
@@ -845,7 +854,7 @@ function epEl(e){
</div> </div>
<span class="fd">${esc(feedName(e.feed_id))}</span> <span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file"> <div class="file">
${enc?`<span class="chip ${esc(enc.state)}">${ ${enc&&!gone?`<span class="chip ${esc(enc.state)}">${
has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}</span>`:''} has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}</span>`:''}
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''} ${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''} ${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
@@ -1022,8 +1031,9 @@ function encBox(x){
// straight to the publisher's copy in a new tab -- no download, and nothing proxied // straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay. // through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending'; const viewable = !isPlayable(x) && x.state !== 'pending';
// A file deleted to save space reads as if it was never downloaded, so it gets no chip.
return `<div class="encbox"> return `<div class="encbox">
<span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span> ${x.state==='reaped'?'':`<span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span>`}
<span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span> <span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''} ${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer">View</a>`:''} ${viewable?`<a class="btn" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer">View</a>`:''}