Upload an OPML file to import; tests for every way in and out

- The import screen has a file picker beside the paste box. The page
  reads the file, checks it looks like OPML before sending, and clears
  the picker when it is refused and after it is imported. The file is
  sent as text and never written to disk on the server.
- The server parses the OPML before touching anything and answers 400
  "that is not an OPML file" (was a 500). subscribe_opml takes a parsed
  document, so ipx import also refuses a non-OPML file by name.
- Tests: Settings' Export OPML download and paste import; uploading an
  RSS file (refused) and a real OPML; the server's 400; the admin's
  export round-tripped into a second account; ipx import/export in a
  scratch config.

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:42:38 +00:00
parent 8784d0a3fd
commit 5d3fdde4da
7 changed files with 165 additions and 32 deletions

View File

@@ -5,6 +5,19 @@ matters, what was wrong before -- the reasoning is the point, not the diff.
See [README.md](README.md) for what the thing is, and [docs/](docs/) for how to run it. See [README.md](README.md) for what the thing is, and [docs/](docs/) for how to run it.
## 2026-09-11 — Import an OPML file by uploading it
The import screen now has a file picker as well as the paste box. The page reads the file and
checks it looks like OPML before sending anything. If it doesn't, the page says so and clears the
picker, and it also clears the picker after an import. The file travels as text and is never
written to disk on the server, so there is nothing to clean up there.
The server now parses the OPML before touching anything, and returns `400` "that is not an OPML
file" instead of a `500`. `subscribe_opml` takes a parsed document, so `ipx import` also refuses a
non-OPML file by name before changing anything.
---
## 2026-09-11 — OPML import and export are per person ## 2026-09-11 — OPML import and export are per person
Importing an OPML now subscribes you to every feed in it. Feeds already in the catalogue cost Importing an OPML now subscribes you to every feed in it. Feeds already in the catalogue cost
@@ -17,6 +30,12 @@ through `subscribe_opml`.
Export now lists only your own subscriptions. It used to write out the whole catalogue to anyone Export now lists only your own subscriptions. It used to write out the whole catalogue to anyone
signed in, including other people's feeds and any private URLs in them. signed in, including other people's feeds and any private URLs in them.
Tests now cover every way in and out:
- Settings' Export OPML download and paste-to-import screen.
- A round trip that imports the admin's actual export into a second account and checks both
exports match. That round trip is exactly what failed.
- `ipx import` and `ipx export`, run in their own scratch config and database.
--- ---
## 2026-09-11 — The log is admin-only ## 2026-09-11 — The log is admin-only

View File

@@ -611,7 +611,9 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path
.into_iter() .into_iter()
.find(|u| u.is_admin) .find(|u| u.is_admin)
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?; .ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
let (added, had) = subscribe_opml(ctx, config_path, &text, admin.id)?; let doc = opml::OPML::from_str(&text)
.map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = subscribe_opml(ctx, config_path, &doc, admin.id)?;
println!("subscribed {} to {added} feed(s); {had} already there", admin.name); println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
Ok(()) Ok(())
} }
@@ -623,14 +625,15 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path
/// Before accounts, importing only added unknown URLs to config.toml. Once subscriptions /// Before accounts, importing only added unknown URLs to config.toml. Once subscriptions
/// decided what each person sees, that imported nothing at all for a feed someone else /// decided what each person sees, that imported nothing at all for a feed someone else
/// already had, and a new one had no subscriber, so it was never scanned. /// already had, and a new one had no subscriber, so it was never scanned.
///
/// The caller parses the document, so each refuses a file that is not OPML in its own terms,
/// before anything is touched: a 400 from the web, a message from the CLI.
pub fn subscribe_opml( pub fn subscribe_opml(
ctx: &Ctx, ctx: &Ctx,
config_path: &std::path::Path, config_path: &std::path::Path,
xml: &str, doc: &opml::OPML,
user_id: i64, user_id: i64,
) -> Result<(usize, usize)> { ) -> Result<(usize, usize)> {
let doc = opml::OPML::from_str(xml)
.map_err(|e| anyhow::anyhow!("that does not parse as OPML: {e}"))?;
let mut found = vec![]; let mut found = vec![];
collect_outlines(&doc.body.outlines, &mut found); collect_outlines(&doc.body.outlines, &mut found);

View File

@@ -1172,8 +1172,11 @@ async fn import_opml(
user: crate::db::User, user: crate::db::User,
Json(body): Json<OpmlBody>, Json(body): Json<OpmlBody>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let (added, already) = // Refused before anything is touched. Nothing reaches the disk either way: an uploaded
crate::subscribe_opml(&state.ctx, &state.config_path, &body.xml, user.id)?; // file arrives as text, is read here, and is gone when the request ends.
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)?;
Ok(Json(serde_json::json!({ "added": added, "already": already }))) Ok(Json(serde_json::json!({ "added": added, "already": already })))
} }

View File

@@ -82,6 +82,7 @@ const drive = [
['removeFeed', () => ctx.removeFeed(feed)], ['removeFeed', () => ctx.removeFeed(feed)],
['prefsModal', () => ctx.prefsModal()], ['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()], ['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()],
['logsModal', () => ctx.logsModal()], ['logsModal', () => ctx.logsModal()],
// `const S` is not reachable from here: top-level const/let do not become properties // `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do. // of a vm context the way var and function declarations do.

View File

@@ -413,9 +413,67 @@ test('the only admin cannot be demoted or removed', async ({ page }) => {
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400); expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
}); });
test('importing an OPML subscribes you, and export lists only your feeds', async ({ browser }) => { test('Settings exports your OPML and imports a pasted one', async ({ page }) => {
// Regression: import only added URLs the catalogue lacked and subscribed nobody, so a feed await page.locator('#prefs').click();
// someone else already had imported as nothing at all. const [dl] = await Promise.all([
page.waitForEvent('download'),
page.locator('#modalCard a', { hasText: 'Export OPML' }).click(),
]);
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
const out = require('fs').readFileSync(await dl.path(), 'utf8');
for (const f of ['show.xml', 'pics.xml', 'multi.xml', 'subs.opml']) expect(out).toContain(f);
// A feed from an OPML subscription comes back with the OPML itself, not on its own.
expect(out).not.toContain('other.xml');
// One feed new to everyone, one the admin already has.
await page.locator('#gopml').click();
await page.locator('#opmlText').fill('<opml version="2.0"><head><title>t</title></head><body>' +
'<outline text="Imported Show" xmlUrl="http://127.0.0.1:8792/imported.xml"/>' +
'<outline text="Test Show" xmlUrl="http://127.0.0.1:8792/show.xml"/></body></opml>');
await page.locator('#oimp').click();
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
.toHaveText('Subscribed to 1 feed(s), 1 you already had');
// Named from the OPML's id until the first scan reads the feed's own title.
await expect(page.locator('#feedlist .feed', { hasText: /Imported Show|imported-show/ }))
.toBeVisible({ timeout: 20_000 });
});
test('an uploaded OPML file imports, and a file that is not OPML is refused', async ({ page }) => {
await page.locator('#prefs').click();
await page.locator('#gopml').click();
const pick = page.locator('#opmlFile');
// An RSS feed is XML but not OPML: refused in the page, and the picker lets go of it.
await pick.setInputFiles({
name: 'feed.xml', mimeType: 'application/xml',
buffer: require('fs').readFileSync(require('path').join(__dirname, 'fixtures', 'show.xml')),
});
await page.locator('#oimp').click();
await expect(page.locator('.toast.bad', { hasText: 'feed.xml is not an OPML file' })).toBeVisible();
expect(await pick.evaluate(i => i.files.length)).toBe(0);
// Something that gets past the page's quick look is still refused by the server, untouched.
const sneaky = await page.request.post('/api/opml', {
data: { xml: '<rss version="2.0"><channel><title>&lt;opml&gt;</title></channel></rss>' },
});
expect(sneaky.status()).toBe(400);
expect(await sneaky.text()).toContain('not an OPML file');
// A real one. Multi Show is already the admin's, so it counts as already had.
await pick.setInputFiles({
name: 'subs.opml', mimeType: 'text/x-opml',
buffer: Buffer.from('<opml version="2.0"><head><title>t</title></head><body>' +
'<outline text="Multi Show" xmlUrl="http://127.0.0.1:8792/multi.xml"/></body></opml>'),
});
await page.locator('#oimp').click();
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
.toHaveText('Subscribed to 0 feed(s), 1 you already had');
await expect(page.locator('#modal.on')).toBeHidden();
});
test('an export from one account imports into another', async ({ page, browser }) => {
// Regression: import only added URLs the catalogue lacked and subscribed nobody, so importing
// the admin's export into a second account did nothing at all.
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const setup = require('./global-setup'); const setup = require('./global-setup');
const env = { const env = {
@@ -429,26 +487,60 @@ test('importing an OPML subscribes you, and export lists only your feeds', async
if (!String(e.stderr || e.stdout).includes('already exists')) throw e; if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
} }
const ctx = await browser.newContext(); const ctx = await browser.newContext();
const page = await ctx.newPage(); const opal = await ctx.newPage();
await page.goto('/login'); await opal.goto('/login');
await page.locator('#name').fill('opal'); await opal.locator('#name').fill('opal');
await page.locator('#pw').fill('opalpassword'); await opal.locator('#pw').fill('opalpassword');
await page.locator('button[type=submit]').click(); await opal.locator('button[type=submit]').click();
await expect(page.locator('#feedlist')).toContainText('No feeds.'); await expect(opal.locator('#feedlist')).toContainText('No feeds.');
// Test Show is already in the catalogue, because the admin reads it. // Export used to hand anyone the whole catalogue. Opal has nothing yet, so gets nothing.
const xml = '<opml version="2.0"><head><title>t</title></head><body>' + const empty = await opal.request.get('/api/opml');
'<outline text="Test Show" xmlUrl="http://127.0.0.1:8792/show.xml"/></body></opml>'; expect(empty.status()).toBe(200);
expect(await (await page.request.post('/api/opml', { data: { xml } })).json()) expect(await empty.text()).not.toContain('xmlUrl');
.toEqual({ added: 1, already: 0 });
expect(await (await page.request.post('/api/opml', { data: { xml } })).json())
.toEqual({ added: 0, already: 1 });
await page.reload();
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// The admin also reads Picture Blog; that is not opal's to export. const urlsIn = xml => [...xml.matchAll(/xmlUrl="([^"]+)"/g)].map(m => m[1]).sort();
const out = await (await page.request.get('/api/opml')).text(); const exported = await (await page.request.get('/api/opml')).text(); // the admin's
expect(out).toContain('show.xml'); const urls = urlsIn(exported);
expect(out).not.toContain('pics.xml'); expect(urls.length).toBeGreaterThan(2);
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
.toEqual({ added: urls.length, already: 0 });
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
.toEqual({ added: 0, already: urls.length });
await opal.reload();
await expect(opal.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// The round trip closes: opal's own export now lists what the admin's did.
expect(urlsIn(await (await opal.request.get('/api/opml')).text())).toEqual(urls);
await ctx.close(); await ctx.close();
}); });
test('ipx import subscribes the admin, and ipx export writes the feeds out', async () => {
// Its own config and database. The CLI works in-process, and the suite's running daemon
// reads config.toml once at start, so it would not see what the CLI added anyway.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const dir = path.join(setup.root, 'cli');
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
fs.writeFileSync(path.join(dir, 'config.toml'),
`[general]\ndownload_dir = "${dir}/downloads"\nsocket = "${dir}/ipx.sock"\n`);
const env = { ...process.env, IPX_CONFIG: path.join(dir, 'config.toml'), IPX_DATA_DIR: path.join(dir, 'data') };
const ipx = (args, input) => execFileSync('./target/debug/ipx', args, { env, input, encoding: 'utf8' });
ipx(['user', 'add', 'boss'], 'bosspassword'); // the first account is the admin
const opml = path.join(dir, 'in.opml');
fs.writeFileSync(opml, '<opml version="2.0"><head><title>t</title></head><body>' +
'<outline text="One" xmlUrl="http://127.0.0.1:8792/one.xml"/>' +
'<outline text="Two" xmlUrl="http://127.0.0.1:8792/two.xml"/></body></opml>');
expect(ipx(['import', opml])).toContain('subscribed boss to 2 feed(s); 0 already there');
expect(ipx(['import', opml])).toContain('subscribed boss to 0 feed(s); 2 already there');
const out = path.join(dir, 'out.opml');
ipx(['export', out]);
const xml = fs.readFileSync(out, 'utf8');
expect(xml).toContain('http://127.0.0.1:8792/one.xml');
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
});

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Imported Show</title><link>http://127.0.0.1:8792/</link>
<description>Only ever arrives through an OPML import.</description>
<item><title>Imported Ep</title><guid>imp-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -1390,15 +1390,25 @@ function opmlModal(){
<div class="cardacts" style="justify-content:flex-start"> <div class="cardacts" style="justify-content:flex-start">
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export</a> <a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export</a>
</div> </div>
<div class="field" style="margin-top:16px"><label>Import: paste OPML</label> <div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div> <textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button> <div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
<button class="btn primary" id="oimp">Import</button></div>`); <button class="btn primary" id="oimp">Import</button></div>`);
$('#oimp').onclick=async()=>{ $('#oimp').onclick=async()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported.
const pick=$('#opmlFile'), file=pick.files[0];
const xml=file ? await file.text() : $('#opmlText').value;
const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word.
if(!/<opml[\s>]/i.test(xml)){
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
}
try{ try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml:$('#opmlText').value})}); const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true); letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ toast(e.message,true); } }catch(e){ letGo(); toast(e.message,true); }
}; };
} }