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

@@ -1390,15 +1390,25 @@ function opmlModal(){
<div class="cardacts" style="justify-content:flex-start">
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export</a>
</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>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
<button class="btn primary" id="oimp">Import</button></div>`);
$('#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{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml:$('#opmlText').value})});
closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ toast(e.message,true); }
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ letGo(); toast(e.message,true); }
};
}