"), "a whole body wins");
- assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
+ assert_eq!(
+ body(Some("
Notes
"), Some("
Notes
")).as_deref(),
+ Some("
Notes
"),
+ "a whole body wins"
+ );
+ assert_eq!(
+ body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(),
+ Some("Plain notes, no tags.")
+ );
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
}
@@ -1401,14 +1425,19 @@ mod tests {
);
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
let notes = "
This week we talk about tape, drums and a very long list.
";
- for d in ["This week we talk about tape, drums and a very long list.",
- "This week we talk about tape, drums…",
- "This week we talk about\ntape [...]"] {
+ for d in [
+ "This week we talk about tape, drums and a very long list.",
+ "This week we talk about tape, drums…",
+ "This week we talk about\ntape [...]",
+ ] {
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
}
let long = "word ".repeat(80);
- assert_eq!(body(Some("
The post.
"), Some(&long)).as_deref(), Some("
The post.
"),
- "a long description is notes, not a subtitle");
+ assert_eq!(
+ body(Some("
The post.
"), Some(&long)).as_deref(),
+ Some("
The post.
"),
+ "a long description is notes, not a subtitle"
+ );
}
#[test]
@@ -1421,10 +1450,7 @@ mod tests {
"#;
let feed = parse(xml).unwrap();
- assert!(
- feed.entries[0].explicit,
- "the entry says nothing; the feed-level flag must still mark it explicit"
- );
+ assert!(feed.entries[0].explicit, "the entry says nothing; the feed-level flag must still mark it explicit");
}
#[test]
@@ -1454,13 +1480,17 @@ mod tests {
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
"This address answers with nothing; the site may be gone."
);
- let dns = explain_failure("connecting: dns error: failed to lookup address information: Name or service not known").unwrap();
+ let dns =
+ explain_failure("connecting: dns error: failed to lookup address information: Name or service not known")
+ .unwrap();
assert_eq!(dns.reason, "This address no longer resolves; the site may be gone.");
// A lookup that failed for the moment is not a site gone (#143).
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
assert!(is_temporary_lookup_failure(blip));
assert!(explain_failure(blip).is_none());
- assert!(!is_temporary_lookup_failure("connecting: dns error: failed to lookup address information: Name or service not known"));
+ assert!(!is_temporary_lookup_failure(
+ "connecting: dns error: failed to lookup address information: Name or service not known"
+ ));
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
@@ -1671,10 +1701,7 @@ mod tests {
let two = &f.entries[0].enclosures;
assert_eq!(two.len(), 2, "both enclosures survive");
- assert_eq!(
- two[0].url, "https://x/a.mp3?v=1&t=2",
- "document order, and the escaped ampersand is decoded"
- );
+ assert_eq!(two[0].url, "https://x/a.mp3?v=1&t=2", "document order, and the escaped ampersand is decoded");
assert_eq!(two[0].length, Some(111));
assert_eq!(two[1].url, "https://x/b.mp4");
assert_eq!(two[1].mime.as_deref(), Some("video/mp4"));
@@ -1777,4 +1804,3 @@ mod tests {
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
}
}
-
diff --git a/crates/ipx/src/access.rs b/crates/ipx/src/access.rs
index a3f5bad..a612b3b 100644
--- a/crates/ipx/src/access.rs
+++ b/crates/ipx/src/access.rs
@@ -103,11 +103,8 @@ impl Keys {
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
fn store(&self, set: JwkSet) {
- let keys = set
- .keys
- .iter()
- .filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
- .collect();
+ let keys =
+ set.keys.iter().filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))).collect();
let mut c = self.cache.lock().unwrap();
c.keys = keys;
c.fetched = Some(Instant::now());
@@ -181,8 +178,7 @@ mod tests {
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
assert_eq!(check(&keys, &hs).await, None, "HS256");
- let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
- ok.split('.').nth(1).unwrap());
+ let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", ok.split('.').nth(1).unwrap());
assert_eq!(check(&keys, &none).await, None, "alg none");
}
@@ -191,16 +187,25 @@ mod tests {
let keys = Keys::default();
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
let fetches = std::sync::atomic::AtomicUsize::new(0);
- let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
- assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
- "a key not cached yet is fetched");
+ let count = || {
+ fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ async { Ok(jwks()) }
+ };
+ assert_eq!(
+ keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(),
+ Some("rays@sdf1.net"),
+ "a key not cached yet is fetched"
+ );
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
// A made-up key id straight after: not fetched again.
let mut h = Header::new(Algorithm::RS256);
h.kid = Some("nobody".into());
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
- let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
+ let count = || {
+ fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ async { Ok(jwks()) }
+ };
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
}
diff --git a/crates/ipx/src/art.rs b/crates/ipx/src/art.rs
index bc42804..474d78b 100644
--- a/crates/ipx/src/art.rs
+++ b/crates/ipx/src/art.rs
@@ -1,8 +1,8 @@
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
//! of each publisher's server for every visit, and still there when a publisher is not.
-use std::path::{Path, PathBuf};
use std::collections::HashMap;
+use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
diff --git a/crates/ipx/src/auth.rs b/crates/ipx/src/auth.rs
index 610e760..917c4e8 100644
--- a/crates/ipx/src/auth.rs
+++ b/crates/ipx/src/auth.rs
@@ -25,9 +25,7 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
return false;
};
- Argon2::default()
- .verify_password(password.as_bytes(), &parsed)
- .is_ok()
+ Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
diff --git a/crates/ipx/src/config.rs b/crates/ipx/src/config.rs
index b251e63..08433ba 100644
--- a/crates/ipx/src/config.rs
+++ b/crates/ipx/src/config.rs
@@ -221,13 +221,7 @@ impl Default for General {
impl Default for Torrent {
fn default() -> Self {
- Self {
- enabled: true,
- seed_ratio: 1.0,
- seed_time_mins: 60,
- port_range: "6881-6889".into(),
- stall_mins: 30,
- }
+ Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30 }
}
}
@@ -309,14 +303,11 @@ impl Config {
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
return Ok(Self::default());
}
- let text = std::fs::read_to_string(path)
- .with_context(|| format!("reading {}", path.display()))?;
- let mut cfg: Self =
- toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
+ let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
+ let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
Ok(cfg)
}
-
}
/// What the database keeps of the configuration (issue #18): the server settings the admin page
@@ -515,10 +506,7 @@ pub fn unique_slug(text: &str, taken: &BTreeMap) -> String {
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
fn xdg(var: &str, fallback: &str) -> PathBuf {
- std::env::var_os(var)
- .filter(|v| !v.is_empty())
- .map(PathBuf::from)
- .unwrap_or_else(|| home().join(fallback))
+ std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home().join(fallback))
}
fn home() -> PathBuf {
@@ -597,10 +585,20 @@ mod tests {
#[test]
fn intervals_parse_from_the_forms_people_actually_type() {
for (input, want) in [
- ("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
- ("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
- ("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
- ("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
+ ("every 30m", 30),
+ ("30m", 30),
+ ("30", 30),
+ ("every 30 minutes", 30),
+ ("every 4h", 240),
+ ("4h", 240),
+ ("4 hours", 240),
+ ("EVERY 4H", 240),
+ ("1d", 1440),
+ ("every 2 days", 2880),
+ (" every 90m ", 90),
+ ("1w", 10080),
+ ("every 2 weeks", 20160),
+ ("2 w", 20160),
] {
assert_eq!(parse_interval(input), Some(want), "{input:?}");
}
@@ -672,11 +670,26 @@ mod tests {
assert!(slug(&"x".repeat(100)).len() <= 40);
let mut taken = BTreeMap::new();
- 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, private: false,
- });
+ 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,
+ private: false,
+ },
+ );
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
}
diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs
index 7c37abf..7ac6577 100644
--- a/crates/ipx/src/db.rs
+++ b/crates/ipx/src/db.rs
@@ -1,8 +1,11 @@
//! The database, through SeaORM: SQLite today, Postgres to come (issue #18). Replaces the
//! per-feed .ipxd plists, history.dat and qmcache.dat.
+use crate::entity::{
+ api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings,
+ signal_verdicts, signals, subscriptions, users,
+};
use anyhow::{Context, Result};
-use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, signal_verdicts, signals, subscriptions, users};
use sea_orm::sea_query::{Expr, Func};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -94,10 +97,9 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
// A feed is read whole only when it has changed since the last read, so each one's
// description would wait for its next item. Forgetting how each was last seen makes
// its next read, when it is due anyway, a whole one.
- for sql in [
- "ALTER TABLE feeds ADD COLUMN description TEXT",
- "UPDATE feeds SET etag = NULL, last_modified = NULL",
- ] {
+ for sql in
+ ["ALTER TABLE feeds ADD COLUMN description TEXT", "UPDATE feeds SET etag = NULL, last_modified = NULL"]
+ {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
@@ -125,7 +127,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
// Postgres guessed a third of entries were recent and read them all, entries_recent unused
// (#274). Analysed once when they are missing; autovacuum keeps them after.
if orm.get_database_backend() == sea_orm::DbBackend::Postgres {
- let has = Statement::from_string(sea_orm::DbBackend::Postgres, "SELECT 1 FROM pg_stats WHERE tablename = 'entries_recent'");
+ let has = Statement::from_string(
+ sea_orm::DbBackend::Postgres,
+ "SELECT 1 FROM pg_stats WHERE tablename = 'entries_recent'",
+ );
if orm.query_one_raw(has).await?.is_none() {
orm.execute_unprepared("ANALYZE entries").await.context("analysing entries")?;
}
@@ -222,9 +227,7 @@ async fn connect(location: &str, keep_open: bool) -> Result String {
}
}
-
/// One person's wants for one feed. `None` in a field means the feed's own setting stands.
#[derive(Debug, Clone, Default)]
pub struct Sub {
@@ -330,7 +332,6 @@ pub struct Managed {
pub group_id: String,
}
-
/// What `ipx list` shows next to each configured feed.
#[derive(Debug, Default)]
pub struct FeedSummary {
@@ -414,9 +415,11 @@ impl Db {
admin.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await?;
// Every connection in the pool starts in it, so each test sees only its own tables.
let sep = if url.contains('?') { '&' } else { '?' };
- let orm =
- connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false)
- .await?;
+ let orm = connect(
+ &format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
+ false,
+ )
+ .await?;
create_missing(&orm).await?;
return Ok(Self { orm, tmp: None });
}
@@ -530,9 +533,9 @@ impl Db {
step("blocked"),
)
.await?
- .into_iter()
- .map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
- .collect();
+ .into_iter()
+ .map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
+ .collect();
let mut rows = feeds::Entity::find();
if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id));
@@ -574,7 +577,6 @@ pub struct FeedListing {
pub unread: i64,
}
-
/// Everything `fetch` needs to decide whether to poll a feed, and how.
#[derive(Debug, Default)]
pub struct HttpState {
@@ -611,7 +613,12 @@ impl Db {
/// Every feed's, in one query: a scan, and the daemon working out when the next is due, asked
/// feed by feed, 180 queries a minute (#114).
pub async fn http_states(&self) -> Result> {
- Ok(feeds::Entity::find().all(&self.orm).await?.into_iter().map(|f| (f.id.clone(), HttpState::from(f))).collect())
+ Ok(feeds::Entity::find()
+ .all(&self.orm)
+ .await?
+ .into_iter()
+ .map(|f| (f.id.clone(), HttpState::from(f)))
+ .collect())
}
/// Upsert after a successful poll. Clears any previous error.
@@ -786,30 +793,18 @@ impl Db {
Ok(())
}
- pub async fn record_enclosure(
- &self,
- feed_id: &str,
- guid: &str,
- enc: &crate::feed::Enclosure,
- ) -> Result {
+ pub async fn record_enclosure(&self, feed_id: &str, guid: &str, enc: &crate::feed::Enclosure) -> Result {
let inserted = self
.exec(
"INSERT INTO enclosures (feed_id, guid, url, mime, length, state)
VALUES ($1, $2, $3, $4, $5, 'pending') ON CONFLICT DO NOTHING",
- vec![
- feed_id.into(),
- guid.into(),
- enc.url.clone().into(),
- enc.mime.clone().into(),
- enc.length.into(),
- ],
+ vec![feed_id.into(), guid.into(), enc.url.clone().into(), enc.mime.clone().into(), enc.length.into()],
)
.await?;
Ok(inserted == 1)
}
}
-
/// An enclosure waiting to be downloaded.
#[derive(Debug)]
pub struct Pending {
@@ -848,7 +843,9 @@ impl Db {
.await?;
// And back, when a limit is raised or a feed downloads again.
self.exec(
- &format!("UPDATE enclosures SET state = 'pending' WHERE feed_id = $1 AND state = 'held' AND guid IN ({newest})"),
+ &format!(
+ "UPDATE enclosures SET state = 'pending' WHERE feed_id = $1 AND state = 'held' AND guid IN ({newest})"
+ ),
vec![feed_id.into(), limit],
)
.await?;
@@ -881,7 +878,6 @@ impl Db {
}
}
-
/// A downloaded file the reaper may consider.
#[derive(Debug, Clone, PartialEq)]
pub struct Candidate {
@@ -1019,7 +1015,9 @@ impl Db {
for table in ["entries", "feeds"] {
let col = if table == "feeds" { "id" } else { "feed_id" };
self.exec(
- &format!("UPDATE {table} SET image = 'https://' || substr(image, 8) WHERE {col} = $1 AND image LIKE $2"),
+ &format!(
+ "UPDATE {table} SET image = 'https://' || substr(image, 8) WHERE {col} = $1 AND image LIKE $2"
+ ),
vec![feed_id.into(), like.clone().into()],
)
.await?;
@@ -1108,19 +1106,14 @@ impl Db {
/// (pending, downloaded) across all feeds, for the status command.
pub async fn counts(&self) -> Result<(i64, i64)> {
- let pending = enclosures::Entity::find()
- .filter(enclosures::Column::State.eq("pending"))
- .count(&self.orm)
- .await?;
- let downloaded = enclosures::Entity::find()
- .filter(enclosures::Column::Path.is_not_null())
- .count(&self.orm)
- .await?;
+ let pending =
+ enclosures::Entity::find().filter(enclosures::Column::State.eq("pending")).count(&self.orm).await?;
+ let downloaded =
+ enclosures::Entity::find().filter(enclosures::Column::Path.is_not_null()).count(&self.orm).await?;
Ok((pending as i64, downloaded as i64))
}
}
-
/// An entry plus its enclosures, for the web UI.
#[derive(Debug, serde::Serialize)]
pub struct EntryRow {
@@ -1182,9 +1175,7 @@ fn entries_from(
let search = match search.map(|q| q.trim().to_lowercase()).filter(|q| !q.is_empty()) {
Some(q) => {
let like = a.p(format!("%{q}%"));
- format!(
- "(lower(coalesce(e.title, '')) LIKE {like} OR lower(coalesce(e.description, '')) LIKE {like})"
- )
+ format!("(lower(coalesce(e.title, '')) LIKE {like} OR lower(coalesce(e.description, '')) LIKE {like})")
}
None => "true".into(),
};
@@ -1340,7 +1331,8 @@ impl Db {
pub async fn signal_count(&self, user_id: i64, signal: i64, filter: Filter, search: Option<&str>) -> Result {
let mut a = Args::default();
- let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, None, filter, search, None, Some(signal)));
+ let sql =
+ format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, None, filter, search, None, Some(signal)));
let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
}
@@ -1432,7 +1424,8 @@ impl Db {
search: Option<&str>,
) -> Result {
let mut a = Args::default();
- let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None, None));
+ let sql =
+ format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None, None));
let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
}
@@ -1457,13 +1450,7 @@ impl Db {
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, feed_id, guid) DO UPDATE SET position = excluded.position,
duration = coalesce(excluded.duration, entry_state.duration)",
- vec![
- user_id.into(),
- feed_id.into(),
- guid.into(),
- secs.max(0).into(),
- duration.filter(|d| *d > 0).into(),
- ],
+ vec![user_id.into(), feed_id.into(), guid.into(), secs.max(0).into(), duration.filter(|d| *d > 0).into()],
)
.await?;
Ok(())
@@ -1508,9 +1495,7 @@ impl Db {
return Ok(false); // dropped, so rolled back
}
for (id, feed) in &cfg.feeds {
- catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
- .insert(&tx)
- .await?;
+ catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }.insert(&tx).await?;
}
tx.commit().await?;
Ok(true)
@@ -1531,9 +1516,7 @@ impl Db {
.await?;
catalogue::Entity::delete_many().exec(&tx).await?;
for (id, feed) in &cfg.feeds {
- catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
- .insert(&tx)
- .await?;
+ catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }.insert(&tx).await?;
}
tx.commit().await?;
Ok(())
@@ -1583,10 +1566,7 @@ impl Db {
/// What this person wants from a feed. Absent means they do not subscribe at all.
pub async fn subscription(&self, user_id: i64, feed_id: &str) -> Result