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:
107
src/main.rs
107
src/main.rs
@@ -602,44 +602,91 @@ fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
||||
let mut cfg = (*ctx.cfg()).clone();
|
||||
let text = std::fs::read_to_string(file)
|
||||
.with_context(|| format!("reading {}", file.display()))?;
|
||||
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("parsing OPML: {e}"))?;
|
||||
// The CLI speaks for the operator, as the shared web token does.
|
||||
let admin = ctx
|
||||
.db
|
||||
.users()?
|
||||
.into_iter()
|
||||
.find(|u| u.is_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)?;
|
||||
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribes one person to every feed in an OPML document, for the CLI and the web alike.
|
||||
/// A feed already in the catalogue costs nothing; an unknown one is added under the OPML's
|
||||
/// title rather than refetching each. Returns (newly subscribed, already subscribed).
|
||||
///
|
||||
/// 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
|
||||
/// already had, and a new one had no subscriber, so it was never scanned.
|
||||
pub fn subscribe_opml(
|
||||
ctx: &Ctx,
|
||||
config_path: &std::path::Path,
|
||||
xml: &str,
|
||||
user_id: i64,
|
||||
) -> 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![];
|
||||
collect_outlines(&doc.body.outlines, &mut found);
|
||||
|
||||
let mut added = 0;
|
||||
let known = subscriptions(ctx)?;
|
||||
let mut cfg = (*ctx.cfg()).clone();
|
||||
let mut ids = vec![];
|
||||
let mut grew = false;
|
||||
for (title, url) in found {
|
||||
if cfg.feeds.values().any(|f| f.url == url) {
|
||||
continue;
|
||||
}
|
||||
// Name it from the OPML title rather than refetching every feed.
|
||||
let id = config::unique_slug(&title, &cfg.feeds);
|
||||
cfg.feeds.insert(
|
||||
id.clone(),
|
||||
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,
|
||||
},
|
||||
);
|
||||
println!("added {id}");
|
||||
added += 1;
|
||||
let existing = known
|
||||
.iter()
|
||||
.find(|s| s.cfg.url == url)
|
||||
.map(|s| s.id.clone())
|
||||
// The same URL listed twice in one file.
|
||||
.or_else(|| cfg.feeds.iter().find(|(_, f)| f.url == url).map(|(id, _)| id.clone()));
|
||||
let id = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = config::unique_slug(&title, &cfg.feeds);
|
||||
cfg.feeds.insert(
|
||||
id.clone(),
|
||||
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,
|
||||
},
|
||||
);
|
||||
grew = true;
|
||||
id
|
||||
}
|
||||
};
|
||||
ids.push(id);
|
||||
}
|
||||
cfg.save(config_path)?;
|
||||
println!("{added} feed(s) imported");
|
||||
Ok(())
|
||||
if grew {
|
||||
cfg.save(config_path)?;
|
||||
ctx.reload_cfg(config_path)?;
|
||||
}
|
||||
|
||||
let (mut added, mut had) = (0, 0);
|
||||
for id in ids {
|
||||
if ctx.db.subscription(user_id, &id)?.is_some() {
|
||||
had += 1;
|
||||
} else {
|
||||
ctx.db.subscribe(user_id, &id)?;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Ok((added, had))
|
||||
}
|
||||
|
||||
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
|
||||
|
||||
62
src/web.rs
62
src/web.rs
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user