Compare commits
9 Commits
directory-
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d787bd26ec | |||
| 382f51a480 | |||
| 45c9f9252b | |||
| e4b505564c | |||
| 4cf568b263 | |||
| 724764c8df | |||
| cc155274e9 | |||
| 02bfa9571b | |||
| 1b137d6969 |
@@ -43,7 +43,7 @@ Start every check by seeing that all three run, since Loki cannot see a containe
|
|||||||
docker ps -a --filter name=iPX --filter name=ipx-web --filter name=ipx-directory --format '{{.Names}} {{.Status}}'
|
docker ps -a --filter name=iPX --filter name=ipx-web --filter name=ipx-directory --format '{{.Names}} {{.Status}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
All three should be `(healthy)`. Then work through ipx (the checks below), the page, the
|
All three should be `Up`, with no recent restart; they have no Docker healthcheck (#218). Then work through ipx (the checks below), the page, the
|
||||||
directory, Tower and the monitoring itself, in that order.
|
directory, Tower and the monitoring itself, in that order.
|
||||||
|
|
||||||
## ipx
|
## ipx
|
||||||
@@ -96,7 +96,8 @@ from 2026-10-11 15:15 UTC. All gauges:
|
|||||||
| `ipx_torrent_slots_free` | of 2 |
|
| `ipx_torrent_slots_free` | of 2 |
|
||||||
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
|
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
|
||||||
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
|
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
|
||||||
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets |
|
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets; mimalloc gives back what is freed, so memory follows use and a rise that never comes down is a leak (#224) |
|
||||||
|
| `ipx_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate |
|
||||||
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
|
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
|
||||||
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; the healthcheck passes while it is stuck |
|
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; the healthcheck passes while it is stuck |
|
||||||
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
|
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
|
||||||
@@ -108,6 +109,7 @@ from 2026-10-11 15:15 UTC. All gauges:
|
|||||||
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
|
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
|
||||||
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
|
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
|
||||||
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
|
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
|
||||||
|
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
||||||
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||||
|
|
||||||
### The checks
|
### The checks
|
||||||
@@ -226,8 +228,8 @@ a count says something happened, the lines and traces say why.
|
|||||||
|
|
||||||
Caddy logs one JSON line a request (#210), logger `http.log.access.log0`: Loki's `| json` gives
|
Caddy logs one JSON line a request (#210), logger `http.log.access.log0`: Loki's `| json` gives
|
||||||
`request_uri`, `request_method`, `request_remote_ip`, `status`, `duration` (seconds, not ms), `size`.
|
`request_uri`, `request_method`, `request_remote_ip`, `status`, `duration` (seconds, not ms), `size`.
|
||||||
The request's headers and a `?token=` are filtered out before it is written. Its healthcheck
|
The request's headers and a `?token=` are filtered out before it is written. Requests from
|
||||||
requests come from `127.0.0.1` every 30 s; leave them out. `/api` and `/media` pass on to ipx and
|
`127.0.0.1` are local checks; leave them out. `/api` and `/media` pass on to ipx and
|
||||||
are in ipx's own access log too, with ipx's time; Caddy's `duration` for them adds the hop.
|
are in ipx's own access log too, with ipx's time; Caddy's `duration` for them adds the hop.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -103,11 +103,13 @@ docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
|
|||||||
|
|
||||||
Run those again now and then, or the local copies go stale.
|
Run those again now and then, or the local copies go stale.
|
||||||
|
|
||||||
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
|
The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower
|
||||||
the daemon answers there and can read its database, not just that the web port is up. The socket
|
fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
|
||||||
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or
|
each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
|
||||||
download does not fail the check; it also means a worker stuck on one job would still pass. The
|
and `ipx status` against the control socket, which says the daemon answers and can read its
|
||||||
container restarts on its own after a reboot.
|
database. The socket answers `status` itself instead of queuing it behind the worker's current
|
||||||
|
job, so a worker stuck on one job still answers it. The containers restart on their own after a
|
||||||
|
reboot.
|
||||||
|
|
||||||
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||||
@@ -199,8 +201,7 @@ seeking through files, a flood of wrong passwords. Things about them:
|
|||||||
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
|
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
|
||||||
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
||||||
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
||||||
* `ipx status`, the Docker healthcheck, runs every second throughout, and a run fails if one takes
|
* `ipx status` runs every second throughout, and a run fails if one takes 5s.
|
||||||
the healthcheck's 5s.
|
|
||||||
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
|
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
|
||||||
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
||||||
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
||||||
|
|||||||
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1856,13 +1856,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
"ipx-feed",
|
"ipx-feed",
|
||||||
|
"mimalloc",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry-otlp",
|
"opentelemetry-otlp",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
@@ -2102,6 +2103,15 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libmimalloc-sys"
|
||||||
|
version = "0.1.49"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.23"
|
version = "0.1.23"
|
||||||
@@ -2541,6 +2551,15 @@ dependencies = [
|
|||||||
"rapidhash",
|
"rapidhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mimalloc"
|
||||||
|
version = "0.1.52"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||||
|
dependencies = [
|
||||||
|
"libmimalloc-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ opentelemetry = { version = "0.33", default-features = false, features = ["trace
|
|||||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
|
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
|
||||||
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
|
mimalloc = "0.1.52"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \
|
|||||||
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
|
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
|
||||||
|
|
||||||
COPY crates ./crates
|
COPY crates ./crates
|
||||||
|
# ipx builds the directory crate in, whose public pages serve these (#215).
|
||||||
|
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||||
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- With `metrics_bind` set, the directory serves Prometheus metrics on a port of its own: memory, open files, and the feeds it has fetched and their bytes. A feed check's trace says how large the feed was (#224).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The directory uses mimalloc instead of glibc's allocator, so it gives back memory it has freed instead of holding over a gigabyte while idle (#224).
|
||||||
|
- The example in docs/directory.md has no healthcheck; `ipx-directory health` is still there to run by hand (#218).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- A feed check's trace keeps the feed's address without its query or password (#225).
|
||||||
|
|
||||||
## [1.0.0] - 2026-10-12
|
## [1.0.0] - 2026-10-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -19,5 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
||||||
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...main
|
||||||
|
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -16,6 +16,7 @@ ipx-feed.workspace = true
|
|||||||
opentelemetry.workspace = true
|
opentelemetry.workspace = true
|
||||||
opentelemetry-otlp.workspace = true
|
opentelemetry-otlp.workspace = true
|
||||||
opentelemetry_sdk.workspace = true
|
opentelemetry_sdk.workspace = true
|
||||||
|
mimalloc.workspace = true
|
||||||
percent-encoding.workspace = true
|
percent-encoding.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
sea-orm.workspace = true
|
sea-orm.workspace = true
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub struct Config {
|
|||||||
/// The environment variable holding the database URL, so the file holds no secret.
|
/// The environment variable holding the database URL, so the file holds no secret.
|
||||||
#[serde(default = "default_database_url_env")]
|
#[serde(default = "default_database_url_env")]
|
||||||
pub database_url_env: String,
|
pub database_url_env: String,
|
||||||
|
/// Where to serve /metrics for Prometheus (#224), apart from `bind`, which the tunnel
|
||||||
|
/// publishes: `0.0.0.0:9188`. Empty serves none.
|
||||||
|
#[serde(default)]
|
||||||
|
pub metrics_bind: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tokens: Vec<TokenCfg>,
|
pub tokens: Vec<TokenCfg>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! people's behalf.
|
//! people's behalf.
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod metrics;
|
||||||
pub mod otel;
|
pub mod otel;
|
||||||
mod site;
|
mod site;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ enum Cmd {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: TokenCmd,
|
cmd: TokenCmd,
|
||||||
},
|
},
|
||||||
/// Check that the directory on this machine answers and can read its database: the
|
/// Check that the directory on this machine answers and can read its database.
|
||||||
/// container's healthcheck.
|
|
||||||
Health,
|
Health,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +82,7 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
|||||||
.with(json.then(|| tracing_subscriber::fmt::layer().json().flatten_event(true).with_writer(std::io::stderr).with_filter(filter())))
|
.with(json.then(|| tracing_subscriber::fmt::layer().json().flatten_event(true).with_writer(std::io::stderr).with_filter(filter())))
|
||||||
.with(otel.as_ref().map(|p| {
|
.with(otel.as_ref().map(|p| {
|
||||||
use opentelemetry::trace::TracerProvider;
|
use opentelemetry::trace::TracerProvider;
|
||||||
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info"))
|
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||||
}))
|
}))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
@@ -99,6 +98,11 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
|||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
let listener = tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
||||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
|
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
|
||||||
|
if !cfg.metrics_bind.is_empty() {
|
||||||
|
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
||||||
|
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
|
||||||
|
tokio::spawn(ipx_directory::metrics::serve(metrics));
|
||||||
|
}
|
||||||
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
||||||
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||||
axum::serve(listener, ipx_directory::router(dir))
|
axum::serve(listener, ipx_directory::router(dir))
|
||||||
|
|||||||
67
crates/directory/src/metrics.rs
Normal file
67
crates/directory/src/metrics.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//! Memory, for ipx and the directory both (#224), and the directory's own /metrics.
|
||||||
|
//!
|
||||||
|
//! mimalloc is the allocator of every binary that builds this crate in, ipx included: with
|
||||||
|
//! glibc's, ipx kept the 500-850 MB a night of scans had needed until it restarted, and the
|
||||||
|
//! directory sat at 1.25 GiB doing nothing, each thread's arena holding what it had freed.
|
||||||
|
//! mimalloc gives freed pages back, so resident memory follows what is in use: a spike that
|
||||||
|
//! does not come down again is a leak. Not jemalloc, whose upstream was archived in 2025.
|
||||||
|
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||||
|
|
||||||
|
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
||||||
|
pub fn process() -> (Option<f64>, Option<f64>) {
|
||||||
|
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
|
||||||
|
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||||
|
Some(kb * 1024.0)
|
||||||
|
});
|
||||||
|
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
||||||
|
(rss, fds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory's /metrics, in Prometheus's text format: memory, open files, and the feeds it
|
||||||
|
/// has fetched. Nothing from the database, so a scrape costs nothing and answers while it is down.
|
||||||
|
fn render(started: std::time::Instant) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut gauge = |name: &str, kind: &str, help: &str, labels: &str, v: f64| {
|
||||||
|
out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} {kind}\n{name}{labels} {v}\n"));
|
||||||
|
};
|
||||||
|
let (rss, fds) = process();
|
||||||
|
if let Some(v) = rss {
|
||||||
|
gauge("directory_process_resident_bytes", "gauge", "Memory the process holds.", "", v);
|
||||||
|
}
|
||||||
|
if let Some(v) = fds {
|
||||||
|
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
|
||||||
|
}
|
||||||
|
gauge("directory_feed_fetches_total", "counter", "Feeds fetched, answered 304 included.", "", ipx_feed::FETCHES.load(Relaxed) as f64);
|
||||||
|
gauge("directory_feed_bytes_total", "counter", "Bytes of feed bodies fetched.", "", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64);
|
||||||
|
gauge("directory_uptime_seconds", "gauge", "Seconds since the directory started.", "", started.elapsed().as_secs() as f64);
|
||||||
|
gauge("directory_build_info", "gauge", "The version running, as a label.", &format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")), 1.0);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves /metrics on a listener of its own: the public port is behind the tunnel with no
|
||||||
|
/// Access in front, and these are for the LAN.
|
||||||
|
pub async fn serve(listener: tokio::net::TcpListener) {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let app = axum::Router::new().route(
|
||||||
|
"/metrics",
|
||||||
|
axum::routing::get(move || async move {
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
tracing::error!(error = %e, "metrics stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn metrics_say_memory_and_feeds() {
|
||||||
|
let text = super::render(std::time::Instant::now());
|
||||||
|
assert!(text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,13 +61,36 @@ pub enum Fetched {
|
|||||||
/// downloads, which are not, have no such limit.
|
/// downloads, which are not, have no such limit.
|
||||||
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
|
||||||
|
/// 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);
|
||||||
|
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
|
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
||||||
/// each hop is seen. The second value is where the feed now is when every hop said so for good
|
/// each hop is seen. The second value is where the feed now is when every hop said so for good
|
||||||
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
|
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
|
||||||
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
|
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
|
||||||
/// `system-proxy` reads http_proxy/https_proxy.
|
/// `system-proxy` reads http_proxy/https_proxy.
|
||||||
/// `auth` is a username and password, sent only to the feed's own host.
|
/// `auth` is a username and password, sent only to the feed's own host.
|
||||||
#[tracing::instrument(skip_all, fields(url = %feed_url))]
|
/// An address as a trace may keep it: without its query or user and password, where a private
|
||||||
|
/// feed carries its credential (Patreon's `?auth=`), which Tempo kept for anyone reading it (#225).
|
||||||
|
/// shortcut: a token in the path (`/feed/<token>.xml`) stays; strip paths too if a feed does that.
|
||||||
|
pub fn redact(url: &str) -> String {
|
||||||
|
match url::Url::parse(url) {
|
||||||
|
Ok(mut u) => {
|
||||||
|
u.set_query(None);
|
||||||
|
u.set_fragment(None);
|
||||||
|
let _ = u.set_username("");
|
||||||
|
let _ = u.set_password(None);
|
||||||
|
u.to_string()
|
||||||
|
}
|
||||||
|
// Not an address ipx could fetch; keep what comes before anything that could be a query.
|
||||||
|
Err(_) => url.split(['?', '#', '@']).next().unwrap_or_default().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
feed_url: &str,
|
feed_url: &str,
|
||||||
@@ -106,6 +129,7 @@ pub async fn fetch(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let moved = (permanent && url != start).then(|| url.to_string());
|
let moved = (permanent && url != start).then(|| url.to_string());
|
||||||
|
FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
if status == StatusCode::NOT_MODIFIED {
|
if status == StatusCode::NOT_MODIFIED {
|
||||||
return Ok((Fetched::NotModified, moved));
|
return Ok((Fetched::NotModified, moved));
|
||||||
}
|
}
|
||||||
@@ -119,6 +143,8 @@ pub async fn fetch(
|
|||||||
let etag = header(ETAG);
|
let etag = header(ETAG);
|
||||||
let last_modified = header(LAST_MODIFIED);
|
let last_modified = header(LAST_MODIFIED);
|
||||||
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
||||||
|
tracing::Span::current().record("http.response.body.size", bytes.len());
|
||||||
|
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||||
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||||
}
|
}
|
||||||
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
||||||
@@ -364,6 +390,7 @@ fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
|
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
|
||||||
|
#[tracing::instrument(skip_all, fields(bytes = bytes.len()))]
|
||||||
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
||||||
match rss::Channel::read_from(bytes) {
|
match rss::Channel::read_from(bytes) {
|
||||||
Ok(ch) => Ok(from_rss(ch, bytes)),
|
Ok(ch) => Ok(from_rss(ch, bytes)),
|
||||||
@@ -1163,6 +1190,14 @@ const PAID_HOSTS: &[&str] =
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn redact_drops_what_carries_a_credential() {
|
||||||
|
assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x");
|
||||||
|
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
|
||||||
|
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
|
||||||
|
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_address_carrying_someones_access_looks_private() {
|
fn an_address_carrying_someones_access_looks_private() {
|
||||||
let private = super::url_looks_private;
|
let private = super::url_looks_private;
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `/metrics` counts the feeds fetched and the bytes of their bodies (`ipx_feed_fetches_total`, `ipx_feed_bytes_total`), and a feed's trace says how large its body was (#224).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ipx uses mimalloc instead of glibc's allocator, which gives freed memory back to the system: with glibc's, the memory a night of scans needed stayed held until a restart (#224).
|
||||||
|
- `docker-compose.yml` has no healthchecks: Docker runs them with exec, which can fail on the host while ipx is well. The library's counts in `/metrics` are kept every 30 s by the daemon itself (#218).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Traces no longer keep a feed's or file's address with its query or password, where private feeds carry their credentials, such as Patreon's `auth` (#225).
|
||||||
|
|
||||||
## [1.0.0] - 2026-10-12
|
## [1.0.0] - 2026-10-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -801,7 +816,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...main
|
||||||
|
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
||||||
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
|
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
|
||||||
[0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0
|
[0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ fn log_event(e: &Event, wire: bool) {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// The healthcheck's answer, every 30s: a reply on the socket rather than work done, so it
|
// `ipx status`'s answer: a reply on the socket rather than work done, so it
|
||||||
// stays with the rest of the conversation, and the Scans tab stays about scans.
|
// stays with the rest of the conversation, and the Scans tab stays about scans.
|
||||||
if matches!(e, Event::Status { .. }) {
|
if matches!(e, Event::Status { .. }) {
|
||||||
return line!(info, "ipx::io", format!("<- {json}"));
|
return line!(info, "ipx::io", format!("<- {json}"));
|
||||||
|
|||||||
@@ -254,8 +254,8 @@ impl Ctx {
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
// The daemon only: `ipx status` runs every half minute as the healthcheck, and a trace
|
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
|
||||||
// for each would bury the ones worth looking at.
|
// looking at.
|
||||||
let otel = match cli.command {
|
let otel = match cli.command {
|
||||||
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
|
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -299,7 +299,7 @@ async fn main() -> Result<()> {
|
|||||||
use opentelemetry::trace::TracerProvider;
|
use opentelemetry::trace::TracerProvider;
|
||||||
tracing_opentelemetry::layer()
|
tracing_opentelemetry::layer()
|
||||||
.with_tracer(p.tracer("ipx"))
|
.with_tracer(p.tracer("ipx"))
|
||||||
.with_filter(tracing_subscriber::EnvFilter::new("ipx=info"))
|
.with_filter(tracing_subscriber::EnvFilter::new("ipx=info,ipx_feed=info"))
|
||||||
}))
|
}))
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
@@ -568,7 +568,7 @@ pub(crate) async fn status(ctx: &Ctx) -> Event {
|
|||||||
match ctx.db.counts().await {
|
match ctx.db.counts().await {
|
||||||
Ok((pending, downloaded)) => {
|
Ok((pending, downloaded)) => {
|
||||||
let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0);
|
let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0);
|
||||||
// Kept for /metrics, which asks the database nothing (#195): the healthcheck asks this every 30 s.
|
// Kept for /metrics, which asks the database nothing (#195); the daemon counts every 30 s.
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
let st = &metrics::STATS;
|
let st = &metrics::STATS;
|
||||||
st.feeds.store(feeds as i64, Relaxed);
|
st.feeds.store(feeds as i64, Relaxed);
|
||||||
@@ -663,6 +663,21 @@ async fn daemon(
|
|||||||
// must not wait behind it.
|
// must not wait behind it.
|
||||||
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||||
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
|
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
|
||||||
|
// The library's counts for /metrics. The Docker healthcheck asked for them every 30 s until
|
||||||
|
// it was removed: Docker runs it with exec, which failed on Tower whenever root was logged
|
||||||
|
// out (#218).
|
||||||
|
{
|
||||||
|
let (ctx, mut stop) = (ctx.clone(), rx_stop.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut every = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = every.tick() => { status(&ctx).await; }
|
||||||
|
_ = stop.changed() => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
||||||
async fn until_stopped(
|
async fn until_stopped(
|
||||||
@@ -2124,7 +2139,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
|||||||
|
|
||||||
/// Downloads one file and records it. None when it turned out to be a torrent, handed off to run
|
/// Downloads one file and records it. None when it turned out to be a torrent, handed off to run
|
||||||
/// on its own, which records it when it finishes.
|
/// on its own, which records it when it finishes.
|
||||||
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = url))]
|
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = %feed::redact(url)))]
|
||||||
async fn fetch_one(
|
async fn fetch_one(
|
||||||
ctx: &Arc<Ctx>,
|
ctx: &Arc<Ctx>,
|
||||||
feed_id: &str,
|
feed_id: &str,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ pub struct Stats {
|
|||||||
/// Artwork served from disk, and fetched from the publisher because it was not there.
|
/// Artwork served from disk, and fetched from the publisher because it was not there.
|
||||||
pub art_hits: AtomicU64,
|
pub art_hits: AtomicU64,
|
||||||
pub art_misses: AtomicU64,
|
pub art_misses: AtomicU64,
|
||||||
/// The library as the healthcheck last counted it, every 30 s; -1 until it has.
|
/// The library as the daemon last counted it, every 30 s; -1 until it has.
|
||||||
pub feeds: AtomicI64,
|
pub feeds: AtomicI64,
|
||||||
pub pending: AtomicI64,
|
pub pending: AtomicI64,
|
||||||
pub downloaded: AtomicI64,
|
pub downloaded: AtomicI64,
|
||||||
@@ -169,13 +169,15 @@ pub fn render(s: &Sources) -> String {
|
|||||||
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
|
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
|
||||||
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
|
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
|
||||||
gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]);
|
gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]);
|
||||||
let (rss, fds) = process();
|
let (rss, fds) = ipx_directory::metrics::process();
|
||||||
if let Some(v) = rss {
|
if let Some(v) = rss {
|
||||||
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
||||||
}
|
}
|
||||||
if let Some(v) = fds {
|
if let Some(v) = fds {
|
||||||
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
|
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
|
||||||
}
|
}
|
||||||
|
counter(&mut out, "ipx_feed_fetches_total", "Feeds fetched, answered 304 included.", &[("", ipx_feed::FETCHES.load(Relaxed) as f64)]);
|
||||||
|
counter(&mut out, "ipx_feed_bytes_total", "Bytes of feed bodies fetched.", &[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)]);
|
||||||
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
|
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
|
||||||
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
||||||
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
||||||
@@ -218,9 +220,9 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
|||||||
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
|
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
|
||||||
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
|
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
|
||||||
for (name, help, v) in [
|
for (name, help, v) in [
|
||||||
("ipx_feeds", "Feeds in the catalogue, as the healthcheck last counted.", &st.feeds),
|
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
|
||||||
("ipx_downloads_pending", "Files a scan will download on its own, as the healthcheck last counted.", &st.pending),
|
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
|
||||||
("ipx_files_downloaded", "Files on disk, as the healthcheck last counted.", &st.downloaded),
|
("ipx_files_downloaded", "Files on disk, as last counted.", &st.downloaded),
|
||||||
] {
|
] {
|
||||||
let v = v.load(Relaxed);
|
let v = v.load(Relaxed);
|
||||||
if v >= 0 {
|
if v >= 0 {
|
||||||
@@ -248,16 +250,6 @@ fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
|
||||||
pub fn process() -> (Option<f64>, Option<f64>) {
|
|
||||||
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
|
|
||||||
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
|
||||||
Some(kb * 1024.0)
|
|
||||||
});
|
|
||||||
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
|
||||||
(rss, fds)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -293,13 +285,13 @@ mod tests {
|
|||||||
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
|
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
|
||||||
assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}");
|
assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}");
|
||||||
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
|
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
|
||||||
// The library's counts are left out until the healthcheck has counted it.
|
// The library's counts are left out until the daemon has counted it.
|
||||||
assert!(!out.contains("ipx_feeds "));
|
assert!(!out.contains("ipx_feeds "));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_process_reads_its_memory_and_files_on_linux() {
|
fn the_process_reads_its_memory_and_files_on_linux() {
|
||||||
let (rss, fds) = process();
|
let (rss, fds) = ipx_directory::metrics::process();
|
||||||
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
|
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ services:
|
|||||||
- ./config:/config # config.toml, written on first start
|
- ./config:/config # config.toml, written on first start
|
||||||
- ./data:/data # state.db
|
- ./data:/data # state.db
|
||||||
- ./downloads:/downloads
|
- ./downloads:/downloads
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "ipx", "status"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
ipx-web:
|
ipx-web:
|
||||||
image: ipx-web
|
image: ipx-web
|
||||||
@@ -47,8 +41,3 @@ services:
|
|||||||
- "8099:8080" # the web UI
|
- "8099:8080" # the web UI
|
||||||
depends_on:
|
depends_on:
|
||||||
- ipx
|
- ipx
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/favicon.png"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ with one name) leaves the tokens as they were, and the log says why.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
bind = "0.0.0.0:8100"
|
bind = "0.0.0.0:8100"
|
||||||
|
# Prometheus metrics, on a port of their own and not the one the tunnel publishes. Empty, or
|
||||||
|
# left out, serves none.
|
||||||
|
metrics_bind = "0.0.0.0:9188"
|
||||||
# The environment variable holding the database URL: postgres://... or sqlite://...
|
# The environment variable holding the database URL: postgres://... or sqlite://...
|
||||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||||
|
|
||||||
@@ -110,8 +113,6 @@ services:
|
|||||||
- "8100:8100"
|
- "8100:8100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./directory:/config:ro # directory.toml
|
- ./directory:/config:ro # directory.toml
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "ipx-directory", "health"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## What it will not hold
|
## What it will not hold
|
||||||
|
|||||||
@@ -295,6 +295,13 @@ table("Failing feeds in the directory", [loki(f'sum by (feed, error) (count_over
|
|||||||
table("Snapshots by server", [loki(f'sum by (instance) (count_over_time({DIR} | message="snapshot" [$__range]))', instant=True)],
|
table("Snapshots by server", [loki(f'sum by (instance) (count_over_time({DIR} | message="snapshot" [$__range]))', instant=True)],
|
||||||
12, rename={"instance": "Token", "Value": "Snapshots"}, sort=[{"displayName": "Snapshots", "desc": True}])
|
12, rename={"instance": "Token", "Value": "Snapshots"}, sort=[{"displayName": "Snapshots", "desc": True}])
|
||||||
y += 8
|
y += 8
|
||||||
|
# The directory's own /metrics, on a port apart from the tunnel's (#224).
|
||||||
|
ts("Directory memory", [prom('directory_process_resident_bytes{job="ipx-directory"}', legend="resident")], 0, unit="bytes",
|
||||||
|
desc="mimalloc gives back what is freed, so this follows what is in use: a rise that never comes down is a leak.")
|
||||||
|
ts("Feed bytes fetched", [prom('rate(ipx_feed_bytes_total{job="ipx"}[$__rate_interval])', legend="ipx"),
|
||||||
|
prom('rate(directory_feed_bytes_total{job="ipx-directory"}[$__rate_interval])', ref="B", legend="directory")],
|
||||||
|
12, unit="Bps", desc="Feeds are read whole, so this is what scans and checks allocate; set it beside memory.")
|
||||||
|
y += 8
|
||||||
|
|
||||||
# ---- Traces: all three send them to Tempo (#213)
|
# ---- Traces: all three send them to Tempo (#213)
|
||||||
row("Traces")
|
row("Traces")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
655
site/api.mjs
Normal file
655
site/api.mjs
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
// The API reference's content: each service's endpoints, grouped, and the types they take and
|
||||||
|
// give, as the handlers in crates/ipx/src/web.rs and crates/directory/src/lib.rs have them. A
|
||||||
|
// change to a route or a field there wants its line here; gen.mjs turns this into pages.
|
||||||
|
// Types are written as Rust writes them, the way docs.rs shows a signature; a name in TYPES links
|
||||||
|
// to its page.
|
||||||
|
|
||||||
|
const U = 'http://<your server>:8099';
|
||||||
|
const D = 'https://directory.ipxreader.app';
|
||||||
|
const ipxCurl = (m, p, body) =>
|
||||||
|
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer ipx_…"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${U}${p}"`;
|
||||||
|
const dirCurl = (m, p, body) =>
|
||||||
|
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer <token>"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${D}${p}"`;
|
||||||
|
|
||||||
|
const NOT_SUBSCRIBED = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.'];
|
||||||
|
const NOT_ADMIN = ['403 Forbidden', 'You are not an admin.'];
|
||||||
|
const NO_DIRECTORY = ['503 Service Unavailable', 'No directory is set up on this server.'];
|
||||||
|
|
||||||
|
export const ipx = {
|
||||||
|
name: 'ipx',
|
||||||
|
root: '/docs/api/ipx',
|
||||||
|
changelog: 'crates/ipx/CHANGELOG.md',
|
||||||
|
intro: `<p>The HTTP API of an iPX server: everything the page does, a script can do. Every request
|
||||||
|
and answer is JSON unless an item says otherwise. The page is served by ipx-web, which passes
|
||||||
|
<code>/api</code> and <code>/media</code> on to ipx at the same address, so a script talks to
|
||||||
|
the address people open in a browser.</p>
|
||||||
|
<h2 id="auth">Signing in</h2>
|
||||||
|
<p>Send an API token, made in Settings under API tokens or with <a href="${'/docs/api/ipx/auth/post-tokens'}"><code>POST /api/tokens</code></a>:</p>
|
||||||
|
<pre><code>Authorization: Bearer ipx_…</code></pre>
|
||||||
|
<p>A token acts as the person who made it, and is an admin only if they are. A browser signs in
|
||||||
|
with <a href="/docs/api/ipx/auth/post-login"><code>POST /api/login</code></a> instead and is
|
||||||
|
given a cookie. Behind Cloudflare Access, Access stops a request with no Access sign-in before
|
||||||
|
iPX sees it: send an Access service token's <code>CF-Access-Client-Id</code> and
|
||||||
|
<code>CF-Access-Client-Secret</code> beside the API token.</p>
|
||||||
|
<h2 id="errors">Errors</h2>
|
||||||
|
<p>A request that fails answers with its status and a sentence saying why, as plain text:
|
||||||
|
<code>401</code> not signed in, <code>403</code> an admin's to do, <code>404</code> not there or
|
||||||
|
not yours, <code>400</code> a request that cannot be done as asked, <code>409</code> a conflict
|
||||||
|
the answer explains.</p>
|
||||||
|
<h2 id="ids">Ids</h2>
|
||||||
|
<p>A feed's id is a short name made from its title, such as <code>99-invisible</code>, and
|
||||||
|
appears in the page's address when the feed is open. An episode is its feed's id and its
|
||||||
|
<code>guid</code>, the guid URL-encoded in a path. A file (an enclosure) has a number. Times are
|
||||||
|
Unix seconds.</p>`,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'auth', name: 'auth', title: 'Signing in and tokens',
|
||||||
|
summary: 'Who you are, signing in and out, and API tokens.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/login', slug: 'post-login', access: 'none',
|
||||||
|
summary: 'Sign in with a name and password, for a session cookie.',
|
||||||
|
doc: '<p>Answers who signed in and sets the session cookie, which lasts <code>session_days</code> from the last request. A script is better off with an API token, which needs no password kept.</p>',
|
||||||
|
body: 'Credentials', returns: 'Me',
|
||||||
|
errors: [['401 Unauthorized', 'The name or password is wrong.']],
|
||||||
|
example: `curl -c cookies -H "Content-Type: application/json" \\\n -d '{"name":"sam","password":"…"}' "${U}/api/login"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/logout', slug: 'post-logout',
|
||||||
|
summary: 'Sign out: end the session and clear its cookies.',
|
||||||
|
returns: '204 No Content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/me', slug: 'get-me',
|
||||||
|
summary: 'Who you are, your words that hide items, and the server’s version.',
|
||||||
|
returns: 'Me',
|
||||||
|
example: ipxCurl('GET', '/api/me'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH', path: '/api/me', slug: 'patch-me',
|
||||||
|
summary: 'Change the words that hide an item in every feed you read.',
|
||||||
|
body: 'MePatch', returns: '204 No Content',
|
||||||
|
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']],
|
||||||
|
example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/tokens', slug: 'get-tokens',
|
||||||
|
summary: 'Your API tokens: names and when each was made and last used, never the token.',
|
||||||
|
returns: 'Vec<Token>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/tokens', slug: 'post-tokens',
|
||||||
|
summary: 'Make an API token that acts as you.',
|
||||||
|
doc: '<p>The answer is the only time the token is shown: only its SHA-256 is kept.</p>',
|
||||||
|
body: 'NewToken', returns: 'NewTokenMade',
|
||||||
|
errors: [['400 Bad Request', 'No name.']],
|
||||||
|
example: ipxCurl('POST', '/api/tokens', '{"name":"my script"}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/tokens/{id}', slug: 'delete-token',
|
||||||
|
summary: 'Revoke one of your API tokens.',
|
||||||
|
pathParams: [['id', 'i64', 'The token’s id, from <code>GET /api/tokens</code>.']],
|
||||||
|
returns: '204 No Content',
|
||||||
|
errors: [['404 Not Found', 'No token of yours has that id.']],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'feeds', name: 'feeds', title: 'Feeds',
|
||||||
|
summary: 'Your subscriptions: list, subscribe, change, unsubscribe, check, OPML.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/feeds', slug: 'get-feeds',
|
||||||
|
summary: 'Your subscriptions, each with its counts and your settings for it.',
|
||||||
|
returns: 'Vec<FeedRow>',
|
||||||
|
example: ipxCurl('GET', '/api/feeds'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/feeds', slug: 'post-feeds',
|
||||||
|
summary: 'Subscribe to a feed, by its address or a site that links one.',
|
||||||
|
doc: '<p>A feed someone else on the server already has costs nothing to add: it is fetched once and each file is kept once. An OPML address subscribes to the list, kept in step as a folder.</p>',
|
||||||
|
body: 'NewFeed', returns: 'Added',
|
||||||
|
errors: [['400 Bad Request', 'The address is not a feed and links none; the answer says what was found.']],
|
||||||
|
example: ipxCurl('POST', '/api/feeds', '{"url":"https://feeds.99percentinvisible.org/99percentinvisible"}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed',
|
||||||
|
summary: 'Change your settings for a feed, or (an admin) the feed’s own.',
|
||||||
|
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir and pinning are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
|
body: 'FeedPatch', returns: '204 No Content',
|
||||||
|
errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED],
|
||||||
|
example: ipxCurl('PATCH', '/api/feeds/99-invisible', '{"keywords":["mini-stories"],"pinned":true}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/feeds/{id}', slug: 'delete-feed',
|
||||||
|
summary: 'Unsubscribe. The files stay for anyone else who reads the feed.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
|
returns: '204 No Content', errors: [NOT_SUBSCRIBED],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/feeds/{id}/read-all', slug: 'post-read-all',
|
||||||
|
summary: 'Mark every episode of a feed read, and of every feed in it for an OPML list.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
|
returns: 'Marked',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/feeds/{id}/download-latest', slug: 'post-download-latest',
|
||||||
|
summary: 'Download a feed’s newest episodes that are not downloaded yet.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
|
body: 'HowMany', returns: 'Queued', errors: [NOT_SUBSCRIBED],
|
||||||
|
example: ipxCurl('POST', '/api/feeds/99-invisible/download-latest', '{"count":3}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/fetch', slug: 'post-fetch',
|
||||||
|
summary: 'Check one feed, or all of yours, for new episodes now.',
|
||||||
|
doc: '<p>The check runs in the background; <a href="/docs/api/ipx/server/get-events"><code>GET /api/events</code></a> shows it as it goes.</p>',
|
||||||
|
body: 'FetchBody', returns: '202 Accepted',
|
||||||
|
example: ipxCurl('POST', '/api/fetch', '{"feed":"99-invisible","force":true}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/opml', slug: 'get-opml',
|
||||||
|
summary: 'Your subscriptions as an OPML file, for another podcast app.',
|
||||||
|
returns: 'OPML (text/x-opml)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/opml', slug: 'post-opml',
|
||||||
|
summary: 'Subscribe to every feed in an OPML file.',
|
||||||
|
body: 'OpmlBody', returns: 'Imported',
|
||||||
|
errors: [['400 Bad Request', 'Not OPML.']],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entries', name: 'entries', title: 'Episodes',
|
||||||
|
summary: 'Episodes and posts: list, search, mark read or kept, save where you are.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries',
|
||||||
|
summary: 'A page of one feed’s episodes, newest first unless sorted.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
|
query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED],
|
||||||
|
example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/entries', slug: 'get-entries',
|
||||||
|
summary: 'The same, across every feed you subscribe to.',
|
||||||
|
query: 'Page', returns: 'EntryPage',
|
||||||
|
example: ipxCurl('GET', '/api/entries?q=bridges&sort=published&dir=desc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/entries/{feed}/{guid}', slug: 'get-entry',
|
||||||
|
summary: 'One episode.',
|
||||||
|
pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']],
|
||||||
|
returns: 'EntryRow', errors: [NOT_SUBSCRIBED],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/entries/{feed}/{guid}/flags', slug: 'post-flags',
|
||||||
|
summary: 'Mark an episode read or unread, kept or not.',
|
||||||
|
doc: '<p>Answers with the feed’s row, its counts changed, so a list can redraw one line.</p>',
|
||||||
|
pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']],
|
||||||
|
body: 'Flags', returns: 'Option<FeedRow>', errors: [NOT_SUBSCRIBED],
|
||||||
|
example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/flags', '{"read":true}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/entries/{feed}/{guid}/position', slug: 'post-position',
|
||||||
|
summary: 'Save where you are in an episode, for every device you listen on.',
|
||||||
|
pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']],
|
||||||
|
body: 'Position', returns: '204 No Content', errors: [NOT_SUBSCRIBED],
|
||||||
|
example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/position', '{"secs":1234}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/read-all', slug: 'post-read-all-mine',
|
||||||
|
summary: 'Mark every episode of every feed you subscribe to read.',
|
||||||
|
returns: 'Marked',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'files', name: 'files', title: 'Files',
|
||||||
|
summary: 'Downloading, deleting and playing an episode’s file.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/enclosures/{id}/download', slug: 'post-download',
|
||||||
|
summary: 'Download a file now.',
|
||||||
|
pathParams: [['id', 'i64', 'The file’s id, an <code>EncRow</code>’s <code>id</code>.']],
|
||||||
|
returns: '202 Accepted, or 204 No Content when it is already here', errors: [NOT_SUBSCRIBED],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/enclosures/{id}', slug: 'delete-file',
|
||||||
|
summary: 'Delete a downloaded file. It is not downloaded again.',
|
||||||
|
doc: '<p>There is one copy of each file, so deleting it deletes it for everyone. If someone else has kept the episode or not played it yet, the first try is refused with who: send it again with <code>?force=true</code> to delete it anyway.</p>',
|
||||||
|
pathParams: [['id', 'i64', 'The file’s id.']],
|
||||||
|
query: 'Force', returns: '204 No Content',
|
||||||
|
errors: [['409 Conflict', 'Someone else still wants it; the answer says who. Send <code>force=true</code> to go ahead.'], NOT_SUBSCRIBED],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/media/{id}', slug: 'get-media',
|
||||||
|
summary: 'The file itself, with <code>Range</code> requests, so a player can seek.',
|
||||||
|
pathParams: [['id', 'i64', 'The file’s id.']],
|
||||||
|
returns: 'The file, with its own Content-Type', errors: [NOT_SUBSCRIBED],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/media/{id}/deadair.m3u8', slug: 'get-deadair',
|
||||||
|
summary: 'The episode with its long silences shortened by DeadAir, as an HLS playlist.',
|
||||||
|
doc: '<p>Only once DeadAir has looked at the file: an <code>EncRow</code> with <code>deadair</code> set says how many seconds it saves. <code>/media/{id}/deadair</code> gives the shortened episode as one file to save.</p>',
|
||||||
|
pathParams: [['id', 'i64', 'The file’s id.']],
|
||||||
|
returns: 'An HLS playlist (application/vnd.apple.mpegurl)', errors: [NOT_SUBSCRIBED],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'directory', name: 'directory', title: 'The directory, through iPX',
|
||||||
|
summary: 'Browse the directory and subscribe from it; iPX asks it on your behalf.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/popular', slug: 'get-popular',
|
||||||
|
summary: 'The directory’s ten most subscribed feeds.',
|
||||||
|
returns: 'Vec<PopularRow>', errors: [NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/directory', slug: 'get-directory',
|
||||||
|
summary: 'Every feed the directory lists, A to Z.',
|
||||||
|
returns: 'Vec<PopularRow>', errors: [NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/directory/{id}', slug: 'get-listed',
|
||||||
|
summary: 'A feed’s page in the directory: what it says it is and its latest items.',
|
||||||
|
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
|
||||||
|
returns: 'Listed', errors: [['404 Not Found', 'Not in the directory.'], NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/popular/{id}', slug: 'post-popular',
|
||||||
|
summary: 'Subscribe to a feed from the directory, by the directory’s id.',
|
||||||
|
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
|
||||||
|
returns: 'Subscribed', errors: [['400 Bad Request', 'Not in the directory.'], NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'server', name: 'server', title: 'The server',
|
||||||
|
summary: 'Its counts and version, and live updates.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/status', slug: 'get-status',
|
||||||
|
summary: 'How many feeds, files waiting and files downloaded, and the version.',
|
||||||
|
doc: '<p>The numbers <code>ipx status</code> prints, for a dashboard.</p>',
|
||||||
|
returns: 'Status', example: ipxCurl('GET', '/api/status'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/events', slug: 'get-events',
|
||||||
|
summary: 'A live stream of checks and downloads as they happen (server-sent events).',
|
||||||
|
doc: '<p>Each message’s data is one JSON <a href="/docs/api/ipx/types/Event"><code>Event</code></a>. Every event of a check reaches everyone; a feed’s new row (<code>feed_row</code>) only those who subscribe to it.</p>',
|
||||||
|
returns: 'text/event-stream of Event',
|
||||||
|
example: `curl -N -H "Authorization: Bearer ipx_…" "${U}/api/events"\ndata: {"ev":"feed_start","feed":"99-invisible"}\ndata: {"ev":"feed_done","feed":"99-invisible","new":1,"downloaded":1,"failed":0,"torrents":0}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin', name: 'admin', title: 'Admin',
|
||||||
|
summary: 'Server settings, accounts, the log and the directory’s administration. Admins only.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/settings', slug: 'get-settings', access: 'signed in',
|
||||||
|
summary: 'The server’s settings. Anyone signed in may read them.',
|
||||||
|
returns: 'Settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH', path: '/api/settings', slug: 'patch-settings', access: 'admin',
|
||||||
|
summary: 'Change the server’s settings. They take effect at once.',
|
||||||
|
body: 'SettingsPatch', returns: '204 No Content',
|
||||||
|
errors: [['400 Bad Request', 'A value that does not parse, such as a schedule.'], NOT_ADMIN],
|
||||||
|
example: ipxCurl('PATCH', '/api/settings', '{"schedule":"every 2h","max_total_gb":200}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/users', slug: 'get-users', access: 'admin',
|
||||||
|
summary: 'Every account.', returns: 'Vec<UserRow>', errors: [NOT_ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/users', slug: 'post-users', access: 'admin',
|
||||||
|
summary: 'Add an account.',
|
||||||
|
doc: '<p>With no password, the account signs in only through single sign-on.</p>',
|
||||||
|
body: 'NewUser', returns: '201 Created',
|
||||||
|
errors: [['400 Bad Request', 'No name, or one that exists already.'], NOT_ADMIN],
|
||||||
|
example: ipxCurl('POST', '/api/users', '{"name":"sam","password":"…"}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH', path: '/api/users/{id}', slug: 'patch-user', access: 'admin',
|
||||||
|
summary: 'Make an account an admin, or not.',
|
||||||
|
pathParams: [['id', 'i64', 'The account’s id.']],
|
||||||
|
body: 'UserPatch', returns: '204 No Content',
|
||||||
|
errors: [['400 Bad Request', 'No such account, or the only admin, who cannot stop being one.'], NOT_ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/users/{id}', slug: 'delete-user', access: 'admin',
|
||||||
|
summary: 'Remove an account, with its subscriptions and read state. Files stay.',
|
||||||
|
pathParams: [['id', 'i64', 'The account’s id.']],
|
||||||
|
returns: '204 No Content',
|
||||||
|
errors: [['400 Bad Request', 'No such account, or the only admin, who cannot be removed.'], NOT_ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/logs', slug: 'get-logs', access: 'admin',
|
||||||
|
summary: 'The newest lines of the server’s log, or those after a given one.',
|
||||||
|
query: 'LogQuery', returns: 'LogPage', errors: [NOT_ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/directory-status', slug: 'get-directory-status', access: 'admin',
|
||||||
|
summary: 'Who the directory takes this server’s token to be, and how the last share went.',
|
||||||
|
returns: 'DirectoryStatus', errors: [NOT_ADMIN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/directory/{id}', slug: 'delete-listed', access: 'admin',
|
||||||
|
summary: 'Remove a feed from the directory and keep it out.',
|
||||||
|
doc: '<p>Only when this server’s directory token is <code>rwa</code>; the directory refuses otherwise.</p>',
|
||||||
|
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
|
||||||
|
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/directory/{id}/refresh', slug: 'post-listed-refresh', access: 'admin',
|
||||||
|
summary: 'Have the directory check a feed now. Needs an <code>rwa</code> token.',
|
||||||
|
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
|
||||||
|
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/directory-admin', slug: 'get-directory-admin', access: 'admin',
|
||||||
|
summary: 'The servers sharing with the directory, and the feeds it keeps out. Needs <code>rwa</code>.',
|
||||||
|
returns: 'DirectoryAdmin', errors: [NOT_ADMIN, NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/directory-blocked/{id}', slug: 'delete-directory-blocked', access: 'admin',
|
||||||
|
summary: 'Let a feed the directory keeps out back in. Needs <code>rwa</code>.',
|
||||||
|
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
|
||||||
|
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/directory-instances/{name}', slug: 'delete-directory-instance', access: 'admin',
|
||||||
|
summary: 'Drop a server’s share from the directory until it next shares. Needs <code>rwa</code>.',
|
||||||
|
pathParams: [['name', 'String', 'The server’s token name.']],
|
||||||
|
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
types: [
|
||||||
|
{ name: 'Credentials', doc: 'A name and password.', fields: [['name', 'String', ''], ['password', 'String', '']] },
|
||||||
|
{ name: 'Me', doc: 'Who is signed in.', fields: [
|
||||||
|
['name', 'String', 'The account’s name, often an email address.'],
|
||||||
|
['admin', 'bool', ''],
|
||||||
|
['blocked', 'Vec<String>', 'Words that hide an item in every feed you read. <code>GET /api/me</code> only, as are the fields below.'],
|
||||||
|
['sign_out', 'Option<String>', 'Where signing out should go, for someone single sign-on signed in.'],
|
||||||
|
['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'],
|
||||||
|
['version', 'String', 'ipx’s version.'],
|
||||||
|
] },
|
||||||
|
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.']] },
|
||||||
|
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
|
||||||
|
{ name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] },
|
||||||
|
{ name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
|
||||||
|
{ name: 'FeedRow', doc: 'A feed as you see it: the feed, your settings for it and its counts for you.', fields: [
|
||||||
|
['id', 'String', ''], ['url', 'String', ''], ['title', 'Option<String>', ''], ['image', 'Option<String>', ''],
|
||||||
|
['folder', 'Option<String>', ''],
|
||||||
|
['category', 'Option<String>', 'The directory category an admin gave it.'],
|
||||||
|
['feed_category', 'Option<String>', 'The one the feed names itself, which wins.'],
|
||||||
|
['keywords', 'Vec<String>', 'Your words: with any, only episodes that mention one are downloaded.'],
|
||||||
|
['blocked', 'Vec<String>', 'Your words that hide an episode of this feed.'],
|
||||||
|
['deadair', 'bool', 'Played with its silences shortened.'],
|
||||||
|
['deadair_pause', 'i64', 'The pause DeadAir leaves where it cuts, in milliseconds.'],
|
||||||
|
['allow_explicit', 'bool', ''], ['auto_download', 'bool', ''],
|
||||||
|
['max_new_per_check', 'Option<usize>', 'Your own limit, or none for the server’s.'],
|
||||||
|
['group', 'Option<String>', 'The OPML list it came from.'],
|
||||||
|
['orphaned', 'bool', 'In a list that no longer has it, kept for its downloads.'],
|
||||||
|
['managed', 'bool', ''],
|
||||||
|
['schedule', 'Option<String>', 'Its own schedule, such as <code>every 2h</code>.'],
|
||||||
|
['schedule_mins', 'Option<u64>', 'The same in minutes.'],
|
||||||
|
['every_mins', 'u64', 'How often it is checked, all things considered.'],
|
||||||
|
['last_checked', 'Option<i64>', ''], ['next_check', 'Option<i64>', ''],
|
||||||
|
['last_error', 'Option<String>', ''],
|
||||||
|
['failing', 'Option<FailingRow>', 'Set once an error has held for a day.'],
|
||||||
|
['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''],
|
||||||
|
['subscribers', 'i64', 'People on this server, you included.'],
|
||||||
|
['pinned', 'bool', 'At the top of your list.'],
|
||||||
|
['private', 'bool', 'Kept out of the directory.'],
|
||||||
|
['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'],
|
||||||
|
] },
|
||||||
|
{ name: 'FailingRow', doc: 'Why a feed has been failing.', fields: [['reason', 'String', 'In words.'], ['new_url', 'Option<String>', 'Where it seems to have moved.']] },
|
||||||
|
{ name: 'NewFeed', doc: 'A feed to subscribe to.', fields: [
|
||||||
|
['url', 'String', 'A feed’s address, an OPML list’s, or a site’s page that links its feed.'],
|
||||||
|
['folder', 'Option<String>', ''],
|
||||||
|
['keywords', 'Vec<String>', 'Download only episodes that mention one. Empty for all.'],
|
||||||
|
['allow_explicit', 'bool', 'Download explicit episodes too. False if left out.'],
|
||||||
|
] },
|
||||||
|
{ name: 'Added', doc: 'A feed subscribed to.', fields: [['id', 'String', 'Its id here.'], ['existing', 'bool', 'You subscribed to it already.']] },
|
||||||
|
{ name: 'FeedPatch', doc: 'What <code>PATCH /api/feeds/{id}</code> changes. Leave out what stays; <code>null</code> clears a field that can be cleared.', fields: [
|
||||||
|
['keywords', 'Option<Vec<String>>', 'Yours.'], ['blocked', 'Option<Vec<String>>', 'Yours.'],
|
||||||
|
['allow_explicit', 'Option<bool>', 'Yours.'], ['auto_download', 'Option<bool>', 'Yours.'],
|
||||||
|
['max_new_per_check', 'Option<Option<usize>>', 'Yours; <code>null</code> for the server’s.'],
|
||||||
|
['pinned', 'Option<bool>', 'Yours.'],
|
||||||
|
['deadair', 'Option<Option<bool>>', 'Yours; <code>null</code> follows its OPML list’s.'],
|
||||||
|
['deadair_pause', 'Option<i64>', 'Yours: 200, 300, 400 or 500.'],
|
||||||
|
['private', 'Option<bool>', 'Anyone subscribed may mark it private; only an admin unmarks it.'],
|
||||||
|
['url', 'Option<String>', 'Admin.'], ['schedule', 'Option<Option<String>>', 'Admin.'],
|
||||||
|
['folder', 'Option<Option<String>>', 'Admin.'], ['category', 'Option<Option<String>>', 'Admin.'],
|
||||||
|
] },
|
||||||
|
{ name: 'Marked', doc: '', fields: [['marked', 'i64', 'How many episodes were marked.']] },
|
||||||
|
{ name: 'HowMany', doc: '', fields: [['count', 'i64', 'From 1 to 100; 5 if left out.']] },
|
||||||
|
{ name: 'Queued', doc: '', fields: [['queued', 'usize', 'How many files were queued.']] },
|
||||||
|
{ name: 'FetchBody', doc: 'What to check.', fields: [['feed', 'Option<String>', 'One feed; all of yours if left out.'], ['force', 'bool', 'Check even if it is not due.']] },
|
||||||
|
{ name: 'OpmlBody', doc: '', fields: [['xml', 'String', 'The OPML file’s text.']] },
|
||||||
|
{ name: 'Imported', doc: '', fields: [['added', 'usize', ''], ['already', 'usize', 'Feeds you had.']] },
|
||||||
|
{ name: 'Page', doc: 'Which episodes, as query parameters.', fields: [
|
||||||
|
['offset', 'i64', '0 if left out.'], ['limit', 'i64', '50 if left out.'],
|
||||||
|
['filter', 'Option<String>', '<code>unread</code>, <code>downloaded</code> or <code>flagged</code> (kept).'],
|
||||||
|
['q', 'Option<String>', 'Words to look for in titles and show notes.'],
|
||||||
|
['sort', 'Option<String>', '<code>published</code>, <code>title</code>, <code>feed</code>, <code>type</code>, <code>size</code> or <code>kept</code>. Newest first if left out.'],
|
||||||
|
['dir', 'Option<String>', '<code>asc</code> or <code>desc</code>.'],
|
||||||
|
] },
|
||||||
|
{ name: 'EntryPage', doc: 'A page of episodes.', fields: [['total', 'i64', 'How many match, over every page.'], ['entries', 'Vec<EntryRow>', '']] },
|
||||||
|
{ name: 'EntryRow', doc: 'An episode or post, with your state for it.', fields: [
|
||||||
|
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
|
||||||
|
['published', 'Option<i64>', ''],
|
||||||
|
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
|
||||||
|
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
|
||||||
|
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
|
||||||
|
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
|
||||||
|
['position', 'i64', 'Where you are in it, in seconds.'],
|
||||||
|
['enclosures', 'Vec<EncRow>', 'Its files.'],
|
||||||
|
] },
|
||||||
|
{ name: 'EncRow', doc: 'An episode’s file.', fields: [
|
||||||
|
['id', 'i64', 'What <code>/media/{id}</code> and the file routes take.'],
|
||||||
|
['feed_id', 'String', ''], ['guid', 'String', ''], ['url', 'String', 'Where the publisher has it.'],
|
||||||
|
['mime', 'Option<String>', ''], ['length', 'Option<i64>', 'Bytes.'],
|
||||||
|
['path', 'Option<String>', 'Where it is on the server, once downloaded.'],
|
||||||
|
['state', 'String', '<code>pending</code> (a check will download it), <code>held</code> (back catalogue, downloaded on request), <code>downloading</code>, <code>done</code>, <code>skipped</code> (a filter or a failure) or <code>reaped</code> (deleted).'],
|
||||||
|
['last_error', 'Option<String>', ''],
|
||||||
|
['deadair', 'Option<i64>', 'Seconds DeadAir saves, when you play it shortened. Absent otherwise.'],
|
||||||
|
['deadair_pending', 'bool', 'DeadAir is still working on it. Absent when false.'],
|
||||||
|
] },
|
||||||
|
{ name: 'Flags', doc: '', fields: [['read', 'Option<bool>', ''], ['flagged', 'Option<bool>', 'Kept.']] },
|
||||||
|
{ name: 'Position', doc: '', fields: [['secs', 'i64', 'Seconds in.'], ['duration', 'Option<i64>', 'The length the player measured, for an episode whose feed gives none.']] },
|
||||||
|
{ name: 'Force', doc: 'A query parameter.', fields: [['force', 'bool', 'Delete it even though someone else wants it.']] },
|
||||||
|
{ name: 'PopularRow', doc: 'A feed in the directory. Never its address: subscribe by its id.', fields: [
|
||||||
|
['id', 'String', 'The directory’s id for it.'], ['title', 'Option<String>', ''], ['image', 'Option<String>', ''],
|
||||||
|
['subscribers', 'i64', 'On every server sharing with the directory.'],
|
||||||
|
['subscribed', 'bool', 'Yours already.'], ['feed', 'Option<String>', 'Its id here, when it is yours.'],
|
||||||
|
['category', 'Option<String>', ''], ['subcategory', 'Option<String>', ''],
|
||||||
|
['podcast', 'bool', 'Has audio or video.'],
|
||||||
|
] },
|
||||||
|
{ name: 'Listed', doc: 'A feed’s page in the directory.', fields: [
|
||||||
|
['description', 'Option<String>', 'What it says it is, sanitized.'],
|
||||||
|
['failing', 'Option<String>', 'Why its last check failed, when it did.'],
|
||||||
|
['items', 'Vec<ListedItem>', 'Its latest twenty.'],
|
||||||
|
['can_admin', 'bool', 'You may remove or refresh it.'],
|
||||||
|
] },
|
||||||
|
{ name: 'ListedItem', doc: '', fields: [['guid', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''], ['published', 'Option<i64>', ''], ['duration', 'Option<i64>', ''], ['description', 'Option<String>', 'Sanitized.']] },
|
||||||
|
{ name: 'Subscribed', doc: '', fields: [['id', 'String', 'The feed’s id here.']] },
|
||||||
|
{ name: 'Status', doc: '', fields: [['feeds', 'usize', ''], ['pending', 'usize', 'Files a check will download.'], ['downloaded', 'usize', ''], ['version', 'String', '']] },
|
||||||
|
{ name: 'Event', doc: 'One live update. <code>ev</code> says which, and the rest depends on it.', fields: [
|
||||||
|
['feed_start', '{ feed }', 'A feed is being checked.'],
|
||||||
|
['feed_skip', '{ feed }', 'Not due.'],
|
||||||
|
['feed_done', '{ feed, new, downloaded, failed, torrents }', ''],
|
||||||
|
['feed_error', '{ feed, msg }', ''],
|
||||||
|
['feed_row', '{ row: FeedRow }', 'A feed you subscribe to changed.'],
|
||||||
|
['progress', '{ feed, enclosure, file, done, total }', 'Bytes of a download, in whole percents.'],
|
||||||
|
['download_done', '{ feed, enclosure, path, bytes }', ''],
|
||||||
|
['download_error', '{ feed, enclosure, msg, url }', ''],
|
||||||
|
['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'],
|
||||||
|
['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'],
|
||||||
|
] },
|
||||||
|
{ name: 'Settings', doc: 'The server’s settings.', fields: [
|
||||||
|
['schedule', 'String', 'How often feeds are checked, such as <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
|
||||||
|
['max_new_per_check', 'usize', 'The newest episodes a check downloads, a feed.'],
|
||||||
|
['media_types', 'Vec<String>', 'What is downloaded, such as <code>audio</code>, <code>video</code>.'],
|
||||||
|
['download_dir', 'String', ''], ['max_total_gb', 'f64', 'Disk to use; 0 for no limit.'],
|
||||||
|
['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'],
|
||||||
|
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
|
||||||
|
['directory_share', 'bool', ''],
|
||||||
|
] },
|
||||||
|
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
|
||||||
|
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
|
||||||
|
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
|
||||||
|
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
|
||||||
|
['directory_share', 'Option<bool>', ''],
|
||||||
|
] },
|
||||||
|
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
|
||||||
|
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },
|
||||||
|
{ name: 'UserPatch', doc: '', fields: [['admin', 'bool', '']] },
|
||||||
|
{ name: 'LogQuery', doc: 'Query parameters.', fields: [['after', 'u64', 'The last <code>seq</code> you have; 0 for the newest lines.'], ['limit', 'usize', '200 if left out.']] },
|
||||||
|
{ name: 'LogPage', doc: '', fields: [['lines', 'Vec<LogLine>', ''], ['latest', 'u64', 'The newest <code>seq</code>, to ask after next time.']] },
|
||||||
|
{ name: 'LogLine', doc: '', fields: [['seq', 'u64', ''], ['ts', 'i64', ''], ['level', 'String', ''], ['target', 'String', ''], ['msg', 'String', '']] },
|
||||||
|
{ name: 'DirectoryStatus', doc: '', fields: [
|
||||||
|
['configured', 'bool', 'An address and token are set.'], ['name', 'Option<String>', 'The token’s name, as the directory knows it.'],
|
||||||
|
['access', 'Option<String>', '<code>ro</code>, <code>rw</code> or <code>rwa</code>.'], ['error', 'Option<String>', 'Why the directory would not say.'],
|
||||||
|
['shared', 'Option<SnapshotResult>', 'How the last share went.'],
|
||||||
|
] },
|
||||||
|
{ name: 'DirectoryAdmin', doc: '', fields: [['instances', 'Vec<Instance>', 'Servers sharing.'], ['blocked', 'Vec<BlockedRow>', '']] },
|
||||||
|
{ name: 'BlockedRow', doc: 'A feed the directory keeps out. Never its address.', fields: [['id', 'String', ''], ['title', 'Option<String>', ''], ['at', 'i64', 'When.']] },
|
||||||
|
{ name: 'Instance', doc: 'A server sharing with the directory.', fields: [['name', 'String', 'Its token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] },
|
||||||
|
{ name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', 'Addresses that look like someone’s paid access.'], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const directory = {
|
||||||
|
name: 'ipx_directory',
|
||||||
|
label: 'ipx-directory',
|
||||||
|
root: '/docs/api/directory',
|
||||||
|
changelog: 'crates/directory/CHANGELOG.md',
|
||||||
|
intro: `<p>The HTTP API of the directory of public podcasts that iPX servers share, at
|
||||||
|
<code>${D}/v1</code>. Its pages at <a href="${D}">${D.replace('https://', '')}</a> are anyone’s
|
||||||
|
to browse; the API needs a token for everything.</p>
|
||||||
|
<h2 id="auth">Tokens</h2>
|
||||||
|
<pre><code>Authorization: Bearer <token></code></pre>
|
||||||
|
<p>A token has a name and one of three levels of access: <code>ro</code> reads,
|
||||||
|
<code>rw</code> also shares, and <code>rwa</code> also removes and blocks feeds and drops a
|
||||||
|
server’s share. Tokens are given out by whoever runs the directory, and none can be made or
|
||||||
|
revoked over the API.</p>
|
||||||
|
<h2 id="errors">Errors</h2>
|
||||||
|
<p>A request that fails answers with its status and <code>{"error": "…"}</code>:
|
||||||
|
<code>401</code> no token or an unknown one, <code>403</code> a token without the access,
|
||||||
|
<code>404</code> not listed.</p>
|
||||||
|
<h2 id="ids">Ids</h2>
|
||||||
|
<p>A feed’s <code>id</code> is the first 16 hex digits of the SHA-256 of its address with the
|
||||||
|
scheme and host in lower case and no default port or fragment, so every server arrives at the
|
||||||
|
same one. Descriptions are the publisher’s own HTML, unsanitized: sanitize them before you
|
||||||
|
show them.</p>`,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'feeds', name: 'feeds', title: 'Feeds',
|
||||||
|
summary: 'What the directory lists.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/v1/me', slug: 'get-me', access: 'ro',
|
||||||
|
summary: 'The token’s name and access.', returns: 'Me', example: dirCurl('GET', '/v1/me'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/v1/feeds', slug: 'get-feeds', access: 'ro',
|
||||||
|
summary: 'Every feed listed, A to Z.', returns: 'Vec<Listing>', example: dirCurl('GET', '/v1/feeds'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/v1/popular', slug: 'get-popular', access: 'ro',
|
||||||
|
summary: 'The most subscribed feeds.', query: 'Limit', returns: 'Vec<Listing>',
|
||||||
|
example: dirCurl('GET', '/v1/popular?limit=5'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/v1/feeds/{id}', slug: 'get-feed', access: 'ro',
|
||||||
|
summary: 'One feed, with its description and latest twenty items.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
|
returns: 'FeedPage', errors: [['404 Not Found', 'Not listed.']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/healthz', slug: 'get-healthz', access: 'none',
|
||||||
|
summary: '<code>ok</code> when the directory can read its database.', returns: 'text/plain',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share', name: 'share', title: 'Sharing',
|
||||||
|
summary: 'A server telling the directory what its people follow.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/v1/snapshot', slug: 'post-snapshot', access: 'rw',
|
||||||
|
summary: 'Share: every public feed this server’s people follow, and how many follow each.',
|
||||||
|
doc: '<p>Replaces everything this token shared before, so send the whole list each time. At most 20,000 feeds. Addresses that look private (a login, a key in the query, a paid-feed host) are refused and counted, never stored. A feed is listed once the directory has read it as one.</p>',
|
||||||
|
body: 'Vec<SnapshotEntry>', returns: 'SnapshotResult',
|
||||||
|
errors: [['413 Payload Too Large', 'More than 20,000 feeds.']],
|
||||||
|
example: dirCurl('POST', '/v1/snapshot', '[{"url":"https://feeds.99percentinvisible.org/99percentinvisible","subscribers":3}]'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin', name: 'admin', title: 'Administering it',
|
||||||
|
summary: 'Removing and blocking feeds, and the servers sharing. <code>rwa</code> tokens only.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/v1/feeds/{id}', slug: 'delete-feed', access: 'rwa',
|
||||||
|
summary: 'Remove a feed and block it, so no server’s share brings it back.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/v1/feeds/{id}/refresh', slug: 'post-refresh', access: 'rwa',
|
||||||
|
summary: 'Check a feed now, answering with its page.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']], returns: 'FeedPage',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/v1/blocked', slug: 'get-blocked', access: 'rwa',
|
||||||
|
summary: 'The feeds kept out.', returns: 'Vec<Blocked>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/v1/blocked/{id}', slug: 'delete-blocked', access: 'rwa',
|
||||||
|
summary: 'Let a blocked feed back in.',
|
||||||
|
pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/v1/instances', slug: 'get-instances', access: 'rwa',
|
||||||
|
summary: 'Each token holding a share, with how many feeds and subscribers it gave.',
|
||||||
|
returns: 'Vec<Instance>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/v1/instances/{name}', slug: 'delete-instance', access: 'rwa',
|
||||||
|
summary: 'Drop one token’s share, its counts with it, until it next shares.',
|
||||||
|
doc: '<p>To keep a server out for good, revoke its token instead.</p>',
|
||||||
|
pathParams: [['name', 'String', 'The token’s name.']], returns: '204 No Content',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
types: [
|
||||||
|
{ name: 'Me', doc: 'A token, as the directory knows it.', fields: [['name', 'String', ''], ['access', 'Access', '']] },
|
||||||
|
{ name: 'Access', doc: 'A token’s access: <code>"ro"</code>, <code>"rw"</code> or <code>"rwa"</code>.', fields: [] },
|
||||||
|
{ name: 'Limit', doc: 'A query parameter.', fields: [['limit', 'Option<u64>', 'From 1 to 100; 10 if left out.']] },
|
||||||
|
{ name: 'Listing', doc: 'A feed listed.', fields: [
|
||||||
|
['id', 'String', ''], ['url', 'String', 'Its address, to subscribe to anywhere.'], ['title', 'Option<String>', ''], ['image', 'Option<String>', ''],
|
||||||
|
['category', 'Option<String>', 'Apple’s category, from the feed’s own.'], ['subcategory', 'Option<String>', ''],
|
||||||
|
['podcast', 'bool', 'Audio or video in its latest items.'],
|
||||||
|
['subscribers', 'i64', 'Summed over every server’s latest share.'],
|
||||||
|
] },
|
||||||
|
{ name: 'FeedPage', doc: 'A feed’s page: every field of <a href="/docs/api/directory/types/Listing"><code>Listing</code></a>, and these.', fields: [
|
||||||
|
['description', 'Option<String>', 'The feed’s own HTML, unsanitized.'],
|
||||||
|
['failing', 'Option<String>', 'Why its last check failed, in words.'],
|
||||||
|
['items', 'Vec<Item>', 'Its latest twenty.'],
|
||||||
|
] },
|
||||||
|
{ name: 'Item', doc: 'An episode or post. Never its file.', fields: [['guid', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''], ['published', 'Option<i64>', 'Unix seconds.'], ['duration', 'Option<i64>', 'Seconds.'], ['description', 'Option<String>', 'Unsanitized.']] },
|
||||||
|
{ name: 'SnapshotEntry', doc: 'One feed in a share.', fields: [['url', 'String', ''], ['subscribers', 'i64', ''], ['category', 'Option<String>', 'A category for a feed that names none itself.']] },
|
||||||
|
{ name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', ''], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] },
|
||||||
|
{ name: 'Blocked', doc: '', fields: [['id', 'String', ''], ['url', 'Option<String>', ''], ['title', 'Option<String>', ''], ['at', 'i64', 'When.']] },
|
||||||
|
{ name: 'Instance', doc: '', fields: [['name', 'String', 'The token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -10,3 +10,5 @@ cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here
|
|||||||
cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
|
cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
|
||||||
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
|
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
|
||||||
cp -r "$here/shots" "$here/dist/"
|
cp -r "$here/shots" "$here/dist/"
|
||||||
|
# The docs: the guides and the API references, generated (#223).
|
||||||
|
node "$here/gen.mjs"
|
||||||
|
|||||||
24
site/docs.mjs
Normal file
24
site/docs.mjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// The guides: a page each, from site/guides, with the docs' front page written here (#223).
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const read = name => fs.readFileSync(path.join(here, 'guides', name), 'utf8');
|
||||||
|
|
||||||
|
const front = `<h1>Docs</h1>
|
||||||
|
<p class="lede">How to run iPX on your own server, and how to talk to it and to the directory from a program of your own.</p>
|
||||||
|
<dl class="cards">
|
||||||
|
<div><dt><a href="/docs/install">Install ipx and ipx-web</a></dt><dd>Build the two containers, start them, sign in and add people.</dd></div>
|
||||||
|
<div><dt><a href="/docs/cli">The command line</a></dt><dd>The <code>ipx</code> commands, and the control socket a program on the same machine can use.</dd></div>
|
||||||
|
<div><dt><a href="/docs/directory">The directory</a></dt><dd>Browsing it, connecting your iPX to it, and running one of your own.</dd></div>
|
||||||
|
<div><dt><a href="/docs/api/ipx/">ipx API reference</a></dt><dd>Every endpoint of an iPX server, what it takes and what it gives.</dd></div>
|
||||||
|
<div><dt><a href="/docs/api/directory/">ipx-directory API reference</a></dt><dd>The directory's API, for reading what it lists and sharing with it.</dd></div>
|
||||||
|
</dl>`;
|
||||||
|
|
||||||
|
export const guides = [
|
||||||
|
{ href: '/docs/', file: 'docs/index.html', name: 'Overview', title: 'iPX docs', description: 'How to run iPX on your own server, and how to use its API and the directory’s.', body: front },
|
||||||
|
{ href: '/docs/install', file: 'docs/install.html', name: 'Install', title: 'Install ipx and ipx-web - iPX docs', description: 'Run iPX and its page on your own server with Docker.', body: read('install.html') },
|
||||||
|
{ href: '/docs/cli', file: 'docs/cli.html', name: 'Command line', title: 'The command line - iPX docs', description: 'The ipx commands, and its control socket.', body: read('cli.html') },
|
||||||
|
{ href: '/docs/directory', file: 'docs/directory.html', name: 'Directory', title: 'The directory - iPX docs', description: 'Browse the directory, connect your iPX to it, or run your own.', body: read('directory.html') },
|
||||||
|
];
|
||||||
212
site/gen.mjs
Normal file
212
site/gen.mjs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// The docs pages, into site/dist/docs: the guides in docs.mjs, and an API reference for each
|
||||||
|
// service from api.mjs, laid out as docs.rs lays out a crate (#223): a root page listing its
|
||||||
|
// modules and types, a page a module listing its endpoints, a page an endpoint and a page a type.
|
||||||
|
// No script: the site's Content-Security-Policy allows none of its own.
|
||||||
|
//
|
||||||
|
// node site/gen.mjs (site/build.sh runs it)
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { ipx, directory } from './api.mjs';
|
||||||
|
import { guides } from './docs.mjs';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dist = path.join(here, 'dist');
|
||||||
|
const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main';
|
||||||
|
|
||||||
|
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
/// The version a reference documents: its service's last release, from its changelog, not the
|
||||||
|
/// -dev version between releases.
|
||||||
|
function released(changelog) {
|
||||||
|
const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(rel, html) {
|
||||||
|
const file = path.join(dist, rel);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell({ title, description, sidebar, body, current }) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${esc(title)}</title>
|
||||||
|
<meta name="description" content="${esc(description)}">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<link rel="icon" href="/favicon.png">
|
||||||
|
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="stylesheet" href="/site.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="bar">
|
||||||
|
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
|
||||||
|
<nav aria-label="Elsewhere">
|
||||||
|
<a href="/docs/" aria-current="true">Docs</a>
|
||||||
|
<a href="https://directory.ipxreader.app">Directory</a>
|
||||||
|
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<div class="docs">
|
||||||
|
<aside class="side">${sidebar}</aside>
|
||||||
|
<main class="doc">${body}</main>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<p>iPX is free and open source under the MIT licence.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- the guides ---------------- */
|
||||||
|
|
||||||
|
const REFS = [[ipx, 'ipx'], [directory, 'ipx-directory']];
|
||||||
|
|
||||||
|
function guideSidebar(cur) {
|
||||||
|
const li = (href, name, on) => `<li><a href="${href}"${on ? ' aria-current="page"' : ''}>${name}</a></li>`;
|
||||||
|
return `<nav aria-label="Docs">
|
||||||
|
<h2 class="side-h">Guides</h2>
|
||||||
|
<ul>${guides.map(g => li(g.href, g.name, g.href === cur)).join('')}</ul>
|
||||||
|
<h2 class="side-h">API reference</h2>
|
||||||
|
<ul>${REFS.map(([r, label]) => li(r.root + '/', label, false)).join('')}</ul>
|
||||||
|
</nav>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const g of guides) {
|
||||||
|
write(g.file, shell({ title: g.title, description: g.description, sidebar: guideSidebar(g.href), body: g.body, current: 'docs' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- the references ---------------- */
|
||||||
|
|
||||||
|
const METHOD_CLASS = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' };
|
||||||
|
|
||||||
|
function reference(api) {
|
||||||
|
const label = api.label || api.name;
|
||||||
|
const version = released(api.changelog);
|
||||||
|
const typeNames = new Set(api.types.map(t => t.name));
|
||||||
|
const typeHref = n => `${api.root}/types/${n}`;
|
||||||
|
const itemHref = (g, it) => `${api.root}/${g.id}/${it.slug}`;
|
||||||
|
const fnName = it => it.slug.replace(/-/g, '_');
|
||||||
|
// A Rust type, its names that are types of this API linked.
|
||||||
|
const ty = s => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `<a class="t" href="${typeHref(n)}">${n}</a>` : n);
|
||||||
|
const isType = s => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ','));
|
||||||
|
const access = it => it.access || 'signed in';
|
||||||
|
|
||||||
|
// Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up.
|
||||||
|
const usedBy = new Map();
|
||||||
|
for (const g of api.groups) for (const it of g.items) {
|
||||||
|
for (const s of [it.body, it.query, it.returns].filter(Boolean)) {
|
||||||
|
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) {
|
||||||
|
if (!usedBy.has(n)) usedBy.set(n, []);
|
||||||
|
if (!usedBy.get(n).some(([, x]) => x === it)) usedBy.get(n).push([g, it]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Types inside types: a type used only as a field is reached from the type that holds it.
|
||||||
|
for (const t of api.types) for (const [, ft] of t.fields) {
|
||||||
|
for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) {
|
||||||
|
if (!usedBy.has(n)) usedBy.set(n, []);
|
||||||
|
if (!usedBy.get(n).some(([, x]) => x === t)) usedBy.get(n).push([null, t]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crumbs = (...parts) => `<p class="path"><a href="${api.root}/">${esc(api.name)}</a>${parts.map(([h, n]) => `::<a href="${h}">${n}</a>`).join('')}</p>`;
|
||||||
|
const top = `<a class="crate" href="${api.root}/"><img src="/logo.svg" alt="" width="40" height="40"><span><b>${esc(label)}</b>${version ? `<span class="ver">${version}</span>` : ''}</span></a>`;
|
||||||
|
const back = `<p class="side-back"><a href="/docs/">All docs</a></p>`;
|
||||||
|
|
||||||
|
function sidebar({ group, item, type } = {}) {
|
||||||
|
let s = top;
|
||||||
|
if (group) {
|
||||||
|
s += `<h2 class="side-h">Module <a href="${api.root}/${group.id}/">${group.name}</a></h2>
|
||||||
|
<ul class="items">${group.items.map(it => `<li><a class="${METHOD_CLASS[it.method]}" href="${itemHref(group, it)}" title="${it.method} ${esc(it.path)}"${it === item ? ' aria-current="page"' : ''}><span class="verb">${it.method}</span> ${fnName(it)}</a></li>`).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
s += `<h2 class="side-h">Modules</h2><ul>${api.groups.map(g => `<li><a href="${api.root}/${g.id}/"${g === group && !item ? ' aria-current="page"' : ''}>${g.name}</a></li>`).join('')}</ul>`;
|
||||||
|
s += `<h2 class="side-h">Types</h2><ul class="types">${api.types.map(t => `<li><a class="t" href="${typeHref(t.name)}"${t === type ? ' aria-current="page"' : ''}>${t.name}</a></li>`).join('')}</ul>`;
|
||||||
|
return `<nav aria-label="${esc(label)} reference">${s}</nav>${back}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = (rel, title, description, side, body) =>
|
||||||
|
write(rel, shell({ title, description, sidebar: side, body, current: 'docs' }));
|
||||||
|
|
||||||
|
// The root: what the crate page shows on docs.rs, its modules and types.
|
||||||
|
page(`${api.root.slice(1)}/index.html`, `${label} API`, `The HTTP API of ${label}, endpoint by endpoint.`, sidebar(),
|
||||||
|
`<h1 class="item"><span class="kind">API</span> <span class="name">${esc(label)}</span>${version ? ` <span class="ver">${version}</span>` : ''}</h1>
|
||||||
|
${api.intro}
|
||||||
|
<h2 id="modules">Modules</h2>
|
||||||
|
<dl class="list">${api.groups.map(g => `<dt><a href="${api.root}/${g.id}/">${g.name}</a></dt><dd>${g.summary}</dd>`).join('')}</dl>
|
||||||
|
<h2 id="types">Types</h2>
|
||||||
|
<dl class="list">${api.types.map(t => `<dt><a class="t" href="${typeHref(t.name)}">${t.name}</a></dt><dd>${t.doc || ''}</dd>`).join('')}</dl>`);
|
||||||
|
|
||||||
|
for (const g of api.groups) {
|
||||||
|
page(`${api.root.slice(1)}/${g.id}/index.html`, `${api.name}::${g.name}`, g.title, sidebar({ group: g }),
|
||||||
|
`${crumbs()}<h1 class="item"><span class="kind">Module</span> ${esc(api.name)}::<span class="name">${g.name}</span></h1>
|
||||||
|
<p>${g.summary}</p>
|
||||||
|
<h2 id="endpoints">Endpoints</h2>
|
||||||
|
<dl class="list">${g.items.map(it => `<dt><a class="${METHOD_CLASS[it.method]}" href="${itemHref(g, it)}"><span class="verb">${it.method}</span> ${esc(it.path)}</a></dt><dd>${it.summary}</dd>`).join('')}</dl>`);
|
||||||
|
|
||||||
|
for (const it of g.items) {
|
||||||
|
const decl = [
|
||||||
|
`<span class="verb ${METHOD_CLASS[it.method]}">${it.method}</span> ${esc(it.path)}`,
|
||||||
|
it.query && ` query ${ty(it.query)}`,
|
||||||
|
it.body && ` body ${ty(it.body)}`,
|
||||||
|
` -> ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
const fieldsOf = n => api.types.find(t => t.name === n)?.fields || [];
|
||||||
|
const rows = list => `<dl class="fields">${list.map(([n, t, d]) => `<dt id="${it.slug}-${n}"><code>${esc(n)}: ${ty(t)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
|
||||||
|
let body = `${crumbs([`${api.root}/${g.id}/`, g.name])}
|
||||||
|
<h1 class="item"><span class="kind">Endpoint</span> ${esc(api.name)}::${g.name}::<span class="name fn">${fnName(it)}</span><a class="srclink" href="${SRC}/${api.name === 'ipx' ? 'crates/ipx/src/web.rs' : 'crates/directory/src/lib.rs'}">Source</a></h1>
|
||||||
|
<pre class="decl"><code>${decl}</code></pre>
|
||||||
|
<p class="summary">${it.summary}</p>
|
||||||
|
${it.doc || ''}
|
||||||
|
<p class="access">Access: <b>${esc(access(it))}</b></p>`;
|
||||||
|
if (it.pathParams) body += `<h2>Path parameters</h2>${rows(it.pathParams)}`;
|
||||||
|
if (it.query) body += `<h2>Query</h2><p>${ty(it.query)}, as query parameters:</p>${rows(fieldsOf(it.query))}`;
|
||||||
|
if (it.body) body += `<h2>Body</h2><p>${ty(it.body)}, as JSON.</p>`;
|
||||||
|
body += `<h2>Returns</h2><p>${isType(it.returns) ? `${ty(it.returns)}, as JSON.` : esc(it.returns)}</p>`;
|
||||||
|
if (it.errors?.length) body += `<h2>Errors</h2><dl class="fields">${it.errors.map(([c, d]) => `<dt><code>${esc(c)}</code></dt><dd>${d}</dd>`).join('')}</dl>`;
|
||||||
|
if (it.example) body += `<h2>Example</h2><pre><code>${esc(it.example)}</code></pre>`;
|
||||||
|
page(`${api.root.slice(1)}/${g.id}/${it.slug}.html`, `${it.method} ${it.path} - ${label}`, it.summary.replace(/<[^>]+>/g, ''), sidebar({ group: g, item: it }), body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page(`${api.root.slice(1)}/types/index.html`, `${api.name}::types`, `The types ${label}'s API takes and gives.`, sidebar(),
|
||||||
|
`${crumbs()}<h1 class="item"><span class="kind">Module</span> ${esc(api.name)}::<span class="name">types</span></h1>
|
||||||
|
<dl class="list">${api.types.map(t => `<dt><a class="t" href="${typeHref(t.name)}">${t.name}</a></dt><dd>${t.doc || ''}</dd>`).join('')}</dl>`);
|
||||||
|
|
||||||
|
for (const t of api.types) {
|
||||||
|
const decl = t.name === 'Event'
|
||||||
|
? `enum Event {\n${t.fields.map(([n, f]) => ` ${n} ${ty(f)},`).join('\n')}\n}`
|
||||||
|
: t.fields.length
|
||||||
|
? `struct ${t.name} {\n${t.fields.map(([n, f]) => ` ${n}: ${ty(f)},`).join('\n')}\n}`
|
||||||
|
: `enum ${t.name}`;
|
||||||
|
const uses = usedBy.get(t.name) || [];
|
||||||
|
let body = `${crumbs([`${api.root}/types/`, 'types'])}
|
||||||
|
<h1 class="item"><span class="kind">${t.name === 'Event' || !t.fields.length ? 'Enum' : 'Struct'}</span> ${esc(api.name)}::types::<span class="name t">${t.name}</span></h1>
|
||||||
|
<pre class="decl"><code>${decl}</code></pre>
|
||||||
|
${t.doc ? `<p class="summary">${t.doc}</p>` : ''}`;
|
||||||
|
if (t.fields.length) {
|
||||||
|
body += `<h2>${t.name === 'Event' ? 'Variants' : 'Fields'}</h2><dl class="fields">${t.fields.map(([n, f, d]) =>
|
||||||
|
`<dt id="${n}"><a class="anchor" href="#${n}" aria-label="Link to ${esc(n)}">§</a><code>${esc(n)}${t.name === 'Event' ? ' ' : ': '}${ty(f)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
|
||||||
|
}
|
||||||
|
if (uses.length) {
|
||||||
|
body += `<h2>Used by</h2><ul class="uses">${uses.map(([g, x]) => g
|
||||||
|
? `<li><a class="${METHOD_CLASS[x.method]}" href="${itemHref(g, x)}"><span class="verb">${x.method}</span> ${esc(x.path)}</a></li>`
|
||||||
|
: `<li><a class="t" href="${typeHref(x.name)}">${x.name}</a></li>`).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every link to a type must name one there is: a misspelt type in api.mjs is otherwise a
|
||||||
|
// name in plain text, which looks right and leads nowhere.
|
||||||
|
for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter(Boolean)) {
|
||||||
|
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g)) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reference(ipx);
|
||||||
|
reference(directory);
|
||||||
22
site/guides/cli.html
Normal file
22
site/guides/cli.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<h1>The command line</h1>
|
||||||
|
<p>The same <code>ipx</code> program that runs the server is its command line. Run it in the container with <code>docker exec ipx ipx …</code>; when the server is running, it does the work and the command shows what happened.</p>
|
||||||
|
<div class="scroll"><table>
|
||||||
|
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>ipx list</code></td><td>Every podcast and its state</td></tr>
|
||||||
|
<tr><td><code>ipx status</code></td><td>How many podcasts, how many files waiting and downloaded</td></tr>
|
||||||
|
<tr><td><code>ipx fetch [id] [--force]</code></td><td>Check everything that is due, or one podcast; <code>--force</code> checks it even if it is not due</td></tr>
|
||||||
|
<tr><td><code>ipx add <url></code>, <code>ipx rm <id></code></td><td>Add a podcast, or take it out</td></tr>
|
||||||
|
<tr><td><code>ipx import <file></code>, <code>ipx export <file></code></td><td>OPML in or out</td></tr>
|
||||||
|
<tr><td><code>ipx reap [--dry-run]</code></td><td>Clear old files now, to stay within the space you set</td></tr>
|
||||||
|
<tr><td><code>ipx user add|list|passwd|rm</code></td><td>Accounts</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
<h2>The control socket</h2>
|
||||||
|
<p>A program on the same machine can also talk to the running server over its control socket, one JSON command a line, and read back one event a line:</p>
|
||||||
|
<pre><code>printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
|
||||||
|
{"ev":"feed_start","feed":"atp"}
|
||||||
|
{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
|
||||||
|
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
|
||||||
|
{"ev":"scan_done","feeds":1}</code></pre>
|
||||||
|
<p>The commands are <code>fetch</code> (optional <code>feed</code>, <code>force</code>), <code>download</code> (<code>enclosure</code>), <code>reap</code> (optional <code>dry_run</code>) and <code>status</code>.</p>
|
||||||
32
site/guides/directory.html
Normal file
32
site/guides/directory.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<h1>The directory</h1>
|
||||||
|
<p>The directory gathers the public podcasts that iPX servers' listeners follow, and how many follow each. It never holds a file and never learns who listens.</p>
|
||||||
|
|
||||||
|
<h2>Browsing it</h2>
|
||||||
|
<p>Anyone can browse <a href="https://directory.ipxreader.app">directory.ipxreader.app</a> without signing in: the most followed, the categories, everything A to Z, and a page for each podcast with its latest episodes and its address, to subscribe to in any podcast app.</p>
|
||||||
|
|
||||||
|
<h2>Connecting your iPX</h2>
|
||||||
|
<p>In iPX, an admin opens Admin, Directory and enters the directory's address, <code>https://directory.ipxreader.app</code>, and the token its operator gave your server. Everyone on your server then has the Directory at the top of their list, and subscribes from it in one click.</p>
|
||||||
|
<p>Turn on sharing to add your server's podcasts to it. iPX sends only the podcasts' addresses and how many people on your server follow each, never who. It leaves out anything that looks private: a podcast someone marked Private, one with a login or a key in its address, and paid feeds such as Patreon's.</p>
|
||||||
|
|
||||||
|
<h2>Its API</h2>
|
||||||
|
<p>Anything the pages show, a program can read through the directory's API with a token: see the <a href="/docs/api/directory/">ipx-directory API reference</a>.</p>
|
||||||
|
|
||||||
|
<h2>Running your own</h2>
|
||||||
|
<p>The directory is in the same repository. Build it, give it a database (Postgres or SQLite) and a config file, and make a token for each server that will use it:</p>
|
||||||
|
<pre><code>docker build -f Dockerfile.directory -t ipx-directory .</code></pre>
|
||||||
|
<pre><code>services:
|
||||||
|
ipx-directory:
|
||||||
|
image: ipx-directory
|
||||||
|
container_name: ipx-directory
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
|
||||||
|
ports:
|
||||||
|
- "8100:8100"
|
||||||
|
volumes:
|
||||||
|
- ./directory:/config:ro # directory.toml</code></pre>
|
||||||
|
<pre><code># directory/directory.toml
|
||||||
|
bind = "0.0.0.0:8100"
|
||||||
|
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
|
||||||
|
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
|
||||||
|
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.</p>
|
||||||
67
site/guides/install.html
Normal file
67
site/guides/install.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<h1>Install ipx and ipx-web</h1>
|
||||||
|
<p class="lede">iPX is two containers: <strong>ipx</strong>, which checks your podcasts, downloads them and answers the API, and <strong>ipx-web</strong>, which serves the page and passes the API on to ipx.</p>
|
||||||
|
<p>You need Docker with Compose. There are no published images yet, so you build both from the source:</p>
|
||||||
|
<pre><code>git clone https://git.sdf1.net/rays/ipx.git
|
||||||
|
cd ipx
|
||||||
|
docker build -t ipx .
|
||||||
|
docker build -f Dockerfile.web -t ipx-web .</code></pre>
|
||||||
|
<p>The repository has a sample <code>docker-compose.yml</code> that runs both, with the database in SQLite and everything ipx keeps in three folders beside the file:</p>
|
||||||
|
<pre><code>services:
|
||||||
|
ipx:
|
||||||
|
image: ipx
|
||||||
|
container_name: ipx
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PUID: "99" # who owns the files it writes
|
||||||
|
PGID: "100"
|
||||||
|
TZ: "UTC"
|
||||||
|
ports:
|
||||||
|
- "6881:6881/tcp" # BitTorrent
|
||||||
|
- "6881:6881/udp"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config # config.toml, written on first start
|
||||||
|
- ./data:/data # the database
|
||||||
|
- ./downloads:/downloads
|
||||||
|
|
||||||
|
ipx-web:
|
||||||
|
image: ipx-web
|
||||||
|
container_name: ipx-web
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
IPX_UPSTREAM: "ipx:8099"
|
||||||
|
ports:
|
||||||
|
- "8099:8080" # the page
|
||||||
|
depends_on:
|
||||||
|
- ipx</code></pre>
|
||||||
|
<pre><code>docker compose up -d</code></pre>
|
||||||
|
<p>ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open <code>http://<your server>:8099</code> and sign in as <strong>admin</strong> with the password <strong>ipodderx</strong>, then change it:</p>
|
||||||
|
<pre><code>echo -n 'a good password' | docker exec -i ipx ipx user passwd admin</code></pre>
|
||||||
|
<p>The password is read from what you pipe in, so it stays out of your shell's history.</p>
|
||||||
|
|
||||||
|
<h2>Adding people</h2>
|
||||||
|
<p>An admin adds people in Admin, Accounts, or from the command line:</p>
|
||||||
|
<pre><code>echo -n 'their password' | docker exec -i ipx ipx user add sam</code></pre>
|
||||||
|
<p>Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.</p>
|
||||||
|
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<p>How often podcasts are checked, how much disk to use, how many new episodes to download and the rest are in Admin on the page, and take effect straight away. <code>/config/config.toml</code> holds only what ipx needs before it can read its database: where downloads go, BitTorrent, and who gets in.</p>
|
||||||
|
<div class="scroll"><table>
|
||||||
|
<thead><tr><th>Environment variable</th><th>What it does</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
|
||||||
|
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
|
||||||
|
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
|
||||||
|
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
|
||||||
|
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
|
||||||
|
<h2>Reaching it from outside</h2>
|
||||||
|
<p>The page is plain HTTP. If anyone will reach it from outside your network, put HTTPS in front of it: a reverse proxy you already run, or a Cloudflare tunnel. People can then sign in through Cloudflare Access or Authentik instead of a password; <a href="https://git.sdf1.net/rays/ipx/src/branch/main/docs/sso.md">single sign-on</a> says how.</p>
|
||||||
|
|
||||||
|
<h2>Updating</h2>
|
||||||
|
<pre><code>git pull
|
||||||
|
docker build -t ipx .
|
||||||
|
docker build -f Dockerfile.web -t ipx-web .
|
||||||
|
docker compose up -d</code></pre>
|
||||||
|
<p>Your podcasts, accounts and downloads are in the three folders and are kept.</p>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<header class="bar">
|
<header class="bar">
|
||||||
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
|
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
|
||||||
<nav aria-label="Elsewhere">
|
<nav aria-label="Elsewhere">
|
||||||
|
<a href="/docs/">Docs</a>
|
||||||
<a href="https://directory.ipxreader.app">Directory</a>
|
<a href="https://directory.ipxreader.app">Directory</a>
|
||||||
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
|
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -90,6 +90,84 @@ h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4
|
|||||||
.also dd{margin:.45rem 0 0;max-width:34rem}
|
.also dd{margin:.45rem 0 0;max-width:34rem}
|
||||||
|
|
||||||
|
|
||||||
|
/* Docs (#223): a sidebar and a column, as docs.rs lays out a crate. The sidebar is the dial's
|
||||||
|
sky; an endpoint's name is the scale's blue, a type's green, and each method has its own
|
||||||
|
colour, every one AA on the page. Code to read is dark, as on the home page; a declaration is
|
||||||
|
light, as docs.rs shows one. On a phone the sidebar follows the page instead of leading it. */
|
||||||
|
.docs{max-width:72rem;margin:0 auto;padding-inline:1.5rem;display:grid;grid-template-columns:15rem minmax(0,1fr);gap:3rem;align-items:start}
|
||||||
|
.docs main.doc{max-width:50rem;margin:0;padding:0}
|
||||||
|
.side{position:sticky;top:1rem;max-height:calc(100vh - 2rem);overflow:auto;padding:1rem .75rem;background:var(--sky);border-radius:1rem;font-size:.92rem}
|
||||||
|
.side ul{list-style:none;margin:0;padding:0}
|
||||||
|
.side li a{display:block;padding:.2rem .5rem;border-radius:.4rem;color:var(--ink);text-decoration:none;overflow-wrap:anywhere}
|
||||||
|
.side li a:hover{background:#fff}
|
||||||
|
.side a[aria-current=page]{background:#fff;font-weight:700}
|
||||||
|
.side-h{margin:1.1rem .5rem .35rem;color:var(--muted);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
|
||||||
|
.side-h a{color:inherit}
|
||||||
|
.side-back{margin:1rem .5rem 0}
|
||||||
|
.crate{display:flex;align-items:center;gap:.6rem;padding:.25rem .5rem;color:var(--navy);text-decoration:none}
|
||||||
|
.crate img{border-radius:9px}
|
||||||
|
.crate b{display:block;font-size:1.1rem}
|
||||||
|
.ver{color:var(--muted);font-size:.85rem;font-weight:500}
|
||||||
|
.mono,.items a,.uses a,.list dt,.path,.decl,.fields dt,.item .name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||||
|
.items a,.uses a{font-size:.82rem}
|
||||||
|
.items .verb{display:inline-block;min-width:3.6em}
|
||||||
|
.verb{font-weight:700}
|
||||||
|
.m-get .verb,.verb.m-get{color:#2f6aa0}
|
||||||
|
.m-post .verb,.verb.m-post{color:#1d6b44}
|
||||||
|
.m-patch .verb,.verb.m-patch{color:#875400}
|
||||||
|
.m-delete .verb,.verb.m-delete{color:#b3261e}
|
||||||
|
a.t,.item .name.t{color:#1d6b44}
|
||||||
|
|
||||||
|
.doc h1{margin:2rem 0 0;color:var(--navy);font-weight:900;font-size:clamp(2.6rem,8vw,4rem);line-height:1;letter-spacing:-.04em}
|
||||||
|
.doc h1.item{margin-top:.25rem;font-size:clamp(1.35rem,4vw,1.85rem);font-weight:500;letter-spacing:-.01em;line-height:1.3;overflow-wrap:anywhere}
|
||||||
|
.item .kind{font-family:Inter,system-ui,sans-serif;color:var(--muted)}
|
||||||
|
.item .name{font-weight:700;color:var(--scale)}
|
||||||
|
.srclink{float:right;margin-left:1rem;font:500 .9rem/2.4 Inter,system-ui,sans-serif}
|
||||||
|
.path{margin:2rem 0 0;font-size:.9rem;color:var(--muted)}
|
||||||
|
.path a{color:inherit}
|
||||||
|
.lede{margin:1.5rem 0 0;color:var(--muted);font-size:1.125rem}
|
||||||
|
.doc h2{margin:2.5rem 0 .75rem;padding-bottom:.35rem;border-bottom:1px solid var(--rule);font-size:1.35rem;letter-spacing:-.01em}
|
||||||
|
.doc p{margin:.75rem 0}
|
||||||
|
.summary{font-size:1.06rem}
|
||||||
|
.access{color:var(--muted)}
|
||||||
|
.access b{color:var(--ink)}
|
||||||
|
.doc code{font:500 .9em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||||
|
.doc :not(pre)>code{padding:.08em .3em;background:var(--sky);border-radius:.3em;overflow-wrap:anywhere}
|
||||||
|
.doc dt>code,.doc a>code{background:none;padding:0}
|
||||||
|
.doc pre{margin:1rem 0;padding:1rem 1.15rem;background:var(--navy);color:#e6f1fa;border-radius:.8rem;overflow-x:auto}
|
||||||
|
.doc pre code{font-size:.85rem}
|
||||||
|
.doc pre.decl{background:#fff;color:var(--ink);border:1px solid var(--rule)}
|
||||||
|
.decl a{text-decoration:none}
|
||||||
|
.scroll{margin:1rem 0;overflow-x:auto}
|
||||||
|
.doc table{width:100%;border-collapse:collapse;font-size:.95rem}
|
||||||
|
.doc th,.doc td{padding:.55rem .75rem .55rem 0;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
|
||||||
|
.doc th{color:var(--navy);font-weight:700}
|
||||||
|
.doc td:first-child code{white-space:nowrap}
|
||||||
|
/* An item table: the name, and the first line of what it is. */
|
||||||
|
.list{display:grid;grid-template-columns:minmax(0,max-content) minmax(0,1fr);gap:.4rem 1.5rem;margin:0}
|
||||||
|
.list dt{font-size:.88rem;overflow-wrap:anywhere}
|
||||||
|
.list dt a{text-decoration:none}
|
||||||
|
.list dt a:hover{text-decoration:underline}
|
||||||
|
.list dd{margin:0}
|
||||||
|
.fields dt{position:relative;margin-top:1rem;font-size:.92rem;overflow-wrap:anywhere}
|
||||||
|
.fields dd{margin:.25rem 0 0 1.5rem}
|
||||||
|
.anchor{position:absolute;left:-1.1rem;color:var(--muted);text-decoration:none;opacity:0}
|
||||||
|
.fields dt:hover .anchor,.anchor:focus-visible{opacity:1}
|
||||||
|
.uses{padding-left:1.25rem}
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin:2rem 0 0}
|
||||||
|
.cards div{padding:1.1rem 1.25rem;background:#fff;border:1px solid var(--rule);border-radius:1rem}
|
||||||
|
.cards dt{font-weight:750;font-size:1.1rem}
|
||||||
|
.cards dd{margin:.35rem 0 0;color:var(--muted)}
|
||||||
|
.bar nav a[aria-current]{text-decoration:underline;text-decoration-color:var(--needle);text-decoration-thickness:2px}
|
||||||
|
@media (max-width:800px){
|
||||||
|
.docs{grid-template-columns:minmax(0,1fr);gap:2rem;padding-inline:1rem}
|
||||||
|
.side{position:static;max-height:none;order:2}
|
||||||
|
.srclink{float:none;display:block;margin:0;line-height:1.8}
|
||||||
|
.list{grid-template-columns:minmax(0,1fr);gap:.15rem}
|
||||||
|
.list dd{margin-bottom:.6rem}
|
||||||
|
.cards{grid-template-columns:minmax(0,1fr)}
|
||||||
|
}
|
||||||
|
|
||||||
footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem}
|
footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem}
|
||||||
footer p{margin:0}
|
footer p{margin:0}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
|
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
|
||||||
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
|
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
|
||||||
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
|
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
|
||||||
// While each runs, `ipx status` -- the Docker healthcheck -- is run every second, and the run
|
// While each runs, `ipx status` is run every second, and the run fails if any answer takes 5s.
|
||||||
// fails if any answer takes as long as the healthcheck's 5s timeout.
|
|
||||||
//
|
//
|
||||||
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
|
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
|
||||||
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
|
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ for (const [name, fn] of drive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A trace is one tap's (#219): the same id while requests keep coming, a new one after a quiet
|
||||||
|
// minute, so a refresh hours later is not filed under the last tap.
|
||||||
|
if (PAGE === 'index.html') {
|
||||||
|
const id = () => ctx.traceparent().split('-')[1];
|
||||||
|
vm.runInContext('Date.now = (n => () => n)(1e12)', ctx);
|
||||||
|
const first = id();
|
||||||
|
vm.runInContext('Date.now = (n => () => n)(1e12 + 30_000)', ctx);
|
||||||
|
if (id() !== first) { console.error('FAIL: a trace ended 30 s into a tap'); process.exit(1); }
|
||||||
|
vm.runInContext('Date.now = (n => () => n)(1e12 + 120_000)', ctx);
|
||||||
|
if (id() === first) { console.error('FAIL: a trace outlived a quiet minute'); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
||||||
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
||||||
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-10-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A live update or a list refresh that fails because the network is gone (a laptop asleep) is no longer reported as an error, an error page from Cloudflare no longer becomes a request's message, and requests a minute or more after a tap start a trace of their own instead of joining that tap's (#219).
|
||||||
|
|
||||||
## [1.0.0] - 2026-10-12
|
## [1.0.0] - 2026-10-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -32,5 +38,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...main
|
||||||
|
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ function connect(){
|
|||||||
// Back after a drop: whatever was sent meanwhile went to nobody.
|
// Back after a drop: whatever was sent meanwhile went to nobody.
|
||||||
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
||||||
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
|
||||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const mine=id=>S.feeds.some(f=>f.id===id);
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
@@ -70,4 +71,4 @@ function connect(){
|
|||||||
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
connect();
|
connect();
|
||||||
loadFeeds();
|
loadFeeds().catch(()=>{});
|
||||||
|
|||||||
@@ -72,11 +72,18 @@ function spanText(secs){
|
|||||||
}
|
}
|
||||||
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
|
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
|
||||||
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
|
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
|
||||||
/// LAN address over plain HTTP does not have.
|
/// LAN address over plain HTTP does not have. A trace ends after a minute with no request in it:
|
||||||
let traceId='';
|
/// the requests a live update or a woken laptop sets off hours later used to join the last tap's
|
||||||
|
/// trace, which then lasted nine hours (#219). A play's position saves, every ten seconds, keep it.
|
||||||
|
let traceId='', traceAt=0;
|
||||||
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
|
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
|
||||||
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); },{capture:true,passive:true});
|
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); traceAt=Date.now(); },{capture:true,passive:true});
|
||||||
function traceparent(){ return `00-${traceId||=hex(16)}-${hex(8)}-01`; }
|
function traceparent(){
|
||||||
|
const now=Date.now();
|
||||||
|
if(!traceId||now-traceAt>60_000) traceId=hex(16);
|
||||||
|
traceAt=now;
|
||||||
|
return `00-${traceId}-${hex(8)}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
/// Something that went wrong, or how long something took, in the browser, for the server's log
|
/// Something that went wrong, or how long something took, in the browser, for the server's log
|
||||||
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
|
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
|
||||||
@@ -95,7 +102,9 @@ window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e
|
|||||||
async function api(url: string, opts?: RequestInit): Promise<any>{
|
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
||||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||||
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
|
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||||
|
// whole 502 page (#219).
|
||||||
|
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
||||||
return r.status===204?null:r.json().catch(()=>null);
|
return r.status===204?null:r.json().catch(()=>null);
|
||||||
}
|
}
|
||||||
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||||
|
|||||||
Reference in New Issue
Block a user