From d7fac2d0e7a0e25a36cfa5f66131e57e77363d69 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 11 Sep 2026 14:47:03 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn --- CHANGELOG.md | 10 ++++++++++ docs/history.md | 7 +++++++ docs/users.md | 3 ++- src/ipc.rs | 4 ++-- src/web.rs | 36 +++++++++++++++++++++++++++++++++--- tests/ui/app.spec.js | 25 +++++++++++++++++++++++++ tests/ui/fixtures/fresh.xml | 5 +++++ web/index.html | 22 ++++++++++++++++------ 8 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 tests/ui/fixtures/fresh.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index a2967bf..1c4dd42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, 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 diff --git a/docs/history.md b/docs/history.md index 4843912..62ffbea 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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`, `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. + + 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/`. 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 they would bury everything anyone chose on purpose. diff --git a/docs/users.md b/docs/users.md index 048abd1..bb138a1 100644 --- a/docs/users.md +++ b/docs/users.md @@ -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 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 -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. An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none diff --git a/src/ipc.rs b/src/ipc.rs index 1adef43..d703bf7 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -62,10 +62,10 @@ impl Event { Event::DownloadDone { path, .. } => format!(" saved {path}"), Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"), 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!( - "reaped {files} file(s), {:.1} MB", + "deleted {files} old file(s), {:.1} MB", *bytes as f64 / 1_048_576.0 ), Event::Status { feeds, pending, downloaded } => { diff --git a/src/web.rs b/src/web.rs index 73fa2f4..4a57f5a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -539,14 +539,20 @@ async fn feeds( /// 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. /// -/// ponytail: a heuristic. A token hidden in the URL's path gets through; a per-feed -/// `unlisted` flag is the upgrade if that ever happens. +/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets +/// through; a per-feed `unlisted` flag is the upgrade if that happens again. fn looks_private(feed: &crate::config::Feed) -> bool { if feed.username.is_some() || feed.password.is_some() || feed.password_env.is_some() { 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/` 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.query_pairs().any(|(k, _)| { 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. #[derive(Serialize)] struct PopularRow { @@ -714,6 +724,11 @@ mod tests { // 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://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("not a url")), "unparseable is not safe to list"); 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(); state.ctx.db.subscribe(user.id, &existing.id)?; + scan_soon(&state, Some(existing.id.clone())).await; return Ok(Json( serde_json::json!({ "id": existing.id, "existing": already }), )); @@ -890,9 +906,20 @@ async fn add_feed( cfg.save(&state.config_path)?; state.ctx.reload_cfg(&state.config_path)?; state.ctx.db.subscribe(user.id, &id)?; + scan_soon(&state, Some(id.clone())).await; 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) { + 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". /// /// 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) .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)?; + if added > 0 { + scan_soon(&state, None).await; + } Ok(Json(serde_json::json!({ "added": added, "already": already }))) } diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 9f06acd..fe327dd 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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 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(); +}); diff --git a/tests/ui/fixtures/fresh.xml b/tests/ui/fixtures/fresh.xml new file mode 100644 index 0000000..28895c9 --- /dev/null +++ b/tests/ui/fixtures/fresh.xml @@ -0,0 +1,5 @@ + +Fresh Showhttp://127.0.0.1:8792/ +Added in the browser suite, and scanned by adding it. +Fresh Epfresh-1x + diff --git a/web/index.html b/web/index.html index 0e198fd..aa707fb 100644 --- a/web/index.html +++ b/web/index.html @@ -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 .dlbar{flex-basis:100%;margin-top:2px} .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} .chip{ 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; } /* 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 .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 .sub{margin-bottom:0;font-size:12.5px} .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"} /* 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} - .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} #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px} body.reading #detail{display:block} @@ -675,7 +681,7 @@ function renderFeed(){ ${artHTML(f.image,name)}

${esc(name)}

-
${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} +
${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} · 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'}`:''}
${f.last_error?`
${esc(f.last_error)}
`:''} @@ -708,8 +714,9 @@ function renderFeed(){ const pane=document.createElement('div'); pane.id='split'; + if(f) pane.className='one'; pane.innerHTML='
'+ - 'ItemFeedFilePublished
'+ + 'ItemFeedFilePublished
'+ '
'; box.appendChild(pane); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%'); @@ -823,6 +830,8 @@ function epEl(e){ const has=!!(enc&&enc.path); const playable=isPlayable(enc); 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'); el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+ (player.guid===e.guid?' playing':''); @@ -845,7 +854,7 @@ function epEl(e){
${esc(feedName(e.feed_id))}
- ${enc?`${ + ${enc&&!gone?`${ has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}`:''} ${enc&&enc.length?`${mb(enc.length)}`:''} ${enc&&!has?`
`:''} @@ -1022,8 +1031,9 @@ function encBox(x){ // 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. 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 `
- ${x.state==='skipped'?kindOf(x):esc(x.state)} + ${x.state==='reaped'?'':`${x.state==='skipped'?kindOf(x):esc(x.state)}`} ${esc(kindOf(x))}${size?' \u00b7 '+size:''} ${x.state==='error'&&x.last_error?`${esc(x.last_error)}`:''} ${viewable?`View`:''}