Popular on this server; changelog follows Keep a Changelog; 0.3.0

- Add feed lists what other accounts subscribe to, most subscribers
  first, and subscribes you by id (GET /api/popular, POST
  /api/popular/{id}). Rows never carry a URL. Feeds from an OPML and
  anything that looks private (a login, credentials in the URL, a key
  such as auth= or token=) are never listed, and the subscribe route
  checks the id against the same list.
- CHANGELOG.md follows Keep a Changelog 1.1.0: 0.1.0 (2026-09-09, the
  CLI), 0.2.0 (2026-09-10, the web UI), 0.3.0 (2026-09-11, accounts and
  sharing). The long-form entries moved unchanged to docs/history.md.
- Cargo.toml is 0.3.0. CLAUDE.md says how to add an entry and cut a
  release.

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 13:56:57 +00:00
parent 5d3fdde4da
commit 8b8b48302b
13 changed files with 1679 additions and 1310 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -108,8 +108,16 @@ Comments explain **why**, not what. If a line looks odd, the comment says what w
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
addressed to the person using it. addressed to the person using it.
Every change gets an entry at the top of [CHANGELOG.md](CHANGELOG.md), dated, saying what landed and Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
what was wrong before. That record has been more useful than the git log more than once. [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
say, such as what was wrong before or what it cost to find out, write it up at the top of
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
than once.
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
compare links at the bottom of the changelog.
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g. Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`. `// ponytail: global connection mutex, move to a pool if feed count makes it contend`.

2
Cargo.lock generated
View File

@@ -1634,7 +1634,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -42,7 +42,8 @@ change it with `echo -n '<password>' | ipx user passwd admin`.
| [docs/users.md](docs/users.md) | Accounts, and what several people share | | [docs/users.md](docs/users.md) | Accounts, and what several people share |
| [docs/sso.md](docs/sso.md) | Cloudflare Zero Trust or Authentik in front | | [docs/sso.md](docs/sso.md) | Cloudflare Zero Trust or Authentik in front |
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, socket, HTTP API | | [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, socket, HTTP API |
| [CHANGELOG.md](CHANGELOG.md) | What landed, and why | | [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
| [docs/history.md](docs/history.md) | How it was built: the long form, with what was wrong and why |
| [CLAUDE.md](CLAUDE.md) | Notes for anyone (or anything) working on the code | | [CLAUDE.md](CLAUDE.md) | Notes for anyone (or anything) working on the code |
## The web UI ## The web UI

View File

@@ -110,6 +110,7 @@ else a `401`.
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | | | `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | | `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
| `GET /api/popular`, `POST /api/popular/{id}` | what others here subscribe to (id, title, art, count; never a URL, never a private feed); subscribe by id |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write | | `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/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 | | `GET /api/events` | SSE, the same broadcast the socket carries |

1331
docs/history.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,13 @@ list with their own read state. Unsubscribing removes it from their list alone;
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
re-subscribing does not pull the back catalogue again. re-subscribing does not pull the back catalogue again.
**Add feed** also lists what other people on this server subscribe to, most subscribers first, as
a place to start. 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
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
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or

View File

@@ -74,6 +74,8 @@ pub fn router(state: WebState) -> Router {
.route("/api/fetch", post(fetch_now)) .route("/api/fetch", post(fetch_now))
.route("/api/opml", get(export_opml).post(import_opml)) .route("/api/opml", get(export_opml).post(import_opml))
.route("/api/settings", get(get_settings).patch(patch_settings)) .route("/api/settings", get(get_settings).patch(patch_settings))
.route("/api/popular", get(get_popular))
.route("/api/popular/{id}", post(subscribe_popular))
.route("/api/users", get(list_users).post(add_user)) .route("/api/users", get(list_users).post(add_user))
.route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/users/{id}", patch(patch_user).delete(remove_user))
.route("/api/logs", get(logs)) .route("/api/logs", get(logs))
@@ -530,6 +532,84 @@ async fn feeds(
Ok(Json(out)) Ok(Json(out))
} }
// ---- popular on this server ----
/// 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.
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()
|| u.password().is_some()
|| u.query_pairs().any(|(k, _)| {
let k = k.to_ascii_lowercase();
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
.iter()
.any(|w| k.contains(w))
})
}
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
#[derive(Serialize)]
struct PopularRow {
id: String,
title: Option<String>,
image: Option<String>,
subscribers: i64,
}
/// What other people here subscribe to that you don't, most subscribers first. What the
/// Add feed screen offers, and all that `subscribe_popular` will subscribe you to.
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db;
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?;
let mut out = vec![];
for s in crate::subscriptions(&state.ctx)? {
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 || mine.contains(&s.id) || looks_private(&s.cfg) {
continue;
}
let sum = db.feed_summary(&s.id)?;
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n });
}
let name = |p: &PopularRow| p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase();
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).cmp(&name(b))));
Ok(out)
}
async fn get_popular(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?;
rows.truncate(20);
Ok(Json(rows))
}
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so
/// a guessed id cannot reach a private feed.
async fn subscribe_popular(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, 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")));
}
state.ctx.db.subscribe(user.id, &id)?;
Ok(Json(serde_json::json!({ "id": id })))
}
/// Validates a replacement feed URL: present, parseable, and not already subscribed under /// Validates a replacement feed URL: present, parseable, and not already subscribed under
/// a different id. Returns the trimmed URL. /// a different id. Returns the trimmed URL.
fn check_url( fn check_url(
@@ -594,6 +674,34 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_feed_with_a_credential_is_never_popular() {
let f = |url: &str| crate::config::Feed {
url: url.into(),
folder: None,
group: None,
media_types: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
};
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
// 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")));
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");
basic.username = Some("ray".into());
assert!(looks_private(&basic), "a feed with a login configured");
}
#[test] #[test]
fn only_the_last_admin_is_protected() { fn only_the_last_admin_is_protected() {
let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin }; let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin };

View File

@@ -10,8 +10,8 @@ test.beforeEach(async ({ page }) => {
test('the page loads and lists the configured feeds', async ({ page }) => { test('the page loads and lists the configured feeds', async ({ page }) => {
// Regression: a ReferenceError in the script left the shell rendered and the sidebar // Regression: a ReferenceError in the script left the shell rendered and the sidebar
// empty, with every handler below the error dead. Server-side checks all passed. // empty, with every handler below the error dead. Server-side checks all passed.
// Three top-level feeds in the fixture config; the OPML's children are inside a closed folder. // Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 }); await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
await expect(page.getByText('Test Show')).toBeVisible(); await expect(page.getByText('Test Show')).toBeVisible();
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
@@ -544,3 +544,44 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
expect(xml).toContain('http://127.0.0.1:8792/one.xml'); expect(xml).toContain('http://127.0.0.1:8792/one.xml');
expect(xml).toContain('http://127.0.0.1:8792/two.xml'); expect(xml).toContain('http://127.0.0.1:8792/two.xml');
}); });
test('Add feed offers what other people here read, but never a private feed', async ({ browser }) => {
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = {
...process.env,
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
};
try {
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
} catch (e) {
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
}
const ctx = await browser.newContext();
const piper = await ctx.newPage();
await piper.goto('/login');
await piper.locator('#name').fill('piper');
await piper.locator('#pw').fill('piperpassword');
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
await piper.locator('#addFeed').click();
const offered = piper.locator('#popular .childrow');
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed.
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
const listed = await (await piper.request.get('/api/popular')).text();
expect(listed).not.toContain('secret123');
expect(listed).not.toContain('.xml');
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
await offered.filter({ hasText: 'Test Show' }).locator('button', { hasText: 'Subscribe' }).click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// Once it is yours, it is no longer offered.
expect(await (await piper.request.get('/api/popular')).text()).not.toContain('"test-show"');
await ctx.close();
});

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Paid Show</title><link>http://127.0.0.1:8792/</link>
<description>Subscribed with a key in its URL, so it must never be offered to anyone else.</description>
<item><title>Paid Ep</title><guid>paid-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -55,6 +55,11 @@ auto_download = true
[feeds.test-subscriptions] [feeds.test-subscriptions]
url = "http://127.0.0.1:8792/subs.opml" url = "http://127.0.0.1:8792/subs.opml"
auto_download = false auto_download = false
# A key in its URL, like a Patreon feed: someone's paid subscription, never offered to others.
[feeds.paid-show]
url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
auto_download = false
`); `);
} }

View File

@@ -1142,9 +1142,12 @@ $('#addFeed').onclick=()=>{
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div> <div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
<div class="field"><label>Keywords (optional, comma separated)</label> <div class="field"><label>Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div> <input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
<div class="field"><label>Popular on this server</label>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div></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="nsave">Add feed</button></div>`); <button class="btn primary" id="nsave">Add feed</button></div>`);
$('#nurl').focus(); $('#nurl').focus();
showPopular();
$('#nsave').onclick=async()=>{ $('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return; const url=$('#nurl').value.trim(); if(!url) return;
$('#nsave').textContent='Adding…'; $('#nsave').disabled=true; $('#nsave').textContent='Adding…'; $('#nsave').disabled=true;
@@ -1158,6 +1161,31 @@ $('#addFeed').onclick=()=>{
}; };
}; };
// What other people here read, as a place to start. The rows carry an id, never a URL, so a
// key in someone's feed address never reaches this page.
async function showPopular(){
const box=$('#popular');
let rows=[];
try{ rows=await api('/api/popular')||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds other people here subscribe to show up here.</p>';
for(const p of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
`<button class="btn" data-a="sub">Subscribe</button>`;
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
};
box.appendChild(el);
}
}
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
function toggleGroup(id){ function toggleGroup(id){
expanded.has(id) ? expanded.delete(id) : expanded.add(id); expanded.has(id) ? expanded.delete(id) : expanded.add(id);