Keep a feed's items and files to the people who subscribe to it (#129)

Routes that take a feed or an enclosure id did not check who was asking. Anyone signed in could
read any feed's items through GET /api/feeds/{id}/entries, a paid feed's included, with the
addresses of its files, which can carry the subscriber's key: the Directory leaves such feeds
out for that reason, and this route handed them back to whoever guessed the id, a slug of the
title. In production it answered 25 items of a feed the asking account does not subscribe to.
/media/{id} served any downloaded file by its sequential id, POST /api/enclosures/{id}/download
and /api/feeds/{id}/download-latest queued any feed's downloads, and DELETE
/api/enclosures/{id}?force=true deleted any file.

Each now answers 404, "you do not subscribe to that feed", unless the person subscribes to it.
A feed inside an OPML has a subscription row of its own for everyone subscribed to the OPML, so
that holds for those feeds too. Found while adding the Directory's feed page (#128), which has
its own route that answers only for listed feeds and carries no files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 16:19:38 +00:00
parent 95acde3046
commit b86062b97a
4 changed files with 41 additions and 2 deletions

View File

@@ -1325,9 +1325,22 @@ async fn entries(
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
subscribed(&state, &user, &id).await?;
entry_page(&state, user.id, Some(&id), &page).await
}
/// A feed's items and files are for the people who subscribe to it (#129). Anyone signed in
/// could read any feed's by its id, a paid one's included, with its enclosure addresses, which
/// can carry the subscriber's key, and fetch, queue or delete its files by theirs. A feed in
/// an OPML has a subscription of its own for everyone subscribed to the OPML, so this holds
/// for those too. Not found, as for any other feed that is not yours.
async fn subscribed(state: &WebState, user: &crate::db::User, feed_id: &str) -> Result<(), ApiError> {
match state.ctx.db.subscription(user.id, feed_id).await? {
Some(_) => Ok(()),
None => Err(ApiError::not_found("you do not subscribe to that feed")),
}
}
/// Every subscribed feed's items together, newest first: All Subscriptions.
async fn all_entries(
State(state): State<WebState>,
@@ -1676,12 +1689,14 @@ async fn set_flags(
async fn download_now(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
let enc = state
.ctx
.db
.enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
subscribed(&state, &user, &enc.feed_id).await?;
if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here.
}
@@ -1711,6 +1726,7 @@ async fn delete_file(
.db
.enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
subscribed(&state, &user, &enc.feed_id).await?;
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
// it, once, and let them decide.
@@ -1828,11 +1844,15 @@ fn changed_feed(ev: &Event) -> Option<&str> {
async fn media(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
req: Request,
) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
@@ -1962,8 +1982,10 @@ fn five() -> i64 {
async fn download_latest(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
Json(body): Json<HowMany>,
) -> Result<Json<serde_json::Value>, ApiError> {
subscribed(&state, &user, &id).await?;
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100)).await?;
for enc in &ids {
state.ctx.db.requeue(*enc).await?;