Popular on this server; changelog follows Keep a Changelog; 0.3.0
- Add feed lists what other accounts subscribe to, most subscribers
first, and subscribes you by id (GET /api/popular, POST
/api/popular/{id}). Rows never carry a URL. Feeds from an OPML and
anything that looks private (a login, credentials in the URL, a key
such as auth= or token=) are never listed, and the subscribe route
checks the id against the same list.
- CHANGELOG.md follows Keep a Changelog 1.1.0: 0.1.0 (2026-09-09, the
CLI), 0.2.0 (2026-09-10, the web UI), 0.3.0 (2026-09-11, accounts and
sharing). The long-form entries moved unchanged to docs/history.md.
- Cargo.toml is 0.3.0. CLAUDE.md says how to add an entry and cut a
release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
108
src/web.rs
108
src/web.rs
@@ -74,6 +74,8 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/fetch", post(fetch_now))
|
||||
.route("/api/opml", get(export_opml).post(import_opml))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/popular", get(get_popular))
|
||||
.route("/api/popular/{id}", post(subscribe_popular))
|
||||
.route("/api/users", get(list_users).post(add_user))
|
||||
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
||||
.route("/api/logs", get(logs))
|
||||
@@ -530,6 +532,84 @@ async fn feeds(
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
// ---- popular on this server ----
|
||||
|
||||
/// 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.
|
||||
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()
|
||||
|| u.password().is_some()
|
||||
|| u.query_pairs().any(|(k, _)| {
|
||||
let k = k.to_ascii_lowercase();
|
||||
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
|
||||
.iter()
|
||||
.any(|w| k.contains(w))
|
||||
})
|
||||
}
|
||||
|
||||
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
|
||||
#[derive(Serialize)]
|
||||
struct PopularRow {
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
image: Option<String>,
|
||||
subscribers: i64,
|
||||
}
|
||||
|
||||
/// What other people here subscribe to that you don't, most subscribers first. What the
|
||||
/// Add feed screen offers, and all that `subscribe_popular` will subscribe you to.
|
||||
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
let db = &state.ctx.db;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
||||
let counts = db.subscriber_counts()?;
|
||||
let mut out = vec![];
|
||||
for s in crate::subscriptions(&state.ctx)? {
|
||||
let n = counts.get(&s.id).copied().unwrap_or(0);
|
||||
// A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed
|
||||
// inside, which would bury everything anyone chose on purpose.
|
||||
let from_opml = s.managed || s.cfg.group.is_some();
|
||||
if n == 0 || from_opml || mine.contains(&s.id) || looks_private(&s.cfg) {
|
||||
continue;
|
||||
}
|
||||
let sum = db.feed_summary(&s.id)?;
|
||||
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n });
|
||||
}
|
||||
let name = |p: &PopularRow| p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase();
|
||||
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).cmp(&name(b))));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get_popular(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id)?;
|
||||
rows.truncate(20);
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so
|
||||
/// a guessed id cannot reach a private feed.
|
||||
async fn subscribe_popular(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
|
||||
return Err(ApiError::bad_request(format!("{id:?} is not on the popular list")));
|
||||
}
|
||||
state.ctx.db.subscribe(user.id, &id)?;
|
||||
Ok(Json(serde_json::json!({ "id": id })))
|
||||
}
|
||||
|
||||
/// Validates a replacement feed URL: present, parseable, and not already subscribed under
|
||||
/// a different id. Returns the trimmed URL.
|
||||
fn check_url(
|
||||
@@ -594,6 +674,34 @@ impl IntoResponse for ApiError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feed_with_a_credential_is_never_popular() {
|
||||
let f = |url: &str| crate::config::Feed {
|
||||
url: url.into(),
|
||||
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,
|
||||
};
|
||||
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
||||
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
||||
// 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")));
|
||||
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");
|
||||
basic.username = Some("ray".into());
|
||||
assert!(looks_private(&basic), "a feed with a login configured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_admin_is_protected() {
|
||||
let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin };
|
||||
|
||||
Reference in New Issue
Block a user