OPML import subscribes you; export lists only your feeds

Import predated accounts: it only added URLs missing from config.toml
and subscribed nobody. Importing another account's export did nothing
("Imported 0 feed(s)"), and a genuinely new feed had no subscriber, so
it was never scanned. Web and CLI import now share subscribe_opml,
which subscribes the caller (the CLI: the first admin) to every feed in
the file and reports new vs already-subscribed.

Export wrote the whole catalogue to anyone signed in, including other
people's private feed URLs. It now lists only your own subscriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
2026-09-11 12:53:29 +00:00
parent 5e95557cbb
commit 8784d0a3fd
7 changed files with 156 additions and 74 deletions

View File

@@ -1119,8 +1119,14 @@ async fn download_latest(
}
/// Subscriptions as OPML, so they can move to another podcast app.
async fn export_opml(State(state): State<WebState>) -> Result<Response, ApiError> {
let cfg = state.ctx.cfg();
async fn export_opml(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Response, ApiError> {
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
// not yours to download. This used to export config.toml to whoever asked.
let mine: std::collections::HashSet<String> =
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
let mut doc = opml::OPML {
head: Some(opml::Head {
title: Some("ipx subscriptions".into()),
@@ -1128,15 +1134,19 @@ async fn export_opml(State(state): State<WebState>) -> Result<Response, ApiError
}),
..Default::default()
};
for (id, feed) in &cfg.feeds {
for s in crate::subscriptions(&state.ctx)? {
// A feed from an OPML subscription comes back with the OPML itself.
if s.managed || !mine.contains(&s.id) {
continue;
}
let title = state
.ctx
.db
.feed_summary(id)
.feed_summary(&s.id)
.ok()
.and_then(|s| s.title)
.unwrap_or_else(|| id.clone());
doc.add_feed(&title, &feed.url);
.and_then(|sum| sum.title)
.unwrap_or_else(|| s.id.clone());
doc.add_feed(&title, &s.cfg.url);
}
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
Ok((
@@ -1159,42 +1169,12 @@ struct OpmlBody {
async fn import_opml(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<OpmlBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
let doc = opml::OPML::from_str(&body.xml)
.map_err(|e| anyhow::anyhow!("that does not parse as OPML: {e}"))?;
let mut found = vec![];
crate::collect_outlines(&doc.body.outlines, &mut found);
let mut cfg = (*state.ctx.cfg()).clone();
let mut added = 0;
for (title, url) in found {
if cfg.feeds.values().any(|f| f.url == url) {
continue;
}
let id = crate::config::unique_slug(&title, &cfg.feeds);
cfg.feeds.insert(
id,
crate::config::Feed {
url,
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,
},
);
added += 1;
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(Json(serde_json::json!({ "added": added })))
let (added, already) =
crate::subscribe_opml(&state.ctx, &state.config_path, &body.xml, user.id)?;
Ok(Json(serde_json::json!({ "added": added, "already": already })))
}
#[derive(Serialize)]