The Directory comes from the directory service (#205)
ipx asks ipx-directory on its people's behalf, with the token an admin set in the new Admin, Directory tab, so the page never talks to it and never sees the token or a feed's address. /api/directory, /api/popular and a feed's Directory page answer from it; subscribing takes the address from the directory and goes through Add feed's steps. With no directory set up, the Directory says so. The local directory code is gone; its privacy checks are the snapshot builder's now, and their tests moved with them. With sharing on, ipx sends its snapshot after every scan, whenever the config changes (set_cfg) and every half hour: each public feed and how many here subscribe, never an OPML or Patreon creator itself or anything private. A feed can be marked private in its settings, for a paid feed whose address does not give it away; anyone subscribed may mark it, only an admin unmark it. The admin's category for a feed that names none goes with it, which the directory uses only while the feed names none, so the category an admin gives a blog still reaches the Directory. The directory lists only feeds it has read as a feed: the first snapshot after a start went before the first scan, when an OPML was not yet known to be one, and the directory listed the OPML. It now fetches what a snapshot brings in at once instead of within the minute. The browser suite runs its own ipx-directory on SQLite; the Directory's tests match feeds by title, since the directory names them by address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,8 @@ const SCHEMA: &[&str] = &[
|
||||
instance TEXT NOT NULL,
|
||||
feed_id TEXT NOT NULL,
|
||||
subscribers BIGINT NOT NULL,
|
||||
category TEXT,
|
||||
subcategory TEXT,
|
||||
PRIMARY KEY (instance, feed_id)
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
||||
@@ -136,6 +138,11 @@ impl Store {
|
||||
for sql in SCHEMA {
|
||||
db.execute_unprepared(sql).await?;
|
||||
}
|
||||
// Added after the first deploy: a database made before has the table without them.
|
||||
if db.execute_unprepared("SELECT category FROM snapshots WHERE false").await.is_err() {
|
||||
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN category TEXT").await?;
|
||||
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN subcategory TEXT").await?;
|
||||
}
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
@@ -182,8 +189,12 @@ impl Store {
|
||||
}
|
||||
// The same feed twice in one snapshot counts once, at its larger number.
|
||||
let n = e.subscribers.max(0);
|
||||
let slot = keep.entry(id).or_insert((url, n));
|
||||
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
|
||||
let slot = keep.entry(id).or_insert((url, n, path.clone()));
|
||||
slot.1 = slot.1.max(n);
|
||||
if slot.2.is_none() {
|
||||
slot.2 = path;
|
||||
}
|
||||
}
|
||||
result.accepted = keep.len();
|
||||
|
||||
@@ -191,15 +202,21 @@ impl Store {
|
||||
let backend = tx.get_database_backend();
|
||||
let run = |sql: &str, values: Vec<Value>| Statement::from_sql_and_values(backend, sql, values);
|
||||
tx.execute_raw(run("DELETE FROM snapshots WHERE instance = $1", vec![instance.into()])).await?;
|
||||
for (id, (url, n)) in &keep {
|
||||
for (id, (url, n, path)) in &keep {
|
||||
tx.execute_raw(run(
|
||||
"INSERT INTO feeds (id, url) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
|
||||
vec![id.clone().into(), url.clone().into()],
|
||||
))
|
||||
.await?;
|
||||
tx.execute_raw(run(
|
||||
"INSERT INTO snapshots (instance, feed_id, subscribers) VALUES ($1, $2, $3)",
|
||||
vec![instance.into(), id.clone().into(), (*n).into()],
|
||||
"INSERT INTO snapshots (instance, feed_id, subscribers, category, subcategory) VALUES ($1, $2, $3, $4, $5)",
|
||||
vec![
|
||||
instance.into(),
|
||||
id.clone().into(),
|
||||
(*n).into(),
|
||||
path.as_ref().map(|p| p.0.clone()).into(),
|
||||
path.as_ref().and_then(|p| p.1.clone()).into(),
|
||||
],
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
@@ -231,9 +248,14 @@ impl Store {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
const LISTING: &str = "SELECT f.id, f.url, f.title, f.image, f.category, f.subcategory, f.podcast,
|
||||
CAST(sum(s.subscribers) AS BIGINT) AS subscribers
|
||||
FROM feeds f JOIN snapshots s ON s.feed_id = f.id";
|
||||
// The feed's own category, or else one a sending server's admin gave it.
|
||||
// Only feeds read as a feed at least once (`items` set): never an OPML a server sent before it
|
||||
// knew it was one, nor an address that has never worked.
|
||||
const LISTING: &str = "SELECT f.id, f.url, f.title, f.image,
|
||||
CASE WHEN f.category IS NULL THEN max(s.category) ELSE f.category END AS category,
|
||||
CASE WHEN f.category IS NULL THEN max(s.subcategory) ELSE f.subcategory END AS subcategory,
|
||||
f.podcast, CAST(sum(s.subscribers) AS BIGINT) AS subscribers
|
||||
FROM feeds f JOIN snapshots s ON s.feed_id = f.id WHERE f.items IS NOT NULL";
|
||||
|
||||
fn listing(r: &sea_orm::QueryResult) -> Result<Listing> {
|
||||
Ok(Listing {
|
||||
@@ -268,7 +290,7 @@ impl Store {
|
||||
|
||||
/// One feed with its description and latest items.
|
||||
pub async fn feed(&self, id: &str) -> Result<Option<FeedPage>> {
|
||||
let sql = format!("{} WHERE f.id = $1 GROUP BY f.id", Self::LISTING);
|
||||
let sql = format!("{} AND f.id = $1 GROUP BY f.id", Self::LISTING);
|
||||
let Some(r) = self.rows(&sql, vec![id.into()]).await?.into_iter().next() else { return Ok(None) };
|
||||
let listing = Self::listing(&r)?;
|
||||
let r = self
|
||||
@@ -419,11 +441,29 @@ fn failure_words(err: &str, failing_for: i64) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Reads the fixture feed as every feed waiting to be fetched, as the refresher would.
|
||||
pub(crate) async fn fetch_all(s: &Store) {
|
||||
for d in s.due(0, 100).await.unwrap() {
|
||||
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn e(url: &str, n: i64) -> SnapshotEntry {
|
||||
SnapshotEntry { url: url.into(), subscribers: n }
|
||||
SnapshotEntry { url: url.into(), subscribers: n, category: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_is_explained_after_a_day_and_never_names_its_address() {
|
||||
let nothing = "the feed's address answered with nothing at all";
|
||||
assert_eq!(failure_words(nothing, FLAG_AFTER_SECS), "This address answers with nothing; the site may be gone.");
|
||||
// Not before it has been failing a day, as on the feed's own page in ipx (#143).
|
||||
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
|
||||
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
|
||||
assert_eq!(failure_words(raw, FLAG_AFTER_SECS), "Its last check failed.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -445,6 +485,9 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(r.accepted, 2);
|
||||
s.snapshot("b", &[e("https://x.test/a.xml", 2)]).await.unwrap();
|
||||
// Nothing is listed until it has been read as a feed.
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
fetch_all(&s).await;
|
||||
let pop = s.popular(10).await.unwrap();
|
||||
assert_eq!((pop[0].url.as_str(), pop[0].subscribers), ("https://x.test/a.xml", 7));
|
||||
assert_eq!(pop[1].subscribers, 1);
|
||||
@@ -480,6 +523,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((r.accepted, r.refused_private, r.refused_invalid), (1, 4, 1));
|
||||
fetch_all(&s).await;
|
||||
|
||||
let id = s.list().await.unwrap()[0].id.clone();
|
||||
assert!(s.block(&id).await.unwrap());
|
||||
@@ -491,6 +535,21 @@ mod tests {
|
||||
assert_eq!(s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap().accepted, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
|
||||
let s = Store::scratch().await;
|
||||
let entry = |c: Option<&str>| SnapshotEntry { url: "https://x.test/blog.xml".into(), subscribers: 1, category: c.map(Into::into) };
|
||||
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
|
||||
let d = s.due(0, 10).await.unwrap().remove(0);
|
||||
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
parsed.category = None;
|
||||
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||
let l = s.list().await.unwrap().remove(0);
|
||||
assert_eq!((l.category.as_deref(), l.subcategory.as_deref()), (Some("Arts"), Some("Visual Arts")));
|
||||
s.snapshot("a", &[entry(None)]).await.unwrap();
|
||||
assert_eq!(s.list().await.unwrap()[0].category, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetched_feed_shows_its_metadata_and_newest_items_but_no_files() {
|
||||
let s = Store::scratch().await;
|
||||
|
||||
Reference in New Issue
Block a user