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:
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- The Directory shows a directory service that iPX servers share, set up in Admin, Directory with its address and this server's token, instead of only this server's feeds. Sharing this server's public feeds with it is a switch there, and subscriber counts are every sharing server's (#205).
|
||||
- A feed's settings have a Private switch: a private feed is never shared with the directory. A feed with a login, or whose address carries a key, is private already (#205).
|
||||
- Add a feed no longer asks for a folder; a feed's folder is its title (#201).
|
||||
- `docker-compose.yml` is a sample to start from, ipx alone on SQLite with its folders beside the file, instead of a copy of the author's server (#208).
|
||||
- The source is a Cargo workspace: the daemon in `crates/ipx`, feed fetching and parsing in `crates/feed`. `cargo build --release` still makes `target/release/ipx` (#202).
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1830,6 +1830,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"ipx-directory",
|
||||
"ipx-feed",
|
||||
"jsonwebtoken",
|
||||
"librqbit",
|
||||
|
||||
@@ -70,6 +70,10 @@ pub struct Item {
|
||||
pub struct SnapshotEntry {
|
||||
pub url: String,
|
||||
pub subscribers: i64,
|
||||
/// The category the sending server's admin gave a feed that names none itself: used only
|
||||
/// while the feed names none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
@@ -109,6 +113,8 @@ pub struct Directory {
|
||||
pub store: Store,
|
||||
tokens: RwLock<Vec<Token>>,
|
||||
client: reqwest::Client,
|
||||
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
|
||||
wake: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
pub type Shared = Arc<Directory>;
|
||||
@@ -120,7 +126,7 @@ impl Directory {
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
Ok(Arc::new(Self { store, tokens: RwLock::new(tokens), client }))
|
||||
Ok(Arc::new(Self { store, tokens: RwLock::new(tokens), client, wake: tokio::sync::Notify::new() }))
|
||||
}
|
||||
|
||||
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
|
||||
@@ -182,7 +188,10 @@ pub async fn refresher(dir: Shared) {
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not read which feeds are due"),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||
_ = dir.wake.notified() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +342,7 @@ async fn snapshot(
|
||||
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("a snapshot lists at most {MAX_SNAPSHOT} feeds")));
|
||||
}
|
||||
let r = dir.store.snapshot(&me.name, &entries).await?;
|
||||
dir.wake.notify_one();
|
||||
tracing::info!(
|
||||
instance = %me.name, accepted = r.accepted, refused_private = r.refused_private,
|
||||
refused_blocked = r.refused_blocked, refused_invalid = r.refused_invalid, "snapshot"
|
||||
@@ -381,7 +391,7 @@ mod tests {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn app() -> Router {
|
||||
async fn app() -> (Router, Shared) {
|
||||
let tokens = ["ro", "rw", "rwa"]
|
||||
.iter()
|
||||
.map(|n| Token {
|
||||
@@ -390,7 +400,8 @@ mod tests {
|
||||
sha256: config::hash(&format!("secret-{n}")),
|
||||
})
|
||||
.collect();
|
||||
router(Directory::new(Store::scratch().await, tokens).unwrap())
|
||||
let dir = Directory::new(Store::scratch().await, tokens).unwrap();
|
||||
(router(dir.clone()), dir)
|
||||
}
|
||||
|
||||
async fn call(app: &Router, method: &str, path: &str, token: Option<&str>, body: Option<serde_json::Value>) -> (u16, serde_json::Value) {
|
||||
@@ -411,8 +422,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_access_level_does_what_it_may_and_no_more() {
|
||||
let app = app().await;
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 2 }]);
|
||||
let (app, dir) = app().await;
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 2, "category": "Comedy" }]);
|
||||
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("wrong"), None).await.0, 401);
|
||||
@@ -424,9 +435,11 @@ mod tests {
|
||||
|
||||
let (s, r) = call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await;
|
||||
assert_eq!((s, r["accepted"].as_u64()), (200, Some(1)));
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
let (_, list) = call(&app, "GET", "/v1/feeds", Some("ro"), None).await;
|
||||
let id = list[0]["id"].as_str().unwrap().to_owned();
|
||||
assert_eq!(list[0]["subscribers"], 2);
|
||||
assert_ne!(list[0]["category"], "Comedy", "the feed's own category wins over the sender's");
|
||||
assert_eq!(call(&app, "GET", &format!("/v1/feeds/{id}"), Some("ro"), None).await.0, 200);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
|
||||
assert_eq!(call(&app, "GET", "/v1/popular?limit=5", Some("ro"), None).await.1[0]["id"], id.as_str());
|
||||
@@ -453,6 +466,7 @@ mod tests {
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 1 }]);
|
||||
call(&app, "POST", "/v1/snapshot", Some("a"), Some(snap.clone())).await;
|
||||
call(&app, "POST", "/v1/snapshot", Some("b"), Some(snap)).await;
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 2);
|
||||
|
||||
dir.set_tokens(vec![t("a")]).await.unwrap();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1163,6 +1163,22 @@ const PAID_HOSTS: &[&str] =
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn an_address_carrying_someones_access_looks_private() {
|
||||
let private = super::url_looks_private;
|
||||
assert!(!private("https://feeds.twit.tv/twit.xml"));
|
||||
// Patreon's shape: the key is a query parameter.
|
||||
assert!(private("https://www.patreon.com/rss/x?auth=abc123&show=2073588"));
|
||||
assert!(private("https://example.com/rss?api_key=abc"));
|
||||
// Paid-feed services put the key in the path; the host gives them away.
|
||||
assert!(private("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx"));
|
||||
assert!(private("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!(!private("https://feeds.acast.com/public/shows/0123456789abcdef01234567"));
|
||||
assert!(private("https://ray:hunter2@example.com/rss"));
|
||||
assert!(private("not a url"), "unparseable is not safe to list");
|
||||
}
|
||||
|
||||
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
|
||||
/// to /tmp, /new is the feed.
|
||||
async fn redirecting_server() -> String {
|
||||
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ipx-feed.workspace = true
|
||||
ipx-directory.workspace = true
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
|
||||
@@ -42,6 +42,14 @@ pub struct General {
|
||||
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
|
||||
/// every publisher; the least recently shown goes first. 0 keeps none.
|
||||
pub art_cache_mb: u64,
|
||||
/// The directory service the Directory shows (#205), as `https://ipx-dir.sdf1.net`. Empty
|
||||
/// for none: then the Directory says so, and feeds are added by their address.
|
||||
pub directory_url: String,
|
||||
/// The token the directory gave this server. Never sent to the page: it says only whether
|
||||
/// one is set.
|
||||
pub directory_token: String,
|
||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||
pub directory_share: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -174,6 +182,10 @@ pub struct Feed {
|
||||
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub listed: bool,
|
||||
/// Never sent to the directory (#205), whatever its address looks like: someone's own
|
||||
/// access to a paid show that `feed::url_looks_private` does not recognise.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -192,6 +204,9 @@ impl Default for General {
|
||||
max_new_per_check: 3,
|
||||
media_types: vec!["audio".into(), "video".into()],
|
||||
art_cache_mb: 500,
|
||||
directory_url: String::new(),
|
||||
directory_token: String::new(),
|
||||
directory_share: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +325,12 @@ pub struct Stored {
|
||||
/// Settings saved before it existed have none: they get the default.
|
||||
#[serde(default = "art_cache_mb")]
|
||||
pub art_cache_mb: u64,
|
||||
#[serde(default)]
|
||||
pub directory_url: String,
|
||||
#[serde(default)]
|
||||
pub directory_token: String,
|
||||
#[serde(default)]
|
||||
pub directory_share: bool,
|
||||
}
|
||||
|
||||
fn art_cache_mb() -> u64 {
|
||||
@@ -317,8 +338,17 @@ fn art_cache_mb() -> u64 {
|
||||
}
|
||||
|
||||
/// `[general]` keys that live in the database once it holds the configuration.
|
||||
const STORED_KEYS: [&str; 6] =
|
||||
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"];
|
||||
const STORED_KEYS: [&str; 9] = [
|
||||
"schedule",
|
||||
"max_total_gb",
|
||||
"max_age_days",
|
||||
"max_new_per_check",
|
||||
"media_types",
|
||||
"art_cache_mb",
|
||||
"directory_url",
|
||||
"directory_token",
|
||||
"directory_share",
|
||||
];
|
||||
|
||||
impl Stored {
|
||||
pub fn of(cfg: &Config) -> Self {
|
||||
@@ -330,6 +360,9 @@ impl Stored {
|
||||
max_new_per_check: g.max_new_per_check,
|
||||
media_types: g.media_types.clone(),
|
||||
art_cache_mb: g.art_cache_mb,
|
||||
directory_url: g.directory_url.clone(),
|
||||
directory_token: g.directory_token.clone(),
|
||||
directory_share: g.directory_share,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +374,9 @@ impl Stored {
|
||||
g.max_new_per_check = self.max_new_per_check;
|
||||
g.media_types = self.media_types;
|
||||
g.art_cache_mb = self.art_cache_mb;
|
||||
g.directory_url = self.directory_url;
|
||||
g.directory_token = self.directory_token;
|
||||
g.directory_share = self.directory_share;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +653,7 @@ mod tests {
|
||||
taken.insert("the-daily".to_string(), Feed {
|
||||
url: "u".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, category: None, listed: false,
|
||||
password: None, password_env: None, category: None, listed: false, private: false,
|
||||
});
|
||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||
}
|
||||
@@ -639,6 +675,7 @@ mod tests {
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||
|
||||
|
||||
201
crates/ipx/src/directory.rs
Normal file
201
crates/ipx/src/directory.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
//! The Directory, from the directory service (#205): ipx asks it on its people's behalf, so the
|
||||
//! page never talks to it and never sees its token, and sends it the public feeds people here
|
||||
//! subscribe to when an admin has turned sharing on.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use ipx_directory::{FeedPage, Listing, Me, SnapshotEntry, SnapshotResult};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::{Ctx, Sub, config, feed};
|
||||
|
||||
/// How often the snapshot goes when nothing has woken the sender sooner.
|
||||
const SHARE_EVERY: std::time::Duration = std::time::Duration::from_secs(30 * 60);
|
||||
|
||||
/// What went wrong asking the directory, in words for an admin.
|
||||
#[derive(Debug)]
|
||||
pub enum DirError {
|
||||
/// No address, or no token, in the settings.
|
||||
NotConfigured,
|
||||
/// It answered, refusing: 401 is a token it does not know, 403 one without the access asked for.
|
||||
Refused(u16, String),
|
||||
/// It did not answer, or not with what was asked for.
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DirError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DirError::NotConfigured => write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory."),
|
||||
DirError::Refused(401, _) => write!(f, "The directory does not accept this server's token. It may have been revoked."),
|
||||
DirError::Refused(403, msg) => write!(f, "The directory refused: {msg}."),
|
||||
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
|
||||
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
|
||||
DirError::Unreachable(e) => write!(f, "The directory could not be reached: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the snapshot last went, and what came of it, for the admin page.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Shared {
|
||||
pub at: i64,
|
||||
#[serde(flatten)]
|
||||
pub result: Option<SnapshotResult>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn target(cfg: &config::Config) -> Option<(String, String)> {
|
||||
let url = cfg.general.directory_url.trim().trim_end_matches('/');
|
||||
let token = cfg.general.directory_token.trim();
|
||||
(!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned()))
|
||||
}
|
||||
|
||||
pub fn configured(cfg: &config::Config) -> bool {
|
||||
target(cfg).is_some()
|
||||
}
|
||||
|
||||
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
|
||||
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
|
||||
let mut req = ctx.client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(20));
|
||||
if let Some(b) = body {
|
||||
req = req.json(b);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let msg = resp.json::<serde_json::Value>().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)).unwrap_or_default();
|
||||
return Err(DirError::Refused(status.as_u16(), msg));
|
||||
}
|
||||
resp.json().await.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||
}
|
||||
|
||||
pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/me", None).await
|
||||
}
|
||||
|
||||
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/feeds", None).await
|
||||
}
|
||||
|
||||
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await
|
||||
}
|
||||
|
||||
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
|
||||
call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", percent_encoding::utf8_percent_encode(id, percent_encoding::NON_ALPHANUMERIC)), None).await
|
||||
}
|
||||
|
||||
/// Whether a feed is someone's own: marked private, given a username or password, or at an
|
||||
/// address that carries a key (`feed::url_looks_private`).
|
||||
pub fn looks_private(feed: &config::Feed) -> bool {
|
||||
feed.private
|
||||
|| feed.username.is_some()
|
||||
|| feed.password.is_some()
|
||||
|| feed.password_env.is_some()
|
||||
|| feed::url_looks_private(&feed.url)
|
||||
}
|
||||
|
||||
/// What the snapshot lists: every feed in the catalogue, with how many people here subscribe,
|
||||
/// except an OPML or a Patreon creator itself, which is sent as the feeds in it, and anything
|
||||
/// private, a feed inside a private OPML included. An OPML not read yet is not known to be one
|
||||
/// and goes too; the directory lists only what it has read as a feed.
|
||||
pub fn snapshot_entries(catalogue: &[Sub], counts: &HashMap<String, i64>) -> Vec<SnapshotEntry> {
|
||||
let by_id: HashMap<&str, &config::Feed> = catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||
let is_group: HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
||||
catalogue
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
!is_group.contains(s.id.as_str())
|
||||
&& !feed::is_patreon_creator(&s.cfg.url)
|
||||
&& !looks_private(&s.cfg)
|
||||
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
|
||||
})
|
||||
.map(|s| SnapshotEntry {
|
||||
url: s.cfg.url.clone(),
|
||||
subscribers: counts.get(&s.id).copied().unwrap_or(0),
|
||||
category: s.cfg.category.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sends the snapshot now.
|
||||
pub async fn share(ctx: &Ctx) -> Result<SnapshotResult, DirError> {
|
||||
let catalogue = crate::subscriptions(ctx).await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let counts = ctx.db.subscriber_counts().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let entries = snapshot_entries(&catalogue, &counts);
|
||||
let r: SnapshotResult = call(ctx, reqwest::Method::POST, "/v1/snapshot", Some(&entries)).await?;
|
||||
if r.refused_private > 0 {
|
||||
// The directory caught what this side should have: worth knowing which check differs.
|
||||
tracing::warn!(refused = r.refused_private, "the directory refused feeds as private that ipx sent");
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Sends the snapshot every half hour while sharing is on, and at once when the settings change.
|
||||
pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
let cfg = ctx.cfg();
|
||||
if cfg.general.directory_share && configured(&cfg) {
|
||||
let got = share(&ctx).await;
|
||||
match &got {
|
||||
Ok(r) => tracing::info!(accepted = r.accepted, refused_private = r.refused_private, refused_blocked = r.refused_blocked, "shared with the directory"),
|
||||
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
|
||||
}
|
||||
let (result, error) = match got {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
};
|
||||
*ctx.directory_shared.lock().unwrap() = Some(Shared { at: crate::db::now(), result, error });
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(SHARE_EVERY) => {}
|
||||
_ = ctx.directory_wake.notified() => {}
|
||||
_ = stop.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
|
||||
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
|
||||
edit(&mut cfg);
|
||||
Sub { id: id.into(), cfg, managed: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_holds_public_feeds_and_never_a_private_one() {
|
||||
let catalogue = vec![
|
||||
sub("public", "https://example.com/feed.xml", |_| {}),
|
||||
sub("marked", "https://example.com/mine.xml", |f| f.private = true),
|
||||
sub("password", "https://example.com/locked.xml", |f| f.username = Some("me".into())),
|
||||
sub("paid", "https://feeds.supercast.com/feeds/abc", |_| {}),
|
||||
sub("keyed", "https://example.com/feed?key=k", |_| {}),
|
||||
// An OPML: sent as the feeds in it, never itself.
|
||||
sub("list", "https://example.com/list.opml", |_| {}),
|
||||
sub("in-list", "https://example.com/a.xml", |f| f.group = Some("list".into())),
|
||||
// Inside a private OPML: as private as the OPML.
|
||||
sub("secret-list", "https://example.com/l.opml?token=t", |_| {}),
|
||||
sub("in-secret", "https://example.com/b.xml", |f| f.group = Some("secret-list".into())),
|
||||
];
|
||||
let counts = HashMap::from([("public".to_string(), 3), ("marked".to_string(), 1)]);
|
||||
let got = snapshot_entries(&catalogue, &counts);
|
||||
let urls: Vec<(&str, i64)> = got.iter().map(|e| (e.url.as_str(), e.subscribers)).collect();
|
||||
assert_eq!(urls, [("https://example.com/feed.xml", 3), ("https://example.com/a.xml", 0)]);
|
||||
let patreon = vec![sub("creator", "https://www.patreon.com/c/someone", |_| {})];
|
||||
assert!(snapshot_entries(&patreon, &counts).is_empty(), "a Patreon creator goes as its shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_asked_without_an_address_and_a_token() {
|
||||
let mut cfg = config::Config::default();
|
||||
assert!(!configured(&cfg));
|
||||
cfg.general.directory_url = "https://ipx-dir.example/".into();
|
||||
assert!(!configured(&cfg));
|
||||
cfg.general.directory_token = "t".into();
|
||||
assert_eq!(target(&cfg), Some(("https://ipx-dir.example".into(), "t".into())));
|
||||
}
|
||||
}
|
||||
@@ -438,7 +438,7 @@ mod tests {
|
||||
let mut f = crate::config::Feed {
|
||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), 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, category: None, listed: false,
|
||||
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false, private: false,
|
||||
};
|
||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod deadair;
|
||||
mod directory;
|
||||
mod entity;
|
||||
mod download;
|
||||
// The crate root's import, so `crate::feed` reaches it from every module as before.
|
||||
@@ -161,6 +162,10 @@ pub struct Ctx {
|
||||
/// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since
|
||||
/// starting.
|
||||
pub directory_cleaned: std::sync::atomic::AtomicI64,
|
||||
/// Sends the snapshot to the directory now, as when its settings change (#205).
|
||||
pub directory_wake: tokio::sync::Notify,
|
||||
/// When the snapshot last went and what came of it, for the admin page.
|
||||
pub directory_shared: std::sync::Mutex<Option<directory::Shared>>,
|
||||
}
|
||||
|
||||
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
|
||||
@@ -230,6 +235,9 @@ impl Ctx {
|
||||
fn set_cfg(&self, cfg: config::Config) {
|
||||
*self.cfg.write().unwrap() = std::sync::Arc::new(cfg);
|
||||
tracing::info!("config reloaded");
|
||||
// A feed added, removed, made private or given a category, or the directory's own
|
||||
// settings changed: the directory hears of it now, not at the next half hour (#205).
|
||||
self.directory_wake.notify_one();
|
||||
}
|
||||
|
||||
async fn torrents(&self) -> Result<&torrent::Torrents> {
|
||||
@@ -355,6 +363,8 @@ async fn main() -> Result<()> {
|
||||
cfg_write: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
deadair: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
});
|
||||
|
||||
let result = match cli.command {
|
||||
@@ -665,6 +675,7 @@ async fn daemon(
|
||||
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
|
||||
// must not wait behind it.
|
||||
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
|
||||
|
||||
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
||||
async fn until_stopped(
|
||||
@@ -907,6 +918,7 @@ async fn add_one(
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
|
||||
let title = match feed::fetch(&ctx.feed_client, &probe.url, None, None, None).await.map(|(got, _)| got) {
|
||||
@@ -1034,6 +1046,7 @@ pub async fn subscribe_opml(
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
);
|
||||
grew = true;
|
||||
@@ -1331,6 +1344,8 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
||||
}
|
||||
}
|
||||
ctx.out.emit(Event::ScanDone { feeds: scanned });
|
||||
// What people subscribe to may have changed, and new feeds have titles now: share it.
|
||||
ctx.directory_wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1397,6 +1412,7 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||
password_env: parent.and_then(|p| p.password_env.clone()),
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
managed: true,
|
||||
});
|
||||
@@ -2426,6 +2442,7 @@ mod tests {
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2499,6 +2516,8 @@ mod tests {
|
||||
retry: Default::default(),
|
||||
cfg_write: Default::default(),
|
||||
deadair: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ 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/directory-status", get(get_directory_status))
|
||||
.route("/api/status", get(status))
|
||||
.route("/api/popular", get(get_popular))
|
||||
.route("/api/directory", get(get_directory))
|
||||
@@ -815,6 +816,10 @@ struct FeedRow {
|
||||
subscribers: i64,
|
||||
/// Pinned to the top of your list.
|
||||
pinned: bool,
|
||||
/// Marked private: kept out of the directory (#205).
|
||||
private: bool,
|
||||
/// Private whatever it is marked, for a login or an address with a key in it.
|
||||
private_by_address: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -866,6 +871,8 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
|
||||
// A feed not scanned yet has no row: blank, as feed_summary gave it.
|
||||
let crate::db::FeedListing { summary: s, ttl_mins, blocked, unread } = listed.remove(id).unwrap_or_default();
|
||||
out.push(FeedRow {
|
||||
private: feed.private,
|
||||
private_by_address: crate::directory::looks_private(&crate::config::Feed { private: false, ..feed.clone() }),
|
||||
id: id.clone(),
|
||||
url: feed.url.clone(),
|
||||
title: s.title,
|
||||
@@ -933,115 +940,85 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- popular on this server ----
|
||||
// ---- the directory (#205) ----
|
||||
|
||||
/// 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. The address half is
|
||||
/// `feed::url_looks_private`, which the directory shares.
|
||||
fn looks_private(feed: &crate::config::Feed) -> bool {
|
||||
feed.username.is_some()
|
||||
|| feed.password.is_some()
|
||||
|| feed.password_env.is_some()
|
||||
|| crate::feed::url_looks_private(&feed.url)
|
||||
}
|
||||
|
||||
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
|
||||
/// A feed as the Directory draws it: never a URL, so a key in someone's feed address never
|
||||
/// reaches the page, even though the directory itself has them.
|
||||
#[derive(Serialize)]
|
||||
struct PopularRow {
|
||||
/// The directory's id for it, which its page and Subscribe go by.
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
image: Option<String>,
|
||||
/// Everyone on every server that shares with the directory.
|
||||
subscribers: i64,
|
||||
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
||||
/// Yours already.
|
||||
subscribed: bool,
|
||||
/// Apple's category, from the feed's own iTunes category or else the catalogue's, and the
|
||||
/// subcategory within it where there is one: the Directory browses them in that order.
|
||||
/// When it is yours, its id here, which opens it.
|
||||
feed: Option<String>,
|
||||
category: Option<String>,
|
||||
subcategory: Option<String>,
|
||||
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
|
||||
/// Directory's Podcasts and Blogs between them hold everything.
|
||||
podcast: bool,
|
||||
}
|
||||
|
||||
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
||||
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
||||
/// feeds inside it and never itself: both lists are for finding a show.
|
||||
/// The catalogue as others may see it. `everything` is the Directory: every feed, with or
|
||||
/// without subscribers, so the ones an admin listed show before anyone takes them. Without it,
|
||||
/// Popular: only what people subscribe to.
|
||||
async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec<PopularRow>> {
|
||||
let db = &state.ctx.db;
|
||||
// One pass for every feed's title, artwork and category, not three queries a feed.
|
||||
let mut listed = db.feed_list(user_id, None).await?;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let counts = db.subscriber_counts().await?;
|
||||
let media = db.media_feeds().await?;
|
||||
let catalogue = crate::subscriptions(&state.ctx).await?;
|
||||
let ok = listable(&catalogue);
|
||||
let mut out = vec![];
|
||||
for s in &catalogue {
|
||||
let n = counts.get(&s.id).copied().unwrap_or(0);
|
||||
if (n == 0 && !everything) || !ok.contains(s.id.as_str()) {
|
||||
continue;
|
||||
fn dir_error(e: crate::directory::DirError) -> ApiError {
|
||||
use crate::directory::DirError;
|
||||
let status = match &e {
|
||||
DirError::NotConfigured => StatusCode::SERVICE_UNAVAILABLE,
|
||||
DirError::Refused(404, _) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
if !matches!(e, DirError::NotConfigured | DirError::Refused(404, _)) {
|
||||
tracing::warn!(error = %e, "asking the directory failed");
|
||||
}
|
||||
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
|
||||
let subscribed = mine.contains(&s.id);
|
||||
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
|
||||
let path = sum.category.or_else(|| s.cfg.category.clone()).map(|c| crate::feed::category_path(&c));
|
||||
out.push(PopularRow {
|
||||
id: s.id.clone(),
|
||||
title: sum.title,
|
||||
image: sum.image,
|
||||
subscribers: n,
|
||||
subscribed,
|
||||
category: path.as_ref().map(|p| p.0.clone()),
|
||||
subcategory: path.and_then(|p| p.1),
|
||||
podcast: media.contains(&s.id),
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
|
||||
Ok(out)
|
||||
ApiError { error: anyhow::Error::msg(e.to_string()), status }
|
||||
}
|
||||
|
||||
/// The feeds the Directory may list: not an OPML or a Patreon creator itself, which is listed as
|
||||
/// the feeds in it, and neither the feed nor the group it is in private. Asked of one feed, without
|
||||
/// building the whole Directory, by a feed's Directory page and by subscribing from it: building
|
||||
/// it was nearly all of the 0.57 s a feed's page took (#160).
|
||||
fn listable(catalogue: &[crate::Sub]) -> std::collections::HashSet<&str> {
|
||||
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
||||
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||
let is_folder: std::collections::HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
||||
catalogue
|
||||
.iter()
|
||||
// A feed inside an OPML that looks private is as private as the OPML.
|
||||
.filter(|s| {
|
||||
!is_folder.contains(s.id.as_str())
|
||||
&& !looks_private(&s.cfg)
|
||||
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
|
||||
/// The directory's listings as this person sees them: which are theirs already, matched by
|
||||
/// address, since the directory and this server name a feed differently.
|
||||
async fn rows_for(state: &WebState, user_id: i64, listed: Vec<ipx_directory::Listing>) -> Result<Vec<PopularRow>> {
|
||||
let mine: std::collections::HashSet<String> =
|
||||
state.ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let here: std::collections::HashMap<String, String> = crate::subscriptions(&state.ctx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|s| mine.contains(&s.id))
|
||||
.filter_map(|s| ipx_directory::store::normalise(&s.cfg.url).map(|u| (u, s.id)))
|
||||
.collect();
|
||||
Ok(listed
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let feed = ipx_directory::store::normalise(&l.url).and_then(|u| here.get(&u).cloned());
|
||||
PopularRow {
|
||||
id: l.id,
|
||||
title: l.title,
|
||||
image: l.image,
|
||||
subscribers: l.subscribers,
|
||||
subscribed: feed.is_some(),
|
||||
feed,
|
||||
category: l.category,
|
||||
subcategory: l.subcategory,
|
||||
podcast: l.podcast,
|
||||
}
|
||||
})
|
||||
.map(|s| s.id.as_str())
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_popular(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id, false).await?;
|
||||
rows.truncate(10);
|
||||
Ok(Json(rows))
|
||||
let listed = crate::directory::popular(&state.ctx).await.map_err(dir_error)?;
|
||||
Ok(Json(rows_for(&state, user.id, listed).await?))
|
||||
}
|
||||
|
||||
/// Every feed that may be listed, A to Z, with the feeds inside an OPML in place of the OPML.
|
||||
/// Every feed in the directory, A to Z.
|
||||
async fn get_directory(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id, true).await?;
|
||||
rows.sort_by_key(sort_name);
|
||||
Ok(Json(rows))
|
||||
let listed = crate::directory::feeds(&state.ctx).await.map_err(dir_error)?;
|
||||
Ok(Json(rows_for(&state, user.id, listed).await?))
|
||||
}
|
||||
|
||||
/// One of a listed feed's latest items, for its page in the Directory before anyone subscribes
|
||||
@@ -1060,40 +1037,23 @@ struct ListedItem {
|
||||
struct Listed {
|
||||
/// What the feed says it is (#130), sanitized as its items are.
|
||||
description: Option<String>,
|
||||
/// Why its last check failed, when it did (#142), in the words a feed's own page uses.
|
||||
/// Why its last check failed, when it did (#142), in the directory's words.
|
||||
failing: Option<String>,
|
||||
items: Vec<ListedItem>,
|
||||
}
|
||||
|
||||
/// A feed's last error as its Directory page says it: explained once it has been failing a day, as
|
||||
/// the feed's own page does, or else that the check failed; never the error as it stands, which can
|
||||
/// name the feed's address, and the Directory names none. A day, so that a site down for an hour,
|
||||
/// or a resolver for a minute, is not called gone (#143).
|
||||
fn failure_words(err: &str, failing_for: i64) -> String {
|
||||
Some(err)
|
||||
.filter(|_| failing_for >= FLAG_AFTER_SECS)
|
||||
.and_then(crate::feed::explain_failure)
|
||||
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
||||
}
|
||||
|
||||
/// A listed feed's description and latest twenty items. Only a feed the Directory lists, so a
|
||||
/// guessed id reaches nothing private, as `subscribe_popular` checks.
|
||||
/// A listed feed's description and latest items, from the directory, sanitized here: they are
|
||||
/// the feed's own HTML, which the directory passes on as it was sent.
|
||||
async fn get_listed(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
_user: crate::db::User,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Listed>, ApiError> {
|
||||
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) {
|
||||
return Err(ApiError::not_found(format!("{id:?} is not in the directory")));
|
||||
}
|
||||
let order = crate::db::order_sql("published", "desc");
|
||||
let rows = state.ctx.db.entries_in(user.id, Some(&id), crate::db::Filter::parse("all"), None, 0, 20, &order).await?;
|
||||
let page = crate::directory::page(&state.ctx, &id).await.map_err(dir_error)?;
|
||||
let mut sanitizer = feed_sanitizer();
|
||||
let description = state.ctx.db.feed_description(&id).await?.map(|d| clean_description(&mut sanitizer, &d, None));
|
||||
let summary = state.ctx.db.feed_summary(&id).await?;
|
||||
let failing_for = summary.error_since.map_or(0, |since| crate::db::now() - since);
|
||||
let failing = summary.last_error.as_deref().map(|e| failure_words(e, failing_for));
|
||||
let items = rows
|
||||
let description = page.description.as_deref().map(|d| clean_description(&mut sanitizer, d, None));
|
||||
let items = page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|e| ListedItem {
|
||||
description: e.description.as_deref().map(|d| clean_description(&mut sanitizer, d, e.link.as_deref())),
|
||||
@@ -1104,27 +1064,28 @@ async fn get_listed(
|
||||
duration: e.duration,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(Listed { description, failing, items }))
|
||||
Ok(Json(Listed { description, failing: page.failing, items }))
|
||||
}
|
||||
|
||||
fn sort_name(p: &PopularRow) -> String {
|
||||
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
|
||||
}
|
||||
|
||||
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so
|
||||
/// a guessed id cannot reach a private feed.
|
||||
/// Subscribes by the directory's id, because the page never sees a URL: the address comes from
|
||||
/// the directory, and goes through the same steps as Add feed.
|
||||
async fn subscribe_popular(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) {
|
||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||
let page = crate::directory::page(&state.ctx, &id).await.map_err(|e| match e {
|
||||
crate::directory::DirError::Refused(404, _) => ApiError::bad_request(format!("{id:?} is not in the directory")),
|
||||
e => dir_error(e),
|
||||
})?;
|
||||
let (feed_id, existed) = crate::add_to_catalogue(&state.ctx, &page.listing.url, None, vec![]).await?;
|
||||
let already = existed && state.ctx.db.subscription(user.id, &feed_id).await?.is_some();
|
||||
state.ctx.db.subscribe(user.id, &feed_id).await?;
|
||||
if !already {
|
||||
explicit_on_add(&state, user.id, &feed_id, false).await?;
|
||||
}
|
||||
state.ctx.db.subscribe(user.id, &id).await?;
|
||||
// A listed feed nobody took was checked once a day at most: read it now, not in an hour.
|
||||
scan_soon(&state, Some(id.clone())).await;
|
||||
Ok(Json(serde_json::json!({ "id": id })))
|
||||
scan_soon(&state, Some(feed_id.clone())).await;
|
||||
Ok(Json(serde_json::json!({ "id": feed_id })))
|
||||
}
|
||||
|
||||
/// Validates a replacement feed URL: present, parseable, and not already subscribed under
|
||||
@@ -1198,18 +1159,6 @@ impl IntoResponse for ApiError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_failing_feed_says_why_in_the_directory_without_its_address() {
|
||||
let day = FLAG_AFTER_SECS;
|
||||
let nothing = "the feed's address answered with nothing at all";
|
||||
assert_eq!(failure_words(nothing, day), "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 (#143).
|
||||
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
|
||||
// reqwest names the URL; the Directory names none.
|
||||
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
|
||||
assert_eq!(failure_words(raw, day), "Its last check failed.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_image_resolves_against_the_post() {
|
||||
let mut b = feed_sanitizer();
|
||||
@@ -1249,60 +1198,6 @@ mod tests {
|
||||
assert!(page("modern", "dark\"").contains("<html lang=en>"));
|
||||
}
|
||||
|
||||
#[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,
|
||||
category: None,
|
||||
listed: false,
|
||||
};
|
||||
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")));
|
||||
// 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");
|
||||
basic.username = Some("ray".into());
|
||||
assert!(looks_private(&basic), "a feed with a login configured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_directory_lists_feeds_not_their_groups_and_nothing_private() {
|
||||
let sub = |id: &str, url: &str, group: Option<&str>| crate::Sub {
|
||||
id: id.into(),
|
||||
cfg: crate::config::Feed { group: group.map(Into::into), ..feed(url) },
|
||||
managed: group.is_some(),
|
||||
};
|
||||
let catalogue = [
|
||||
sub("plain", "https://example.com/rss", None),
|
||||
sub("opml", "https://example.com/list.opml", None),
|
||||
sub("kid", "https://kid.example/rss", Some("opml")),
|
||||
sub("paid", "https://www.patreon.com/rss/x?auth=abc", None),
|
||||
sub("paid-show", "https://www.patreon.com/rss/x?auth=abc&show=1", Some("paid")),
|
||||
];
|
||||
let mut got: Vec<&str> = listable(&catalogue).into_iter().collect();
|
||||
got.sort();
|
||||
assert_eq!(got, ["kid", "plain"], "the OPML as its feeds; a private feed and its shows never");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requests_trace_is_the_pages_from_its_traceparent() {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
@@ -1366,7 +1261,7 @@ mod tests {
|
||||
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, category: None, listed: false,
|
||||
password: None, password_env: None, category: None, listed: false, private: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1625,6 +1520,9 @@ struct FeedPatch {
|
||||
deadair: Option<Option<bool>>,
|
||||
/// The pause DeadAir leaves where it cuts, in ms: one of `deadair::PAUSES` (#182).
|
||||
deadair_pause: Option<i64>,
|
||||
/// Kept out of the directory (#205). Anyone subscribed may mark a feed private, since it is
|
||||
/// most likely their own paid feed; only an admin may unmark it.
|
||||
private: Option<bool>,
|
||||
}
|
||||
|
||||
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
|
||||
@@ -1704,14 +1602,14 @@ async fn patch_feed(
|
||||
|
||||
// The rest describes the feed itself -- where its files land, its address, when it is
|
||||
// polled -- and there is one of those however many people read it.
|
||||
let feed_level =
|
||||
body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some();
|
||||
if !feed_level {
|
||||
let admin_only =
|
||||
body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some() || body.private == Some(false);
|
||||
if !admin_only && body.private.is_none() {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
if !user.is_admin {
|
||||
if admin_only && !user.is_admin {
|
||||
return Err(ApiError::forbidden(
|
||||
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them",
|
||||
"the feed's address, folder, schedule and category, and whether it may be shared, are the same for everyone, so only an admin changes them",
|
||||
));
|
||||
}
|
||||
let mut cfg = state.ctx.edit_cfg().await;
|
||||
@@ -1761,6 +1659,9 @@ async fn patch_feed(
|
||||
if let Some(v) = body.category {
|
||||
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||
}
|
||||
if let Some(v) = body.private {
|
||||
feed.private = v;
|
||||
}
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
if url_changed {
|
||||
// Refreshing a rotated auth token is the common case; entries and download history
|
||||
@@ -2315,6 +2216,10 @@ struct Settings {
|
||||
max_total_gb: f64,
|
||||
max_age_days: u64,
|
||||
art_cache_mb: u64,
|
||||
directory_url: String,
|
||||
/// Whether a token is set: the token itself never leaves the server.
|
||||
directory_token_set: bool,
|
||||
directory_share: bool,
|
||||
}
|
||||
|
||||
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||
@@ -2328,6 +2233,9 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||
max_total_gb: cfg.general.max_total_gb,
|
||||
max_age_days: cfg.general.max_age_days,
|
||||
art_cache_mb: cfg.general.art_cache_mb,
|
||||
directory_url: cfg.general.directory_url.clone(),
|
||||
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
|
||||
directory_share: cfg.general.directory_share,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2339,6 +2247,11 @@ struct SettingsPatch {
|
||||
max_total_gb: Option<f64>,
|
||||
max_age_days: Option<u64>,
|
||||
art_cache_mb: Option<u64>,
|
||||
/// Empty for no directory.
|
||||
directory_url: Option<String>,
|
||||
/// Replaces the token; empty clears it.
|
||||
directory_token: Option<String>,
|
||||
directory_share: Option<bool>,
|
||||
}
|
||||
|
||||
async fn patch_settings(
|
||||
@@ -2378,10 +2291,43 @@ async fn patch_settings(
|
||||
if let Some(v) = body.art_cache_mb {
|
||||
cfg.general.art_cache_mb = v;
|
||||
}
|
||||
if let Some(v) = body.directory_url {
|
||||
let v = v.trim().trim_end_matches('/').to_owned();
|
||||
if !v.is_empty() && !url::Url::parse(&v).is_ok_and(|u| matches!(u.scheme(), "http" | "https")) {
|
||||
return Err(ApiError::bad_request(format!("{v:?} is not a web address; try https://ipx-dir.example.net")));
|
||||
}
|
||||
cfg.general.directory_url = v;
|
||||
}
|
||||
if let Some(v) = body.directory_token {
|
||||
cfg.general.directory_token = v.trim().to_owned();
|
||||
}
|
||||
if let Some(v) = body.directory_share {
|
||||
cfg.general.directory_share = v;
|
||||
}
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// What the admin page says about the directory: who it takes this server's token to be, or why
|
||||
/// it would not say, and how the last snapshot went.
|
||||
async fn get_directory_status(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if !user.is_admin {
|
||||
return Err(ApiError::forbidden("only an admin sees the directory's settings"));
|
||||
}
|
||||
let shared = state.ctx.directory_shared.lock().unwrap().clone();
|
||||
let me = crate::directory::me(&state.ctx).await;
|
||||
Ok(Json(serde_json::json!({
|
||||
"configured": crate::directory::configured(&state.ctx.cfg()),
|
||||
"name": me.as_ref().ok().map(|m| m.name.clone()),
|
||||
"access": me.as_ref().ok().map(|m| m.access),
|
||||
"error": me.err().map(|e| e.to_string()),
|
||||
"shared": shared,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LogQuery {
|
||||
/// Highest seq the client already has; 0 means "give me the tail".
|
||||
|
||||
@@ -137,9 +137,10 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
|
||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||
| `POST /api/fetch` | |
|
||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||
| `GET /api/directory/{id}` | a listed feed's description, why its last check failed when it did (in words, never its error, which can name its address), and latest twenty items, for its page before you subscribe: title, link, date, length and text, never a file or its address, and only for a feed the directory lists |
|
||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
|
||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||
| `GET /api/directory/{id}` | a feed's page from the directory service (#205), by the directory's id: its description and latest twenty items, sanitized here, and why its last check failed, in words; never a file or its address |
|
||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the directory service's ten most subscribed and every feed it lists, A to Z, asked by ipx with its token (directory id, title, art, subscribers across every sharing server, whether it is yours and its id here, category, whether it carries audio or video; never a URL); subscribe by the directory's id, which takes the feed's address from the directory. `503` with no directory set up |
|
||||
| `GET /api/directory-status` | admin-only: the name and access the directory gives this server's token, or why it refused, and how the last snapshot went |
|
||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write. The directory's token is written, never read back: `directory_token_set` says whether there is one |
|
||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
|
||||
|
||||
@@ -9,6 +9,20 @@ one's address: every ipx server still fetches its own feeds.
|
||||
This server's is at `https://ipx-dir.sdf1.net`, the `ipx-directory` service of the Arcane project
|
||||
`ipx`, with its database `ipx_directory` on the `databases` project's Postgres.
|
||||
|
||||
## In ipx
|
||||
|
||||
An admin sets it up in **Admin, Directory**: the directory's address, the token its operator gave
|
||||
this server, and whether to share. ipx asks the directory on its people's behalf, so the page never
|
||||
talks to it and never sees the token or a feed's address. With no directory set up, the Directory
|
||||
says so and feeds are added by their address.
|
||||
|
||||
With sharing on, ipx sends its snapshot after every scan, whenever the catalogue or these settings
|
||||
change, and every half hour: each feed checked at least once, with how many people here subscribe,
|
||||
and the category an admin gave a feed that names none. It never sends an OPML or a Patreon creator
|
||||
itself (their feeds go instead), or a private feed. A feed's settings have a **Private** switch for
|
||||
a paid feed iPX does not recognise by its address; anyone subscribed may turn it on, and only an
|
||||
admin off.
|
||||
|
||||
## Tokens
|
||||
|
||||
Everything but `/healthz` needs a token, sent as `Authorization: Bearer <token>`. Each one has a
|
||||
@@ -95,5 +109,6 @@ blocks it, so no snapshot brings it back.
|
||||
|
||||
A feed's `id` is the first 64 bits of the SHA-256 of its address, normalised (scheme and host in
|
||||
lower case, no default port or fragment), so every server arrives at the same id. `subscribers`
|
||||
is the sum over every token's latest snapshot. A feed no snapshot has any more goes. Descriptions
|
||||
is the sum over every token's latest snapshot. A feed is listed once the directory has read it as
|
||||
a feed, so an OPML or an address that never worked is not, and goes when no snapshot has it any more. Descriptions
|
||||
are the feed's own HTML, unsanitized: whoever shows them sanitizes them, as ipx does.
|
||||
|
||||
@@ -26,6 +26,15 @@ module.exports = defineConfig({
|
||||
reuseExistingServer: false,
|
||||
stdout: 'ignore',
|
||||
},
|
||||
{
|
||||
// The directory the Directory shows (#205), on SQLite in the scratch directory.
|
||||
command: `cargo build -q -p ipx-directory && exec ./target/debug/ipx-directory --config ${setup.root}/directory.toml`,
|
||||
url: `${setup.DIRECTORY}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'ignore',
|
||||
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc` },
|
||||
},
|
||||
{
|
||||
// Build first so the tests always run against current source.
|
||||
command: 'cargo build -q && exec ./target/debug/ipx daemon',
|
||||
|
||||
@@ -821,7 +821,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
|
||||
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
||||
});
|
||||
|
||||
test('the Directory lists what everyone here reads, the most subscribed first, but never a private feed', async ({ browser, page }) => {
|
||||
test('the Directory lists what servers share, the most subscribed first, but never a private feed', async ({ browser, page }) => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const setup = require('./global-setup');
|
||||
const env = {
|
||||
@@ -863,11 +863,15 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
const top = await (await piper.request.get('/api/popular')).json();
|
||||
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
||||
expect(names).toEqual([...names].sort());
|
||||
// The directory's ids, not this server's: it names a feed by its address (#205).
|
||||
const ids = dir.map(p => p.id);
|
||||
expect(ids).not.toContain('test-subscriptions');
|
||||
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
|
||||
const titles = dir.map(p => p.title);
|
||||
const byTitle = t => dir.find(p => p.title === t);
|
||||
expect(titles).not.toContain('Test Subscriptions');
|
||||
expect(titles).toEqual(expect.arrayContaining(['Grouped Show', 'Aardvark Radio']));
|
||||
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
||||
expect(ids).not.toContain('paid-show');
|
||||
expect(titles).not.toContain('Paid Show');
|
||||
expect(ids).not.toContain('test-show');
|
||||
await expect(chart).toHaveCount(top.length);
|
||||
await expect(chart.first().locator('.no')).toHaveText('1');
|
||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||
@@ -875,8 +879,8 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
// Then the categories, as tiles, each a page of its own.
|
||||
const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) });
|
||||
const tiles = piper.locator('#dtiles .tile');
|
||||
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
|
||||
expect(byTitle('Test Show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||
expect(byTitle('Picture Blog')).toMatchObject({ podcast: false });
|
||||
await cat('Technology').click();
|
||||
await expect(piper.locator('#directory h2')).toHaveText('Technology');
|
||||
await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length);
|
||||
@@ -927,7 +931,7 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused();
|
||||
await expect(tiles).toHaveCount(dir.length);
|
||||
// Only a listed feed has one, and it carries no file or its address.
|
||||
const preview = await (await piper.request.get('/api/directory/test-show')).text();
|
||||
const preview = await (await piper.request.get(`/api/directory/${byTitle('Test Show').id}`)).text();
|
||||
expect(preview).toContain('First Episode');
|
||||
expect(JSON.parse(preview).description).toBe('A fixture feed.');
|
||||
expect(preview).not.toContain('.mp3');
|
||||
@@ -953,14 +957,15 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||
|
||||
const row = async () =>
|
||||
(await (await piper.request.get('/api/directory')).json()).find(p => p.id === 'test-show');
|
||||
(await (await piper.request.get('/api/directory')).json()).find(p => p.title === 'Test Show');
|
||||
const before = await row();
|
||||
expect(before.subscribed).toBe(false);
|
||||
expect(before).toMatchObject({ subscribed: false, feed: null });
|
||||
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
||||
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
||||
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
|
||||
// Everyone counts, you included: it stays listed, marked as yours by the id it has here, with
|
||||
// one more subscriber once this server has shared again.
|
||||
await expect.poll(row, { timeout: 20_000 }).toMatchObject({ subscribed: true, feed: 'test-show', subscribers: before.subscribers + 1 });
|
||||
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(200);
|
||||
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||
await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
||||
@@ -1000,19 +1005,20 @@ test('ipx add and rm while the daemon runs reach it, and its next change keeps t
|
||||
const setup = require('./global-setup');
|
||||
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
|
||||
const ipx = (...args) => execFileSync('./target/debug/ipx', args, { env, encoding: 'utf8' });
|
||||
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.id);
|
||||
// By title: the directory names a feed by its address, not by its id here (#205).
|
||||
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
||||
// A change of the daemon's own: it writes the whole catalogue from the copy it holds, which
|
||||
// wrote a removed feed back and dropped an added one when that copy was old.
|
||||
const daemonWrites = category => page.evaluate(c =>
|
||||
api('/api/feeds/picture-blog', { method: 'PATCH', body: JSON.stringify({ category: c }) }), category);
|
||||
|
||||
const id = ipx('add', '--list', 'http://127.0.0.1:8792/cli.xml').match(/^added (\S+?),? /m)[1];
|
||||
await expect.poll(listed).toContain(id);
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
||||
await daemonWrites('Comedy');
|
||||
expect(await listed()).toContain(id);
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
||||
|
||||
ipx('rm', id);
|
||||
await expect.poll(listed).not.toContain(id);
|
||||
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Command Line Show');
|
||||
await daemonWrites(null);
|
||||
expect(await listed()).not.toContain(id);
|
||||
});
|
||||
@@ -1299,11 +1305,14 @@ test('an API token acts as its owner until it is revoked', async ({ page, reques
|
||||
test('an admin can give a blog its Directory category', async ({ page }) => {
|
||||
const patch = (id, category) => page.evaluate(([id, category]) =>
|
||||
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
|
||||
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
||||
// By title: the directory names a feed by its address, not by its id here (#205). It hears of
|
||||
// a category given here with this server's next snapshot, sent as soon as one is saved.
|
||||
const listed = async title => (await page.evaluate(() => api('/api/directory'))).find(p => p.title === title);
|
||||
|
||||
await patch('picture-blog', 'Visual Arts');
|
||||
// Apple's subcategory, under its category, as the Directory browses them (#118).
|
||||
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
||||
await expect.poll(() => listed('Picture Blog'), { timeout: 20_000 })
|
||||
.toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
||||
// A category's subcategories are on its own page, as Apple's directory has them.
|
||||
const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) });
|
||||
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||
@@ -1314,10 +1323,52 @@ test('an admin can give a blog its Directory category', async ({ page }) => {
|
||||
await expect(page.locator('#dtiles .tile')).toHaveCount(1);
|
||||
// A feed's own iTunes category wins over one given here.
|
||||
await patch('test-show', 'Comedy');
|
||||
expect((await listed('test-show')).category).toBe('Technology');
|
||||
expect((await listed('Test Show')).category).toBe('Technology');
|
||||
|
||||
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
|
||||
expect((await listed('picture-blog')).category).toBeNull();
|
||||
await expect.poll(async () => (await listed('Picture Blog')).category, { timeout: 20_000 }).toBeNull();
|
||||
});
|
||||
|
||||
test('the admin page sets up the directory, and a feed marked private leaves it (#205)', async ({ page }) => {
|
||||
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||
|
||||
await page.goto('/admin#directory');
|
||||
await expect(page.locator('#durl')).toHaveValue('http://127.0.0.1:8795');
|
||||
// The token is set and never shown: the field is empty, and says so.
|
||||
await expect(page.locator('#dtoken')).toHaveValue('');
|
||||
await expect(page.locator('#dtoken')).toHaveAttribute('placeholder', /^Set\./);
|
||||
const settings = await page.evaluate(() => api('/api/settings'));
|
||||
expect(settings.directory_token_set).toBe(true);
|
||||
expect(JSON.stringify(settings)).not.toContain('ipxd_');
|
||||
await expect(page.locator('#dstatus')).toContainText('Connected as test, which may browse, share and administer the directory.');
|
||||
await expect(page.locator('#dstatus')).toContainText(/Shared \d+ feeds? at/);
|
||||
|
||||
// A wrong token says so, in words.
|
||||
await page.locator('#dtoken').fill('not-the-token');
|
||||
await page.locator('#dsave').click();
|
||||
await expect(page.locator('#dstatus')).toContainText('does not accept this server');
|
||||
await page.locator('#dtoken').fill(require('./global-setup').DIRECTORY_TOKEN);
|
||||
await page.locator('#dsave').click();
|
||||
await expect(page.locator('#dstatus')).toContainText('Connected as test');
|
||||
|
||||
// Marked private in its settings, a feed is out of the directory with the next snapshot.
|
||||
await page.goto('/');
|
||||
await page.locator('.feed', { hasText: 'Multi Show' }).click();
|
||||
await page.locator('#content .acts [data-a="settings"]').click();
|
||||
const priv = page.locator('#sprivate');
|
||||
await expect(priv).not.toBeChecked();
|
||||
await priv.check({ force: true });
|
||||
await page.locator('#ssave').click();
|
||||
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Multi Show');
|
||||
// Its address shows nothing, so only the mark keeps it out; and an admin can take it off.
|
||||
const row = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'multi-show'));
|
||||
expect(row).toMatchObject({ private: true, private_by_address: false });
|
||||
await page.evaluate(() => api('/api/feeds/multi-show', { method: 'PATCH', body: JSON.stringify({ private: false }) }));
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||
// A feed whose address carries a key is private whatever it is marked.
|
||||
const paid = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'paid-show'));
|
||||
if (paid) expect(paid.private_by_address).toBe(true);
|
||||
});
|
||||
|
||||
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
|
||||
|
||||
@@ -6,6 +6,9 @@ const os = require('os');
|
||||
|
||||
const root = path.join(os.tmpdir(), 'ipx-ui-test');
|
||||
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
|
||||
// The suite's own directory service (#205), and this daemon's token for it, which may do anything.
|
||||
const DIRECTORY = 'http://127.0.0.1:8795';
|
||||
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
|
||||
|
||||
// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts
|
||||
// webServer *before* globalSetup, so a config written there does not exist yet when the
|
||||
@@ -29,6 +32,9 @@ download_dir = "${path.join(root, 'downloads')}"
|
||||
socket = "${path.join(root, 'ipx.sock')}"
|
||||
schedule = "every 60m"
|
||||
max_new_per_check = 1
|
||||
directory_url = "${DIRECTORY}"
|
||||
directory_token = "${DIRECTORY_TOKEN}"
|
||||
directory_share = true
|
||||
|
||||
[torrent]
|
||||
enabled = false
|
||||
@@ -64,7 +70,16 @@ auto_download = false
|
||||
[feeds.paid-show]
|
||||
url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
|
||||
auto_download = false
|
||||
`);
|
||||
fs.writeFileSync(path.join(root, 'directory.toml'), `
|
||||
bind = "127.0.0.1:8795"
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||
|
||||
[[tokens]]
|
||||
name = "test"
|
||||
access = "rwa"
|
||||
token_sha256 = "${require('crypto').createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
|
||||
`);
|
||||
}
|
||||
|
||||
module.exports = { prepare, root, TOKEN };
|
||||
module.exports = { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN };
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
<nav class="tabs" id="atabs">
|
||||
<a href="#server" data-t="server">Server</a>
|
||||
<a href="#accounts" data-t="accounts">Accounts</a>
|
||||
<a href="#directory" data-t="directory">Directory</a>
|
||||
<a href="#log" data-t="log">Log</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="wrap plain" id="admin">
|
||||
<section id="server" hidden></section>
|
||||
<section id="accounts" hidden></section>
|
||||
<section id="directory" hidden></section>
|
||||
<section id="log" hidden></section>
|
||||
</main>
|
||||
<div id="toasts"></div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
let me: {name: string} | null = null;
|
||||
api('/api/me').then(u => { me = u; }).catch(() => {});
|
||||
|
||||
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
|
||||
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, directory: drawDirectorySettings, log: drawLogView};
|
||||
|
||||
function showSection(){
|
||||
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
|
||||
@@ -70,6 +70,67 @@ async function drawServer(){
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- directory ---------------- */
|
||||
// The directory service the Directory shows (#205): where it is, this server's token for it, and
|
||||
// whether to share. The token is sent, never shown: the server says only whether one is set.
|
||||
const ACCESS: Record<string, string> = {ro: 'browse', rw: 'browse and share', rwa: 'browse, share and administer the directory'};
|
||||
|
||||
async function drawDirectorySettings(){
|
||||
const box = $('#directory');
|
||||
const g = await api('/api/settings');
|
||||
box.innerHTML = `<h2>Directory</h2>
|
||||
<p class="hint">The Directory shows podcasts from a directory service that iPX servers share.
|
||||
Without one, people add feeds by their address.</p>
|
||||
<div class="field"><label for="durl">Directory address</label>
|
||||
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://ipx-dir.example.net" spellcheck="false"></div>
|
||||
<div class="field"><label for="dtoken">Access token</label>
|
||||
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
|
||||
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
|
||||
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
|
||||
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div>
|
||||
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label>
|
||||
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every
|
||||
half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div>
|
||||
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
|
||||
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`;
|
||||
const save = async (patch: Record<string, unknown>, said: string) => {
|
||||
try{
|
||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
|
||||
toast(said);
|
||||
drawDirectorySettings();
|
||||
}catch(e){ toast(e.message, true); }
|
||||
};
|
||||
$('#dsave').onclick = () => {
|
||||
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
|
||||
// Blank leaves the token as it is; Clear is the way to remove it.
|
||||
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
|
||||
save(patch, 'Directory settings saved');
|
||||
};
|
||||
const clear = $('#dclear');
|
||||
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
||||
drawDirectoryStatus();
|
||||
}
|
||||
|
||||
async function drawDirectoryStatus(){
|
||||
const el = $('#dstatus');
|
||||
let s;
|
||||
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; }
|
||||
if(!el.isConnected) return;
|
||||
const lines: string[] = [];
|
||||
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
|
||||
else if(s.error) lines.push(esc(s.error));
|
||||
else lines.push(`Connected as <b>${esc(s.name)}</b>, which may ${esc(ACCESS[s.access] || s.access)}.`);
|
||||
const sh = s.shared;
|
||||
if(sh){
|
||||
const when = new Date(sh.at * 1000).toLocaleString();
|
||||
lines.push(sh.error ? `Sharing failed at ${esc(when)}: ${esc(sh.error)}`
|
||||
: `Shared ${plural(sh.accepted, 'feed')} at ${esc(when)}.` +
|
||||
(sh.refused_private ? ` The directory refused ${plural(sh.refused_private, 'feed')} as private.` : '') +
|
||||
(sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : ''));
|
||||
}
|
||||
el.innerHTML = lines.join('<br>');
|
||||
}
|
||||
|
||||
/* ---------------- accounts ---------------- */
|
||||
async function drawAccounts(){
|
||||
const box = $('#accounts');
|
||||
|
||||
@@ -34,8 +34,11 @@ $('#addFeed').onclick=()=>{
|
||||
};
|
||||
};
|
||||
|
||||
// The rows carry an id, never a URL, so a key in someone's feed address never reaches this page.
|
||||
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||
// The rows come from the directory through iPX (#205), and carry its id, never a URL, so a key in
|
||||
// someone's feed address never reaches this page.
|
||||
const NONE_LISTED='<p class="hint">Nothing in the directory yet. Feeds people subscribe to show up here once their servers share them.</p>';
|
||||
// Why the directory could not be shown: none set up, or it did not answer. In iPX's words.
|
||||
let dirError='';
|
||||
|
||||
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
|
||||
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
|
||||
@@ -57,7 +60,8 @@ function listedFeed(p,cls,rank?: number){
|
||||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.id); return el; }
|
||||
// Yours has an id here as well as the directory's, and opens by it.
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||
// Its own click, not the tile's as well.
|
||||
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
|
||||
@@ -66,9 +70,9 @@ function listedFeed(p,cls,rank?: number){
|
||||
|
||||
async function subscribeListed(p){
|
||||
try{
|
||||
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||
toast(`Subscribed to ${NAME(p)}`);
|
||||
await loadFeeds(true); selectFeed(p.id);
|
||||
await loadFeeds(true); selectFeed(r.id);
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}
|
||||
|
||||
@@ -99,7 +103,8 @@ const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
|
||||
async function renderDirectory(url,box){
|
||||
// Each visit starts at the front, as going back to a tab does.
|
||||
dirCat=null; dirSub=null; dirShow=null;
|
||||
try{ dirRows=await api(url)||[]; }catch{ dirRows=[]; }
|
||||
dirError='';
|
||||
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=e.message; }
|
||||
drawDirectory(box);
|
||||
return dirRows.length;
|
||||
}
|
||||
@@ -107,7 +112,7 @@ async function renderDirectory(url,box){
|
||||
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
||||
function drawDirectory(box=$('#directory'), focus?: string){
|
||||
if(!box) return;
|
||||
if(!dirRows.length){ box.innerHTML=NONE_LISTED; return; }
|
||||
if(!dirRows.length){ box.innerHTML=dirError?`<p class="hint">${esc(dirError)}</p>`:NONE_LISTED; return; }
|
||||
// Only a choice when the server has both kinds.
|
||||
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
|
||||
if(!both) dirKind='All';
|
||||
@@ -137,7 +142,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
|
||||
// Its category's page is one click away from here.
|
||||
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
|
||||
${n?`<p class="sub">${n} ${n===1?'person here subscribes':'people here subscribe'}</p>`:''}
|
||||
${n?`<p class="sub">${n} ${n===1?'person subscribes':'people subscribe'}</p>`:''}
|
||||
<p class="sub dfail" id="dfail" hidden></p>
|
||||
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
|
||||
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
|
||||
@@ -153,7 +158,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
|
||||
// Other, the feeds with no category, goes last.
|
||||
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
|
||||
html=head('Directory',`${count(rows.length)} on this server. Private feeds are never listed.`)
|
||||
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
|
||||
+(chart.length?sec('Most subscribed',board):'')
|
||||
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`,
|
||||
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
|
||||
@@ -227,8 +232,7 @@ async function fillShow(box,p){
|
||||
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
|
||||
+(text?`<p>${esc(text)}</p>`:'')
|
||||
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
|
||||
}).join(''):`<p class="hint">Nothing read from it yet.${p.subscribers||got.failing?''
|
||||
:' A feed nobody here subscribes to is checked once a day.'}</p>`;
|
||||
}).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>';
|
||||
}
|
||||
|
||||
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
|
||||
@@ -539,6 +543,13 @@ function settingsModal(f, newUrl?: string){
|
||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
||||
with what Settings hides everywhere.</p></div>`}
|
||||
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
||||
<p class="cap">${f.private_by_address
|
||||
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
|
||||
:f.private&&!admin
|
||||
?'Never shared with the directory. Only an admin can share it again.'
|
||||
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
|
||||
</div>
|
||||
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
||||
@@ -625,6 +636,9 @@ function settingsModal(f, newUrl?: string){
|
||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
|
||||
deadair_pause:Number($('#sdpause').value)};
|
||||
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||
const priv=$('#sprivate');
|
||||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
||||
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||
if(admin) patch.url=$('#surl').value.trim();
|
||||
|
||||
Reference in New Issue
Block a user