Doc comments on ipx-feed's and ipx-directory's public items (#354)
The style guide asks for doc comments on a library's public API. Every type, function, constant and static the two libraries export now has one, saying what it is for or what it does that its name does not: fetch follows redirects itself so credentials stay on the feed's host, Config::parse refuses repeated token names, Store::due_one is the admin's Refresh. Fields and variants whose names say it all have none, as the guide allows. category_path's comment had slid above categories(), which then had two; each has its own again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,11 @@ use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// `directory.toml`. See docs/directory.md.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// Where the API and the public pages listen.
|
||||
#[serde(default = "default_bind")]
|
||||
pub bind: String,
|
||||
/// The environment variable holding the database URL, so the file holds no secret.
|
||||
@@ -39,6 +41,7 @@ fn default_database_url_env() -> String {
|
||||
"DIRECTORY_DATABASE_URL".into()
|
||||
}
|
||||
|
||||
/// A token as `directory.toml` lists it: a name, what it may do, and only the token's hash.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TokenCfg {
|
||||
@@ -74,11 +77,14 @@ pub struct Token {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Reads and checks `path`; see `parse`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&text).with_context(|| format!("in {}", path.display()))
|
||||
}
|
||||
|
||||
/// Reads a config, refusing tokens with empty or repeated names, which would file two
|
||||
/// instances' snapshots together.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
let cfg: Config = toml::from_str(text)?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
@@ -63,7 +63,9 @@ pub struct Item {
|
||||
pub guid: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
/// Unix seconds.
|
||||
pub published: Option<i64>,
|
||||
/// Seconds.
|
||||
pub duration: Option<i64>,
|
||||
/// As the feed sent it, unsanitized.
|
||||
pub description: Option<String>,
|
||||
@@ -80,6 +82,7 @@ pub struct SnapshotEntry {
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// What became of a snapshot's entries.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SnapshotResult {
|
||||
pub accepted: usize,
|
||||
@@ -91,6 +94,7 @@ pub struct SnapshotResult {
|
||||
pub refused_invalid: usize,
|
||||
}
|
||||
|
||||
/// Who a token belongs to and what it may do: `/v1/me`'s answer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Me {
|
||||
pub name: String,
|
||||
@@ -105,11 +109,13 @@ pub struct Instance {
|
||||
pub subscribers: i64,
|
||||
}
|
||||
|
||||
/// A feed an admin removed from the directory, which snapshots may not bring back.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Blocked {
|
||||
pub id: String,
|
||||
pub url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
/// When it was blocked, Unix seconds.
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
@@ -121,6 +127,8 @@ pub const MAX_SNAPSHOT: usize = 20_000;
|
||||
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
||||
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
||||
|
||||
/// The running directory: its database, the tokens it accepts and the client its refresher
|
||||
/// fetches feeds with.
|
||||
pub struct Directory {
|
||||
pub store: Store,
|
||||
tokens: RwLock<Vec<Token>>,
|
||||
@@ -134,9 +142,12 @@ pub struct Directory {
|
||||
/// How long the public pages show the same copy of the list.
|
||||
const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The directory as every handler and task holds it.
|
||||
pub type Shared = Arc<Directory>;
|
||||
|
||||
impl Directory {
|
||||
/// A directory on `store`, accepting `tokens`, whose refresher may reach the internal
|
||||
/// addresses `fetch_internal` lists and no others.
|
||||
pub fn new(
|
||||
store: Store,
|
||||
tokens: Vec<Token>,
|
||||
@@ -288,6 +299,7 @@ pub async fn reloader(dir: Shared, path: std::path::PathBuf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every route: the API under `/v1`, behind a token, and the public pages.
|
||||
pub fn router(dir: Shared) -> Router {
|
||||
let v1 = Router::new()
|
||||
.route("/v1/me", get(me))
|
||||
|
||||
@@ -16,6 +16,7 @@ const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
||||
/// The most subscribers one instance may say a feed has.
|
||||
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
|
||||
|
||||
/// The directory's database: feeds, snapshots, blocks and when each sync ran.
|
||||
pub struct Store {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
@@ -30,11 +31,15 @@ pub struct Due {
|
||||
|
||||
/// What a fetch found, to be stored.
|
||||
pub enum Refreshed {
|
||||
/// Unchanged since the last fetch.
|
||||
NotModified,
|
||||
/// Read anew, with the validators to send next time.
|
||||
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
||||
/// The fetch failed, and why, in words.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Unix seconds now.
|
||||
#[must_use]
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
||||
@@ -184,6 +189,7 @@ impl Store {
|
||||
})
|
||||
}
|
||||
|
||||
/// Records when the sync `name` (Apple's charts) last ran.
|
||||
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
||||
@@ -193,6 +199,7 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the database answers, for the healthcheck.
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
self.rows("SELECT 1", vec![]).await?;
|
||||
Ok(())
|
||||
@@ -377,6 +384,7 @@ impl Store {
|
||||
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
|
||||
}
|
||||
|
||||
/// Each instance that has sent a snapshot, with how many feeds and people it counted.
|
||||
pub async fn instances(&self) -> Result<Vec<Instance>> {
|
||||
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
|
||||
self.rows(
|
||||
@@ -396,6 +404,7 @@ impl Store {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The feeds an admin has blocked.
|
||||
pub async fn blocked(&self) -> Result<Vec<Blocked>> {
|
||||
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
||||
.await?
|
||||
@@ -432,6 +441,7 @@ impl Store {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One feed, due or not, for an admin's Refresh.
|
||||
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||
.await?
|
||||
@@ -447,6 +457,7 @@ impl Store {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Stores what a fetch of feed `id` found, and when.
|
||||
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
||||
let t = now();
|
||||
match got {
|
||||
|
||||
@@ -24,10 +24,12 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
|
||||
|
||||
impl Allowed {
|
||||
/// Reads `fetch_internal`'s list, refusing an entry that is neither an address nor a network.
|
||||
pub fn parse(list: &[String]) -> Result<Self> {
|
||||
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
|
||||
}
|
||||
|
||||
/// Whether a connection to `ip` may go ahead: any public address, and the internal ones listed.
|
||||
#[must_use]
|
||||
pub fn permits(&self, ip: IpAddr) -> bool {
|
||||
let ip = canonical(ip);
|
||||
@@ -109,6 +111,7 @@ pub fn is_internal(ip: IpAddr) -> bool {
|
||||
pub struct Guard(Allowed);
|
||||
|
||||
impl Guard {
|
||||
/// A guard letting through what `allowed` lists, besides the public internet.
|
||||
#[must_use]
|
||||
pub fn new(allowed: Allowed) -> Self {
|
||||
Self(allowed)
|
||||
@@ -123,6 +126,7 @@ impl<S> tower::Layer<S> for Guard {
|
||||
}
|
||||
}
|
||||
|
||||
/// The connector `Guard` wraps, checking each connection it makes.
|
||||
#[derive(Clone)]
|
||||
pub struct Guarded<S> {
|
||||
inner: S,
|
||||
|
||||
@@ -7,10 +7,13 @@ use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
||||
|
||||
/// A feed as `parse` reads it, RSS or Atom alike: the channel's own details and its items.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ParsedFeed {
|
||||
pub title: Option<String>,
|
||||
/// RSS's `<ttl>`: how long the publisher asks to be left alone between reads.
|
||||
pub ttl_mins: Option<u64>,
|
||||
/// The show's artwork, `<itunes:image>` before the channel's `<image>`.
|
||||
pub image: Option<String>,
|
||||
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
||||
pub site: Option<String>,
|
||||
@@ -22,17 +25,22 @@ pub struct ParsedFeed {
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
/// One item of a feed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Entry {
|
||||
/// What tells this item from the feed's others: its `<guid>`, or failing that its link, its
|
||||
/// enclosure's address or its title (`pick_guid`).
|
||||
pub guid: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
/// Unix seconds.
|
||||
pub published: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
||||
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
||||
pub markdown: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
/// `<itunes:explicit>` said yes.
|
||||
pub explicit: bool,
|
||||
/// Episode artwork; falls back to the feed's in the UI.
|
||||
pub image: Option<String>,
|
||||
@@ -43,21 +51,22 @@ pub struct Entry {
|
||||
pub enclosures: Vec<Enclosure>,
|
||||
}
|
||||
|
||||
/// A file an item carries, an episode as a rule.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Enclosure {
|
||||
pub url: String,
|
||||
/// The type the feed declares, which may be missing or wrong.
|
||||
pub mime: Option<String>,
|
||||
/// The size the feed declares, in bytes.
|
||||
pub length: Option<i64>,
|
||||
}
|
||||
|
||||
/// What `fetch` got.
|
||||
pub enum Fetched {
|
||||
/// Server said 304, or returned a body we already have.
|
||||
NotModified,
|
||||
Body {
|
||||
bytes: Vec<u8>,
|
||||
etag: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
},
|
||||
/// A body to read, with the validators to send next time.
|
||||
Body { bytes: Vec<u8>, etag: Option<String>, last_modified: Option<String> },
|
||||
}
|
||||
|
||||
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
||||
@@ -76,6 +85,7 @@ pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15)
|
||||
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
|
||||
/// allocations.
|
||||
pub static FETCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// See `FETCHES`.
|
||||
pub static FETCHED_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
||||
@@ -135,6 +145,9 @@ async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reads a feed, asking only for what changed since `etag` and `last_modified`. Redirects are
|
||||
/// followed here, up to ten, so `auth` goes to the feed's own host alone. Also returns the new
|
||||
/// address when every hop said the feed moved for good, for the caller to keep instead.
|
||||
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
||||
pub async fn fetch(
|
||||
client: &reqwest::Client,
|
||||
@@ -258,7 +271,9 @@ fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
|
||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||
/// it is, and whether there is a new address to switch to.
|
||||
pub struct Failure {
|
||||
/// What to tell them, in a sentence.
|
||||
pub reason: &'static str,
|
||||
/// Where the feed went, when the error says.
|
||||
pub new_url: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1309,15 +1324,15 @@ const CATEGORIES: &[(&str, &[&str])] = &[
|
||||
const ALIASES: &[(&str, &str)] =
|
||||
&[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")];
|
||||
|
||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
||||
#[must_use]
|
||||
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
||||
CATEGORIES
|
||||
}
|
||||
|
||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||
#[must_use]
|
||||
pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
|
||||
|
||||
Reference in New Issue
Block a user