Never list paid-feed services; scan on add; no "reaped"; slimmer header
- Security: feeds from Patreon, Supercast, Supporting Cast, Glow and Memberful are never listed in Popular or the Directory. A Supercast feed keeps its key in the URL's path, which the query check missed, so it was being listed. - Adding a feed queues a scan of it, and an OPML import that added feeds scans what is due, so items show without pressing Scan. - A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped" chip, just the Download button. The retention summary says "deleted". - The feed header keeps its title and stats to one line each and wraps its buttons; a single feed's table drops the Feed column. - Tests: adding a feed shows its item without Scan; a deleted file shows no "reaped"; paid-feed hosts and acast public ids in the unit test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
@@ -62,10 +62,10 @@ impl Event {
|
||||
Event::DownloadDone { path, .. } => format!(" saved {path}"),
|
||||
Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"),
|
||||
Event::Reaped { path, bytes } => {
|
||||
format!("reap {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::ReapDone { files, bytes } => format!(
|
||||
"reaped {files} file(s), {:.1} MB",
|
||||
"deleted {files} old file(s), {:.1} MB",
|
||||
*bytes as f64 / 1_048_576.0
|
||||
),
|
||||
Event::Status { feeds, pending, downloaded } => {
|
||||
|
||||
36
src/web.rs
36
src/web.rs
@@ -539,14 +539,20 @@ async fn feeds(
|
||||
/// 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.
|
||||
/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
|
||||
/// through; a per-feed `unlisted` flag is the upgrade if that happens again.
|
||||
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()
|
||||
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
|
||||
// long path segment alone proves nothing (acast's public show ids look the same). So a
|
||||
// feed from one of them is private whatever its URL looks like. Supercast is why this
|
||||
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
|
||||
let host = u.host_str().unwrap_or("");
|
||||
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|
||||
|| !u.username().is_empty()
|
||||
|| u.password().is_some()
|
||||
|| u.query_pairs().any(|(k, _)| {
|
||||
let k = k.to_ascii_lowercase();
|
||||
@@ -556,6 +562,10 @@ fn looks_private(feed: &crate::config::Feed) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Services whose feeds are always one subscriber's own.
|
||||
const PAID_HOSTS: &[&str] =
|
||||
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||
|
||||
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
|
||||
#[derive(Serialize)]
|
||||
struct PopularRow {
|
||||
@@ -714,6 +724,11 @@ mod tests {
|
||||
// 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")));
|
||||
// Paid-feed services put the key in the path; the host gives them away.
|
||||
assert!(looks_private(&f("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx")));
|
||||
assert!(looks_private(&f("https://someshow.supportingcast.fm/content/abc123.rss")));
|
||||
// A long path segment alone is not a key: acast's public show ids look the same.
|
||||
assert!(!looks_private(&f("https://feeds.acast.com/public/shows/0123456789abcdef01234567")));
|
||||
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");
|
||||
@@ -882,6 +897,7 @@ async fn add_feed(
|
||||
{
|
||||
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
|
||||
state.ctx.db.subscribe(user.id, &existing.id)?;
|
||||
scan_soon(&state, Some(existing.id.clone())).await;
|
||||
return Ok(Json(
|
||||
serde_json::json!({ "id": existing.id, "existing": already }),
|
||||
));
|
||||
@@ -890,9 +906,20 @@ async fn add_feed(
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
state.ctx.db.subscribe(user.id, &id)?;
|
||||
scan_soon(&state, Some(id.clone())).await;
|
||||
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
||||
}
|
||||
|
||||
/// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
|
||||
/// `None` scans whatever is due, which a feed never checked always is. The add has
|
||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||
let force = feed.is_some();
|
||||
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
|
||||
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent means "leave alone"; JSON `null` means "clear this".
|
||||
///
|
||||
/// That distinction needs `double_option`: serde maps `null` onto the *outer* `None` for a
|
||||
@@ -1321,6 +1348,9 @@ async fn import_opml(
|
||||
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)?;
|
||||
if added > 0 {
|
||||
scan_soon(&state, None).await;
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "added": added, "already": already })))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user