diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c4e17..b3d97e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ The long form, with what was wrong before and how it was found, is in ## [Unreleased] +### Changed + +- Directory lists the feeds inside an OPML one by one, and no longer the OPML itself, so you can + subscribe to just the shows you want. Popular still counts an OPML as one feed. + ## [0.5.1] - 2026-09-12 ### Fixed diff --git a/docs/architecture.md b/docs/architecture.md index fdbae7c..13811b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,7 +115,7 @@ else a `401`. | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/fetch` | | | `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | -| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id | +| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds with an OPML counted as one, and every listable feed A to Z with an OPML's feeds in place of the OPML, with everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id | | `GET /api/settings`, `PATCH /api/settings` | admin-only to write | | `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed | | `GET /api/events` | SSE, the same broadcast the socket carries | diff --git a/docs/users.md b/docs/users.md index 3a853ed..686fbf5 100644 --- a/docs/users.md +++ b/docs/users.md @@ -73,12 +73,14 @@ re-subscribing does not pull the back catalogue again. **Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed. -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, 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. +It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is one +feed in Popular, since everyone subscribed to it counts for every feed inside; Directory lists the +feeds inside it one by one instead, and never the OPML, so you can take just the shows you want. +Anything that looks private is left out: a login configured for the feed, credentials in its URL, +or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as +Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private +itself. 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 for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its diff --git a/src/web.rs b/src/web.rs index d0349df..ecba821 100644 --- a/src/web.rs +++ b/src/web.rs @@ -579,25 +579,36 @@ struct PopularRow { } /// Every feed that may be listed, with everyone counted, you included, most subscribers -/// first. Popular is the top of it, the directory is all of it, and it is all that -/// `subscribe_popular` will subscribe you to. -fn popular(state: &WebState, user_id: i64) -> Result> { +/// first. Popular is the top of one list and the directory is all of the other, and between +/// them they are all that `subscribe_popular` will subscribe you to. +/// +/// `folders` says how an OPML appears. Popular lists the OPML once and not the feeds inside: +/// everyone subscribed to it counts for every one of them, and eighty of those would bury +/// everything anyone chose on purpose. The directory, which is for finding a show, lists the +/// feeds inside and never the OPML. +fn popular(state: &WebState, user_id: i64, folders: bool) -> Result> { let db = &state.ctx.db; let mine: std::collections::HashSet = db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect(); let counts = db.subscriber_counts()?; + let catalogue = crate::subscriptions(&state.ctx)?; + let by_id: std::collections::HashMap<&str, &crate::config::Feed> = + catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect(); + let is_folder: std::collections::HashSet<&str> = + catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect(); let mut out = vec![]; - for s in crate::subscriptions(&state.ctx)? { + for s in &catalogue { let n = counts.get(&s.id).copied().unwrap_or(0); - // A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed - // inside, which would bury everything anyone chose on purpose. - let from_opml = s.managed || s.cfg.group.is_some(); - if n == 0 || from_opml || looks_private(&s.cfg) { + let inside = s.managed || s.cfg.group.is_some(); + let hidden = if folders { inside } else { is_folder.contains(s.id.as_str()) }; + // A feed inside an OPML that looks private is as private as the OPML. + let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g)); + if n == 0 || hidden || looks_private(&s.cfg) || folder.is_some_and(|f| looks_private(f)) { continue; } let sum = db.feed_summary(&s.id)?; let subscribed = mine.contains(&s.id); - out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n, subscribed }); + out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed }); } out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b)))); Ok(out) @@ -607,17 +618,17 @@ async fn get_popular( State(state): State, user: crate::db::User, ) -> Result>, ApiError> { - let mut rows = popular(&state, user.id)?; + let mut rows = popular(&state, user.id, true)?; rows.truncate(10); Ok(Json(rows)) } -/// Every feed that may be listed, A to Z. +/// Every feed that may be listed, A to Z, with the feeds inside an OPML in place of the OPML. async fn get_directory( State(state): State, user: crate::db::User, ) -> Result>, ApiError> { - let mut rows = popular(&state, user.id)?; + let mut rows = popular(&state, user.id, false)?; rows.sort_by_key(sort_name); Ok(Json(rows)) } @@ -626,15 +637,16 @@ fn sort_name(p: &PopularRow) -> String { p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase() } -/// Subscribes by id, because the list never shows a URL. Checked against the same list, so +/// Subscribes by id, because the lists never show a URL. Checked against the same lists, so /// a guessed id cannot reach a private feed. async fn subscribe_popular( State(state): State, user: crate::db::User, Path(id): Path, ) -> Result, ApiError> { - if !popular(&state, user.id)?.iter().any(|p| p.id == id) { - return Err(ApiError::bad_request(format!("{id:?} is not on the popular list"))); + let listed = |folders| popular(&state, user.id, folders).map(|rows| rows.iter().any(|p| p.id == id)); + if !(listed(true)? || listed(false)?) { + return Err(ApiError::bad_request(format!("{id:?} is not in the directory"))); } state.ctx.db.subscribe(user.id, &id)?; Ok(Json(serde_json::json!({ "id": id }))) diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 52c8fa6..c282ecf 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -623,19 +623,26 @@ test('Popular lists what everyone here reads, but never a private feed', async ( expect(listed).not.toContain('.xml'); expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400); - // Popular is the top ten of the directory, and the directory is every listed feed, A to Z. + // The directory is every listed feed A to Z, with an OPML's feeds in place of the OPML. + // Popular is the most subscribed, with an OPML as one feed, so its feeds cannot bury the rest. const dir = await (await piper.request.get('/api/directory')).json(); const top = await (await piper.request.get('/api/popular')).json(); const names = dir.map(p => (p.title || p.id).toLowerCase()); expect(names).toEqual([...names].sort()); - expect(top.length).toBe(Math.min(10, dir.length)); - expect(top.every(t => dir.some(d => d.id === t.id))).toBe(true); - expect(dir.map(p => p.id)).not.toContain('paid-show'); + const ids = dir.map(p => p.id); + expect(ids).not.toContain('test-subscriptions'); + expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio'])); + expect(top.length).toBeLessThanOrEqual(10); + expect(top.map(t => t.id)).not.toContain('grouped-show'); + expect(top.filter(t => t.id !== 'test-subscriptions').every(t => ids.includes(t.id))).toBe(true); + expect(ids).not.toContain('paid-show'); // Subscribe from the directory this time; the popular list shares the same rows. await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`); await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible(); + await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible(); + await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0); await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0); const row = async () => diff --git a/web/index.html b/web/index.html index f3ec460..1d5ab5c 100644 --- a/web/index.html +++ b/web/index.html @@ -775,9 +775,9 @@ const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0); // an id starting with ':' can never be a feed's, since feed ids are slugs. const VIEWS={ ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory', - blurb:'Every feed anyone on this server subscribes to, A to Z.'}, + blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'}, ':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular', - blurb:'The ten feeds with the most subscribers here.'}, + blurb:'The ten feeds with the most subscribers here. An OPML counts as one feed.'}, ':all':{title:'All Subscriptions',icon:ICON.all}, }; function renderFeeds(){ @@ -1592,8 +1592,7 @@ async function renderListed(v){
${v.icon}

${v.title}

-
${v.blurb} Everyone counts, you included. Feeds inside an OPML - subscription, and private feeds, are never listed.
+
${v.blurb} Everyone counts, you included. Private feeds are never listed.
`; $('#count').textContent=v.title;