Compare commits
236 Commits
deadair-ui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 62f8451700 | |||
| 00eb75472c | |||
| ecff2f8dcc | |||
| ff5d786602 | |||
| 4f4fd4c3fe | |||
| f22c1993b1 | |||
| d33d83043d | |||
| 41affc8d8e | |||
| 59c7f5f213 | |||
| 0be8eb5cdf | |||
| e1291c1627 | |||
| a11e3b6367 | |||
| cb4ee5a570 | |||
| c4878731c6 | |||
| 21fa0ae476 | |||
| aa430f6d50 | |||
| df643536c0 | |||
| 543d35f02c | |||
| 0de0fb4332 | |||
| 79b496bdfb | |||
| a20fade8ad | |||
| daf95a25cd | |||
| 91de1114d8 | |||
| 37c08195f1 | |||
| d1149e1236 | |||
| 46fe0dddbf | |||
| d35b8054b4 | |||
| 3bfd8f848b | |||
| fc04a741a6 | |||
| 80f12cda70 | |||
| 4f7dab9c04 | |||
| cc3b8742bc | |||
| c99f481124 | |||
| a20062d0f7 | |||
| 6d71613e19 | |||
| f398211fd6 | |||
| 708b69c083 | |||
| 62a2456dde | |||
| ec3dedf113 | |||
| 511246acb2 | |||
| ccc4766b03 | |||
| 78b81e43aa | |||
| 4af12363bc | |||
| 84b4203daf | |||
| 0696583547 | |||
| 6ec2c35291 | |||
| c8afcec73d | |||
| 5bad68670a | |||
| d87d024096 | |||
| 44afc08b9d | |||
| a21472730e | |||
| 995ee05557 | |||
| 7af6153d18 | |||
| c1d8983ca1 | |||
| 4c1b78408f | |||
| d850c02501 | |||
| 363a5a122d | |||
| 3d9b45ddd7 | |||
| b54b47897b | |||
| b89c371662 | |||
| 6daab6989e | |||
| 187729a278 | |||
| 3df7c974e8 | |||
| 59aa00e07c | |||
| b4c0048f68 | |||
| d5ffe5d042 | |||
| d16460267f | |||
| 21c786f48a | |||
| 3a8d564eb7 | |||
| f9246129f2 | |||
| 5cf7a28c44 | |||
| ac3808fe97 | |||
| 341d880e13 | |||
| cb4407b19d | |||
| 7f61250852 | |||
| c90a66a24f | |||
| 692a510217 | |||
| 2c4ba7896e | |||
| 36a820f283 | |||
| 41fc2e3933 | |||
| 4eba746ba4 | |||
| ce78599ab4 | |||
| 5130f754cc | |||
| 2077ea5810 | |||
| 026fbd1622 | |||
| 1aef6ba421 | |||
| 9a41641eaa | |||
| 33eb1f4db2 | |||
| 3b48f51a56 | |||
| 55b0192df5 | |||
| e07d27d9f2 | |||
| 3532710b3d | |||
| b56c97259d | |||
| 1025416181 | |||
| 894e5e4711 | |||
| 0e21195e8d | |||
| 3b41abd860 | |||
| 47ad68c7da | |||
| ae58e4653a | |||
| dc3ee016db | |||
| f916ca972a | |||
| 7627af5bc8 | |||
| fbe7629420 | |||
| e882904c11 | |||
| f1a156c974 | |||
| f34423735a | |||
| 15bdce486d | |||
| 0c2c3a379e | |||
| 3e4d67fd2f | |||
| 2ed8781604 | |||
| d0ed953e84 | |||
| 4f1e7e23f6 | |||
| b18c92d1f5 | |||
| 9e5dcbb8c4 | |||
| ce485a82ac | |||
| f8371f255d | |||
| 1ae91170fd | |||
| 20f53183ac | |||
| 8d93b58deb | |||
| 0fdd609791 | |||
| 13cad18857 | |||
| 70990c70c7 | |||
| 30622fd3e1 | |||
| 1949f3e493 | |||
| 3d94bce4f5 | |||
| cd7a9b8530 | |||
| ca73b8136f | |||
| 6fde4b73cd | |||
| cdf2c98f9f | |||
| 6640ca9612 | |||
| 62a3241139 | |||
| 8f19d88fc9 | |||
| dbfe7918ac | |||
| c957db7f61 | |||
| 4e0b5eed5d | |||
| a9991545f9 | |||
| 60007d4e6a | |||
| 1471b9b9d0 | |||
| ec2b9e87b8 | |||
| af6eae9baf | |||
| 78704e40f2 | |||
| f20660a7d5 | |||
| a5e64e520d | |||
| 7ecbe8293f | |||
| 8d7a30d1f9 | |||
| cece114d98 | |||
| f00adf5da5 | |||
| d787bd26ec | |||
| 382f51a480 | |||
| 45c9f9252b | |||
| e4b505564c | |||
| 4cf568b263 | |||
| 724764c8df | |||
| cc155274e9 | |||
| 02bfa9571b | |||
| 1b137d6969 | |||
| 25f4d23112 | |||
| 69958d6926 | |||
| 0051590c71 | |||
| 83e7ee817b | |||
| 68ac180c9a | |||
| 0875bb9e39 | |||
| 2dd6e50065 | |||
| 91125700d9 | |||
| c0f8424209 | |||
| 50ffc5c81b | |||
| 0045396efd | |||
| 8d5a86d02a | |||
| 4cb2426b49 | |||
| 700037c368 | |||
| 579fe27b15 | |||
| 2ee2382c9b | |||
| 0cb6c84abe | |||
| f23274284c | |||
| 1c6f4eb321 | |||
| bc650153ce | |||
| 954399ed5d | |||
| 10a78efcc3 | |||
| 38c1abba1d | |||
| fa949f467d | |||
| 4eab53bf5d | |||
| 44e75df99e | |||
| a3708769a4 | |||
| ee7ab36730 | |||
| 42f983f81a | |||
| 34a0cdf914 | |||
| 7ff73f1a8b | |||
| cd099380d1 | |||
| 751a45f132 | |||
| 7ff6f86e7b | |||
| 77a414d8df | |||
| 3bab6210c5 | |||
| 0c820bbde1 | |||
| 3e58447f12 | |||
| f9870938bf | |||
| c47aa57909 | |||
| ed82166745 | |||
| 83bda97cce | |||
| beded23530 | |||
| c2cea0fc5b | |||
| fca704f7ff | |||
| e459d1474c | |||
| 7c3d36b29a | |||
| 78eb255a78 | |||
| afd3cf448d | |||
| c28accb8e4 | |||
| 8dc6dd4ce6 | |||
| f032d50ed3 | |||
| 12fadbd0f8 | |||
| 0d930e75ee | |||
| 29f1cbce29 | |||
| a985cfafef | |||
| 973eeb67c7 | |||
| 9fc1e7e248 | |||
| b51df33bb6 | |||
| cd9aef36a1 | |||
| 35fca0a6a2 | |||
| 3d005da847 | |||
| 6c931d0485 | |||
| 9648a79f3f | |||
| e5b9b0cfdb | |||
| ba9759f44f | |||
| c214b3e79a | |||
| e56bc9e8dd | |||
| b2fe1c725d | |||
| 7bec82d053 | |||
| 4ccf2e5511 | |||
| 089202f72b | |||
| f29d493a0e | |||
| 9ea2a9250c | |||
| 95202ec411 | |||
| 17fbf79db8 | |||
| d6c0e3e7a6 | |||
| 527a90ed2d | |||
| a67b949960 | |||
| 845448a754 |
@@ -5,5 +5,18 @@
|
||||
"Bash(env)",
|
||||
"mcp__outline__read_document"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.rs) rustfmt --edition 2024 \"$f\" 2>/dev/null || true;; esac"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
---
|
||||
name: ipx-prod-check
|
||||
description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki and its traces in Tempo, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
|
||||
description: Look for problems in production ipx (the ipx-web, iPX and ipx-directory containers on Tower, Tower itself and the monitoring that watches them) from their logs in Loki, their traces in Tempo and the metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
|
||||
---
|
||||
|
||||
# Checking production ipx
|
||||
|
||||
Production logs one JSON object a line (`IPX_LOG_FORMAT=json`), which Alloy ships to Loki under
|
||||
`{container="iPX"}`, and sends traces to Tempo tagged
|
||||
`resource.deployment.environment.name="production"`. Anything without that tag is a daemon run by
|
||||
hand, not production. Loki and Tempo publish no query port, so use the script beside this file:
|
||||
Production is three containers in the Arcane project `ipx` on Tower
|
||||
(`/mnt/fast/arcane/projects/ipx/compose.yaml`), one request passing through them in this order:
|
||||
|
||||
| container | what | logs, in Loki | traces, in Tempo | metrics, in Prometheus |
|
||||
|---|---|---|---|---|
|
||||
| `ipx-web` | Caddy: serves the page, passes `/api` and `/media` to ipx (#203). Port 8099, the LAN and the tunnel | `{container="ipx-web"}`, JSON: a line a request (`http.log.access`), its own errors | service `ipx-web`: a span a request, passing `traceparent` on to ipx (#213) | none |
|
||||
| `iPX` | the daemon: API, scans, downloads, DeadAir | `{container="iPX"}`, JSON (`IPX_LOG_FORMAT=json`) | service `ipx`; passes `traceparent` to the directory | `job="ipx"`, its `/metrics` |
|
||||
| `ipx-directory` | the directory of public podcasts, `directory.ipxreader.app` (#204, #214) | `{container="ipx-directory"}`, JSON (`DIRECTORY_LOG_FORMAT=json`) | service `ipx-directory`: a span a request, joining ipx's trace when ipx called; a trace a refresher round (#213) | none |
|
||||
|
||||
Under them, Tower itself: `job="node"` (the node exporter: load, CPU, memory, disks). The Grafana
|
||||
dashboard `iPX` draws all of it in that order (`grafana/dashboard.py`). A trace without the
|
||||
production tag (`resource.deployment.environment.name="production"`, on all three) is a daemon run
|
||||
by hand, not production. Since #213 a request is one trace across the services it touched: a
|
||||
Directory page is ipx-web, then ipx, then the directory, so start from the trace, not the logs. Alloy ships the logs; Loki, Tempo and
|
||||
Prometheus publish no query port to this machine, so use the script beside this file:
|
||||
|
||||
```sh
|
||||
Q=.claude/skills/ipx-prod-check/query.py
|
||||
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
||||
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
||||
$Q traces '<TraceQL>' [since] # slowest first
|
||||
$Q spans '<TraceQL>' [since] # the matching spans, each with its own time, slowest first
|
||||
$Q trace <trace id> # one trace as a tree, with its log lines
|
||||
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
|
||||
$Q flame '<TraceQL>' [since] [out.svg] # where the matching spans' time went, summed over up to
|
||||
# 100 traces: a flame graph, and out.svg.folded for speedscope
|
||||
```
|
||||
|
||||
`since` is `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||
"Since the last deploy" is since that container started (each deploys on its own):
|
||||
|
||||
## What the log carries
|
||||
```sh
|
||||
for c in iPX ipx-web ipx-directory; do S=$(docker inspect $c -f '{{.State.StartedAt}}'); echo "$c $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m"; done
|
||||
```
|
||||
|
||||
Start every check by seeing that all three run, since Loki cannot see a container that is not:
|
||||
|
||||
```sh
|
||||
docker ps -a --filter name=iPX --filter name=ipx-web --filter name=ipx-directory --format '{{.Names}} {{.Status}}'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## ipx
|
||||
|
||||
### What the log carries
|
||||
|
||||
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
|
||||
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
|
||||
@@ -34,13 +65,17 @@ lines had no trace id, the access log's time was `ms`, and each event was logged
|
||||
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
|
||||
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
|
||||
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
|
||||
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
|
||||
| `ipx::daemon`, `ipx::cli`, `ipx::catalogue`, `ipx::scan`, ... | message, sometimes fields | start-up, shutdown, account and config messages, under the module that logs them; plain `ipx` before 2026-10-24, when main.rs was split (#356) |
|
||||
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
|
||||
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
|
||||
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
|
||||
updated), `download_done` (bytes), `download_error` (msg, url),
|
||||
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
|
||||
downloaded: the healthcheck's, every 30s), `error` (msg).
|
||||
downloaded: the healthcheck's, every 30s), `deadair_done` (feed, enclosure, `saved_s` at the
|
||||
default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`,
|
||||
`took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on
|
||||
one, which it then passes over until the next restart.
|
||||
|
||||
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
|
||||
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
|
||||
@@ -49,7 +84,42 @@ Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`):
|
||||
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
|
||||
`| json | __error__=""` drops them.
|
||||
|
||||
## The checks
|
||||
### What Prometheus carries
|
||||
|
||||
The daemon's own state between requests, which no log line or trace holds (#194): ipx serves
|
||||
`/metrics` on `[web] metrics_bind` (production: `192.168.1.130:9187`, LAN only), on a thread and
|
||||
port of its own, from memory alone, and Prometheus scrapes it every 15 s as `job="ipx"`. Kept
|
||||
from 2026-10-11 15:15 UTC. All gauges:
|
||||
|
||||
| metric | what |
|
||||
|---|---|
|
||||
| `ipx_db_connections{state="in_use"\|"idle"}`, `ipx_db_connections_max` | the database pool: in use reaching the max means requests and scans wait for a connection |
|
||||
| `ipx_worker_queue` | commands waiting for the sequential worker (scans, downloads, adds) |
|
||||
| `ipx_event_listeners` | open pages and socket clients on the live-update stream |
|
||||
| `ipx_torrent_slots_free` | of 2 |
|
||||
| `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_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_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_last_scan_age_seconds` | since a scan last finished; absent until one has since starting |
|
||||
| `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress |
|
||||
| `ipx_download_bytes_total` | counter; `rate()` is the download speed |
|
||||
| `ipx_event_queue`, `ipx_events_missed_total` | live updates unread by some listener; counter of those dropped for one that fell behind |
|
||||
| `ipx_deadair_backlog`, `ipx_deadair_busy_seconds` | files of shows with DeadAir on not yet looked at (episodes everyone heard through stay counted); how long it has been on its file |
|
||||
| `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_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_signals_judged_total{user,key}`, `ipx_signals_hits_total{user,key}` | counters: items Jev judged for Signals, and Signals they fit, by person and whose key paid (`server` or `own`, #278) (#254) |
|
||||
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"rate_limited"\|"error"}`, `ipx_jev_seconds_total`, `ipx_jev_paced_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended; their seconds, the wait for a turn included; and that wait alone. `malformed` is ipx's request at fault (#257); `rate_limited` is a 429 from TypeSafe, whose limits are 80 calls and 100,000 input tokens a second, which ipx paces itself under at 60 and 80,000 (#277) |
|
||||
| `ipx_jev_tokens_total{kind="input"\|"output",user,key}` | counter: tokens TypeSafe counted, as its answers say, by person and whose key paid: what Signals, icon picks and key checks cost (#275, #278). A call shared on the server's key is split by each person's share of its questions |
|
||||
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
|
||||
| `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) |
|
||||
|
||||
### The checks
|
||||
|
||||
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
|
||||
a count says something happened, the lines and traces say why.
|
||||
@@ -92,26 +162,227 @@ a count says something happened, the lines and traces say why.
|
||||
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
|
||||
the real queue (#113): files a scan will download on its own. Back-catalogue files are
|
||||
`held`, listed but not counted, so it is usually 0 or a handful.
|
||||
5. **Slow and failed traces.**
|
||||
5. **Slow and failed traces.** Look at ipx's requests as spans, not traces: a trace started by a
|
||||
tap in the page also holds that page's live updates and any audio it streamed, minutes long,
|
||||
so a list of traces over a few seconds is nothing but those. `spans` lists each matching span
|
||||
with its own time and route, slowest first:
|
||||
```
|
||||
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
|
||||
R='resource.deployment.environment.name="production" && resource.service.name="ipx"'
|
||||
$Q spans "{$R && span.http.route != \"\" && span.http.route != \"/api/events\" && span.http.route !~ \"/media/.*\" && duration > 1s} | select(span.http.route)"
|
||||
$Q spans "{$R && name=\"step\" && duration > 500ms} | select(span.of)"
|
||||
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||
$Q trace <id>
|
||||
```
|
||||
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
|
||||
A web request over a second is worth a look; the trace shows whether the time is in the
|
||||
handler or a scan it waited on.
|
||||
Group the first by hour and by route (`awk '{print substr($3,1,2), $NF}' | sort | uniq -c`),
|
||||
then lay the hours beside Tower's (the Tower section): load over 100 with processes blocked
|
||||
and memory under 4 GB is the host stalling, and every request of those minutes is slow with
|
||||
it, ipx's queries included. On 2026-10-18 that was 120 of 130 requests over a second, up to 43 s.
|
||||
What is left at quiet hours is ipx's own: open its trace and see which step holds the time. A
|
||||
request far longer than its steps added up waited outside them, for a connection or a thread.
|
||||
The second line finds steps over half a second wherever they ran; a step slow only once, first
|
||||
thing after hours idle, is Postgres reading from disk what it no longer had in memory (All
|
||||
Subscriptions' `entries_in`, a second where it is 60 ms warm).
|
||||
|
||||
Also check the container itself, since Loki cannot see a daemon that is not running:
|
||||
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||
`feed` or `fetch` span holding most of it (`$Q spans '{... && name="feed" && duration > 10s}'`),
|
||||
or a `download` far slower than its size explains.
|
||||
|
||||
Steps are `step` spans named by `of` (#187): an item list's `entries_in` (with the list asked for: `filter`, `sort`, `dir`, `offset`, `limit`, `search`), `sanitize`,
|
||||
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
|
||||
`missing_files`, `prune_abandoned_failures`, `reap_candidates`, `clean_directory` (once a day
|
||||
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
|
||||
span. DeadAir's work on a file is a `deadair` trace (feed, enclosure, cuts) with `probe`,
|
||||
`silencedetect`, `frame_bytes`, `store_plans`: seconds each, more is a file ffmpeg struggles
|
||||
with. A trace with no name in `$Q traces` started in the page (#190, #192): its root is the tap
|
||||
in the browser, which sends no span, so it holds the requests that tap made (API calls, the
|
||||
playlist and audio of a play, position saves, the page's own report) side by side.
|
||||
|
||||
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
|
||||
hint at.
|
||||
```
|
||||
$Q prom 'up{job="ipx"}'
|
||||
$Q prom 'max(max_over_time(ipx_db_connections{job="ipx",state="in_use"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_db_connections_max{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_worker_queue{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_runtime_queue{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(resets(ipx_uptime_seconds{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'ipx_process_resident_bytes{job="ipx"} - ipx_process_resident_bytes{job="ipx"} offset $range' 24h
|
||||
$Q prom 'ipx_process_open_fds{job="ipx"} - ipx_process_open_fds{job="ipx"} offset $range' 24h
|
||||
$Q prom 'ipx_runtime_tasks{job="ipx"} - ipx_runtime_tasks{job="ipx"} offset $range' 24h
|
||||
$Q prom 'max(max_over_time(ipx_worker_job_seconds{job="ipx"}[$range])) by (kind)' 24h
|
||||
$Q prom 'max(max_over_time(ipx_last_scan_age_seconds{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_signals_last_pass_age_seconds{job="ipx"}[$range]))' 24h
|
||||
```
|
||||
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
|
||||
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
||||
those in use; in use at 20, idle at 0, means something is waiting for one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
||||
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
|
||||
above 0 for more than a scrape or two means every thread is busy: something is blocking a
|
||||
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
|
||||
is a leak (a file or socket not closed, a task never ending); a step after a deploy is not. A
|
||||
reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4).
|
||||
|
||||
The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over
|
||||
15 minutes ago, is the worker stuck, which the healthcheck does not notice; its trace and the
|
||||
log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is
|
||||
where the worker's time went (reap was most of it until #193). Web requests in flight near
|
||||
the pool's max mean the pool is what they wait on. Live updates missed mean a page showed stale
|
||||
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
|
||||
Any `rate_limited` Jev call means the pacing is not holding ipx under TypeSafe's limits (#277); each
|
||||
`jev` span has `paced_ms`, its wait for a turn. Any `malformed` Jev call is ipx asking wrongly (#257); `key_refused` is someone's key; the Signals
|
||||
judge over 10 minutes since a pass is stuck on Jev: its `signals` trace shows which `jev` call.
|
||||
|
||||
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
|
||||
```
|
||||
$Q metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
|
||||
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
|
||||
$Q metric 'quantile_over_time(0.95, {container="iPX"} |= "\"ev\":\"list_shown\"" | json | __error__="" | unwrap ms [$range]) by (feed, filter)' 24h
|
||||
```
|
||||
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
|
||||
playlist (its trace has the playlist and audio requests), `media error 4` a file the browser
|
||||
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
|
||||
the minified `app.js`, so match the message to `web/src`. A `list_shown` over 1000 ms is a list someone waited for: its trace says whether the API or the network held it. `play_start` over 2000 ms, or a
|
||||
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
|
||||
|
||||
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
|
||||
|
||||
## ipx-web, the page
|
||||
|
||||
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`.
|
||||
The request's headers and a `?token=` are filtered out before it is written. Requests from
|
||||
`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.
|
||||
|
||||
```sh
|
||||
docker ps --filter name=iPX --format '{{.Status}}'
|
||||
docker logs --since 10m iPX 2>&1 | tail -5
|
||||
W='{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
|
||||
$Q metric "sum by (status) (count_over_time($W [\$range]))"
|
||||
$Q metric "sum by (request_uri) (count_over_time($W | status >= 500 [\$range]))"
|
||||
$Q metric "topk(10, quantile_over_time(0.95, $W | request_uri !~ \"/(api|media)/.*\" | unwrap duration [\$range]) by (request_uri))"
|
||||
$Q logs '{container="ipx-web"} | json | __error__="" | level=~"warn|error" | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"'
|
||||
```
|
||||
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
|
||||
directly: ask the user if a question needs the database.
|
||||
A 502 is ipx not answering Caddy: a restart (match the deploy) or ipx down; Caddy's error line
|
||||
names it (`dial tcp ...:8099: connect: connection refused`). The page itself (`/`, `/admin`)
|
||||
waits on `/api/page-check` before it is sent, so a slow page is usually a slow page-check: look it
|
||||
up in ipx's access log. 401 and 303 on `/` are people signed out being sent to `/login`, not a
|
||||
problem. Caddy's own notices at each start (no admin endpoint, no HTTPS or HTTP/3, its goodbye on
|
||||
SIGTERM) are by design and filtered out above.
|
||||
|
||||
Each access line carries the request's `traceID` (`| json` gives `traceID`): give it to `$Q trace`
|
||||
to see where the time went, Caddy's span with ipx's inside it. A slow page with ipx's span short is
|
||||
Caddy or the network; a long one is ipx.
|
||||
|
||||
```sh
|
||||
$Q spans '{resource.service.name="ipx-web" && span.url.path != "/api/events" && span.url.path !~ "/media/.*" && duration > 2s} | select(span.url.path)'
|
||||
$Q traces '{resource.service.name="ipx-web" && status = error}'
|
||||
```
|
||||
|
||||
Who reaches it: `request_remote_ip` is `192.168.64.1` for the tunnel and anything else on Tower
|
||||
(Docker's gateway), `192.168.1.130` for Tower itself, a LAN address for the LAN. Caddy passes
|
||||
Cloudflare Access's headers on only from `192.168.64.1` (`TUNNEL_PEER`), and ipx trusts them only
|
||||
from Caddy, with a valid signed token. Testing sign-in from Tower proves nothing about the tunnel
|
||||
(#209): send it from a container on another network.
|
||||
|
||||
## ipx-directory
|
||||
|
||||
It logs a line a request (`target` `ipx_directory::http`: `method`, `path`, `status`,
|
||||
`duration_ms`, `token`, the token's name and never the token), a line a snapshot (`message`
|
||||
`snapshot`: `instance`, `accepted`, `refused_private`, `refused_blocked`, `refused_invalid`), a
|
||||
line a refresher round (`feeds checked`: `checked`), a line a failed feed check (`feed check
|
||||
failed`: `feed`, the directory's id, and `error`), the admin's actions (`feed removed and
|
||||
blocked`, `feed unblocked`, `snapshot dropped`, each with `by`), and `config read again` when its
|
||||
token file changes. It has no metrics. Its traces (#213): a span a request (`http`), a child of
|
||||
ipx's when ipx called with a `traceparent`, and a trace a refresher round (`refresh`, `feeds`)
|
||||
with a `feed` span for each check. Its log lines carry no trace id; find the trace by time or by
|
||||
the feed.
|
||||
|
||||
```sh
|
||||
$Q traces '{resource.service.name="ipx-directory" && duration > 2s}'
|
||||
$Q traces '{resource.service.name="ipx-directory" && name="feed" && duration > 10s}'
|
||||
$Q traces '{resource.service.name="ipx" && name="directory_share"}'
|
||||
```
|
||||
|
||||
A slow Directory page in ipx is usually its `http` span in the directory: the trace says whether.
|
||||
A refresher round far over a minute is one publisher's feed holding it, the slow `feed` span.
|
||||
|
||||
```sh
|
||||
D='{container="ipx-directory"} | json | __error__=""'
|
||||
$Q metric "sum by (status, token) (count_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" [\$range]))"
|
||||
$Q metric "topk(5, max_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" | unwrap duration_ms [\$range]) by (path))"
|
||||
$Q logs "$D | level=~\"WARN|ERROR\""
|
||||
$Q logs "$D | message=\"snapshot\"" 7d
|
||||
$Q metric "sum(count_over_time($D | message=\"snapshot\" [\$range])) by (instance)"
|
||||
$Q metric "sum by (feed, error) (count_over_time($D | message=\"feed check failed\" [\$range]))" 7d
|
||||
$Q metric "sum(sum_over_time($D | message=\"feeds checked\" | unwrap checked [\$range]))" 24h
|
||||
```
|
||||
|
||||
Expect a snapshot from each server when what it shares changes and every half hour, so about 48 a
|
||||
day each; one every half minute is ipx resending what has not changed (#211, fixed). A burst of
|
||||
401s with an empty `token` is someone without a token trying the public address, expected now and
|
||||
then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A snapshot whose
|
||||
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
|
||||
in that server's log, since the directory refuses it but the sender should not have sent it. Many
|
||||
`feed check failed` lines for one feed are the publisher's, not the directory's. Each feed is
|
||||
checked every twelve hours (#243), 25 at a time, so `feeds checked` over a day should be about
|
||||
twice the feeds listed; none for over twelve hours means the refresher is stuck. `could not store a feed
|
||||
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
|
||||
`databases` project's Postgres). A config that does not load (`config changed but does not
|
||||
load`) keeps the old tokens: worth fixing before someone's new token is needed. Weekly, `Apple
|
||||
Podcasts synced` (`shows`, `accepted`) brings Apple's charts in as the `apple-podcasts` snapshot
|
||||
(#252); `Apple Podcasts' sync failed` retries in an hour, worth an issue when it keeps failing, and
|
||||
`Apple's podcast categories are not ipx's` (`added`, `gone`) is an issue to update `CATEGORIES` in
|
||||
`crates/feed`.
|
||||
|
||||
## Tower
|
||||
|
||||
The three share Tower with everything else, so a slow or failing ipx can be the host. The node
|
||||
exporter (`job="node"`) has the history; twelve threads.
|
||||
|
||||
```sh
|
||||
$Q prom 'max_over_time(node_load1{job="node"}[$range])' 24h
|
||||
$Q prom 'min_over_time(node_memory_MemAvailable_bytes{job="node"}[$range]) / 1e9' 24h
|
||||
$Q prom 'sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$range]))' 1h
|
||||
$Q prom 'max by (device) (rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$range]))' 1h
|
||||
```
|
||||
|
||||
For when, step back with `offset`: `max_over_time(node_load1[10m] offset 60m)`. Load far above
|
||||
twelve with little CPU busy is processes waiting, on a disk or on the user shares: on 2026-10-12
|
||||
debug builds linking through `/mnt/user` (shfs, FUSE) took load to 248 with mostly system time and
|
||||
stalled everything on the shares, ipx's downloads included. Available memory near nothing makes
|
||||
it worse. Disk busy near 100% on `nvme1n1` is `/mnt/fast`, appdata and Postgres's data; on `md*`
|
||||
the array behind `/mnt/user`, the downloads. Say what was running at the time if you can tell
|
||||
(`docker stats --no-stream`); a host problem is the user's, not an ipx issue, unless ipx caused it.
|
||||
|
||||
## The monitoring itself
|
||||
|
||||
A quiet dashboard can be a dead exporter. Check what Prometheus scrapes and that each
|
||||
container's lines are reaching Loki:
|
||||
|
||||
```sh
|
||||
curl -s http://192.168.1.130:9090/api/v1/targets | python3 -c 'import json,sys
|
||||
for t in json.load(sys.stdin)["data"]["activeTargets"]: print(t["health"], t["labels"]["job"], t["scrapeUrl"], t.get("lastError","")[:80])'
|
||||
for c in iPX ipx-web ipx-directory; do echo "$c $($Q metric "sum(count_over_time({container=\"$c\"} [1h]))" 1h | tail -1)"; done
|
||||
docker ps -a --filter label=com.docker.compose.project=monitoring --format '{{.Names}} {{.Status}}'
|
||||
```
|
||||
|
||||
`ipx` and `node` should be `up`. Every monitoring container should be running; after Docker
|
||||
restarts, one can be left `Exited (255)` though its policy is `unless-stopped` (Prometheus, on
|
||||
2026-10-12, when Docker lost its exit during an overload): `docker start` it and say so. blocky
|
||||
and nut on 192.168.2.21 are another machine, not ipx's; leave them. A container with no lines in
|
||||
an hour while it runs means Alloy is not shipping it; Alloy can take a few minutes to pick up a
|
||||
container after a deploy.
|
||||
|
||||
## Rules
|
||||
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
|
||||
BUFFERS)` of any query is allowed, read only, as CLAUDE.md's "Production's database" shows; any
|
||||
other query, ask the user first.
|
||||
|
||||
## What to do with what you find
|
||||
|
||||
@@ -130,12 +401,14 @@ Put in each issue what would let someone pick it up cold: the LogQL or TraceQL t
|
||||
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
|
||||
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
|
||||
|
||||
Finish with a short report to the user: what is healthy, what is wrong (with the issue numbers),
|
||||
and anything you could not tell from logs and traces alone. Do not fix things unless asked; the
|
||||
Finish with a short report to the user, a line for each of ipx-web, ipx, the directory, Tower and
|
||||
the monitoring: what is healthy, what is wrong (with the issue numbers), and anything you could
|
||||
not tell from logs, traces and metrics alone. Do not fix things unless asked; the
|
||||
check is for finding them.
|
||||
|
||||
## When the checks come back empty
|
||||
|
||||
Check that there is data before concluding all is well: `$Q metric 'sum(count_over_time({container="iPX"} [1h]))' 1h`
|
||||
should be in the hundreds or more. Nothing at all means Alloy is not shipping (it can take a few
|
||||
minutes to pick up a container after a deploy), or the container is down.
|
||||
Check that there is data before concluding all is well (the monitoring section's per-container
|
||||
count): iPX should have hundreds of lines an hour or more, ipx-web at least its healthchecks'
|
||||
120, ipx-directory a few dozen. Nothing at all means Alloy is not shipping it, or the container
|
||||
is down.
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ask production's Loki or Tempo a question, from anywhere that can run docker on Tower.
|
||||
"""Ask production's Loki, Tempo or Prometheus a question, from anywhere that can run docker on Tower.
|
||||
|
||||
query.py logs '<LogQL log query>' [since] lines, newest first
|
||||
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
|
||||
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
||||
query.py spans '<TraceQL query>' [since] the matching spans, each with its own time
|
||||
query.py trace <trace id> one trace's spans, as a tree
|
||||
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
|
||||
query.py flame '<TraceQL query>' [since] [out.svg] where the matching spans' time
|
||||
went, summed over up to 100 traces, as a
|
||||
flame graph (and out.svg.folded)
|
||||
|
||||
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
|
||||
host, so each call runs a throwaway alpine container on the monitoring project's network.
|
||||
"""
|
||||
import json, subprocess, sys, time, urllib.parse
|
||||
import base64, html, json, subprocess, sys, time, urllib.parse, zlib
|
||||
|
||||
NET = "monitoring_default"
|
||||
|
||||
@@ -22,11 +27,108 @@ def fetch(url):
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def fetch_many(urls):
|
||||
# One container for them all: a docker run apiece is about a second, a hundred traces a minute and a half.
|
||||
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "sh", "-c",
|
||||
'for u; do wget -qO- "$u"; echo; echo @@; done', "--", *urls],
|
||||
capture_output=True, text=True, timeout=600)
|
||||
return [json.loads(part) for part in out.stdout.split("\n@@\n") if part.strip()]
|
||||
|
||||
|
||||
def trace_spans(r):
|
||||
return [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||
|
||||
|
||||
def span_hex(sid):
|
||||
# A trace's span ids are base64 and the search's are hex.
|
||||
return sid if len(sid) == 16 and all(c in "0123456789abcdef" for c in sid) else base64.b64decode(sid).hex()
|
||||
|
||||
|
||||
def label(sp):
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
|
||||
return f"step:{attrs['of']}" if sp["name"] == "step" and attrs.get("of") else sp["name"]
|
||||
|
||||
|
||||
def fold(spans, roots):
|
||||
"""Self time in µs for each stack under the spans in `roots` (hex ids), identical stacks summed.
|
||||
|
||||
shortcut: only as fine as the spans; a route's time outside its `step`s is its own frame. Add
|
||||
steps in ipx where that is large. Children run at once (try_join) can add up past their
|
||||
parent, so a frame's self time is floored at 0.
|
||||
"""
|
||||
kids, stacks = {}, {}
|
||||
for sp in spans:
|
||||
kids.setdefault(span_hex(sp.get("parentSpanId") or "0" * 16), []).append(sp)
|
||||
|
||||
def walk(sp, path):
|
||||
path = f"{path};{label(sp)}" if path else label(sp)
|
||||
dur = lambda s: (int(s["endTimeUnixNano"]) - int(s["startTimeUnixNano"])) // 1000
|
||||
cs = kids.get(span_hex(sp["spanId"]), [])
|
||||
stacks[path] = stacks.get(path, 0) + max(0, dur(sp) - sum(dur(c) for c in cs))
|
||||
for c in cs:
|
||||
walk(c, path)
|
||||
|
||||
for sp in spans:
|
||||
if span_hex(sp["spanId"]) in roots:
|
||||
walk(sp, "")
|
||||
return stacks
|
||||
|
||||
|
||||
def check_fold():
|
||||
def sp(sid, parent, name, start, end, of=None):
|
||||
attrs = [{"key": "of", "value": {"stringValue": of}}] if of else []
|
||||
return {"spanId": sid, "parentSpanId": parent, "name": name, "attributes": attrs,
|
||||
"startTimeUnixNano": str(start * 1000), "endTimeUnixNano": str(end * 1000)}
|
||||
a, b, c, d = (f"{i:016x}" for i in range(1, 5))
|
||||
spans = [sp(a, "", "GET /x", 0, 100), sp(b, a, "step", 0, 60, "q"), sp(c, a, "step", 0, 60, "q"),
|
||||
sp(d, b, "inner", 0, 10)]
|
||||
assert fold(spans, {a}) == {"GET /x": 0, "GET /x;step:q": 110, "GET /x;step:q;inner": 10}, fold(spans, {a})
|
||||
assert fold(spans, {b}) == {"step:q": 50, "step:q;inner": 10}
|
||||
# base64 ids, as a trace sends them, match the search's hex.
|
||||
assert span_hex(base64.b64encode(bytes.fromhex(a)).decode()) == a
|
||||
print("fold: ok")
|
||||
|
||||
|
||||
def flame_svg(stacks, n, title):
|
||||
tree = {} # name -> [total, children]
|
||||
for path, us in stacks.items():
|
||||
node = tree
|
||||
for part in path.split(";"):
|
||||
node = node.setdefault(part, [0, {}])
|
||||
node[0] += us
|
||||
node = node[1]
|
||||
total = sum(v[0] for v in tree.values()) or 1
|
||||
width, row, rects = 1200, 18, []
|
||||
|
||||
def draw(node, x, depth, trail):
|
||||
for name, (us, sub) in sorted(node.items()):
|
||||
w = us / total * width
|
||||
stack = f"{trail};{name}" if trail else name
|
||||
hue = 10 + zlib.crc32(name.encode()) % 45
|
||||
tip = f"{stack}\n{us / 1000:.0f} ms in all, {100 * us / total:.1f}%, {us / 1000 / n:.1f} ms a request"
|
||||
text = html.escape(name[: int(w / 7)]) if w > 30 else ""
|
||||
rects.append(f'<g><title>{html.escape(tip)}</title><rect x="{x:.1f}" y="{30 + depth * row}" '
|
||||
f'width="{max(w - 0.5, 0.1):.1f}" height="{row - 1}" fill="hsl({hue},85%,62%)"/>'
|
||||
f'<text x="{x + 3:.1f}" y="{30 + depth * row + 13}">{text}</text></g>')
|
||||
draw(sub, x, depth + 1, stack)
|
||||
x += w
|
||||
|
||||
draw(tree, 0, 0, "")
|
||||
depth = max(p.count(";") for p in stacks) + 1 if stacks else 1
|
||||
head = html.escape(f"{title}: {n} request(s), {total / 1000 / n:.1f} ms each on average")
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{40 + depth * row}" '
|
||||
f'font-family="monospace" font-size="11"><rect width="100%" height="100%" fill="#fff"/>'
|
||||
f'<text x="4" y="18" font-size="13">{head}</text>{"".join(rects)}</svg>')
|
||||
|
||||
|
||||
def seconds(since):
|
||||
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["flame", "--check"]:
|
||||
return check_fold()
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
kind, q = sys.argv[1], sys.argv[2]
|
||||
@@ -53,12 +155,51 @@ def main():
|
||||
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
|
||||
for t in traces:
|
||||
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
|
||||
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {t.get('rootTraceName', '')}")
|
||||
# No root name: its root span is a tap in the page, which the browser never sends.
|
||||
name = t.get("rootTraceName") or "(from the page)"
|
||||
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
|
||||
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
||||
elif kind == "spans":
|
||||
# The spans that matched, each with its own time: a trace's length says nothing about a
|
||||
# slow request in it when the trace also holds a page's live updates or an audio stream.
|
||||
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=200&spss=20")
|
||||
rows = []
|
||||
for t in r.get("traces", []):
|
||||
for ss in t.get("spanSets") or [t.get("spanSet") or {}]:
|
||||
for sp in ss.get("spans", []):
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
|
||||
what = attrs.get("http.route") or attrs.get("of") or attrs.get("url.path") or sp.get("name") or ""
|
||||
rows.append((int(sp.get("durationNanos", 0)) / 1e6, int(sp["startTimeUnixNano"]), t["traceID"], what))
|
||||
for ms, ts, tid, what in sorted(rows, reverse=True):
|
||||
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(ts / 1e9))
|
||||
print(f"{ms:>8.0f}ms {when}Z {tid} {what}")
|
||||
print(f"-- {len(rows)} span(s)", file=sys.stderr)
|
||||
elif kind == "prom":
|
||||
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
|
||||
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")
|
||||
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
|
||||
for s in rows:
|
||||
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")}
|
||||
print(f"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
|
||||
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||
elif kind == "flame":
|
||||
out = sys.argv[4] if len(sys.argv) > 4 else "flame.svg"
|
||||
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100&spss=20")
|
||||
found = r.get("traces", [])
|
||||
roots = {sp["spanID"] for t in found for ss in t.get("spanSets") or [t.get("spanSet") or {}]
|
||||
for sp in ss.get("spans", [])}
|
||||
stacks = {}
|
||||
for tr in fetch_many([f"http://tempo:3200/api/traces/{t['traceID']}" for t in found]):
|
||||
for path, us in fold(trace_spans(tr), roots).items():
|
||||
stacks[path] = stacks.get(path, 0) + us
|
||||
n = len(roots) or 1
|
||||
with open(out + ".folded", "w") as f:
|
||||
f.writelines(f"{path} {us}\n" for path, us in sorted(stacks.items()))
|
||||
with open(out, "w") as f:
|
||||
f.write(flame_svg(stacks, n, q))
|
||||
print(f"-- {len(roots)} span(s) in {len(found)} trace(s): {out}, {out}.folded", file=sys.stderr)
|
||||
elif kind == "trace":
|
||||
r = fetch(f"http://tempo:3200/api/traces/{q}")
|
||||
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||
spans = trace_spans(fetch(f"http://tempo:3200/api/traces/{q}"))
|
||||
kids = {}
|
||||
for sp in spans:
|
||||
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
|
||||
|
||||
3
.git-blame-ignore-revs
Normal file
3
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,3 @@
|
||||
# Formatting only: `git config blame.ignoreRevsFile .git-blame-ignore-revs` makes blame skip these.
|
||||
# rustfmt adopted
|
||||
692a5102179b958b5d15cecc4f5e6f16b2955b76
|
||||
26
.githooks/pre-commit
Executable file
26
.githooks/pre-commit
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
|
||||
# checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the
|
||||
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory,
|
||||
# and both use ipx-feed. A commit to the page alone runs nothing here.
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACMD)
|
||||
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
|
||||
if ! cargo fmt --all --check >/dev/null 2>&1; then
|
||||
echo "pre-commit: the Rust is not formatted; run cargo fmt --all" >&2
|
||||
exit 1
|
||||
fi
|
||||
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
|
||||
pkgs="ipx-feed ipx-directory ipx"
|
||||
elif echo "$staged" | grep -q '^crates/directory/'; then
|
||||
pkgs="ipx-directory ipx"
|
||||
elif echo "$staged" | grep -q '^crates/ipx/'; then
|
||||
pkgs="ipx"
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
args=$(for p in $pkgs; do printf ' -p %s' "$p"; done)
|
||||
# shellcheck disable=SC2086 # one word a flag
|
||||
if ! cargo clippy $args --all-targets -q -- -D warnings; then
|
||||
echo "pre-commit: clippy has warnings in $pkgs; fix them, or allow one where it is wrong, with a comment saying why" >&2
|
||||
exit 1
|
||||
fi
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@
|
||||
|
||||
.claude/settings.local.json
|
||||
__pycache__/
|
||||
site/dist/
|
||||
|
||||
243
CLAUDE.md
243
CLAUDE.md
@@ -7,18 +7,22 @@ made here.
|
||||
## Where things are
|
||||
|
||||
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
||||
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
||||
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
||||
Arcane project `ipx`: `/mnt/fast/arcane/projects/ipx/compose.yaml`. That file is what runs, and
|
||||
the containers of the split (#207) join it there. `docker-compose.yml` in this repo is a sample for
|
||||
others, ipx alone on SQLite; editing it changes nothing in production. Until 2026-10-12 ipx was a
|
||||
service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
||||
|
||||
| | Host | In the container |
|
||||
|---|---|---|
|
||||
| Image | `192.168.1.130:5000/ipx:latest` | |
|
||||
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
||||
| Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
|
||||
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
|
||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
||||
@@ -47,33 +51,70 @@ Deploying a change is: build and push the image, then pull it and recreate the c
|
||||
|
||||
```sh
|
||||
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx
|
||||
docker logs --tail 20 iPX
|
||||
```
|
||||
|
||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||
The directory is the same, with its own image:
|
||||
|
||||
```sh
|
||||
docker buildx build -f Dockerfile.directory --tag 192.168.1.130:5000/ipx-directory:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory
|
||||
```
|
||||
|
||||
And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one:
|
||||
|
||||
```sh
|
||||
docker buildx build -f Dockerfile.web --tag 192.168.1.130:5000/ipx-web:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-web
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-web
|
||||
```
|
||||
|
||||
The home page (`site/`, #216) is not on Tower. `sh site/build.sh` puts it in `site/dist` with the
|
||||
typeface, logo and icon from `web/`, and it goes to the Cloudflare Pages project `ipxreader` by
|
||||
direct upload, since there is no wrangler login here: hash each file as wrangler does (BLAKE3 of
|
||||
its base64 contents plus its extension, the first 32 hex digits, with the `blake3-wasm` package),
|
||||
ask the project for an upload token (`GET .../pages/projects/ipxreader/upload-token`), send the
|
||||
files to `/pages/assets/upload` with that token, then create a deployment
|
||||
(`POST .../pages/projects/ipxreader/deployments`) with the manifest of paths and hashes and
|
||||
`_headers`. Its screenshots (`site/shots`) come from a scratch iPX on public feeds, never
|
||||
production's, which would show whose subscriptions are whose.
|
||||
|
||||
Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own requests reach the
|
||||
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
|
||||
container on another network, as cloudflared's are (#209).
|
||||
|
||||
The three containers resolve names through Cloudflare (`dns: [1.1.1.1, 1.0.0.1]` in the compose
|
||||
file), not the router: the LAN's DNS is NextDNS, which answers for a domain it filters with its own
|
||||
block page, and ipx then logged a feed as failing with `invalid peer certificate: UnknownIssuer`
|
||||
(tomrenner.com, 2026-10-17). Docker's own DNS still answers for the services' names first.
|
||||
|
||||
**Name the service.** A bare `up -d` recreates every container in the project, which once the
|
||||
split lands means the frontend and directory too. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||
|
||||
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
|
||||
this host. There is no Docker Hub login here, and the build asks about `debian:bookworm-slim` and
|
||||
`rust:1-slim-bookworm` every time unless they are already stored locally. Pull them from Google's
|
||||
this host. There is no Docker Hub login here, and the build asks about `debian:trixie-slim` and
|
||||
`rust:1-slim-trixie` every time unless they are already stored locally. Pull them from Google's
|
||||
mirror and tag them; the build then uses the local copies without asking Docker Hub:
|
||||
|
||||
```sh
|
||||
docker pull mirror.gcr.io/library/debian:bookworm-slim
|
||||
docker tag mirror.gcr.io/library/debian:bookworm-slim debian:bookworm-slim
|
||||
docker pull mirror.gcr.io/library/rust:1-slim-bookworm
|
||||
docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm
|
||||
docker pull mirror.gcr.io/library/debian:trixie-slim
|
||||
docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
|
||||
docker pull mirror.gcr.io/library/rust:1-slim-trixie
|
||||
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.
|
||||
|
||||
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
|
||||
the daemon answers there and can read its database, not just that the web port is up. The socket
|
||||
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or
|
||||
download does not fail the check; it also means a worker stuck on one job would still pass. The
|
||||
container restarts on its own after a reboot.
|
||||
The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower
|
||||
fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
|
||||
each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
|
||||
and `ipx status` against the control socket, which says the daemon answers and can read its
|
||||
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
|
||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||
@@ -87,13 +128,23 @@ running the command and kills the session (exit 144), which has happened more th
|
||||
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
||||
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
||||
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
||||
make up each page's script. `build.rs` runs
|
||||
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
|
||||
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
|
||||
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
|
||||
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
|
||||
deploy changes them and their names. So **every page change needs a
|
||||
rebuild** before it is visible, and building needs node and `npm ci` run once.
|
||||
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
|
||||
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
|
||||
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
|
||||
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
|
||||
serves no page. The page loads its script as `/app.js?v=<hash of its contents>`, and `/app.css`
|
||||
and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser
|
||||
keeps them until a deploy changes them and their names. So **every page change needs ipx-web
|
||||
rebuilt and deployed**, not ipx, and building needs node and `npm ci` run once.
|
||||
|
||||
Caddy asks ipx before it serves a page (`forward_auth`): `/api/page-check` sends a browser that is
|
||||
not signed in to `/login` and anyone but an admin away from `/admin` (and refuses them
|
||||
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
|
||||
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
|
||||
for everyone, so what ipx used to write into it per person is the page's own job now:
|
||||
`web/boot.js`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
|
||||
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
|
||||
theme).
|
||||
|
||||
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
||||
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
||||
@@ -105,13 +156,19 @@ After any edit to it:
|
||||
```sh
|
||||
npx tsc -p .
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js
|
||||
```
|
||||
|
||||
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
|
||||
The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
|
||||
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
|
||||
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
|
||||
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
|
||||
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
|
||||
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
||||
function, `String.replace` silently matched nothing, and the whole UI died with a
|
||||
`ReferenceError` while every server-side test passed.
|
||||
`ReferenceError` while every server-side test passed. The third loads the same built script and
|
||||
calls its helpers directly (#234): a pure function added to the page gets its check there.
|
||||
|
||||
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
||||
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
||||
@@ -119,15 +176,57 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||
cargo fmt --all --check # rustfmt, as rustfmt.toml says; the pre-commit hook refuses a commit without it
|
||||
cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
|
||||
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
|
||||
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
|
||||
cargo test # parsing, filters, retention, schedules, SQL, per-user state
|
||||
npx tsc -p . # type-checks web/src
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/contrast.js # every theme's palette against WCAG AA
|
||||
npx playwright test # 66 browser tests against a real daemon on fixture feeds
|
||||
npx playwright test # the browser tests, against a real daemon on fixture feeds
|
||||
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||
```
|
||||
|
||||
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
|
||||
directory followed in the same command by its tests' compile took Tower's load past 200 and the
|
||||
session was stopped. So:
|
||||
|
||||
* One cargo command at a time: a build, then the tests, as separate commands, never chained or
|
||||
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
||||
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
|
||||
threads twice over, wait for it to come down before starting another.
|
||||
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
|
||||
|
||||
| changed | run |
|
||||
|---|---|
|
||||
| `web/` alone | the three page checks; `npx playwright test -g '<what changed>'`; no cargo |
|
||||
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
|
||||
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
|
||||
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
|
||||
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
|
||||
|
||||
The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end
|
||||
of a change to the page or the API; never `cargo test` for the workspace in one command.
|
||||
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
|
||||
`/mnt/user` (FUSE) once took the load to 248 on its own.
|
||||
* **Memory is the tighter limit.** Tower has 31 GB, no swap, and the containers leave about 4-7
|
||||
GB. A build past that stalls everything on page faults: load 160 with the CPU idle and the
|
||||
disks quiet, many processes blocked (`node_procs_blocked`, `node_vmstat_pgmajfault`). On
|
||||
2026-10-18 linking a 431 MB test binary did it for 54 minutes. So `[profile.dev]` keeps only
|
||||
line tables and dependencies no debug info; keep it that way, and look at `MemAvailable` beside
|
||||
the load.
|
||||
|
||||
The Rust is formatted by rustfmt (`rustfmt.toml`) and kept free of clippy's warnings. Claude Code
|
||||
formats each `.rs` file it edits (a hook in `.claude/settings.json`), and the pre-commit hook refuses
|
||||
a commit touching Rust that is unformatted or has a clippy warning. A clone turns on the hook once: `git config core.hooksPath .githooks`; and `git config blame.ignoreRevsFile
|
||||
.git-blame-ignore-revs` keeps blame past the commit that first formatted everything. A clippy
|
||||
suggestion is not always the same code: on adopting it, `fold(false, |any, id| remove(id) || any)`
|
||||
was offered as `any(...)`, which stops at the first and would have removed one feed of many. A lint
|
||||
that is wrong for a line gets `#[allow(clippy::...)]` there with a comment saying why.
|
||||
|
||||
Things about the browser suite that have cost time:
|
||||
|
||||
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
|
||||
@@ -155,8 +254,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.
|
||||
* 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.
|
||||
* `ipx status`, the Docker healthcheck, runs every second throughout, and a run fails if one takes
|
||||
the healthcheck's 5s.
|
||||
* `ipx status` runs every second throughout, and a run fails if one takes 5s.
|
||||
* 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
|
||||
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
||||
@@ -170,6 +268,25 @@ seeking through files, a flood of wrong passwords. Things about them:
|
||||
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
||||
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
||||
|
||||
## Production's database
|
||||
|
||||
The owner allows `EXPLAIN (ANALYZE, BUFFERS)` of any query against production's Postgres, to see
|
||||
where a query's time goes; nothing else without asking. Run it read only, so nothing can change:
|
||||
|
||||
```sh
|
||||
U=$(grep -E '^IPX_DATABASE_URL=' /mnt/fast/arcane/projects/ipx/ipx.env | cut -d= -f2- | tr -d '"')
|
||||
psql "$U" -X -v ON_ERROR_STOP=1 <<'SQL'
|
||||
BEGIN READ ONLY;
|
||||
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
```
|
||||
|
||||
Never print the URL: it carries the password. Write the query as ipx sends it (`$1` parameters
|
||||
become `PREPARE`/`EXECUTE`, or values), for a real account. A query fast here and slow in ipx's
|
||||
trace is time outside the query: on 2026-10-15 the feed list's count took 5 ms in Postgres and 366
|
||||
ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
|
||||
|
||||
## Things that are true and easy to get wrong
|
||||
|
||||
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
|
||||
@@ -203,7 +320,7 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
||||
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
||||
different task. `ipx status` checks the control socket and the database; to see the worker
|
||||
getting through its jobs, watch for `scan complete` in the log.
|
||||
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
|
||||
* **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.**
|
||||
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
||||
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
||||
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
||||
@@ -221,19 +338,55 @@ Comments explain **why**, not what. If a line looks odd, the comment says what w
|
||||
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
|
||||
addressed to the person using it.
|
||||
|
||||
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
||||
Every change gets one line under `## [Unreleased]` in the changelog of the service it changes, in
|
||||
its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||
Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md)
|
||||
(the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page
|
||||
and ipx-web) and [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory). A
|
||||
change to more than one gets a line in each, each saying what that service does differently. The
|
||||
monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not
|
||||
a service anyone uses and get no line. Say it the way someone using ipx would notice it. When there is more to
|
||||
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
||||
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
||||
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||
|
||||
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
||||
`[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit
|
||||
`vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit,
|
||||
set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
|
||||
in the logo's tooltip instead of claiming to be the last release. The release that follows can
|
||||
still be a minor or major one; `-dev` only says the work comes after `X.Y.Z`.
|
||||
Each service has its own version and is released on its own, when its changelog has something to
|
||||
release:
|
||||
|
||||
| service | version in | changelog | tag |
|
||||
|---|---|---|---|
|
||||
| ipx | `crates/ipx/Cargo.toml` | `crates/ipx/CHANGELOG.md` | `vX.Y.Z`, as every release before the split |
|
||||
| ipx-directory | `crates/directory/Cargo.toml` | `crates/directory/CHANGELOG.md` | `directory-vX.Y.Z` |
|
||||
| ipx-web, the page | `package.json` | `web/CHANGELOG.md` | `web-vX.Y.Z` |
|
||||
|
||||
`crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it
|
||||
is a line in the changelog of each service it changes. The page shows ipx's version and its own in
|
||||
the logo's tooltip; the directory logs its own at start; each binary answers `--version`.
|
||||
|
||||
Cutting a release of one: first read its `[Unreleased]` against its last release, not line by
|
||||
line, and rewrite it as what changed between the two. Lines go in as the work happens, so they
|
||||
record the way there: a feature added and then reworked in the same release is one line saying
|
||||
what it is now, under Added; something added and taken out again before the release, or a fix for
|
||||
a bug no release ever had, is not there at all; and "instead of" and "no longer" compare with the
|
||||
last release, not with last week. Issue numbers stay, all of them, on the line that survives. Then
|
||||
rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it,
|
||||
set the service's version to `X.Y.Z` (dropping `-dev`), tag the commit with the service's tag, and
|
||||
update the compare links at the bottom of its changelog. Push the commit and the tag, build and
|
||||
deploy the service's image, then `sh scripts/release.sh ipx|directory|web` makes the Gitea
|
||||
release: that version's changelog section as its notes, and a tarball of what the image runs (the
|
||||
binary, or the page's files with the Caddyfile) attached, checked to be that version. Then, in the
|
||||
next commit, set its version
|
||||
to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
|
||||
instead of claiming to be the last release. The release that follows can still be a minor or major
|
||||
one; `-dev` only says the work comes after `X.Y.Z`. A service never released is at `0.1.0-dev`,
|
||||
working towards its first release, `0.1.0`.
|
||||
|
||||
**Traces first, logs second.** Anything that does work (a service, a handler, a job, a call to
|
||||
another service) gets spans, and passes `traceparent` on to whatever it calls, so one trace in
|
||||
Tempo follows a request or a job across ipx-web, ipx and the directory. A trace shows where the
|
||||
time went and what called what; a log line only that something happened. Log what a trace cannot
|
||||
carry: events worth counting on the dashboard, warnings and errors. A new service or route without
|
||||
traces is not finished.
|
||||
|
||||
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
||||
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
||||
@@ -243,6 +396,10 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
||||
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||
issue left open.
|
||||
|
||||
## Styleguide
|
||||
|
||||
* Best effort to adhere to the rust style guide found at /src/styleguide/rustguide.md
|
||||
|
||||
<!-- rtk-instructions v2 -->
|
||||
# Command output
|
||||
|
||||
|
||||
231
Cargo.lock
generated
231
Cargo.lock
generated
@@ -17,6 +17,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -25,9 +40,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.1.4"
|
||||
version = "4.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
||||
checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"html5ever",
|
||||
@@ -445,6 +460,27 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -620,9 +656,12 @@ version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -750,9 +789,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
|
||||
dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
@@ -761,9 +800,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -1398,9 +1437,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1516,12 +1555,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1820,26 +1860,25 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.10.4-dev"
|
||||
version = "1.5.1-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"atom_syndication",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-directory",
|
||||
"ipx-feed",
|
||||
"jsonwebtoken",
|
||||
"librqbit",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
"opml",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"reqwest",
|
||||
"rss",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1854,6 +1893,58 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipx-directory"
|
||||
version = "1.4.1-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-feed",
|
||||
"mimalloc",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipx-feed"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"atom_syndication",
|
||||
"chrono",
|
||||
"http",
|
||||
"hyper-util",
|
||||
"opml",
|
||||
"pulldown-cmark",
|
||||
"quick-xml 0.42.0",
|
||||
"reqwest",
|
||||
"rss",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -2058,6 +2149,15 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "libredox"
|
||||
version = "0.1.23"
|
||||
@@ -2429,9 +2529,9 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
@@ -2497,6 +2597,15 @@ dependencies = [
|
||||
"rapidhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2747,6 +2856,7 @@ version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"http",
|
||||
"httpdate",
|
||||
"opentelemetry",
|
||||
@@ -2878,9 +2988,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"serde",
|
||||
@@ -2888,9 +2998,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -2898,9 +3008,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
@@ -2908,9 +3018,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -3023,6 +3133,24 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
@@ -3437,9 +3565,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.44"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -3501,9 +3629,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -4172,9 +4300,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
@@ -4184,9 +4312,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -4587,6 +4715,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -4962,9 +5091,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
@@ -4983,9 +5112,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -5394,3 +5523,31 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.1+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
38
Cargo.toml
38
Cargo.toml
@@ -1,26 +1,41 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.10.4-dev"
|
||||
[workspace]
|
||||
members = ["crates/ipx", "crates/feed", "crates/directory"]
|
||||
resolver = "3"
|
||||
|
||||
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
|
||||
# (the page), released and tagged on its own; see CLAUDE.md.
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# None of ours needs unsafe, and the style guide asks for none where safe code will do.
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.dependencies]
|
||||
ipx-feed = { path = "crates/feed" }
|
||||
ipx-directory = { path = "crates/directory" }
|
||||
ammonia = "4.1.4"
|
||||
anyhow = "1.0.104"
|
||||
argon2 = "0.6.0"
|
||||
atom_syndication = "0.12.10"
|
||||
axum = "0.8.9"
|
||||
flate2 = "1.1.10"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||
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", "gzip-http"] }
|
||||
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opml = "1.1.6"
|
||||
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
|
||||
mimalloc = "0.1.52"
|
||||
http = "1.5.0"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy"] }
|
||||
percent-encoding = "2.3.2"
|
||||
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", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
|
||||
rss = "2.1.1"
|
||||
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
@@ -29,8 +44,17 @@ sha2 = "0.10.9"
|
||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
|
||||
toml = "1.1.5"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs", "compression-br", "compression-gzip", "compression-zstd", "decompression-gzip"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
url = "2.5.8"
|
||||
|
||||
# Backtraces keep file and line; the full debug info made a 431 MB test binary whose link ran
|
||||
# Tower, which has no swap, out of memory and stalled it for most of an hour (2026-10-18).
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Nobody steps through dependencies.
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,29 +1,31 @@
|
||||
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
||||
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
||||
# build.rs builds the web pages from TypeScript with swc, which needs node.
|
||||
FROM rust:1-slim-bookworm AS build
|
||||
# The pages are ipx-web's (Dockerfile.web), so no node here (#203).
|
||||
FROM rust:1-slim-trixie AS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential nodejs npm \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
|
||||
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
||||
# Dependencies first, so editing the source does not rebuild librqbit every time: each crate of
|
||||
# the workspace as an empty stub, its manifest real.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
||||
&& cargo build --release --locked \
|
||||
&& rm -rf src
|
||||
COPY crates/ipx/Cargo.toml crates/ipx/
|
||||
COPY crates/feed/Cargo.toml crates/feed/
|
||||
COPY crates/directory/Cargo.toml crates/directory/
|
||||
RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \
|
||||
&& echo 'fn main(){}' > crates/ipx/src/main.rs && touch crates/feed/src/lib.rs \
|
||||
&& echo 'fn main(){}' > crates/directory/src/main.rs && touch crates/directory/src/lib.rs \
|
||||
&& cargo build --release --locked -p ipx \
|
||||
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
|
||||
|
||||
COPY build.rs ./
|
||||
COPY src ./src
|
||||
COPY web ./web
|
||||
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.
|
||||
RUN touch src/main.rs && cargo build --release --locked
|
||||
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
|
||||
# about 480 MB, once: the layer stays cached until the base image changes.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
25
Dockerfile.directory
Normal file
25
Dockerfile.directory
Normal file
@@ -0,0 +1,25 @@
|
||||
# ipx-directory (#204). Built from the repo's root: docker buildx build -f Dockerfile.directory .
|
||||
# No node here: only ipx's own build runs web/build.mjs.
|
||||
FROM rust:1-slim-trixie AS build
|
||||
# SQLite is compiled from source for sqlx, which needs a C toolchain.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
# Every member of the workspace, since cargo reads them all to build one.
|
||||
# ponytail: no dependency layer, so each build compiles sea-orm again; add the stub trick from
|
||||
# the main Dockerfile if builds get slow enough to matter.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
# The logo and icon the public pages serve (#215), compiled into the binary.
|
||||
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||
RUN cargo build --release --locked -p ipx-directory
|
||||
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/target/release/ipx-directory /usr/local/bin/ipx-directory
|
||||
ENV DIRECTORY_CONFIG=/config/directory.toml
|
||||
# Unraid's nobody:users. It writes nothing to disk: the database is elsewhere.
|
||||
USER 99:100
|
||||
EXPOSE 8100
|
||||
CMD ["ipx-directory"]
|
||||
16
Dockerfile.web
Normal file
16
Dockerfile.web
Normal file
@@ -0,0 +1,16 @@
|
||||
# ipx-web (#203): the frontend, built from web/ and served by Caddy, which passes the API to ipx.
|
||||
# Built from the repo's root: docker buildx build -f Dockerfile.web .
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY web ./web
|
||||
RUN node web/build.mjs /dist
|
||||
|
||||
FROM caddy:2
|
||||
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
|
||||
# curl flaw such as CVE-2026-92392 has nothing to reach in a container facing the internet (#237).
|
||||
RUN apk del --no-cache curl
|
||||
COPY web/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /dist /srv
|
||||
EXPOSE 8080
|
||||
12
README.md
12
README.md
@@ -31,11 +31,13 @@ With Docker:
|
||||
|
||||
```sh
|
||||
docker build -t ipx .
|
||||
docker build -f Dockerfile.web -t ipx-web .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` is set up for the author's own server. Point its `image` and its three volumes
|
||||
(`/config`, `/data` and `/downloads`) at yours first. The UI is on port 8099. BitTorrent uses 6881
|
||||
`docker-compose.yml` is a sample: ipx, with its database in SQLite and its three volumes
|
||||
(`/config`, `/data` and `/downloads`) in folders beside the file, and ipx-web, the page, served by
|
||||
Caddy in front of it. Change them to suit. The UI is on port 8099. BitTorrent uses 6881
|
||||
over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
|
||||
|
||||
From source:
|
||||
@@ -45,7 +47,9 @@ cargo build --release
|
||||
./target/release/ipx daemon
|
||||
```
|
||||
|
||||
The first start creates **admin / ipodderx**. Sign in at `/login`, then change it:
|
||||
The first start creates **admin** with a random password, written to `admin-password` beside
|
||||
config.toml (readable by ipx's user alone; the log says where, never what it is). Set
|
||||
`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it:
|
||||
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user passwd admin
|
||||
@@ -62,7 +66,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
||||
| [docs/users.md](docs/users.md) | Accounts, and what several people share |
|
||||
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
||||
| [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md), [web/CHANGELOG.md](web/CHANGELOG.md), [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) | What changed, by release, for ipx, the page and the directory |
|
||||
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
||||
|
||||
## Tests
|
||||
|
||||
14
build.rs
14
build.rs
@@ -1,14 +0,0 @@
|
||||
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
|
||||
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first.
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=web");
|
||||
println!("cargo:rerun-if-changed=package-lock.json");
|
||||
let out = std::env::var("OUT_DIR").unwrap();
|
||||
let status = Command::new("node")
|
||||
.args(["web/build.mjs", &out])
|
||||
.status()
|
||||
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
|
||||
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
|
||||
}
|
||||
94
crates/directory/CHANGELOG.md
Normal file
94
crates/directory/CHANGELOG.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Changelog: ipx-directory
|
||||
|
||||
The directory of public podcasts that ipx servers share (docs/directory.md). ipx's changelog is
|
||||
[crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md), the page's [web/CHANGELOG.md](../../web/CHANGELOG.md).
|
||||
|
||||
Its version is in `crates/directory/Cargo.toml`, and a release is tagged `directory-vX.Y.Z`.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- A WordPress.com site that answers its feed with a browser check (HTTP 403) is read from WordPress.com's API instead of failing (#317).
|
||||
|
||||
### Security
|
||||
|
||||
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
||||
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
|
||||
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
|
||||
- A server's count of a feed's subscribers is taken up to a million. Two servers sending the largest number there is made the sum overflow, and every listing and page failed until one was dropped (#342).
|
||||
|
||||
## [1.3.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- A feed's items written in Markdown show formatted on its page, from a Markdown description or RSS's `<source:markdown>` (#284).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A listed feed whose address serves something larger than 50 MB is refused as too large, instead of read whole into memory at every check (#295).
|
||||
|
||||
### Security
|
||||
|
||||
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one (#281).
|
||||
|
||||
## [1.2.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Answers and pages go out compressed, brotli, zstd or gzip as the caller takes them: the feed list ipx asks for was 603 KB of JSON. A snapshot may come gzipped, `/metrics` answers gzipped, and with `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` traces go to the collector compressed (#253).
|
||||
- With `apple_country` set, Apple Podcasts' top charts for that country (the top 100 and the top 50 of each category) come into the directory every week, beside what servers share, without counting as subscribers; and a warning says when Apple's categories no longer match ipx's (#252).
|
||||
|
||||
### Changed
|
||||
|
||||
- Each feed is checked every twelve hours instead of six (#243).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the server keeps all ten of its database connections open, so a request does not wait for a new one (#241).
|
||||
|
||||
## [1.1.1] - 2026-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
|
||||
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
|
||||
|
||||
### Security
|
||||
|
||||
- A failed feed check is logged without its address's query or password (#238).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- Anyone can browse the directory in a web browser, read only: the most subscribed, categories, every feed A to Z, a search by name, and each feed's page with its latest items and its address to copy into any podcast app (#215).
|
||||
- `GET /v1/instances` lists the servers holding a snapshot, with their feeds and subscribers, for an admin token (#206).
|
||||
- `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).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...main
|
||||
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.3.0...directory-v1.4.0
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...directory-v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
|
||||
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
||||
[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
|
||||
41
crates/directory/Cargo.toml
Normal file
41
crates/directory/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "ipx-directory"
|
||||
version = "1.4.1-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "ipx-directory"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
futures-util.workspace = true
|
||||
ipx-feed.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry-otlp.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
mimalloc.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
reqwest.workspace = true
|
||||
sea-orm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
flate2.workspace = true
|
||||
tower.workspace = true
|
||||
428
crates/directory/src/api.rs
Normal file
428
crates/directory/src/api.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
//! The HTTP side: the router, the token check, and `/v1`, where ipx servers send snapshots and read the
|
||||
//! catalogue.
|
||||
|
||||
use crate::config::Access;
|
||||
use crate::directory::Shared;
|
||||
use crate::wire::{Blocked, FeedPage, Instance, Listing, MAX_SNAPSHOT, Me, SnapshotEntry, SnapshotResult};
|
||||
use crate::{otel, site};
|
||||
use axum::extract::{Path, Query, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use std::time::Instant;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::decompression::RequestDecompressionLayer;
|
||||
use tracing::Instrument;
|
||||
|
||||
/// Every route: the API under `/v1`, behind a token, and the public pages.
|
||||
pub fn router(dir: Shared) -> Router {
|
||||
let v1 = Router::new()
|
||||
.route("/v1/me", get(me))
|
||||
.route("/v1/feeds", get(list))
|
||||
.route("/v1/popular", get(popular))
|
||||
.route("/v1/feeds/{id}", get(feed).delete(remove))
|
||||
.route("/v1/feeds/{id}/refresh", post(refresh))
|
||||
.route("/v1/snapshot", post(snapshot))
|
||||
.route("/v1/blocked", get(blocked))
|
||||
.route("/v1/blocked/{id}", delete(unblock))
|
||||
.route("/v1/instances", get(instances))
|
||||
.route("/v1/instances/{name}", delete(drop_instance))
|
||||
.layer(axum::middleware::from_fn_with_state(dir.clone(), authenticate));
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.merge(v1)
|
||||
// Anyone may browse, no token, read only (#215).
|
||||
.merge(site::router())
|
||||
// Brotli, zstd or gzip as the caller takes them: the feed list went to ipx as 603 KB of
|
||||
// JSON, most of a Directory page's time (#253). Inside the access log, which records what
|
||||
// was sent.
|
||||
.layer(CompressionLayer::new().br(true).zstd(true).gzip(true))
|
||||
// And takes a request's body gzipped: ipx sends its snapshot so, about 100 KB as JSON.
|
||||
.layer(RequestDecompressionLayer::new().gzip(true))
|
||||
.layer(axum::middleware::from_fn(access_log))
|
||||
.with_state(dir)
|
||||
}
|
||||
|
||||
pub(crate) struct ApiError(StatusCode, String);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ApiError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
tracing::error!(error = %format!("{e:#}"), "request failed");
|
||||
ApiError(StatusCode::INTERNAL_SERVER_ERROR, "something went wrong; the directory's log says what".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Who made the request, for the access log, set by `authenticate`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Who(String);
|
||||
|
||||
/// One line a request, with the token's name and never the token: what the Grafana panels read.
|
||||
pub(crate) async fn access_log(req: Request, next: Next) -> Response {
|
||||
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
|
||||
let start = Instant::now();
|
||||
// A span for each request but the healthcheck's, a child of the caller's when it sent a
|
||||
// traceparent, as ipx does on its calls here (#213).
|
||||
let span = if path == "/healthz" {
|
||||
tracing::Span::none()
|
||||
} else {
|
||||
tracing::info_span!(
|
||||
"http",
|
||||
otel.name = %format!("{method} {path}"),
|
||||
http.request.method = %method,
|
||||
url.path = %path,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
)
|
||||
};
|
||||
if let Some(parent) = otel::remote_parent(req.headers(), None) {
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let _ = span.set_parent(parent);
|
||||
}
|
||||
let resp = Instrument::instrument(next.run(req), span.clone()).await;
|
||||
span.record("http.response.status_code", resp.status().as_u16());
|
||||
let _in = span.enter();
|
||||
let who = resp.extensions().get::<Who>().map_or("", |w| w.0.as_str()).to_owned();
|
||||
let status = resp.status().as_u16();
|
||||
if path != "/healthz" {
|
||||
tracing::info!(
|
||||
target: "ipx_directory::http",
|
||||
method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who,
|
||||
"{method} {path} -> {status}"
|
||||
);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
pub(crate) async fn authenticate(State(dir): State<Shared>, mut req: Request, next: Next) -> Response {
|
||||
let presented = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty());
|
||||
let Some(me) = presented.and_then(|t| dir.caller(t)) else {
|
||||
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into())
|
||||
.into_response();
|
||||
};
|
||||
let who = Who(me.name.clone());
|
||||
req.extensions_mut().insert(me);
|
||||
let mut resp = next.run(req).await;
|
||||
resp.extensions_mut().insert(who);
|
||||
resp
|
||||
}
|
||||
|
||||
pub(crate) fn need(me: &Me, access: Access) -> Result<(), ApiError> {
|
||||
if me.access >= access {
|
||||
Ok(())
|
||||
} else {
|
||||
let what = match access {
|
||||
Access::Ro => "browse",
|
||||
Access::Rw => "send snapshots",
|
||||
Access::Rwa => "administer the directory",
|
||||
};
|
||||
Err(ApiError(StatusCode::FORBIDDEN, format!("this token may not {what}")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn healthz(State(dir): State<Shared>) -> Result<&'static str, ApiError> {
|
||||
dir.store.ping().await?;
|
||||
Ok("ok")
|
||||
}
|
||||
|
||||
pub(crate) async fn me(Extension(me): Extension<Me>) -> Json<Me> {
|
||||
Json(me)
|
||||
}
|
||||
|
||||
pub(crate) async fn list(State(dir): State<Shared>) -> Result<Json<Vec<Listing>>, ApiError> {
|
||||
Ok(Json(dir.store.list().await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Limit {
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) async fn popular(State(dir): State<Shared>, Query(q): Query<Limit>) -> Result<Json<Vec<Listing>>, ApiError> {
|
||||
Ok(Json(dir.store.popular(q.limit.unwrap_or(10).clamp(1, 100)).await?))
|
||||
}
|
||||
|
||||
pub(crate) async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
||||
dir.store
|
||||
.feed(&id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
|
||||
}
|
||||
|
||||
pub(crate) async fn snapshot(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Json(entries): Json<Vec<SnapshotEntry>>,
|
||||
) -> Result<Json<SnapshotResult>, ApiError> {
|
||||
need(&me, Access::Rw)?;
|
||||
if entries.len() > MAX_SNAPSHOT {
|
||||
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("a snapshot lists at most {MAX_SNAPSHOT} feeds")));
|
||||
}
|
||||
let r = dir.store.snapshot(&me.name, &entries).await?;
|
||||
dir.wake.notify_one();
|
||||
tracing::info!(
|
||||
instance = %me.name, accepted = r.accepted, refused_private = r.refused_private,
|
||||
refused_blocked = r.refused_blocked, refused_invalid = r.refused_invalid, "snapshot"
|
||||
);
|
||||
Ok(Json(r))
|
||||
}
|
||||
|
||||
pub(crate) async fn remove(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
dir.store.block(&id).await?;
|
||||
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub(crate) async fn blocked(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
) -> Result<Json<Vec<Blocked>>, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
Ok(Json(dir.store.blocked().await?))
|
||||
}
|
||||
|
||||
pub(crate) async fn unblock(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
if !dir.store.unblock(&id).await? {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
|
||||
}
|
||||
tracing::info!(feed = %id, by = %me.name, "feed unblocked");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub(crate) async fn instances(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
) -> Result<Json<Vec<Instance>>, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
Ok(Json(dir.store.instances().await?))
|
||||
}
|
||||
|
||||
pub(crate) async fn drop_instance(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
let n = dir.store.drop_instance(&name).await?;
|
||||
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<FeedPage>, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
let due = dir
|
||||
.store
|
||||
.due_one(&id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
|
||||
dir.refresh(&due).await?;
|
||||
feed(State(dir), Path(id)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::config;
|
||||
use crate::config::Token;
|
||||
use crate::directory::Directory;
|
||||
use crate::store;
|
||||
use crate::store::Store;
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn app() -> (Router, Shared) {
|
||||
let tokens = ["ro", "rw", "rwa"]
|
||||
.iter()
|
||||
.map(|n| Token {
|
||||
name: format!("{n}-token"),
|
||||
access: serde_json::from_value(serde_json::json!(n)).unwrap(),
|
||||
sha256: config::hash(&format!("secret-{n}")),
|
||||
})
|
||||
.collect();
|
||||
let dir = Directory::new(Store::scratch().await, tokens, Default::default()).unwrap();
|
||||
(router(dir.clone()), dir)
|
||||
}
|
||||
|
||||
async fn call(
|
||||
app: &Router,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> (u16, serde_json::Value) {
|
||||
let mut req = axum::http::Request::builder().method(method).uri(path);
|
||||
if let Some(t) = token {
|
||||
req = req.header("authorization", format!("Bearer secret-{t}"));
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_snapshot_may_come_gzipped() {
|
||||
use std::io::Write as _;
|
||||
let (app, _) = app().await;
|
||||
let json = serde_json::json!([{"url": "https://example.com/feed.xml", "subscribers": 2}]).to_string();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(json.as_bytes()).unwrap();
|
||||
// As ipx sends it (#253).
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/snapshot")
|
||||
.header("authorization", "Bearer secret-rw")
|
||||
.header("content-type", "application/json")
|
||||
.header("content-encoding", "gzip")
|
||||
.body(Body::from(gz.finish().unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got: serde_json::Value =
|
||||
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
|
||||
assert_eq!(got["accepted"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn answers_come_compressed_as_the_caller_asks() {
|
||||
let (app, _) = app().await;
|
||||
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
|
||||
// A page, not an empty feed list: under 32 bytes is sent as it is.
|
||||
let req =
|
||||
axum::http::Request::builder().uri("/").header("accept-encoding", asked).body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
||||
assert_eq!(got.as_deref(), sent, "asked for {asked} (#253)");
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_page(app: &Router, path: &str) -> (u16, String, Option<String>) {
|
||||
let req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let csp = resp.headers().get("content-security-policy").map(|v| v.to_str().unwrap().to_owned());
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 22).await.unwrap();
|
||||
(status, String::from_utf8(bytes.to_vec()).unwrap(), csp)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anyone_browses_the_pages_and_the_api_still_needs_a_token() {
|
||||
let (app, dir) = app().await;
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 3 }]);
|
||||
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
let id = dir.store.list().await.unwrap()[0].id.clone();
|
||||
|
||||
let (s, home, csp) = get_page(&app, "/").await;
|
||||
assert_eq!(s, 200);
|
||||
assert!(csp.unwrap().contains("script-src 'self'"));
|
||||
assert!(home.contains("Most subscribed") && home.contains(&format!("/feed/{id}")), "{home}");
|
||||
assert!(get_page(&app, "/all").await.1.contains("3 people subscribe"));
|
||||
let (s, page, _) = get_page(&app, &format!("/feed/{id}")).await;
|
||||
assert_eq!(s, 200);
|
||||
// Its address, to subscribe to anywhere.
|
||||
assert!(page.contains("value=\"https://x.test/a.xml\""), "{page}");
|
||||
assert!(!page.contains("<script>"), "the publisher's HTML is text here");
|
||||
assert_eq!(get_page(&app, "/feed/nope").await.0, 404);
|
||||
assert_eq!(get_page(&app, "/category/Nothing").await.0, 404);
|
||||
assert_eq!(get_page(&app, "/search?q=").await.0, 303);
|
||||
assert_eq!(get_page(&app, "/site.js").await.0, 200);
|
||||
// Reading the pages takes no token; the API still does.
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), None, None).await.0, 401);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/feed/{id}"), None, None).await.0, 405);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_access_level_does_what_it_may_and_no_more() {
|
||||
let (app, dir) = app().await;
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 2, "category": "Comedy" }]);
|
||||
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("wrong"), None).await.0, 401);
|
||||
assert_eq!(call(&app, "GET", "/healthz", None, None).await.0, 200);
|
||||
|
||||
let (s, me) = call(&app, "GET", "/v1/me", Some("ro"), None).await;
|
||||
assert_eq!((s, me["name"].as_str(), me["access"].as_str()), (200, Some("ro-token"), Some("ro")));
|
||||
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("ro"), Some(snap.clone())).await.0, 403);
|
||||
|
||||
let (s, r) = call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await;
|
||||
assert_eq!((s, r["accepted"].as_u64()), (200, Some(1)));
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
let (_, list) = call(&app, "GET", "/v1/feeds", Some("ro"), None).await;
|
||||
let id = list[0]["id"].as_str().unwrap().to_owned();
|
||||
assert_eq!(list[0]["subscribers"], 2);
|
||||
assert_ne!(list[0]["category"], "Comedy", "the feed's own category wins over the sender's");
|
||||
assert_eq!(call(&app, "GET", &format!("/v1/feeds/{id}"), Some("ro"), None).await.0, 200);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
|
||||
assert_eq!(call(&app, "GET", "/v1/popular?limit=5", Some("ro"), None).await.1[0]["id"], id.as_str());
|
||||
|
||||
for (m, p) in [
|
||||
("DELETE", format!("/v1/feeds/{id}")),
|
||||
("GET", "/v1/blocked".into()),
|
||||
("DELETE", "/v1/instances/rw-token".into()),
|
||||
] {
|
||||
assert_eq!(call(&app, m, &p, Some("rw"), None).await.0, 403, "{m} {p}");
|
||||
}
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), Some("rwa"), None).await.0, 204);
|
||||
assert_eq!(call(&app, "GET", "/v1/blocked", Some("rwa"), None).await.1[0]["id"], id.as_str());
|
||||
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await.1["refused_blocked"], 1);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 204);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 404);
|
||||
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
|
||||
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rwa"), None).await.0, 204);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("ro"), None).await.1, serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_revoked_token_loses_its_access_and_its_counts() {
|
||||
let dir = Directory::new(Store::scratch().await, vec![], Default::default()).unwrap();
|
||||
let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) };
|
||||
dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
|
||||
let app = router(dir.clone());
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 1 }]);
|
||||
call(&app, "POST", "/v1/snapshot", Some("a"), Some(snap.clone())).await;
|
||||
call(&app, "POST", "/v1/snapshot", Some("b"), Some(snap)).await;
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 2);
|
||||
|
||||
dir.set_tokens(vec![t("a")]).await.unwrap();
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("b"), None).await.0, 401);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 1);
|
||||
}
|
||||
}
|
||||
281
crates/directory/src/apple.rs
Normal file
281
crates/directory/src/apple.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! Apple Podcasts, weekly (#252): its top charts for a country into the directory, beside what
|
||||
//! ipx servers share, and its categories checked against the copy ipx keeps (`ipx_feed`).
|
||||
//!
|
||||
//! The charts are a snapshot of their own, under `INSTANCE`, with no subscribers: Apple's shows
|
||||
//! are fetched, categorised and listed like any other feed, and the most subscribed stay what this
|
||||
//! directory's people subscribe to. Each show's RSS address comes from Apple's lookup API.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::{Shared, SnapshotEntry};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
/// What the charts are filed under, as a server's snapshot is under its token's name.
|
||||
pub const INSTANCE: &str = "apple-podcasts";
|
||||
/// How often, and how long to wait after a sync fails before trying again.
|
||||
const EVERY: i64 = 7 * 86_400;
|
||||
const RETRY: Duration = Duration::from_secs(3600);
|
||||
/// The top this many overall, and of each category: about a thousand shows once they overlap.
|
||||
const TOP: u32 = 100;
|
||||
const TOP_OF_EACH: u32 = 50;
|
||||
/// Apple asks for no more than about twenty calls a minute.
|
||||
const PAUSE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Syncs when a week has passed since the last, forever.
|
||||
pub async fn run(dir: Shared, country: String) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return tracing::error!(error = %e, "Apple Podcasts' sync could not start"),
|
||||
};
|
||||
loop {
|
||||
let last = dir.store.synced_at(INSTANCE).await.unwrap_or(None).unwrap_or(0);
|
||||
let wait = last + EVERY - crate::store::now();
|
||||
if wait > 0 {
|
||||
// Woken hourly at most, so a database restored from before the last sync is noticed.
|
||||
tokio::time::sleep(Duration::from_secs((wait as u64).min(3600))).await;
|
||||
continue;
|
||||
}
|
||||
let span = tracing::info_span!("apple_sync", country = %country);
|
||||
match sync(&dir, &client, &country).instrument(span).await {
|
||||
Ok(()) => {
|
||||
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "Apple Podcasts' sync failed; trying again in an hour");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
|
||||
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
|
||||
let theirs = categories(&genres);
|
||||
let (added, gone) = differ(&theirs, ipx_feed::categories());
|
||||
if !added.is_empty() || !gone.is_empty() {
|
||||
tracing::warn!(
|
||||
added = %added.join(", "), gone = %gone.join(", "),
|
||||
"Apple's podcast categories are not ipx's: the copy in crates/feed (CATEGORIES) wants updating"
|
||||
);
|
||||
}
|
||||
|
||||
// The top overall, then the top of each category, each show once.
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
|
||||
for (genre, n) in charts {
|
||||
let url = match &genre {
|
||||
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
|
||||
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
|
||||
};
|
||||
match get::<Chart>(client, &url).await {
|
||||
Ok(chart) => ids.extend(chart_ids(&chart)),
|
||||
// One category's chart missing is no reason to keep last week's whole list.
|
||||
Err(e) => tracing::info!(genre = ?genre, error = %format!("{e:#}"), "an Apple chart did not load"),
|
||||
}
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
ids.retain(|id| seen.insert(id.clone()));
|
||||
anyhow::ensure!(!ids.is_empty(), "no chart had a show in it");
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for batch in ids.chunks(100) {
|
||||
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
|
||||
let found: Lookup = get(client, &url).await?;
|
||||
entries.extend(lookup_entries(&found));
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
||||
tracing::info!(
|
||||
shows = ids.len(),
|
||||
accepted = result.accepted,
|
||||
refused_private = result.refused_private,
|
||||
refused_blocked = result.refused_blocked,
|
||||
refused_invalid = result.refused_invalid,
|
||||
"Apple Podcasts synced"
|
||||
);
|
||||
// Fetched now, not when the refresher next looks.
|
||||
dir.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get<T: serde::de::DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
|
||||
let span = tracing::info_span!("apple_get", url = %url);
|
||||
async {
|
||||
let resp = client.get(url).send().await.context("asking Apple")?.error_for_status()?;
|
||||
resp.json::<T>().await.context("reading Apple's answer")
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Apple's genres under Podcasts (26): each category's id and name, and its subcategories.
|
||||
#[derive(Deserialize)]
|
||||
struct Genres(BTreeMap<String, Genre>);
|
||||
#[derive(Deserialize)]
|
||||
struct Genre {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
subgenres: BTreeMap<String, Genre>,
|
||||
}
|
||||
|
||||
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
||||
g.0.get("26")
|
||||
.map(|podcasts| {
|
||||
podcasts
|
||||
.subgenres
|
||||
.values()
|
||||
.map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn genre_ids(g: &Genres) -> Vec<String> {
|
||||
g.0.get("26").map(|p| p.subgenres.keys().cloned().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What Apple has that ipx's list lacks, and what ipx's has that Apple no longer does, as
|
||||
/// "Category" or "Category / Subcategory".
|
||||
fn differ(theirs: &BTreeMap<String, Vec<String>>, ours: &[(&str, &[&str])]) -> (Vec<String>, Vec<String>) {
|
||||
let flat = |c: &str, subs: &mut dyn Iterator<Item = String>| {
|
||||
std::iter::once(c.to_string()).chain(subs.map(|s| format!("{c} / {s}"))).collect::<Vec<_>>()
|
||||
};
|
||||
let theirs: BTreeSet<String> = theirs.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().cloned())).collect();
|
||||
let ours: BTreeSet<String> =
|
||||
ours.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().map(|s| s.to_string()))).collect();
|
||||
(theirs.difference(&ours).cloned().collect(), ours.difference(&theirs).cloned().collect())
|
||||
}
|
||||
|
||||
/// A chart, as Apple's iTunes RSS gives it: the shows' Apple ids, in order.
|
||||
#[derive(Deserialize)]
|
||||
struct Chart {
|
||||
feed: ChartFeed,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartFeed {
|
||||
// A chart of one show is that show, not a list of one.
|
||||
#[serde(default, deserialize_with = "one_or_many")]
|
||||
entry: Vec<ChartEntry>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartEntry {
|
||||
id: ChartId,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartId {
|
||||
attributes: ChartIdAttributes,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartIdAttributes {
|
||||
#[serde(rename = "im:id")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ChartEntry>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany {
|
||||
One(ChartEntry),
|
||||
Many(Vec<ChartEntry>),
|
||||
}
|
||||
Ok(match OneOrMany::deserialize(d)? {
|
||||
OneOrMany::One(e) => vec![e],
|
||||
OneOrMany::Many(v) => v,
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_ids(c: &Chart) -> Vec<String> {
|
||||
c.feed.entry.iter().map(|e| e.id.attributes.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Apple's lookup answer: each show's RSS address and the category Apple files it under.
|
||||
#[derive(Deserialize)]
|
||||
struct Lookup {
|
||||
#[serde(default)]
|
||||
results: Vec<LookupResult>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct LookupResult {
|
||||
#[serde(rename = "feedUrl")]
|
||||
feed_url: Option<String>,
|
||||
#[serde(rename = "primaryGenreName")]
|
||||
genre: Option<String>,
|
||||
}
|
||||
|
||||
fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
|
||||
l.results
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let url = r.feed_url.as_deref()?.trim();
|
||||
(!url.is_empty()).then(|| SnapshotEntry {
|
||||
url: url.to_owned(),
|
||||
subscribers: 0,
|
||||
// "Podcasts" is the root Apple files a show under when it gives no category.
|
||||
category: r.genre.clone().filter(|g| g != "Podcasts"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apples_categories_are_read_and_compared_with_ipxs() {
|
||||
let genres: Genres = serde_json::from_str(
|
||||
r#"{"26": {"name": "Podcasts", "subgenres": {
|
||||
"1301": {"name": "Arts", "subgenres": {"1482": {"name": "Books"}, "1999": {"name": "Murals"}}},
|
||||
"1310": {"name": "Music"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(genre_ids(&genres), ["1301", "1310"]);
|
||||
let ours: &[(&str, &[&str])] = &[("Arts", &["Books", "Design"]), ("Music", &[]), ("Hobbies", &[])];
|
||||
let (added, gone) = differ(&categories(&genres), ours);
|
||||
assert_eq!(added, ["Arts / Murals"]);
|
||||
assert_eq!(gone, ["Arts / Design", "Hobbies"]);
|
||||
// ipx's own copy is Apple's whole list, each category once.
|
||||
let mine = ipx_feed::categories();
|
||||
assert_eq!(mine.len(), 19);
|
||||
assert!(mine.iter().any(|(c, subs)| *c == "News" && subs.contains(&"Daily News")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_gives_its_shows_in_order_and_a_lookup_their_addresses() {
|
||||
let chart: Chart = serde_json::from_str(
|
||||
r#"{"feed": {"entry": [
|
||||
{"id": {"label": "https://x", "attributes": {"im:id": "1200361736"}}},
|
||||
{"id": {"label": "https://y", "attributes": {"im:id": "1028908750"}}}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
||||
let one: Chart =
|
||||
serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
||||
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
|
||||
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
||||
assert!(chart_ids(&empty).is_empty());
|
||||
|
||||
let found: Lookup = serde_json::from_str(
|
||||
r#"{"resultCount": 3, "results": [
|
||||
{"trackName": "The Daily", "feedUrl": "https://feeds.simplecast.com/Sl5CSM3S", "primaryGenreName": "Daily News"},
|
||||
{"trackName": "No feed"},
|
||||
{"trackName": "Rooted", "feedUrl": "https://example.com/rss", "primaryGenreName": "Podcasts"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let entries = lookup_entries(&found);
|
||||
assert_eq!(entries.len(), 2, "a show Apple has no feed for is left out");
|
||||
assert_eq!((entries[0].subscribers, entries[0].category.as_deref()), (0, Some("Daily News")));
|
||||
assert_eq!(entries[1].category, None, "Podcasts is no category");
|
||||
}
|
||||
}
|
||||
207
crates/directory/src/config.rs
Normal file
207
crates/directory/src/config.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! The directory's config file: where it listens, where its database is, and the tokens it
|
||||
//! accepts. Tokens are only ever changed here, never over the API, so whoever can edit this
|
||||
//! file decides who gets in (#204).
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
/// `directory.toml`. See docs/directory.md.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// Where the API and the public pages listen.
|
||||
#[serde(default = "default_bind")]
|
||||
pub bind: String,
|
||||
/// The environment variable holding the database URL, so the file holds no secret.
|
||||
#[serde(default = "default_database_url_env")]
|
||||
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,
|
||||
/// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the
|
||||
/// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or
|
||||
/// left out, does neither.
|
||||
#[serde(default)]
|
||||
pub apple_country: String,
|
||||
/// Internal addresses and networks the refresher may fetch feeds from all the same, as ipx's
|
||||
/// `fetch_internal` (#329). Empty, the default, fetches nothing off the public internet.
|
||||
#[serde(default)]
|
||||
pub fetch_internal: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tokens: Vec<TokenCfg>,
|
||||
}
|
||||
|
||||
fn default_bind() -> String {
|
||||
"0.0.0.0:8100".into()
|
||||
}
|
||||
|
||||
fn default_database_url_env() -> String {
|
||||
"DIRECTORY_DATABASE_URL".into()
|
||||
}
|
||||
|
||||
/// A token as `directory.toml` lists it: a name, what it may do, and only the token's hash.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TokenCfg {
|
||||
/// Who holds it: what its snapshot is filed under and what the log says, never the token.
|
||||
pub name: String,
|
||||
/// No default: a token whose access is missing or misspelled is refused when the file is
|
||||
/// read, rather than given some access nobody chose.
|
||||
pub access: Access,
|
||||
/// The token's SHA-256, in hex: what `ipx-directory token new` prints. The file holds no
|
||||
/// token, so adding or revoking one is an edit here and nothing else.
|
||||
pub token_sha256: String,
|
||||
}
|
||||
|
||||
/// What a token may do, each level including the one before.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Access {
|
||||
/// Browse.
|
||||
Ro,
|
||||
/// Browse and send snapshots.
|
||||
Rw,
|
||||
/// All that, and remove, block and refresh feeds and drop an instance's snapshot.
|
||||
Rwa,
|
||||
}
|
||||
|
||||
/// A token as the server checks it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token {
|
||||
pub name: String,
|
||||
pub access: Access,
|
||||
/// SHA-256 of the token, lower-case hex.
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Reads and checks `path`; see `parse`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&text).with_context(|| format!("in {}", path.display()))
|
||||
}
|
||||
|
||||
/// Reads a config, refusing tokens with empty or repeated names, which would file two
|
||||
/// instances' snapshots together.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
let cfg: Config = toml::from_str(text)?;
|
||||
let mut seen = HashSet::new();
|
||||
for t in &cfg.tokens {
|
||||
if t.name.trim().is_empty() {
|
||||
bail!("a token has an empty name");
|
||||
}
|
||||
// Snapshots are filed by name: two instances under one name would overwrite each other.
|
||||
if !seen.insert(t.name.as_str()) {
|
||||
bail!("two tokens are named {:?}", t.name);
|
||||
}
|
||||
// Apple's charts are filed under it; a server of that name would replace them.
|
||||
if t.name == crate::apple::INSTANCE {
|
||||
bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name);
|
||||
}
|
||||
}
|
||||
let c = cfg.apple_country.trim();
|
||||
if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) {
|
||||
bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}");
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// The tokens as the server checks them. A hash that is not 64 hex digits, or the same
|
||||
/// hash under two names, is an error: which name a request is would be ambiguous.
|
||||
pub fn tokens(&self) -> Result<Vec<Token>> {
|
||||
let mut seen = HashSet::new();
|
||||
self.tokens
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let sha256 = t.token_sha256.trim().to_ascii_lowercase();
|
||||
if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
bail!("token {:?}: token_sha256 is not a SHA-256 in hex", t.name);
|
||||
}
|
||||
if !seen.insert(sha256.clone()) {
|
||||
bail!("token {:?} is the same token as another", t.name);
|
||||
}
|
||||
Ok(Token { name: t.name.clone(), access: t.access, sha256 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A token's SHA-256 in hex, as the config file holds it.
|
||||
#[must_use]
|
||||
pub fn hash(token: &str) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(token.trim().as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Equal without saying, by how long it took, how much of the guess was right.
|
||||
#[must_use]
|
||||
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// A new token: `ipxd_` and 256 random bits, the prefix so one found in a log says what it is.
|
||||
#[must_use]
|
||||
pub fn new_token() -> String {
|
||||
use std::io::Read;
|
||||
let mut bytes = [0u8; 32];
|
||||
// Falling back to the clock would make a guessable token. Better to fail.
|
||||
File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)).expect("no source of randomness for a token");
|
||||
format!("ipxd_{}", bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = r#"
|
||||
[[tokens]]
|
||||
name = "tower"
|
||||
access = "rwa"
|
||||
token_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
[[tokens]]
|
||||
name = "friend"
|
||||
access = "ro"
|
||||
token_sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn tokens_carry_their_access_and_take_their_value_from_the_environment() {
|
||||
let cfg = Config::parse(GOOD).unwrap();
|
||||
assert_eq!(cfg.bind, "0.0.0.0:8100");
|
||||
let t = cfg.tokens().unwrap();
|
||||
assert_eq!(
|
||||
(t[0].name.as_str(), t[0].access, t[0].sha256.as_str()),
|
||||
("tower", Access::Rwa, "a".repeat(64).as_str())
|
||||
);
|
||||
assert_eq!(t[1].access, Access::Ro);
|
||||
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
|
||||
let h = "a".repeat(64);
|
||||
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err());
|
||||
assert!(
|
||||
Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err()
|
||||
);
|
||||
assert!(Config::parse(&GOOD.replace("\"friend\"", "\"tower\"")).is_err());
|
||||
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), "xyz")).unwrap().tokens().is_err());
|
||||
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_compare_whole() {
|
||||
assert!(same_secret("abc", "abc"));
|
||||
assert!(!same_secret("abc", "abd"));
|
||||
assert!(!same_secret("abc", "abcd"));
|
||||
let t = new_token();
|
||||
assert!(t.starts_with("ipxd_") && t.len() == 69 && t != new_token());
|
||||
assert_eq!(hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
}
|
||||
}
|
||||
218
crates/directory/src/directory.rs
Normal file
218
crates/directory/src/directory.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! The running directory: its store, the tokens it accepts, the refresher that fetches its feeds, and
|
||||
//! the reloader that picks up a changed config.
|
||||
|
||||
use crate::config::Token;
|
||||
use crate::wire::{Listing, Me};
|
||||
use crate::{config, store};
|
||||
use reqwest::redirect::Policy;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::{Refreshed, Store};
|
||||
use tokio::sync::Notify;
|
||||
use tracing::Instrument;
|
||||
|
||||
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
|
||||
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
||||
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
||||
|
||||
/// The running directory: its database, the tokens it accepts and the client its refresher
|
||||
/// fetches feeds with.
|
||||
pub struct Directory {
|
||||
pub store: Store,
|
||||
pub(crate) tokens: RwLock<Vec<Token>>,
|
||||
pub(crate) client: reqwest::Client,
|
||||
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
|
||||
pub(crate) wake: Notify,
|
||||
/// The public pages' copy of the list, and when it was read.
|
||||
pub(crate) pages_list: tokio::sync::Mutex<Option<(Instant, Arc<Vec<Listing>>)>>,
|
||||
}
|
||||
|
||||
/// How long the public pages show the same copy of the list.
|
||||
pub(crate) const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The directory as every handler and task holds it.
|
||||
pub type Shared = Arc<Directory>;
|
||||
|
||||
impl Directory {
|
||||
/// A directory on `store`, accepting `tokens`, whose refresher may reach the internal
|
||||
/// addresses `fetch_internal` lists and no others.
|
||||
pub fn new(
|
||||
store: Store,
|
||||
tokens: Vec<Token>,
|
||||
fetch_internal: ipx_feed::guard::Allowed,
|
||||
) -> Result<Shared, reqwest::Error> {
|
||||
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(ipx_feed::FEED_SILENCE)
|
||||
.redirect(Policy::none())
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
// The feeds are named by whoever holds a write token, and redirect where their
|
||||
// publishers like: nothing on this network is fetched unless listed (#329).
|
||||
.connector_layer(ipx_feed::guard::Guard::new(fetch_internal))
|
||||
.build()?;
|
||||
Ok(Arc::new(Self {
|
||||
store,
|
||||
tokens: RwLock::new(tokens),
|
||||
client,
|
||||
wake: Notify::new(),
|
||||
pages_list: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The whole list for the public pages, read from the database at most once a minute. Anyone
|
||||
/// may ask for a page, and each asked the database for the whole catalogue on the pool the
|
||||
/// refresher and every server's /v1 calls share; a query string of its own got past any
|
||||
/// cache in front (#341). The lock is held through the read, so a burst waits on one query.
|
||||
pub async fn pages_list(&self) -> anyhow::Result<Arc<Vec<Listing>>> {
|
||||
let mut held = self.pages_list.lock().await;
|
||||
if let Some((at, list)) = held.as_ref()
|
||||
&& at.elapsed() < PAGES_LIST_FOR
|
||||
{
|
||||
return Ok(list.clone());
|
||||
}
|
||||
let list = Arc::new(self.store.list().await?);
|
||||
*held = Some((Instant::now(), list.clone()));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
|
||||
pub async fn set_tokens(&self, tokens: Vec<Token>) -> anyhow::Result<u64> {
|
||||
let names: Vec<String> = tokens.iter().map(|t| t.name.clone()).collect();
|
||||
*self.tokens.write().expect("a panic elsewhere poisoned this lock") = tokens;
|
||||
self.store.keep_instances(&names).await
|
||||
}
|
||||
|
||||
pub(crate) fn caller(&self, presented: &str) -> Option<Me> {
|
||||
let h = config::hash(presented);
|
||||
// Every token compared, whether or not one matched, so timing says nothing either way.
|
||||
self.tokens.read().expect("a panic elsewhere poisoned this lock").iter().fold(None, |found, t| {
|
||||
if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found }
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches one feed and stores what it found.
|
||||
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
|
||||
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok((ipx_feed::Fetched::NotModified, _)) => Refreshed::NotModified,
|
||||
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
|
||||
if ipx_feed::is_opml(&bytes) {
|
||||
// ipx sends the feeds inside an OPML, never the OPML.
|
||||
Refreshed::Failed("an OPML list, not a feed".into())
|
||||
} else {
|
||||
match ipx_feed::parse(&bytes) {
|
||||
Ok(parsed) => Refreshed::Feed { parsed, etag, last_modified },
|
||||
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||
};
|
||||
if let Refreshed::Failed(e) = &got {
|
||||
tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
|
||||
}
|
||||
// ponytail: a feed that moved for good is still asked for at its old address, which
|
||||
// redirects; follow the move when a feed's id can change without losing its counts.
|
||||
self.store.save(&due.id, got).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches whatever is due, a few at a time, forever.
|
||||
pub async fn refresher(dir: Shared) {
|
||||
loop {
|
||||
match dir.store.due(REFRESH_EVERY, 25).await {
|
||||
Ok(due) => {
|
||||
if !due.is_empty() {
|
||||
// A round is a trace, each feed's check a span in it (#213).
|
||||
let round = tracing::info_span!("refresh", feeds = due.len());
|
||||
Instrument::instrument(
|
||||
async {
|
||||
// A few at a time: in turn, two publishers timing out held a round
|
||||
// for a minute and more (#227).
|
||||
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
|
||||
let check = tracing::info_span!("feed", feed = %d.id);
|
||||
async {
|
||||
if let Err(e) = Instrument::instrument(dir.refresh(d), check).await {
|
||||
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
|
||||
tracing::info!(checked = due.len(), "feeds checked");
|
||||
},
|
||||
round,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not read which feeds are due"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||
_ = dir.wake.notified() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the config file again whenever it changes, checked each minute. A file that fails to
|
||||
/// load leaves the tokens as they were, so a typo locks nobody out.
|
||||
pub async fn reloader(dir: Shared, path: PathBuf) {
|
||||
let mtime = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
|
||||
let mut last = mtime(&path);
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
let now = mtime(&path);
|
||||
if now == last {
|
||||
continue;
|
||||
}
|
||||
last = now;
|
||||
match config::Config::load(&path).and_then(|c| c.tokens()) {
|
||||
Ok(tokens) => {
|
||||
let n = tokens.len();
|
||||
match dir.set_tokens(tokens).await {
|
||||
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn scratch() -> Shared {
|
||||
Directory::new(Store::scratch().await, vec![], Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_refresher_fetches_nothing_on_this_network() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
|
||||
let dir = scratch().await;
|
||||
let err = dir.client.get(format!("http://{addr}/feed.xml")).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_public_pages_read_the_list_once_a_minute() {
|
||||
let dir = scratch().await;
|
||||
let first = dir.pages_list().await.unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()), "a second page asks the database nothing");
|
||||
// Once the copy is a minute old, the next page reads the list again.
|
||||
dir.pages_list.lock().await.as_mut().unwrap().0 -= PAGES_LIST_FOR;
|
||||
assert!(!Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()));
|
||||
}
|
||||
}
|
||||
22
crates/directory/src/lib.rs
Normal file
22
crates/directory/src/lib.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! ipx's directory service (#204): a catalogue of public podcasts and blogs that ipx servers
|
||||
//! share. It holds metadata only, never a file: each server still fetches its own feeds. ipx
|
||||
//! servers send which public feeds their people subscribe to, the directory fetches each
|
||||
//! feed's title, artwork, description and latest items on a slow schedule, and any server
|
||||
//! holding a token can browse it.
|
||||
//!
|
||||
//! The types here are the wire format, shared with ipx, which asks the directory on its
|
||||
//! people's behalf.
|
||||
|
||||
mod api;
|
||||
pub mod apple;
|
||||
pub mod config;
|
||||
mod directory;
|
||||
pub mod metrics;
|
||||
pub mod otel;
|
||||
mod site;
|
||||
pub mod store;
|
||||
mod wire;
|
||||
|
||||
pub use api::router;
|
||||
pub use directory::{Directory, REFRESH_EVERY, Shared, refresher, reloader};
|
||||
pub use wire::{Blocked, FeedPage, Instance, Item, Listing, MAX_SNAPSHOT, Me, SnapshotEntry, SnapshotResult};
|
||||
143
crates/directory/src/main.rs
Normal file
143
crates/directory/src/main.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! `ipx-directory`: serve the directory, make a token, or check that a running one answers.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use ipx_directory::store::Store;
|
||||
use ipx_directory::{Directory, config};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "ipx's directory of public podcasts")]
|
||||
struct Cli {
|
||||
/// The config file: where to listen, the database, and the tokens it accepts. Otherwise
|
||||
/// DIRECTORY_CONFIG, or else /config/directory.toml.
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Serve the directory (the default).
|
||||
Serve,
|
||||
/// Make a token, and print it with the lines that let it in.
|
||||
Token {
|
||||
#[command(subcommand)]
|
||||
cmd: TokenCmd,
|
||||
},
|
||||
/// Check that the directory on this machine answers and can read its database.
|
||||
Health,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TokenCmd {
|
||||
New {
|
||||
/// Who it is for, as the log and their snapshot will name them.
|
||||
name: String,
|
||||
#[arg(long, value_parser = ["ro", "rw", "rwa"])]
|
||||
access: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config_path = cli
|
||||
.config
|
||||
.or_else(|| std::env::var_os("DIRECTORY_CONFIG").map(Into::into))
|
||||
.unwrap_or_else(|| "/config/directory.toml".into());
|
||||
match cli.cmd.unwrap_or(Cmd::Serve) {
|
||||
Cmd::Serve => serve(&config_path).await,
|
||||
Cmd::Token { cmd: TokenCmd::New { name, access } } => {
|
||||
let token = config::new_token();
|
||||
println!("A token for {name}, to give them; it is not kept anywhere:\n\n {token}\n");
|
||||
println!("To let it in, add this to {}:\n", config_path.display());
|
||||
println!("[[tokens]]\nname = {name:?}\naccess = {access:?}\ntoken_sha256 = {:?}", config::hash(&token));
|
||||
println!("\nThe directory reads the file again within a minute. Delete the entry to revoke it.");
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Health => {
|
||||
let cfg = config::Config::load(&config_path)?;
|
||||
let port = cfg.bind.rsplit(':').next().unwrap_or("8100");
|
||||
let resp = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:{port}/healthz"))
|
||||
.timeout(Duration::from_secs(4))
|
||||
.send()
|
||||
.await
|
||||
.context("the directory does not answer")?;
|
||||
anyhow::ensure!(resp.status().is_success(), "the directory answers {}", resp.status());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(path: &Path) -> Result<()> {
|
||||
// One JSON object a line for Loki when DIRECTORY_LOG_FORMAT=json, as ipx does; text otherwise.
|
||||
let json = std::env::var("DIRECTORY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
let filter = || EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
|
||||
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's (#213).
|
||||
let otel = ipx_directory::otel::provider("ipx-directory")?;
|
||||
tracing_subscriber::registry()
|
||||
.with((!json).then(|| tracing_subscriber::fmt::layer().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| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx-directory"))
|
||||
.with_filter(EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
|
||||
let cfg = config::Config::load(path)?;
|
||||
let tokens = cfg.tokens()?;
|
||||
let url = std::env::var(&cfg.database_url_env)
|
||||
.ok()
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
|
||||
let store = Store::open(&url, /* keep_open */ true).await.context("opening the database")?;
|
||||
let dir = Directory::new(store, vec![], ipx_feed::guard::Allowed::parse(&cfg.fetch_internal)?)?;
|
||||
let dropped = dir.set_tokens(tokens).await?;
|
||||
|
||||
let listener = 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");
|
||||
if !cfg.metrics_bind.is_empty() {
|
||||
let metrics = 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()));
|
||||
let country = cfg.apple_country.trim().to_ascii_lowercase();
|
||||
if !country.is_empty() {
|
||||
tracing::info!(country = %country, "Apple Podcasts' charts are synced weekly");
|
||||
tokio::spawn(ipx_directory::apple::run(dir.clone(), country));
|
||||
}
|
||||
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||
axum::serve(listener, ipx_directory::router(dir))
|
||||
.with_graceful_shutdown(async {
|
||||
let mut term = tokio::signal::unix::signal(SignalKind::terminate()).expect("SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = term.recv() => {}
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
||||
if let Some(p) = otel {
|
||||
let _ = p.shutdown();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
103
crates/directory/src/metrics.rs
Normal file
103
crates/directory/src/metrics.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! 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 axum::Router;
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
use std::time::Instant;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
||||
#[must_use]
|
||||
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: 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: TcpListener) {
|
||||
let started = Instant::now();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || async move {
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip (#253).
|
||||
.layer(CompressionLayer::new().gzip(true));
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
95
crates/directory/src/otel.rs
Normal file
95
crates/directory/src/otel.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! Traces, for ipx and the directory both (#213): the exporter, and W3C `traceparent` in from a
|
||||
//! caller and out to a callee, so one trace follows a request from the browser through ipx-web
|
||||
//! and ipx into the directory.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
/// Traces over OTLP, to Tempo for one, when OTEL_EXPORTER_OTLP_ENDPOINT names a collector
|
||||
/// (`http://host:4318`: the exporter speaks OTLP over HTTP and adds `/v1/traces`). The exporter
|
||||
/// reads that and the other `OTEL_` variables itself.
|
||||
pub fn provider(service: &'static str) -> anyhow::Result<Option<opentelemetry_sdk::trace::SdkTracerProvider>> {
|
||||
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let exporter = opentelemetry_otlp::SpanExporter::builder().with_http().build()?;
|
||||
let resource = opentelemetry_sdk::Resource::builder().with_service_name(service).build();
|
||||
Ok(Some(
|
||||
opentelemetry_sdk::trace::SdkTracerProvider::builder()
|
||||
.with_batch_exporter(exporter)
|
||||
.with_resource(resource)
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
|
||||
/// one action sets off are one trace. None for a missing or malformed one. In the query where no
|
||||
/// header can be set: a position saved by sendBeacon, and the playlist Safari's player fetches
|
||||
/// itself (#192).
|
||||
#[must_use]
|
||||
pub fn remote_parent(headers: &HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
|
||||
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
|
||||
let v = match headers.get("traceparent") {
|
||||
Some(h) => h.to_str().ok()?,
|
||||
None => query?.split('&').find_map(|kv| kv.strip_prefix("traceparent="))?,
|
||||
};
|
||||
let mut parts = v.split('-');
|
||||
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
if version != "00" || trace.len() != 32 || span.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let sc = SpanContext::new(
|
||||
TraceId::from_hex(trace).ok()?,
|
||||
SpanId::from_hex(span).ok()?,
|
||||
TraceFlags::SAMPLED,
|
||||
true,
|
||||
TraceState::default(),
|
||||
);
|
||||
sc.is_valid().then(|| opentelemetry::Context::new().with_remote_span_context(sc))
|
||||
}
|
||||
|
||||
/// The current span as a `traceparent`, for a request to another service, so its spans join
|
||||
/// this trace. None outside a traced span.
|
||||
#[must_use]
|
||||
pub fn traceparent() -> Option<String> {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let cx = tracing::Span::current().context();
|
||||
let sc = cx.span().span_context().clone();
|
||||
sc.is_valid().then(|| format!("00-{}-{}-{:02x}", sc.trace_id(), sc.span_id(), sc.trace_flags().to_u8()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_requests_trace_is_the_pages_from_its_traceparent() {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
let with = |v: &str| {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("traceparent", v.parse().unwrap());
|
||||
remote_parent(&h, None)
|
||||
};
|
||||
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
|
||||
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assert!(cx.span().span_context().is_remote());
|
||||
// Anything else from the browser starts a trace of its own.
|
||||
for bad in [
|
||||
"",
|
||||
"00-xyz-00f067aa0ba902b7-01",
|
||||
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01",
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
] {
|
||||
assert!(with(bad).is_none(), "{bad}");
|
||||
}
|
||||
assert!(remote_parent(&axum::http::HeaderMap::new(), None).is_none());
|
||||
// From the query, where a beacon or Safari's player cannot send a header (#192).
|
||||
let q = remote_parent(
|
||||
&axum::http::HeaderMap::new(),
|
||||
Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
|
||||
);
|
||||
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
|
||||
}
|
||||
}
|
||||
67
crates/directory/src/site.css
Normal file
67
crates/directory/src/site.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/* The directory's pages (#215), in iPX's palette: the logo's navy, sky blue and needle orange,
|
||||
dark or light as the system is. */
|
||||
:root{--bg:#eef5fb;--panel:#fff;--panel2:#e4eff9;--line:#c6d9ea;--fg:#10233a;--dim:#46596e;--faint:#56697e;--accent:#2f6aa0;--accent2:#c43e00;--ink:#fff}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0a1726;--panel:#0f1f31;--panel2:#15283d;--line:#264060;--fg:#f3f7fb;--dim:#a3b6ca;--faint:#8a9fb5;--accent:#8fc2ea;--accent2:#ff6a1a;--ink:#0a1726}}
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1}
|
||||
.brand{display:flex;align-items:center;gap:10px;color:var(--fg);font-weight:700;font-size:17px;white-space:nowrap}
|
||||
.brand img{border-radius:8px;display:block}
|
||||
header form{margin-left:auto;flex:0 1 320px}
|
||||
input{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:8px 12px;width:100%}
|
||||
main{max-width:1100px;margin:0 auto;padding:20px 20px 40px}
|
||||
footer{max-width:1100px;margin:0 auto;padding:16px 20px 32px;color:var(--faint);font-size:13px;border-top:1px solid var(--line)}
|
||||
h2{font-size:22px;margin:20px 0 12px}
|
||||
h3{font-size:17px;margin:28px 0 8px}
|
||||
.hint,.count{color:var(--dim)}
|
||||
.back{margin:0;color:var(--faint);font-size:14px}
|
||||
.more{font-weight:600}
|
||||
.art{position:relative;display:block;aspect-ratio:1;border-radius:10px;overflow:hidden;background:var(--panel2);flex:none}
|
||||
.art i{position:absolute;inset:0;display:grid;place-items:center;font-style:normal;font-weight:700;color:var(--accent);font-size:28px}
|
||||
.art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||
.art.sm{width:48px;border-radius:8px}.art.sm i{font-size:16px}
|
||||
.art.lg{width:180px;border-radius:14px}.art.lg i{font-size:48px}
|
||||
.chart{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));gap:4px 24px}
|
||||
/* A grid item is as wide as its content unless told otherwise, and a long title then runs off the page. */
|
||||
.chart li,.tile{min-width:0}
|
||||
.chart a{display:flex;align-items:center;gap:12px;padding:8px;border-radius:10px;color:var(--fg)}
|
||||
.chart a:hover{background:var(--panel);text-decoration:none}
|
||||
.no{width:22px;text-align:right;color:var(--faint);font-variant-numeric:tabular-nums;flex:none}
|
||||
.txt{display:flex;flex-direction:column;min-width:0}
|
||||
.txt b,.tile b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
small{color:var(--dim);font-size:13px}
|
||||
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
|
||||
.cat{display:flex;flex-direction:column;gap:2px;padding:14px 16px;border-radius:12px;background:var(--panel);border:1px solid var(--line);color:var(--fg)}
|
||||
.cat:hover{border-color:var(--accent);text-decoration:none}
|
||||
.tabs,.pills{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 12px}
|
||||
.tabs a,.pills a{padding:6px 14px;border-radius:999px;background:var(--panel);border:1px solid var(--line);color:var(--fg);font-size:14px}
|
||||
.tabs a[aria-current],.pills a[aria-current]{background:var(--accent);border-color:var(--accent);color:var(--ink)}
|
||||
.pills a[aria-current] small{color:inherit}
|
||||
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px 14px}
|
||||
.tile{display:flex;flex-direction:column;gap:4px;min-width:0;color:var(--fg)}
|
||||
.tile:hover{text-decoration:none}.tile:hover b{text-decoration:underline}
|
||||
.show{display:flex;gap:24px;align-items:flex-end;margin-top:8px}
|
||||
.meta{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1}
|
||||
.meta h2{margin:0;font-size:28px;line-height:1.15;overflow-wrap:anywhere}
|
||||
.sub{margin:0;color:var(--dim)}
|
||||
.addr{display:flex;flex-direction:column;gap:4px;margin-top:8px;max-width:560px}
|
||||
.addr label{font-size:13px;color:var(--faint)}
|
||||
.addr .row{display:flex;gap:8px}
|
||||
.addr input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
|
||||
button{font:inherit;font-weight:600;padding:8px 16px;border-radius:10px;border:0;background:var(--accent);color:var(--ink);cursor:pointer}
|
||||
.about{color:var(--dim);max-width:72ch;margin:20px 0 0}
|
||||
.failing{color:var(--accent2)}
|
||||
article{padding:12px 0;border-bottom:1px solid var(--line);max-width:80ch}
|
||||
article p{margin:4px 0;color:var(--dim)}
|
||||
@media (max-width:600px){
|
||||
header{padding:10px 16px;gap:10px}
|
||||
.brand span,.brand{font-size:15px}
|
||||
main{padding:16px}
|
||||
.show{flex-direction:column;align-items:flex-start;gap:14px}
|
||||
.art.lg{width:140px}
|
||||
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
|
||||
}
|
||||
10
crates/directory/src/site.js
Normal file
10
crates/directory/src/site.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// The one thing the directory's pages script (#215): Copy puts a feed's address on the clipboard.
|
||||
// Without it, the address is in a field of its own to select by hand.
|
||||
document.addEventListener('click', ev => {
|
||||
const b = ev.target.closest('[data-copy]');
|
||||
if (!b || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(b.dataset.copy).then(() => {
|
||||
b.textContent = 'Copied';
|
||||
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
429
crates/directory/src/site.rs
Normal file
429
crates/directory/src/site.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
//! The directory in a web browser (#215): anyone may browse it, no token, read only. The most
|
||||
//! subscribed and the categories, every feed A to Z, a search by name, and a page per feed with
|
||||
//! its address to subscribe to in any podcast app. Rendered here from the store, as plain HTML:
|
||||
//! no build step, and nothing the API does not already hold.
|
||||
//!
|
||||
//! A feed's description and items are the publisher's HTML, unsanitized in the store; they are
|
||||
//! shown as text, tags stripped, and an item's link only when it is http(s).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
|
||||
use crate::{Listing, Shared};
|
||||
use url::form_urlencoded::Serializer;
|
||||
|
||||
pub fn router() -> Router<Shared> {
|
||||
Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/all", get(all))
|
||||
.route("/search", get(search))
|
||||
.route("/category/{name}", get(category))
|
||||
.route("/feed/{id}", get(feed))
|
||||
.route("/site.css", get(|| asset("text/css; charset=utf-8", include_bytes!("site.css"))))
|
||||
.route("/site.js", get(|| asset("text/javascript; charset=utf-8", include_bytes!("site.js"))))
|
||||
.route("/logo.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo.svg"))))
|
||||
.route("/logo-dark.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo-dark.svg"))))
|
||||
.route("/favicon.png", get(|| asset("image/png", include_bytes!("../../../web/favicon.png"))))
|
||||
}
|
||||
|
||||
/// Its own script and stylesheet only; covers come from their publishers.
|
||||
const CSP: &str = "default-src 'none'; img-src https: http: data:; style-src 'self'; script-src 'self'; \
|
||||
base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
async fn asset(kind: &'static str, body: &'static [u8]) -> Response {
|
||||
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Filter {
|
||||
/// `podcasts` or `blogs`; anything else is both.
|
||||
kind: Option<String>,
|
||||
sub: Option<String>,
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn keep(&self, l: &Listing) -> bool {
|
||||
match self.kind.as_deref() {
|
||||
Some("podcasts") => l.podcast,
|
||||
Some("blogs") => !l.podcast,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn home(State(dir): State<Shared>) -> Response {
|
||||
let all = match dir.pages_list().await {
|
||||
Ok(a) => a,
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
let mut top = (*all).clone();
|
||||
top.sort_by(|a, b| {
|
||||
b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))
|
||||
});
|
||||
top.truncate(10);
|
||||
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
|
||||
for l in all.iter() {
|
||||
cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
|
||||
}
|
||||
let mut b = String::new();
|
||||
if all.is_empty() {
|
||||
b.push_str("<p class=hint>Nothing is listed yet. A feed appears once a server has shared it and the directory has read it.</p>");
|
||||
} else {
|
||||
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
|
||||
for (i, l) in top.iter().enumerate() {
|
||||
let _ = write!(
|
||||
b,
|
||||
"<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
|
||||
esc(&l.id),
|
||||
i + 1,
|
||||
art(l, "art sm"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers)
|
||||
);
|
||||
}
|
||||
b.push_str("</ol><h2>Categories</h2><div class=cats>");
|
||||
for (c, ls) in &cats {
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>",
|
||||
seg(c),
|
||||
esc(c),
|
||||
ls.len()
|
||||
);
|
||||
}
|
||||
let _ = write!(b, "</div><p><a class=more href=\"/all\">All {} feeds, A to Z</a></p>", all.len());
|
||||
}
|
||||
page("iPX Directory", None, &b)
|
||||
}
|
||||
|
||||
async fn all(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
|
||||
listed(&dir, "All feeds", "/all", &f, |_| true).await
|
||||
}
|
||||
|
||||
async fn search(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
|
||||
let q = f.q.clone().unwrap_or_default().trim().to_lowercase();
|
||||
if q.is_empty() {
|
||||
return redirect("/all");
|
||||
}
|
||||
let title = format!("Results for \u{201c}{}\u{201d}", f.q.as_deref().unwrap_or("").trim());
|
||||
listed(&dir, &title, "/search", &f, move |l| name(l).to_lowercase().contains(&q)).await
|
||||
}
|
||||
|
||||
async fn category(State(dir): State<Shared>, Path(c): Path<String>, Query(f): Query<Filter>) -> Response {
|
||||
let here = format!("/category/{}", seg(&c));
|
||||
let cat = c.clone();
|
||||
listed(&dir, &c, &here, &f, move |l| l.category.as_deref().unwrap_or("Other") == cat).await
|
||||
}
|
||||
|
||||
/// A page of tiles: those `pick` takes, of the kind and subcategory asked for, A to Z.
|
||||
async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn(&Listing) -> bool) -> Response {
|
||||
let mut ls: Vec<Listing> = match dir.pages_list().await {
|
||||
Ok(a) => a.iter().filter(|l| pick(l)).cloned().collect(),
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
if ls.is_empty() && here.starts_with("/category/") {
|
||||
return not_found("That category has nothing in it.");
|
||||
}
|
||||
let subs: BTreeMap<String, usize> =
|
||||
ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
|
||||
*m.entry(s).or_default() += 1;
|
||||
m
|
||||
});
|
||||
ls.retain(|l| f.keep(l) && f.sub.as_ref().is_none_or(|s| l.subcategory.as_ref() == Some(s)));
|
||||
ls.sort_by_key(|l| name(l).to_lowercase());
|
||||
|
||||
// The query a link keeps, with one of its keys changed.
|
||||
let link = |kind: Option<&str>, sub: Option<&str>| {
|
||||
let mut q = Serializer::new(String::new());
|
||||
if let Some(v) = f.q.as_deref() {
|
||||
q.append_pair("q", v);
|
||||
}
|
||||
if let Some(v) = kind {
|
||||
q.append_pair("kind", v);
|
||||
}
|
||||
if let Some(v) = sub {
|
||||
q.append_pair("sub", v);
|
||||
}
|
||||
let q = q.finish();
|
||||
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
|
||||
};
|
||||
let mut b = String::new();
|
||||
let _ = write!(
|
||||
b,
|
||||
"<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>",
|
||||
esc(title)
|
||||
);
|
||||
for (k, label) in [(None, "All"), (Some("podcasts"), "Podcasts"), (Some("blogs"), "Blogs")] {
|
||||
let on = f.kind.as_deref() == k;
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>{label}</a>",
|
||||
esc(&link(k, f.sub.as_deref())),
|
||||
if on { " aria-current=page" } else { "" }
|
||||
);
|
||||
}
|
||||
b.push_str("</nav>");
|
||||
if !subs.is_empty() {
|
||||
b.push_str("<nav class=pills aria-label=Subcategory>");
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>All</a>",
|
||||
esc(&link(f.kind.as_deref(), None)),
|
||||
if f.sub.is_none() { " aria-current=page" } else { "" }
|
||||
);
|
||||
for (s, n) in &subs {
|
||||
let on = f.sub.as_deref() == Some(s.as_str());
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>{} <small>{n}</small></a>",
|
||||
esc(&link(f.kind.as_deref(), Some(s))),
|
||||
if on { " aria-current=page" } else { "" },
|
||||
esc(s)
|
||||
);
|
||||
}
|
||||
b.push_str("</nav>");
|
||||
}
|
||||
if ls.is_empty() {
|
||||
b.push_str("<p class=hint>Nothing here.</p>");
|
||||
} else {
|
||||
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
|
||||
for l in &ls {
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>",
|
||||
esc(&l.id),
|
||||
art(l, "art"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers)
|
||||
);
|
||||
}
|
||||
b.push_str("</div>");
|
||||
}
|
||||
page(title, None, &b)
|
||||
}
|
||||
|
||||
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
||||
let p = match dir.store.feed(&id).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return not_found("That feed is not in the directory."),
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
let l = &p.listing;
|
||||
let mut b = String::new();
|
||||
b.push_str("<p class=back><a href=\"/\">Directory</a>");
|
||||
if let Some(c) = &l.category {
|
||||
let _ = write!(b, " <span aria-hidden=true>/</span> <a href=\"/category/{}\">{}</a>", seg(c), esc(c));
|
||||
}
|
||||
let _ = write!(
|
||||
b,
|
||||
"</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>",
|
||||
art(l, "art lg"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers),
|
||||
l.subcategory.as_deref().map(|s| format!(" · {}", esc(s))).unwrap_or_default()
|
||||
);
|
||||
let _ = write!(
|
||||
b,
|
||||
"<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
|
||||
<button type=button data-copy=\"{}\">Copy</button></span><small>Paste it into any podcast app or feed reader to subscribe.</small></div></div></div>",
|
||||
esc(&l.url),
|
||||
esc(&l.url)
|
||||
);
|
||||
if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) {
|
||||
let _ = write!(b, "<p class=about>{}</p>", esc(&d));
|
||||
}
|
||||
if let Some(why) = &p.failing {
|
||||
let _ = write!(b, "<p class=failing>{}</p>", esc(why));
|
||||
}
|
||||
let _ = write!(b, "<h3>{}</h3>", if l.podcast { "Latest episodes" } else { "Latest posts" });
|
||||
if p.items.is_empty() {
|
||||
b.push_str("<p class=hint>Nothing read from it yet. The directory checks each feed every six hours.</p>");
|
||||
}
|
||||
for e in &p.items {
|
||||
let title = e.title.as_deref().map(text).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled".into());
|
||||
let head = match e.link.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://")) {
|
||||
Some(u) => format!("<a href=\"{}\" rel=\"noopener nofollow\">{}</a>", esc(u), esc(&title)),
|
||||
None => esc(&title),
|
||||
};
|
||||
let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect();
|
||||
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let _ = write!(
|
||||
b,
|
||||
"<article><b>{head}</b>{}{}</article>",
|
||||
if blurb.is_empty() { String::new() } else { format!("<p>{}</p>", esc(&blurb)) },
|
||||
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") }
|
||||
);
|
||||
}
|
||||
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
|
||||
}
|
||||
|
||||
fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
||||
let full = if title == "iPX Directory" { title.to_owned() } else { format!("{title} | iPX Directory") };
|
||||
let desc = description
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(|d| d.chars().take(200).collect::<String>())
|
||||
.unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into());
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
|
||||
<title>{}</title><meta name=description content=\"{}\"><meta name=color-scheme content=\"light dark\">\
|
||||
<link rel=icon href=/favicon.png><link rel=stylesheet href=/site.css><script src=/site.js defer></script></head>\
|
||||
<body><header><a class=brand href=\"/\"><picture><source srcset=/logo-dark.svg media=\"(prefers-color-scheme: dark)\">\
|
||||
<img src=/logo.svg alt=\"\" width=32 height=32></picture>iPX Directory</a>\
|
||||
<form action=/search role=search><input type=search name=q placeholder=\"Find a feed\" aria-label=\"Find a feed by name\"></form></header>\
|
||||
<main>{body}</main><footer>Public podcasts and blogs that iPX servers share, and how many people on them subscribe. \
|
||||
Read only: to change what is here, an iPX server shares its public feeds.</footer></body></html>",
|
||||
esc(&full),
|
||||
esc(&desc),
|
||||
);
|
||||
let mut r = html.into_response();
|
||||
let h = r.headers_mut();
|
||||
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
|
||||
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP));
|
||||
h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
|
||||
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
r
|
||||
}
|
||||
|
||||
fn not_found(why: &str) -> Response {
|
||||
let mut r = page(
|
||||
"Not found",
|
||||
None,
|
||||
&format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)),
|
||||
);
|
||||
*r.status_mut() = StatusCode::NOT_FOUND;
|
||||
r
|
||||
}
|
||||
|
||||
fn failed(e: anyhow::Error) -> Response {
|
||||
tracing::error!(error = %format!("{e:#}"), "a directory page failed");
|
||||
let mut r = page(
|
||||
"Unavailable",
|
||||
None,
|
||||
"<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>",
|
||||
);
|
||||
*r.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
|
||||
r
|
||||
}
|
||||
|
||||
fn redirect(to: &'static str) -> Response {
|
||||
(StatusCode::SEE_OTHER, [(header::LOCATION, to)]).into_response()
|
||||
}
|
||||
|
||||
/// A cover from its publisher, over the feed's initials, which show where it has none or it fails.
|
||||
fn art(l: &Listing, class: &str) -> String {
|
||||
// The first letter of each of the first two words that have one: `<antirez>` is A, not nothing.
|
||||
let initials: String =
|
||||
name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
|
||||
let img = l
|
||||
.image
|
||||
.as_deref()
|
||||
.filter(|u| u.starts_with("https://") || u.starts_with("http://"))
|
||||
.map(|u| format!("<img src=\"{}\" alt=\"\" loading=lazy referrerpolicy=no-referrer>", esc(u)))
|
||||
.unwrap_or_default();
|
||||
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
|
||||
}
|
||||
|
||||
fn name(l: &Listing) -> &str {
|
||||
l.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&l.id)
|
||||
}
|
||||
|
||||
fn subscribers(n: i64) -> String {
|
||||
match n {
|
||||
0 => "Listed, no subscribers yet".into(),
|
||||
1 => "1 person subscribes".into(),
|
||||
n => format!("{n} people subscribe"),
|
||||
}
|
||||
}
|
||||
|
||||
fn plural(n: usize, word: &str) -> String {
|
||||
format!("{n} {word}{}", if n == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
/// `12 Oct 2026`, from a Unix time, with no date library: the civil-from-days algorithm.
|
||||
fn date(t: i64) -> String {
|
||||
let z = t.div_euclid(86_400) + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
format!("{d} {} {y}", MONTHS[(m - 1) as usize])
|
||||
}
|
||||
|
||||
fn clock(secs: i64) -> String {
|
||||
let (h, m) = (secs / 3600, secs % 3600 / 60);
|
||||
if h > 0 { format!("{h} h {m} min") } else { format!("{} min", m.max(1)) }
|
||||
}
|
||||
|
||||
/// The publisher's HTML as plain text: tags gone, entities read, whitespace collapsed. Escaped
|
||||
/// again where it is shown.
|
||||
fn text(html: &str) -> String {
|
||||
// A space where each tag was, or two paragraphs run together: "drives.For over".
|
||||
let stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
|
||||
let decoded = stripped
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&");
|
||||
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A category as a path segment.
|
||||
fn seg(s: &str) -> String {
|
||||
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_publishers_html_becomes_text_and_is_escaped_where_shown() {
|
||||
assert_eq!(text("<p>Tom & Jerry <script>alert(1)</script><b>live</b></p>"), "Tom & Jerry live");
|
||||
assert_eq!(text("<p>One.</p><p>Two.</p>"), "One. Two.");
|
||||
assert_eq!(esc(&text("a <b> c")), "a <b> c");
|
||||
assert_eq!(esc("\"><img onerror=x>"), ""><img onerror=x>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_and_lengths_read_as_people_write_them() {
|
||||
assert_eq!(date(0), "1 Jan 1970");
|
||||
assert_eq!(date(1_791_820_800), "12 Oct 2026");
|
||||
assert_eq!(date(951_782_400), "29 Feb 2000");
|
||||
assert_eq!(clock(59), "1 min");
|
||||
assert_eq!(clock(3_725), "1 h 2 min");
|
||||
}
|
||||
}
|
||||
695
crates/directory/src/store.rs
Normal file
695
crates/directory/src/store.rs
Normal file
@@ -0,0 +1,695 @@
|
||||
//! The directory's database: the feeds instances have sent, what each instance sent, and what
|
||||
//! an admin has blocked. SQL written by hand, as ipx's is, so it runs on SQLite (tests) and
|
||||
//! Postgres (production) both: `$1` parameters, `ON CONFLICT`, booleans as themselves.
|
||||
|
||||
use anyhow::Result;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait, Value};
|
||||
|
||||
use crate::{Blocked, FeedPage, Instance, Item, Listing, SnapshotEntry, SnapshotResult};
|
||||
use sea_orm::{ConnectOptions, Database, QueryResult};
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::time::SystemTime;
|
||||
use url::Url;
|
||||
|
||||
/// A feed's items as its page shows them, and as many as ipx's own Directory page showed.
|
||||
pub const ITEMS: usize = 20;
|
||||
|
||||
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
|
||||
const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
||||
|
||||
/// The most subscribers one instance may say a feed has.
|
||||
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
|
||||
|
||||
/// The directory's database: feeds, snapshots, blocks and when each sync ran.
|
||||
pub struct Store {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
/// What the refresher needs to ask for a feed again.
|
||||
pub struct Due {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub etag: Option<String>,
|
||||
pub last_modified: Option<String>,
|
||||
}
|
||||
|
||||
/// What a fetch found, to be stored.
|
||||
pub enum Refreshed {
|
||||
/// Unchanged since the last fetch.
|
||||
NotModified,
|
||||
/// Read anew, with the validators to send next time.
|
||||
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
||||
/// The fetch failed, and why, in words.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Unix seconds now.
|
||||
#[must_use]
|
||||
pub fn now() -> i64 {
|
||||
SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// The address as every instance would write it: scheme and host lower-cased, a default port
|
||||
/// and a fragment dropped. None for anything that is not http(s).
|
||||
#[must_use]
|
||||
pub fn normalise(url: &str) -> Option<String> {
|
||||
let mut u = Url::parse(url.trim()).ok()?;
|
||||
if !matches!(u.scheme(), "http" | "https") || u.host_str().is_none() {
|
||||
return None;
|
||||
}
|
||||
u.set_fragment(None);
|
||||
Some(u.to_string())
|
||||
}
|
||||
|
||||
/// A feed's id: the first 64 bits of its normalised address's SHA-256, so every instance, and
|
||||
/// anyone else, arrives at the same id for the same feed without asking.
|
||||
#[must_use]
|
||||
pub fn feed_id(normalised: &str) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(normalised.as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
const SCHEMA: &[&str] = &[
|
||||
"CREATE TABLE IF NOT EXISTS feeds (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
image TEXT,
|
||||
category TEXT,
|
||||
subcategory TEXT,
|
||||
description TEXT,
|
||||
podcast BOOLEAN NOT NULL DEFAULT false,
|
||||
items TEXT,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
checked_at BIGINT,
|
||||
last_error TEXT,
|
||||
error_since BIGINT
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS snapshots (
|
||||
instance TEXT NOT NULL,
|
||||
feed_id TEXT NOT NULL,
|
||||
subscribers BIGINT NOT NULL,
|
||||
category TEXT,
|
||||
subcategory TEXT,
|
||||
PRIMARY KEY (instance, feed_id)
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
||||
// When each periodic job last finished: Apple's weekly sync (#252).
|
||||
"CREATE TABLE IF NOT EXISTS syncs (
|
||||
name TEXT PRIMARY KEY,
|
||||
at BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS blocked (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
at BIGINT NOT NULL
|
||||
)",
|
||||
];
|
||||
|
||||
impl Store {
|
||||
/// A database of its own for a test: a fresh schema on IPX_TEST_DATABASE_URL's Postgres when
|
||||
/// that is set, as ipx's tests do, or else SQLite in memory.
|
||||
#[cfg(test)]
|
||||
pub async fn scratch() -> Self {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) else {
|
||||
return Self::open("sqlite::memory:", false).await.unwrap();
|
||||
};
|
||||
let (pid, n) = (std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
// A bare connection: Store::open would make the tables in the public schema, and the
|
||||
// tests running at once raced each other doing it.
|
||||
let admin = Self { db: sea_orm::Database::connect(url.as_str()).await.unwrap() };
|
||||
// Schemas an earlier run left behind: anything of ours not made by this process.
|
||||
let old = admin
|
||||
.rows(&format!(r"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'ipxd\_%' AND nspname NOT LIKE 'ipxd\_{pid}\_%'"), vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
for r in old {
|
||||
let name: String = r.try_get_by_index(0).unwrap();
|
||||
admin.db.execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")).await.unwrap();
|
||||
}
|
||||
let schema = format!("ipxd_{pid}_{n}");
|
||||
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
Self::open(
|
||||
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `keep_open` for the server, which keeps its whole pool connected.
|
||||
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
|
||||
let mut url = url.to_owned();
|
||||
if url.starts_with("postgres") && !url.contains("options=") {
|
||||
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
|
||||
// asking for none keeps them out of the log, as ipx's connections do.
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
url = format!("{url}{sep}options=-c%20client_min_messages%3Dwarning");
|
||||
}
|
||||
let mut opts = ConnectOptions::new(url.clone());
|
||||
opts.sqlx_logging(false);
|
||||
// An in-memory SQLite database is one per connection: a pool of them would be several.
|
||||
if url.contains(":memory:") {
|
||||
opts.max_connections(1);
|
||||
}
|
||||
// The server keeps every connection open, as ipx's daemon does: a Postgres connection takes
|
||||
// 80-330 ms to open (#241). Not a test, which would hold ten for one question.
|
||||
if keep_open && url.starts_with("postgres") {
|
||||
opts.max_connections(10).min_connections(10);
|
||||
}
|
||||
let db = Database::connect(opts).await?;
|
||||
for sql in SCHEMA {
|
||||
db.execute_unprepared(sql).await?;
|
||||
}
|
||||
// Added after the first deploy: a database made before has the table without them.
|
||||
if db.execute_unprepared("SELECT category FROM snapshots WHERE false").await.is_err() {
|
||||
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN category TEXT").await?;
|
||||
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN subcategory TEXT").await?;
|
||||
}
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
fn stmt(&self, sql: &str, values: Vec<Value>) -> Statement {
|
||||
Statement::from_sql_and_values(self.db.get_database_backend(), sql, values)
|
||||
}
|
||||
|
||||
async fn exec(&self, sql: &str, values: Vec<Value>) -> Result<u64> {
|
||||
Ok(self.db.execute_raw(self.stmt(sql, values)).await?.rows_affected())
|
||||
}
|
||||
|
||||
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<QueryResult>> {
|
||||
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
||||
}
|
||||
|
||||
/// When the job `name` last finished, Unix seconds.
|
||||
pub async fn synced_at(&self, name: &str) -> Result<Option<i64>> {
|
||||
Ok(match self.rows("SELECT at FROM syncs WHERE name = $1", vec![name.into()]).await?.first() {
|
||||
Some(r) => Some(r.try_get("", "at")?),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Records when the sync `name` (Apple's charts) last ran.
|
||||
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
||||
vec![name.into(), at.into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the database answers, for the healthcheck.
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
self.rows("SELECT 1", vec![]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces everything `instance` sent before with `entries`. A private-looking address or a
|
||||
/// blocked feed is left out and counted; the rest are added to the feeds, new ones to be
|
||||
/// fetched by the refresher. Feeds no instance sends any more go.
|
||||
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
|
||||
let blocked: HashSet<String> = self
|
||||
.rows("SELECT id FROM blocked", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "id"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut result = SnapshotResult::default();
|
||||
let mut keep = BTreeMap::new();
|
||||
for e in entries {
|
||||
let Some(url) = normalise(&e.url) else {
|
||||
result.refused_invalid += 1;
|
||||
continue;
|
||||
};
|
||||
// Checked here as well as by the sender: one instance's miss must not publish
|
||||
// someone's paid feed to every other.
|
||||
if ipx_feed::url_looks_private(&url) {
|
||||
result.refused_private += 1;
|
||||
continue;
|
||||
}
|
||||
let id = feed_id(&url);
|
||||
if blocked.contains(&id) {
|
||||
result.refused_blocked += 1;
|
||||
continue;
|
||||
}
|
||||
// The same feed twice in one snapshot counts once, at its larger number.
|
||||
// Bounded above as well: two instances' i64::MAX summed past BIGINT, and every listing
|
||||
// and page failed until the instance was dropped (#342). No server has a million.
|
||||
let n = e.subscribers.clamp(0, MAX_SUBSCRIBERS);
|
||||
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
|
||||
let slot = keep.entry(id).or_insert((url, n, path.clone()));
|
||||
slot.1 = slot.1.max(n);
|
||||
if slot.2.is_none() {
|
||||
slot.2 = path;
|
||||
}
|
||||
}
|
||||
result.accepted = keep.len();
|
||||
|
||||
let tx = self.db.begin().await?;
|
||||
let backend = tx.get_database_backend();
|
||||
let run = |sql: &str, values: Vec<Value>| Statement::from_sql_and_values(backend, sql, values);
|
||||
tx.execute_raw(run("DELETE FROM snapshots WHERE instance = $1", vec![instance.into()])).await?;
|
||||
for (id, (url, n, path)) in &keep {
|
||||
tx.execute_raw(run(
|
||||
"INSERT INTO feeds (id, url) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
|
||||
vec![id.clone().into(), url.clone().into()],
|
||||
))
|
||||
.await?;
|
||||
tx.execute_raw(run(
|
||||
"INSERT INTO snapshots (instance, feed_id, subscribers, category, subcategory) VALUES ($1, $2, $3, $4, $5)",
|
||||
vec![
|
||||
instance.into(),
|
||||
id.clone().into(),
|
||||
(*n).into(),
|
||||
path.as_ref().map(|p| p.0.clone()).into(),
|
||||
path.as_ref().and_then(|p| p.1.clone()).into(),
|
||||
],
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
tx.execute_raw(run("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![])).await?;
|
||||
tx.commit().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Drops the snapshots of every instance not in `names`: a token taken out of the config
|
||||
/// file takes its counts with it.
|
||||
pub async fn keep_instances(&self, names: &[String]) -> Result<u64> {
|
||||
let mut dropped = 0;
|
||||
let present: Vec<String> = self
|
||||
.rows("SELECT DISTINCT instance FROM snapshots", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "instance"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
// Apple's charts are no token's, and stay until the next sync replaces them.
|
||||
let gone = present.iter().filter(|n| !names.contains(n) && n.as_str() != crate::apple::INSTANCE);
|
||||
for name in gone {
|
||||
dropped += self.drop_instance(name).await?;
|
||||
}
|
||||
Ok(dropped)
|
||||
}
|
||||
|
||||
/// Drops what one instance sent; how many feeds it had.
|
||||
pub async fn drop_instance(&self, name: &str) -> Result<u64> {
|
||||
let n = self.exec("DELETE FROM snapshots WHERE instance = $1", vec![name.into()]).await?;
|
||||
self.exec("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![]).await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// The feed's own category, or else one a sending server's admin gave it.
|
||||
// Only feeds read as a feed at least once (`items` set): never an OPML a server sent before it
|
||||
// knew it was one, nor an address that has never worked.
|
||||
const LISTING: &str = "SELECT f.id, f.url, f.title, f.image,
|
||||
CASE WHEN f.category IS NULL THEN max(s.category) ELSE f.category END AS category,
|
||||
CASE WHEN f.category IS NULL THEN max(s.subcategory) ELSE f.subcategory END AS subcategory,
|
||||
f.podcast, CAST(sum(s.subscribers) AS BIGINT) AS subscribers
|
||||
FROM feeds f JOIN snapshots s ON s.feed_id = f.id WHERE f.items IS NOT NULL";
|
||||
|
||||
fn listing(r: &QueryResult) -> Result<Listing> {
|
||||
Ok(Listing {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
title: r.try_get("", "title")?,
|
||||
image: r.try_get("", "image")?,
|
||||
category: r.try_get("", "category")?,
|
||||
subcategory: r.try_get("", "subcategory")?,
|
||||
podcast: r.try_get("", "podcast")?,
|
||||
subscribers: r.try_get("", "subscribers")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every feed, A to Z by title (one not fetched yet has none and sorts by its address).
|
||||
///
|
||||
/// ponytail: the whole list in one answer, as ipx's own Directory was; page it when it
|
||||
/// grows past a few thousand feeds.
|
||||
pub async fn list(&self) -> Result<Vec<Listing>> {
|
||||
let sql = format!("{} GROUP BY f.id ORDER BY lower(coalesce(f.title, f.url)), f.id", Self::LISTING);
|
||||
self.rows(&sql, vec![]).await?.iter().map(Self::listing).collect()
|
||||
}
|
||||
|
||||
/// The most subscribed, most first.
|
||||
pub async fn popular(&self, limit: u64) -> Result<Vec<Listing>> {
|
||||
let sql = format!(
|
||||
"{} GROUP BY f.id ORDER BY sum(s.subscribers) DESC, lower(coalesce(f.title, f.url)), f.id LIMIT $1",
|
||||
Self::LISTING
|
||||
);
|
||||
self.rows(&sql, vec![(limit as i64).into()]).await?.iter().map(Self::listing).collect()
|
||||
}
|
||||
|
||||
/// One feed with its description and latest items.
|
||||
pub async fn feed(&self, id: &str) -> Result<Option<FeedPage>> {
|
||||
let sql = format!("{} AND f.id = $1 GROUP BY f.id", Self::LISTING);
|
||||
let Some(r) = self.rows(&sql, vec![id.into()]).await?.into_iter().next() else { return Ok(None) };
|
||||
let listing = Self::listing(&r)?;
|
||||
let r = self
|
||||
.rows("SELECT description, items, last_error, error_since FROM feeds WHERE id = $1", vec![id.into()])
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("feed {id} went while it was read"))?;
|
||||
let items: Option<String> = r.try_get("", "items")?;
|
||||
let last_error: Option<String> = r.try_get("", "last_error")?;
|
||||
let error_since: Option<i64> = r.try_get("", "error_since")?;
|
||||
Ok(Some(FeedPage {
|
||||
listing,
|
||||
description: r.try_get("", "description")?,
|
||||
failing: last_error.map(|e| failure_words(&e, now() - error_since.unwrap_or_else(now))),
|
||||
items: items.and_then(|i| serde_json::from_str(&i).ok()).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Removes a feed and blocks it, so no snapshot brings it back. False when it was neither
|
||||
/// listed nor blocked already.
|
||||
pub async fn block(&self, id: &str) -> Result<bool> {
|
||||
let r = self.rows("SELECT url, title FROM feeds WHERE id = $1", vec![id.into()]).await?.into_iter().next();
|
||||
let (url, title): (Option<String>, Option<String>) = match &r {
|
||||
Some(r) => (r.try_get("", "url")?, r.try_get("", "title")?),
|
||||
None => (None, None),
|
||||
};
|
||||
let added = self
|
||||
.exec(
|
||||
"INSERT INTO blocked (id, url, title, at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
|
||||
vec![id.into(), url.into(), title.into(), now().into()],
|
||||
)
|
||||
.await?;
|
||||
self.exec("DELETE FROM snapshots WHERE feed_id = $1", vec![id.into()]).await?;
|
||||
let removed = self.exec("DELETE FROM feeds WHERE id = $1", vec![id.into()]).await?;
|
||||
Ok(added + removed > 0)
|
||||
}
|
||||
|
||||
/// Unblocks a feed; it comes back with the next snapshot that has it.
|
||||
pub async fn unblock(&self, id: &str) -> Result<bool> {
|
||||
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
|
||||
}
|
||||
|
||||
/// Each instance that has sent a snapshot, with how many feeds and people it counted.
|
||||
pub async fn instances(&self) -> Result<Vec<Instance>> {
|
||||
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
|
||||
self.rows(
|
||||
"SELECT instance, count(*) AS feeds, CAST(sum(subscribers) AS BIGINT) AS subscribers
|
||||
FROM snapshots GROUP BY instance ORDER BY instance",
|
||||
vec![],
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(Instance {
|
||||
name: r.try_get("", "instance")?,
|
||||
feeds: r.try_get("", "feeds")?,
|
||||
subscribers: r.try_get("", "subscribers")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The feeds an admin has blocked.
|
||||
pub async fn blocked(&self) -> Result<Vec<Blocked>> {
|
||||
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(Blocked {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
title: r.try_get("", "title")?,
|
||||
at: r.try_get("", "at")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Feeds never fetched, then those fetched longest ago, older than `every` seconds.
|
||||
pub async fn due(&self, every: i64, limit: u64) -> Result<Vec<Due>> {
|
||||
self.rows(
|
||||
"SELECT id, url, etag, last_modified FROM feeds
|
||||
WHERE checked_at IS NULL OR checked_at < $1
|
||||
ORDER BY coalesce(checked_at, 0), id LIMIT $2",
|
||||
vec![(now() - every).into(), (limit as i64).into()],
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(Due {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
etag: r.try_get("", "etag")?,
|
||||
last_modified: r.try_get("", "last_modified")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One feed, due or not, for an admin's Refresh.
|
||||
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||
.await?
|
||||
.first()
|
||||
.map(|r| -> Result<Due> {
|
||||
Ok(Due {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
etag: r.try_get("", "etag")?,
|
||||
last_modified: r.try_get("", "last_modified")?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Stores what a fetch of feed `id` found, and when.
|
||||
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
||||
let t = now();
|
||||
match got {
|
||||
Refreshed::NotModified => {
|
||||
self.exec(
|
||||
"UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2",
|
||||
vec![t.into(), id.into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Refreshed::Failed(err) => {
|
||||
self.exec(
|
||||
"UPDATE feeds SET checked_at = $1, last_error = $2, error_since = coalesce(error_since, $1) WHERE id = $3",
|
||||
vec![t.into(), err.into(), id.into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Refreshed::Feed { parsed, etag, last_modified } => {
|
||||
let path = parsed.category.as_deref().map(ipx_feed::category_path);
|
||||
let podcast = parsed
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|e| &e.enclosures)
|
||||
.any(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/")));
|
||||
let mut entries = parsed.entries;
|
||||
entries.sort_by_key(|e| Reverse(e.published));
|
||||
// What an item is, never its files: their addresses are where a key would be.
|
||||
let items: Vec<Item> = entries
|
||||
.into_iter()
|
||||
.take(ITEMS)
|
||||
.map(|e| Item {
|
||||
guid: e.guid,
|
||||
title: e.title,
|
||||
link: e.link,
|
||||
published: e.published,
|
||||
duration: e.duration,
|
||||
description: e.description,
|
||||
})
|
||||
.collect();
|
||||
self.exec(
|
||||
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
|
||||
podcast = $6, items = $7, etag = $8, last_modified = $9, checked_at = $10,
|
||||
last_error = NULL, error_since = NULL
|
||||
WHERE id = $11",
|
||||
vec![
|
||||
parsed.title.into(),
|
||||
parsed.image.into(),
|
||||
path.as_ref().map(|p| p.0.clone()).into(),
|
||||
path.and_then(|p| p.1).into(),
|
||||
parsed.description.into(),
|
||||
podcast.into(),
|
||||
serde_json::to_string(&items)?.into(),
|
||||
etag.into(),
|
||||
last_modified.into(),
|
||||
t.into(),
|
||||
id.into(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A feed's last error as its page says it: explained once it has been failing a day, or else
|
||||
/// only that the check failed. Never the error itself, which is for the log.
|
||||
fn failure_words(err: &str, failing_for: i64) -> String {
|
||||
(failing_for >= FLAG_AFTER_SECS)
|
||||
.then_some(err)
|
||||
.and_then(ipx_feed::explain_failure)
|
||||
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Reads the fixture feed as every feed waiting to be fetched, as the refresher would.
|
||||
pub(crate) async fn fetch_all(s: &Store) {
|
||||
for d in s.due(0, 100).await.unwrap() {
|
||||
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn e(url: &str, n: i64) -> SnapshotEntry {
|
||||
SnapshotEntry { url: url.into(), subscribers: n, category: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_is_explained_after_a_day_and_never_names_its_address() {
|
||||
let nothing = "the feed's address answered with nothing at all";
|
||||
assert_eq!(failure_words(nothing, FLAG_AFTER_SECS), "This address answers with nothing; the site may be gone.");
|
||||
// Not before it has been failing a day, as on the feed's own page in ipx (#143).
|
||||
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
|
||||
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
|
||||
assert_eq!(failure_words(raw, FLAG_AFTER_SECS), "Its last check failed.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_instance_writes_one_feed_the_same_way() {
|
||||
let a = normalise("HTTPS://Example.COM:443/feed.xml#top").unwrap();
|
||||
assert_eq!(a, "https://example.com/feed.xml");
|
||||
assert_eq!(feed_id(&a), feed_id(&normalise("https://example.com/feed.xml").unwrap()));
|
||||
assert_eq!(feed_id(&a).len(), 16);
|
||||
assert!(normalise("ftp://example.com/x").is_none());
|
||||
assert!(normalise("not a url").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_count_an_instance_sends_breaks_the_listing() {
|
||||
let s = Store::scratch().await;
|
||||
s.snapshot("a", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||
s.snapshot("b", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||
fetch_all(&s).await;
|
||||
assert_eq!(s.list().await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS, "summed, not overflowed (#342)");
|
||||
assert_eq!(s.popular(10).await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS);
|
||||
assert!(s.instances().await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
|
||||
let s = Store::scratch().await;
|
||||
let r = s
|
||||
.snapshot("a", &[e("https://x.test/a.xml", 3), e("https://x.test/b.xml", 1), e("https://x.test/a.xml", 5)])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.accepted, 2);
|
||||
s.snapshot("b", &[e("https://x.test/a.xml", 2)]).await.unwrap();
|
||||
// Nothing is listed until it has been read as a feed.
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
fetch_all(&s).await;
|
||||
let pop = s.popular(10).await.unwrap();
|
||||
assert_eq!((pop[0].url.as_str(), pop[0].subscribers), ("https://x.test/a.xml", 7));
|
||||
assert_eq!(pop[1].subscribers, 1);
|
||||
|
||||
// a no longer has b.xml: nobody does, so it goes.
|
||||
s.snapshot("a", &[e("https://x.test/a.xml", 1)]).await.unwrap();
|
||||
let all = s.list().await.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].subscribers, 3);
|
||||
|
||||
let i = s.instances().await.unwrap();
|
||||
assert_eq!(
|
||||
i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(),
|
||||
[("a", 1, 1), ("b", 1, 2)]
|
||||
);
|
||||
|
||||
// A token taken out of the config file takes its counts.
|
||||
s.keep_instances(&["b".into()]).await.unwrap();
|
||||
assert_eq!(s.list().await.unwrap()[0].subscribers, 2);
|
||||
s.drop_instance("b").await.unwrap();
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_looking_addresses_and_blocked_feeds_are_refused() {
|
||||
let s = Store::scratch().await;
|
||||
let r = s
|
||||
.snapshot(
|
||||
"a",
|
||||
&[
|
||||
e("https://feeds.supercast.com/feeds/abc123", 1),
|
||||
e("https://x.test/feed?token=secret", 1),
|
||||
e("https://user:pass@x.test/feed", 1),
|
||||
e("https://www.patreon.com/rss/show?auth=k", 1),
|
||||
e("mailto:x@y", 1),
|
||||
e("https://x.test/public.xml", 1),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((r.accepted, r.refused_private, r.refused_invalid), (1, 4, 1));
|
||||
fetch_all(&s).await;
|
||||
|
||||
let id = s.list().await.unwrap()[0].id.clone();
|
||||
assert!(s.block(&id).await.unwrap());
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
let r = s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap();
|
||||
assert_eq!((r.accepted, r.refused_blocked), (0, 1));
|
||||
assert_eq!(s.blocked().await.unwrap()[0].url.as_deref(), Some("https://x.test/public.xml"));
|
||||
assert!(s.unblock(&id).await.unwrap());
|
||||
assert_eq!(s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap().accepted, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
|
||||
let s = Store::scratch().await;
|
||||
let entry = |c: Option<&str>| SnapshotEntry {
|
||||
url: "https://x.test/blog.xml".into(),
|
||||
subscribers: 1,
|
||||
category: c.map(Into::into),
|
||||
};
|
||||
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
|
||||
let d = s.due(0, 10).await.unwrap().remove(0);
|
||||
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
parsed.category = None;
|
||||
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||
let l = s.list().await.unwrap().remove(0);
|
||||
assert_eq!((l.category.as_deref(), l.subcategory.as_deref()), (Some("Arts"), Some("Visual Arts")));
|
||||
s.snapshot("a", &[entry(None)]).await.unwrap();
|
||||
assert_eq!(s.list().await.unwrap()[0].category, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetched_feed_shows_its_metadata_and_newest_items_but_no_files() {
|
||||
let s = Store::scratch().await;
|
||||
s.snapshot("a", &[e("https://x.test/feed.xml", 1)]).await.unwrap();
|
||||
let due = s.due(3600, 10).await.unwrap();
|
||||
assert_eq!(due.len(), 1);
|
||||
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
let title = parsed.title.clone();
|
||||
s.save(&due[0].id, Refreshed::Feed { parsed, etag: Some("e".into()), last_modified: None }).await.unwrap();
|
||||
assert!(s.due(3600, 10).await.unwrap().is_empty());
|
||||
let page = s.feed(&due[0].id).await.unwrap().unwrap();
|
||||
assert_eq!(page.listing.title, title);
|
||||
assert!(page.listing.podcast);
|
||||
assert!(!page.items.is_empty() && page.items.len() <= ITEMS);
|
||||
assert!(page.items.windows(2).all(|w| w[0].published >= w[1].published));
|
||||
assert!(page.failing.is_none());
|
||||
let json = serde_json::to_string(&page).unwrap();
|
||||
assert!(!json.contains(".mp3"), "an item's file must not be in its page: {json}");
|
||||
|
||||
s.save(&due[0].id, Refreshed::Failed("HTTP 404 Not Found".into())).await.unwrap();
|
||||
let page = s.feed(&due[0].id).await.unwrap().unwrap();
|
||||
assert_eq!(page.failing.as_deref(), Some("Its last check failed."));
|
||||
assert_eq!(page.listing.title, title, "a failure keeps what was found before");
|
||||
}
|
||||
}
|
||||
99
crates/directory/src/wire.rs
Normal file
99
crates/directory/src/wire.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! What ipx servers and the directory send each other: listings, feed pages, snapshots and their results.
|
||||
|
||||
use crate::config::Access;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A feed as the directory lists it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Listing {
|
||||
/// The first 64 bits of the SHA-256 of its normalised address (`store::feed_id`).
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub image: Option<String>,
|
||||
/// Apple's category and the subcategory within it, from the feed's own iTunes category.
|
||||
pub category: Option<String>,
|
||||
pub subcategory: Option<String>,
|
||||
/// Any audio or video in its latest items.
|
||||
pub podcast: bool,
|
||||
/// Summed over every instance's latest snapshot.
|
||||
pub subscribers: i64,
|
||||
}
|
||||
|
||||
/// One feed's page: what it says it is, and its latest items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeedPage {
|
||||
#[serde(flatten)]
|
||||
pub listing: Listing,
|
||||
/// The feed's own text, HTML as often as not, as it was sent: whoever shows it sanitizes it.
|
||||
pub description: Option<String>,
|
||||
/// Why its last check failed, in words, when it did.
|
||||
pub failing: Option<String>,
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// One of a feed's latest items: what it is, never its files.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
pub guid: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
/// Unix seconds.
|
||||
pub published: Option<i64>,
|
||||
/// Seconds.
|
||||
pub duration: Option<i64>,
|
||||
/// As the feed sent it, unsanitized.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// One feed in a snapshot: its address, and how many people on the sending server subscribe.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SnapshotEntry {
|
||||
pub url: String,
|
||||
pub subscribers: i64,
|
||||
/// The category the sending server's admin gave a feed that names none itself: used only
|
||||
/// while the feed names none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// What became of a snapshot's entries.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SnapshotResult {
|
||||
pub accepted: usize,
|
||||
/// Addresses that look like they carry a subscriber's own access.
|
||||
pub refused_private: usize,
|
||||
/// Feeds an admin removed from the directory.
|
||||
pub refused_blocked: usize,
|
||||
/// Not an http(s) address.
|
||||
pub refused_invalid: usize,
|
||||
}
|
||||
|
||||
/// Who a token belongs to and what it may do: `/v1/me`'s answer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Me {
|
||||
pub name: String,
|
||||
pub access: Access,
|
||||
}
|
||||
|
||||
/// A token whose snapshot the directory holds: how many feeds it gave, and how many people.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub name: String,
|
||||
pub feeds: i64,
|
||||
pub subscribers: i64,
|
||||
}
|
||||
|
||||
/// A feed an admin removed from the directory, which snapshots may not bring back.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Blocked {
|
||||
pub id: String,
|
||||
pub url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
/// When it was blocked, Unix seconds.
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
/// The most a snapshot may list: far more than one server's subscriptions, few enough that one
|
||||
/// request cannot hold the database for long.
|
||||
pub const MAX_SNAPSHOT: usize = 20_000;
|
||||
27
crates/feed/Cargo.toml
Normal file
27
crates/feed/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "ipx-feed"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
atom_syndication.workspace = true
|
||||
chrono.workspace = true
|
||||
http.workspace = true
|
||||
hyper-util.workspace = true
|
||||
opml.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
rss.workspace = true
|
||||
serde_json.workspace = true
|
||||
tower.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
117
crates/feed/src/artwork.rs
Normal file
117
crates/feed/src/artwork.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Artwork: on https when its host serves it there, and a site icon where a feed has none.
|
||||
|
||||
use crate::{discover, fetch};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Artwork for a feed that has none: the icon its site's page names, or else the site's
|
||||
/// `/favicon.ico`. None if neither is there.
|
||||
#[tracing::instrument(skip_all, fields(site = site))]
|
||||
pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
||||
let timeout = Duration::from_secs(20);
|
||||
let resp = client.get(site).timeout(timeout).send().await.ok()?;
|
||||
// Relative to where the page ended up, not where it was asked for: a site that redirects
|
||||
// to /en/ would otherwise have its icon looked for in the wrong place.
|
||||
let base = resp.url().clone();
|
||||
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
|
||||
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
|
||||
if resp.status().is_success()
|
||||
&& let Ok(page) = fetch::read_capped(resp).await
|
||||
&& let Some(href) = page_icon(&page)
|
||||
&& let Ok(url) = base.join(&href)
|
||||
&& is_image(client, url.as_str()).await
|
||||
{
|
||||
return Some(url.into());
|
||||
}
|
||||
let ico = base.join("/favicon.ico").ok()?;
|
||||
is_image(client, ico.as_str()).await.then(|| ico.into())
|
||||
}
|
||||
|
||||
/// How many hosts one scan of a feed asks whether its artwork is on https.
|
||||
pub(crate) const MAX_ART_HOSTS: usize = 8;
|
||||
|
||||
/// Artwork's address on https when its host serves it there, else as it was. The page is https
|
||||
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
|
||||
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
|
||||
/// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN
|
||||
/// presents another name's certificate (#110).
|
||||
pub async fn prefer_https(client: &reqwest::Client, url: &str, known: &mut HashMap<String, bool>) -> String {
|
||||
let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() };
|
||||
let host = rest.split('/').next().unwrap_or("").to_owned();
|
||||
let secure = format!("https://{rest}");
|
||||
let ok = match known.get(&host) {
|
||||
Some(ok) => *ok,
|
||||
// Each new host is a probe of up to 20 s, one after another on the scan, and the feed's
|
||||
// author decides how many hosts there are: ports and user names make as many as they like
|
||||
// of one server (#339). Real feeds put their artwork on a few.
|
||||
None if known.len() >= MAX_ART_HOSTS => false,
|
||||
None => {
|
||||
let ok = is_image(client, &secure).await;
|
||||
known.insert(host, ok);
|
||||
ok
|
||||
}
|
||||
};
|
||||
if ok { secure } else { url.to_owned() }
|
||||
}
|
||||
|
||||
/// Whether `url` answers with an image. A site with no favicon often answers 200 with its home
|
||||
/// page, which is not an icon.
|
||||
pub async fn is_image(client: &reqwest::Client, url: &str) -> bool {
|
||||
let timeout = Duration::from_secs(20);
|
||||
let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false };
|
||||
resp.status().is_success()
|
||||
&& resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|t| t.starts_with("image/"))
|
||||
}
|
||||
|
||||
/// The icon a web page names in its `<link>` tags, the larger apple-touch-icon first: a plain
|
||||
/// `icon` is often 16 pixels, which blurs at the size the list draws artwork.
|
||||
pub(crate) fn page_icon(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
// ASCII only, so byte offsets in the lowered copy stay valid in the original.
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let (mut touch, mut icon) = (None, None);
|
||||
let mut pos = 0;
|
||||
while let Some(at) = lower[pos..].find("<link") {
|
||||
let start = pos + at;
|
||||
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
|
||||
pos = end + 1;
|
||||
let tag = &text[start..end];
|
||||
let Some(rel) = discover::tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
|
||||
let rels: Vec<&str> = rel.split_whitespace().collect();
|
||||
if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) {
|
||||
touch = discover::tag_attr(tag, "href");
|
||||
} else if icon.is_none() && rels.contains(&"icon") {
|
||||
icon = discover::tag_attr(tag, "href");
|
||||
}
|
||||
}
|
||||
touch.or(icon).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn artwork_on_more_hosts_than_the_limit_is_not_asked_about() {
|
||||
let mut known: std::collections::HashMap<String, bool> =
|
||||
(0..MAX_ART_HOSTS).map(|n| (format!("h{n}:80"), false)).collect();
|
||||
// No server answers here: were it asked, this would wait on a connection, not return.
|
||||
let url = "http://192.0.2.1:9/a.png";
|
||||
assert_eq!(prefer_https(&reqwest::Client::new(), url, &mut known).await, url);
|
||||
assert_eq!(known.len(), MAX_ART_HOSTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_icon_prefers_the_touch_icon() {
|
||||
let page = br#"<head><link rel="stylesheet" href="/a.css">
|
||||
<link rel="shortcut icon" href="/fav.ico">
|
||||
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
|
||||
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
|
||||
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
|
||||
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
|
||||
}
|
||||
}
|
||||
125
crates/feed/src/category.rs
Normal file
125
crates/feed/src/category.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! Apple Podcasts' categories, and where a feed's category sits among them (#118).
|
||||
|
||||
/// Apple's podcast categories, each with its subcategories
|
||||
/// (https://podcasters.apple.com/support/1691-apple-podcasts-categories): the list a podcast's own
|
||||
/// `<itunes:category>` comes from, and a fixed one, so the Directory's categories stay few.
|
||||
pub(crate) const CATEGORIES: &[(&str, &[&str])] = &[
|
||||
("Arts", &["Books", "Design", "Fashion & Beauty", "Food", "Performing Arts", "Visual Arts"]),
|
||||
("Business", &["Careers", "Entrepreneurship", "Investing", "Management", "Marketing", "Non-Profit"]),
|
||||
("Comedy", &["Comedy Interviews", "Improv", "Stand-Up"]),
|
||||
("Education", &["Courses", "How To", "Language Learning", "Self-Improvement"]),
|
||||
("Fiction", &["Comedy Fiction", "Drama", "Science Fiction"]),
|
||||
("Government", &[]),
|
||||
("History", &[]),
|
||||
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
|
||||
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
|
||||
(
|
||||
"Leisure",
|
||||
&["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"],
|
||||
),
|
||||
("Music", &["Music Commentary", "Music History", "Music Interviews"]),
|
||||
(
|
||||
"News",
|
||||
&[
|
||||
"Business News",
|
||||
"Daily News",
|
||||
"Entertainment News",
|
||||
"News Commentary",
|
||||
"Politics",
|
||||
"Sports News",
|
||||
"Tech News",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Religion & Spirituality",
|
||||
&["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"],
|
||||
),
|
||||
(
|
||||
"Science",
|
||||
&[
|
||||
"Astronomy",
|
||||
"Chemistry",
|
||||
"Earth Sciences",
|
||||
"Life Sciences",
|
||||
"Mathematics",
|
||||
"Natural Sciences",
|
||||
"Nature",
|
||||
"Physics",
|
||||
"Social Sciences",
|
||||
],
|
||||
),
|
||||
("Society & Culture", &["Documentary", "Personal Journals", "Philosophy", "Places & Travel", "Relationships"]),
|
||||
(
|
||||
"Sports",
|
||||
&[
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Cricket",
|
||||
"Fantasy Sports",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Hockey",
|
||||
"Rugby",
|
||||
"Running",
|
||||
"Soccer",
|
||||
"Swimming",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wilderness",
|
||||
"Wrestling",
|
||||
],
|
||||
),
|
||||
("Technology", &[]),
|
||||
("True Crime", &[]),
|
||||
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
||||
];
|
||||
|
||||
/// Names that are not in Apple's list but mean one that is (#125). Jev picked from this list when
|
||||
/// it spelt Football and Soccer its own way, and stored those answers; Software How-To is Apple's
|
||||
/// Technology subcategory from before its 2019 list, which older feeds still name.
|
||||
pub(crate) const ALIASES: &[(&str, &str)] =
|
||||
&[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")];
|
||||
|
||||
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
||||
#[must_use]
|
||||
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
||||
CATEGORIES
|
||||
}
|
||||
|
||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||
#[must_use]
|
||||
pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
|
||||
let name = name.trim().trim_end_matches([',', ';', '.']).trim_end();
|
||||
let name = ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name)).map_or(name, |(_, now)| now);
|
||||
for (category, subs) in CATEGORIES {
|
||||
if category.eq_ignore_ascii_case(name) {
|
||||
return (category.to_string(), None);
|
||||
}
|
||||
if let Some(sub) = subs.iter().find(|s| s.eq_ignore_ascii_case(name)) {
|
||||
return (category.to_string(), Some(sub.to_string()));
|
||||
}
|
||||
}
|
||||
(name.to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn category_path_finds_a_subcategory_s_category() {
|
||||
use super::category_path;
|
||||
assert_eq!(category_path("Tech News"), ("News".into(), Some("Tech News".into())));
|
||||
assert_eq!(category_path("video games"), ("Leisure".into(), Some("Video Games".into())), "Apple's spelling");
|
||||
assert_eq!(category_path("Technology"), ("Technology".into(), None));
|
||||
assert_eq!(category_path("Homebrew"), ("Homebrew".into(), None), "not Apple's: its own category");
|
||||
// Apple's spelling, an old answer of Jev's, a stray comma and Apple's old list (#125).
|
||||
assert_eq!(category_path("Soccer"), ("Sports".into(), Some("Soccer".into())));
|
||||
assert_eq!(category_path("Football (Soccer)"), ("Sports".into(), Some("Soccer".into())));
|
||||
assert_eq!(category_path("Soccer,"), ("Sports".into(), Some("Soccer".into())));
|
||||
assert_eq!(category_path("Football"), ("Sports".into(), Some("Football".into())));
|
||||
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
|
||||
}
|
||||
}
|
||||
107
crates/feed/src/discover.rs
Normal file
107
crates/feed/src/discover.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Finding a feed from what someone typed: a feed address, a site, or a page linking one.
|
||||
|
||||
use crate::{fetch, outlines, parse};
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
|
||||
/// feed, since the token alone says whose it is. An address with no scheme is https: 'cnn.com'
|
||||
/// was stored as typed and every check failed with "relative URL without a base" (#101).
|
||||
#[must_use]
|
||||
pub fn expand_input(input: &str) -> String {
|
||||
let s = input.trim();
|
||||
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||
if token {
|
||||
format!("https://www.patreon.com/rss?auth={s}")
|
||||
} else if let Some(rest) = s.strip_prefix("//") {
|
||||
format!("https://{rest}")
|
||||
} else if !s.contains("://") {
|
||||
format!("https://{s}")
|
||||
} else {
|
||||
s.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The feed a web page names as its own via `<link rel="alternate" type="application/rss+xml"
|
||||
/// href="...">` (or the Atom equivalent) -- how the new address was found for om.co, ms.now,
|
||||
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
||||
pub(crate) fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
// ASCII only: to_lowercase changes some characters' length (U+0130 grows a byte), and the
|
||||
// offsets found in the lowered copy then sliced the original off a char boundary.
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(rel) = lower[pos..].find("<link") {
|
||||
let start = pos + rel;
|
||||
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
|
||||
pos = end + 1;
|
||||
let tag = &text[start..end];
|
||||
let tag_lower = &lower[start..end];
|
||||
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
|
||||
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
|
||||
if is_alternate
|
||||
&& is_feed_type
|
||||
&& let Some(href) = tag_attr(tag, "href")
|
||||
{
|
||||
return Some(href);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// What someone asked to add, as a feed: the address itself when it is a feed or an OPML list,
|
||||
/// otherwise the feed its web page links as its own, otherwise an error and nothing is added.
|
||||
/// A page that linked no feed used to be added as it was and failed on every check, called a
|
||||
/// feed that moved (#102); cnn.com is one.
|
||||
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
||||
let read = |u: String| async move {
|
||||
let resp = client.get(&u).timeout(Duration::from_secs(30)).send().await.context("connecting")?;
|
||||
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
|
||||
let base = resp.url().clone();
|
||||
anyhow::Ok((base, fetch::read_capped(resp).await.context("reading")?))
|
||||
};
|
||||
let is_feed = |b: &[u8]| outlines::is_opml(b) || parse::parse(b).is_ok();
|
||||
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
|
||||
if is_feed(&body) {
|
||||
return Ok(url.to_owned());
|
||||
}
|
||||
if !parse::looks_like_html(&body) {
|
||||
let why = parse::parse(&body).err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||
anyhow::bail!("{url} is not a feed: {why}");
|
||||
}
|
||||
let Some(href) = alternate_feed_link(&body) else {
|
||||
anyhow::bail!("{url} is a web page that links no feed, so there is nothing to subscribe to");
|
||||
};
|
||||
let linked =
|
||||
base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
|
||||
let (_, feed) = read(linked.to_string()).await.with_context(|| format!("{url} links {linked} as its feed, but"))?;
|
||||
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
|
||||
Ok(linked.into())
|
||||
}
|
||||
|
||||
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
||||
pub(crate) fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||
let key = format!("{name}=");
|
||||
let idx = tag.to_lowercase().find(&key)?;
|
||||
let after = &tag[idx + key.len()..];
|
||||
let quote = after.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
let rest = &after[1..];
|
||||
let close = rest.find(quote)?;
|
||||
Some(rest[..close].trim().to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feed_link_after_non_ascii_text_is_found() {
|
||||
// U+0130 lowercases to three bytes from two, which once shifted every offset after it.
|
||||
let page = "<html><title>\u{130}stanbul \u{130}\u{130}</title>\
|
||||
<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/feed.xml\">";
|
||||
assert_eq!(alternate_feed_link(page.as_bytes()).as_deref(), Some("/feed.xml"));
|
||||
}
|
||||
}
|
||||
145
crates/feed/src/failure.rs
Normal file
145
crates/feed/src/failure.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! A failed read, in words for whoever subscribes, and as a kind for the log.
|
||||
|
||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||
/// it is, and whether there is a new address to switch to.
|
||||
pub struct Failure {
|
||||
/// What to tell them, in a sentence.
|
||||
pub reason: &'static str,
|
||||
/// Where the feed went, when the error says.
|
||||
pub new_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as
|
||||
/// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the
|
||||
/// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind.
|
||||
#[must_use]
|
||||
pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
||||
let low = msg.to_ascii_lowercase();
|
||||
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().ok());
|
||||
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
|
||||
return (c.to_string(), Some(c));
|
||||
}
|
||||
let kind = if low.contains("dns error")
|
||||
|| low.contains("failed to lookup address")
|
||||
|| low.contains("no address associated")
|
||||
{
|
||||
"dns"
|
||||
} else if low.contains("too many redirects") {
|
||||
"redirect_loop"
|
||||
} else if low.contains("timed out") || low.contains("timeout") {
|
||||
"timeout"
|
||||
} else if low.contains("certificate") || low.contains("tls") {
|
||||
"tls"
|
||||
} else if low.contains("got a web page") {
|
||||
"not_a_feed"
|
||||
} else if low.contains("the site sent ") {
|
||||
"site_message"
|
||||
} else if low.contains("connect") {
|
||||
"connect"
|
||||
} else if low.contains("pars") {
|
||||
"parse"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
(kind.into(), None)
|
||||
}
|
||||
|
||||
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
||||
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
||||
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
||||
/// comes back `None`: transient by nature, or with nothing more useful to say than the raw
|
||||
/// text already shown once a feed is open.
|
||||
///
|
||||
/// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP
|
||||
/// {status}")`, and `parse`'s "got a web page" and "the site sent") plus the substrings a DNS failure
|
||||
/// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing
|
||||
/// nothing extra, so a miss costs a clearer message, not a wrong one.
|
||||
pub fn explain_failure(msg: &str) -> Option<Failure> {
|
||||
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
|
||||
let new_url = rest.strip_prefix("; it links ").and_then(|r| r.strip_suffix(" as its feed")).map(str::to_owned);
|
||||
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
|
||||
}
|
||||
if msg.contains("the site sent ") {
|
||||
return Some(Failure {
|
||||
reason: "The site sent a message instead of the feed; the publisher has to fix it.",
|
||||
new_url: None,
|
||||
});
|
||||
}
|
||||
let low = msg.to_ascii_lowercase();
|
||||
if low.contains("http 404") {
|
||||
return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None });
|
||||
}
|
||||
if low.contains("http 401") || low.contains("http 403") {
|
||||
return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None });
|
||||
}
|
||||
if low.contains("answered with nothing") {
|
||||
return Some(Failure { reason: "This address answers with nothing; the site may be gone.", new_url: None });
|
||||
}
|
||||
if low.contains("http 402") {
|
||||
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
|
||||
}
|
||||
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
|
||||
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|
||||
if !is_temporary_lookup_failure(msg)
|
||||
&& (low.contains("dns error")
|
||||
|| low.contains("failed to lookup address")
|
||||
|| low.contains("no address associated"))
|
||||
{
|
||||
return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A host that could not be looked up for the moment -- the resolver's EAI_AGAIN, glibc's
|
||||
/// "Temporary failure in name resolution", musl's "Try again" -- as against a name that does
|
||||
/// not exist ("Name or service not known"). It says the network was not answering, not that the
|
||||
/// feed is gone (#143).
|
||||
#[must_use]
|
||||
pub fn is_temporary_lookup_failure(msg: &str) -> bool {
|
||||
let low = msg.to_ascii_lowercase();
|
||||
low.contains("dns error") && (low.contains("temporary failure in name resolution") || low.contains("try again"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
||||
assert_eq!(
|
||||
explain_failure("HTTP 404 Not Found").unwrap().reason,
|
||||
"The publisher took this feed down, or moved it."
|
||||
);
|
||||
assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests.");
|
||||
assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests.");
|
||||
assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan.");
|
||||
assert_eq!(
|
||||
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
||||
"This address answers with nothing; the site may be gone."
|
||||
);
|
||||
let dns =
|
||||
explain_failure("connecting: dns error: failed to lookup address information: Name or service not known")
|
||||
.unwrap();
|
||||
assert_eq!(dns.reason, "This address no longer resolves; the site may be gone.");
|
||||
// A lookup that failed for the moment is not a site gone (#143).
|
||||
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||
assert!(is_temporary_lookup_failure(blip));
|
||||
assert!(explain_failure(blip).is_none());
|
||||
assert!(!is_temporary_lookup_failure(
|
||||
"connecting: dns error: failed to lookup address information: Name or service not known"
|
||||
));
|
||||
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
||||
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
||||
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
||||
let down = explain_failure("the site sent \"Unable to establish a DB connection\" instead of a feed").unwrap();
|
||||
assert_eq!(down.reason, "The site sent a message instead of the feed; the publisher has to fix it.");
|
||||
for transient in [
|
||||
"HTTP 500 Internal Server Error",
|
||||
"HTTP 429 Too Many Requests",
|
||||
"operation timed out",
|
||||
"not RSS (reached end of input without finding a complete channel) and not Atom (unexpected end of input)",
|
||||
] {
|
||||
assert!(explain_failure(transient).is_none(), "{transient} must not be flagged");
|
||||
}
|
||||
}
|
||||
}
|
||||
189
crates/feed/src/fetch.rs
Normal file
189
crates/feed/src/fetch.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! Reading a feed over HTTP: conditional requests, redirects followed one by one, bounded bodies.
|
||||
|
||||
use crate::{privacy, wordpress};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::HeaderName;
|
||||
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What `fetch` got.
|
||||
pub enum Fetched {
|
||||
/// Server said 304, or returned a body we already have.
|
||||
NotModified,
|
||||
/// A body to read, with the validators to send next time.
|
||||
Body { bytes: Vec<u8>, etag: Option<String>, last_modified: Option<String> },
|
||||
}
|
||||
|
||||
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
||||
/// with no limit one hung server held every scan for as long as it did. Dreamwidth answered 504
|
||||
/// after 60-67 s for a day, and each scan took 70-80 s instead of 15 (#108). A feed is small;
|
||||
/// downloads, which are not, have no such limit.
|
||||
pub const FEED_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The longest a feed's server may send nothing, for the clients that fetch feeds to set: one
|
||||
/// that took the connection and never answered ran the whole 30 s, every scan it was in (#230).
|
||||
/// No feed that answered took over 8 s in a day of traces; one that keeps sending, however
|
||||
/// slowly, still has until FEED_TIMEOUT.
|
||||
pub const FEED_SILENCE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 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: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// See `FETCHES`.
|
||||
pub static FETCHED_BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The most of a feed or page read. The largest feeds here are a few MB; without a limit an
|
||||
/// episode's MP3 given as a feed was read whole into memory, at every check (#295).
|
||||
pub(crate) const MAX_BODY: usize = 50 << 20;
|
||||
|
||||
/// A response's body, up to `MAX_BODY`: past it, an error, and the rest is never read.
|
||||
pub(crate) async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
|
||||
if resp.content_length().is_some_and(|n| n > MAX_BODY as u64) {
|
||||
return Err(anyhow!("larger than {} MB, too large to be a feed", MAX_BODY >> 20));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
while let Some(c) = resp.chunk().await? {
|
||||
out.extend_from_slice(&c);
|
||||
anyhow::ensure!(out.len() <= MAX_BODY, "larger than {} MB, too large to be a feed", MAX_BODY >> 20);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reads a feed, asking only for what changed since `etag` and `last_modified`. Redirects are
|
||||
/// followed here, up to ten, so `auth` goes to the feed's own host alone. Also returns the new
|
||||
/// address when every hop said the feed moved for good, for the caller to keep instead.
|
||||
#[tracing::instrument(skip_all, fields(url = %privacy::redact(feed_url), http.response.body.size))]
|
||||
pub async fn fetch(
|
||||
client: &reqwest::Client,
|
||||
feed_url: &str,
|
||||
auth: Option<(&str, Option<String>)>,
|
||||
etag: Option<&str>,
|
||||
last_modified: Option<&str>,
|
||||
) -> Result<(Fetched, Option<String>)> {
|
||||
let start = reqwest::Url::parse(feed_url).context("the feed's address")?;
|
||||
let mut url = start.clone();
|
||||
let mut permanent = true;
|
||||
for _ in 0..10 {
|
||||
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
|
||||
if let Some(tag) = etag {
|
||||
req = req.header(IF_NONE_MATCH, tag);
|
||||
}
|
||||
if let Some(lm) = last_modified {
|
||||
req = req.header(IF_MODIFIED_SINCE, lm);
|
||||
}
|
||||
// The feed's own host only: a redirect elsewhere must not be handed the password.
|
||||
if let Some((user, password)) = &auth
|
||||
&& url.host_str() == start.host_str()
|
||||
{
|
||||
req = req.basic_auth(user, password.as_deref());
|
||||
}
|
||||
|
||||
let resp = req.send().await.context("connecting")?;
|
||||
let status = resp.status();
|
||||
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
|
||||
let to = resp
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| anyhow!("HTTP {status} without a Location to go to"))?;
|
||||
url = url.join(to).with_context(|| format!("redirected to {to:?}, which is not an address"))?;
|
||||
permanent &= matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT);
|
||||
continue;
|
||||
}
|
||||
let moved = (permanent && url != start).then(|| url.to_string());
|
||||
FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if status == StatusCode::NOT_MODIFIED {
|
||||
return Ok((Fetched::NotModified, moved));
|
||||
}
|
||||
if !status.is_success() {
|
||||
// WordPress.com answers some readers' feed requests with a browser check that only
|
||||
// JavaScript passes, for hours at a time and whatever the User-Agent; its API has the
|
||||
// same posts and no check (#317).
|
||||
if status == StatusCode::FORBIDDEN
|
||||
&& let Some(host) = url.host_str().map(str::to_owned)
|
||||
&& read_capped(resp).await.is_ok_and(|b| wordpress::is_wpcom_challenge(&b))
|
||||
{
|
||||
return Ok((wordpress::wpcom_posts(client, &host).await?, moved));
|
||||
}
|
||||
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
||||
return Err(anyhow!("HTTP {status}"));
|
||||
}
|
||||
let header = |h: HeaderName| resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
let etag = header(ETAG);
|
||||
let last_modified = header(LAST_MODIFIED);
|
||||
let bytes = read_capped(resp).await.context("reading body")?;
|
||||
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));
|
||||
}
|
||||
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
||||
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::failure;
|
||||
|
||||
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
|
||||
/// to /tmp, /new is the feed.
|
||||
async fn redirecting_server() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
||||
let body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
|
||||
let resp = match path.as_str() {
|
||||
"/old" => {
|
||||
"HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
"/tmp" => "HTTP/1.1 302 Found\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||
"/chain" => {
|
||||
"HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
// An episode's file given as a feed: its size said, its body never sent.
|
||||
"/huge" => "HTTP/1.1 200 OK\r\nContent-Length: 600000000\r\n\r\n".to_owned(),
|
||||
"/loop" => {
|
||||
"HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
|
||||
};
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
|
||||
let base = redirecting_server().await;
|
||||
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
|
||||
let get = |path: &str| {
|
||||
let url = format!("{base}{path}");
|
||||
let client = client.clone();
|
||||
async move { super::fetch(&client, &url, None, None, None).await }
|
||||
};
|
||||
let (got, moved) = get("/old").await.unwrap();
|
||||
assert!(matches!(got, super::Fetched::Body { .. }));
|
||||
assert_eq!(moved, Some(format!("{base}/new")));
|
||||
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
|
||||
let huge = get("/huge").await.err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||
assert!(huge.contains("too large to be a feed"), "{huge}");
|
||||
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
|
||||
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
||||
let looped = get("/loop").await.err().unwrap().to_string();
|
||||
assert_eq!(failure::failure_kind(&looped).0, "redirect_loop");
|
||||
}
|
||||
}
|
||||
277
crates/feed/src/guard.rs
Normal file
277
crates/feed/src/guard.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! Keeps fetches off internal addresses (#320). A feed, an episode, its artwork and a .torrent are
|
||||
//! all at addresses a publisher or any signed-in person names, and redirects go anywhere, so
|
||||
//! without this the server fetched from its own loopback, its Docker network and the LAN, and
|
||||
//! handed back what answered.
|
||||
//!
|
||||
//! The check is on the connection, after the name is resolved and before a byte of the request
|
||||
//! is sent, so it covers every request, every redirect, an address written as an IP, and a name
|
||||
//! that resolves to an internal address only on the second lookup.
|
||||
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use http::Extensions;
|
||||
use hyper_util::client::legacy::connect::{Connection, HttpInfo};
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
/// Internal addresses and networks a fetch may reach all the same: config.toml's `fetch_internal`,
|
||||
/// as `192.168.1.20` or `192.168.1.0/24`. Names are not taken: the check sees only the address.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
|
||||
|
||||
impl Allowed {
|
||||
/// Reads `fetch_internal`'s list, refusing an entry that is neither an address nor a network.
|
||||
pub fn parse(list: &[String]) -> Result<Self> {
|
||||
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
|
||||
}
|
||||
|
||||
/// Whether a connection to `ip` may go ahead: any public address, and the internal ones listed.
|
||||
#[must_use]
|
||||
pub fn permits(&self, ip: IpAddr) -> bool {
|
||||
let ip = canonical(ip);
|
||||
!is_internal(ip) || self.0.iter().any(|&(net, bits)| contains(net, bits, ip))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_net(s: &str) -> Result<(IpAddr, u8)> {
|
||||
let bad = || format!("fetch_internal: {s:?} is not an address or a network such as 192.168.1.0/24");
|
||||
let (addr, bits) = s.trim().split_once('/').map_or((s.trim(), None), |(a, b)| (a, Some(b)));
|
||||
let ip = canonical(addr.parse::<IpAddr>().with_context(bad)?);
|
||||
let max = if ip.is_ipv4() { 32 } else { 128 };
|
||||
let bits = match bits {
|
||||
Some(b) => b.parse::<u8>().ok().filter(|n| *n <= max).with_context(bad)?,
|
||||
None => max,
|
||||
};
|
||||
Ok((ip, bits))
|
||||
}
|
||||
|
||||
/// An IPv4 address written as IPv6 (`::ffff:127.0.0.1`) is the IPv4 one.
|
||||
fn canonical(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
|
||||
v4 => v4,
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(net: IpAddr, bits: u8, ip: IpAddr) -> bool {
|
||||
match (net, ip) {
|
||||
(IpAddr::V4(n), IpAddr::V4(i)) => {
|
||||
let mask = u32::MAX.checked_shl(32 - u32::from(bits)).unwrap_or(0);
|
||||
u32::from(n) & mask == u32::from(i) & mask
|
||||
}
|
||||
(IpAddr::V6(n), IpAddr::V6(i)) => {
|
||||
let mask = u128::MAX.checked_shl(128 - u32::from(bits)).unwrap_or(0);
|
||||
u128::from(n) & mask == u128::from(i) & mask
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Not on the public internet: loopback, private, link-local, shared (CGNAT), multicast and
|
||||
/// reserved ranges, and their IPv6 counterparts. `IpAddr::is_global` would say this, but is
|
||||
/// unstable.
|
||||
// shortcut: 6to4 (2002::/16) and Teredo addresses that wrap an internal IPv4 one pass; check the
|
||||
// embedded address if an IPv6 route to them ever exists here.
|
||||
#[must_use]
|
||||
pub fn is_internal(ip: IpAddr) -> bool {
|
||||
match canonical(ip) {
|
||||
IpAddr::V4(a) => {
|
||||
let [o0, o1, o2, _] = a.octets();
|
||||
a.is_private()
|
||||
|| a.is_loopback()
|
||||
|| a.is_link_local()
|
||||
|| a.is_unspecified()
|
||||
|| a.is_broadcast()
|
||||
|| a.is_multicast()
|
||||
|| o0 == 0
|
||||
|| o0 >= 240
|
||||
|| (o0 == 100 && o1 & 0xc0 == 64)
|
||||
|| (o0 == 192 && o1 == 0 && o2 == 0)
|
||||
}
|
||||
IpAddr::V6(a) => {
|
||||
let s = a.segments();
|
||||
a.is_loopback()
|
||||
|| a.is_unspecified()
|
||||
|| a.is_multicast()
|
||||
|| s[0] & 0xfe00 == 0xfc00
|
||||
|| s[0] & 0xffc0 == 0xfe80
|
||||
// NAT64 carries an IPv4 address in its last 32 bits.
|
||||
|| (s[0] == 0x64 && s[1] == 0xff9b && is_internal(IpAddr::V4(Ipv4Addr::from(u128::from(a) as u32))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector layer for `reqwest::ClientBuilder::connector_layer`: a connection to an address
|
||||
/// `Allowed` does not permit fails before the request is sent.
|
||||
#[derive(Clone)]
|
||||
pub struct Guard(Allowed);
|
||||
|
||||
impl Guard {
|
||||
/// A guard letting through what `allowed` lists, besides the public internet.
|
||||
#[must_use]
|
||||
pub fn new(allowed: Allowed) -> Self {
|
||||
Self(allowed)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> tower::Layer<S> for Guard {
|
||||
type Service = Guarded<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Guarded<S> {
|
||||
Guarded { inner, allowed: self.0.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The connector `Guard` wraps, checking each connection it makes.
|
||||
#[derive(Clone)]
|
||||
pub struct Guarded<S> {
|
||||
inner: S,
|
||||
allowed: Allowed,
|
||||
}
|
||||
|
||||
impl<S, R> tower::Service<R> for Guarded<S>
|
||||
where
|
||||
S: tower::Service<R>,
|
||||
S::Response: Connection,
|
||||
S::Error: Into<BoxError>,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = BoxError;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<S::Response, BoxError>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
|
||||
self.inner.poll_ready(cx).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: R) -> Self::Future {
|
||||
let connecting = self.inner.call(req);
|
||||
let allowed = self.allowed.clone();
|
||||
Box::pin(async move {
|
||||
let conn = connecting.await.map_err(Into::into)?;
|
||||
let mut extras = Extensions::new();
|
||||
conn.connected().get_extras(&mut extras);
|
||||
// Refused when the peer is unknown too: a connection this cannot check is one it
|
||||
// cannot vouch for.
|
||||
match extras.get::<HttpInfo>().map(|i| i.remote_addr().ip()) {
|
||||
Some(ip) if allowed.permits(ip) => Ok(conn),
|
||||
Some(ip) => {
|
||||
Err(format!("{ip} is an internal address, not fetched (fetch_internal in config.toml)").into())
|
||||
}
|
||||
None => Err("the connection's address is unknown, so it is not fetched".into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_addresses_are_known() {
|
||||
for a in [
|
||||
"127.0.0.1",
|
||||
"10.1.2.3",
|
||||
"172.16.0.1",
|
||||
"192.168.1.130",
|
||||
"169.254.169.254",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
"::1",
|
||||
"::",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"64:ff9b::a00:1",
|
||||
] {
|
||||
assert!(is_internal(ip(a)), "{a}");
|
||||
}
|
||||
for a in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111", "64:ff9b::808:808", "100.128.0.1"] {
|
||||
assert!(!is_internal(ip(a)), "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_allowlist_lets_through_what_it_names_and_no_more() {
|
||||
let a = Allowed::parse(&["192.168.1.0/24".into(), "127.0.0.1".into(), "fd00::/8".into()]).unwrap();
|
||||
assert!(a.permits(ip("192.168.1.20")));
|
||||
assert!(a.permits(ip("127.0.0.1")));
|
||||
assert!(a.permits(ip("::ffff:127.0.0.1")), "the same address written as IPv6");
|
||||
assert!(a.permits(ip("fd12::1")));
|
||||
assert!(!a.permits(ip("192.168.2.1")));
|
||||
assert!(!a.permits(ip("127.0.0.2")));
|
||||
assert!(a.permits(ip("1.1.1.1")), "public addresses need no listing");
|
||||
assert!(Allowed::default().permits(ip("1.1.1.1")));
|
||||
assert!(!Allowed::default().permits(ip("10.0.0.1")));
|
||||
for bad in ["nas.lan", "10.0.0.0/33", "10.0.0.0/x", ""] {
|
||||
assert!(Allowed::parse(&[bad.into()]).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn answers() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let _ = sock.read(&mut [0u8; 1024]).await;
|
||||
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nsecret").await;
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/")
|
||||
}
|
||||
|
||||
fn client(allowed: &[&str]) -> reqwest::Client {
|
||||
let allowed = Allowed::parse(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
|
||||
reqwest::Client::builder().connector_layer(Guard::new(allowed)).build().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetch_from_loopback_is_refused_unless_listed() {
|
||||
let url = answers().await;
|
||||
let err = client(&[]).get(&url).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
|
||||
let body = client(&["127.0.0.1"]).get(&url).send().await.unwrap().text().await.unwrap();
|
||||
assert_eq!(body, "secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_redirect_to_loopback_is_refused() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let inner = answers().await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let _ = sock.read(&mut [0u8; 1024]).await;
|
||||
let r = format!("HTTP/1.1 302 Found\r\nLocation: {inner}\r\nContent-Length: 0\r\n\r\n");
|
||||
let _ = sock.write_all(r.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
// 127.0.0.2 stands for a public address: listed, it is let through, and the redirect it
|
||||
// sends to 127.0.0.1 is not.
|
||||
let err = client(&["127.0.0.2"]).get(format!("http://{addr}/")).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("127.0.0.1 is an internal address"), "{err:?}");
|
||||
}
|
||||
|
||||
/// Run by hand: `cargo test -p ipx-feed guard -- --ignored`. HTTPS gives the peer's address
|
||||
/// too; were it lost under TLS, every https fetch would be refused.
|
||||
#[tokio::test]
|
||||
#[ignore = "reaches the internet"]
|
||||
async fn https_is_checked_and_let_through() {
|
||||
let r = client(&[]).get("https://example.com/").send().await.unwrap();
|
||||
assert!(r.status().is_success());
|
||||
}
|
||||
}
|
||||
67
crates/feed/src/jev.rs
Normal file
67
crates/feed/src/jev.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! Asking TypeSafe's Jev which Apple category a feed belongs to.
|
||||
|
||||
use crate::parse::ParsedFeed;
|
||||
use crate::{category, fetch};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
/// TypeSafe's System One endpoint, where Jev answers questions about a piece of state.
|
||||
pub const JEV_URL: &str = "https://api.typesafe.ai/v1/systemone";
|
||||
|
||||
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
||||
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
|
||||
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
||||
/// is taken however unsure, since an admin can change it and asking again would cost a call a scan.
|
||||
pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) -> Result<String> {
|
||||
let items: Vec<&str> = feed.entries.iter().filter_map(|e| e.title.as_deref()).take(15).collect();
|
||||
// A subcategory says which category it is in; without that, "Games" or "Drama" is ambiguous.
|
||||
let mut criteria = serde_json::Map::new();
|
||||
for (category, subs) in category::CATEGORIES {
|
||||
criteria.insert(category.to_string(), serde_json::Value::Null);
|
||||
for sub in *subs {
|
||||
criteria.insert(sub.to_string(), format!("Within {category}").into());
|
||||
}
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": { "title": feed.title, "recent_items": items },
|
||||
"questions": { "category": {
|
||||
"type": "choice",
|
||||
"instructions": "Which category best describes what this feed publishes?",
|
||||
"criteria": criteria,
|
||||
}},
|
||||
});
|
||||
let answer: serde_json::Value = client
|
||||
.post(JEV_URL)
|
||||
.bearer_auth(key)
|
||||
.json(&body)
|
||||
.timeout(fetch::FEED_TIMEOUT)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
picked(&answer)
|
||||
}
|
||||
|
||||
pub(crate) fn picked(answer: &serde_json::Value) -> Result<String> {
|
||||
let choice = answer["answers"]["category"]["choice"].as_str().unwrap_or_default();
|
||||
category::CATEGORIES
|
||||
.iter()
|
||||
.flat_map(|(c, subs)| std::iter::once(c).chain(subs.iter()))
|
||||
.find(|c| **c == choice)
|
||||
.map(|c| c.to_string())
|
||||
.ok_or_else(|| anyhow!("no category in the answer: {answer}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn picked_takes_only_an_apple_category() {
|
||||
let answer = |c: &str| serde_json::json!({"answers": {"category": {"type": "choice", "choice": c}}});
|
||||
assert_eq!(super::picked(&answer("Technology")).unwrap(), "Technology");
|
||||
assert_eq!(super::picked(&answer("Tech News")).unwrap(), "Tech News", "a subcategory");
|
||||
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
|
||||
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
|
||||
}
|
||||
}
|
||||
26
crates/feed/src/lib.rs
Normal file
26
crates/feed/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
|
||||
//! FeedData.__getFeed / __getEntries.
|
||||
|
||||
mod artwork;
|
||||
mod category;
|
||||
mod discover;
|
||||
mod failure;
|
||||
mod fetch;
|
||||
pub mod guard;
|
||||
mod jev;
|
||||
mod outlines;
|
||||
mod parse;
|
||||
mod patreon;
|
||||
mod privacy;
|
||||
mod wordpress;
|
||||
|
||||
pub use artwork::{is_image, prefer_https, site_icon};
|
||||
pub use category::{categories, category_path};
|
||||
pub use discover::{expand_input, find_feed};
|
||||
pub use failure::{Failure, explain_failure, failure_kind, is_temporary_lookup_failure};
|
||||
pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch};
|
||||
pub use jev::{JEV_URL, classify};
|
||||
pub use outlines::{collect_outlines, is_opml, opml_title, parse_opml, read_opml};
|
||||
pub use parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key};
|
||||
pub use patreon::{is_patreon_creator, patreon_shows, same_feed};
|
||||
pub use privacy::{redact, redact_text, url_looks_private};
|
||||
111
crates/feed/src/outlines.rs
Normal file
111
crates/feed/src/outlines.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! OPML documents: recognising one, reading it safely, and the feeds it lists.
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
/// True when a body is an OPML document rather than a feed.
|
||||
///
|
||||
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
|
||||
/// OPML served from a URL without that extension. Sniffing the body catches both.
|
||||
#[must_use]
|
||||
pub fn is_opml(bytes: &[u8]) -> bool {
|
||||
let head = &bytes[..bytes.len().min(1024)];
|
||||
let text = String::from_utf8_lossy(head).to_lowercase();
|
||||
text.contains("<opml")
|
||||
}
|
||||
|
||||
/// How deep an OPML document's elements may nest. Real ones are a folder or two deep.
|
||||
pub(crate) const MAX_OPML_DEPTH: usize = 100;
|
||||
|
||||
/// Parses an OPML document, refusing one nested deeper than `MAX_OPML_DEPTH`. The opml crate's
|
||||
/// reader recurses once per level with no limit, and a few MB of `<outline>` overflowed the
|
||||
/// stack, which aborts the whole daemon, and again on each restart for a subscribed feed (#322).
|
||||
pub fn read_opml(text: &str) -> Result<opml::OPML> {
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_str(text);
|
||||
let mut depth = 0usize;
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(_)) => {
|
||||
depth += 1;
|
||||
if depth > MAX_OPML_DEPTH {
|
||||
anyhow::bail!("that OPML nests more than {MAX_OPML_DEPTH} levels deep");
|
||||
}
|
||||
}
|
||||
Ok(Event::End(_)) => depth = depth.saturating_sub(1),
|
||||
// Malformed XML is the opml crate's to describe.
|
||||
Ok(Event::Eof) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
opml::OPML::from_str(text).map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
/// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
|
||||
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let doc = read_opml(&text).map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
|
||||
let mut out = vec![];
|
||||
collect_outlines(&doc.body.outlines, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
|
||||
pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
|
||||
for o in outlines {
|
||||
if let Some(url) = &o.xml_url {
|
||||
let title = o.title.clone().unwrap_or_else(|| o.text.clone());
|
||||
out.push((title, url.clone()));
|
||||
}
|
||||
collect_outlines(&o.outlines, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The <head><title> of an OPML document.
|
||||
#[must_use]
|
||||
pub fn opml_title(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let doc = read_opml(&text).ok()?;
|
||||
doc.head.and_then(|h| h.title).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_opml_is_refused_before_it_is_parsed() {
|
||||
let nest = |n: usize| {
|
||||
format!(
|
||||
r#"<opml version="2.0"><head><title>T</title></head><body>{}<outline text="f" xmlUrl="https://e.com/f"/>{}</body></opml>"#,
|
||||
r#"<outline text="a">"#.repeat(n),
|
||||
"</outline>".repeat(n)
|
||||
)
|
||||
};
|
||||
assert_eq!(parse_opml(nest(5).as_bytes()).unwrap().len(), 1, "a few folders deep is fine");
|
||||
// Deep enough to overflow a 2 MiB stack in the opml crate's reader without the check.
|
||||
let deep = nest(100_000);
|
||||
assert!(parse_opml(deep.as_bytes()).unwrap_err().to_string().contains("levels deep"));
|
||||
assert_eq!(opml_title(deep.as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opml_is_recognised_and_its_feeds_listed() {
|
||||
let xml = br#"<opml version="2.0"><head><title>My Subscriptions</title></head><body>
|
||||
<outline text="Folder">
|
||||
<outline type="rss" text="Alpha" xmlUrl="https://a.example/rss"/>
|
||||
<outline type="rss" text="Beta" xmlUrl="https://b.example/rss"/>
|
||||
</outline>
|
||||
<outline text="Not a feed"/>
|
||||
</body></opml>"#;
|
||||
assert!(is_opml(xml));
|
||||
assert_eq!(opml_title(xml).as_deref(), Some("My Subscriptions"));
|
||||
|
||||
let feeds = parse_opml(xml).unwrap();
|
||||
assert_eq!(feeds.len(), 2, "nested folders are walked, non-feed outlines skipped");
|
||||
assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into()));
|
||||
|
||||
// A feed must never be mistaken for a subscription list.
|
||||
assert!(!is_opml(include_bytes!("../../../tests/data/rss2.xml")));
|
||||
assert!(!is_opml(include_bytes!("../../../tests/data/atom.xml")));
|
||||
}
|
||||
}
|
||||
980
crates/feed/src/parse.rs
Normal file
980
crates/feed/src/parse.rs
Normal file
@@ -0,0 +1,980 @@
|
||||
//! RSS and Atom, read into `ParsedFeed`: titles as text, notes, enclosures, artwork, dates and durations.
|
||||
|
||||
use crate::{discover, wordpress};
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A feed as `parse` reads it, RSS or Atom alike: the channel's own details and its items.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ParsedFeed {
|
||||
pub title: Option<String>,
|
||||
/// RSS's `<ttl>`: how long the publisher asks to be left alone between reads.
|
||||
pub ttl_mins: Option<u64>,
|
||||
/// The show's artwork, `<itunes:image>` before the channel's `<image>`.
|
||||
pub image: Option<String>,
|
||||
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
||||
pub site: Option<String>,
|
||||
/// The channel's first `<itunes:category>`, for the Directory's categories.
|
||||
pub category: Option<String>,
|
||||
/// What the feed says it is, for its page in the Directory (#130): RSS's `<description>`,
|
||||
/// or iTunes' summary where that is empty, or Atom's subtitle. HTML as often as not.
|
||||
pub description: Option<String>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
/// One item of a feed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Entry {
|
||||
/// What tells this item from the feed's others: its `<guid>`, or failing that its link, its
|
||||
/// enclosure's address or its title (`pick_guid`).
|
||||
pub guid: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
/// Unix seconds.
|
||||
pub published: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
||||
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
||||
pub markdown: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
/// `<itunes:explicit>` said yes.
|
||||
pub explicit: bool,
|
||||
/// Episode artwork; falls back to the feed's in the UI.
|
||||
pub image: Option<String>,
|
||||
/// Seconds.
|
||||
pub duration: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub season: Option<i64>,
|
||||
pub enclosures: Vec<Enclosure>,
|
||||
}
|
||||
|
||||
/// A file an item carries, an episode as a rule.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Enclosure {
|
||||
pub url: String,
|
||||
/// The type the feed declares, which may be missing or wrong.
|
||||
pub mime: Option<String>,
|
||||
/// The size the feed declares, in bytes.
|
||||
pub length: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if bytes.trim_ascii_start().starts_with(b"[")
|
||||
&& let Ok(posts) = serde_json::from_slice::<Vec<serde_json::Value>>(bytes)
|
||||
{
|
||||
return Ok(wordpress::from_wp_posts(&posts));
|
||||
}
|
||||
match rss::Channel::read_from(bytes) {
|
||||
Ok(ch) => Ok(from_rss(ch, bytes)),
|
||||
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
||||
Ok(feed) => Ok(from_atom(feed)),
|
||||
Err(atom_err) => {
|
||||
if let Some(said) = plain_text(bytes) {
|
||||
return Err(anyhow!("the site sent {said} instead of a feed"));
|
||||
}
|
||||
// Some publishers (kcpw, feedland) write a bare "&" in a URL instead of
|
||||
// "&". Strict XML parsers refuse it; browsers don't. Retry once with
|
||||
// every offending "&" escaped rather than fail outright.
|
||||
let escaped = escape_bare_ampersands(bytes);
|
||||
if escaped != bytes {
|
||||
if let Ok(ch) = rss::Channel::read_from(escaped.as_slice()) {
|
||||
return Ok(from_rss(ch, &escaped));
|
||||
}
|
||||
if let Ok(feed) = atom_syndication::Feed::read_from(escaped.as_slice()) {
|
||||
return Ok(from_atom(feed));
|
||||
}
|
||||
}
|
||||
Err(match discover::alternate_feed_link(bytes) {
|
||||
Some(href) if looks_like_html(bytes) => {
|
||||
anyhow!("got a web page, not a feed; it links {href} as its feed")
|
||||
}
|
||||
None if looks_like_html(bytes) => anyhow!("got a web page, not a feed"),
|
||||
_ => anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})"),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a body is a web page rather than a feed: most of the errors traced back to a feed
|
||||
/// that moved or a domain that lapsed, with the old URL now serving the site instead (or a
|
||||
/// redirect to it). `is_opml` already sniffs the other "not actually a feed" case.
|
||||
pub(crate) fn looks_like_html(bytes: &[u8]) -> bool {
|
||||
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]).to_lowercase();
|
||||
head.contains("<!doctype html") || head.contains("<html")
|
||||
}
|
||||
|
||||
/// What a site sent when it sent a sentence instead of markup. doghouse's feed answered 200 with
|
||||
/// "Unable to establish a DB connection", and the two parsers' errors about end of input buried
|
||||
/// it. Anything starting with `<` is markup, however broken, and keeps the parsers' errors.
|
||||
pub(crate) fn plain_text(bytes: &[u8]) -> Option<String> {
|
||||
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(512)]);
|
||||
let text = head.trim_start_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
|
||||
if text.starts_with('<') {
|
||||
return None;
|
||||
}
|
||||
let line = text.lines().next().unwrap_or("").trim_end();
|
||||
if line.is_empty() {
|
||||
return Some("an empty reply".into());
|
||||
}
|
||||
let mut said: String = line.chars().take(80).collect();
|
||||
if said.len() < line.len() {
|
||||
said.push('…');
|
||||
}
|
||||
Some(format!("\"{said}\""))
|
||||
}
|
||||
|
||||
/// Escapes every `&` that does not already start a recognized XML entity
|
||||
/// (`&`, `<`, `>`, `"`, `'`, or a numeric reference like `'`).
|
||||
pub(crate) fn escape_bare_ampersands(bytes: &[u8]) -> Vec<u8> {
|
||||
fn is_entity_start(rest: &[u8]) -> bool {
|
||||
for named in [&b"amp;"[..], b"lt;", b"gt;", b"quot;", b"apos;"] {
|
||||
if rest.starts_with(named) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let digits = if rest.starts_with(b"#x") || rest.starts_with(b"#X") {
|
||||
&rest[2..]
|
||||
} else if rest.starts_with(b"#") {
|
||||
&rest[1..]
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let len = digits.iter().take_while(|b| b.is_ascii_alphanumeric()).count();
|
||||
len > 0 && digits.get(len) == Some(&b';')
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'&' && !is_entity_start(&bytes[i + 1..]) {
|
||||
out.extend_from_slice(b"&");
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every `<enclosure>` of every `<item>`, in document order.
|
||||
///
|
||||
/// The `rss` crate models an item as having at most one enclosure -- which is what RSS 2.0
|
||||
/// says -- and when a feed carries several it keeps only the *last*, silently losing the
|
||||
/// rest. Feeds do ship several, so read them from the XML directly.
|
||||
pub(crate) fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
|
||||
use quick_xml::events::Event;
|
||||
|
||||
let mut reader = quick_xml::Reader::from_reader(bytes);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut out: Vec<Vec<Enclosure>> = Vec::new();
|
||||
let mut current: Option<Vec<Enclosure>> = None;
|
||||
|
||||
let read_enclosure = |e: &quick_xml::events::BytesStart| -> Option<Enclosure> {
|
||||
let (mut url, mut mime, mut length) = (String::new(), None, None);
|
||||
for attr in e.attributes().flatten() {
|
||||
// Values arrive escaped: a feed URL's "&" is "&" in the document.
|
||||
let val = quick_xml::escape::unescape(&attr.value).map(|v| v.trim().to_string()).unwrap_or_default();
|
||||
match attr.key.local_name().as_ref() {
|
||||
"url" => url = val,
|
||||
"type" => mime = Some(val).filter(|v| !v.is_empty()),
|
||||
"length" => length = val.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(!url.is_empty()).then_some(Enclosure { url, mime, length })
|
||||
};
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => match e.name().local_name().as_ref() {
|
||||
"item" => current = Some(Vec::new()),
|
||||
"enclosure" => {
|
||||
if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) {
|
||||
list.push(enc);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Empty(e)) => match e.name().local_name().as_ref() {
|
||||
// <item/> with no children still counts, so the indexes stay aligned.
|
||||
"item" => out.push(Vec::new()),
|
||||
"enclosure" => {
|
||||
if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) {
|
||||
list.push(enc);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::End(e)) => {
|
||||
if e.name().local_name().as_ref() == "item"
|
||||
&& let Some(list) = current.take()
|
||||
{
|
||||
out.push(list);
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
if let Some(list) = current.take() {
|
||||
out.push(list);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
||||
let per_item = enclosures_by_item(bytes);
|
||||
let explicit = ch.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||
|
||||
let entries = ch
|
||||
.items()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, item)| {
|
||||
// Straight from the XML, so an item with several keeps all of them. Falls
|
||||
// back to the parsed one if the scan and the parser disagree on item count.
|
||||
let mut enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
|
||||
item.enclosure()
|
||||
.into_iter()
|
||||
.map(|e| Enclosure {
|
||||
url: e.url().trim().to_owned(),
|
||||
mime: non_empty(Some(e.mime_type())),
|
||||
length: e.length().parse().ok(),
|
||||
})
|
||||
.filter(|e| !e.url.is_empty())
|
||||
.collect()
|
||||
});
|
||||
drop_player_repeats(&mut enclosures);
|
||||
|
||||
let guid = pick_guid(
|
||||
item.guid().map(|g| g.value()),
|
||||
item.link(),
|
||||
enclosures.first().map(|e| e.url.as_str()),
|
||||
item.title(),
|
||||
)?;
|
||||
|
||||
let entry_explicit = item.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||
let it = item.itunes_ext();
|
||||
|
||||
Some(Entry {
|
||||
guid,
|
||||
title: title_text(item.title()),
|
||||
link: non_empty(item.link()),
|
||||
published: item.pub_date().and_then(parse_date),
|
||||
// Content wins over description, as __getEntries preferred entry.content. The
|
||||
// source namespace's Markdown copy only when there is neither (#284).
|
||||
description: notes(item.content(), item.description(), source_markdown(item)),
|
||||
markdown: non_empty(source_markdown(item))
|
||||
.filter(|_| non_empty(item.content()).or_else(|| non_empty(item.description())).is_some()),
|
||||
categories: item
|
||||
.categories()
|
||||
.iter()
|
||||
.map(|c| c.name().to_owned())
|
||||
.filter(|c| !c.is_empty() && !c.starts_with("http"))
|
||||
.collect(),
|
||||
explicit: explicit || entry_explicit,
|
||||
image: item_image(item, &enclosures),
|
||||
duration: it.and_then(|i| i.duration()).and_then(parse_duration),
|
||||
episode: it.and_then(|i| i.episode()).and_then(|e| e.trim().parse().ok()),
|
||||
season: it.and_then(|i| i.season()).and_then(|e| e.trim().parse().ok()),
|
||||
enclosures,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ParsedFeed {
|
||||
title: title_text(Some(ch.title())),
|
||||
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
|
||||
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
|
||||
image: ch
|
||||
.itunes_ext()
|
||||
.and_then(|i| i.image())
|
||||
.map(str::to_owned)
|
||||
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
||||
site: non_empty(Some(ch.link().trim())),
|
||||
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
||||
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
||||
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
||||
description: non_empty(Some(ch.description())).or_else(|| non_empty(ch.itunes_ext().and_then(|i| i.summary()))),
|
||||
category: ch
|
||||
.itunes_ext()
|
||||
.and_then(|i| i.categories().first())
|
||||
.map(|c| c.subcategory().filter(|s| !s.text().trim().is_empty()).unwrap_or(c))
|
||||
.and_then(|c| non_empty(Some(c.text().trim()))),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
||||
let entries = feed
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
// Atom carries enclosures as <link rel="enclosure">.
|
||||
let mut enclosures: Vec<Enclosure> = e
|
||||
.links()
|
||||
.iter()
|
||||
.filter(|l| l.rel() == "enclosure")
|
||||
.map(|l| Enclosure {
|
||||
url: l.href().trim().to_owned(),
|
||||
mime: non_empty(l.mime_type()),
|
||||
length: l.length().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
.filter(|e| !e.url.is_empty())
|
||||
.collect();
|
||||
drop_player_repeats(&mut enclosures);
|
||||
|
||||
let alt = e.links().iter().find(|l| l.rel() == "alternate" || l.rel().is_empty()).map(|l| l.href());
|
||||
|
||||
let guid =
|
||||
pick_guid(Some(e.id()), alt, enclosures.first().map(|x| x.url.as_str()), Some(e.title().as_str()))?;
|
||||
|
||||
Some(Entry {
|
||||
guid,
|
||||
title: title_text(Some(e.title().as_str())),
|
||||
link: alt.map(str::to_owned),
|
||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||
description: notes(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str()), None),
|
||||
markdown: None,
|
||||
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
||||
explicit: false,
|
||||
image: None,
|
||||
duration: None,
|
||||
episode: None,
|
||||
season: None,
|
||||
enclosures,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ParsedFeed {
|
||||
title: title_text(Some(feed.title().as_str())),
|
||||
ttl_mins: None,
|
||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||
site: feed
|
||||
.links()
|
||||
.iter()
|
||||
.find(|l| l.rel() == "alternate")
|
||||
.map(|l| l.href().trim().to_owned())
|
||||
.filter(|h| !h.is_empty()),
|
||||
category: None,
|
||||
description: non_empty(feed.subtitle().map(|t| t.as_str())),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
/// The original fell back to hashing the title or description. A guid, permalink or
|
||||
/// enclosure URL is a stable identifier already, so no hashing is needed; an entry with
|
||||
/// none of them has nothing to download and is dropped.
|
||||
pub(crate) fn pick_guid(
|
||||
guid: Option<&str>,
|
||||
link: Option<&str>,
|
||||
enclosure: Option<&str>,
|
||||
title: Option<&str>,
|
||||
) -> Option<String> {
|
||||
[guid, link, enclosure, title].into_iter().flatten().map(str::trim).find(|s| !s.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn is_yes(s: &str) -> bool {
|
||||
matches!(s.trim().to_ascii_lowercase().as_str(), "yes" | "true" | "explicit")
|
||||
}
|
||||
|
||||
pub(crate) fn non_empty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// WordPress numbers each audio player on a page by adding `?_=N` to its file's URL, so a post
|
||||
/// that embeds the file it encloses lists the same file twice: Rands in Repose's "The Promotion
|
||||
/// Paradox" was downloaded twice and offered two play buttons for one mp3. The first stays.
|
||||
pub(crate) fn drop_player_repeats(encs: &mut Vec<Enclosure>) {
|
||||
let mut seen = HashSet::new();
|
||||
encs.retain(|e| seen.insert(same_file_key(&e.url)));
|
||||
}
|
||||
|
||||
/// An enclosure URL without WordPress's player number, for telling repeats of one file apart
|
||||
/// from different files.
|
||||
#[must_use]
|
||||
pub fn same_file_key(url: &str) -> String {
|
||||
let Ok(mut u) = url::Url::parse(url) else { return url.to_owned() };
|
||||
let kept: Vec<(String, String)> = u
|
||||
.query_pairs()
|
||||
.filter(|(k, v)| !(k == "_" && !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())))
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
u.set_query(None);
|
||||
} else {
|
||||
u.query_pairs_mut().clear().extend_pairs(kept);
|
||||
}
|
||||
u.to_string()
|
||||
}
|
||||
|
||||
/// A title as plain text. An Atom title of `type="html"`, or an RSS one in CDATA, comes through
|
||||
/// the XML parser with its HTML entities intact: The Verge's "Meta’s" reached the page as
|
||||
/// typed. Decoded one entity at a time, so an `&` that starts none, as in "Q&A", stays as it is
|
||||
/// instead of failing the whole title.
|
||||
pub(crate) fn title_text(s: Option<&str>) -> Option<String> {
|
||||
let s = non_empty(s)?;
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s.as_str();
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at]);
|
||||
rest = &rest[at..];
|
||||
let len = 1 + rest[1..].find(|c: char| !(c.is_ascii_alphanumeric() || c == '#')).unwrap_or(rest.len() - 1);
|
||||
let decoded = rest[len..]
|
||||
.starts_with(';')
|
||||
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
|
||||
.flatten();
|
||||
match decoded {
|
||||
Some(v) => {
|
||||
out.push_str(&v);
|
||||
rest = &rest[len + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
non_empty(Some(&out))
|
||||
}
|
||||
|
||||
/// An item's notes as HTML: `body`, with Markdown made HTML, whether the feed wrote its text in
|
||||
/// Markdown or sent only a Markdown copy (#284). The page cleans it as it does any feed's HTML.
|
||||
pub(crate) fn notes(content: Option<&str>, description: Option<&str>, markdown: Option<&str>) -> Option<String> {
|
||||
match body(content, description) {
|
||||
Some(b) if is_markdown(&b) => Some(markdown_html(&b)),
|
||||
Some(b) => Some(b),
|
||||
None => non_empty(markdown).map(|m| markdown_html(&m)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<source:markdown>`, from https://source.scripting.com/: the item's text in Markdown.
|
||||
///
|
||||
/// shortcut: found by the `source` prefix the namespace's own docs and feeds use; a feed binding
|
||||
/// it to another prefix is not read, until one turns up.
|
||||
pub(crate) fn source_markdown(item: &rss::Item) -> Option<&str> {
|
||||
item.extensions().get("source")?.get("markdown")?.first()?.value()
|
||||
}
|
||||
|
||||
/// Text with no HTML tag and some Markdown: a link, a heading, a list, a quote, code or bold.
|
||||
/// Plain text without any is left as it was, since rendering it would change nothing but
|
||||
/// could still read a line starting "1984." as a list.
|
||||
pub(crate) fn is_markdown(s: &str) -> bool {
|
||||
let tag = s
|
||||
.match_indices('<')
|
||||
.any(|(i, _)| s[i + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!'));
|
||||
let block = |l: &str| ["# ", "## ", "### ", "- ", "* ", "> ", "```"].iter().any(|m| l.trim_start().starts_with(m));
|
||||
!tag && (s.contains("](") || s.contains("**") || s.lines().any(block))
|
||||
}
|
||||
|
||||
/// Markdown made HTML, uncleaned: whoever shows it cleans it as any feed's HTML.
|
||||
#[must_use]
|
||||
pub fn markdown_html(md: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH));
|
||||
out
|
||||
}
|
||||
|
||||
/// An item's show notes: its full body when that is whole, else its description.
|
||||
///
|
||||
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
||||
/// pasted from a web app (`[&:has([data-writing-block])>*]:pointer-events-auto`), so the body
|
||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||
pub(crate) fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||
Some(c) => Some(match subtitle(&c, description) {
|
||||
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||
None => c,
|
||||
}),
|
||||
None => non_empty(description).or_else(|| non_empty(content)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||
/// description found in the body is not shown twice.
|
||||
///
|
||||
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||
/// `entries` column.
|
||||
pub(crate) fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||
let d = title_text(description)?;
|
||||
if d.contains('<') || d.chars().count() > 300 {
|
||||
return None;
|
||||
}
|
||||
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||
// description ends in "…" or "[...]".
|
||||
let words = |s: &str| {
|
||||
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||
};
|
||||
let want = words(&d);
|
||||
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||
}
|
||||
|
||||
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||
pub(crate) fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
out.push(' ');
|
||||
}
|
||||
_ if !in_tag => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||
/// start was cut off.
|
||||
pub(crate) fn starts_mid_tag(html: &str) -> bool {
|
||||
html[..html.find('<').unwrap_or(html.len())].contains("\">")
|
||||
}
|
||||
|
||||
/// The picture to show beside an item, in order of how deliberate it is:
|
||||
/// `itunes:image`, then Media RSS `media:thumbnail`, then a `media:content` that is an
|
||||
/// image, and finally an image enclosure -- which is how a blog's article picture arrives
|
||||
/// (Substack puts it there), so those entries get artwork rather than a blank square.
|
||||
pub(crate) fn item_image(item: &rss::Item, enclosures: &[Enclosure]) -> Option<String> {
|
||||
if let Some(url) = item.itunes_ext().and_then(|i| i.image()) {
|
||||
return non_empty(Some(url));
|
||||
}
|
||||
|
||||
let media = item.extensions().get("media");
|
||||
let attr = |name: &str, want_image: bool| -> Option<String> {
|
||||
media?.get(name)?.iter().find_map(|e| {
|
||||
if want_image {
|
||||
// media:content carries anything; only take it when it says it is a picture.
|
||||
let is_image = e.attrs.get("type").is_some_and(|t| t.starts_with("image/"))
|
||||
|| e.attrs.get("medium").is_some_and(|m| m == "image");
|
||||
if !is_image {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
non_empty(e.attrs.get("url").map(String::as_str))
|
||||
})
|
||||
};
|
||||
attr("thumbnail", false).or_else(|| attr("content", true)).or_else(|| {
|
||||
enclosures.iter().find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/"))).map(|e| e.url.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
|
||||
pub(crate) fn parse_duration(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !s.contains(':') {
|
||||
return s.parse().ok().filter(|n| *n > 0);
|
||||
}
|
||||
let mut total: i64 = 0;
|
||||
for part in s.split(':') {
|
||||
total = total * 60 + part.trim().parse::<i64>().ok()?;
|
||||
}
|
||||
Some(total).filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
/// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead.
|
||||
pub(crate) fn parse_date(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
DateTime::parse_from_rfc2822(s).or_else(|_| DateTime::parse_from_rfc3339(s)).ok().map(|d| d.timestamp())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::failure;
|
||||
|
||||
#[test]
|
||||
fn parses_rss_with_itunes_extensions() {
|
||||
let bytes = include_bytes!("../../../tests/data/rss2.xml");
|
||||
let feed = parse(bytes).unwrap();
|
||||
|
||||
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
||||
assert_eq!(feed.ttl_mins, Some(45));
|
||||
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
|
||||
assert_eq!(feed.description.as_deref(), Some("A synthetic feed used by the parser tests."));
|
||||
assert_eq!(feed.entries.len(), 3);
|
||||
|
||||
let ep = &feed.entries[0];
|
||||
assert_eq!(ep.guid, "https://example.com/ep/1");
|
||||
assert_eq!(ep.title.as_deref(), Some("Episode One"));
|
||||
assert_eq!(ep.published, Some(1_078_016_400));
|
||||
assert_eq!(ep.categories, vec!["Tech"]);
|
||||
assert!(!ep.explicit);
|
||||
assert_eq!(
|
||||
ep.enclosures,
|
||||
vec![Enclosure {
|
||||
url: "https://example.com/ep1.mp3".into(),
|
||||
mime: Some("audio/mpeg".into()),
|
||||
length: Some(12_345_678),
|
||||
}]
|
||||
);
|
||||
|
||||
assert!(feed.entries[1].explicit, "entry-level itunes:explicit=yes");
|
||||
assert_eq!(feed.entries[2].enclosures[0].mime.as_deref(), Some("application/x-bittorrent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_says_what_it_is_in_itunes_summary_or_atom_subtitle() {
|
||||
// An empty <description> gives way to iTunes' summary (#130).
|
||||
let rss = br#"<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel><title>R</title><description> </description><itunes:summary>Said here.</itunes:summary></channel></rss>"#;
|
||||
assert_eq!(parse(rss).unwrap().description.as_deref(), Some("Said here."));
|
||||
let atom = br#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>A</title>
|
||||
<subtitle>Or here.</subtitle><id>urn:a</id><updated>2026-01-01T00:00:00Z</updated></feed>"#;
|
||||
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_notes_are_made_html_and_html_is_left_alone() {
|
||||
let feed = |items: &str| {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>{items}</channel></rss>"#
|
||||
);
|
||||
parse(xml.as_bytes()).unwrap().entries.into_iter().map(|e| e.description).collect::<Vec<_>>()
|
||||
};
|
||||
let got = feed(
|
||||
r#"<item><guid>a</guid><description># Hi
|
||||
|
||||
Some **bold** and [a link](https://x.example/).</description></item>
|
||||
<item><guid>b</guid><source:markdown>Only *Markdown* here.</source:markdown></item>
|
||||
<item><guid>c</guid><description><p>HTML **as sent**</p></description><source:markdown>ignored</source:markdown></item>
|
||||
<item><guid>d</guid><description>1984. Plain text, left as it is.</description></item>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].as_deref(),
|
||||
Some("<h1>Hi</h1>\n<p>Some <strong>bold</strong> and <a href=\"https://x.example/\">a link</a>.</p>\n")
|
||||
);
|
||||
assert_eq!(got[1].as_deref(), Some("<p>Only <em>Markdown</em> here.</p>\n"), "the Markdown copy, alone");
|
||||
assert_eq!(got[2].as_deref(), Some("<p>HTML **as sent**</p>"), "HTML wins, untouched");
|
||||
assert_eq!(got[3].as_deref(), Some("1984. Plain text, left as it is."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_sent_beside_html_is_kept_for_the_page_to_switch_to() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>
|
||||
<item><guid>both</guid><description><p>Hi</p></description><source:markdown>*Hi*</source:markdown></item>
|
||||
<item><guid>alone</guid><source:markdown>*Hi*</source:markdown></item></channel></rss>"#;
|
||||
let got: Vec<_> = parse(xml).unwrap().entries.into_iter().map(|e| e.markdown).collect();
|
||||
assert_eq!(got, [Some("*Hi*".to_owned()), None], "only beside HTML; alone, it is the description (#284)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
||||
<item><title>The Promotion Paradox</title><guid>p</guid>
|
||||
<enclosure url="https://x/ep.mp3" length="1" type="audio/mpeg"/>
|
||||
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
|
||||
<enclosure url="https://x/other.mp3?_=3&key=k" length="1" type="audio/mpeg"/>
|
||||
</item></channel></rss>"#;
|
||||
let urls: Vec<String> = parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
||||
assert_eq!(
|
||||
urls,
|
||||
["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"],
|
||||
"the repeat goes, a different file stays"
|
||||
);
|
||||
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
|
||||
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_are_read_as_text_not_html() {
|
||||
// The Verge: an Atom title of type="html", its entity inside CDATA.
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"><title type="text">V</title><id>v</id>
|
||||
<updated>2026-09-15T00:00:00Z</updated>
|
||||
<entry><title type="html"><![CDATA[Meta’s new One]]></title><id>e1</id>
|
||||
<updated>2026-09-15T00:00:00Z</updated></entry></feed>"#;
|
||||
assert_eq!(parse(xml).unwrap().entries[0].title.as_deref(), Some("Meta\u{2019}s new One"));
|
||||
// HTML names as well as numbers; a bare `&` and an unknown name are left as they are.
|
||||
assert_eq!(
|
||||
title_text(Some("Peña & “Q&A” &bogus; AT&T;")).as_deref(),
|
||||
Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
|
||||
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
|
||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||
assert_eq!(
|
||||
body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(),
|
||||
Some("<p>Notes</p>"),
|
||||
"a whole body wins"
|
||||
);
|
||||
assert_eq!(
|
||||
body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(),
|
||||
Some("Plain notes, no tags.")
|
||||
);
|
||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||
Some("<p>The post.</p>"),
|
||||
"a description with markup in it is notes, not a subtitle",
|
||||
);
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||
);
|
||||
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||
for d in [
|
||||
"This week we talk about tape, drums and a very long list.",
|
||||
"This week we talk about tape, drums…",
|
||||
"This week we talk about\ntape [...]",
|
||||
] {
|
||||
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||
}
|
||||
let long = "word ".repeat(80);
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some(&long)).as_deref(),
|
||||
Some("<p>The post.</p>"),
|
||||
"a long description is notes, not a subtitle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_level_explicit_overrides_entries() {
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel><title>X</title><link>https://x</link><description>d</description>
|
||||
<itunes:explicit>yes</itunes:explicit>
|
||||
<item><title>a</title><guid>g1</guid>
|
||||
<enclosure url="https://x/a.mp3" length="1" type="audio/mpeg"/></item>
|
||||
</channel></rss>"#;
|
||||
let feed = parse(xml).unwrap();
|
||||
assert!(feed.entries[0].explicit, "the entry says nothing; the feed-level flag must still mark it explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_are_named_by_kind_for_the_log() {
|
||||
let k = |m: &str| failure::failure_kind(m);
|
||||
assert_eq!(k("HTTP 404 Not Found"), ("404".into(), Some(404)));
|
||||
assert_eq!(k("HTTP 503 Service Unavailable"), ("503".into(), Some(503)));
|
||||
assert_eq!(
|
||||
k("connecting: error following redirect for url (https://www.toddstashwick.com/): too many redirects").0,
|
||||
"redirect_loop"
|
||||
);
|
||||
assert_eq!(k("connecting: dns error: failed to lookup address information").0, "dns");
|
||||
assert_eq!(k("operation timed out").0, "timeout");
|
||||
assert_eq!(k("something new").0, "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_says_so_and_names_the_feed_it_links() {
|
||||
let html = br#"<!doctype html><html><head>
|
||||
<link rel="alternate" type="application/rss+xml" href="https://x.example/feed">
|
||||
</head><body>not a feed</body></html>"#;
|
||||
let err = parse(html).unwrap_err().to_string();
|
||||
assert_eq!(err, "got a web page, not a feed; it links https://x.example/feed as its feed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_with_no_feed_link_still_says_so() {
|
||||
let html = b"<!doctype html><html><body>moved</body></html>";
|
||||
assert_eq!(parse(html).unwrap_err().to_string(), "got a web page, not a feed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_xml_gets_the_original_parser_errors() {
|
||||
let err = parse(b"<rss><channel><title>cut off").unwrap_err().to_string();
|
||||
assert!(err.starts_with("not RSS ("), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_with_no_markup_says_what_the_site_sent() {
|
||||
let err = parse(b"\xef\xbb\xbf\r\n Unable to establish a DB connection\nmore").unwrap_err().to_string();
|
||||
assert_eq!(err, "the site sent \"Unable to establish a DB connection\" instead of a feed");
|
||||
let long = parse("x".repeat(200).as_bytes()).unwrap_err().to_string();
|
||||
assert_eq!(long, format!("the site sent \"{}…\" instead of a feed", "x".repeat(80)));
|
||||
assert_eq!(parse(b" \n").unwrap_err().to_string(), "the site sent an empty reply instead of a feed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_atom_enclosure_links() {
|
||||
let bytes = include_bytes!("../../../tests/data/atom.xml");
|
||||
let feed = parse(bytes).unwrap();
|
||||
|
||||
assert_eq!(feed.title.as_deref(), Some("Atom Cast"));
|
||||
assert_eq!(feed.entries.len(), 1);
|
||||
let ep = &feed.entries[0];
|
||||
assert_eq!(ep.guid, "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a");
|
||||
assert_eq!(ep.link.as_deref(), Some("https://example.org/ep/1"));
|
||||
assert_eq!(ep.published, Some(1_078_016_400));
|
||||
assert_eq!(
|
||||
ep.enclosures,
|
||||
vec![Enclosure {
|
||||
url: "https://example.org/ep1.m4a".into(),
|
||||
mime: Some("audio/mp4".into()),
|
||||
length: Some(9_876_543),
|
||||
}],
|
||||
"rel=enclosure only; the alternate link must not become an enclosure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_ampersand_in_a_link_is_repaired_and_parsed() {
|
||||
// kcpw.org: <link>https://kcpw.org/?post_type=post&p=125715</link> -- a bare "&"
|
||||
// that strict XML rejects but browsers accept.
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>X</title><link>https://x</link><description>d</description>
|
||||
<item><title>a</title><guid>g1</guid>
|
||||
<link>https://kcpw.org/?post_type=post&p=125715</link>
|
||||
<enclosure url="https://x/a.mp3?a=1&b=2" length="1" type="audio/mpeg"/></item>
|
||||
</channel></rss>"#;
|
||||
let feed = parse(xml).unwrap();
|
||||
assert_eq!(feed.entries[0].link.as_deref(), Some("https://kcpw.org/?post_type=post&p=125715"));
|
||||
assert_eq!(feed.entries[0].enclosures[0].url, "https://x/a.mp3?a=1&b=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_bare_ampersands_leaves_real_entities_alone() {
|
||||
let out = escape_bare_ampersands(b"a&b <x> ' / c&d");
|
||||
assert_eq!(out, b"a&b <x> ' / c&d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rss_title_always_wins_and_episode_numbers_stay_metadata() {
|
||||
// Some feeds set a different itunes:title. The displayed title is always the RSS
|
||||
// <title>, verbatim -- separators and all -- and season/episode are stored
|
||||
// alongside it rather than folded into it.
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel><title>Show</title><link>https://x</link><description>d</description>
|
||||
<item>
|
||||
<title>Music from a Darkened Room | Session Zero</title>
|
||||
<itunes:title>Session Zero</itunes:title>
|
||||
<guid>sz</guid>
|
||||
<itunes:season>8</itunes:season>
|
||||
<itunes:duration>6720</itunes:duration>
|
||||
<enclosure url="https://x/sz.mp3" length="1" type="audio/mpeg"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>Music from a Darkened Room Part 1 | Murphy's Drawer</title>
|
||||
<guid>p1</guid>
|
||||
<itunes:season>8</itunes:season><itunes:episode>1</itunes:episode>
|
||||
<enclosure url="https://x/p1.mp3" length="1" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel></rss>"#;
|
||||
let feed = parse(xml).unwrap();
|
||||
|
||||
let sz = &feed.entries[0];
|
||||
assert_eq!(
|
||||
sz.title.as_deref(),
|
||||
Some("Music from a Darkened Room | Session Zero"),
|
||||
"itunes:title must not override the RSS title"
|
||||
);
|
||||
assert_eq!(sz.season, Some(8));
|
||||
assert_eq!(sz.episode, None, "a missing episode number stays missing");
|
||||
assert_eq!(sz.duration, Some(6720));
|
||||
|
||||
let p1 = &feed.entries[1];
|
||||
assert_eq!(p1.title.as_deref(), Some("Music from a Darkened Room Part 1 | Murphy's Drawer"));
|
||||
assert_eq!((p1.season, p1.episode), (Some(8), Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_may_carry_several_enclosures() {
|
||||
// The rss crate keeps only one per item -- the last -- so these come from the XML.
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>M</title><link>https://x</link><description>d</description>
|
||||
<item><title>Two files</title><guid>m1</guid>
|
||||
<enclosure url="https://x/a.mp3?v=1&t=2" length="111" type="audio/mpeg"/>
|
||||
<enclosure url="https://x/b.mp4" length="222" type="video/mp4"/>
|
||||
</item>
|
||||
<item><title>One file</title><guid>m2</guid>
|
||||
<enclosure url="https://x/c.mp3" length="333" type="audio/mpeg"/></item>
|
||||
<item><title>None</title><guid>m3</guid></item>
|
||||
</channel></rss>"#;
|
||||
let f = parse(xml).unwrap();
|
||||
assert_eq!(f.entries.len(), 3);
|
||||
|
||||
let two = &f.entries[0].enclosures;
|
||||
assert_eq!(two.len(), 2, "both enclosures survive");
|
||||
assert_eq!(two[0].url, "https://x/a.mp3?v=1&t=2", "document order, and the escaped ampersand is decoded");
|
||||
assert_eq!(two[0].length, Some(111));
|
||||
assert_eq!(two[1].url, "https://x/b.mp4");
|
||||
assert_eq!(two[1].mime.as_deref(), Some("video/mp4"));
|
||||
|
||||
assert_eq!(f.entries[1].enclosures.len(), 1);
|
||||
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_items_picture_comes_from_the_most_deliberate_source() {
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel><title>P</title><link>https://x</link><description>d</description>
|
||||
<item><title>Has itunes</title><guid>a</guid>
|
||||
<itunes:image href="https://x/itunes.jpg"/>
|
||||
<media:thumbnail url="https://x/thumb.jpg"/>
|
||||
<enclosure url="https://x/a.jpg" length="1" type="image/jpeg"/></item>
|
||||
<item><title>Has thumbnail</title><guid>b</guid>
|
||||
<media:thumbnail url="https://x/thumb.jpg"/>
|
||||
<enclosure url="https://x/b.jpg" length="1" type="image/jpeg"/></item>
|
||||
<item><title>Has media content</title><guid>c</guid>
|
||||
<media:content url="https://x/pic.jpg" type="image/jpeg"/>
|
||||
<media:content url="https://x/clip.mp4" type="video/mp4"/></item>
|
||||
<item><title>Only an image enclosure</title><guid>d</guid>
|
||||
<enclosure url="https://x/d.jpg" length="1" type="image/jpeg"/></item>
|
||||
<item><title>Audio only</title><guid>e</guid>
|
||||
<enclosure url="https://x/e.mp3" length="1" type="audio/mpeg"/></item>
|
||||
</channel></rss>"#;
|
||||
let f = parse(xml).unwrap();
|
||||
let img = |i: usize| f.entries[i].image.as_deref();
|
||||
assert_eq!(img(0), Some("https://x/itunes.jpg"), "itunes:image wins");
|
||||
assert_eq!(img(1), Some("https://x/thumb.jpg"), "then media:thumbnail");
|
||||
assert_eq!(img(2), Some("https://x/pic.jpg"), "media:content, and only the image one");
|
||||
assert_eq!(img(3), Some("https://x/d.jpg"), "a blog's article picture arrives as an enclosure");
|
||||
assert_eq!(img(4), None, "audio is not a picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_parse_from_seconds_or_a_clock() {
|
||||
assert_eq!(parse_duration("5649"), Some(5649));
|
||||
assert_eq!(parse_duration("23:45"), Some(1425));
|
||||
assert_eq!(parse_duration("1:34:09"), Some(5649));
|
||||
assert_eq!(parse_duration("0"), None, "zero is not a duration");
|
||||
assert_eq!(parse_duration(""), None);
|
||||
assert_eq!(parse_duration("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_html_masquerading_as_a_feed() {
|
||||
assert!(parse(b"<html><body>nope</body></html>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guid_falls_back_through_link_then_enclosure() {
|
||||
assert_eq!(pick_guid(Some(" "), Some("l"), Some("e"), None).as_deref(), Some("l"));
|
||||
assert_eq!(pick_guid(None, None, Some("e"), Some("t")).as_deref(), Some("e"));
|
||||
assert_eq!(pick_guid(None, None, None, None), None);
|
||||
}
|
||||
}
|
||||
147
crates/feed/src/patreon.rs
Normal file
147
crates/feed/src/patreon.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Patreon: a creator's feed, its shows, and when two Patreon addresses are the same feed.
|
||||
|
||||
use crate::fetch;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
/// The token and show of a Patreon feed link, or None for any other URL.
|
||||
///
|
||||
/// Patreon gives each patron one token per creator. With no show it stands for the creator,
|
||||
/// whose feed carries every show at once.
|
||||
pub(crate) fn patreon_parts(url: &str) -> Option<(String, Option<String>)> {
|
||||
let u = url::Url::parse(url).ok()?;
|
||||
if !matches!(u.host_str()?, "patreon.com" | "www.patreon.com") || !u.path().starts_with("/rss") {
|
||||
return None;
|
||||
}
|
||||
let param = |name: &str| u.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned());
|
||||
Some((param("auth")?, param("show")))
|
||||
}
|
||||
|
||||
/// A Patreon link naming a creator but no show.
|
||||
#[must_use]
|
||||
pub fn is_patreon_creator(url: &str) -> bool {
|
||||
matches!(patreon_parts(url), Some((_, None)))
|
||||
}
|
||||
|
||||
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
||||
/// creator's name, by number, or with no creator at all -- and the token and show are what
|
||||
/// identify it.
|
||||
#[must_use]
|
||||
pub fn same_feed(a: &str, b: &str) -> bool {
|
||||
// Parsing both is the slow part, and only Patreon's addresses can differ and still match.
|
||||
a == b
|
||||
|| (a.contains("patreon.com")
|
||||
&& b.contains("patreon.com")
|
||||
&& patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b)))
|
||||
}
|
||||
|
||||
/// A Patreon creator's name and shows, each show as (title, feed URL).
|
||||
///
|
||||
/// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes,
|
||||
/// finding shows stops and the show feeds already found keep working. The documented API
|
||||
/// needs an OAuth client per install and does not list shows.
|
||||
pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
||||
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
||||
let mut resp = client.get(url).timeout(fetch::FEED_TIMEOUT).send().await.context("connecting")?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
||||
}
|
||||
let mut head = Vec::new();
|
||||
while patreon_campaign(&head).is_none() && head.len() < 64 * 1024 {
|
||||
let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break };
|
||||
head.extend_from_slice(&chunk);
|
||||
}
|
||||
let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
||||
|
||||
let api = format!(
|
||||
"https://www.patreon.com/api/campaigns/{campaign}\
|
||||
?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title"
|
||||
);
|
||||
let resp = client.get(api).send().await.context("asking Patreon for the shows")?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Patreon would not list the shows: HTTP {}", resp.status()));
|
||||
}
|
||||
let (name, shows) = parse_patreon_shows(&resp.bytes().await.context("reading the shows")?)?;
|
||||
Ok((name, shows.into_iter().map(|(id, title)| (title, format!("{url}&show={id}"))).collect()))
|
||||
}
|
||||
|
||||
/// The campaign number in the start of a Patreon feed.
|
||||
pub(crate) fn patreon_campaign(head: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(head);
|
||||
text.match_indices("patreon.com/rss/").find_map(|(i, m)| {
|
||||
let id: String = text[i + m.len()..].chars().take_while(char::is_ascii_digit).collect();
|
||||
(!id.is_empty()).then_some(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// A campaign's name, and its shows as (id, title).
|
||||
pub(crate) type PatreonShows = (Option<String>, Vec<(String, String)>);
|
||||
|
||||
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
|
||||
pub(crate) fn parse_patreon_shows(json: &[u8]) -> Result<PatreonShows> {
|
||||
let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?;
|
||||
// Missing is not the same as none. Read as no shows, the creator feed would be scanned as
|
||||
// a plain feed, claim every show's files, and leave the shows empty once the list returned.
|
||||
let ids = v["data"]["relationships"]["shows"]["data"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("Patreon's answer does not list the shows"))?;
|
||||
let title = |id: &str| -> Option<String> {
|
||||
let show = v["included"].as_array()?.iter().find(|x| x["type"] == "collection" && x["id"] == id)?;
|
||||
show["attributes"]["title"].as_str().map(|t| t.trim().to_owned())
|
||||
};
|
||||
let shows = ids
|
||||
.iter()
|
||||
.filter_map(|s| s["id"].as_str())
|
||||
.map(|id| (id.to_owned(), title(id).unwrap_or_else(|| format!("Show {id}"))))
|
||||
.collect();
|
||||
Ok((v["data"]["attributes"]["name"].as_str().map(str::to_owned), shows))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discover;
|
||||
|
||||
#[test]
|
||||
fn a_patreon_creator_is_a_list_of_its_shows() {
|
||||
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
||||
assert_eq!(discover::expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
||||
assert_eq!(discover::expand_input("https://example.com/rss"), "https://example.com/rss");
|
||||
assert_eq!(discover::expand_input("http://example.com/rss"), "http://example.com/rss");
|
||||
assert_eq!(discover::expand_input(" cnn.com "), "https://cnn.com");
|
||||
assert_eq!(discover::expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
|
||||
assert_eq!(discover::expand_input("//example.com/rss"), "https://example.com/rss");
|
||||
|
||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
||||
assert!(!is_patreon_creator(&format!("https://www.patreon.com/rss/x?auth={tok}&show=1")), "one show is a feed");
|
||||
assert!(!is_patreon_creator(&format!("https://example.com/rss?auth={tok}")));
|
||||
|
||||
// The show you already have by name is the one a bare token would add by number.
|
||||
assert!(same_feed(
|
||||
&format!("https://www.patreon.com/rss/glasscannon?auth={tok}&show=2073588"),
|
||||
&format!("https://www.patreon.com/rss?auth={tok}&show=2073588"),
|
||||
));
|
||||
assert!(!same_feed(
|
||||
&format!("https://www.patreon.com/rss?auth={tok}&show=1"),
|
||||
&format!("https://www.patreon.com/rss?auth={tok}&show=2"),
|
||||
));
|
||||
|
||||
// The self link carries the campaign by number, whichever spelling was asked for.
|
||||
let head = br#"<rss><channel><link>https://www.patreon.com/glasscannon</link>
|
||||
<atom:link href="https://www.patreon.com/rss/369921?auth=t" rel="self"/>"#;
|
||||
assert_eq!(patreon_campaign(head).as_deref(), Some("369921"));
|
||||
assert_eq!(patreon_campaign(b"<rss><channel><title>T"), None);
|
||||
|
||||
let json = br#"{"data":{"id":"369921","type":"campaign","attributes":{"name":"The Glass Cannon Network"},
|
||||
"relationships":{"shows":{"data":[{"id":"2073588","type":"collection"},{"id":"2073636","type":"collection"}]}}},
|
||||
"included":[{"id":"2073588","type":"collection","attributes":{"title":"Get in the Trunk "}},
|
||||
{"id":"2073636","type":"collection","attributes":{"title":"Shadowdark"}}]}"#;
|
||||
let (name, shows) = parse_patreon_shows(json).unwrap();
|
||||
assert_eq!(name.as_deref(), Some("The Glass Cannon Network"));
|
||||
assert_eq!(shows, [("2073588".into(), "Get in the Trunk".into()), ("2073636".into(), "Shadowdark".into())]);
|
||||
|
||||
// An answer that stops naming the shows is an error, never "this creator has none".
|
||||
assert!(parse_patreon_shows(br#"{"data":{"attributes":{"name":"X"}}}"#).is_err());
|
||||
}
|
||||
}
|
||||
105
crates/feed/src/privacy.rs
Normal file
105
crates/feed/src/privacy.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Addresses that carry someone's access: taking it out of what is logged, and keeping such feeds private.
|
||||
|
||||
/// 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
|
||||
/// (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.
|
||||
/// `system-proxy` reads http_proxy/https_proxy.
|
||||
/// `auth` is a username and password, sent only to the feed's own host.
|
||||
/// 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.
|
||||
#[must_use]
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with each address in it redacted, for what is logged and sent to every open page: an
|
||||
/// error from reqwest names the whole address it fetched, a private feed's key with it (#238).
|
||||
#[must_use]
|
||||
pub fn redact_text(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() {
|
||||
out.push_str(&rest[..i]);
|
||||
let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n);
|
||||
out.push_str(&redact(&rest[i..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// An address that carries a subscriber's own access: a paid-feed host, credentials in the URL,
|
||||
/// or a key in the query. ipx leaves such a feed out of anything others see, and the directory
|
||||
/// refuses one sent to it (#202).
|
||||
///
|
||||
/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
|
||||
/// through; a per-feed private flag is the upgrade (#205).
|
||||
#[must_use]
|
||||
pub fn url_looks_private(feed_url: &str) -> bool {
|
||||
let Ok(u) = url::Url::parse(feed_url) else { return true };
|
||||
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
|
||||
// long path segment alone proves nothing (acast's public show ids look the same). So a
|
||||
// feed from one of them is private whatever its URL looks like. Supercast is why this
|
||||
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
|
||||
let host = u.host_str().unwrap_or("");
|
||||
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|
||||
|| !u.username().is_empty()
|
||||
|| u.password().is_some()
|
||||
|| u.query_pairs().any(|(k, _)| {
|
||||
let k = k.to_ascii_lowercase();
|
||||
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"].iter().any(|w| k.contains(w))
|
||||
})
|
||||
}
|
||||
|
||||
/// Services whose feeds are always one subscriber's own.
|
||||
pub(crate) const PAID_HOSTS: &[&str] =
|
||||
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||
|
||||
#[cfg(test)]
|
||||
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");
|
||||
// As reqwest words a failure, and with two addresses and none.
|
||||
assert_eq!(
|
||||
super::redact_text(
|
||||
"connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"
|
||||
),
|
||||
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
|
||||
);
|
||||
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
|
||||
assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_carrying_someones_access_looks_private() {
|
||||
let private = super::url_looks_private;
|
||||
assert!(!private("https://feeds.twit.tv/twit.xml"));
|
||||
// Patreon's shape: the key is a query parameter.
|
||||
assert!(private("https://www.patreon.com/rss/x?auth=abc123&show=2073588"));
|
||||
assert!(private("https://example.com/rss?api_key=abc"));
|
||||
// Paid-feed services put the key in the path; the host gives them away.
|
||||
assert!(private("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx"));
|
||||
assert!(private("https://someshow.supportingcast.fm/content/abc123.rss"));
|
||||
// A long path segment alone is not a key: acast's public show ids look the same.
|
||||
assert!(!private("https://feeds.acast.com/public/shows/0123456789abcdef01234567"));
|
||||
assert!(private("https://ray:hunter2@example.com/rss"));
|
||||
assert!(private("not a url"), "unparseable is not safe to list");
|
||||
}
|
||||
}
|
||||
86
crates/feed/src/wordpress.rs
Normal file
86
crates/feed/src/wordpress.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! WordPress.com sites that answer their feed with a browser check, read from WordPress.com's API instead (#317).
|
||||
|
||||
use crate::fetch::Fetched;
|
||||
use crate::parse::{Entry, ParsedFeed};
|
||||
use crate::{fetch, parse};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
/// WordPress.com's browser check, in place of the feed it was asked for.
|
||||
pub(crate) fn is_wpcom_challenge(body: &[u8]) -> bool {
|
||||
String::from_utf8_lossy(&body[..body.len().min(4096)]).contains("/__challenge/")
|
||||
}
|
||||
|
||||
/// A WordPress.com site's latest posts, from WordPress.com's API, which `parse` reads as a feed.
|
||||
/// shortcut: the API's default page of ten posts, as many as WordPress puts in a feed; a feed
|
||||
/// set to carry more gets ten here.
|
||||
pub(crate) async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
|
||||
let api = format!("https://public-api.wordpress.com/wp/v2/sites/{host}/posts");
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.timeout(fetch::FEED_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP 403 Forbidden, a browser check; connecting to WordPress.com's API in its place")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"HTTP 403 Forbidden, a browser check, and WordPress.com's API in its place answered HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
let bytes = fetch::read_capped(resp).await.context("reading WordPress.com's API")?;
|
||||
fetch::FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
fetch::FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(Fetched::Body { bytes, etag: None, last_modified: None })
|
||||
}
|
||||
|
||||
/// WordPress's posts as its REST API sends them (`wpcom_posts`). The guid is the one the site's
|
||||
/// own feed gives each post, so they are the items already stored. No title for the feed: the
|
||||
/// stored one stays.
|
||||
pub(crate) fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
|
||||
let entries = posts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
Some(Entry {
|
||||
guid: parse::non_empty(p["guid"]["rendered"].as_str())
|
||||
.or_else(|| parse::non_empty(p["link"].as_str()))?,
|
||||
title: parse::title_text(p["title"]["rendered"].as_str()),
|
||||
link: parse::non_empty(p["link"].as_str()),
|
||||
published: p["date_gmt"]
|
||||
.as_str()
|
||||
.and_then(|d| NaiveDateTime::parse_from_str(d, "%Y-%m-%dT%H:%M:%S").ok())
|
||||
.map(|d| d.and_utc().timestamp()),
|
||||
description: parse::non_empty(p["content"]["rendered"].as_str()),
|
||||
image: parse::non_empty(p["jetpack_featured_media_url"].as_str()),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ParsedFeed { entries, ..Default::default() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn reads_wordpress_posts_as_a_feed() {
|
||||
// As WordPress.com's API sends them, trimmed (#317).
|
||||
let json = br#"[{"id":90248260,"date_gmt":"2026-10-09T13:34:00",
|
||||
"guid":{"rendered":"https:\/\/pelgranepress.com\/?p=90248260"},
|
||||
"link":"https:\/\/pelgranepress.com\/2026\/10\/09\/whisky\/",
|
||||
"title":{"rendered":"Baby’s First Whisky"},"content":{"rendered":"<p>Notes<\/p>"},
|
||||
"jetpack_featured_media_url":""}]"#;
|
||||
let feed = super::parse(json).expect("WordPress posts parse as a feed");
|
||||
assert_eq!(feed.title, None);
|
||||
let e = &feed.entries[0];
|
||||
assert_eq!(e.guid, "https://pelgranepress.com/?p=90248260");
|
||||
assert_eq!(e.title.as_deref(), Some("Baby\u{2019}s First Whisky"));
|
||||
assert_eq!(e.link.as_deref(), Some("https://pelgranepress.com/2026/10/09/whisky/"));
|
||||
assert_eq!(e.published, Some(1_791_552_840));
|
||||
assert_eq!(e.description.as_deref(), Some("<p>Notes</p>"));
|
||||
assert_eq!(e.image, None);
|
||||
assert!(super::is_wpcom_challenge(
|
||||
b"<!DOCTYPE html><title>Checking your browser...</title><script src=\"/__challenge/probes.js\">"
|
||||
));
|
||||
assert!(!super::is_wpcom_challenge(b"<html>Forbidden</html>"));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,194 @@
|
||||
# Changelog
|
||||
# Changelog: ipx
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
The daemon: the API, scans, downloads, DeadAir and the database. The page has its own changelog,
|
||||
[web/CHANGELOG.md](../../web/CHANGELOG.md), and the directory service its own,
|
||||
[crates/directory/CHANGELOG.md](../directory/CHANGELOG.md). Until 0.12.0 this was the changelog of
|
||||
all of ipx, the page included; they were split with the services (#207).
|
||||
|
||||
Its version is in `crates/ipx/Cargo.toml`, and a release is tagged `vX.Y.Z`.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
### Changed
|
||||
|
||||
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is, and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172).
|
||||
- Log lines about starting, stopping, accounts and the configuration name the part of iPX that wrote them in `target` (`ipx::daemon`, `ipx::cli`, `ipx::catalogue`, ...), where they all said `ipx` (#356).
|
||||
|
||||
## [1.5.0] - 2026-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Traces show more of where a request's time goes: a page of items' query and its files apart, artwork's cache, check and fetch, the status counts, and each part of changing or leaving a feed (#350).
|
||||
- A page of items finds its files by feed as well as item: it read every file's row in the database, about a quarter of a second a page (#344).
|
||||
- A page of items asks for its count beside the items, and the feed list asks its five questions at once, where each waited for the one before (#346).
|
||||
- A Signal's counts of items and of unread ones are worked out in one pass, not two (#348).
|
||||
- Artwork kept on disk is read and written off the threads that answer requests, and two requests for the same missing artwork fetch it once (#345, #349).
|
||||
- Unread lists and counts start from each person's oldest unread item in each feed, which the daily cleanup works out, instead of reading the whole back catalogue and everything you have read (#347, #352).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A WordPress.com site that answers its feed with a browser check (HTTP 403, "Checking your browser...") is read from WordPress.com's API instead, so Pelgrane Press's feed no longer fails for hours at a time. The items are the ones the feed gave: same ids (#317).
|
||||
|
||||
### Security
|
||||
|
||||
- An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331).
|
||||
- A downloaded file opens in the browser only when it is audio, video, an image other than SVG, a PDF or plain text, and is saved otherwise. An SVG or web page a podcast sent ran its script as iPX, signed in as whoever opened it (#319).
|
||||
- An OPML nested more than 100 levels deep is refused, uploaded or fetched. A deep enough one stopped iPX, and a subscribed one stopped it again each time it restarted (#322).
|
||||
- A `.torrent` file larger than 10 MB, or slower than the torrent stall limit, is refused. iPX read one into memory however large it was (#330).
|
||||
- Feeds, episodes, artwork and `.torrent` files are no longer fetched from internal addresses (loopback, the LAN, Docker's networks), redirects included, unless config.toml's `fetch_internal` lists them. Adding a feed could read what a service inside the network answered (#320).
|
||||
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
||||
- A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321).
|
||||
- A show kept from a private OPML or Patreon list stays private, with the list's username and password, once the list is removed or left. It became public and was shared to the directory (#332).
|
||||
- A feed's username and password go to its own host alone: not to its episodes on other hosts, nor to the feeds an OPML lists on other hosts, which took the OPML's password on every check (#327, #328).
|
||||
- A check of one feed asks at most eight hosts whether its artwork is on https. A feed could name artwork on as many hosts as it liked, each asked in turn for up to 20 seconds, and hold up every other feed's check for hours (#339).
|
||||
- An OPML or Patreon list may bring in up to 2,000 feeds; one with more shows an error and adds none. A list of tens of thousands held up checking for everyone, and adding its feeds read the whole feeds table once for each (#337).
|
||||
- A request that changes something is refused when a browser sent it from another site. A page on another site, a sibling subdomain included, could mark everything read, start downloads, subscribe you to a Directory feed or sign you out (#336).
|
||||
- `ipx user passwd` signs the account out of every browser, and says how many of its API tokens still work. A session signed in with the old password went on working (#334).
|
||||
- Live updates about a feed go only to the people who read it, and admins. Everyone signed in had every feed's, errors included, which carried a paid feed's address with its key (#325).
|
||||
- An open page stops getting live updates within half a minute of its account being removed. They went on until the page was closed (#335).
|
||||
- Only someone subscribed to a feed, or an admin, may mark it private. Anyone signed in could keep anyone's feed, or a whole OPML's, out of the directory, and only an admin could undo it (#333).
|
||||
- An OPML upload may add up to 2,000 feeds; one with more is refused and adds none. Importing tens of thousands held up every change to the feed list for minutes and left them all in it (#338).
|
||||
- Password checks stay a few at a time when the person signing in gives up before the answer. Each sign-in that hung up let another start, so a flood of them used every core (#324).
|
||||
- Jev picks at most 20 icons an hour for each person, on the server's key; past that the page says to choose one by hand. Anyone signed in could have it pick without end, on the server's bill, holding back everyone's Signals (#340).
|
||||
- Each person's Signals on an item are judged in a call of their own, on the server's key too. One person's description was in the question that decided another's verdicts (#343).
|
||||
|
||||
## [1.4.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- An item whose feed sends Markdown beside its HTML, as Scripting News does with `<source:markdown>`, keeps it: `markdown` and `markdown_html` in the API's items (#285).
|
||||
- An item written in Markdown shows formatted, not as Markdown's raw characters: a description in Markdown, or, with no other text, RSS's `<source:markdown>`. HTML a feed sends is left as it is (#284).
|
||||
- A show from an OPML subscription or a Patreon creator can be kept: `kept` on `PATCH /api/feeds/{id}` and in the feed list. When the OPML drops a kept show, or you leave the OPML, it stays on your list as a feed of its own (#280).
|
||||
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, by person and by whose key paid so the server's bill can be split, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275, #278).
|
||||
- An admin setting for how many days back a new Signal reads (#276).
|
||||
- A list's trace says which filter, sort and page it was asked for, and whether a search, so a slow list can be told from the others (#287).
|
||||
- A feed, an OPML subscription or a Patreon creator can be left out of All Subscriptions: `hide_from_all` on `PATCH /api/feeds/{id}` and in the feed list. Set on an OPML or creator it covers every show in it; Signals and Currently Listening still read them (#288).
|
||||
- A feed, an OPML subscription or a Patreon creator can be kept out of Signals: `hide_from_signals` on `PATCH /api/feeds/{id}` and in the feed list. Jev is not asked about its items, so they cost nothing, and they are left out of each Signal's list and counts (#289).
|
||||
- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).
|
||||
|
||||
### Changed
|
||||
|
||||
- `GET /api/feeds/{id}/entries` for an OPML subscription or a Patreon creator lists the items of the shows in it that you subscribe to, together, where it listed nothing (#283).
|
||||
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
|
||||
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
|
||||
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).
|
||||
- Signals fill faster and cost less: up to ten calls to TypeSafe at once instead of one, paced under TypeSafe's limits of 80 calls and 100,000 input tokens a second, with every call held back while TypeSafe answers 429 (#277), everyone's Signals on an item in one call on the server's key, and an item's text cut at 1,000 characters instead of 2,000 (#275).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Artwork comes quickly: an image already kept is served without asking the database again, and the database finds which feed names an image by an index instead of reading every item, which took over half a second an image (#272).
|
||||
- The Directory shows the artwork of podcasts nobody on this server subscribes to, where it drew their initials (#273).
|
||||
- Artwork a publisher sends without an image type, or as `application/octet-stream`, is shown when it is a JPEG, PNG, GIF or WebP, instead of initials (#279).
|
||||
- Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274).
|
||||
- With the downloads folder out of reach during a clean-up, as when the array stops, every downloaded episode was taken as deleted by hand and forgotten; the clean-up now leaves them alone until the folder is back (#293).
|
||||
- A feed address that serves something larger than 50 MB, such as an episode's file given as a feed, is refused as too large, instead of read whole into memory at every check (#295).
|
||||
- Searching items for `%` or `_` finds those characters, instead of treating them as wildcards that matched nearly everything (#296).
|
||||
|
||||
### Security
|
||||
|
||||
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one, in every connection ipx makes (#281).
|
||||
- The address of a file being downloaded, which for Patreon carries its signature, is sent to open pages and written to the log without its query, as other addresses already were (#292).
|
||||
|
||||
## [1.3.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||
- With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`, traces go to the collector compressed; `/metrics` answers gzipped, and a snapshot goes to the directory gzipped (#253).
|
||||
- A Directory request's trace shows the call to the directory whole, with the size of its answer, and the steps that match it against your subscriptions (#250).
|
||||
- The page's report of how long a list took to show after a click (`list_shown`, with the view and filter) is logged under that click's trace (#246).
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed that sends the same body again, as Patreon and Substack do whatever they are asked, is taken as unchanged instead of read again, and, without a schedule of its own, checked less often the longer it stays the same, up to every four hours, back on schedule when it changes (#242).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the daemon keeps all twenty of its database connections open, so a page load no longer waits for new ones, 80-330 ms each: the feed list and item lists took 0.3-1.6 s now and then, where their queries take 5-30 ms (#241).
|
||||
- Currently Listening and the Pinned filter answer in a few milliseconds: they read every item on the server, 1.3 s for Currently Listening (#249).
|
||||
|
||||
## [1.2.0] - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
|
||||
|
||||
### Changed
|
||||
|
||||
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
|
||||
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
|
||||
- A scan's trace names the feed each fetch is for (#229).
|
||||
|
||||
### Security
|
||||
|
||||
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- With a directory token that may administer it, ipx's admins remove a feed from the directory (blocking it) or check it now, list and unblock what is blocked, and list and drop the servers sharing (#206).
|
||||
- ipx passes its trace on when it calls the directory, so a Directory page is one trace from the browser to the directory (#213).
|
||||
|
||||
### Changed
|
||||
|
||||
- The Directory comes from a directory service that iPX servers share, set up in Admin, Directory with its address and this server's token, instead of only this server's feeds. Sharing this server's public feeds with it is a setting, never a private one, and subscriber counts are every sharing server's. ipx sends the directory its feeds when they change, and every half hour (#205, #211).
|
||||
- A feed can be marked private, which keeps it out of the directory. A feed with a login, or whose address carries a key, is private already (#205).
|
||||
- ipx serves no page: the page is ipx-web's, which passes the API on to ipx, and ipx is built without node (#203).
|
||||
- `docker-compose.yml` is a sample to start from, ipx alone on SQLite with its folders beside the file, instead of a copy of the author's server (#208).
|
||||
- The source is a Cargo workspace: the daemon in `crates/ipx`, feed fetching and parsing in `crates/feed`. `cargo build --release` still makes `target/release/ipx` (#202).
|
||||
|
||||
## [0.12.0] - 2026-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- With `[web] metrics_bind` set, ipx serves Prometheus metrics on a port and thread of their own: the database pool, the worker's job and queue, jobs done and their time, scans, downloads and requests in progress, download speed, live-update listeners and missed updates, DeadAir's backlog, the artwork cache's fill, hit rate and evictions, memory, open files and the library's counts. (#194, #195, #198, #199).
|
||||
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192).
|
||||
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
|
||||
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
|
||||
|
||||
### Changed
|
||||
|
||||
- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space; the copies already made are removed (#179, #181, #196).
|
||||
- DeadAir is in the same box as a show's download settings, below them, and stays there when automatic downloads are off (#185).
|
||||
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
|
||||
- On Postgres, ipx keeps up to 20 connections to the database instead of 10, for the bursts of a page load asking for many covers at once (#200).
|
||||
- Traces of the disk sweep, episode lists and DeadAir's work on each file show each step, and the sweep's trace says what it removed (#187).
|
||||
|
||||
### Fixed
|
||||
|
||||
- All Subscriptions, and every item list, comes up faster, most of all the first time after a quiet spell: it took half a second to two (#188).
|
||||
- Checking for new episodes no longer waits up to 12 seconds each time on a clear-out of dead feeds from the Directory, which now runs once a day (#193).
|
||||
- A page left open no longer misses updates after a check for new episodes: the note that each feed not due was skipped, one per feed, filled the queue they share (#197).
|
||||
|
||||
### Security
|
||||
|
||||
- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189).
|
||||
|
||||
## [0.11.0] - 2026-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is ("Cutting dead air…" until it is done), and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172, #173, #174, #176).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A downloaded file's play, save, share and delete buttons stay on one row in the Files pane, instead of delete wrapping underneath (#175).
|
||||
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.
|
||||
- Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part.
|
||||
- Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why.
|
||||
@@ -28,7 +204,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- The feed list's traces show each of its database queries, so it can be told where its time goes.
|
||||
- Saving a feed's settings asks the database for your subscription once, not twice.
|
||||
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code.
|
||||
- Glass's top bar is solid on a phone, in the colour its glass showed, so the strip above it on an iPhone can match it.
|
||||
- A pinned item or feed's pin button is filled with the theme's accent, in the reader, on the feed's page and in the toolbar, so pinned no longer looks like not pinned.
|
||||
- A feed's page in the Directory says how many people here subscribe only when someone does, without "Nobody here subscribes yet".
|
||||
|
||||
@@ -36,10 +211,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- The unread counts catch up when you come back to the page: an item read on another device, or while the phone app was in the background, no longer stays counted as unread until a reload.
|
||||
- A feed set to download 0 of its newest items downloads all of them, the whole back catalogue, as the server's 0 does; it downloaded nothing.
|
||||
- The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server.
|
||||
- The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server, and asks for a group's title once, not once for every feed in it.
|
||||
- Downloading or deleting a file the server no longer has answers "not found" instead of a server error.
|
||||
- A feed's page in the Directory, and subscribing from it, no longer wait while the whole Directory is put together to check that the one feed is in it.
|
||||
- The feed list, and each live update of it, no longer asks the database for a group's title once for every feed in the group.
|
||||
- An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good.
|
||||
- Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one.
|
||||
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
|
||||
@@ -48,7 +222,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.
|
||||
- A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart.
|
||||
- On a phone, the closed feed list no longer shades the left edge of the page with its shadow.
|
||||
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it.
|
||||
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it. In Glass the bar is solid on a phone, in the colour its glass showed.
|
||||
- A feed's buttons stay on one row on a narrow phone, and the reader's buttons stay together instead of breaking across lines under a long feed name.
|
||||
- A moment's DNS failure no longer marks a feed as failing for a day or calls it gone: iPX tries it again in ten minutes. One failed lookup one morning left 205 podcasts unread, uncategorised and labelled gone until the next day. A name that does not resolve is said to be maybe gone, and only once it has been failing a day, on its Directory page as on its own.
|
||||
|
||||
@@ -91,10 +265,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- The Directory is laid out as Apple's is. It opens on the ten most subscribed feeds, ranked, and the categories as tiles showing some of their shows' covers. A category opens a page of its own with its most subscribed feeds, its subcategories (Tech News under News, Video Games under Leisure) and its feeds; See all lists every one. This replaces the grid of every feed under rows of category buttons.
|
||||
- The most subscribed feeds are the Directory's first section instead of a place of their own: Popular is gone from the feed list, and so is its key, g p.
|
||||
- The Directory is laid out as Apple's is. It opens on the ten most subscribed feeds, ranked, and the categories as tiles showing some of their shows' covers. A category opens a page of its own with its most subscribed feeds, its subcategories (Tech News under News, Video Games under Leisure) and its feeds; See all lists every one, A to Z, Z to A or by most subscribers. This replaces the grid of every feed under rows of category buttons, and Popular, which is gone from the feed list with its key, g p.
|
||||
- A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin.
|
||||
- The Directory can be sorted by name, A to Z or Z to A, or by most subscribers. It still opens A to Z.
|
||||
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
|
||||
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
|
||||
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
|
||||
@@ -122,43 +294,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `IPX_LOG_FORMAT=json` logs one JSON object a line, with a request's method, route, status and time
|
||||
and a scan's or download's details as fields, for Loki and the like.
|
||||
- A Grafana dashboard for iPX, from its log in Loki and its traces in Tempo (`grafana/dashboard.py`).
|
||||
- `IPX_LOG_FORMAT=json` logs one JSON object a line, for Loki and the like: a request's method,
|
||||
route, status and `duration_ms`, a scan's or download's details, a failure's kind in `error.type`
|
||||
(and its HTTP status in `http.response.status_code`), and each line's `trace_id` and `span_id`.
|
||||
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
|
||||
requests to a collector such as Tempo.
|
||||
- A feed that has no artwork of its own shows its website's icon instead.
|
||||
requests to a collector such as Tempo; a scan's shows the time each feed spends fetching, on its
|
||||
artwork and in the database.
|
||||
- A feed with no artwork of its own, or whose artwork is missing, shows its website's icon, or the
|
||||
site's `/favicon.ico`, and follows the site when it changes. It is asked for again only when the
|
||||
artwork changes or you refresh the feed.
|
||||
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
|
||||
while any of yours is.
|
||||
- Adding a website's address subscribes to the feed that site links, instead of failing on every scan.
|
||||
- Adding a website's address, with or without `https://`, subscribes to the feed that site links.
|
||||
An address is checked before it is added, and anything that is neither a feed nor a page linking
|
||||
one is refused with the reason, instead of being added and failing on every check.
|
||||
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
|
||||
the version, for a dashboard such as Homepage.
|
||||
|
||||
### Changed
|
||||
|
||||
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
|
||||
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list.
|
||||
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list every minute.
|
||||
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
|
||||
- An open page reloads the feed list only when a scan has checked something, not every minute.
|
||||
- Adding an address checks it first: a feed is added, a web page adds the feed it links, and anything else is refused with the reason, instead of being added and failing on every check.
|
||||
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
|
||||
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
|
||||
- A scan's trace shows the time a feed spends on its artwork and in the database after the fetch.
|
||||
- The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of
|
||||
twice, names a failure's kind in `error.type` (and its HTTP status in
|
||||
`http.response.status_code`), and calls a request's time `duration_ms` instead of `ms`.
|
||||
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
|
||||
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
|
||||
chosen one yet starts from the theme your account had.
|
||||
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
|
||||
orange of its needle.
|
||||
- The logo follows the page: the dark version in a dark theme, the light one in a light theme.
|
||||
- The browser tab's icon follows the page's light or dark mode too.
|
||||
- On a phone, the top bar leaves out the logo, giving its room to the search box.
|
||||
- Between releases, the version on the logo says so: 0.9.1-dev, not 0.9.0.
|
||||
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
|
||||
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
|
||||
feed list. Hovering it shows iPX's version.
|
||||
feed list, and left out on a phone, for the search box. It follows the page, dark in a dark theme
|
||||
and light in a light one, as the browser tab's icon does, and hovering it shows iPX's version,
|
||||
which says so between releases: 0.9.1-dev, not 0.9.0.
|
||||
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -168,28 +337,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Adding a site without `https://`, such as `cnn.com`, works; it failed on every check.
|
||||
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
|
||||
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
|
||||
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
|
||||
- A scan no longer asks a feed's website for its icon every time; it asks again when the artwork changes or you refresh the feed.
|
||||
- The feed list loads several times faster: it was asking the database six questions per feed.
|
||||
- Artwork a feed offers only over plain http shows on the https site too.
|
||||
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
|
||||
its counts, and its artwork greyed out once it has failed for a day.
|
||||
- A feed whose own artwork is missing shows its website's icon instead of nothing.
|
||||
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
|
||||
- A feed whose website names an icon that is missing shows the site's `/favicon.ico` instead of
|
||||
no artwork.
|
||||
- A website's icon standing in for a feed's artwork follows the site when it changes, and a
|
||||
feed that drops its own artwork gets the site's instead. Refreshing a feed checks again.
|
||||
- The read button shows whether an item is read, as the pin beside it shows whether it is
|
||||
pinned: a tick when read, an envelope when not. It used to show the opposite.
|
||||
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
|
||||
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
|
||||
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
|
||||
a feed that has not changed is still read again.
|
||||
- A web page with some non-ASCII characters no longer hides the feed it links, or crashes looking for it.
|
||||
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
|
||||
a second pull meanwhile does nothing, instead of no sign at all that the check started.
|
||||
- Settings no longer lists the server's download folder, which only an admin can change, on
|
||||
@@ -229,14 +390,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
||||
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
||||
them.
|
||||
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
||||
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
|
||||
across as it changes.
|
||||
- Swiping between items on a phone no longer shows the item list through the reader. The next
|
||||
or previous item sits beside the one you are reading and comes across with it, the end of the
|
||||
list says so, and swiping right from the first item draws the reader off the list, dimmed
|
||||
until it is uncovered. A swipe from the screen's left edge stays with the page, rather than
|
||||
Safari taking it as Back.
|
||||
- Swiping between items on a phone takes a quarter of the screen, so a diagonal scroll no longer
|
||||
jumps ahead by accident, and no longer shows the item list through the reader: the next or
|
||||
previous item sits beside the one you are reading and comes across with it, following your
|
||||
finger, the end of the list says so, and swiping right from the first item draws the reader off
|
||||
the list, dimmed until it is uncovered. A swipe from the screen's left edge stays with the page,
|
||||
rather than Safari taking it as Back.
|
||||
|
||||
## [0.8.4] - 2026-09-19
|
||||
|
||||
@@ -343,6 +502,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
||||
swipe the item you are reading left for the next one and right for the one before, or back
|
||||
to the list from the first.
|
||||
- A favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on their own,
|
||||
and on white for an iPhone's home screen.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -357,17 +518,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
|
||||
them, where long notes left them looking missing.
|
||||
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
|
||||
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
|
||||
`web/src`, type-checked, and built with swc; building ipx needs node.
|
||||
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
|
||||
between visits and fetches it again only when an update changes it.
|
||||
- The page is served minified, about a quarter smaller, with its script in a file of its own,
|
||||
`/app.js`, which your browser keeps between visits and fetches again only when an update changes
|
||||
it. The script is TypeScript in `web/src`, type-checked and built with swc; building ipx needs
|
||||
node.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching tabs straight after marking everything read no longer shows the previous tab's
|
||||
items: of two lists asked for at once, only the later one is shown.
|
||||
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
|
||||
their own, and on white for an iPhone's home screen.
|
||||
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
|
||||
higher than a downloaded one's.
|
||||
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
|
||||
@@ -378,8 +537,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
unread dot back until the next refresh.
|
||||
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
|
||||
one, rather than a few read items lingering until a refresh cleared them.
|
||||
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
|
||||
leaves it out of their page.
|
||||
- An image or link in a post given relative to the post, such as The Observation Deck's, now
|
||||
points at the post's site rather than at ipx, and shows.
|
||||
|
||||
@@ -449,10 +606,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
connection", now shows that message and is flagged as the publisher's problem, instead of two
|
||||
parser errors about reaching the end of input.
|
||||
|
||||
### Removed
|
||||
|
||||
- An unused icon glyph (`minus`) left over from before Unsubscribe settled on `circleMinus`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
|
||||
@@ -621,13 +774,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
|
||||
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
|
||||
through all three and remembers the choice.
|
||||
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
|
||||
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
|
||||
- A toolbar across the top, after the original iPodderX: add, unsubscribe and scan; play, mark
|
||||
read and keep for the selected item, showing its state; a search box for what is showing; and
|
||||
Settings and Log.
|
||||
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
|
||||
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
|
||||
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
|
||||
- Items show as a table (unread, kept, title, feed, file, size, published) with a Files pane beside
|
||||
it, and a status bar with the totals.
|
||||
it, and a status bar with the totals. A file's type is an icon (audio, video, image, PDF, torrent,
|
||||
other), green once it is downloaded and red when the download failed, with the details in its
|
||||
tooltip; its size is in KB for small files instead of "0 MB".
|
||||
- Mark everything read from All Subscriptions, across every feed you subscribe to
|
||||
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
|
||||
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
|
||||
@@ -642,28 +798,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
items show without pressing Scan.
|
||||
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
|
||||
label, just the Download button. The retention summary says "deleted", not "reaped".
|
||||
- Buttons are icons, with the words in their tooltips: the Files pane (save, delete, view,
|
||||
download), an item's own buttons (mark read, keep, open the original), the feed header (scan,
|
||||
download latest, mark all read, settings, unsubscribe), and the Settings, feed settings and
|
||||
Download latest dialogs (save, download, cancel). The icons are Font Awesome Free, embedded as
|
||||
SVG: only the ones used, no font to download, and nothing fetched from anyone else. They
|
||||
replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to
|
||||
font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope.
|
||||
- One meaning per icon. Minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||
dialog's confirm button carries the icon of what it does. The feed header's unsubscribe was an x
|
||||
and read as closing the page. The remaining word buttons are icons too:
|
||||
- Log, Add feed, Users, Unsubscribe and OPML.
|
||||
- Popular's Subscribe, Copy and Sign out.
|
||||
- The Subscribed label in Popular, the Directory and Add feed, which is now a green check.
|
||||
- The player's back, play, forward and close, which were font characters, and the folder arrow.
|
||||
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
|
||||
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
|
||||
- Buttons are icons, with the words in their tooltips, one meaning per icon: minus unsubscribes,
|
||||
x closes or cancels, plus adds or subscribes, a dialog's confirm button carries the icon of what
|
||||
it does, Keep is a flag, as it was in the original, mark unread is an envelope, and Subscribed is
|
||||
a green check. The icons are Font Awesome Free, embedded as SVG: only the ones used, no font to
|
||||
download, and nothing fetched from anyone else. They replace words and font characters such as
|
||||
⟳ ⤓ ↗, which came out thin and tiny and differed from font to font, and the feed header's x for
|
||||
unsubscribe, which read as closing the page.
|
||||
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
|
||||
- The item table's size has its own column, apart from the file's type, and shows KB for small
|
||||
files instead of "0 MB". The Item heading is now Title.
|
||||
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
|
||||
downloaded and red when the download failed, with the details in its tooltip. One icon per
|
||||
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -714,11 +856,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Importing another account's OPML export subscribed nobody and reported "Imported 0 feed(s)".
|
||||
Feeds it added had no subscriber, so they were never scanned.
|
||||
- Importing something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
|
||||
refused before anything changes.
|
||||
- Starring stopped protecting a file from the quota and age sweeps once read state became per-user.
|
||||
|
||||
### Security
|
||||
|
||||
@@ -732,7 +871,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Web UI served by the daemon: plain HTML and JS compiled into the binary, with feeds, items,
|
||||
filters, search, sanitised show notes and live progress over SSE.
|
||||
filters, search, sanitised show notes and live progress over SSE. Opening an item marks it
|
||||
read; playing it marks it read only at the end or past 90%.
|
||||
- Player bar with resume, speed, keyboard shortcuts and lock-screen controls.
|
||||
- Three-pane layout: feeds beside, items above, and the selected item's text and files below.
|
||||
- Phone layout.
|
||||
@@ -740,7 +880,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
trusted proxy header for Cloudflare Zero Trust or Authentik (`docs/sso.md`).
|
||||
- Subscribing to an OPML: it is re-read every scan and its feeds show as a folder. A feed dropped
|
||||
from it is removed unless it has downloads.
|
||||
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`).
|
||||
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`), set by an
|
||||
admin, as are the global settings.
|
||||
- `[general] media_types`, default audio and video, and `max_new_per_check`, default 3.
|
||||
- Every enclosure of an item, a View link for files that are not audio or video, and per-item
|
||||
artwork.
|
||||
@@ -753,33 +894,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Global settings and scan schedules are admin-only. The per-feed schedule picker is gone.
|
||||
- Feeds from an OPML live in the database, not `config.toml`.
|
||||
- Opening an item marks it read. Playing it marks it read only at the end or past 90%.
|
||||
- "Episodes" became "items", since half the library is text.
|
||||
- A burst of scan events causes one refresh, not one per feed.
|
||||
- All is the default filter. OPML import and export, Settings and Log moved out of the header.
|
||||
- Torrents run detached, two at a time.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Download fetched the next queued episodes instead of the one clicked.
|
||||
- Pressing play made an item vanish from the Unread list.
|
||||
- Clearing a folder, schedule or cap from the UI did nothing.
|
||||
- The daemon ignored SIGTERM until the current download finished.
|
||||
- A missing function stopped the page script and left the whole UI dead.
|
||||
- One download painted progress on every pending row.
|
||||
- A torrent could freeze scanning for up to an hour.
|
||||
- Downloading from an OPML feed failed with "belongs to unsubscribed feed".
|
||||
- Image enclosures were downloaded, counted as episodes and given a play button.
|
||||
- Image enclosures were downloaded as episodes.
|
||||
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
|
||||
- An item with several enclosures kept only the last.
|
||||
- OPML folders rendered open by default.
|
||||
- Mark read in the text pane recursed until the stack overflowed.
|
||||
- The Unread, Downloaded and Flagged filters answered `500` without a search term.
|
||||
- An OPML subscription always showed 0 unread.
|
||||
- Folder names kept doubled spaces where separators were stripped.
|
||||
- Sidebar rows had four different left edges.
|
||||
|
||||
### Security
|
||||
|
||||
@@ -803,7 +927,15 @@ 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.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.5.0...main
|
||||
[1.5.0]: https://git.sdf1.net/rays/ipx/compare/v1.4.0...v1.5.0
|
||||
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...v1.4.0
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
||||
[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
|
||||
[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.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3
|
||||
[0.10.2]: https://git.sdf1.net/rays/ipx/compare/v0.10.1...v0.10.2
|
||||
[0.10.1]: https://git.sdf1.net/rays/ipx/compare/v0.10.0...v0.10.1
|
||||
39
crates/ipx/Cargo.toml
Normal file
39
crates/ipx/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "1.5.1-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ipx-feed.workspace = true
|
||||
ipx-directory.workspace = true
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
flate2.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
futures-util.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
librqbit.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
opml.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
reqwest.workspace = true
|
||||
sea-orm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use jsonwebtoken::jwk::JwkSet;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
||||
|
||||
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
|
||||
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
|
||||
@@ -55,7 +55,7 @@ impl Keys {
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = Result<JwkSet>>,
|
||||
{
|
||||
let kid = decode_header(token).ok()?.kid?;
|
||||
let kid = jsonwebtoken::decode_header(token).ok()?.kid?;
|
||||
let key = match self.key(&kid) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
@@ -78,7 +78,7 @@ impl Keys {
|
||||
v.set_audience(&[aud]);
|
||||
v.set_issuer(&[format!("https://{team}")]);
|
||||
v.validate_nbf = true;
|
||||
match decode::<Claims>(token, &key, &v) {
|
||||
match jsonwebtoken::decode::<Claims>(token, &key, &v) {
|
||||
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "refused a Cloudflare Access token");
|
||||
@@ -88,12 +88,12 @@ impl Keys {
|
||||
}
|
||||
|
||||
fn key(&self, kid: &str) -> Option<DecodingKey> {
|
||||
self.cache.lock().unwrap().keys.get(kid).cloned()
|
||||
self.cache.lock().expect("a panic elsewhere poisoned this lock").keys.get(kid).cloned()
|
||||
}
|
||||
|
||||
/// Takes the slot as it answers, so two requests at once do not both fetch.
|
||||
fn may_refetch(&self) -> bool {
|
||||
let mut c = self.cache.lock().unwrap();
|
||||
let mut c = self.cache.lock().expect("a panic elsewhere poisoned this lock");
|
||||
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
|
||||
return false;
|
||||
}
|
||||
@@ -103,12 +103,9 @@ impl Keys {
|
||||
|
||||
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||
fn store(&self, set: JwkSet) {
|
||||
let keys = set
|
||||
.keys
|
||||
.iter()
|
||||
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
||||
.collect();
|
||||
let mut c = self.cache.lock().unwrap();
|
||||
let keys =
|
||||
set.keys.iter().filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))).collect();
|
||||
let mut c = self.cache.lock().expect("a panic elsewhere poisoned this lock");
|
||||
c.keys = keys;
|
||||
c.fetched = Some(Instant::now());
|
||||
}
|
||||
@@ -131,32 +128,32 @@ async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
|
||||
use jsonwebtoken::{EncodingKey, Header};
|
||||
|
||||
const TEAM: &str = "team.cloudflareaccess.com";
|
||||
const AUD: &str = "aud-tag";
|
||||
|
||||
fn jwks() -> JwkSet {
|
||||
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
|
||||
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
|
||||
}
|
||||
|
||||
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||
let mut h = Header::new(alg);
|
||||
h.kid = Some("k1".into());
|
||||
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||
encode(&h, &claims, &k).unwrap()
|
||||
jsonwebtoken::encode(&h, &claims, &k).unwrap()
|
||||
}
|
||||
|
||||
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||
let now = get_current_timestamp() as i64;
|
||||
let now = jsonwebtoken::get_current_timestamp() as i64;
|
||||
serde_json::json!({
|
||||
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
|
||||
})
|
||||
}
|
||||
|
||||
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
|
||||
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
|
||||
const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
|
||||
const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
|
||||
|
||||
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||
@@ -181,8 +178,7 @@ mod tests {
|
||||
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
||||
ok.split('.').nth(1).unwrap());
|
||||
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", ok.split('.').nth(1).unwrap());
|
||||
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||
}
|
||||
|
||||
@@ -191,16 +187,25 @@ mod tests {
|
||||
let keys = Keys::default();
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
||||
"a key not cached yet is fetched");
|
||||
let count = || {
|
||||
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
async { Ok(jwks()) }
|
||||
};
|
||||
assert_eq!(
|
||||
keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(),
|
||||
Some("rays@sdf1.net"),
|
||||
"a key not cached yet is fetched"
|
||||
);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
// A made-up key id straight after: not fetched again.
|
||||
let mut h = Header::new(Algorithm::RS256);
|
||||
h.kid = Some("nobody".into());
|
||||
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||
let stray = jsonwebtoken::encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||
let count = || {
|
||||
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
async { Ok(jwks()) }
|
||||
};
|
||||
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
||||
//! of each publisher's server for every visit, and still there when a publisher is not.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub fn dir() -> PathBuf {
|
||||
@@ -16,7 +19,7 @@ pub fn dir() -> PathBuf {
|
||||
// one in 10^12. Move to a SHA if either ever matters.
|
||||
pub fn path(url: &str) -> PathBuf {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let mut h = DefaultHasher::new();
|
||||
url.hash(&mut h);
|
||||
dir().join(format!("{:016x}", h.finish()))
|
||||
}
|
||||
@@ -25,8 +28,8 @@ pub fn path(url: &str) -> PathBuf {
|
||||
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
|
||||
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
|
||||
let body = std::fs::read(file).ok()?;
|
||||
if let Ok(f) = std::fs::File::options().write(true).open(file) {
|
||||
let _ = f.set_modified(std::time::SystemTime::now());
|
||||
if let Ok(f) = File::options().write(true).open(file) {
|
||||
let _ = f.set_modified(SystemTime::now());
|
||||
}
|
||||
Some((kind, body))
|
||||
}
|
||||
@@ -75,7 +78,7 @@ pub fn fail(url: &str, why: &str) {
|
||||
/// Returns how many went.
|
||||
pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
||||
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
|
||||
let mut kept: Vec<(SystemTime, u64, PathBuf)> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_none())
|
||||
@@ -97,6 +100,8 @@ pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||
gone += 1;
|
||||
}
|
||||
}
|
||||
crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed);
|
||||
crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
gone
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
//! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated
|
||||
//! address in `Cf-Access-Authenticated-User-Email`.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
|
||||
use argon2::password_hash::phc::PasswordHash;
|
||||
use argon2::password_hash::{PasswordHasher, PasswordVerifier};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The
|
||||
/// salt is generated per password by the hasher itself.
|
||||
@@ -25,35 +30,39 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
|
||||
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
|
||||
return false;
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
||||
}
|
||||
|
||||
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
||||
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
|
||||
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
|
||||
static CHECKS: std::sync::LazyLock<tokio::sync::Semaphore> = std::sync::LazyLock::new(|| {
|
||||
tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)))
|
||||
static CHECKS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
|
||||
Arc::new(Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1))))
|
||||
});
|
||||
|
||||
/// What a name that is not an account is checked against, so that refusing it takes as long as
|
||||
/// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the
|
||||
/// time told anyone which names are accounts here (#138).
|
||||
static DECOY: std::sync::LazyLock<String> =
|
||||
std::sync::LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
|
||||
static DECOY: LazyLock<String> =
|
||||
LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
|
||||
|
||||
/// A sign-in's password against the account's hash, or against `DECOY` when there is no account
|
||||
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
|
||||
/// a time; see `CHECKS`.
|
||||
pub async fn check_password(password: String, stored: Option<String>) -> bool {
|
||||
let Ok(_turn) = CHECKS.acquire().await else { return false };
|
||||
tokio::task::spawn_blocking(move || match stored {
|
||||
let Ok(turn) = CHECKS.clone().acquire_owned().await else { return false };
|
||||
// The turn goes with the check, not with this future: a client that hung up dropped the
|
||||
// future, freeing the turn while the check ran on, so disconnecting sign-ins ran any number
|
||||
// at once (#324).
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _turn = turn;
|
||||
match stored {
|
||||
Some(h) => verify_password(&password, &h),
|
||||
None => {
|
||||
verify_password(&password, &DECOY);
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
@@ -84,7 +93,7 @@ pub fn api_token_hash(token: &str) -> String {
|
||||
|
||||
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
|
||||
use std::io::Read;
|
||||
std::fs::File::open("/dev/urandom")?.read_exact(buf)
|
||||
File::open("/dev/urandom")?.read_exact(buf)
|
||||
}
|
||||
|
||||
/// A username taken from a proxy header. Cloudflare sends an email address; the local part
|
||||
@@ -101,6 +110,36 @@ pub fn name_from_header(raw: &str) -> Option<String> {
|
||||
Some(name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The first admin's password: `IPX_ADMIN_PASSWORD` when set, or a random one written to
|
||||
/// `admin-password` beside config.toml, readable by the daemon's user alone. Every install began
|
||||
/// as admin / ipodderx, published in the README, and nothing made anyone change it (#321).
|
||||
/// Returns the password and where it can be found, for the log, which never shows it.
|
||||
pub(crate) fn first_password(config_dir: &Path, from_env: Option<String>) -> Result<(String, String)> {
|
||||
if let Some(p) = from_env.filter(|p| !p.is_empty()) {
|
||||
return Ok((p, "with the password in IPX_ADMIN_PASSWORD".into()));
|
||||
}
|
||||
let password = crate::auth::new_session_token()[..24].to_owned();
|
||||
let file = config_dir.join("admin-password");
|
||||
write_private(&file, &password)?;
|
||||
Ok((password, format!("with the password in {}", file.display())))
|
||||
}
|
||||
|
||||
/// Writes a secret only its owner may read: created 0600, never readable for a moment between.
|
||||
pub(crate) fn write_private(file: &Path, text: &str) -> Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
|
||||
let _ = std::fs::remove_file(file);
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(file)
|
||||
.with_context(|| format!("writing {}", file.display()))?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -156,4 +195,28 @@ mod tests {
|
||||
assert_eq!(name_from_header("ray\nadmin"), None);
|
||||
assert_eq!(name_from_header("ray;admin"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_admin_gets_a_password_nobody_else_knows() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("ipx-first-password-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
let (a, told) = first_password(&dir, None).unwrap();
|
||||
let file = dir.join("admin-password");
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), a);
|
||||
assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert!(told.contains("admin-password") && !told.contains(&a), "the log says where, not what: {told}");
|
||||
assert_eq!(a.len(), 24);
|
||||
assert_ne!(a, "ipodderx");
|
||||
assert_ne!(first_password(&dir, None).unwrap().0, a, "each new database gets its own");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let (b, told) = first_password(&dir, Some("chosen".into())).unwrap();
|
||||
assert_eq!(b, "chosen");
|
||||
assert!(!told.contains("chosen"));
|
||||
assert!(!file.exists(), "a password set in the environment is not written anywhere");
|
||||
assert_eq!(first_password(&dir, Some(String::new())).unwrap().0.len(), 24, "empty means unset");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
616
crates/ipx/src/catalogue.rs
Normal file
616
crates/ipx/src/catalogue.rs
Normal file
@@ -0,0 +1,616 @@
|
||||
//! The feeds the server knows and who reads them: adding, removing, leaving, and what an
|
||||
//! OPML or Patreon list leaves behind when it goes.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Event;
|
||||
use crate::{cli, config, db, feed, lists};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use url::Url;
|
||||
|
||||
/// Subscribes to one feed, naming it from its own title.
|
||||
pub(crate) async fn add(
|
||||
ctx: &Ctx,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
category: Option<String>,
|
||||
list: bool,
|
||||
) -> Result<()> {
|
||||
let url = feed::find_feed(&ctx.client, &feed::expand_input(url)).await?;
|
||||
let (id, existed) = add_to_catalogue(ctx, &url, folder, keywords).await?;
|
||||
// Listing a feed the catalogue already has, or giving it a category, is the point of asking
|
||||
// again: it changes those, and nothing else. A feed an OPML lists has no entry to change.
|
||||
if list || category.is_some() {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
if let Some(f) = cfg.feeds.get_mut(&id) {
|
||||
f.listed |= list;
|
||||
if category.is_some() {
|
||||
f.category = category;
|
||||
}
|
||||
ctx.store_cfg(cfg).await?;
|
||||
if existed {
|
||||
println!("{id} is already in the catalogue; updated its Directory listing");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if existed {
|
||||
anyhow::bail!("already subscribed as {id:?}");
|
||||
}
|
||||
println!("added {id}{}", if list { ", listed in the Directory" } else { "" });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
|
||||
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add`
|
||||
/// and the page's Add feed alike, which were the same steps written twice (#161); each finds the
|
||||
/// feed first, to say its own way why an address is not one.
|
||||
pub async fn add_to_catalogue(
|
||||
ctx: &Ctx,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
) -> Result<(String, bool)> {
|
||||
// Taken before the duplicate check, so two people adding the same feed at once add it once.
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
if let Some(existing) = subscriptions(ctx).await?.into_iter().find(|s| feed::same_feed(&s.cfg.url, url)) {
|
||||
return Ok((existing.id, true));
|
||||
}
|
||||
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
||||
ctx.store_cfg(cfg).await?;
|
||||
Ok((id, false))
|
||||
}
|
||||
|
||||
/// Returns the new feed id. Adding checks the address is a feed first (`feed::find_feed`); this
|
||||
/// still names one it cannot read from its URL rather than failing.
|
||||
pub(crate) async fn add_one(
|
||||
ctx: &Ctx,
|
||||
cfg: &mut config::Config,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
) -> Result<String> {
|
||||
let probe = config::Feed {
|
||||
url: url.to_owned(),
|
||||
folder: folder.clone(),
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: keywords.clone(),
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
|
||||
let title = match feed::fetch(&ctx.feed_client, &probe.url, None, None, None).await.map(|(got, _)| got) {
|
||||
// An OPML subscription is named from its own <head><title>, not by trying to
|
||||
// parse it as a feed and falling back to the hostname.
|
||||
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
|
||||
feed::opml_title(&bytes).unwrap_or_else(|| url_stem(url))
|
||||
}
|
||||
Ok(feed::Fetched::Body { bytes, .. }) => {
|
||||
feed::parse(&bytes).ok().and_then(|f| f.title).unwrap_or_else(|| url_stem(url))
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(url, "could not read the feed; naming it from its URL");
|
||||
url_stem(url)
|
||||
}
|
||||
};
|
||||
|
||||
// Slugs must be unique across derived feeds too, or a new feed can collide with one
|
||||
// an OPML already introduced.
|
||||
let mut taken: BTreeMap<String, config::Feed> =
|
||||
subscriptions(ctx).await?.into_iter().map(|s| (s.id, s.cfg)).collect();
|
||||
// A removed feed keeps its rows, so its id is only free again for the same feed: re-adding
|
||||
// it gets its history back, and a different feed does not inherit someone else's.
|
||||
for (id, other) in ctx.db.feed_urls().await? {
|
||||
if !feed::same_feed(&other, url) {
|
||||
taken.entry(id).or_insert_with(|| probe.clone());
|
||||
}
|
||||
}
|
||||
let id = config::unique_slug(&title, |s| taken.contains_key(s));
|
||||
cfg.feeds.insert(id.clone(), probe);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Host plus last path segment, for naming a feed we could not read.
|
||||
pub(crate) fn url_stem(url: &str) -> String {
|
||||
Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_owned)).unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
let Some(parent) = cfg.feeds.remove(feed) else {
|
||||
// Derived from an OPML: drop it here, though the subscription will list it again
|
||||
// on the next read unless the OPML itself goes.
|
||||
ctx.db.drop_managed(feed).await?;
|
||||
println!("removed {feed}; it came from an OPML subscription and may return on the next read");
|
||||
return Ok(());
|
||||
};
|
||||
ctx.store_cfg(cfg).await?;
|
||||
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
||||
println!("removed {feed}; downloads and history kept");
|
||||
retire_group(ctx, feed, Some(&parent)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
let text = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
|
||||
// The CLI speaks for the operator, as the shared web token does.
|
||||
let admin = ctx
|
||||
.db
|
||||
.users()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|u| u.is_admin)
|
||||
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
|
||||
let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
|
||||
let (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&doc)?, admin.id).await?;
|
||||
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The configuration ipx runs with: config.toml for where things are and who may sign in, the
|
||||
/// database for the feeds and the server settings (issue #18). The first time a database holds
|
||||
/// neither, it takes them from config.toml, which is then cut down to the rest, the original kept
|
||||
/// beside it as config.toml.pre-database.
|
||||
pub(crate) async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &Path) -> Result<config::Config> {
|
||||
for _ in 0..2 {
|
||||
if let Some((stored, feeds)) = db.stored_config().await? {
|
||||
if config::Config::file_holds_stored(path) {
|
||||
tracing::warn!(
|
||||
"config.toml still lists feeds or server settings; they are ignored, since the \
|
||||
database holds them now. Change them in the web UI, or with ipx add and rm."
|
||||
);
|
||||
}
|
||||
stored.apply(&mut cfg);
|
||||
cfg.feeds = feeds;
|
||||
return Ok(cfg);
|
||||
}
|
||||
if db.import_config(&cfg).await? {
|
||||
if path.exists() {
|
||||
let original = path.with_extension("toml.pre-database");
|
||||
if !original.exists() {
|
||||
std::fs::copy(path, &original)
|
||||
.with_context(|| format!("keeping the original as {}", original.display()))?;
|
||||
}
|
||||
cfg.save_bootstrap(path)?;
|
||||
}
|
||||
tracing::info!(
|
||||
feeds = cfg.feeds.len(),
|
||||
"moved the feeds and server settings from config.toml into the database"
|
||||
);
|
||||
return Ok(cfg);
|
||||
}
|
||||
// Another ipx imported between our look and our insert; the next pass reads its copy.
|
||||
}
|
||||
anyhow::bail!("the database says it holds the configuration and then that it does not")
|
||||
}
|
||||
|
||||
pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
||||
let mut doc = opml::OPML {
|
||||
head: Some(opml::Head { title: Some("ipx subscriptions".into()), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
for (id, feed) in &ctx.cfg().feeds {
|
||||
let title = ctx.db.feed_summary(id).await.ok().and_then(|s| s.title).unwrap_or_else(|| id.clone());
|
||||
doc.add_feed(&title, &feed.url);
|
||||
}
|
||||
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
|
||||
std::fs::write(file, xml).with_context(|| format!("writing {}", file.display()))?;
|
||||
println!("exported {} feed(s) to {}", ctx.cfg().feeds.len(), file.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list(ctx: &Ctx) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
if cfg.feeds.is_empty() {
|
||||
println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI.");
|
||||
return Ok(());
|
||||
}
|
||||
for (id, feed) in &cfg.feeds {
|
||||
let s = ctx.db.feed_summary(id).await?;
|
||||
// The id on a line of its own, labelled: first on the title's line, antirez.com's id
|
||||
// "feed" read as a heading and 'ipx fetch antirez' was tried instead (issue #82).
|
||||
println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id));
|
||||
println!(" id {id}");
|
||||
println!(" url {}", feed.url);
|
||||
println!(" last checked {}", cli::ago(s.last_checked));
|
||||
println!(" entries {} ({} downloaded)", s.entries, s.downloaded);
|
||||
if let Some(err) = &s.last_error {
|
||||
println!(" last error {err}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes feeds nobody subscribes to that are dead (failing for 30 days) or quiet (nothing new
|
||||
/// in a year), from the catalogue and the database, so the Directory lists feeds worth taking.
|
||||
/// A feed listed in it with no subscribers stays for as long as it works and publishes.
|
||||
pub(crate) async fn clean_directory(ctx: &Ctx) -> Result<()> {
|
||||
const DEAD: i64 = 30 * 86_400;
|
||||
const QUIET: i64 = 365 * 86_400;
|
||||
let now = db::now();
|
||||
// Once a day, for feeds dead a month or quiet a year: before every scan, its query took 1 to
|
||||
// 12 s every few minutes, and the scans and downloads waited behind it (#193).
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
if now - ctx.directory_cleaned.load(Relaxed) < 86_400 {
|
||||
return Ok(());
|
||||
}
|
||||
ctx.directory_cleaned.store(now, Relaxed);
|
||||
let stale = ctx.db.stale_unsubscribed(now - DEAD, now - QUIET).await?;
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
// Every one removed, so not `any`, which would stop at the first.
|
||||
let mut removed = std::collections::HashMap::new();
|
||||
for id in &stale {
|
||||
if let Some(f) = cfg.feeds.remove(id) {
|
||||
removed.insert(id.clone(), f);
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
ctx.store_cfg(cfg).await?;
|
||||
}
|
||||
for id in &stale {
|
||||
retire_group(ctx, id, removed.get(id)).await?;
|
||||
ctx.db.forget_feed(id).await?;
|
||||
tracing::info!(
|
||||
feed = id,
|
||||
"removed a feed nobody subscribes to that is dead or has published nothing in a year"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One feed to scan: either an entry you wrote in config.toml, or one derived from an
|
||||
/// OPML subscription and held only in the database.
|
||||
pub struct Sub {
|
||||
pub id: String,
|
||||
pub cfg: config::Feed,
|
||||
/// True when it came from an OPML and has no config entry of its own.
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
/// Everything to scan: your config entries, plus whatever the OPML subscriptions listed.
|
||||
///
|
||||
/// A derived feed borrows its parent's settings wholesale. That is why it needs no config
|
||||
/// entry -- there is nothing to store but its URL and where it came from.
|
||||
pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||
let cfg = ctx.cfg();
|
||||
let mut out: Vec<Sub> =
|
||||
cfg.feeds.iter().map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false }).collect();
|
||||
|
||||
// A group's title, once a group: asked for each feed in it, three queries a feed, it was about
|
||||
// 96 for The Glass Cannon Network's 32 shows on every feed list, live update and scan (#159).
|
||||
let mut group_titles = std::collections::HashMap::<String, String>::new();
|
||||
for m in ctx.db.managed_feeds().await? {
|
||||
if cfg.feeds.contains_key(&m.id) {
|
||||
continue; // promoted to config at some point; that entry wins
|
||||
}
|
||||
let parent = cfg.feeds.get(&m.group_id);
|
||||
if parent.is_none() {
|
||||
// The OPML or Patreon feed this was derived from is no longer in config --
|
||||
// removing it should have retired these rows too (see `retire_group`), but
|
||||
// skip them here regardless so a row that slips through is never scanned.
|
||||
continue;
|
||||
}
|
||||
let base = match parent.and_then(|p| p.folder.clone()) {
|
||||
Some(folder) => folder,
|
||||
None => {
|
||||
if !group_titles.contains_key(&m.group_id) {
|
||||
let title = ctx.db.feed_summary(&m.group_id).await.ok().and_then(|s| s.title);
|
||||
group_titles.insert(m.group_id.clone(), title.unwrap_or_else(|| m.group_id.clone()));
|
||||
}
|
||||
group_titles[&m.group_id].clone()
|
||||
}
|
||||
};
|
||||
let title = m.title.clone().unwrap_or_else(|| m.id.clone());
|
||||
let (username, password, password_env) = parent.map(|p| p.credentials_for(&m.url)).unwrap_or_default();
|
||||
out.push(Sub {
|
||||
id: m.id.clone(),
|
||||
cfg: config::Feed {
|
||||
url: m.url.clone(),
|
||||
folder: Some(format!("{base}/{title}")),
|
||||
group: Some(m.group_id.clone()),
|
||||
media_types: parent.and_then(|p| p.media_types.clone()),
|
||||
schedule: parent.and_then(|p| p.schedule.clone()),
|
||||
keywords: parent.map(|p| p.keywords.clone()).unwrap_or_default(),
|
||||
allow_explicit: parent.is_some_and(|p| p.allow_explicit),
|
||||
auto_download: parent.is_none_or(|p| p.auto_download),
|
||||
max_new_per_check: parent.and_then(|p| p.max_new_per_check),
|
||||
username,
|
||||
password,
|
||||
password_env,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
managed: true,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's
|
||||
/// lists are left alone.
|
||||
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
|
||||
use tracing::Instrument;
|
||||
ctx.db.unsubscribe(user_id, id).instrument(db::step("unsubscribe")).await?;
|
||||
// What they kept stays on their list, a feed of its own once nobody reads the list (#280).
|
||||
let kept = ctx.db.kept_feeds(user_id).instrument(db::step("kept")).await?;
|
||||
let all = subscriptions(ctx).instrument(db::step("subscriptions")).await?;
|
||||
let unkept = all.iter().filter(|s| s.cfg.group.as_deref() == Some(id) && !kept.contains(&s.id));
|
||||
for child in unkept {
|
||||
ctx.db.unsubscribe(user_id, &child.id).instrument(db::step("unsubscribe")).await?;
|
||||
}
|
||||
if ctx.db.subscriber_counts().instrument(db::step("subscriber_counts")).await?.contains_key(id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
||||
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
||||
// admin listed stays in the Directory, for the next person.
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
if cfg.feeds.get(id).is_some_and(|f| f.listed) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(parent) = cfg.feeds.remove(id) else {
|
||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||
// read unless you unsubscribe from the OPML itself.
|
||||
return ctx.db.drop_managed(id).await;
|
||||
};
|
||||
ctx.store_cfg(cfg).await?;
|
||||
retire_group(ctx, id, Some(&parent)).await
|
||||
}
|
||||
|
||||
/// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys
|
||||
/// took the subscriptions with the account, but not the feed: one only they read stayed in the
|
||||
/// catalogue with nobody subscribed, downloading every new episode for no one (#157).
|
||||
pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> {
|
||||
let catalogue = subscriptions(ctx).await?;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
for id in &mine {
|
||||
// A feed in a group of theirs goes with the group.
|
||||
let in_theirs = catalogue.iter().any(|s| &s.id == id && s.cfg.group.as_ref().is_some_and(|g| mine.contains(g)));
|
||||
if !in_theirs {
|
||||
leave(ctx, user_id, id).await?;
|
||||
}
|
||||
}
|
||||
ctx.db.delete_user(user_id).await
|
||||
}
|
||||
|
||||
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
|
||||
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
|
||||
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
|
||||
/// itself is removed, since `subscriptions()` would otherwise keep scanning them under a
|
||||
/// fallback policy meant for a feed with no parent at all. A feed promoted to config is not
|
||||
/// derived any more, so it is only unmanaged. `parent` is the list's settings as they were before
|
||||
/// it was removed, None when that is no longer known.
|
||||
pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Feed>) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
let managed = ctx.db.managed_feeds().await?;
|
||||
let derived = managed.into_iter().filter(|m| m.group_id == parent_id);
|
||||
for m in derived {
|
||||
if cfg.feeds.contains_key(&m.id) {
|
||||
// Scanned from its config entry and still read. Dropped as derived, its stored
|
||||
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
||||
ctx.db.unmanage(&m.id).await?;
|
||||
} else if promote_kept(ctx, &m, parent).await? {
|
||||
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
|
||||
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||
ctx.db.set_orphaned(&m.id, true).await?;
|
||||
} else {
|
||||
ctx.db.drop_managed(&m.id).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes a feed from an OPML or Patreon list a feed of its own, for those who kept it (#280): a
|
||||
/// catalogue entry in no group, which the list no longer adds or takes away. Whoever had it only
|
||||
/// through the list, without keeping it, loses it, as they would have when the list dropped it.
|
||||
/// False, changing nothing, when nobody kept it. `base` is the list's settings: most callers have
|
||||
/// removed the list from config by now, so they pass them.
|
||||
pub(crate) async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&config::Feed>) -> Result<bool> {
|
||||
let subs = ctx.db.subscribers_of(&m.id).await?;
|
||||
if !subs.iter().any(|(_, kept)| *kept) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
// The list's own settings carry over; its folder and group do not.
|
||||
let (username, password, password_env) = base.map(|p| p.credentials_for(&m.url)).unwrap_or_default();
|
||||
cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
|
||||
url: m.url.clone(),
|
||||
folder: None,
|
||||
group: None,
|
||||
media_types: base.and_then(|p| p.media_types.clone()),
|
||||
schedule: base.and_then(|p| p.schedule.clone()),
|
||||
keywords: base.map(|p| p.keywords.clone()).unwrap_or_default(),
|
||||
allow_explicit: base.is_some_and(|p| p.allow_explicit),
|
||||
auto_download: base.is_none_or(|p| p.auto_download),
|
||||
max_new_per_check: base.and_then(|p| p.max_new_per_check),
|
||||
username,
|
||||
password,
|
||||
password_env,
|
||||
category: None,
|
||||
listed: false,
|
||||
// Looked up after the list was removed, private was always false, and a private OPML's
|
||||
// kept show went to the directory (#332). A list no longer known may have been private.
|
||||
private: base.is_none_or(|p| p.private),
|
||||
});
|
||||
ctx.store_cfg(cfg).await?;
|
||||
ctx.db.unmanage(&m.id).await?;
|
||||
ctx.db.set_orphaned(&m.id, false).await?;
|
||||
for (user, kept) in subs {
|
||||
if !kept {
|
||||
ctx.db.unsubscribe(user, &m.id).await?;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Retires every group whose parent is gone from config, and returns how many derived rows that
|
||||
/// dropped or unmanaged. An OPML removed before `retire_group` existed left its feeds behind:
|
||||
/// davewiner's 922 were skipped by every scan and never cleared, and their stale errors were
|
||||
/// most of the ones stored.
|
||||
pub(crate) async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
||||
let cfg = ctx.cfg();
|
||||
let before = ctx.db.managed_feeds().await?;
|
||||
let stranded: std::collections::BTreeSet<&str> =
|
||||
before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect();
|
||||
for group in stranded {
|
||||
retire_group(ctx, group, None).await?;
|
||||
}
|
||||
Ok(before.len() - ctx.db.managed_feeds().await?.len())
|
||||
}
|
||||
|
||||
/// A feed that answered from a new address after permanent redirects moves there in the
|
||||
/// catalogue, so it is read from there and no longer redirected every time. One an OPML lists
|
||||
/// is left alone, since the OPML would only put the old address back; so is a move onto an
|
||||
/// address another feed already has.
|
||||
pub(crate) async fn follow_move(ctx: &Ctx, id: &str, to: &str) -> Result<()> {
|
||||
// Said in the event below, not logged here: the event is the log line.
|
||||
if subscriptions(ctx).await?.iter().any(|s| s.id != id && feed::same_feed(&s.cfg.url, to)) {
|
||||
tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it");
|
||||
return Ok(());
|
||||
}
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) };
|
||||
let from = std::mem::replace(&mut f.url, to.to_owned());
|
||||
ctx.store_cfg(cfg).await?;
|
||||
ctx.out.emit(Event::FeedMoved { feed: id.to_owned(), from, to: to.to_owned() });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
|
||||
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[general]\nschedule = \"every 2h\"\n[web]\ntoken = \"t\"\n[feeds.show]\nurl = \"http://x/show.xml\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let db = db::Db::memory().await.unwrap();
|
||||
|
||||
let first = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
|
||||
assert_eq!(first.feeds.keys().collect::<Vec<_>>(), ["show"]);
|
||||
assert_eq!(first.general.schedule, "every 2h");
|
||||
assert!(dir.join("config.toml.pre-database").exists(), "the original is kept");
|
||||
assert!(!config::Config::file_holds_stored(&path), "and the file no longer lists them");
|
||||
|
||||
// The next start reads them from the database, the trimmed file notwithstanding.
|
||||
let next = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
|
||||
assert_eq!(next.feeds.keys().collect::<Vec<_>>(), ["show"]);
|
||||
assert_eq!(next.general.schedule, "every 2h");
|
||||
assert_eq!(next.web.token, "t");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_feeds_leave_the_directory_once_a_day_not_before_every_scan() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let dead = |id: &str| {
|
||||
format!("INSERT INTO feeds (id, url, error_since, managed) VALUES ('{id}', 'u-{id}', 100, false)")
|
||||
};
|
||||
let listed = async || ctx.db.feed_urls().await.unwrap().into_iter().map(|(id, _)| id).collect::<Vec<_>>();
|
||||
ctx.db.sql(&dead("a")).await.unwrap();
|
||||
clean_directory(&ctx).await.unwrap();
|
||||
assert!(listed().await.is_empty(), "the first pass since starting looks");
|
||||
ctx.db.sql(&dead("b")).await.unwrap();
|
||||
clean_directory(&ctx).await.unwrap();
|
||||
assert_eq!(listed().await, ["b"], "not again the same day (#193)");
|
||||
ctx.directory_cleaned.store(db::now() - 86_401, std::sync::atomic::Ordering::Relaxed);
|
||||
clean_directory(&ctx).await.unwrap();
|
||||
assert!(listed().await.is_empty(), "a day on, it looks again");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_removed_account_takes_the_feeds_only_it_read_with_it() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
for id in ["only-theirs", "shared", "listed"] {
|
||||
cfg.feeds.insert(id.into(), config::Feed { listed: id == "listed", ..feed() });
|
||||
}
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
let gone = ctx.db.create_user("gone", None, false).await.unwrap();
|
||||
let stays = ctx.db.create_user("stays", None, true).await.unwrap();
|
||||
for id in ["only-theirs", "shared", "listed"] {
|
||||
ctx.db.subscribe(gone, id).await.unwrap();
|
||||
}
|
||||
ctx.db.subscribe(stays, "shared").await.unwrap();
|
||||
|
||||
remove_user(&ctx, gone).await.unwrap();
|
||||
assert!(ctx.db.user_by_id(gone).await.unwrap().is_none());
|
||||
// Nobody reads it now: out of the catalogue, not downloading for no one (#157).
|
||||
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["listed", "shared"]);
|
||||
assert!(ctx.db.subscription(stays, "shared").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() {
|
||||
// davewiner: the OPML subscription left config.toml, but its 922 derived rows
|
||||
// stayed in the database and kept being scanned under the no-parent fallback.
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
ctx.db.upsert_managed("child", "http://x/child.xml", "Child", "gone-opml").await.unwrap();
|
||||
assert!(
|
||||
subscriptions(&ctx).await.unwrap().iter().all(|s| s.id != "child"),
|
||||
"a derived feed whose parent is gone from config must not be scanned"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
ctx.db.upsert_managed("empty", "http://x/empty.xml", "Empty", "parent").await.unwrap();
|
||||
ctx.db.upsert_managed("has-file", "http://x/has-file.xml", "Has File", "parent").await.unwrap();
|
||||
let enc = feed::Enclosure { url: "http://x/ep.mp3".into(), mime: None, length: None };
|
||||
ctx.db.record_enclosure("has-file", "g1", &enc).await.unwrap();
|
||||
ctx.db.mark_downloaded(&enc.url, std::path::Path::new("/downloads/ep.mp3"), 1).await.unwrap();
|
||||
|
||||
retire_group(&ctx, "parent", None).await.unwrap();
|
||||
|
||||
let managed = ctx.db.managed_feeds().await.unwrap();
|
||||
assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten");
|
||||
assert!(managed.iter().any(|m| m.id == "has-file"), "has a file on disk, so it is kept");
|
||||
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
|
||||
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
|
||||
// promoted to config since still said managed = 1.
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert("promoted".into(), feed());
|
||||
cfg.feeds.insert("live-opml".into(), feed());
|
||||
let ctx = test_ctx(cfg).await;
|
||||
ctx.db.upsert_managed("promoted", "http://x/p.xml", "Promoted", "gone-opml").await.unwrap();
|
||||
ctx.db.upsert_managed("empty", "http://x/e.xml", "Empty", "gone-opml").await.unwrap();
|
||||
ctx.db.upsert_managed("listed", "http://x/l.xml", "Listed", "live-opml").await.unwrap();
|
||||
ctx.db.record_entry("promoted", &feed::Entry { guid: "g1".into(), ..Default::default() }).await.unwrap();
|
||||
|
||||
assert_eq!(retire_stranded(&ctx).await.unwrap(), 2, "empty dropped, promoted unmanaged");
|
||||
|
||||
let managed: Vec<String> = ctx.db.managed_feeds().await.unwrap().into_iter().map(|m| m.id).collect();
|
||||
assert_eq!(managed, ["listed"], "a group still in config is left alone");
|
||||
assert_eq!(ctx.db.feed_summary("promoted").await.unwrap().entries, 1, "its entries survive");
|
||||
}
|
||||
}
|
||||
234
crates/ipx/src/cli.rs
Normal file
234
crates/ipx/src/cli.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! The command line: its commands, and what each does when no daemon takes it.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Command as Cmd;
|
||||
use crate::{catalogue, daemon, db, metrics, retention, scan};
|
||||
use anyhow::Result;
|
||||
use chrono::DateTime;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ipx", version, about = "A headless podcatcher")]
|
||||
pub(crate) struct Cli {
|
||||
/// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml)
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) config: Option<PathBuf>,
|
||||
|
||||
/// Do the work here even if a daemon is running
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) local: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
/// Show configured feeds and their state
|
||||
List,
|
||||
/// Scan feeds for new entries
|
||||
Fetch {
|
||||
/// Only this feed id
|
||||
feed: Option<String>,
|
||||
/// Read the feed in full now, even when it is not due and has not changed
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Delete old or over-quota downloads
|
||||
Reap {
|
||||
/// Show what would go, delete nothing
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Counts of feeds, pending and downloaded enclosures
|
||||
Status,
|
||||
/// Subscribe to a feed
|
||||
Add {
|
||||
url: String,
|
||||
/// Download folder name (default: the feed title)
|
||||
#[arg(long)]
|
||||
folder: Option<String>,
|
||||
/// Only take enclosures matching these keywords
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
keywords: Vec<String>,
|
||||
/// The Directory's category, for a feed that names none of its own (News, Technology)
|
||||
#[arg(long)]
|
||||
category: Option<String>,
|
||||
/// Put it in the Directory for anyone to subscribe to, and keep it there when nobody does
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
},
|
||||
/// Unsubscribe. Downloads and history are left alone.
|
||||
Rm { feed: String },
|
||||
/// Add every feed in an OPML file
|
||||
Import { file: PathBuf },
|
||||
/// Write subscriptions out as OPML
|
||||
Export { file: PathBuf },
|
||||
/// Add, list or remove the accounts that can sign in to the web UI
|
||||
User {
|
||||
#[command(subcommand)]
|
||||
cmd: UserCmd,
|
||||
},
|
||||
/// Run the scheduler and serve the control socket
|
||||
Daemon {
|
||||
/// Serve the web UI on this address, overriding [web] in the config
|
||||
#[arg(long, value_name = "ADDR")]
|
||||
web: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum UserCmd {
|
||||
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray`
|
||||
Add {
|
||||
name: String,
|
||||
/// May manage other accounts, and is who the shared web token signs in as
|
||||
#[arg(long)]
|
||||
admin: bool,
|
||||
/// Sign-in comes from the proxy instead, so there is no password to set
|
||||
#[arg(long)]
|
||||
no_password: bool,
|
||||
},
|
||||
/// Show the accounts and how each one signs in
|
||||
List,
|
||||
/// Replace a password, read from stdin
|
||||
Passwd { name: String },
|
||||
/// Delete an account and everything it knows: its subscriptions and read state
|
||||
Rm { name: String },
|
||||
/// Rename an account, keeping its feeds, read state and admin rights. This is how an
|
||||
/// account made before the proxy takes the name the proxy signs it in as
|
||||
Rename { name: String, new_name: String },
|
||||
}
|
||||
|
||||
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
|
||||
/// listing.
|
||||
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
let read_password = || -> Result<String> {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
||||
if pw.is_empty() {
|
||||
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
|
||||
}
|
||||
Ok(pw)
|
||||
};
|
||||
|
||||
match cmd {
|
||||
UserCmd::Add { name, admin, no_password } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("a name is required");
|
||||
}
|
||||
if ctx.db.user_by_name(&name).await?.is_some() {
|
||||
anyhow::bail!("{name} already exists");
|
||||
}
|
||||
let hash = if no_password { None } else { Some(crate::auth::hash_password(&read_password()?)?) };
|
||||
// The first account runs the place; there is nobody else to grant it.
|
||||
let first = ctx.db.users().await?.is_empty();
|
||||
ctx.db.create_user(&name, hash.as_deref(), admin || first).await?;
|
||||
println!(
|
||||
"added {name}{}{}",
|
||||
if admin || first { " (admin)" } else { "" },
|
||||
if no_password { ", signs in through the proxy" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::List => {
|
||||
let users = ctx.db.users().await?;
|
||||
if users.is_empty() {
|
||||
println!("no accounts yet: ipx user add <name>");
|
||||
}
|
||||
for u in users {
|
||||
let added = u
|
||||
.created
|
||||
.and_then(|t| DateTime::from_timestamp(t, 0))
|
||||
.map_or("?".into(), |d| d.format("%Y-%m-%d").to_string());
|
||||
let seen = u.last_login.map_or("never signed in".into(), |t| format!("signed in {}", ago(Some(t))));
|
||||
println!(
|
||||
"{:<20} {:<6} {:<11} added {added} {seen}",
|
||||
u.name,
|
||||
if u.is_admin { "admin" } else { "" },
|
||||
if u.pass_hash.is_some() { "password" } else { "proxy only" },
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Passwd { name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?;
|
||||
println!("password changed for {name}, and every browser signed in as {name} signed out");
|
||||
let tokens = ctx.db.api_tokens(user.id).await?.len();
|
||||
if tokens > 0 {
|
||||
println!(
|
||||
"{tokens} API token(s) of {name}'s still work: remove any that may be someone else's in Settings"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Rename { name, new_name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
// The same rules as a name the proxy vouches for, or the proxy would never find it.
|
||||
let new_name = crate::auth::name_from_header(&new_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
|
||||
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
if ctx.db.user_by_name(&new_name).await?.is_some() {
|
||||
anyhow::bail!("{new_name} already exists");
|
||||
}
|
||||
ctx.db.rename_user(user.id, &new_name).await?;
|
||||
println!("renamed {name} to {new_name}");
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Rm { name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
catalogue::remove_user(ctx, user.id).await?;
|
||||
println!("removed {name}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||
// What the worker is on, for /metrics: the healthcheck answers while it is stuck (#195).
|
||||
let _job = metrics::STATS.job(match &cmd {
|
||||
Cmd::Fetch { .. } => "scan",
|
||||
Cmd::Reap { .. } => "reap",
|
||||
Cmd::Download { .. } => "download",
|
||||
Cmd::Status => "status",
|
||||
Cmd::Reload => "reload",
|
||||
});
|
||||
match cmd {
|
||||
Cmd::Fetch { feed, force, feeds } => {
|
||||
// Make room before pulling more down, as the original did per download.
|
||||
retention::reap(ctx, /* dry_run */ false, /* standalone */ false).await?;
|
||||
scan::fetch(ctx, feed.as_deref(), force, &feeds).await?;
|
||||
metrics::STATS.last_scan_done.store(db::now(), std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Reap { dry_run } => retention::reap(ctx, dry_run, /* standalone */ true).await,
|
||||
Cmd::Download { enclosure } => scan::download_one(ctx, enclosure).await,
|
||||
Cmd::Status => {
|
||||
ctx.out.emit(daemon::status(ctx).await);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Reload => daemon::reload(ctx).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ago(t: Option<i64>) -> String {
|
||||
let Some(t) = t else { return "never".into() };
|
||||
format!("{} ago", duration((db::now() - t).max(0) as u64))
|
||||
}
|
||||
|
||||
pub(crate) fn duration(secs: u64) -> String {
|
||||
match secs {
|
||||
s if s < 90 => format!("{s}s"),
|
||||
s if s < 5400 => format!("{}m", s / 60),
|
||||
s if s < 172_800 => format!("{}h", s / 3600),
|
||||
s => format!("{}d", s / 86_400),
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::Permissions;
|
||||
use std::path::{Path, PathBuf};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
@@ -42,6 +44,24 @@ pub struct General {
|
||||
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
|
||||
/// every publisher; the least recently shown goes first. 0 keeps none.
|
||||
pub art_cache_mb: u64,
|
||||
/// The directory service the Directory shows (#205), as `https://directory.ipxreader.app`. Empty
|
||||
/// for none: then the Directory says so, and feeds are added by their address.
|
||||
pub directory_url: String,
|
||||
/// The token the directory gave this server. Never sent to the page: it says only whether
|
||||
/// one is set.
|
||||
pub directory_token: String,
|
||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||
pub directory_share: bool,
|
||||
/// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than
|
||||
/// on each person's own (#271). Without that key it changes nothing.
|
||||
pub signals_on_server: bool,
|
||||
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a
|
||||
/// month of production's feeds was about 8,000.
|
||||
pub signal_days: u64,
|
||||
/// Internal addresses and networks feeds, episodes and artwork may still be fetched from, as
|
||||
/// `["192.168.1.20", "10.0.0.0/8"]`: everything off the public internet is refused otherwise,
|
||||
/// since a feed or anyone signed in names what is fetched (#320). Addresses, not names.
|
||||
pub fetch_internal: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -97,6 +117,9 @@ pub struct Web {
|
||||
pub sign_out_url: String,
|
||||
/// Sign a session out after this long without a request.
|
||||
pub session_days: i64,
|
||||
/// Where to serve /metrics for Prometheus (#194), on a thread of its own, without sign-in:
|
||||
/// `0.0.0.0:9187`. Empty serves none.
|
||||
pub metrics_bind: String,
|
||||
}
|
||||
|
||||
impl Default for Web {
|
||||
@@ -112,6 +135,7 @@ impl Default for Web {
|
||||
auto_create_users: true,
|
||||
sign_out_url: String::new(),
|
||||
session_days: 30,
|
||||
metrics_bind: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +194,10 @@ pub struct Feed {
|
||||
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub listed: bool,
|
||||
/// Never sent to the directory (#205), whatever its address looks like: someone's own
|
||||
/// access to a paid show that `feed::url_looks_private` does not recognise.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -188,19 +216,19 @@ impl Default for General {
|
||||
max_new_per_check: 3,
|
||||
media_types: vec!["audio".into(), "video".into()],
|
||||
art_cache_mb: 500,
|
||||
directory_url: String::new(),
|
||||
directory_token: String::new(),
|
||||
directory_share: false,
|
||||
signals_on_server: false,
|
||||
signal_days: 7,
|
||||
fetch_internal: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Torrent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
seed_ratio: 1.0,
|
||||
seed_time_mins: 60,
|
||||
port_range: "6881-6889".into(),
|
||||
stall_mins: 30,
|
||||
}
|
||||
Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +297,32 @@ impl Feed {
|
||||
}
|
||||
self.password.clone()
|
||||
}
|
||||
|
||||
/// What `feed::fetch` sends the feed's host: a username, and its password if there is one.
|
||||
pub fn auth(&self) -> Option<(&str, Option<String>)> {
|
||||
self.username.as_deref().map(|u| (u, self.password()))
|
||||
}
|
||||
|
||||
/// `auth()`, for a URL on the feed's own host only. The credential is for whoever issued it,
|
||||
/// and an enclosure, or a feed an OPML lists, can be on any host its author names (#327, #328).
|
||||
pub fn auth_for(&self, url: &str) -> Option<(&str, Option<String>)> {
|
||||
same_host(&self.url, url).then(|| self.auth()).flatten()
|
||||
}
|
||||
|
||||
/// The username, password and password_env a feed at `url` takes from this one, its OPML or
|
||||
/// Patreon list: all of them on the list's own host, none elsewhere.
|
||||
pub fn credentials_for(&self, url: &str) -> (Option<String>, Option<String>, Option<String>) {
|
||||
if same_host(&self.url, url) {
|
||||
(self.username.clone(), self.password.clone(), self.password_env.clone())
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_host(a: &str, b: &str) -> bool {
|
||||
let host = |u: &str| Url::parse(u).ok().and_then(|u| u.host_str().map(str::to_ascii_lowercase));
|
||||
host(a).is_some_and(|h| host(b).as_ref() == Some(&h))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -277,14 +331,11 @@ impl Config {
|
||||
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut cfg: Self =
|
||||
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||
@@ -301,6 +352,20 @@ pub struct Stored {
|
||||
/// Settings saved before it existed have none: they get the default.
|
||||
#[serde(default = "art_cache_mb")]
|
||||
pub art_cache_mb: u64,
|
||||
#[serde(default)]
|
||||
pub directory_url: String,
|
||||
#[serde(default)]
|
||||
pub directory_token: String,
|
||||
#[serde(default)]
|
||||
pub directory_share: bool,
|
||||
#[serde(default)]
|
||||
pub signals_on_server: bool,
|
||||
#[serde(default = "signal_days")]
|
||||
pub signal_days: u64,
|
||||
}
|
||||
|
||||
fn signal_days() -> u64 {
|
||||
General::default().signal_days
|
||||
}
|
||||
|
||||
fn art_cache_mb() -> u64 {
|
||||
@@ -308,8 +373,19 @@ fn art_cache_mb() -> u64 {
|
||||
}
|
||||
|
||||
/// `[general]` keys that live in the database once it holds the configuration.
|
||||
const STORED_KEYS: [&str; 6] =
|
||||
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"];
|
||||
const STORED_KEYS: [&str; 11] = [
|
||||
"schedule",
|
||||
"max_total_gb",
|
||||
"max_age_days",
|
||||
"max_new_per_check",
|
||||
"media_types",
|
||||
"art_cache_mb",
|
||||
"directory_url",
|
||||
"directory_token",
|
||||
"directory_share",
|
||||
"signals_on_server",
|
||||
"signal_days",
|
||||
];
|
||||
|
||||
impl Stored {
|
||||
pub fn of(cfg: &Config) -> Self {
|
||||
@@ -321,6 +397,11 @@ impl Stored {
|
||||
max_new_per_check: g.max_new_per_check,
|
||||
media_types: g.media_types.clone(),
|
||||
art_cache_mb: g.art_cache_mb,
|
||||
directory_url: g.directory_url.clone(),
|
||||
directory_token: g.directory_token.clone(),
|
||||
directory_share: g.directory_share,
|
||||
signals_on_server: g.signals_on_server,
|
||||
signal_days: g.signal_days,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +413,11 @@ impl Stored {
|
||||
g.max_new_per_check = self.max_new_per_check;
|
||||
g.media_types = self.media_types;
|
||||
g.art_cache_mb = self.art_cache_mb;
|
||||
g.directory_url = self.directory_url;
|
||||
g.directory_token = self.directory_token;
|
||||
g.directory_share = self.directory_share;
|
||||
g.signals_on_server = self.signals_on_server;
|
||||
g.signal_days = self.signal_days;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +458,7 @@ fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
std::fs::set_permissions(path, Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -438,20 +524,17 @@ pub fn slug(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// `slug`, with a numeric suffix if that id is taken.
|
||||
pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
|
||||
pub fn unique_slug(text: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
let base = slug(text);
|
||||
if !taken.contains_key(&base) {
|
||||
if !taken(&base) {
|
||||
return base;
|
||||
}
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap()
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).expect("an unending count finds a free name")
|
||||
}
|
||||
|
||||
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
|
||||
fn xdg(var: &str, fallback: &str) -> PathBuf {
|
||||
std::env::var_os(var)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home().join(fallback))
|
||||
std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home().join(fallback))
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
@@ -469,6 +552,22 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feeds_password_goes_to_its_own_host_alone() {
|
||||
let paid = Feed {
|
||||
url: "https://Members.example.com/feed.xml".into(),
|
||||
username: Some("me".into()),
|
||||
password: Some("pw".into()),
|
||||
..toml::from_str("url = 'x'").unwrap()
|
||||
};
|
||||
assert_eq!(paid.auth_for("https://members.example.com/ep1.mp3"), Some(("me", Some("pw".into()))));
|
||||
assert_eq!(paid.auth_for("https://cdn.example.net/ep1.mp3"), None, "an enclosure elsewhere (#328)");
|
||||
assert_eq!(paid.auth_for("https://members.example.com.evil.net/x"), None);
|
||||
assert_eq!(paid.auth_for("not a url"), None);
|
||||
assert_eq!(paid.credentials_for("https://members.example.com/other.xml").0.as_deref(), Some("me"));
|
||||
assert_eq!(paid.credentials_for("https://elsewhere.org/feed.xml"), (None, None, None), "an OPML's feed (#327)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
||||
let cfg: Config = toml::from_str(
|
||||
@@ -530,10 +629,20 @@ mod tests {
|
||||
#[test]
|
||||
fn intervals_parse_from_the_forms_people_actually_type() {
|
||||
for (input, want) in [
|
||||
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
|
||||
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
|
||||
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
|
||||
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
|
||||
("every 30m", 30),
|
||||
("30m", 30),
|
||||
("30", 30),
|
||||
("every 30 minutes", 30),
|
||||
("every 4h", 240),
|
||||
("4h", 240),
|
||||
("4 hours", 240),
|
||||
("EVERY 4H", 240),
|
||||
("1d", 1440),
|
||||
("every 2 days", 2880),
|
||||
(" every 90m ", 90),
|
||||
("1w", 10080),
|
||||
("every 2 weeks", 20160),
|
||||
("2 w", 20160),
|
||||
] {
|
||||
assert_eq!(parse_interval(input), Some(want), "{input:?}");
|
||||
}
|
||||
@@ -605,12 +714,27 @@ mod tests {
|
||||
assert!(slug(&"x".repeat(100)).len() <= 40);
|
||||
|
||||
let mut taken = BTreeMap::new();
|
||||
taken.insert("the-daily".to_string(), Feed {
|
||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||
auto_download: true, max_new_per_check: None, username: None,
|
||||
password: None, password_env: None, category: None, listed: false,
|
||||
});
|
||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||
taken.insert(
|
||||
"the-daily".to_string(),
|
||||
Feed {
|
||||
url: "u".into(),
|
||||
folder: None,
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(unique_slug("The Daily", |s| taken.contains_key(s)), "the-daily-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -630,11 +754,13 @@ mod tests {
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||
|
||||
unsafe { std::env::set_var("IPX_TEST_PASS", "from-env") };
|
||||
f.password_env = Some("IPX_TEST_PASS".into());
|
||||
assert_eq!(f.password().as_deref(), Some("from-env"));
|
||||
// A variable every test run already has: setting one is unsafe while other tests'
|
||||
// threads read the environment.
|
||||
f.password_env = Some("PATH".into());
|
||||
assert_eq!(f.password(), std::env::var("PATH").ok(), "password_env wins over password");
|
||||
}
|
||||
}
|
||||
261
crates/ipx/src/ctx.rs
Normal file
261
crates/ipx/src/ctx.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! What every command shares: the configuration, the database, the clients, and the state the
|
||||
//! daemon's tasks hand each other.
|
||||
|
||||
use crate::ipc::{Emitter, Event};
|
||||
use crate::{config, db, directory, feed, scan, torrent};
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{MutexGuard, Notify, OnceCell, Semaphore};
|
||||
|
||||
/// Everything a command needs. One per process.
|
||||
pub struct Ctx {
|
||||
/// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks
|
||||
/// up feed changes without a restart. Callers take a snapshot; no guard is ever held
|
||||
/// across an await.
|
||||
pub cfg: std::sync::RwLock<Arc<config::Config>>,
|
||||
pub db: db::Db,
|
||||
/// For anything a feed or a person names: refuses internal addresses (#320).
|
||||
pub client: reqwest::Client,
|
||||
/// For the services this server is set up to call, which are on its own network by design:
|
||||
/// the directory, as `http://ipx-directory:8100`. Never for an address a feed or a person names.
|
||||
pub local_client: reqwest::Client,
|
||||
/// For feeds only, refusing internal addresses as `client` does: follows no redirects, so `feed::fetch` sees each hop and can tell a feed
|
||||
/// that moved for good from one sent elsewhere for now.
|
||||
pub feed_client: reqwest::Client,
|
||||
pub out: Emitter,
|
||||
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
|
||||
/// rude to do for a config that has never seen a torrent.
|
||||
pub torrents: OnceCell<torrent::Torrents>,
|
||||
/// Caps how many torrents run at once when they are detached.
|
||||
pub torrent_slots: Arc<Semaphore>,
|
||||
/// Needed because a subscribed OPML rewrites the feed list as it syncs.
|
||||
pub config_path: PathBuf,
|
||||
/// Run torrents off the command worker. A torrent takes minutes to fetch metadata and
|
||||
/// then seeds for up to an hour, and the worker is sequential -- inline, one torrent
|
||||
/// stops every feed scan, every HTTP download and every status command behind it.
|
||||
/// A one-shot CLI run keeps them inline, or the process would exit mid-download.
|
||||
pub detach_torrents: bool,
|
||||
/// Feeds whose host could not be looked up for the moment, and when to try them again: a
|
||||
/// failure that says nothing about the feed, kept off it and out of the database (#143).
|
||||
/// Forgotten on a restart, which only makes the retry sooner.
|
||||
pub retry: std::sync::Mutex<HashMap<String, i64>>,
|
||||
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
|
||||
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
|
||||
/// another add spent a minute fetching was gone once that one stored (#153).
|
||||
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
|
||||
// own, and a change in flight here can still store over theirs; take the copy from the
|
||||
// database rather than memory if that ever happens.
|
||||
pub cfg_write: tokio::sync::Mutex<()>,
|
||||
/// Wakes DeadAir's maker when a show is turned on, so its copies start at once instead of
|
||||
/// at the next pass (#171).
|
||||
pub deadair: Notify,
|
||||
/// Wakes the Signals judge when there are new items or a Signal is made or changed (#254).
|
||||
pub signals: Notify,
|
||||
/// People whose TypeSafe key was refused, passed over until they save another.
|
||||
pub signals_refused: std::sync::Mutex<HashSet<i64>>,
|
||||
/// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since
|
||||
/// starting.
|
||||
pub directory_cleaned: AtomicI64,
|
||||
/// Sends the snapshot to the directory now, as when its settings change (#205).
|
||||
pub directory_wake: Notify,
|
||||
/// When the snapshot last went and what came of it, for the admin page.
|
||||
pub directory_shared: std::sync::Mutex<Option<directory::Shared>>,
|
||||
/// Send the snapshot even if it has not changed: after an unblock or a dropped snapshot the
|
||||
/// directory needs it again, though nothing here differs (#206).
|
||||
pub directory_resend: AtomicBool,
|
||||
}
|
||||
|
||||
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
|
||||
pub struct CfgEdit<'a> {
|
||||
pub(crate) cfg: config::Config,
|
||||
pub(crate) _held: MutexGuard<'a, ()>,
|
||||
}
|
||||
|
||||
impl Deref for CfgEdit<'_> {
|
||||
type Target = config::Config;
|
||||
fn deref(&self) -> &config::Config {
|
||||
&self.cfg
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for CfgEdit<'_> {
|
||||
fn deref_mut(&mut self) -> &mut config::Config {
|
||||
&mut self.cfg
|
||||
}
|
||||
}
|
||||
|
||||
/// How soon a feed whose host could not be looked up is tried again (#143).
|
||||
pub(crate) const RETRY_LOOKUP_SECS: i64 = 600;
|
||||
|
||||
impl Ctx {
|
||||
/// Everything a command shares, built once at start: the clients refuse internal addresses
|
||||
/// (#320) but `local_client`, and a daemon sends its events to the socket and the page.
|
||||
pub(crate) fn new(
|
||||
cfg: config::Config,
|
||||
db: db::Db,
|
||||
config_path: PathBuf,
|
||||
is_daemon: bool,
|
||||
events: &broadcast::Sender<Event>,
|
||||
) -> Result<Self> {
|
||||
let guard = feed::guard::Guard::new(feed::guard::Allowed::parse(&cfg.general.fetch_internal)?);
|
||||
Ok(Ctx {
|
||||
cfg: std::sync::RwLock::new(Arc::new(cfg)),
|
||||
db,
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
// Connecting only, so it bounds a download's start, not a long download (#108).
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Two minutes without a byte is a stalled download, however long it is: unbounded, one
|
||||
// held the worker, and every scan behind it, for good, while the daemon read healthy (#152).
|
||||
.read_timeout(Duration::from_secs(120))
|
||||
.connector_layer(guard.clone())
|
||||
.build()?,
|
||||
local_client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()?,
|
||||
feed_client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(feed::FEED_SILENCE)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connector_layer(guard)
|
||||
.build()?,
|
||||
out: if is_daemon {
|
||||
Emitter::socket(events.clone(), /* print */ false)
|
||||
} else {
|
||||
Emitter::terminal()
|
||||
},
|
||||
torrents: OnceCell::new(),
|
||||
torrent_slots: Arc::new(Semaphore::new(2)),
|
||||
config_path,
|
||||
detach_torrents: is_daemon,
|
||||
retry: Default::default(),
|
||||
cfg_write: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// When a feed is next due, taking a lookup retry into account: not before it.
|
||||
pub(crate) fn due(
|
||||
&self,
|
||||
cfg: &config::Config,
|
||||
feed: &config::Feed,
|
||||
id: &str,
|
||||
state: &db::HttpState,
|
||||
subscribed: bool,
|
||||
) -> Option<i64> {
|
||||
let at = scan::due_at(cfg, feed, state, subscribed);
|
||||
match self.retry.lock().expect("a panic elsewhere poisoned this lock").get(id) {
|
||||
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
|
||||
None => at,
|
||||
}
|
||||
}
|
||||
|
||||
/// A scan's failure, recorded on the feed, unless it was only a moment's failure to look its
|
||||
/// host up: on 2026-10-05 a DNS outage on Tower failed 204 feeds in a minute with
|
||||
/// "Temporary failure in name resolution", and each, read once a day and backed off as a
|
||||
/// failing feed, sat a day failing, unread and labelled gone (#143). Tried again in minutes
|
||||
/// instead, with nothing on the feed to say it failed.
|
||||
pub(crate) async fn feed_failed(&self, id: &str, url: &str, msg: &str) -> Result<()> {
|
||||
if feed::is_temporary_lookup_failure(msg) {
|
||||
self.retry
|
||||
.lock()
|
||||
.expect("a panic elsewhere poisoned this lock")
|
||||
.insert(id.to_owned(), db::now() + RETRY_LOOKUP_SECS);
|
||||
return Ok(());
|
||||
}
|
||||
self.db.set_feed_error(id, url, msg).await
|
||||
}
|
||||
|
||||
pub fn cfg(&self) -> Arc<config::Config> {
|
||||
self.cfg.read().expect("a panic elsewhere poisoned this lock").clone()
|
||||
}
|
||||
|
||||
/// The config as it stands, to change and then keep with `store_cfg`; any other change waits
|
||||
/// until then.
|
||||
pub async fn edit_cfg(&self) -> CfgEdit<'_> {
|
||||
// A step of its own: waiting here is waiting on another change to the catalogue.
|
||||
let held = tracing::Instrument::instrument(self.cfg_write.lock(), db::step("cfg_lock")).await;
|
||||
CfgEdit { cfg: (*self.cfg()).clone(), _held: held }
|
||||
}
|
||||
|
||||
/// Keeps a changed catalogue or server settings: in the database, and for everything running
|
||||
/// here from now on. What config.toml's save and reload did, before the database held them.
|
||||
pub async fn store_cfg(&self, edit: CfgEdit<'_>) -> Result<()> {
|
||||
tracing::Instrument::instrument(self.db.store_config(&edit.cfg), db::step("store_config")).await?;
|
||||
self.set_cfg(edit.cfg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_cfg(&self, cfg: config::Config) {
|
||||
*self.cfg.write().expect("a panic elsewhere poisoned this lock") = Arc::new(cfg);
|
||||
tracing::info!("config reloaded");
|
||||
// A feed added, removed, made private or given a category, or the directory's own
|
||||
// settings changed: the directory hears of it now, not at the next half hour (#205).
|
||||
self.directory_wake.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) async fn torrents(&self) -> Result<&torrent::Torrents> {
|
||||
let cfg = self.cfg();
|
||||
self.torrents.get_or_try_init(|| torrent::Torrents::new(&cfg)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_changes_to_the_catalogue_at_once_both_keep() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
// A slow change, as adding a feed is while it fetches, and a quick one made meanwhile.
|
||||
let slow = {
|
||||
let ctx = ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
cfg.feeds.insert("a".into(), feed());
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
})
|
||||
};
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
cfg.feeds.insert("b".into(), feed());
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
slow.await.unwrap();
|
||||
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["a", "b"], "neither stored over the other (#153)");
|
||||
let (_, stored) = ctx.db.stored_config().await.unwrap().unwrap();
|
||||
assert_eq!(stored.keys().collect::<Vec<_>>(), ["a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_moments_lookup_failure_is_tried_again_soon_and_not_held_against_the_feed() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||
ctx.feed_failed("npr", "https://feeds.npr.org/x", blip).await.unwrap();
|
||||
// Nothing on the feed: no error, no run of failures to back off from or call it gone by.
|
||||
let s = ctx.db.feed_summary("npr").await.unwrap();
|
||||
assert!(s.last_error.is_none() && s.error_since.is_none(), "{:?}", s.last_error);
|
||||
// Due again in minutes, though a feed nobody subscribes to is otherwise read once a day.
|
||||
let state = db::HttpState { last_checked: Some(db::now() - 2 * 86_400), ..Default::default() };
|
||||
let due = ctx.due(&config::Config::default(), &feed(), "npr", &state, false).unwrap();
|
||||
assert!((due - db::now() - RETRY_LOOKUP_SECS).abs() <= 2, "{due}");
|
||||
// A real failure is the feed's.
|
||||
ctx.feed_failed("gone", "https://x/feed", "HTTP 404 Not Found").await.unwrap();
|
||||
assert_eq!(ctx.db.feed_summary("gone").await.unwrap().last_error.as_deref(), Some("HTTP 404 Not Found"));
|
||||
}
|
||||
}
|
||||
356
crates/ipx/src/daemon.rs
Normal file
356
crates/ipx/src/daemon.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
//! The daemon: its worker, its web server, and how it stops.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::{Command as Cmd, Event};
|
||||
use crate::{access, auth, catalogue, cli, config, db, deadair, directory, ipc, metrics, signals, web};
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// The catalogue and server settings as the database now has them, in place of the copy in
|
||||
/// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where
|
||||
/// things are and who gets in.
|
||||
pub(crate) async fn reload(ctx: &Ctx) -> Result<()> {
|
||||
let _held = ctx.cfg_write.lock().await;
|
||||
let cfg = catalogue::assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?;
|
||||
ctx.set_cfg(cfg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with
|
||||
/// this directly rather than through the job queue.
|
||||
pub(crate) async fn status(ctx: &Ctx) -> Event {
|
||||
match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await {
|
||||
Ok((pending, downloaded)) => {
|
||||
let feeds = tracing::Instrument::instrument(catalogue::subscriptions(ctx), db::step("subscriptions"))
|
||||
.await
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
// Kept for /metrics, which asks the database nothing (#195); the daemon counts every 30 s.
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let st = &metrics::STATS;
|
||||
st.feeds.store(feeds as i64, Relaxed);
|
||||
st.pending.store(pending, Relaxed);
|
||||
st.downloaded.store(downloaded, Relaxed);
|
||||
Event::Status { feeds, pending, downloaded }
|
||||
}
|
||||
Err(e) => Event::Error { msg: format!("{e:#}") },
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn daemon(
|
||||
ctx: Arc<Ctx>,
|
||||
config_path: PathBuf,
|
||||
web_addr: Option<String>,
|
||||
events: broadcast::Sender<Event>,
|
||||
) -> Result<()> {
|
||||
let socket = ctx.cfg().general.socket.clone();
|
||||
if ipc::daemon_is_live(&socket).await {
|
||||
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
||||
}
|
||||
|
||||
// A database with nobody in it cannot be signed into.
|
||||
if ctx.db.users().await?.is_empty() {
|
||||
let dir = ctx.config_path.parent().unwrap_or(Path::new("."));
|
||||
let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?;
|
||||
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?;
|
||||
tracing::warn!(
|
||||
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipx user passwd admin`"
|
||||
);
|
||||
}
|
||||
|
||||
// Unread lists start from here, and a reap may be a day off (#347). Slower without, never wrong.
|
||||
if let Err(e) = ctx.db.advance_unread_from().await {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not work out where unread items start");
|
||||
}
|
||||
|
||||
if let Some(admin) = ctx.db.users().await?.into_iter().find(|u| u.is_admin) {
|
||||
let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect();
|
||||
match ctx.db.adopt_catalogue(admin.id, &catalogue).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(user = %admin.name, feeds = n, "subscribed the first admin to the catalogue"),
|
||||
Err(e) => tracing::error!(error = %e, "could not subscribe the first admin to the catalogue"),
|
||||
}
|
||||
}
|
||||
|
||||
match ctx.db.requeue_interrupted().await {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not requeue interrupted downloads"),
|
||||
}
|
||||
|
||||
match catalogue::retire_stranded(&ctx).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
|
||||
}
|
||||
|
||||
// Room for any one request's commands: a web request waits inside itself for a free place,
|
||||
// and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and
|
||||
// every other request that queues work waited behind them (#154).
|
||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(1024);
|
||||
|
||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
||||
// status and reload are answered by the socket itself; everything else waits its turn in the
|
||||
// queue.
|
||||
let answer: ipc::StatusFn = {
|
||||
let ctx = ctx.clone();
|
||||
Arc::new(move |cmd| {
|
||||
let ctx = ctx.clone();
|
||||
Box::pin(async move {
|
||||
if matches!(cmd, Cmd::Reload)
|
||||
&& let Err(e) = reload(&ctx).await
|
||||
{
|
||||
return Event::Error { msg: format!("reading the catalogue again: {e:#}") };
|
||||
}
|
||||
status(&ctx).await
|
||||
})
|
||||
})
|
||||
};
|
||||
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
|
||||
|
||||
// One command at a time: the queue is what keeps two scans from overlapping.
|
||||
tracing::info!(feeds = catalogue::subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0), "daemon started");
|
||||
|
||||
// A signal has to be able to interrupt work in progress, not just the wait between
|
||||
// jobs. Racing `shutdown()` in the outer select only cancels branch selection: once
|
||||
// inside a long download the daemon stopped listening and had to be SIGKILLed.
|
||||
let (tx_stop, rx_stop) = tokio::sync::watch::channel(false);
|
||||
tokio::spawn(async move {
|
||||
shutdown().await;
|
||||
let _ = tx_stop.send(true);
|
||||
});
|
||||
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
|
||||
// must not wait behind it.
|
||||
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(signals::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(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.
|
||||
async fn until_stopped(ctx: &Ctx, rx: &watch::Receiver<bool>, job: impl Future<Output = Result<()>>) -> bool {
|
||||
let mut stop = rx.clone();
|
||||
tokio::select! {
|
||||
_ = stop.changed() => {
|
||||
tracing::info!("signal received; abandoning the job in progress");
|
||||
false
|
||||
}
|
||||
result = job => {
|
||||
if let Err(e) = result {
|
||||
ctx.out.emit(Event::Error { msg: format!("{e:#}") });
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stop = rx_stop.clone();
|
||||
// The first pass at once, as the minute's tick did: what came due while it was down.
|
||||
let mut first = true;
|
||||
loop {
|
||||
if *stop.borrow() {
|
||||
break;
|
||||
}
|
||||
let wait = if std::mem::take(&mut first) { Duration::ZERO } else { until_next_scan(&ctx).await };
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = stop.changed() => break,
|
||||
Some(cmd) = rx_cmd.recv() => {
|
||||
// Both halves of the protocol are logged under one target so the UI can
|
||||
// show the conversation on its own: this is everything arriving, whatever
|
||||
// the source -- a socket client, the CLI proxying, or the web UI.
|
||||
tracing::info!(
|
||||
target: "ipx::io",
|
||||
"-> {}",
|
||||
serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}"))
|
||||
);
|
||||
if !until_stopped(&ctx, &rx_stop, cli::run(&ctx, cmd)).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(wait) => {
|
||||
// Per-feed schedule and TTL decide what actually gets polled.
|
||||
let job = cli::run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
|
||||
if !until_stopped(&ctx, &rx_stop, job).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.abort();
|
||||
if let Some(w) = web {
|
||||
w.abort();
|
||||
}
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
tracing::info!("daemon stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts the web UI when it is switched on, minting and saving a token if there is none.
|
||||
pub(crate) async fn start_web(
|
||||
ctx: &Arc<Ctx>,
|
||||
config_path: &Path,
|
||||
web_addr: Option<String>,
|
||||
cmds: &mpsc::Sender<Cmd>,
|
||||
events: &broadcast::Sender<Event>,
|
||||
) -> Result<Option<JoinHandle<()>>> {
|
||||
let cfg = ctx.cfg();
|
||||
let enabled = cfg.web.enabled || web_addr.is_some();
|
||||
if !enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let bind = web_addr.unwrap_or_else(|| cfg.web.bind.clone());
|
||||
|
||||
if cfg.web.token.is_empty() {
|
||||
let mut fresh = (*cfg).clone();
|
||||
fresh.web.enabled = true;
|
||||
fresh.web.bind = bind.clone();
|
||||
fresh.web.token = crate::auth::new_session_token();
|
||||
// The token is config.toml's, not the database's: it decides who gets in.
|
||||
fresh.save_bootstrap(config_path)?;
|
||||
ctx.set_cfg(fresh.clone());
|
||||
// The token signs in as the admin, and whatever reads this process's output (docker logs,
|
||||
// for one) is wider than who reads config.toml. So say where it is, never what it is.
|
||||
// Logged, not printed, so a JSON log stays one object a line (#91).
|
||||
tracing::info!(
|
||||
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
|
||||
config_path.display()
|
||||
);
|
||||
} else {
|
||||
tracing::info!("web ui at http://{bind}/ (the sign-in token is [web] token in {})", config_path.display());
|
||||
}
|
||||
|
||||
// Bound beyond localhost, as a container has to be for its port to be published. Worth a
|
||||
// warning only when nothing but a password stands in front of it: it said "the token is all
|
||||
// that guards it" on every start of production, behind Cloudflare Access, and was the only
|
||||
// warning in a healthy log (#106).
|
||||
if ctx.cfg().web.binds_publicly() {
|
||||
if ctx.cfg().web.access().is_some() {
|
||||
tracing::info!(
|
||||
bind,
|
||||
"web ui is reachable off this machine; signing in takes an account's password or the admin token, or Cloudflare Access through a trusted proxy"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
bind,
|
||||
"web ui is reachable off this machine; an account's password or the admin token is all that guards it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let access = Arc::new(access::Keys::default());
|
||||
if let Some((team, _)) = ctx.cfg().web.access() {
|
||||
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
|
||||
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
|
||||
}
|
||||
let playing = deadair::Playing::default();
|
||||
if !cfg.web.metrics_bind.is_empty() {
|
||||
let sources = metrics::Sources {
|
||||
ctx: ctx.clone(),
|
||||
cmds: cmds.clone(),
|
||||
events: events.clone(),
|
||||
playing: playing.clone(),
|
||||
runtime: Handle::current(),
|
||||
started: Instant::now(),
|
||||
};
|
||||
let at = metrics::spawn(&cfg.web.metrics_bind, sources)?;
|
||||
tracing::info!(%at, "serving metrics for Prometheus at /metrics");
|
||||
}
|
||||
let state = web::WebState { ctx: ctx.clone(), cmds: cmds.clone(), events: events.clone(), access, playing };
|
||||
Ok(Some(tokio::spawn(async move {
|
||||
if let Err(e) = web::serve(state, &bind).await {
|
||||
tracing::error!(error = %format!("{e:#}"), "web ui stopped");
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown() {
|
||||
use tokio::signal::unix;
|
||||
use tokio::signal::unix::SignalKind;
|
||||
let mut term = match unix::signal(SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::future::pending().await,
|
||||
};
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
_ = term.recv() => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
|
||||
/// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding
|
||||
/// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the
|
||||
/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a
|
||||
/// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway.
|
||||
pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
|
||||
let next = async {
|
||||
let cfg = ctx.cfg();
|
||||
let subscribed = ctx.db.subscriber_counts().await?;
|
||||
let states = ctx.db.http_states().await?;
|
||||
anyhow::Ok(
|
||||
catalogue::subscriptions(ctx)
|
||||
.await?
|
||||
.iter()
|
||||
// A feed with no row yet has never been checked: due now.
|
||||
.map(|s| {
|
||||
let never = db::HttpState::default();
|
||||
let state = states.get(&s.id).unwrap_or(&never);
|
||||
ctx.due(&cfg, &s.cfg, &s.id, state, subscribed.contains_key(&s.id)).unwrap_or(0)
|
||||
})
|
||||
.min(),
|
||||
)
|
||||
};
|
||||
let wait = match next.await {
|
||||
Ok(Some(at)) => (at - db::now()).max(0) as u64,
|
||||
Ok(None) => u64::MAX,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not work out when the next feed is due");
|
||||
0
|
||||
}
|
||||
};
|
||||
std::time::Duration::from_secs(wait.clamp(30, 600))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reload_takes_the_catalogue_another_ipx_wrote() {
|
||||
let with = |id: &str| {
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert(id.into(), config::Feed { url: format!("http://x/{id}.xml"), ..feed() });
|
||||
cfg
|
||||
};
|
||||
let held = with("kept-in-memory");
|
||||
let ctx = test_ctx(held.clone()).await;
|
||||
ctx.db.store_config(&held).await.unwrap();
|
||||
// `ipx add` and `ipx rm`, from a process of their own (#120).
|
||||
ctx.db.store_config(&with("added-by-the-cli")).await.unwrap();
|
||||
reload(&ctx).await.unwrap();
|
||||
let urls: Vec<String> = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect();
|
||||
assert_eq!(urls, vec!["http://x/added-by-the-cli.xml".to_string()]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
958
crates/ipx/src/deadair.rs
Normal file
958
crates/ipx/src/deadair.rs
Normal file
@@ -0,0 +1,958 @@
|
||||
//! DeadAir (issue #171): podcasts play with their long silences shortened, so they finish
|
||||
//! sooner without sounding sped up.
|
||||
//!
|
||||
//! Nothing is copied (#181). DeadAir finds a file's silences once, in the background, and keeps a
|
||||
//! `Plan` for each pause length a person can choose (#182): the frames kept, as byte ranges of
|
||||
//! the original. The player gets an HLS playlist of those ranges (`playlist`), which Safari plays
|
||||
//! itself and other browsers through hls.js, with no gap at the cuts. Seeking past each silence
|
||||
//! instead left about 1.3 s of nothing at every one (#178); a copy made with ffmpeg cost as much
|
||||
//! disk again as the original and could only have one pause length.
|
||||
//!
|
||||
//! Positions stay in the original's time. What a plan leaves out is stored as `Cut`s of the
|
||||
//! original's time, and the server converts both ways (`to_copy`, `to_original`): the iOS and
|
||||
//! Android apps save positions themselves, from their own player, so converting in the page
|
||||
//! would miss them.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::db;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use axum::body::Bytes;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::io::SeekFrom;
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
|
||||
/// Quieter than this for longer than `MIN_SILENCE` is a silence: what the measurements on the
|
||||
/// issue used. A podcast's room tone sits well below it and speech well above.
|
||||
const THRESHOLD: &str = "-38dB";
|
||||
const MIN_SILENCE: f64 = 0.35;
|
||||
/// The pauses a person can have DeadAir leave where it cuts a silence, in ms (#182). Half of it
|
||||
/// is kept at each end of the silence, so the frames on both sides of a cut are silent. Decoding
|
||||
/// blends a frame with the one before it, and an MP3 frame can begin in the bytes of the frame
|
||||
/// before (the bit reservoir): the first frame after a cut decodes against the wrong neighbour,
|
||||
/// which nobody hears between two silent frames and everyone hears at the start of a word.
|
||||
pub const PAUSES: [i64; 4] = [200, 300, 400, 500];
|
||||
/// 0.2 s, the first DeadAir's, made speech sound rushed.
|
||||
pub const DEFAULT_PAUSE: i64 = 300;
|
||||
/// The longest piece of a playlist, in seconds: about what HLS players expect, so a long stretch
|
||||
/// of talk is not one request for megabytes.
|
||||
const MAX_PIECE: f64 = 10.0;
|
||||
/// How long the maker waits when there is nothing to do: a download or a feed turned on is
|
||||
/// picked up this long after, at most.
|
||||
const IDLE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A span of the original, in milliseconds, that DeadAir leaves out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Cut(pub i64, pub i64);
|
||||
|
||||
/// What one pause length leaves of a file: what it cuts, and what it keeps as pieces of the
|
||||
/// original, (byte offset, bytes, seconds).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Plan {
|
||||
pub cuts: Vec<Cut>,
|
||||
pub pieces: Vec<(u64, u64, f64)>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub fn saved_ms(&self) -> i64 {
|
||||
saved(&self.cuts)
|
||||
}
|
||||
}
|
||||
|
||||
/// The length of the ID3v2 tag a file starts with, 0 if it has none.
|
||||
pub fn id3_len(head: &[u8]) -> u64 {
|
||||
if head.len() < 10 || &head[..3] != b"ID3" {
|
||||
return 0;
|
||||
}
|
||||
// Seven bits a byte, so no byte of the size looks like the start of a frame.
|
||||
let size = head[6..10].iter().fold(0u64, |n, b| n << 7 | u64::from(b & 0x7f));
|
||||
let footer = if head[5] & 0x10 != 0 { 10 } else { 0 };
|
||||
10 + size + footer
|
||||
}
|
||||
|
||||
/// A file as `plan` plays it, to save (#186): its ID3 tag, so the title and artwork come too, then
|
||||
/// the pieces one after another. MP3 and ADTS frames each stand alone, so that is a file any
|
||||
/// player plays. Read as it is sent and never stored, as the playlist is: (its length, its bytes).
|
||||
pub async fn kept(
|
||||
path: &Path,
|
||||
plan: &Plan,
|
||||
) -> std::io::Result<(u64, impl futures_util::Stream<Item = std::io::Result<Bytes>> + use<>)> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = File::open(path).await?;
|
||||
let mut head = [0u8; 10];
|
||||
let n = file.read(&mut head).await?;
|
||||
// Only up to the first frame kept: past the tag can be the Xing frame, which gives the
|
||||
// original's length.
|
||||
let first = plan.pieces.first().map_or(0, |p| p.0);
|
||||
let tag = id3_len(&head[..n]).min(first);
|
||||
let ranges: VecDeque<(u64, u64)> =
|
||||
std::iter::once((0, tag)).chain(plan.pieces.iter().map(|&(o, n, _)| (o, n))).filter(|r| r.1 > 0).collect();
|
||||
let len = ranges.iter().map(|r| r.1).sum();
|
||||
let body = futures_util::stream::try_unfold((file, ranges), |(mut f, mut left)| async move {
|
||||
let Some((off, n)) = left.pop_front() else { return Ok(None) };
|
||||
let mut buf = vec![0; n.min(1 << 16) as usize];
|
||||
f.seek(SeekFrom::Start(off)).await?;
|
||||
f.read_exact(&mut buf).await?;
|
||||
let got = buf.len() as u64;
|
||||
if got < n {
|
||||
left.push_front((off + got, n - got));
|
||||
}
|
||||
Ok(Some((Bytes::from(buf), (f, left))))
|
||||
});
|
||||
Ok((len, body))
|
||||
}
|
||||
|
||||
/// The silences silencedetect reported, as (start, end) seconds. One still open at the end of
|
||||
/// the file has no end and is left alone.
|
||||
pub fn parse_silences(log: &str) -> Vec<(f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = None;
|
||||
for line in log.lines() {
|
||||
if let Some(v) = after(line, "silence_start: ") {
|
||||
start = Some(v);
|
||||
} else if let Some(v) = after(line, "silence_end: ")
|
||||
&& let Some(s) = start.take()
|
||||
{
|
||||
out.push((s, v));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn after(line: &str, key: &str) -> Option<f64> {
|
||||
let rest = &line[line.find(key)? + key.len()..];
|
||||
rest.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same
|
||||
/// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
|
||||
/// silence, `keep` seconds in from each end, go.
|
||||
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64, keep: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
|
||||
let spans: Vec<(u64, u64)> = silences
|
||||
.iter()
|
||||
.filter(|(s, e)| e - s >= MIN_SILENCE)
|
||||
.filter_map(|&(s, e)| {
|
||||
let a = ((s + keep - first) / frame).ceil().max(0.0) as u64;
|
||||
let b = ((e - keep - first) / frame).floor() as i64 - 1;
|
||||
(b >= a as i64).then_some((a, b as u64))
|
||||
})
|
||||
.collect();
|
||||
let ms = |n: u64| ((first + n as f64 * frame) * 1000.0).round() as i64;
|
||||
let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
|
||||
(spans, cuts)
|
||||
}
|
||||
|
||||
/// The frames kept, as pieces of the file: runs of frames lying end to end in it, none past
|
||||
/// `MAX_PIECE`. `frames` is each frame's (byte offset, bytes); `drop` is from `plan`.
|
||||
pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u64, u64, f64)> {
|
||||
let most = ((MAX_PIECE / frame).floor() as usize).max(1);
|
||||
let mut out = Vec::new();
|
||||
let push = |out: &mut Vec<(u64, u64, f64)>, (first, n): (usize, usize)| {
|
||||
let (start, (pos, size)) = (frames[first].0, frames[first + n - 1]);
|
||||
out.push((start, pos + size - start, n as f64 * frame));
|
||||
};
|
||||
let mut drops = drop.iter().peekable();
|
||||
let mut run: Option<(usize, usize)> = None;
|
||||
for (n, &(pos, _)) in frames.iter().enumerate() {
|
||||
while drops.peek().is_some_and(|&&(_, b)| (b as usize) < n) {
|
||||
drops.next();
|
||||
}
|
||||
let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
|
||||
// Something between two frames (an ID3 tag partway through) ends a piece too.
|
||||
let joins = !dropped && run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
|
||||
if !joins && let Some(r) = run.take() {
|
||||
push(&mut out, r);
|
||||
}
|
||||
if !dropped {
|
||||
run = Some(run.map_or((n, 1), |(f, c)| (f, c + 1)));
|
||||
}
|
||||
}
|
||||
if let Some(r) = run {
|
||||
push(&mut out, r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The HLS playlist for a plan: each piece a byte range of `uri`, the original.
|
||||
pub fn playlist(plan: &Plan, uri: &str) -> String {
|
||||
let longest = plan.pieces.iter().map(|p| p.2).fold(0.0, f64::max);
|
||||
let mut s = format!(
|
||||
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:{}\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n",
|
||||
longest.ceil() as u64
|
||||
);
|
||||
for &(off, len, secs) in &plan.pieces {
|
||||
let _ = write!(s, "#EXTINF:{secs:.6},\n#EXT-X-BYTERANGE:{len}@{off}\n{uri}\n");
|
||||
}
|
||||
s + "#EXT-X-ENDLIST\n"
|
||||
}
|
||||
|
||||
/// A time in the original, in ms, as the same moment in the shortened version. A time inside a
|
||||
/// cut is where the cut was made.
|
||||
pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
|
||||
let mut out = ms;
|
||||
for &Cut(s, e) in cuts {
|
||||
if ms >= e {
|
||||
out -= e - s;
|
||||
} else {
|
||||
if ms > s {
|
||||
out -= ms - s;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A time in the shortened version, in ms, as the same moment in the original.
|
||||
pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
|
||||
let mut out = ms;
|
||||
for &Cut(s, e) in cuts {
|
||||
if out >= s {
|
||||
out += e - s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn saved(cuts: &[Cut]) -> i64 {
|
||||
cuts.iter().map(|Cut(s, e)| e - s).sum()
|
||||
}
|
||||
|
||||
/// Whether someone with these settings plays a feed shortened. Off for every feed until they
|
||||
/// turn it on for that one, or for the group it came in (an OPML or a Patreon creator), which a
|
||||
/// feed of the group can still say otherwise to.
|
||||
pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool {
|
||||
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The settings of each person with DeadAir on for something, and each managed feed's group,
|
||||
/// which is what `wants` and `pause` need.
|
||||
pub struct Wanting {
|
||||
pub people: HashMap<i64, HashMap<String, bool>>,
|
||||
pub pauses: HashMap<(i64, String), i64>,
|
||||
pub groups: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Wanting {
|
||||
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
|
||||
let mut people: HashMap<i64, HashMap<String, bool>> = HashMap::new();
|
||||
let mut pauses = HashMap::new();
|
||||
for (user, feed, wanted, pause) in ctx.db.deadair_prefs(only).await? {
|
||||
if let Some(p) = pause {
|
||||
pauses.insert((user, feed.clone()), p);
|
||||
}
|
||||
people.entry(user).or_default().insert(feed, wanted);
|
||||
}
|
||||
people.retain(|_, p| p.values().any(|&w| w));
|
||||
let groups = if people.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect()
|
||||
};
|
||||
Ok(Self { people, pauses, groups })
|
||||
}
|
||||
|
||||
pub fn wants(&self, user: i64, feed: &str) -> bool {
|
||||
self.people.get(&user).is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
|
||||
}
|
||||
|
||||
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
|
||||
pub fn pause(&self, user: i64, feed: &str) -> i64 {
|
||||
let group = self.groups.get(feed);
|
||||
self.pauses
|
||||
.get(&(user, feed.to_owned()))
|
||||
.or_else(|| group.and_then(|g| self.pauses.get(&(user, g.clone()))))
|
||||
.copied()
|
||||
.unwrap_or(DEFAULT_PAUSE)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan someone plays a file with: their pause's, for a file in a feed they have DeadAir on
|
||||
/// for, once its silences are found. None if it cuts nothing.
|
||||
fn chosen(wanting: &Wanting, user: i64, feed: &str, plans: &BTreeMap<i64, Plan>) -> Option<Plan> {
|
||||
if !wanting.wants(user, feed) {
|
||||
return None;
|
||||
}
|
||||
plans.get(&wanting.pause(user, feed)).filter(|p| !p.cuts.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// What each person is playing shortened, by (person, feed, guid): set when they are handed the
|
||||
/// playlist and cleared when `/media` serves them the original, so a saved position is converted
|
||||
/// from the clock it was measured in, whether the page saved it or a native app did.
|
||||
// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
|
||||
// for the playlist is then taken as the original's, which resumes a little early, never late.
|
||||
pub type Playing = Arc<std::sync::Mutex<HashMap<(i64, String, String), Arc<Plan>>>>;
|
||||
|
||||
/// The plan for one file as this person would play it, for the playlist route.
|
||||
pub async fn plan_for(ctx: &Ctx, user: i64, feed: &str, enclosure: i64) -> Result<Option<Plan>> {
|
||||
let wanting = Wanting::load(ctx, Some(user)).await?;
|
||||
let plans = ctx.db.plans(&[enclosure]).await?;
|
||||
Ok(plans.get(&enclosure).and_then(|p| chosen(&wanting, user, feed, p)))
|
||||
}
|
||||
|
||||
/// Puts a page of entries in the clock of what this person will play: for each file they play
|
||||
/// shortened, `deadair` is the seconds it saves, and the entry's position and length are the
|
||||
/// shortened version's. The page plays `/media/{id}/deadair.m3u8` for a file that has it.
|
||||
pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
|
||||
let wanting = Wanting::load(ctx, Some(user)).await?;
|
||||
if wanting.people.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ids: Vec<i64> = rows
|
||||
.iter()
|
||||
.filter(|r| wanting.wants(user, &r.feed_id))
|
||||
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
|
||||
.collect();
|
||||
let plans = ctx.db.plans(&ids).await?;
|
||||
for r in rows.iter_mut() {
|
||||
// Heard through already: the maker passes it over, so nothing is coming.
|
||||
let finished = r.duration.is_some_and(|d| d > 0 && r.position * 10 >= d * 9);
|
||||
let mut converted = false;
|
||||
for e in &mut r.enclosures {
|
||||
e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !plans.contains_key(&e.id) && !finished;
|
||||
let Some(p) = plans.get(&e.id).and_then(|p| chosen(&wanting, user, &r.feed_id, p)) else { continue };
|
||||
e.deadair = Some(p.saved_ms() / 1000);
|
||||
// One position an item, however many files: the first played shortened sets the clock.
|
||||
if !std::mem::replace(&mut converted, true) {
|
||||
r.position = to_copy(&p.cuts, r.position * 1000) / 1000;
|
||||
r.duration = r.duration.map(|d| to_copy(&p.cuts, d * 1000) / 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A position and length a player saved, in the clock of what it was playing, as the
|
||||
/// original's. Past 75% of the shortened version, the episode also counts towards the time
|
||||
/// DeadAir has saved this person.
|
||||
pub async fn on_save(
|
||||
ctx: &Ctx,
|
||||
playing: &Playing,
|
||||
user: i64,
|
||||
feed: &str,
|
||||
guid: &str,
|
||||
secs: i64,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(i64, Option<i64>)> {
|
||||
let plan = playing
|
||||
.lock()
|
||||
.expect("a panic elsewhere poisoned this lock")
|
||||
.get(&(user, feed.to_owned(), guid.to_owned()))
|
||||
.cloned();
|
||||
let Some(p) = plan else { return Ok((secs, duration)) };
|
||||
let original = |s: i64| to_original(&p.cuts, s * 1000) / 1000;
|
||||
let (secs, duration) = (original(secs), duration.map(original));
|
||||
let len = match duration {
|
||||
Some(d) => Some(d),
|
||||
None => ctx.db.entry_duration(user, feed, guid).await?,
|
||||
};
|
||||
if let Some(len) = len
|
||||
&& len > 0
|
||||
&& secs * 4 >= len * 3
|
||||
{
|
||||
ctx.db.count_deadair(user, feed, guid, p.saved_ms()).await?;
|
||||
}
|
||||
Ok((secs, duration))
|
||||
}
|
||||
|
||||
/// Finds silences until stopped: in the next file anyone wants shortened, or a wait of `IDLE`
|
||||
/// when there is none.
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
if !have_ffmpeg().await {
|
||||
tracing::warn!("ffmpeg is not installed, so DeadAir shortens nothing");
|
||||
return;
|
||||
}
|
||||
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded
|
||||
// as the file having nothing to cut was never tried again, even once a fix was deployed (#174).
|
||||
let mut failed = HashSet::new();
|
||||
loop {
|
||||
// Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
|
||||
let done = tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
done = next(&ctx, &mut failed) => done,
|
||||
};
|
||||
match done {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not read a file"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
_ = ctx.deadair.notified() => {}
|
||||
_ = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn have_ffmpeg() -> bool {
|
||||
tokio::process::Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
/// Plans one file someone wants shortened, if there is one. A file everyone who would play it
|
||||
/// shortened has already heard through gets none: finished episodes are rarely played again.
|
||||
// shortcut: those stay on the list and are looked at again each pass, one query each; record
|
||||
// them as passed over if a large library makes the pass slow.
|
||||
async fn next(ctx: &Ctx, failed: &mut HashSet<i64>) -> Result<bool> {
|
||||
// Nobody with DeadAir on for anything, which is most passes: nothing more to ask the database.
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let st = &crate::metrics::STATS;
|
||||
let wanting = Wanting::load(ctx, None).await?;
|
||||
if wanting.people.is_empty() {
|
||||
st.deadair_backlog.store(0, Relaxed);
|
||||
return Ok(false);
|
||||
}
|
||||
let todo = ctx.db.deadair_todo().await?;
|
||||
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
|
||||
// per-file subscription and finished checks below.
|
||||
let wanted = |t: &crate::db::DeadAirTodo| {
|
||||
!failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id))
|
||||
};
|
||||
st.deadair_backlog.store(todo.iter().filter(|t| wanted(t)).count() as i64, Relaxed);
|
||||
for t in todo {
|
||||
if failed.contains(&t.id) {
|
||||
continue;
|
||||
}
|
||||
let users: Vec<i64> = wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
|
||||
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
|
||||
continue;
|
||||
}
|
||||
let finished = ctx.db.finished(&t.feed_id, &t.guid).await?;
|
||||
if users.iter().all(|u| finished.contains(u)) {
|
||||
continue;
|
||||
}
|
||||
let src = crate::download::playable(Path::new(&t.path));
|
||||
let started = Instant::now();
|
||||
// A trace of its own for each file looked at, not for the passes that find nothing to do,
|
||||
// which are most of them.
|
||||
let span = tracing::info_span!("deadair", feed = t.feed_id, enclosure = t.id, cuts = tracing::field::Empty);
|
||||
st.deadair_busy_since.store(crate::db::now(), Relaxed);
|
||||
let analysed = analyse(&src).instrument(span.clone()).await;
|
||||
st.deadair_busy_since.store(0, Relaxed);
|
||||
let plans = match analysed {
|
||||
Ok(p) => p.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not read a file");
|
||||
failed.insert(t.id);
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
ctx.db
|
||||
.store_plans(t.id, &serde_json::to_string(&plans)?)
|
||||
.instrument(span.in_scope(|| db::step("store_plans")))
|
||||
.await?;
|
||||
let usual = plans.get(&DEFAULT_PAUSE);
|
||||
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
|
||||
let saved_ms = usual.map_or(0, Plan::saved_ms);
|
||||
ctx.out.emit(crate::ipc::Event::DeadairDone {
|
||||
feed: t.feed_id.clone(),
|
||||
enclosure: t.id,
|
||||
saved_s: saved_ms / 1000,
|
||||
});
|
||||
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
|
||||
tracing::info!(
|
||||
feed = t.feed_id,
|
||||
path = %src.display(),
|
||||
cuts = usual.map_or(0, |p| p.cuts.len()),
|
||||
saved_s = saved_ms / 1000,
|
||||
took_ms = started.elapsed().as_millis() as u64,
|
||||
"DeadAir found the silences"
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// A plan for each of `PAUSES`, for one file. None for a file DeadAir does not apply to: video,
|
||||
/// anything but MP3 or AAC on its own (ADTS; AAC in MP4 has no byte ranges a playlist can
|
||||
/// use), or one with no silence long enough to cut.
|
||||
pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
|
||||
let Some((first, frame)) = probe(src).instrument(db::step("probe")).await? else { return Ok(None) };
|
||||
let path = src.display().to_string();
|
||||
let log = ffmpeg(&[
|
||||
"-hide_banner",
|
||||
"-nostats",
|
||||
"-i",
|
||||
&path,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-af",
|
||||
&format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"),
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
])
|
||||
.instrument(db::step("silencedetect"))
|
||||
.await
|
||||
.context("finding the silences")?;
|
||||
let silences = parse_silences(&log);
|
||||
let frames = frame_bytes(&path).instrument(db::step("frame_bytes")).await?;
|
||||
let plans: BTreeMap<i64, Plan> = PAUSES
|
||||
.iter()
|
||||
.map(|&pause| {
|
||||
let (drop, cuts) = plan(&silences, first, frame, pause as f64 / 2000.0);
|
||||
(pause, Plan { cuts, pieces: pieces(&frames, &drop, frame) })
|
||||
})
|
||||
.collect();
|
||||
Ok(plans.values().any(|p| !p.cuts.is_empty()).then_some(plans))
|
||||
}
|
||||
|
||||
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
|
||||
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path],
|
||||
)
|
||||
.await?;
|
||||
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
|
||||
out.lines()
|
||||
.map(|l| {
|
||||
let field = |k: &str| l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok());
|
||||
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
|
||||
/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
|
||||
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
|
||||
let path = src.display().to_string();
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic",
|
||||
"-of",
|
||||
"json",
|
||||
&path,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
|
||||
let streams = v["streams"].as_array().cloned().unwrap_or_default();
|
||||
let video = streams.iter().any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
||||
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
|
||||
let format = v["format"]["format_name"].as_str().unwrap_or("");
|
||||
let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
|
||||
if video || !(plain("mp3") || plain("aac")) {
|
||||
return Ok(None);
|
||||
}
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"packet=pts_time,duration_time",
|
||||
"-read_intervals",
|
||||
"%+#1",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
&path,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
|
||||
match (f.next(), f.next()) {
|
||||
(Some(Ok(first)), Some(Ok(frame))) if frame > 0.0 => Ok(Some((first, frame))),
|
||||
_ => bail!("no audio frame in {path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs ffmpeg and gives back what it wrote to stderr, where silencedetect reports.
|
||||
async fn ffmpeg(args: &[&str]) -> Result<String> {
|
||||
let out = command("ffmpeg").args(["-nostdin", "-threads", "1"]).args(args).output().await?;
|
||||
let log = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
if !out.status.success() {
|
||||
bail!("ffmpeg failed: {}", log.lines().rev().take(3).collect::<Vec<_>>().join(" / "));
|
||||
}
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
async fn run_out(prog: &str, args: &[&str]) -> Result<String> {
|
||||
let out = command(prog).args(args).output().await.with_context(|| format!("running {prog}"))?;
|
||||
if !out.status.success() {
|
||||
bail!("{prog} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn command(prog: &str) -> tokio::process::Command {
|
||||
let mut c = tokio::process::Command::new(prog);
|
||||
c.kill_on_drop(true).stdin(Stdio::null());
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn silences_are_read_from_the_log_and_an_open_one_is_left() {
|
||||
let log = "[silencedetect @ 0x1] silence_start: 1\n\
|
||||
[silencedetect @ 0x1] silence_end: 3.00002 | silence_duration: 2.00002\n\
|
||||
[silencedetect @ 0x1] silence_start: 10.5\n";
|
||||
assert_eq!(parse_silences(log), vec![(1.0, 3.00002)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() {
|
||||
// MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch; a 0.3 s pause.
|
||||
let frame = 1152.0 / 44100.0;
|
||||
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame, 0.15);
|
||||
assert_eq!(frames, vec![(45, 108)]);
|
||||
let Cut(s, e) = cuts[0];
|
||||
assert!(s >= 1150 && e <= 2850, "{cuts:?}");
|
||||
assert!(s - 1150 < 27 && 2850 - e < 27, "no more than a frame kept beyond half the pause: {cuts:?}");
|
||||
// AAC starts a frame early, for the encoder's priming.
|
||||
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0, 0.15);
|
||||
assert_eq!(frames, vec![(51, 122)]);
|
||||
// A silence the margins use up entirely is left alone.
|
||||
assert!(plan(&[(1.0, 1.35)], 0.0, frame, 0.15).0.is_empty());
|
||||
// A longer pause cuts less of the same silence.
|
||||
assert!(saved(&plan(&[(1.0, 3.0)], 0.0, frame, 0.25).1) < saved(&cuts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_are_the_frames_kept_end_to_end_and_never_too_long() {
|
||||
// 1000 frames of 100 bytes after a 50-byte tag, an ID3 tag of 7 bytes before frame 600.
|
||||
let frames: Vec<(u64, u64)> =
|
||||
(0..1000u64).map(|n| (50 + n * 100 + if n >= 600 { 7 } else { 0 }, 100)).collect();
|
||||
let frame = 0.026;
|
||||
let p = pieces(&frames, &[(100, 199), (900, 999)], frame);
|
||||
let at: Vec<u64> = p.iter().map(|x| x.0).collect();
|
||||
// 0-99; 200-583 and 584-599, split at 384 frames (10 s); 600-899 after the tag.
|
||||
assert_eq!(at, [50, 20_050, 58_450, 60_057]);
|
||||
assert_eq!(p[0], (50, 10_000, 100.0 * frame));
|
||||
assert_eq!(p[2].1, 1600, "frames 584-599");
|
||||
assert!(p.iter().all(|x| x.2 <= MAX_PIECE));
|
||||
let kept: u64 = p.iter().map(|x| x.1).sum();
|
||||
assert_eq!(kept, 800 * 100, "every kept frame once, the tag in none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_playlist_lists_each_piece_as_a_range_of_the_original() {
|
||||
let plan = Plan { cuts: vec![Cut(1000, 2000)], pieces: vec![(50, 400, 1.0), (500, 4000, 9.5)] };
|
||||
assert_eq!(
|
||||
playlist(&plan, "deadair.mp3"),
|
||||
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:10\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n\
|
||||
#EXTINF:1.000000,\n#EXT-X-BYTERANGE:400@50\ndeadair.mp3\n\
|
||||
#EXTINF:9.500000,\n#EXT-X-BYTERANGE:4000@500\ndeadair.mp3\n#EXT-X-ENDLIST\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_convert_both_ways() {
|
||||
let cuts = [Cut(10_000, 20_000), Cut(30_000, 35_000)];
|
||||
assert_eq!(to_copy(&cuts, 5_000), 5_000);
|
||||
assert_eq!(to_copy(&cuts, 15_000), 10_000, "inside a cut is where it was made");
|
||||
assert_eq!(to_copy(&cuts, 25_000), 15_000);
|
||||
assert_eq!(to_copy(&cuts, 40_000), 25_000);
|
||||
for t in [0, 5_000, 10_000, 15_000, 19_999, 20_000, 25_000, 40_000] {
|
||||
let back = to_original(&cuts, to_copy(&cuts, t));
|
||||
// A time inside a cut comes back as the cut's end: the shortened version has nothing between.
|
||||
let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
|
||||
assert_eq!(back, expect, "{t}");
|
||||
}
|
||||
assert_eq!(saved(&cuts), 15_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_for_every_feed_until_turned_on_for_it_or_its_group() {
|
||||
let p = |v: &[(&str, bool)]| v.iter().map(|(k, w)| (k.to_string(), *w)).collect::<HashMap<_, _>>();
|
||||
assert!(!wants(&p(&[]), "a", None), "off until turned on for the feed");
|
||||
assert!(!wants(&p(&[("b", true)]), "a", None), "another feed's says nothing of this one");
|
||||
assert!(wants(&p(&[("a", true)]), "a", None));
|
||||
assert!(wants(&p(&[("g", true)]), "a", Some("g")), "on for its group");
|
||||
assert!(!wants(&p(&[("g", true), ("a", false)]), "a", Some("g")), "kept out of its group's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pause_is_the_feeds_then_its_groups_then_the_default() {
|
||||
let w = Wanting {
|
||||
people: HashMap::new(),
|
||||
pauses: [((1, "a".to_string()), 500), ((1, "g".to_string()), 400)].into(),
|
||||
groups: [("b".to_string(), "g".to_string())].into(),
|
||||
};
|
||||
assert_eq!(w.pause(1, "a"), 500);
|
||||
assert_eq!(w.pause(1, "b"), 400, "its group's");
|
||||
assert_eq!(w.pause(1, "c"), DEFAULT_PAUSE);
|
||||
assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id3_len_reads_the_tags_size() {
|
||||
assert_eq!(id3_len(b"ID3\x04\x00\x00\x00\x00\x02\x01xx"), 10 + 257);
|
||||
assert_eq!(id3_len(b"ID3\x04\x00\x10\x00\x00\x02\x01"), 10 + 257 + 10, "with a footer");
|
||||
assert_eq!(id3_len(b"\xff\xfb\x90\x00\x00\x00\x00\x00\x00\x00"), 0);
|
||||
assert_eq!(id3_len(b"ID3"), 0);
|
||||
}
|
||||
|
||||
/// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on,
|
||||
/// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not
|
||||
/// installed.
|
||||
#[tokio::test]
|
||||
async fn the_playlist_of_a_file_is_shorter_by_what_was_cut_and_plays() {
|
||||
if !have_ffmpeg().await {
|
||||
eprintln!("no ffmpeg; skipped");
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
|
||||
let src = dir.join(format!("t.{ext}"));
|
||||
ffmpeg(&[
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"aevalsrc='if(lt(mod(t,3),1),0.5*sin(2*PI*440*t),0)':d=12:s=44100",
|
||||
"-c:a",
|
||||
codec,
|
||||
&src.display().to_string(),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
let plans = analyse(&src).await.unwrap();
|
||||
if ext == "m4a" {
|
||||
assert!(plans.is_none(), "AAC in MP4 has no byte ranges to play");
|
||||
continue;
|
||||
}
|
||||
let plans = plans.expect("plans");
|
||||
assert_eq!(plans.keys().copied().collect::<Vec<_>>(), PAUSES);
|
||||
let p = &plans[&DEFAULT_PAUSE];
|
||||
assert_eq!(p.cuts.len(), 4, "{ext}: {:?}", p.cuts);
|
||||
// About 1.7 s from each of the four 2 s silences, and less for a longer pause.
|
||||
let cut = p.saved_ms();
|
||||
assert!((6_500..6_900).contains(&cut), "{ext}: {cut}");
|
||||
assert!(plans[&500].saved_ms() < cut && cut < plans[&200].saved_ms());
|
||||
let list = dir.join(format!("{ext}.m3u8"));
|
||||
std::fs::write(&list, playlist(p, &format!("t.{ext}"))).unwrap();
|
||||
// Its frames added up: a raw AAC file's duration is only guessed from its bitrate.
|
||||
let len = |p: &Path| {
|
||||
let p = p.display().to_string();
|
||||
async move {
|
||||
run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"packet=duration_time",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
&p,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
|
||||
.sum::<f64>()
|
||||
}
|
||||
};
|
||||
let short = len(&src).await - len(&list).await;
|
||||
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
|
||||
// Decodes without a complaint.
|
||||
assert!(
|
||||
ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"])
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Saved as a file of its own (#186): as long as the playlist, and it plays.
|
||||
let (n, body) = kept(&src, p).await.unwrap();
|
||||
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
|
||||
let bytes = parts.concat();
|
||||
assert_eq!(bytes.len() as u64, n);
|
||||
if ext == "mp3" {
|
||||
assert!(bytes.starts_with(b"ID3"), "the original's tag comes too");
|
||||
}
|
||||
let saved = dir.join(format!("saved.{ext}"));
|
||||
std::fs::write(&saved, &bytes).unwrap();
|
||||
assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}");
|
||||
assert!(
|
||||
ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"])
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
use crate::config;
|
||||
use crate::test_util::test_ctx;
|
||||
|
||||
#[tokio::test]
|
||||
async fn deadair_hands_out_each_persons_clock_and_saves_in_the_originals() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let db = &ctx.db;
|
||||
let u = db.create_user("dee", None, false).await.unwrap();
|
||||
db.subscribe(u, "f").await.unwrap();
|
||||
for sql in [
|
||||
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||
] {
|
||||
db.sql(sql).await.unwrap();
|
||||
}
|
||||
// Two minutes cut at the usual pause, both before the place saved; 40 s at the longest.
|
||||
let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
||||
"500": {"cuts": [[60000,100000]], "pieces": []}}"#;
|
||||
db.store_plans(7, plans).await.unwrap();
|
||||
db.set_position(u, "f", "g", 300, None).await.unwrap();
|
||||
let order = db::order_sql("published", "desc");
|
||||
let page = || {
|
||||
let (order, ctx) = (order.clone(), &ctx);
|
||||
async move {
|
||||
let mut rows = ctx.db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||
for_reader(ctx, u, &mut rows).await.unwrap();
|
||||
rows.remove(0)
|
||||
}
|
||||
};
|
||||
let off = page().await;
|
||||
assert_eq!((off.position, off.enclosures[0].deadair), (300, None), "off until turned on for the feed");
|
||||
assert!(!off.enclosures[0].deadair_pending);
|
||||
|
||||
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||
db.sql("DELETE FROM deadair_plans").await.unwrap();
|
||||
let waiting = page().await;
|
||||
assert!(waiting.enclosures[0].deadair_pending, "on, and its silences not found yet");
|
||||
db.store_plans(7, plans).await.unwrap();
|
||||
let on = page().await;
|
||||
assert!(!on.enclosures[0].deadair_pending);
|
||||
assert_eq!((on.position, on.duration, on.enclosures[0].deadair), (180, Some(480), Some(120)));
|
||||
// A longer pause of their own: their clock, and the playlist, are that plan's (#182).
|
||||
db.set_deadair_pause(u, "f", 500).await.unwrap();
|
||||
let long = page().await;
|
||||
assert_eq!((long.position, long.duration, long.enclosures[0].deadair), (260, Some(560), Some(40)));
|
||||
assert_eq!(plan_for(&ctx, u, "f", 7).await.unwrap().unwrap().saved_ms(), 40_000);
|
||||
db.set_deadair_pause(u, "f", 300).await.unwrap();
|
||||
|
||||
// Saved from the playlist at 3:20 of its 8:00: 5:20 of the original's 10:00, short of 75%.
|
||||
let playing = Playing::default();
|
||||
let plan = plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
|
||||
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
|
||||
let saved = on_save(&ctx, &playing, u, "f", "g", 200, Some(480)).await.unwrap();
|
||||
assert_eq!(saved, (320, Some(600)));
|
||||
assert_eq!(db.deadair_total(u).await.unwrap(), 0);
|
||||
// Past 75% (6:00 of the playlist is 8:00 of the original), counted once.
|
||||
for _ in 0..2 {
|
||||
on_save(&ctx, &playing, u, "f", "g", 360, None).await.unwrap();
|
||||
}
|
||||
assert_eq!(db.deadair_total(u).await.unwrap(), 120_000);
|
||||
// Playing the original again: saved as it is.
|
||||
playing.lock().unwrap().clear();
|
||||
assert_eq!(on_save(&ctx, &playing, u, "f", "g", 200, Some(600)).await.unwrap(), (200, Some(600)));
|
||||
// Turned off again: the original's clock, and no playlist.
|
||||
db.set_deadair(u, "f", Some(false)).await.unwrap();
|
||||
assert_eq!(page().await.enclosures[0].deadair, None);
|
||||
assert!(plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_people_play_one_file_each_in_their_own_version_and_place() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let db = &ctx.db;
|
||||
for sql in [
|
||||
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||
] {
|
||||
db.sql(sql).await.unwrap();
|
||||
}
|
||||
db.store_plans(
|
||||
7,
|
||||
r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
||||
"500": {"cuts": [[60000,100000]], "pieces": []}}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
|
||||
// one, and the original.
|
||||
let (usual, long, plain) = (
|
||||
db.create_user("usual", None, false).await.unwrap(),
|
||||
db.create_user("long", None, false).await.unwrap(),
|
||||
db.create_user("plain", None, false).await.unwrap(),
|
||||
);
|
||||
for u in [usual, long, plain] {
|
||||
db.subscribe(u, "f").await.unwrap();
|
||||
}
|
||||
for u in [usual, long] {
|
||||
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||
}
|
||||
db.set_deadair_pause(long, "f", 500).await.unwrap();
|
||||
// Each is handed their own playlist, as /media/7/deadair.m3u8 does.
|
||||
let playing = Playing::default();
|
||||
for u in [usual, long] {
|
||||
let plan = plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
|
||||
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
|
||||
}
|
||||
assert!(plan_for(&ctx, plain, "f", 7).await.unwrap().is_none());
|
||||
// All three save 3:20 of what they hear, as the position route does: each is read in
|
||||
// that person's own clock and kept as a place in the original, theirs alone.
|
||||
let mut kept = vec![];
|
||||
for u in [usual, long, plain] {
|
||||
let (secs, len) = on_save(&ctx, &playing, u, "f", "g", 200, None).await.unwrap();
|
||||
db.set_position(u, "f", "g", secs, len).await.unwrap();
|
||||
kept.push(secs);
|
||||
}
|
||||
assert_eq!(kept, [320, 240, 200], "two minutes cut before 3:20, forty seconds, none");
|
||||
// Each comes back to 3:20 of their own version, whatever the others saved.
|
||||
let order = db::order_sql("published", "desc");
|
||||
for (u, len) in [(usual, 480), (long, 560), (plain, 600)] {
|
||||
let mut rows = db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||
for_reader(&ctx, u, &mut rows).await.unwrap();
|
||||
assert_eq!((rows[0].position, rows[0].duration), (200, Some(len)), "user {u}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A real episode, for trying the cut on something other than a test tone:
|
||||
/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. Each pause's
|
||||
/// playlist is left beside it, `ep.mp3.300.m3u8`, to listen to.
|
||||
#[cfg(test)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn real_file() {
|
||||
let src = std::path::PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
|
||||
let t = std::time::Instant::now();
|
||||
let plans = analyse(&src).await.unwrap().expect("plans");
|
||||
eprintln!("analysed in {:.1?}", t.elapsed());
|
||||
let name = src.file_name().unwrap().to_string_lossy().into_owned();
|
||||
for (pause, p) in &plans {
|
||||
std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
|
||||
eprintln!(
|
||||
"{pause} ms: {} cuts, {:.1} min saved, {} pieces",
|
||||
p.cuts.len(),
|
||||
p.saved_ms() as f64 / 60_000.0,
|
||||
p.pieces.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
374
crates/ipx/src/directory.rs
Normal file
374
crates/ipx/src/directory.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
//! The Directory, from the directory service (#205): ipx asks it on its people's behalf, so the
|
||||
//! page never talks to it and never sees its token, and sends it the public feeds people here
|
||||
//! subscribe to when an admin has turned sharing on.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use ipx_directory::{FeedPage, Listing, Me, SnapshotEntry, SnapshotResult};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::catalogue::Sub;
|
||||
use crate::ctx::Ctx;
|
||||
use crate::{config, feed};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::Instrument;
|
||||
|
||||
/// How often the snapshot goes when nothing has woken the sender sooner.
|
||||
const SHARE_EVERY: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// What went wrong asking the directory, in words for an admin.
|
||||
#[derive(Debug)]
|
||||
pub enum DirError {
|
||||
/// No address, or no token, in the settings.
|
||||
NotConfigured,
|
||||
/// It answered, refusing: 401 is a token it does not know, 403 one without the access asked for.
|
||||
Refused(u16, String),
|
||||
/// It did not answer, or not with what was asked for.
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
impl Display for DirError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DirError::NotConfigured => {
|
||||
write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory.")
|
||||
}
|
||||
DirError::Refused(401, _) => {
|
||||
write!(f, "The directory does not accept this server's token. It may have been revoked.")
|
||||
}
|
||||
DirError::Refused(403, msg) => write!(f, "The directory refused: {msg}."),
|
||||
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
|
||||
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
|
||||
DirError::Unreachable(e) => write!(f, "The directory could not be reached: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the snapshot last went, and what came of it, for the admin page.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Shared {
|
||||
pub at: i64,
|
||||
#[serde(flatten)]
|
||||
pub result: Option<SnapshotResult>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn target(cfg: &config::Config) -> Option<(String, String)> {
|
||||
let url = cfg.general.directory_url.trim().trim_end_matches('/');
|
||||
let token = cfg.general.directory_token.trim();
|
||||
(!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned()))
|
||||
}
|
||||
|
||||
pub fn configured(cfg: &config::Config) -> bool {
|
||||
target(cfg).is_some()
|
||||
}
|
||||
|
||||
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
|
||||
/// has answered, so a slow read of a large answer showed under no span (#250).
|
||||
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
|
||||
async fn call<T: DeserializeOwned>(
|
||||
ctx: &Ctx,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<&[SnapshotEntry]>,
|
||||
) -> Result<T, DirError> {
|
||||
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
|
||||
let mut req =
|
||||
ctx.local_client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(Duration::from_secs(20));
|
||||
// The directory's spans join this request's trace (#213).
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
// A snapshot is about 100 KB of JSON, sent gzipped; the directory unpacks it (#253).
|
||||
if let Some(b) = body {
|
||||
use std::io::Write as _;
|
||||
let json = serde_json::to_vec(b).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&json).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let packed = gz.finish().map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
req = req
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.header(reqwest::header::CONTENT_ENCODING, "gzip")
|
||||
.body(packed);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let msg = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
return Err(DirError::Refused(status.as_u16(), msg));
|
||||
}
|
||||
// A removal or an unblock answers 204, with nothing to read: that reads as null, which is ().
|
||||
let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
tracing::Span::current().record("http.response.body.size", body.len());
|
||||
serde_json::from_slice(if body.is_empty() { b"null" } else { &body })
|
||||
.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||
}
|
||||
|
||||
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
|
||||
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC)
|
||||
}
|
||||
|
||||
pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/me", None).await
|
||||
}
|
||||
|
||||
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/feeds", None).await?;
|
||||
note_art(got.iter());
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await?;
|
||||
note_art(got.iter());
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
|
||||
let got: FeedPage = call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await?;
|
||||
note_art(std::iter::once(&got.listing));
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
/// Artwork the directory has listed, which `/api/art` serves though no feed here names it: the
|
||||
/// Directory shows podcasts nobody here subscribes to, and drew their initials instead (#273).
|
||||
///
|
||||
/// shortcut: in memory, one address a listed feed, forgotten on a restart until the Directory is
|
||||
/// next read, which the page does when it loads; a table if that ever shows as initials.
|
||||
static LISTED_ART: LazyLock<std::sync::Mutex<HashSet<String>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn note_art<'a>(listings: impl Iterator<Item = &'a Listing>) {
|
||||
let mut art = LISTED_ART.lock().unwrap_or_else(|e| e.into_inner());
|
||||
art.extend(listings.filter_map(|l| l.image.clone()));
|
||||
}
|
||||
|
||||
pub fn listed_art(url: &str) -> bool {
|
||||
LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()).contains(url)
|
||||
}
|
||||
|
||||
// Administering the directory (#206): each needs an rwa token, which the directory checks.
|
||||
|
||||
/// Removes a feed and blocks it, so no snapshot brings it back.
|
||||
pub async fn remove(ctx: &Ctx, id: &str) -> Result<(), DirError> {
|
||||
call(ctx, reqwest::Method::DELETE, &format!("/v1/feeds/{}", seg(id)), None).await
|
||||
}
|
||||
|
||||
pub async fn refresh(ctx: &Ctx, id: &str) -> Result<serde::de::IgnoredAny, DirError> {
|
||||
call(ctx, reqwest::Method::POST, &format!("/v1/feeds/{}/refresh", seg(id)), None).await
|
||||
}
|
||||
|
||||
pub async fn blocked(ctx: &Ctx) -> Result<Vec<ipx_directory::Blocked>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/blocked", None).await
|
||||
}
|
||||
|
||||
pub async fn unblock(ctx: &Ctx, id: &str) -> Result<(), DirError> {
|
||||
call(ctx, reqwest::Method::DELETE, &format!("/v1/blocked/{}", seg(id)), None).await
|
||||
}
|
||||
|
||||
pub async fn instances(ctx: &Ctx) -> Result<Vec<ipx_directory::Instance>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/instances", None).await
|
||||
}
|
||||
|
||||
/// Drops one server's snapshot, and its counts with it. It comes back with that server's next
|
||||
/// one: to keep a server out, revoke its token.
|
||||
pub async fn drop_instance(ctx: &Ctx, name: &str) -> Result<(), DirError> {
|
||||
call(ctx, reqwest::Method::DELETE, &format!("/v1/instances/{}", seg(name)), None).await
|
||||
}
|
||||
|
||||
/// Has the snapshot sent again now, changed or not.
|
||||
pub fn resend_now(ctx: &Ctx) {
|
||||
ctx.directory_resend.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
ctx.directory_wake.notify_one();
|
||||
}
|
||||
|
||||
/// Whether this server's token may administer the directory.
|
||||
pub async fn can_admin(ctx: &Ctx) -> bool {
|
||||
me(ctx).await.is_ok_and(|m| m.access == ipx_directory::config::Access::Rwa)
|
||||
}
|
||||
|
||||
/// Whether a feed is someone's own: marked private, given a username or password, or at an
|
||||
/// address that carries a key (`feed::url_looks_private`).
|
||||
pub fn looks_private(feed: &config::Feed) -> bool {
|
||||
feed.private
|
||||
|| feed.username.is_some()
|
||||
|| feed.password.is_some()
|
||||
|| feed.password_env.is_some()
|
||||
|| feed::url_looks_private(&feed.url)
|
||||
}
|
||||
|
||||
/// What the snapshot lists: every feed in the catalogue, with how many people here subscribe,
|
||||
/// except an OPML or a Patreon creator itself, which is sent as the feeds in it, and anything
|
||||
/// private, a feed inside a private OPML included. An OPML not read yet is not known to be one
|
||||
/// and goes too; the directory lists only what it has read as a feed.
|
||||
pub fn snapshot_entries(catalogue: &[Sub], counts: &HashMap<String, i64>) -> Vec<SnapshotEntry> {
|
||||
let by_id: HashMap<&str, &config::Feed> = catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||
let is_group: HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
||||
catalogue
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
!is_group.contains(s.id.as_str())
|
||||
&& !feed::is_patreon_creator(&s.cfg.url)
|
||||
&& !looks_private(&s.cfg)
|
||||
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
|
||||
})
|
||||
.map(|s| SnapshotEntry {
|
||||
url: s.cfg.url.clone(),
|
||||
subscribers: counts.get(&s.id).copied().unwrap_or(0),
|
||||
category: s.cfg.category.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sends the snapshot now.
|
||||
async fn snapshot(ctx: &Ctx) -> Result<Vec<SnapshotEntry>, DirError> {
|
||||
let catalogue = crate::catalogue::subscriptions(ctx).await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let counts = ctx.db.subscriber_counts().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
Ok(snapshot_entries(&catalogue, &counts))
|
||||
}
|
||||
|
||||
pub async fn share(ctx: &Ctx, entries: &[SnapshotEntry]) -> Result<SnapshotResult, DirError> {
|
||||
let r: SnapshotResult = call(ctx, reqwest::Method::POST, "/v1/snapshot", Some(entries)).await?;
|
||||
if r.refused_private > 0 {
|
||||
// The directory caught what this side should have: worth knowing which check differs.
|
||||
tracing::warn!(refused = r.refused_private, "the directory refused feeds as private that ipx sent");
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Sends the snapshot every half hour while sharing is on, and at once when the settings change.
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
// What went last, where, and when: a scan wakes this every half minute or so, and the same
|
||||
// 1,500 feeds went each time, the directory rewriting its rows for nothing (#211).
|
||||
let mut last: Option<(Vec<u8>, Instant)> = None;
|
||||
loop {
|
||||
let cfg = ctx.cfg();
|
||||
if cfg.general.directory_share && configured(&cfg) {
|
||||
let got = match snapshot(&ctx).await {
|
||||
Ok(entries) => {
|
||||
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
|
||||
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
|
||||
if forced || resend(last.as_ref(), &key) {
|
||||
let span =
|
||||
tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
||||
let got = Instrument::instrument(share(&ctx, &entries), span).await;
|
||||
if got.is_ok() {
|
||||
last = Some((key, Instant::now()));
|
||||
}
|
||||
Some(got)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => Some(Err(e)),
|
||||
};
|
||||
if let Some(got) = got {
|
||||
match &got {
|
||||
Ok(r) => tracing::info!(
|
||||
accepted = r.accepted,
|
||||
refused_private = r.refused_private,
|
||||
refused_blocked = r.refused_blocked,
|
||||
"shared with the directory"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
|
||||
}
|
||||
let (result, error) = match got {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
};
|
||||
*ctx.directory_shared.lock().expect("a panic elsewhere poisoned this lock") =
|
||||
Some(Shared { at: crate::db::now(), result, error });
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(SHARE_EVERY) => {}
|
||||
_ = ctx.directory_wake.notified() => {}
|
||||
_ = stop.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to send the snapshot `key` stands for: one that changed, or went to another directory
|
||||
/// or with another token, goes now; the same again only once SHARE_EVERY has passed, so a
|
||||
/// directory that lost it, or dropped this server's counts, has it back within the half hour.
|
||||
fn resend(last: Option<&(Vec<u8>, Instant)>, key: &[u8]) -> bool {
|
||||
last.is_none_or(|(k, at)| k != key || at.elapsed() >= SHARE_EVERY)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artwork_the_directory_listed_is_let_through_and_nothing_else() {
|
||||
let listing = |image: Option<&str>| Listing {
|
||||
id: "x".into(),
|
||||
url: "https://x.example/feed".into(),
|
||||
title: None,
|
||||
image: image.map(str::to_owned),
|
||||
category: None,
|
||||
subcategory: None,
|
||||
podcast: true,
|
||||
subscribers: 1,
|
||||
};
|
||||
note_art([listing(Some("https://img.example/cover.jpg")), listing(None)].iter());
|
||||
assert!(listed_art("https://img.example/cover.jpg"));
|
||||
assert!(!listed_art("http://192.168.1.1/admin"));
|
||||
}
|
||||
|
||||
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
|
||||
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
|
||||
edit(&mut cfg);
|
||||
Sub { id: id.into(), cfg, managed: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_holds_public_feeds_and_never_a_private_one() {
|
||||
let catalogue = vec![
|
||||
sub("public", "https://example.com/feed.xml", |_| {}),
|
||||
sub("marked", "https://example.com/mine.xml", |f| f.private = true),
|
||||
sub("password", "https://example.com/locked.xml", |f| f.username = Some("me".into())),
|
||||
sub("paid", "https://feeds.supercast.com/feeds/abc", |_| {}),
|
||||
sub("keyed", "https://example.com/feed?key=k", |_| {}),
|
||||
// An OPML: sent as the feeds in it, never itself.
|
||||
sub("list", "https://example.com/list.opml", |_| {}),
|
||||
sub("in-list", "https://example.com/a.xml", |f| f.group = Some("list".into())),
|
||||
// Inside a private OPML: as private as the OPML.
|
||||
sub("secret-list", "https://example.com/l.opml?token=t", |_| {}),
|
||||
sub("in-secret", "https://example.com/b.xml", |f| f.group = Some("secret-list".into())),
|
||||
];
|
||||
let counts = HashMap::from([("public".to_string(), 3), ("marked".to_string(), 1)]);
|
||||
let got = snapshot_entries(&catalogue, &counts);
|
||||
let urls: Vec<(&str, i64)> = got.iter().map(|e| (e.url.as_str(), e.subscribers)).collect();
|
||||
assert_eq!(urls, [("https://example.com/feed.xml", 3), ("https://example.com/a.xml", 0)]);
|
||||
let patreon = vec![sub("creator", "https://www.patreon.com/c/someone", |_| {})];
|
||||
assert!(snapshot_entries(&patreon, &counts).is_empty(), "a Patreon creator goes as its shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_asked_without_an_address_and_a_token() {
|
||||
let mut cfg = config::Config::default();
|
||||
assert!(!configured(&cfg));
|
||||
cfg.general.directory_url = "https://ipx-dir.example/".into();
|
||||
assert!(!configured(&cfg));
|
||||
cfg.general.directory_token = "t".into();
|
||||
assert_eq!(target(&cfg), Some(("https://ipx-dir.example".into(), "t".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_snapshot_is_not_sent_again_within_the_half_hour() {
|
||||
let now = std::time::Instant::now();
|
||||
assert!(resend(None, b"a"));
|
||||
assert!(!resend(Some(&(b"a".to_vec(), now)), b"a"));
|
||||
assert!(resend(Some(&(b"a".to_vec(), now)), b"b"));
|
||||
if let Some(long_ago) = now.checked_sub(SHARE_EVERY) {
|
||||
assert!(resend(Some(&(b"a".to_vec(), long_ago)), b"a"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::config::{Config, Feed as FeedCfg, Organize};
|
||||
use chrono::Local;
|
||||
use tokio::fs::File;
|
||||
use url::Url;
|
||||
|
||||
/// Forbidden characters that were separating words: they become "-" so the words stay
|
||||
/// apart. The original's stringCleaning() deleted them, turning "Show | Series" into
|
||||
@@ -83,18 +86,10 @@ pub fn filename_for(url: &str, content_disposition: Option<&str>) -> String {
|
||||
if let Some(name) = content_disposition.and_then(disposition_filename) {
|
||||
return sanitize(&name);
|
||||
}
|
||||
let from_url = url::Url::parse(url)
|
||||
let from_url = Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.path_segments()
|
||||
.and_then(|s| s.filter(|p| !p.is_empty()).next_back())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.map(|seg| {
|
||||
percent_encoding::percent_decode_str(&seg)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.and_then(|u| u.path_segments().and_then(|mut s| s.rfind(|p| !p.is_empty())).map(str::to_owned))
|
||||
.map(|seg| percent_encoding::percent_decode_str(&seg).decode_utf8_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
sanitize(&from_url)
|
||||
}
|
||||
@@ -105,11 +100,7 @@ fn disposition_filename(header: &str) -> Option<String> {
|
||||
// RFC 5987 form wins when both are present.
|
||||
if let Some(v) = part.strip_prefix("filename*=") {
|
||||
let raw = v.rsplit('\'').next().unwrap_or(v);
|
||||
return Some(
|
||||
percent_encoding::percent_decode_str(raw)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
return Some(percent_encoding::percent_decode_str(raw).decode_utf8_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
for part in header.split(';') {
|
||||
@@ -145,8 +136,8 @@ pub async fn download(
|
||||
mut on_progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Downloaded> {
|
||||
let mut req = client.get(url);
|
||||
if let Some(user) = &feed_cfg.username {
|
||||
req = req.basic_auth(user, feed_cfg.password());
|
||||
if let Some((user, password)) = feed_cfg.auth_for(url) {
|
||||
req = req.basic_auth(user, password);
|
||||
}
|
||||
let resp = req.send().await.context("connecting")?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -155,27 +146,23 @@ pub async fn download(
|
||||
|
||||
let name = filename_for(
|
||||
resp.url().as_str(),
|
||||
resp.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
resp.headers().get(reqwest::header::CONTENT_DISPOSITION).and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let total = resp.content_length();
|
||||
|
||||
let tmp_dir = cfg.general.download_dir.join(".ipx-incomplete");
|
||||
tokio::fs::create_dir_all(&tmp_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
||||
tokio::fs::create_dir_all(&tmp_dir).await.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
||||
let tmp = tmp_dir.join(&name);
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", tmp.display()))?;
|
||||
let mut file = File::create(&tmp).await.with_context(|| format!("creating {}", tmp.display()))?;
|
||||
let mut done: u64 = 0;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("reading body")?;
|
||||
file.write_all(&chunk).await?;
|
||||
done += chunk.len() as u64;
|
||||
crate::metrics::STATS.download_bytes.fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
on_progress(done, total);
|
||||
}
|
||||
file.flush().await?;
|
||||
@@ -197,13 +184,9 @@ pub async fn download(
|
||||
/// Moves a completed download into its folder. Same filesystem as the incomplete dir, so
|
||||
/// this is a rename rather than the original's copy-then-unlink.
|
||||
pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
tokio::fs::create_dir_all(dir).await.with_context(|| format!("creating {}", dir.display()))?;
|
||||
let dest = unique_path(dir, &d.name);
|
||||
tokio::fs::rename(&d.tmp, &dest)
|
||||
.await
|
||||
.with_context(|| format!("moving into {}", dest.display()))?;
|
||||
tokio::fs::rename(&d.tmp, &dest).await.with_context(|| format!("moving into {}", dest.display()))?;
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
@@ -214,8 +197,6 @@ pub fn remove(path: &Path) -> std::io::Result<()> {
|
||||
if path.is_dir() {
|
||||
return std::fs::remove_dir_all(path);
|
||||
}
|
||||
// Its DeadAir copy goes with it, if it has one; it is no use without the original (#171).
|
||||
let _ = std::fs::remove_file(crate::deadair::copy_path(path));
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
|
||||
@@ -268,7 +249,7 @@ async fn sniff(path: &Path) -> Result<Sniffed> {
|
||||
|
||||
async fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut f = tokio::fs::File::open(path).await?;
|
||||
let mut f = File::open(path).await?;
|
||||
let mut buf = vec![0u8; n];
|
||||
let read = f.read(&mut buf).await?;
|
||||
buf.truncate(read);
|
||||
@@ -281,15 +262,8 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
let stem = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(name);
|
||||
let ext = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{e}"))
|
||||
.unwrap_or_default();
|
||||
let stem = Path::new(name).file_stem().and_then(|s| s.to_str()).unwrap_or(name);
|
||||
let ext = Path::new(name).extension().and_then(|e| e.to_str()).map(|e| format!(".{e}")).unwrap_or_default();
|
||||
for n in 2..1000 {
|
||||
let candidate = dir.join(format!("{stem}-{n}{ext}"));
|
||||
if !candidate.exists() {
|
||||
@@ -306,14 +280,9 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
||||
/// letting the sanitizer eat the separator.
|
||||
pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str>) -> String {
|
||||
match cfg.general.organize {
|
||||
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
|
||||
Organize::Date => Local::now().format("%m-%d-%Y").to_string(),
|
||||
Organize::Feed => {
|
||||
let raw = feed_cfg
|
||||
.folder
|
||||
.as_deref()
|
||||
.or(title)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(id);
|
||||
let raw = feed_cfg.folder.as_deref().or(title).filter(|s| !s.trim().is_empty()).unwrap_or(id);
|
||||
raw.split('/')
|
||||
.map(str::trim)
|
||||
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
|
||||
@@ -346,7 +315,8 @@ pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
|
||||
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
|
||||
let norm = |s: &str| {
|
||||
let mut out = String::from(" ");
|
||||
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
|
||||
let words = s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty());
|
||||
for w in words {
|
||||
out.push_str(&w.to_lowercase());
|
||||
out.push(' ');
|
||||
}
|
||||
@@ -410,19 +380,13 @@ mod tests {
|
||||
"ep1.mp3",
|
||||
"query string is not part of the name"
|
||||
);
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None),
|
||||
"My Show Ep 1.mp3"
|
||||
);
|
||||
assert_eq!(filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None), "My Show Ep 1.mp3");
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/dl?id=9", Some("attachment; filename=\"real name.mp3\"")),
|
||||
"real name.mp3",
|
||||
"Content-Disposition overrides the URL"
|
||||
);
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")),
|
||||
"café.mp3"
|
||||
);
|
||||
assert_eq!(filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")), "café.mp3");
|
||||
assert_eq!(filename_for("https://x.com/", None), "download");
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/a/../../etc/passwd", None),
|
||||
@@ -436,9 +400,21 @@ mod tests {
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = "/tmp".into();
|
||||
let mut f = crate::config::Feed {
|
||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
||||
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false,
|
||||
url: "u".into(),
|
||||
folder: Some("Subscriptions/Some | Show".into()),
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||
|
||||
@@ -46,6 +46,12 @@ pub mod feeds {
|
||||
/// are re-derived instead. Customising one promotes it to config.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub managed: bool,
|
||||
/// SHA-256 of the body last read, in hex: the same body again is read as "not modified"
|
||||
/// (#242). Added after the table was, so `create_missing` adds it, and `same_since`.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub body_hash: Option<String>,
|
||||
/// When the body last changed: the longer ago, the less often the feed is checked (#242).
|
||||
pub same_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -72,6 +78,9 @@ pub mod entries {
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
pub first_seen: i64,
|
||||
/// The item's Markdown, when its feed sends it beside the HTML (#285).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub markdown: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub image: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
@@ -151,6 +160,12 @@ pub mod users {
|
||||
pub theme: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub theme_mode: Option<String>,
|
||||
/// Their own TypeSafe key, which Jev judges their Signals' items on (#254). Never sent
|
||||
/// back to the page.
|
||||
// shortcut: kept as it is, since every call needs it whole; encrypt it with a key of the
|
||||
// server's if the database is ever shared with anything else.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub typesafe_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -204,6 +219,21 @@ pub mod subscriptions {
|
||||
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub pinned: bool,
|
||||
/// A feed from an OPML or Patreon list this person keeps when the list drops it, or when
|
||||
/// they leave the list: it becomes a feed of its own instead of going (#280).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub kept: bool,
|
||||
/// Left out of All Subscriptions: this feed, or on an OPML or Patreon creator every show
|
||||
/// in it (#288).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_all: bool,
|
||||
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_signals: bool,
|
||||
/// Every item of the feed first seen before this is read by this person (#347): unread
|
||||
/// lists start here instead of reading the whole back catalogue. NULL knows nothing, and
|
||||
/// the daily reap moves it up (`Db::advance_unread_from`).
|
||||
pub unread_from: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
@@ -276,6 +306,71 @@ pub mod hidden {
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// A feed someone makes from their own subscriptions (#254): the items Jev says fit its
|
||||
/// keywords, whichever feed they came from.
|
||||
pub mod signals {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signals")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub user_id: i64,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub name: String,
|
||||
/// JSON array of strings.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub keywords: String,
|
||||
/// What its owner means by it, in a sentence of their own, when the name and the words
|
||||
/// are not enough (#259).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
|
||||
/// initials (#261).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub icon: Option<String>,
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// What Jev said of each item a Signal has been asked about, fitting or not: an item with no row
|
||||
/// here is still to be asked.
|
||||
pub mod signal_verdicts {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signal_verdicts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub signal_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
pub hit: bool,
|
||||
/// A picked-out item to ask about again, since its Signal changed (#276). It stays in the
|
||||
/// Signal's list until Jev has answered the new question.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub recheck: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::signals::Entity",
|
||||
from = "Column::SignalId",
|
||||
to = "super::signals::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod sessions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
@@ -355,24 +450,20 @@ pub mod settings {
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// DeadAir's copy of an enclosure: the same file with long silences shortened (issue #171).
|
||||
/// `cuts` is what the copy leaves out, as `[[start_ms, end_ms], ...]` of the original's time,
|
||||
/// which is what converts positions between the two. `path` is NULL for a file that cannot have
|
||||
/// one (video, or not MP3 or AAC), so it is looked at once and not every pass.
|
||||
pub mod deadair {
|
||||
/// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
|
||||
/// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
|
||||
/// AAC, nothing to cut), so that one is looked at once and not every pass.
|
||||
pub mod deadair_plans {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "deadair")]
|
||||
#[sea_orm(table_name = "deadair_plans")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub enclosure_id: i64,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub path: Option<String>,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub cuts: String,
|
||||
pub saved_ms: i64,
|
||||
/// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy again.
|
||||
pub plans: String,
|
||||
/// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
|
||||
pub made_at: i64,
|
||||
}
|
||||
|
||||
@@ -395,6 +486,8 @@ pub mod deadair_prefs {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
pub wanted: bool,
|
||||
/// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
|
||||
pub pause_ms: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
@@ -11,13 +16,31 @@ use tokio::sync::{broadcast, mpsc};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "ev", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
FeedStart { feed: String },
|
||||
FeedSkip { feed: String, reason: String },
|
||||
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
||||
FeedError { feed: String, msg: String },
|
||||
FeedStart {
|
||||
feed: String,
|
||||
},
|
||||
FeedSkip {
|
||||
feed: String,
|
||||
reason: String,
|
||||
},
|
||||
FeedDone {
|
||||
feed: String,
|
||||
new: usize,
|
||||
downloaded: usize,
|
||||
failed: usize,
|
||||
torrents: usize,
|
||||
},
|
||||
FeedError {
|
||||
feed: String,
|
||||
msg: String,
|
||||
},
|
||||
/// The feed answered from a new address after redirects that all said it moved for good,
|
||||
/// and the catalogue now has that address (#115).
|
||||
FeedMoved { feed: String, from: String, to: String },
|
||||
FeedMoved {
|
||||
feed: String,
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
Progress {
|
||||
feed: String,
|
||||
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
||||
@@ -29,18 +52,95 @@ pub enum Event {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total: Option<u64>,
|
||||
},
|
||||
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
|
||||
DownloadError { feed: String, enclosure: i64, url: String, msg: String },
|
||||
TorrentDeferred { feed: String, url: String },
|
||||
Reaped { path: String, bytes: u64 },
|
||||
DownloadDone {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
url: String,
|
||||
path: String,
|
||||
bytes: u64,
|
||||
},
|
||||
DownloadError {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
url: String,
|
||||
msg: String,
|
||||
},
|
||||
TorrentDeferred {
|
||||
feed: String,
|
||||
url: String,
|
||||
},
|
||||
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
|
||||
DeadairDone {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
saved_s: i64,
|
||||
},
|
||||
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
||||
SignalHits {
|
||||
user: i64,
|
||||
hits: usize,
|
||||
},
|
||||
Reaped {
|
||||
path: String,
|
||||
bytes: u64,
|
||||
},
|
||||
/// Terminal: a client that asked for work stops reading here.
|
||||
ScanDone { feeds: usize },
|
||||
ReapDone { files: usize, bytes: u64 },
|
||||
Status { feeds: usize, pending: i64, downloaded: i64 },
|
||||
Error { msg: String },
|
||||
ScanDone {
|
||||
feeds: usize,
|
||||
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
|
||||
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
|
||||
#[serde(default)]
|
||||
only: Option<String>,
|
||||
#[serde(default)]
|
||||
forced: bool,
|
||||
},
|
||||
ReapDone {
|
||||
files: usize,
|
||||
bytes: u64,
|
||||
},
|
||||
Status {
|
||||
feeds: usize,
|
||||
pending: i64,
|
||||
downloaded: i64,
|
||||
},
|
||||
Error {
|
||||
msg: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The feed this is about, for sending it only to those who read that feed.
|
||||
pub fn feed(&self) -> Option<&str> {
|
||||
match self {
|
||||
Event::FeedStart { feed }
|
||||
| Event::FeedSkip { feed, .. }
|
||||
| Event::FeedDone { feed, .. }
|
||||
| Event::FeedError { feed, .. }
|
||||
| Event::FeedMoved { feed, .. }
|
||||
| Event::Progress { feed, .. }
|
||||
| Event::DownloadDone { feed, .. }
|
||||
| Event::DownloadError { feed, .. }
|
||||
| Event::TorrentDeferred { feed, .. }
|
||||
| Event::DeadairDone { feed, .. } => Some(feed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a page signed in as this person is sent it. Everyone had every event, so a key in
|
||||
/// a paid feed's path, which `redacted` keeps, reached people who do not read the feed (#325).
|
||||
/// An admin has them all, for the log.
|
||||
pub fn is_for(&self, user: &crate::db::User, mine: &HashSet<String>) -> bool {
|
||||
if user.is_admin {
|
||||
return true;
|
||||
}
|
||||
match self {
|
||||
Event::SignalHits { user: u, .. } => *u == user.id,
|
||||
// Paths of files on disk, everyone's.
|
||||
Event::Reaped { .. } => false,
|
||||
ev => ev.feed().is_none_or(|f| mine.contains(f)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. })
|
||||
}
|
||||
@@ -68,10 +168,9 @@ impl Event {
|
||||
Event::Reaped { path, bytes } => {
|
||||
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::ReapDone { files, bytes } => format!(
|
||||
"deleted {files} old file(s), {:.1} MB",
|
||||
*bytes as f64 / 1_048_576.0
|
||||
),
|
||||
Event::ReapDone { files, bytes } => {
|
||||
format!("deleted {files} old file(s), {:.1} MB", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::Status { feeds, pending, downloaded } => {
|
||||
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
|
||||
}
|
||||
@@ -79,7 +178,11 @@ impl Event {
|
||||
// Noise in a terminal; a UI still gets them on the socket.
|
||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
|
||||
Event::DeadairDone { feed, saved_s, .. } => {
|
||||
format!("{feed}: DeadAir found the silences, {saved_s} s shorter")
|
||||
}
|
||||
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
|
||||
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -133,6 +236,7 @@ impl Emitter {
|
||||
}
|
||||
|
||||
pub fn emit(&self, e: Event) {
|
||||
let e = e.redacted();
|
||||
// Also log it. Scans and downloads travel as events, not tracing calls, so
|
||||
// without this the log view shows only startup and HTTP lines and none of the
|
||||
// work the daemon is actually doing. Progress goes to debug: it fires on every
|
||||
@@ -141,16 +245,48 @@ impl Emitter {
|
||||
// line per feed per tick for "not due yet" would push everything worth reading
|
||||
// out of the buffer within a few minutes.
|
||||
log_event(&e, self.tx.is_some());
|
||||
if let Some(tx) = &self.tx {
|
||||
// A feed not due changes nothing a listener shows, and a scan has one for nearly every
|
||||
// feed: 1,500 at once overflowed the channel's 1,024 for a page slow to read (#197).
|
||||
let routine = matches!(&e, Event::FeedSkip { reason, .. } if reason.starts_with("not due"));
|
||||
if let Some(tx) = &self.tx
|
||||
&& !routine
|
||||
{
|
||||
// An error here only means nobody is listening yet.
|
||||
let _ = tx.send(e.clone());
|
||||
}
|
||||
if self.print && let Some(line) = e.human() {
|
||||
if self.print
|
||||
&& let Some(line) = e.human()
|
||||
{
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
|
||||
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
|
||||
/// so do the ones in an error's words (#238).
|
||||
fn redacted(mut self) -> Self {
|
||||
match &mut self {
|
||||
Event::DownloadError { url, msg, .. } => {
|
||||
*url = crate::feed::redact(url);
|
||||
*msg = crate::feed::redact_text(msg);
|
||||
}
|
||||
// Progress and DownloadDone too: a Patreon file's address carries its signature (#292).
|
||||
Event::TorrentDeferred { url, .. } | Event::Progress { url, .. } | Event::DownloadDone { url, .. } => {
|
||||
*url = crate::feed::redact(url)
|
||||
}
|
||||
Event::FeedMoved { from, to, .. } => {
|
||||
*from = crate::feed::redact(from);
|
||||
*to = crate::feed::redact(to);
|
||||
}
|
||||
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = crate::feed::redact_text(msg),
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
|
||||
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
|
||||
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
|
||||
@@ -190,7 +326,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.
|
||||
if matches!(e, Event::Status { .. }) {
|
||||
return line!(info, "ipx::io", format!("<- {json}"));
|
||||
@@ -219,9 +355,7 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
||||
/// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended
|
||||
/// any `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
||||
/// A future, since reading the counts is a database query.
|
||||
pub type StatusFn = std::sync::Arc<
|
||||
dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync,
|
||||
>;
|
||||
pub type StatusFn = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>;
|
||||
|
||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||
pub async fn serve(
|
||||
@@ -233,14 +367,12 @@ pub async fn serve(
|
||||
// A socket file left by a crashed daemon would block the bind; a live one was already
|
||||
// rejected by the caller's daemon_is_live() check.
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.with_context(|| format!("removing stale socket {}", path.display()))?;
|
||||
std::fs::remove_file(&path).with_context(|| format!("removing stale socket {}", path.display()))?;
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&path)
|
||||
.with_context(|| format!("binding {}", path.display()))?;
|
||||
let listener = UnixListener::bind(&path).with_context(|| format!("binding {}", path.display()))?;
|
||||
tracing::info!(socket = %path.display(), "listening");
|
||||
|
||||
loop {
|
||||
@@ -295,7 +427,7 @@ async fn handle(
|
||||
Ok(cmd @ (Command::Status | Command::Reload)) => {
|
||||
tracing::info!(target: "ipx::io", "-> {line}");
|
||||
let ev = status(cmd).await;
|
||||
log_event(&ev, true);
|
||||
log_event(&ev, /* wire */ true);
|
||||
let _ = reply.send(ev).await;
|
||||
}
|
||||
Ok(cmd) => {
|
||||
@@ -327,7 +459,7 @@ pub async fn reload(path: &Path) -> Result<()> {
|
||||
}
|
||||
anyhow::bail!("the daemon closed the connection without answering")
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), answer).await.context("the daemon did not answer in 10s")?
|
||||
tokio::time::timeout(Duration::from_secs(10), answer).await.context("the daemon did not answer in 10s")?
|
||||
}
|
||||
|
||||
/// Sends one command to a running daemon and prints the events it produces.
|
||||
@@ -359,11 +491,93 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feeds_events_go_to_those_who_read_it() {
|
||||
let person = |id, is_admin| crate::db::User {
|
||||
id,
|
||||
name: "x".into(),
|
||||
pass_hash: None,
|
||||
is_admin,
|
||||
created: None,
|
||||
last_login: None,
|
||||
};
|
||||
let (me, admin) = (person(1, false), person(2, true));
|
||||
let mine: std::collections::HashSet<String> = ["mine".to_string()].into();
|
||||
let error = |feed: &str| Event::FeedError {
|
||||
feed: feed.into(),
|
||||
msg: "error sending request for url (https://feeds.supercast.com/feeds/KEY)".into(),
|
||||
};
|
||||
assert!(error("mine").is_for(&me, &mine));
|
||||
assert!(!error("theirs").is_for(&me, &mine), "a paid feed's key in its path (#325)");
|
||||
assert!(error("theirs").is_for(&admin, &mine), "an admin's log has everything");
|
||||
assert!(Event::ScanDone { feeds: 3, only: None, forced: false }.is_for(&me, &mine));
|
||||
assert!(Event::SignalHits { user: 1, hits: 2 }.is_for(&me, &mine));
|
||||
assert!(!Event::SignalHits { user: 3, hits: 2 }.is_for(&me, &mine));
|
||||
assert!(!Event::Reaped { path: "/downloads/Theirs/ep.mp3".into(), bytes: 1 }.is_for(&me, &mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_not_due_stays_off_the_live_updates() {
|
||||
let (tx, mut rx) = broadcast::channel(4);
|
||||
let out = Emitter::socket(tx, false);
|
||||
for i in 0..10 {
|
||||
out.emit(Event::FeedSkip { feed: format!("f{i}"), reason: "not due for 5m".into() });
|
||||
}
|
||||
out.emit(Event::FeedSkip { feed: "g".into(), reason: "not modified".into() });
|
||||
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_goes_out_names_no_private_feeds_key() {
|
||||
let (tx, mut rx) = broadcast::channel(8);
|
||||
let out = Emitter::socket(tx, false);
|
||||
let key = "auth=SECRET";
|
||||
out.emit(Event::FeedError {
|
||||
feed: "f".into(),
|
||||
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
|
||||
});
|
||||
out.emit(Event::DownloadError {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
|
||||
});
|
||||
out.emit(Event::FeedMoved {
|
||||
feed: "f".into(),
|
||||
from: format!("https://x/rss?{key}"),
|
||||
to: format!("https://y/rss?{key}"),
|
||||
});
|
||||
out.emit(Event::Progress {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
file: "ep.mp3".into(),
|
||||
done: 1,
|
||||
total: None,
|
||||
});
|
||||
out.emit(Event::DownloadDone {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
path: "/downloads/ep.mp3".into(),
|
||||
bytes: 1,
|
||||
});
|
||||
// What every open page is sent, whoever's feed it is (#238, #292).
|
||||
for _ in 0..5 {
|
||||
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
|
||||
assert!(!sent.contains("SECRET"), "{sent}");
|
||||
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
||||
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||
let wire = serde_json::to_value(&e).unwrap();
|
||||
assert_eq!((wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()), (Some("feed_moved"), Some("http://a/f"), Some("https://a/f")));
|
||||
assert_eq!(
|
||||
(wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()),
|
||||
(Some("feed_moved"), Some("http://a/f"), Some("https://a/f"))
|
||||
);
|
||||
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
|
||||
}
|
||||
|
||||
@@ -402,29 +616,16 @@ mod tests {
|
||||
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
|
||||
|
||||
// total is omitted rather than null when the server sent no length.
|
||||
let ev = Event::Progress {
|
||||
feed: "a".into(),
|
||||
enclosure: 1,
|
||||
url: "u".into(),
|
||||
file: "f".into(),
|
||||
done: 1,
|
||||
total: None,
|
||||
};
|
||||
let ev =
|
||||
Event::Progress { feed: "a".into(), enclosure: 1, url: "u".into(), file: "f".into(), done: 1, total: None };
|
||||
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_completion_events_end_a_client_session() {
|
||||
assert!(Event::ScanDone { feeds: 1 }.is_terminal());
|
||||
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
|
||||
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
||||
assert!(!Event::FeedDone {
|
||||
feed: "a".into(),
|
||||
new: 0,
|
||||
downloaded: 0,
|
||||
failed: 0,
|
||||
torrents: 0
|
||||
}
|
||||
.is_terminal());
|
||||
assert!(!Event::FeedDone { feed: "a".into(), new: 0, downloaded: 0, failed: 0, torrents: 0 }.is_terminal());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
270
crates/ipx/src/lists.rs
Normal file
270
crates/ipx/src/lists.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! OPML: importing a file's feeds, and keeping a subscribed list's feeds in step with it.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::scan::Outcome;
|
||||
use crate::{catalogue, config, feed};
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The feeds an OPML document lists, up to as many as a subscribed list may bring in: each becomes
|
||||
/// a feed in the catalogue, scanned for everyone, and one upload of tens of thousands held the
|
||||
/// catalogue for minutes and stayed in it (#338).
|
||||
pub fn opml_feeds(doc: &opml::OPML) -> Result<Vec<(String, String)>> {
|
||||
let mut found = vec![];
|
||||
feed::collect_outlines(&doc.body.outlines, &mut found);
|
||||
anyhow::ensure!(
|
||||
found.len() <= MAX_LISTED,
|
||||
"it lists {} feeds, more than the {MAX_LISTED} one may have",
|
||||
found.len()
|
||||
);
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Subscribes one person to every feed in an OPML document, for the CLI and the web alike.
|
||||
/// A feed already in the catalogue costs nothing; an unknown one is added under the OPML's
|
||||
/// title rather than refetching each. Returns (newly subscribed, already subscribed).
|
||||
///
|
||||
/// Before accounts, importing only added unknown URLs to config.toml. Once subscriptions
|
||||
/// decided what each person sees, that imported nothing at all for a feed someone else
|
||||
/// already had, and a new one had no subscriber, so it was never scanned.
|
||||
///
|
||||
/// The caller parses the document and lists its feeds (`opml_feeds`), so each refuses a file in
|
||||
/// its own terms, before anything is touched: a 400 from the web, a message from the CLI.
|
||||
pub async fn subscribe_opml(ctx: &Ctx, found: Vec<(String, String)>, user_id: i64) -> Result<(usize, usize)> {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
// By address, once: each feed looked through every subscription and the whole catalogue, and
|
||||
// the catalogue's lock is held throughout (#338).
|
||||
let mut known = HashMap::new();
|
||||
for s in catalogue::subscriptions(ctx).await? {
|
||||
// The first with an address wins, as a search in order found it.
|
||||
known.entry(s.cfg.url).or_insert(s.id);
|
||||
}
|
||||
let mut ids = vec![];
|
||||
let mut grew = false;
|
||||
for (title, url) in found {
|
||||
// Known already, or the same URL listed twice in one file.
|
||||
let existing = known.get(&url).cloned();
|
||||
let id = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = config::unique_slug(&title, |s| cfg.feeds.contains_key(s));
|
||||
known.insert(url.clone(), id.clone());
|
||||
cfg.feeds.insert(
|
||||
id.clone(),
|
||||
config::Feed {
|
||||
url,
|
||||
folder: None,
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
);
|
||||
grew = true;
|
||||
id
|
||||
}
|
||||
};
|
||||
ids.push(id);
|
||||
}
|
||||
if grew {
|
||||
ctx.store_cfg(cfg).await?;
|
||||
}
|
||||
|
||||
let (mut added, mut had) = (0, 0);
|
||||
for id in ids {
|
||||
if ctx.db.subscription(user_id, &id).await?.is_some() {
|
||||
had += 1;
|
||||
} else {
|
||||
ctx.db.subscribe(user_id, &id).await?;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Ok((added, had))
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_opml(ctx: &Arc<Ctx>, parent_id: &str, bytes: &[u8]) -> Result<Outcome> {
|
||||
let listed = feed::parse_opml(bytes)?;
|
||||
if let Some(title) = feed::opml_title(bytes) {
|
||||
ctx.db.set_title(parent_id, &title).await?;
|
||||
}
|
||||
sync_group(ctx, parent_id, &listed).await
|
||||
}
|
||||
|
||||
/// How many feeds an OPML or Patreon list may bring in.
|
||||
pub(crate) const MAX_LISTED: usize = 2000;
|
||||
|
||||
/// Brings the feed list in step with a list of feeds: a subscribed OPML, or a Patreon
|
||||
/// creator's shows.
|
||||
///
|
||||
/// New entries are added under the list's group and folder. An entry that has gone from
|
||||
/// the list is unsubscribed *only if nothing was ever downloaded for it* -- otherwise it
|
||||
/// is kept and flagged, because dropping it would orphan files on disk with nothing in
|
||||
/// the UI to explain them.
|
||||
pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(String, String)]) -> Result<Outcome> {
|
||||
// Each is a feed scanned for everyone subscribed to the list, and the list is its author's to
|
||||
// write: one of tens of thousands held the scan for hours (#337). davewiner's had 922.
|
||||
anyhow::ensure!(
|
||||
listed.len() <= MAX_LISTED,
|
||||
"it lists {} feeds, more than the {MAX_LISTED} a list may have",
|
||||
listed.len()
|
||||
);
|
||||
let cfg = ctx.cfg();
|
||||
let existing = ctx.db.managed_feeds().await?;
|
||||
let mut added = vec![];
|
||||
// Read once, not once a new feed: each read is the whole feeds table.
|
||||
let known = ctx.db.feed_urls().await?;
|
||||
let fixed: HashSet<&str> = cfg.feeds.keys().chain(existing.iter().map(|m| &m.id)).map(String::as_str).collect();
|
||||
let known_ids: HashSet<&str> = known.iter().map(|(id, _)| id.as_str()).collect();
|
||||
|
||||
for (title, url) in listed {
|
||||
// Already known, whether derived or promoted into the config.
|
||||
if let Some(m) = existing.iter().find(|m| &m.url == url) {
|
||||
ctx.db.upsert_managed(&m.id, url, title, parent_id).await?;
|
||||
continue;
|
||||
}
|
||||
// A Patreon show you added by hand may be spelled differently from the one listed.
|
||||
if cfg.feeds.values().any(|f| feed::same_feed(&f.url, url)) {
|
||||
continue;
|
||||
}
|
||||
// A removed feed keeps its rows, so its id is only free again for the same feed.
|
||||
let its_own: Vec<&str> =
|
||||
known.iter().filter(|(_, u)| feed::same_feed(u, url)).map(|(id, _)| id.as_str()).collect();
|
||||
let id = config::unique_slug(title, |s| {
|
||||
fixed.contains(s) || added.iter().any(|a| a == s) || (known_ids.contains(s) && !its_own.contains(&s))
|
||||
});
|
||||
ctx.db.upsert_managed(&id, url, title, parent_id).await?;
|
||||
added.push(id);
|
||||
}
|
||||
|
||||
// Whoever subscribes to the OPML subscribes to what it lists: that is what taking a
|
||||
// subscription means. Their own feeds are untouched.
|
||||
let managed = ctx.db.managed_feeds().await?;
|
||||
let list_and_feeds = managed
|
||||
.iter()
|
||||
.filter(|m| m.group_id == parent_id)
|
||||
.map(|m| m.id.clone())
|
||||
.chain(std::iter::once(parent_id.to_string()));
|
||||
for id in list_and_feeds {
|
||||
for user in ctx.db.users().await? {
|
||||
if ctx.db.subscription(user.id, parent_id).await?.is_some() {
|
||||
ctx.db.subscribe(user.id, &id).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anything in this group the OPML no longer lists.
|
||||
let mut removed = 0;
|
||||
let mut kept = 0;
|
||||
let parent = ctx.cfg().feeds.get(parent_id).cloned();
|
||||
let in_group = existing.iter().filter(|m| m.group_id == parent_id);
|
||||
for m in in_group {
|
||||
if listed.iter().any(|(_, u)| u == &m.url) {
|
||||
continue;
|
||||
}
|
||||
if catalogue::promote_kept(ctx, m, parent.as_ref()).await? {
|
||||
kept += 1;
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
|
||||
continue;
|
||||
}
|
||||
if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||
// Never orphan a downloaded file: keep the feed and say why in the UI.
|
||||
ctx.db.set_orphaned(&m.id, true).await?;
|
||||
kept += 1;
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML but has downloads; keeping it");
|
||||
} else {
|
||||
ctx.db.drop_managed(&m.id).await?;
|
||||
removed += 1;
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML with nothing downloaded; removed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Outcome::Opml { added, removed, kept, total: listed.len() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_opml_upload_is_capped_and_takes_a_repeated_address_once() {
|
||||
let opml = |n: usize| {
|
||||
let outlines: String =
|
||||
(0..n).map(|i| format!(r#"<outline text="Same" xmlUrl="http://x/{}.xml"/>"#, i % 3)).collect();
|
||||
feed::read_opml(&format!(r#"<opml version="2.0"><head/><body>{outlines}</body></opml>"#)).unwrap()
|
||||
};
|
||||
assert!(opml_feeds(&opml(MAX_LISTED + 1)).unwrap_err().to_string().contains("more than the 2000"));
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let me = ctx.db.create_user("me", None, false).await.unwrap();
|
||||
// Six outlines, three addresses, one title: three feeds, named apart.
|
||||
let (added, had) = subscribe_opml(&ctx, opml_feeds(&opml(6)).unwrap(), me).await.unwrap();
|
||||
let ids: std::collections::BTreeSet<String> = ctx.cfg().feeds.keys().cloned().collect();
|
||||
assert_eq!(ids, ["same", "same-2", "same-3"].map(String::from).into());
|
||||
assert_eq!((added, had), (3, 3));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_list_of_more_feeds_than_the_limit_is_refused() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
let listed: Vec<(String, String)> =
|
||||
(0..=MAX_LISTED).map(|n| (format!("Show {n}"), format!("http://x/{n}.xml"))).collect();
|
||||
let err = sync_group(&ctx, "opml", &listed).await.err().expect("refused");
|
||||
assert!(err.to_string().contains("more than the 2000"), "{err:#}");
|
||||
assert!(ctx.db.managed_feeds().await.unwrap().is_empty(), "nothing taken from it");
|
||||
// At the limit, all of them, each its own id.
|
||||
sync_group(&ctx, "opml", &listed[..MAX_LISTED]).await.unwrap();
|
||||
let ids: std::collections::HashSet<String> =
|
||||
ctx.db.managed_feeds().await.unwrap().into_iter().map(|m| m.id).collect();
|
||||
assert_eq!(ids.len(), MAX_LISTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() {
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert("opml".into(), config::Feed { private: true, ..feed() });
|
||||
let ctx = Arc::new(test_ctx(cfg).await);
|
||||
let (me, you) = (
|
||||
ctx.db.create_user("me", None, false).await.unwrap(),
|
||||
ctx.db.create_user("you", None, false).await.unwrap(),
|
||||
);
|
||||
for id in ["opml", "a", "b"] {
|
||||
if id != "opml" {
|
||||
ctx.db.upsert_managed(id, &format!("http://x/{id}.xml"), id, "opml").await.unwrap();
|
||||
}
|
||||
for u in [me, you] {
|
||||
ctx.db.subscribe(u, id).await.unwrap();
|
||||
}
|
||||
}
|
||||
assert!(ctx.db.set_kept(me, "a", true).await.unwrap());
|
||||
assert!(ctx.db.set_kept(me, "b", true).await.unwrap());
|
||||
|
||||
// The OPML drops a: mine to keep, a feed of its own; yours, unkept, goes.
|
||||
sync_group(&ctx, "opml", &[("b".into(), "http://x/b.xml".into())]).await.unwrap();
|
||||
let a = ctx.cfg().feeds.get("a").cloned().expect("a is in the catalogue");
|
||||
assert_eq!((a.url.as_str(), a.group, a.private), ("http://x/a.xml", None, true));
|
||||
assert!(!ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "a"));
|
||||
assert!(ctx.db.subscription(me, "a").await.unwrap().is_some());
|
||||
assert!(ctx.db.subscription(you, "a").await.unwrap().is_none());
|
||||
|
||||
// Leaving the OPML takes what I did not keep and leaves b, still the OPML's while you read it.
|
||||
catalogue::leave(&ctx, me, "opml").await.unwrap();
|
||||
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
|
||||
assert!(ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "b"));
|
||||
// Once nobody reads it, b is mine alone, and yours goes with the OPML.
|
||||
catalogue::leave(&ctx, you, "opml").await.unwrap();
|
||||
// Still private: the OPML is gone from config by the time b is promoted (#332).
|
||||
assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none() && f.private));
|
||||
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
|
||||
assert!(ctx.db.subscription(you, "b").await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
//! to read. Capturing inside the tracing pipeline works the same either way.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::Context;
|
||||
@@ -27,9 +29,8 @@ struct Ring {
|
||||
next_seq: u64,
|
||||
}
|
||||
|
||||
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
|
||||
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
|
||||
});
|
||||
static BUF: LazyLock<Mutex<Ring>> =
|
||||
LazyLock::new(|| Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 }));
|
||||
|
||||
pub fn push(level: &str, target: &str, msg: String) {
|
||||
let mut ring = match BUF.lock() {
|
||||
@@ -57,12 +58,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
let latest = ring.next_seq.saturating_sub(1);
|
||||
let mut out: Vec<LogLine> = ring
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|l| l.seq > after)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut out: Vec<LogLine> = ring.lines.iter().filter(|l| l.seq > after).cloned().collect();
|
||||
// On a first load (after = 0) the tail is what matters, not the head.
|
||||
if out.len() > limit {
|
||||
out.drain(..out.len() - limit);
|
||||
@@ -73,7 +69,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
|
||||
/// A tracing layer that mirrors every event into the ring.
|
||||
pub struct RingLayer;
|
||||
|
||||
impl<S: tracing::Subscriber> Layer<S> for RingLayer {
|
||||
impl<S: Subscriber> Layer<S> for RingLayer {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut v = Collect::default();
|
||||
event.record(&mut v);
|
||||
@@ -108,7 +104,7 @@ impl Collect {
|
||||
}
|
||||
|
||||
impl Visit for Collect {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn Debug) {
|
||||
self.add(field, format!("{value:?}"));
|
||||
}
|
||||
// Numbers and bools reach record_debug through the trait's defaults, which prints them the
|
||||
@@ -130,10 +126,7 @@ mod tests {
|
||||
let (all, latest) = since(0, CAPACITY * 2);
|
||||
assert_eq!(all.len(), CAPACITY, "bounded");
|
||||
assert!(latest >= (CAPACITY + 50) as u64);
|
||||
assert!(
|
||||
all.first().unwrap().seq < all.last().unwrap().seq,
|
||||
"oldest first"
|
||||
);
|
||||
assert!(all.first().unwrap().seq < all.last().unwrap().seq, "oldest first");
|
||||
|
||||
// "everything after the last one I saw" must return nothing new.
|
||||
let (none, _) = since(latest, 100);
|
||||
89
crates/ipx/src/logging.rs
Normal file
89
crates/ipx/src/logging.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Log lines as JSON for Loki, carrying the trace and span they belong to.
|
||||
|
||||
use crate::logbuf;
|
||||
use std::io::IsTerminal;
|
||||
use tracing::Subscriber;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt::format::JsonFields;
|
||||
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// The JSON log line with the trace and span it belongs to (#91), so a line in Loki leads to its
|
||||
/// trace in Tempo. The JSON formatter cannot take a field of its own, so the ids go on the end of
|
||||
/// the object it writes. A line outside any traced span, or with no trace exporter, is unchanged.
|
||||
pub(crate) struct WithTrace<F>(F);
|
||||
|
||||
impl<S, N, F> FormatEvent<S, N> for WithTrace<F>
|
||||
where
|
||||
F: FormatEvent<S, N>,
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
N: for<'w> FormatFields<'w> + 'static,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &FmtContext<'_, S, N>,
|
||||
mut w: tracing_subscriber::fmt::format::Writer<'_>,
|
||||
ev: &tracing::Event<'_>,
|
||||
) -> std::fmt::Result {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let current = tracing::Span::current();
|
||||
let sc = if current.is_none() { None } else { Some(current.context().span().span_context().clone()) };
|
||||
let Some(sc) = sc.filter(|c| c.is_valid()) else {
|
||||
return self.0.format_event(ctx, w, ev);
|
||||
};
|
||||
let mut line = String::new();
|
||||
self.0.format_event(ctx, tracing_subscriber::fmt::format::Writer::new(&mut line), ev)?;
|
||||
match line.trim_end().strip_suffix('}') {
|
||||
Some(body) => writeln!(w, r#"{body},"trace_id":"{}","span_id":"{}"}}"#, sc.trace_id(), sc.span_id()),
|
||||
None => w.write_str(&line),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up logging: to stderr, as text or as JSON (`IPX_LOG_FORMAT`), into the ring the page's log
|
||||
/// reads, and to the trace exporter when there is one.
|
||||
pub(crate) fn init(otel: Option<&opentelemetry_sdk::trace::SdkTracerProvider>) {
|
||||
// Everything goes to stderr as before, and is mirrored into a ring the UI can read.
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
|
||||
// keeps debug as well, so the log view can show protocol traffic and routine
|
||||
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
|
||||
let stderr_filter = || EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into());
|
||||
// One JSON object a line for Loki (#91), with each event's fields as its own; text
|
||||
// otherwise, for someone reading a terminal.
|
||||
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
let ui_filter = EnvFilter::try_from_env("IPX_UI_LOG").unwrap_or_else(|_| "ipx=debug".into());
|
||||
tracing_subscriber::registry()
|
||||
.with((!json).then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
// Colour for a terminal only: in docker logs and Loki the escapes are noise
|
||||
// every query has to strip (#88).
|
||||
.with_ansi(IsTerminal::is_terminal(&std::io::stderr()))
|
||||
.with_filter(stderr_filter())
|
||||
}))
|
||||
.with(json.then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.fmt_fields(JsonFields::new())
|
||||
.event_format(WithTrace(
|
||||
tracing_subscriber::fmt::format()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_current_span(true)
|
||||
.with_span_list(false),
|
||||
))
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(stderr_filter())
|
||||
}))
|
||||
.with(logbuf::RingLayer.with_filter(ui_filter))
|
||||
.with(otel.as_ref().map(|p| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx"))
|
||||
.with_filter(EnvFilter::new("ipx=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
}
|
||||
113
crates/ipx/src/main.rs
Normal file
113
crates/ipx/src/main.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! ipx, the daemon and its command line. The code is in the modules below; this only starts it.
|
||||
|
||||
mod access;
|
||||
mod art;
|
||||
mod auth;
|
||||
mod catalogue;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod ctx;
|
||||
mod daemon;
|
||||
mod db;
|
||||
mod deadair;
|
||||
mod directory;
|
||||
mod download;
|
||||
mod entity;
|
||||
// The crate root's import, so `crate::feed` reaches it from every module as before.
|
||||
use ipx_feed as feed;
|
||||
mod ipc;
|
||||
mod lists;
|
||||
mod logbuf;
|
||||
mod logging;
|
||||
mod metrics;
|
||||
mod retention;
|
||||
mod scan;
|
||||
mod signals;
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
mod torrent;
|
||||
mod web;
|
||||
|
||||
use crate::cli::{Cli, Command, UserCmd};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Command as Cmd;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
|
||||
// looking at.
|
||||
let otel = match cli.command {
|
||||
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
|
||||
_ => None,
|
||||
};
|
||||
logging::init(otel.as_ref());
|
||||
|
||||
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
|
||||
let cfg = config::Config::load(&config_path)?;
|
||||
// The daemon holds its connections open; a one-off command opens what it uses.
|
||||
let keep_open = matches!(cli.command, Command::Daemon { .. });
|
||||
let db = db::Db::open(&db::location(), keep_open).await?;
|
||||
|
||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||
let wire_cmd = match &cli.command {
|
||||
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
|
||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||
Command::Status => Some(Cmd::Status),
|
||||
Command::List
|
||||
| Command::Daemon { .. }
|
||||
| Command::User { .. }
|
||||
| Command::Add { .. }
|
||||
| Command::Rm { .. }
|
||||
| Command::Import { .. }
|
||||
| Command::Export { .. } => None,
|
||||
};
|
||||
if let Some(cmd) = &wire_cmd
|
||||
&& !cli.local
|
||||
&& ipc::daemon_is_live(&cfg.general.socket).await
|
||||
{
|
||||
return ipc::proxy(&cfg.general.socket, cmd).await;
|
||||
}
|
||||
|
||||
let socket = cfg.general.socket.clone();
|
||||
let cfg = catalogue::assemble_config(&db, cfg, &config_path).await?;
|
||||
|
||||
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||
// These write the catalogue from a process of their own, which a running daemon, holding its
|
||||
// own copy, would write over at its next change (#120).
|
||||
let changes_catalogue = matches!(
|
||||
cli.command,
|
||||
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
|
||||
);
|
||||
let (events, _) = broadcast::channel(1024);
|
||||
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
|
||||
|
||||
let result = match cli.command {
|
||||
Command::List => catalogue::list(&ctx).await,
|
||||
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events).await,
|
||||
Command::Add { url, folder, keywords, category, list } => {
|
||||
catalogue::add(&ctx, &url, folder, keywords, category, list).await
|
||||
}
|
||||
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
|
||||
Command::User { cmd } => cli::user_cmd(&ctx, cmd).await,
|
||||
Command::Import { file } => catalogue::import(&ctx, &file).await,
|
||||
Command::Export { file } => catalogue::export(&ctx, &file).await,
|
||||
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||
};
|
||||
if result.is_ok()
|
||||
&& changes_catalogue
|
||||
&& ipc::daemon_is_live(&socket).await
|
||||
&& let Err(e) = ipc::reload(&socket).await
|
||||
{
|
||||
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
|
||||
}
|
||||
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
||||
if let Some(p) = otel {
|
||||
let _ = p.shutdown();
|
||||
}
|
||||
result
|
||||
}
|
||||
569
crates/ipx/src/metrics.rs
Normal file
569
crates/ipx/src/metrics.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
//! Prometheus metrics for what logs and traces cannot show: the daemon's own state between
|
||||
//! requests, such as how many database connections are in use (#194). Served on a port and a
|
||||
//! thread of their own, with a runtime of their own, and made only of what is already in memory:
|
||||
//! a scrape neither waits for the web server's threads or the database, whose pool running dry
|
||||
//! is one of the things it is there to show, nor holds anything up itself. No sign-in, as
|
||||
//! Prometheus exporters have none: checking a token is a database lookup. Written out by hand:
|
||||
//! the text format is a few lines, and a client library would be a dependency for them.
|
||||
|
||||
use axum::Router;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64};
|
||||
use std::time::Instant;
|
||||
use tokio::runtime::Handle;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
/// What the daemon counts as it works, for /metrics to read without asking anything (#195).
|
||||
/// Global: the web layer, the worker, downloads, artwork and DeadAir each add to it, and a handle
|
||||
/// threaded through all of them was more code than the counts.
|
||||
pub static STATS: Stats = Stats::new();
|
||||
|
||||
pub struct Stats {
|
||||
pub http_in_flight: AtomicI64,
|
||||
/// Live updates dropped for a page or socket client that fell behind the broadcast.
|
||||
pub events_missed: AtomicU64,
|
||||
pub downloads_active: AtomicI64,
|
||||
pub download_bytes: AtomicU64,
|
||||
/// Feeds a scan is fetching at this moment, up to its AHEAD.
|
||||
pub feeds_fetching: AtomicI64,
|
||||
/// When a scan last finished, Unix seconds; 0 for not since starting.
|
||||
pub last_scan_done: AtomicI64,
|
||||
/// Files someone wants shortened that DeadAir has not looked at, as of its last pass.
|
||||
pub deadair_backlog: AtomicI64,
|
||||
/// When DeadAir started on the file it is on, Unix seconds; 0 when it is on none.
|
||||
pub deadair_busy_since: AtomicI64,
|
||||
pub art_cache_bytes: AtomicU64,
|
||||
/// Images the sweep threw out to keep under the limit: kept bytes sit at the limit by design,
|
||||
/// so this is what says whether the limit is too small.
|
||||
pub art_evicted: AtomicU64,
|
||||
/// Artwork served from disk, and fetched from the publisher because it was not there.
|
||||
pub art_hits: AtomicU64,
|
||||
pub art_misses: AtomicU64,
|
||||
/// The library as the daemon last counted it, every 30 s; -1 until it has.
|
||||
pub feeds: AtomicI64,
|
||||
pub pending: AtomicI64,
|
||||
pub downloaded: AtomicI64,
|
||||
/// When the Signals judge last finished a pass, Unix seconds; 0 for not since starting.
|
||||
pub signals_last_pass: AtomicI64,
|
||||
/// Calls to Jev by how they ended, in `JEV_RESULTS`' order, and their microseconds all told.
|
||||
pub jev_calls: [AtomicU64; 5],
|
||||
pub jev_micros: AtomicU64,
|
||||
/// Microseconds calls waited for their turn under TypeSafe's limits (#277), part of `jev_micros`.
|
||||
pub jev_paced_micros: AtomicU64,
|
||||
/// What each person's Signals, icons and key checks used, and whose key paid (#278): items
|
||||
/// judged, Signals they fit, and TypeSafe's input and output tokens. Floats: a call shared by
|
||||
/// several people on the server's key is split between them.
|
||||
people: std::sync::Mutex<BTreeMap<(String, &'static str), [f64; 4]>>,
|
||||
/// The worker's job now, and since when.
|
||||
job: std::sync::Mutex<Option<(&'static str, Instant)>>,
|
||||
/// Jobs finished, by kind: how many, and their seconds all told.
|
||||
jobs: std::sync::Mutex<BTreeMap<&'static str, (u64, f64)>>,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
http_in_flight: AtomicI64::new(0),
|
||||
events_missed: AtomicU64::new(0),
|
||||
downloads_active: AtomicI64::new(0),
|
||||
download_bytes: AtomicU64::new(0),
|
||||
feeds_fetching: AtomicI64::new(0),
|
||||
last_scan_done: AtomicI64::new(0),
|
||||
deadair_backlog: AtomicI64::new(0),
|
||||
deadair_busy_since: AtomicI64::new(0),
|
||||
art_cache_bytes: AtomicU64::new(0),
|
||||
art_evicted: AtomicU64::new(0),
|
||||
art_hits: AtomicU64::new(0),
|
||||
art_misses: AtomicU64::new(0),
|
||||
feeds: AtomicI64::new(-1),
|
||||
pending: AtomicI64::new(-1),
|
||||
downloaded: AtomicI64::new(-1),
|
||||
signals_last_pass: AtomicI64::new(0),
|
||||
jev_calls: [const { AtomicU64::new(0) }; 5],
|
||||
jev_micros: AtomicU64::new(0),
|
||||
jev_paced_micros: AtomicU64::new(0),
|
||||
people: std::sync::Mutex::new(BTreeMap::new()),
|
||||
job: std::sync::Mutex::new(None),
|
||||
jobs: std::sync::Mutex::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds to what `who` used on `key` ("server" or "own"): items judged, fits, input and output
|
||||
/// tokens.
|
||||
pub fn bill(&self, who: &str, key: &'static str, used: [f64; 4]) {
|
||||
let mut people = self.people.lock().expect("a panic elsewhere poisoned this lock");
|
||||
let row = people.entry((who.to_owned(), key)).or_default();
|
||||
for (r, u) in row.iter_mut().zip(used) {
|
||||
*r += u;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
|
||||
/// ends: finished, failed or stopped.
|
||||
pub fn job(&'static self, kind: &'static str) -> Job {
|
||||
*self.job.lock().expect("a panic elsewhere poisoned this lock") = Some((kind, Instant::now()));
|
||||
Job(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Job(&'static Stats);
|
||||
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
if let Some((kind, since)) = self.0.job.lock().expect("a panic elsewhere poisoned this lock").take() {
|
||||
let mut jobs = self.0.jobs.lock().expect("a panic elsewhere poisoned this lock");
|
||||
let e = jobs.entry(kind).or_default();
|
||||
e.0 += 1;
|
||||
e.1 += since.elapsed().as_secs_f64();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a call to Jev ended: answered, its key refused (401-403), the request refused as
|
||||
/// malformed (400, 422), over TypeSafe's rate limits (429), or anything else, TypeSafe's 5xx and
|
||||
/// timeouts.
|
||||
pub const JEV_RESULTS: [&str; 5] = ["ok", "key_refused", "malformed", "rate_limited", "error"];
|
||||
|
||||
/// Counts one more of something in progress until dropped, however it ends.
|
||||
pub struct Busy(&'static AtomicI64);
|
||||
|
||||
pub fn busy(gauge: &'static AtomicI64) -> Busy {
|
||||
gauge.fetch_add(1, Relaxed);
|
||||
Busy(gauge)
|
||||
}
|
||||
|
||||
impl Drop for Busy {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the metrics are read from.
|
||||
pub struct Sources {
|
||||
pub ctx: Arc<crate::ctx::Ctx>,
|
||||
pub cmds: tokio::sync::mpsc::Sender<crate::ipc::Command>,
|
||||
pub events: tokio::sync::broadcast::Sender<crate::ipc::Event>,
|
||||
pub playing: crate::deadair::Playing,
|
||||
/// The daemon's runtime, not the metrics thread's own.
|
||||
pub runtime: Handle,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
/// Starts serving /metrics on `bind`, on a thread of its own, and says where.
|
||||
pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<SocketAddr> {
|
||||
use anyhow::Context as _;
|
||||
// Bound here, so a port already taken is the daemon's error at start, not a thread's later.
|
||||
let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding metrics to {bind}"))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let addr = listener.local_addr()?;
|
||||
let s = Arc::new(s);
|
||||
std::thread::Builder::new().name("metrics".into()).spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || {
|
||||
let s = s.clone();
|
||||
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip, every 15 s (#253).
|
||||
.layer(CompressionLayer::new().gzip(true));
|
||||
match tokio::net::TcpListener::from_std(listener) {
|
||||
Ok(l) => {
|
||||
if let Err(e) = axum::serve(l, app).await {
|
||||
tracing::error!(error = %e, "metrics stopped");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(error = %e, "metrics could not listen"),
|
||||
}
|
||||
});
|
||||
})?;
|
||||
Ok(addr)
|
||||
}
|
||||
|
||||
/// Every metric, from memory alone.
|
||||
pub fn render(s: &Sources) -> String {
|
||||
let mut out = String::new();
|
||||
let (open, idle, max) = s.ctx.db.pool();
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_db_connections",
|
||||
"Database connections open, in use or idle.",
|
||||
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)],
|
||||
);
|
||||
gauge(&mut out, "ipx_db_connections_max", "The most database connections the pool opens.", &[("", max as f64)]);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_worker_queue",
|
||||
"Commands waiting for the worker: scans, downloads and the like.",
|
||||
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_event_listeners",
|
||||
"Open pages and socket clients listening for live updates.",
|
||||
&[("", s.events.receiver_count() as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_torrent_slots_free",
|
||||
"Torrents that could start now.",
|
||||
&[("", s.ctx.torrent_slots.available_permits() as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_deadair_playing",
|
||||
"Episodes someone is playing through DeadAir's playlist.",
|
||||
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)],
|
||||
);
|
||||
work(
|
||||
&mut out,
|
||||
&STATS,
|
||||
s.events.len(),
|
||||
s.ctx.retry.lock().map_or(0, |r| r.len()),
|
||||
s.ctx.cfg().general.art_cache_mb * 1_048_576,
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_signals_keys_refused",
|
||||
"People whose TypeSafe key was refused, whose Signals wait until they save another.",
|
||||
&[("", s.ctx.signals_refused.lock().map_or(0, |r| r.len()) as f64)],
|
||||
);
|
||||
let rt = s.runtime.metrics();
|
||||
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_queue",
|
||||
"Tasks waiting in the runtime's global queue.",
|
||||
&[("", rt.global_queue_depth() as f64)],
|
||||
);
|
||||
let (rss, fds) = ipx_directory::metrics::process();
|
||||
if let Some(v) = rss {
|
||||
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
||||
}
|
||||
if let Some(v) = fds {
|
||||
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)],
|
||||
);
|
||||
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
||||
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The work in progress and done, from `STATS`: jobs, queues and what is in flight (#195).
|
||||
fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_limit: u64) {
|
||||
let now = crate::db::now();
|
||||
let job = *st.job.lock().expect("a panic elsewhere poisoned this lock");
|
||||
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
|
||||
if let Some((kind, since)) = job {
|
||||
let label = format!("kind=\"{kind}\"");
|
||||
gauge(
|
||||
out,
|
||||
"ipx_worker_job_seconds",
|
||||
"How long the worker has been on its job, by the job's kind.",
|
||||
&[(&label, since.elapsed().as_secs_f64())],
|
||||
);
|
||||
}
|
||||
let jobs = st.jobs.lock().expect("a panic elsewhere poisoned this lock").clone();
|
||||
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
|
||||
let pairs = |f: fn(&(u64, f64)) -> f64| {
|
||||
labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>()
|
||||
};
|
||||
counter(out, "ipx_jobs_total", "Jobs the worker has finished, by kind.", &pairs(|v| v.0 as f64));
|
||||
counter(out, "ipx_job_seconds_total", "Seconds the worker has spent on jobs, by kind.", &pairs(|v| v.1));
|
||||
let last = st.last_scan_done.load(Relaxed);
|
||||
if last > 0 {
|
||||
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
|
||||
}
|
||||
gauge(
|
||||
out,
|
||||
"ipx_http_requests_in_flight",
|
||||
"Web requests being answered.",
|
||||
&[("", st.http_in_flight.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_scan_feeds_fetching",
|
||||
"Feeds a scan is fetching at once.",
|
||||
&[("", st.feeds_fetching.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_downloads_active", "Files downloading now.", &[("", st.downloads_active.load(Relaxed) as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_download_bytes_total",
|
||||
"Bytes downloaded since the daemon started.",
|
||||
&[("", st.download_bytes.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_event_queue", "Live updates sent and not yet read by every listener.", &[("", event_queue as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_events_missed_total",
|
||||
"Live updates dropped for a listener that fell behind.",
|
||||
&[("", st.events_missed.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_deadair_backlog",
|
||||
"Files of shows someone has DeadAir on that it has not looked at, those already heard through included.",
|
||||
&[("", st.deadair_backlog.load(Relaxed) as f64)],
|
||||
);
|
||||
let since = st.deadair_busy_since.load(Relaxed);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_deadair_busy_seconds",
|
||||
"How long DeadAir has been on its file; 0 when on none.",
|
||||
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })],
|
||||
);
|
||||
gauge(out, "ipx_feed_retries", "Feeds waiting to try a failed name lookup again.", &[("", retries as f64)]);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_art_cache_bytes",
|
||||
"Artwork kept on disk, as of the last sweep.",
|
||||
&[("", st.art_cache_bytes.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_art_cache_limit_bytes", "The most artwork kept.", &[("", art_limit as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_art_evicted_total",
|
||||
"Images thrown out to keep the artwork under its limit.",
|
||||
&[("", st.art_evicted.load(Relaxed) as f64)],
|
||||
);
|
||||
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),
|
||||
],
|
||||
);
|
||||
// By person and whose key paid (#278). An account's name is often an email address: /metrics
|
||||
// is on the LAN only, as Prometheus is.
|
||||
let people = st.people.lock().expect("a panic elsewhere poisoned this lock").clone();
|
||||
let labels: Vec<String> =
|
||||
people.keys().map(|(who, key)| format!("user=\"{}\",key=\"{key}\"", label(who))).collect();
|
||||
let by = |n: usize| labels.iter().zip(people.values()).map(|(l, v)| (l.as_str(), v[n])).collect::<Vec<_>>();
|
||||
counter(
|
||||
out,
|
||||
"ipx_signals_judged_total",
|
||||
"Items Jev has judged for someone's Signals, by person and whose key paid.",
|
||||
&by(0),
|
||||
);
|
||||
counter(out, "ipx_signals_hits_total", "Signals an item was found to fit, by person and whose key paid.", &by(1));
|
||||
let tokens: Vec<String> =
|
||||
labels.iter().flat_map(|l| [format!("kind=\"input\",{l}"), format!("kind=\"output\",{l}")]).collect();
|
||||
let used: Vec<(&str, f64)> =
|
||||
tokens.iter().zip(people.values().flat_map(|v| [v[2], v[3]])).map(|(l, v)| (l.as_str(), v)).collect();
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_tokens_total",
|
||||
"Tokens TypeSafe counted for calls to Jev, what they cost, by person and whose key paid.",
|
||||
&used,
|
||||
);
|
||||
let last = st.signals_last_pass.load(Relaxed);
|
||||
if last > 0 {
|
||||
gauge(
|
||||
out,
|
||||
"ipx_signals_last_pass_age_seconds",
|
||||
"Seconds since the Signals judge last finished a pass.",
|
||||
&[("", (now - last) as f64)],
|
||||
);
|
||||
}
|
||||
let labels: Vec<String> = JEV_RESULTS.iter().map(|r| format!("result=\"{r}\"")).collect();
|
||||
let calls: Vec<(&str, f64)> =
|
||||
labels.iter().zip(&st.jev_calls).map(|(l, c)| (l.as_str(), c.load(Relaxed) as f64)).collect();
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_requests_total",
|
||||
"Calls to TypeSafe's Jev, Signals' and their icons', by how they ended.",
|
||||
&calls,
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_seconds_total",
|
||||
"Seconds spent on calls to Jev, their wait for a turn included.",
|
||||
&[("", st.jev_micros.load(Relaxed) as f64 / 1e6)],
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_paced_seconds_total",
|
||||
"Seconds calls to Jev waited for their turn under TypeSafe's rate limits.",
|
||||
&[("", st.jev_paced_micros.load(Relaxed) as f64 / 1e6)],
|
||||
);
|
||||
for (name, help, v) in [
|
||||
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
|
||||
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
|
||||
("ipx_files_downloaded", "Files on disk, as last counted.", &st.downloaded),
|
||||
] {
|
||||
let v = v.load(Relaxed);
|
||||
if v >= 0 {
|
||||
gauge(out, name, help, &[("", v as f64)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A label's value as Prometheus's text format reads it.
|
||||
fn label(v: &str) -> String {
|
||||
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
|
||||
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
write(out, "counter", name, help, samples);
|
||||
}
|
||||
|
||||
/// One gauge in Prometheus's text format: its help, its type, and a line for each set of labels
|
||||
/// (`state="idle"`, or "" for none).
|
||||
pub fn gauge(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
write(out, "gauge", name, help, samples);
|
||||
}
|
||||
|
||||
fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
|
||||
for (labels, value) in samples {
|
||||
let labels = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
|
||||
let _ = writeln!(out, "{name}{labels} {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gauges_are_written_as_prometheus_reads_them() {
|
||||
let mut out = String::new();
|
||||
gauge(&mut out, "ipx_db_connections", "Connections.", &[("state=\"idle\"", 3.0), ("state=\"in_use\"", 1.0)]);
|
||||
gauge(&mut out, "ipx_feeds", "Feeds.", &[("", 1535.0)]);
|
||||
assert_eq!(
|
||||
out,
|
||||
"# HELP ipx_db_connections Connections.\n# TYPE ipx_db_connections gauge\n\
|
||||
ipx_db_connections{state=\"idle\"} 3\nipx_db_connections{state=\"in_use\"} 1\n\
|
||||
# HELP ipx_feeds Feeds.\n# TYPE ipx_feeds gauge\nipx_feeds 1535\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_and_work_in_progress_are_counted_however_they_end() {
|
||||
static T: Stats = Stats::new();
|
||||
{
|
||||
let _j = T.job("scan");
|
||||
let _b = busy(&T.downloads_active);
|
||||
assert_eq!(T.downloads_active.load(Relaxed), 1);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 0, 0, 0);
|
||||
assert!(
|
||||
out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
|
||||
assert_eq!(T.downloads_active.load(Relaxed), 0);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 3, 1, 10);
|
||||
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_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
|
||||
assert!(
|
||||
out.contains("ipx_jev_requests_total{result=\"key_refused\"} 0\n")
|
||||
&& !out.contains("ipx_signals_last_pass_age_seconds"),
|
||||
"{out}"
|
||||
);
|
||||
T.bill("sam@example.net", "server", [1.0, 1.0, 600.0, 50.0]);
|
||||
T.bill("sam@example.net", "server", [1.0, 0.0, 300.5, 25.0]);
|
||||
T.bill("a \"b\"", "own", [0.0, 0.0, 10.0, 1.0]);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 0, 0, 0);
|
||||
assert!(out.contains("ipx_signals_judged_total{user=\"sam@example.net\",key=\"server\"} 2\n"), "{out}");
|
||||
assert!(
|
||||
out.contains("ipx_jev_tokens_total{kind=\"input\",user=\"sam@example.net\",key=\"server\"} 900.5\n"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("ipx_jev_tokens_total{kind=\"output\",user=\"a \\\"b\\\"\",key=\"own\"} 1\n"), "{out}");
|
||||
// The library's counts are left out until the daemon has counted it.
|
||||
assert!(!out.contains("ipx_feeds "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_process_reads_its_memory_and_files_on_linux() {
|
||||
let (rss, fds) = ipx_directory::metrics::process();
|
||||
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
|
||||
}
|
||||
|
||||
use crate::config;
|
||||
use crate::test_util::test_ctx;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
#[tokio::test]
|
||||
async fn metrics_answer_from_a_thread_of_their_own() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
let (cmds, _rx) = mpsc::channel(8);
|
||||
let (events, _) = broadcast::channel(8);
|
||||
let sources = Sources {
|
||||
ctx: ctx.clone(),
|
||||
cmds,
|
||||
events,
|
||||
playing: Default::default(),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
started: std::time::Instant::now(),
|
||||
};
|
||||
let at = spawn("127.0.0.1:0", sources).unwrap();
|
||||
let text = ctx.client.get(format!("http://{at}/metrics")).send().await.unwrap().text().await.unwrap();
|
||||
// Every sample a name, optional labels and a number, as Prometheus parses them (#194).
|
||||
let samples = text.lines().filter(|l| !l.starts_with('#'));
|
||||
for line in samples {
|
||||
let (name, value) = line.rsplit_once(' ').unwrap();
|
||||
assert!(name.starts_with("ipx_") && value.parse::<f64>().is_ok(), "{line}");
|
||||
}
|
||||
for name in
|
||||
["ipx_db_connections{state=\"in_use\"}", "ipx_db_connections_max", "ipx_worker_queue", "ipx_runtime_tasks"]
|
||||
{
|
||||
assert!(text.contains(name), "{name}");
|
||||
}
|
||||
assert!(
|
||||
spawn(
|
||||
&at.to_string(),
|
||||
Sources {
|
||||
ctx: ctx.clone(),
|
||||
cmds: mpsc::channel(1).0,
|
||||
events: broadcast::channel(1).0,
|
||||
playing: Default::default(),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
started: std::time::Instant::now(),
|
||||
}
|
||||
)
|
||||
.is_err(),
|
||||
"a port already taken is an error at start"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
//! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace").
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::{Candidate, Db, now};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::db;
|
||||
use crate::db::{Candidate, Db};
|
||||
use crate::ipc::Event;
|
||||
use crate::{art, catalogue};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
/// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did.
|
||||
pub const PAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||
@@ -38,18 +45,23 @@ pub fn pick(candidates: &[Candidate], total: u64, limit: u64) -> Vec<Candidate>
|
||||
|
||||
/// Files older than the cutoff. Flagged ones are already excluded by the query.
|
||||
pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|c| c.age_key > 0 && c.age_key < cutoff)
|
||||
.cloned()
|
||||
.collect()
|
||||
candidates.iter().filter(|c| c.age_key > 0 && c.age_key < cutoff).cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
let mut report = Report::default();
|
||||
|
||||
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
||||
for (id, path) in db.missing_files().await? {
|
||||
// Someone may have deleted a file by hand; the row must stop claiming it exists. Not while
|
||||
// the folder itself is gone (the array stopped, shfs dropped): every file would look deleted
|
||||
// and the whole library be forgotten at once (#293).
|
||||
// Each step is a span of the reap's trace, so a slow one says which part was slow.
|
||||
let missing = if cfg.general.download_dir.is_dir() {
|
||||
db.missing_files().instrument(db::step("missing_files")).await?
|
||||
} else {
|
||||
tracing::warn!(dir = %cfg.general.download_dir.display(), "the downloads folder is not there; left every file as it was");
|
||||
vec![]
|
||||
};
|
||||
for (id, path) in missing {
|
||||
if !dry_run {
|
||||
db.mark_reaped(id).await?;
|
||||
}
|
||||
@@ -57,39 +69,32 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
report.reconciled += 1;
|
||||
}
|
||||
|
||||
for id in db.prune_abandoned_failures().await? {
|
||||
for id in db.prune_abandoned_failures().instrument(db::step("prune_abandoned_failures")).await? {
|
||||
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
|
||||
}
|
||||
|
||||
let mut candidates = db.reap_candidates().await?;
|
||||
// A file's DeadAir copy is on the disk too, and goes when it goes (#171).
|
||||
for c in &mut candidates {
|
||||
c.bytes += copy_bytes(&c.path) as i64;
|
||||
}
|
||||
let candidates = db.reap_candidates().instrument(db::step("reap_candidates")).await?;
|
||||
|
||||
if cfg.general.max_age_days > 0 {
|
||||
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||
let cutoff = db::now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||
report.aged_out = aged(&candidates, cutoff);
|
||||
for c in &report.aged_out {
|
||||
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||
report.bytes_freed += remove(db, c, dry_run).instrument(db::step("remove_aged")).await?;
|
||||
}
|
||||
if !dry_run {
|
||||
report.entries_pruned = db.prune_entries(cutoff).await?;
|
||||
report.entries_pruned = db.prune_entries(cutoff).instrument(db::step("prune_entries")).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.general.max_total_gb > 0.0 {
|
||||
let limit = (cfg.general.max_total_gb * 1_073_741_824.0) as u64;
|
||||
// Re-read: the age sweep may already have freed enough.
|
||||
let remaining: Vec<Candidate> = candidates
|
||||
.iter()
|
||||
.filter(|c| !report.aged_out.iter().any(|a| a.id == c.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
let remaining: Vec<Candidate> =
|
||||
candidates.iter().filter(|c| !report.aged_out.iter().any(|a| a.id == c.id)).cloned().collect();
|
||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||
report.over_quota = pick(&remaining, total, limit);
|
||||
for c in &report.over_quota {
|
||||
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||
report.bytes_freed += remove(db, c, dry_run).instrument(db::step("remove_over_quota")).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,13 +106,10 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
return Ok(c.bytes.max(0) as u64);
|
||||
}
|
||||
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
|
||||
let size = std::fs::metadata(&c.path)
|
||||
.ok()
|
||||
.filter(|m| m.is_file())
|
||||
.map_or(c.bytes.max(0) as u64, |m| m.len() + copy_bytes(&c.path));
|
||||
let size = std::fs::metadata(&c.path).ok().filter(|m| m.is_file()).map_or(c.bytes.max(0) as u64, |m| m.len());
|
||||
// Missing is fine: the point is that it is gone.
|
||||
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
if let Err(e) = crate::download::remove(Path::new(&c.path))
|
||||
&& e.kind() != ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||
return Ok(0);
|
||||
@@ -116,8 +118,36 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
fn copy_bytes(path: &str) -> u64 {
|
||||
std::fs::metadata(crate::deadair::copy_path(std::path::Path::new(path))).map_or(0, |m| m.len())
|
||||
/// `standalone` false means this is the sweep that runs before a scan: it reports what it
|
||||
/// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch`
|
||||
/// would stop reading before the scan had even started.
|
||||
#[tracing::instrument(name = "reap", skip_all, fields(dry_run = dry_run, files = tracing::field::Empty, bytes = tracing::field::Empty, entries = tracing::field::Empty))]
|
||||
pub(crate) async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
||||
let r = run(&ctx.cfg(), &ctx.db, dry_run).await?;
|
||||
let span = tracing::Span::current();
|
||||
span.record("files", r.aged_out.len() + r.over_quota.len());
|
||||
span.record("bytes", r.bytes_freed);
|
||||
span.record("entries", r.entries_pruned);
|
||||
if !dry_run {
|
||||
tracing::Instrument::instrument(catalogue::clean_directory(ctx), db::step("clean_directory")).await?;
|
||||
let moved =
|
||||
tracing::Instrument::instrument(ctx.db.advance_unread_from(), db::step("advance_unread_from")).await?;
|
||||
tracing::debug!(moved, "unread lists start from each person's oldest unread item");
|
||||
// Off the async threads: it walks every file kept, on FUSE (#345).
|
||||
let (span, limit) = (db::step("trim_artwork"), ctx.cfg().general.art_cache_mb * 1_048_576);
|
||||
let gone = tokio::task::spawn_blocking(move || span.in_scope(|| art::trim(&art::dir(), limit))).await?;
|
||||
if gone > 0 {
|
||||
tracing::debug!(gone, "trimmed the artwork kept on disk");
|
||||
}
|
||||
}
|
||||
let removed = r.aged_out.iter().chain(r.over_quota.iter());
|
||||
for c in removed {
|
||||
ctx.out.emit(Event::Reaped { path: c.path.clone(), bytes: c.bytes.max(0) as u64 });
|
||||
}
|
||||
if standalone {
|
||||
ctx.out.emit(Event::ReapDone { files: r.aged_out.len() + r.over_quota.len(), bytes: r.bytes_freed });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -125,14 +155,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cand(id: i64, bytes: i64, age_key: i64, read: bool) -> Candidate {
|
||||
Candidate {
|
||||
id,
|
||||
url: format!("https://x/{id}"),
|
||||
path: format!("/tmp/{id}"),
|
||||
bytes,
|
||||
age_key,
|
||||
read,
|
||||
}
|
||||
Candidate { id, url: format!("https://x/{id}"), path: format!("/tmp/{id}"), bytes, age_key, read }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -189,7 +212,8 @@ mod tests {
|
||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||
@@ -218,13 +242,31 @@ mod tests {
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
|
||||
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
|
||||
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
|
||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_downloads_folder_that_is_gone_forgets_no_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1, 'f', 'a', 'u1', '/nowhere/ipx-test/a.mp3', 'done');",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = "/nowhere/ipx-test".into();
|
||||
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 0, "the folder is gone, not the file");
|
||||
|
||||
cfg.general.download_dir = std::env::temp_dir();
|
||||
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is not");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
@@ -239,7 +281,8 @@ mod tests {
|
||||
('f', 'recent', 900);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||
1056
crates/ipx/src/scan.rs
Normal file
1056
crates/ipx/src/scan.rs
Normal file
File diff suppressed because it is too large
Load Diff
688
crates/ipx/src/signals.rs
Normal file
688
crates/ipx/src/signals.rs
Normal file
@@ -0,0 +1,688 @@
|
||||
//! Signals (#254): feeds someone makes from their own subscriptions, holding the items TypeSafe's
|
||||
//! Jev says fit the keywords they gave. Jev is asked on their own key, once for each item, with a
|
||||
//! question for each of their Signals that has not had it yet, and what it says is kept in
|
||||
//! `signal_verdicts`, fitting or not, so nothing is asked twice.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use futures_util::StreamExt as _;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::db;
|
||||
use crate::db::{Ask, Unjudged};
|
||||
use crate::feed;
|
||||
use crate::metrics::STATS;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
|
||||
/// long the queue is held before people who got a key later are looked at.
|
||||
const BATCH: i64 = 50;
|
||||
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
|
||||
const IDLE: Duration = Duration::from_secs(300);
|
||||
/// Calls to Jev in flight at once (#275). One at a time, about 150 ms each, a new Signal's month
|
||||
/// of items took twenty minutes. Enough for `MAX_RATE` at that pace; the pace is `pace`'s to keep.
|
||||
const IN_FLIGHT: usize = 10;
|
||||
/// TypeSafe's default limits are 80 requests and 100,000 input tokens a second (#277). Calls are
|
||||
/// paced under both, with room left for the icon picks and key checks that share them.
|
||||
const MAX_RATE: f64 = 60.0;
|
||||
const MAX_TOKENS: f64 = 80_000.0;
|
||||
/// When the next call may start, for every call ipx makes to Jev.
|
||||
static NEXT_CALL: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
|
||||
/// How sure Jev has to be that an item is about the keywords.
|
||||
const THRESHOLD: f64 = 0.5;
|
||||
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
|
||||
/// items and six Signals, 1000 changed none of 330 verdicts and saved an eighth of the input
|
||||
/// tokens; 500 changed one, an Apple podcast's episode no longer about Apple.
|
||||
const TEXT_CHARS: usize = 1000;
|
||||
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
let done = tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
done = pass(&ctx) => done,
|
||||
};
|
||||
crate::metrics::STATS.signals_last_pass.store(crate::db::now(), Relaxed);
|
||||
match done {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "Signals could not be judged"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
_ = ctx.signals.notified() => {}
|
||||
_ = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key every Signal is judged on, when an admin has chosen that the server pays for Jev
|
||||
/// (#271) and the server has a key; None leaves each person to their own.
|
||||
pub fn server_key(ctx: &Ctx) -> Option<String> {
|
||||
if !ctx.cfg().general.signals_on_server {
|
||||
return None;
|
||||
}
|
||||
typesafe_key()
|
||||
}
|
||||
|
||||
/// The server's own TypeSafe key, `TYPESAFE_KEY`; set but empty is none, as the browser suite
|
||||
/// sets it so the test daemon never spends a real one.
|
||||
pub fn typesafe_key() -> Option<String> {
|
||||
std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty())
|
||||
}
|
||||
|
||||
/// One batch. True when there may be more waiting.
|
||||
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||
let server = server_key(ctx);
|
||||
// People's refused keys are not what is asked with when the server's is.
|
||||
let skip: Vec<i64> = if server.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
ctx.signals_refused.lock().expect("a panic elsewhere poisoned this lock").iter().copied().collect()
|
||||
};
|
||||
let days = ctx.cfg().general.signal_days;
|
||||
let items = ctx.db.unjudged(&skip, BATCH, server.is_some(), days).await?;
|
||||
if items.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let span = tracing::info_span!(
|
||||
"signals",
|
||||
entries = items.len(),
|
||||
calls = tracing::field::Empty,
|
||||
judged = tracing::field::Empty,
|
||||
hits = tracing::field::Empty
|
||||
);
|
||||
async {
|
||||
let groups = calls(&items);
|
||||
let paid = if server.is_some() { "server" } else { "own" };
|
||||
tracing::Span::current().record("calls", groups.len());
|
||||
let refused = std::sync::Mutex::new(HashSet::new());
|
||||
let stopped = AtomicBool::new(false);
|
||||
let mut hits_for = BTreeMap::<i64, usize>::new();
|
||||
let mut judged = 0;
|
||||
// Each call starts only when a place is free, so one that sees a key refused or the pass
|
||||
// stopped is not sent; those already in flight are let finish, and their answers kept.
|
||||
// By index: a closure taking a reference here is not Send for every lifetime, which
|
||||
// tokio::spawn asks of the judge's future.
|
||||
let mut answers = futures_util::stream::iter(0..groups.len())
|
||||
.map(|n| {
|
||||
let (group, refused, stopped, server) = (&groups[n], &refused, &stopped, &server);
|
||||
async move {
|
||||
let first = group[0];
|
||||
if stopped.load(Relaxed) || refused.lock().expect("a panic elsewhere poisoned this lock").contains(&first.user_id) {
|
||||
return None;
|
||||
}
|
||||
let key = server.as_deref().or(first.key.as_deref())?;
|
||||
Some((group, judge(ctx, group, key, paid).await))
|
||||
}
|
||||
})
|
||||
.buffer_unordered(IN_FLIGHT);
|
||||
while let Some(answer) = answers.next().await {
|
||||
let Some((group, answer)) = answer else { continue };
|
||||
let first = group[0];
|
||||
match answer {
|
||||
Ok(verdicts) => {
|
||||
for (item, verdicts) in group.iter().zip(verdicts) {
|
||||
judged += 1;
|
||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||
STATS.bill(&item.user_name, paid, [1.0, hits as f64, 0.0, 0.0]);
|
||||
if hits > 0 {
|
||||
*hits_for.entry(item.user_id).or_default() += hits;
|
||||
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||
}
|
||||
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(db::step("record_verdicts")).await?;
|
||||
}
|
||||
}
|
||||
// The server's key refused is everyone's: the queue waits for the next pass.
|
||||
Err(e) if refuses_key(&e) && server.is_some() => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
|
||||
}
|
||||
}
|
||||
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
|
||||
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
|
||||
Err(e) if refuses_key(&e) => {
|
||||
if refused.lock().expect("a panic elsewhere poisoned this lock").insert(first.user_id) {
|
||||
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||
}
|
||||
}
|
||||
// TypeSafe says the request itself is wrong, so every other item's would be too:
|
||||
// the rest wait for the next pass rather than each failing the same way (#257).
|
||||
Err(e) if malformed(&e) => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||
}
|
||||
}
|
||||
// Over TypeSafe's limits: what is in flight finishes, nothing more starts, and
|
||||
// the next pass waits for `back_off`.
|
||||
Err(e) if rate_limited(&e) => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "TypeSafe asked Signals to slow down");
|
||||
}
|
||||
}
|
||||
// Left unjudged, so it is asked again next pass.
|
||||
Err(e) => tracing::warn!(user = first.user_id, feed = %first.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
||||
}
|
||||
}
|
||||
drop(answers);
|
||||
let span = tracing::Span::current();
|
||||
span.record("judged", judged);
|
||||
span.record("hits", hits_for.values().sum::<usize>());
|
||||
// Once a pass, not once an item: a page fetches its counts again on each.
|
||||
for (user, hits) in hits_for {
|
||||
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
||||
}
|
||||
let refused = refused.into_inner().expect("a panic elsewhere poisoned this lock");
|
||||
let some_refused = !refused.is_empty();
|
||||
ctx.signals_refused.lock().expect("a panic elsewhere poisoned this lock").extend(refused);
|
||||
// A pass that only failed would otherwise go straight round again on the same items, and
|
||||
// one TypeSafe refused as malformed would ask again at once, however full.
|
||||
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
|
||||
/// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
|
||||
/// then one person's description was in the question that decided another's verdicts (#343).
|
||||
fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
|
||||
items.iter().map(|item| vec![item]).collect()
|
||||
}
|
||||
|
||||
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||
/// alone said only 422 (#257).
|
||||
#[derive(Debug)]
|
||||
struct Refused {
|
||||
status: u16,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Display for Refused {
|
||||
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
|
||||
write!(f, "TypeSafe answered {}: {}", self.status, self.body)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refused {}
|
||||
|
||||
fn status(e: &anyhow::Error) -> Option<u16> {
|
||||
e.downcast_ref::<Refused>().map(|r| r.status)
|
||||
}
|
||||
|
||||
/// Whether the batch was as long as `unjudged` gives, so more may be waiting. Its limit is on
|
||||
/// rows, one for each item and Signal: with two Signals, 50 is 25 items, and comparing the items
|
||||
/// with 50 took every batch for the last, 25 items every 5 minutes (#260).
|
||||
fn full(items: &[Unjudged]) -> bool {
|
||||
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
||||
}
|
||||
|
||||
pub fn refuses_key(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(401..=403))
|
||||
}
|
||||
|
||||
fn rate_limited(e: &anyhow::Error) -> bool {
|
||||
status(e) == Some(429)
|
||||
}
|
||||
|
||||
/// A call's turn: it starts at `next` or now, whichever is later, and holds the line for its
|
||||
/// share of a second under both limits. Gives when it starts and when the one after may.
|
||||
fn slot(
|
||||
next: tokio::time::Instant,
|
||||
now: tokio::time::Instant,
|
||||
tokens: f64,
|
||||
) -> (tokio::time::Instant, tokio::time::Instant) {
|
||||
let start = next.max(now);
|
||||
(start, start + Duration::from_secs_f64((1.0 / MAX_RATE).max(tokens / MAX_TOKENS)))
|
||||
}
|
||||
|
||||
/// Waits for this call's turn. Its tokens are guessed from the request, a token to three bytes
|
||||
/// of JSON, on the high side: TypeSafe says the true count only in the answer.
|
||||
async fn pace(body: &serde_json::Value) {
|
||||
let tokens = body.to_string().len() as f64 / 3.0;
|
||||
let start = {
|
||||
let mut next = NEXT_CALL.lock().await;
|
||||
let now = tokio::time::Instant::now();
|
||||
let (start, after) = slot(next.unwrap_or(now), now, tokens);
|
||||
*next = Some(after);
|
||||
start
|
||||
};
|
||||
let waited = start.saturating_duration_since(tokio::time::Instant::now());
|
||||
tokio::time::sleep_until(start).await;
|
||||
// How long the limits held this call back: near nothing unless ipx is near them (#277).
|
||||
STATS.jev_paced_micros.fetch_add(waited.as_micros() as u64, Relaxed);
|
||||
tracing::Span::current().record("paced_ms", waited.as_millis() as u64);
|
||||
}
|
||||
|
||||
/// TypeSafe said too many: nobody calls again until it says, or a second.
|
||||
async fn back_off(retry_after: Option<u64>) {
|
||||
let until = tokio::time::Instant::now() + Duration::from_secs(retry_after.unwrap_or(1).min(60));
|
||||
let mut next = NEXT_CALL.lock().await;
|
||||
*next = Some(next.map_or(until, |n| n.max(until)));
|
||||
}
|
||||
|
||||
fn malformed(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(400 | 422))
|
||||
}
|
||||
|
||||
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
|
||||
async fn judge(ctx: &Ctx, group: &[&Unjudged], key: &str, paid: &'static str) -> Result<Vec<Vec<(i64, bool)>>> {
|
||||
let asks: Vec<&Ask> = group.iter().flat_map(|i| &i.signals).collect();
|
||||
let span = tracing::info_span!(
|
||||
"jev",
|
||||
user = group[0].user_id,
|
||||
people = group.len(),
|
||||
signal_count = asks.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
|
||||
// A shared call is billed by each person's share of its questions, which are most of it.
|
||||
let [input, output] = usage(&answer);
|
||||
for item in group {
|
||||
let share = item.signals.len() as f64 / asks.len() as f64;
|
||||
STATS.bill(&item.user_name, paid, [0.0, 0.0, input * share, output * share]);
|
||||
}
|
||||
// A question Jev left unanswered stays unjudged and is asked again.
|
||||
Ok(group.iter().map(|i| i.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()).collect())
|
||||
}
|
||||
|
||||
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
|
||||
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
|
||||
fn request(item: &Unjudged, asks: &[&Ask]) -> serde_json::Value {
|
||||
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
|
||||
let state = format!(
|
||||
"Feed: {}\nTitle: {}\n\n{text}",
|
||||
item.feed_title.as_deref().unwrap_or(""),
|
||||
item.title.as_deref().unwrap_or("")
|
||||
);
|
||||
let questions: serde_json::Map<String, serde_json::Value> = asks.iter().map(|a| (key(a.id), question(a))).collect();
|
||||
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
||||
}
|
||||
|
||||
fn key(id: i64) -> String {
|
||||
format!("signal_{id}")
|
||||
}
|
||||
|
||||
/// One subject, named by the Signal and described by its words and its owner's sentence. Asked
|
||||
/// whether an item was about any one of its words, AI Development (ai, development, programming)
|
||||
/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and
|
||||
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
|
||||
/// mean: a sentence on its own was refused with a 422 (#257).
|
||||
fn question(a: &Ask) -> serde_json::Value {
|
||||
let mut said = format!(
|
||||
"The reader follows a subject they call \"{}\", described by these words: {}.",
|
||||
a.name,
|
||||
a.keywords.join(", ")
|
||||
);
|
||||
if let Some(d) = &a.description {
|
||||
said.push_str(&format!(" In their own words: {d}"));
|
||||
}
|
||||
serde_json::json!({
|
||||
"type": "noul",
|
||||
"instructions": format!("{said} Is this item about that subject?"),
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether Jev says the item fits Signal `id`; None when the answer does not say.
|
||||
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
||||
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
||||
}
|
||||
|
||||
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
|
||||
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||
/// server's page doing it, once a Signal.
|
||||
pub async fn pick_icon(
|
||||
ctx: &Ctx,
|
||||
who: &str,
|
||||
key: &str,
|
||||
name: &str,
|
||||
words: &[String],
|
||||
description: Option<&str>,
|
||||
candidates: &[(String, String)],
|
||||
) -> Result<String> {
|
||||
let span = tracing::info_span!(
|
||||
"jev_icon",
|
||||
candidates = candidates.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
called(span, async {
|
||||
let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?;
|
||||
let [input, output] = usage(&answer);
|
||||
STATS.bill(who, "server", [0.0, 0.0, input, output]);
|
||||
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Whether TypeSafe takes `key`, by one short question, before it is saved (#271): a key it
|
||||
/// refuses would sit saved with nothing ever picked out, and nobody told why.
|
||||
pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
|
||||
let span = tracing::info_span!(
|
||||
"jev_key_check",
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": "A reader is saving their key.",
|
||||
"questions": { "ok": { "type": "noul", "instructions": "Is this a key check?",
|
||||
"criteria": { "true": "It is.", "false": "It is not." } } },
|
||||
});
|
||||
let answer = called(span, ask(ctx, key, &body)).await?;
|
||||
let [input, output] = usage(&answer);
|
||||
STATS.bill(who, "own", [0.0, 0.0, input, output]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries
|
||||
/// this trace's `traceparent`, as every call out of ipx does.
|
||||
async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT);
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
pace(body).await;
|
||||
let r = req.send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if status == 429 {
|
||||
back_off(r.headers().get("retry-after").and_then(|v| v.to_str().ok()?.parse().ok())).await;
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
let answer: serde_json::Value = r.json().await?;
|
||||
// What a call cost, which TypeSafe says and nothing else would (#275); billed by the caller,
|
||||
// who knows whose it was (#278).
|
||||
for kind in ["input_tokens", "output_tokens"] {
|
||||
if let Some(t) = answer["usage"][kind].as_u64() {
|
||||
tracing::Span::current().record(kind, t);
|
||||
}
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Input and output tokens, as an answer counts them; 0 when it does not.
|
||||
fn usage(answer: &serde_json::Value) -> [f64; 2] {
|
||||
["input_tokens", "output_tokens"].map(|k| answer["usage"][k].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
|
||||
/// failed, which a field named `error` alone does not: Tempo's failed traces left Jev's out.
|
||||
async fn called<T>(span: tracing::Span, call: impl Future<Output = Result<T>>) -> Result<T> {
|
||||
let started = std::time::Instant::now();
|
||||
let r = call.instrument(span.clone()).await;
|
||||
STATS.jev_micros.fetch_add(started.elapsed().as_micros() as u64, Relaxed);
|
||||
STATS.jev_calls[outcome(&r)].fetch_add(1, Relaxed);
|
||||
if let Err(e) = &r {
|
||||
span.record("error", format!("{e:#}").as_str());
|
||||
span.record("otel.status_code", "ERROR");
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Which of `metrics::JEV_RESULTS` a call's result is.
|
||||
fn outcome<T>(r: &Result<T>) -> usize {
|
||||
match r {
|
||||
Ok(_) => 0,
|
||||
Err(e) if refuses_key(e) => 1,
|
||||
Err(e) if malformed(e) => 2,
|
||||
Err(e) if rate_limited(e) => 3,
|
||||
Err(_) => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_request(
|
||||
name: &str,
|
||||
words: &[String],
|
||||
description: Option<&str>,
|
||||
candidates: &[(String, String)],
|
||||
) -> serde_json::Value {
|
||||
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||
if let Some(d) = description {
|
||||
state.push_str(&format!("\nIn their words: {d}"));
|
||||
}
|
||||
let criteria: serde_json::Map<String, serde_json::Value> =
|
||||
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
|
||||
serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": state,
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": criteria,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
/// Jev's choice, only if it is one it was offered.
|
||||
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
|
||||
let c = answer["answers"]["icon"]["choice"].as_str()?;
|
||||
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
|
||||
}
|
||||
|
||||
/// Show notes are HTML: Jev is given their words, and an image's alt and title text, which is all
|
||||
/// the words some posts have. xkcd's is a comic and its hover text; dropping the tag whole sent Jev
|
||||
/// "Ground Effect" alone, an F1 aerodynamics term, and the comic went into Formula 1 (#267).
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut tag: Option<String> = None;
|
||||
for c in html.chars() {
|
||||
match (&mut tag, c) {
|
||||
(None, '<') => tag = Some(String::new()),
|
||||
(Some(t), '>') => {
|
||||
let mut said: Vec<&str> = Vec::new();
|
||||
for name in ["alt", "title"] {
|
||||
if let Some(v) = attr(t, name).filter(|v| !v.trim().is_empty() && !said.contains(v)) {
|
||||
said.push(v);
|
||||
}
|
||||
}
|
||||
for v in said {
|
||||
out.push(' ');
|
||||
out.push_str(v);
|
||||
}
|
||||
out.push(' ');
|
||||
tag = None;
|
||||
}
|
||||
(Some(t), c) => t.push(c),
|
||||
(None, c) => out.push(c),
|
||||
}
|
||||
}
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A quoted attribute's value in a tag's text, `alt="..."` or `alt='...'`.
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
for q in ['"', '\''] {
|
||||
let key = format!(" {name}={q}");
|
||||
if let Some(at) = lower.find(&key) {
|
||||
let rest = &tag[at + key.len()..];
|
||||
return rest.find(q).map(|end| &rest[..end]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_noul_of_a_half_or_more_is_a_fit() {
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": {
|
||||
"signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } });
|
||||
assert_eq!(hit(&answer, 3), Some(true));
|
||||
assert_eq!(hit(&answer, 4), Some(false));
|
||||
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_is_one_jev_was_offered() {
|
||||
let offered = vec![
|
||||
("solid/robot".to_string(), "Robot: ai, bot".to_string()),
|
||||
("solid/laptop-code".to_string(), "Laptop Code".to_string()),
|
||||
];
|
||||
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
|
||||
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
|
||||
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
|
||||
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_words_are_read_with_the_text() {
|
||||
let xkcd = r#"<img src="https://imgs.xkcd.com/comics/ground_effect.png" title="Runners wear sneakers." alt="Runners wear sneakers." referrerpolicy="no-referrer">"#;
|
||||
assert_eq!(text_of(xkcd), "Runners wear sneakers.", "alt and title once, when they say the same");
|
||||
assert_eq!(text_of("<p>Qualifying <b>report</b></p><img alt='A grid'>"), "Qualifying report A grid");
|
||||
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_is_counted_by_how_it_ended() {
|
||||
let refused = |status| -> Result<()> { Err(Refused { status, body: String::new() }.into()) };
|
||||
let named = |r: Result<()>| crate::metrics::JEV_RESULTS[outcome(&r)];
|
||||
assert_eq!(named(Ok(())), "ok");
|
||||
assert_eq!(named(refused(402)), "key_refused");
|
||||
assert_eq!(named(refused(422)), "malformed");
|
||||
assert_eq!(named(refused(503)), "error");
|
||||
assert_eq!(named(refused(429)), "rate_limited");
|
||||
assert_eq!(named(Err(anyhow::anyhow!("timed out"))), "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_persons_signals_never_share_a_call_with_anothers() {
|
||||
let row = |user_id, guid: &str| Unjudged {
|
||||
user_id,
|
||||
user_name: format!("u{user_id}"),
|
||||
key: None,
|
||||
feed_id: "f".into(),
|
||||
guid: guid.into(),
|
||||
feed_title: None,
|
||||
title: None,
|
||||
description: None,
|
||||
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
|
||||
};
|
||||
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
|
||||
let shape: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
|
||||
assert_eq!(shape, [vec![1], vec![2], vec![1]], "x is asked for each of them apart (#343)");
|
||||
let mine: Vec<&Ask> = calls(&items)[0].iter().flat_map(|i| &i.signals).collect();
|
||||
let r = request(&items[0], &mine);
|
||||
assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_are_paced_under_both_of_typesafes_limits() {
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let ms = |a: tokio::time::Instant| (a - t0).as_secs_f64() * 1000.0;
|
||||
// A small call holds the line for a 60th of a second, under 80 requests a second.
|
||||
let (start, next) = slot(t0, t0, 1000.0);
|
||||
assert_eq!((ms(start), ms(next).round()), (0.0, 17.0));
|
||||
// One of 8,000 tokens, a tenth of a second, under 100,000 tokens a second.
|
||||
let (start, next) = slot(next, t0, 8000.0);
|
||||
assert_eq!((ms(start).round(), ms(next).round()), (17.0, 117.0));
|
||||
// After a quiet spell, a call starts at once.
|
||||
let later = t0 + std::time::Duration::from_secs(5);
|
||||
assert_eq!(slot(next, later, 1000.0).0, later);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||
let item = |n: usize| Unjudged {
|
||||
user_id: 1,
|
||||
user_name: "a".into(),
|
||||
key: Some("k".into()),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: None,
|
||||
title: None,
|
||||
description: None,
|
||||
signals: (0..n as i64)
|
||||
.map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None })
|
||||
.collect(),
|
||||
};
|
||||
assert!(
|
||||
full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()),
|
||||
"25 items of two Signals each is the 50 asked for"
|
||||
);
|
||||
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||
let item = Unjudged {
|
||||
user_id: 1,
|
||||
user_name: "a".into(),
|
||||
key: Some("k".into()),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: Some("Pit Lane".into()),
|
||||
title: Some("Monaco".into()),
|
||||
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
||||
signals: vec![
|
||||
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
|
||||
Ask {
|
||||
id: 4,
|
||||
name: "Food".into(),
|
||||
keywords: vec!["cooking".into()],
|
||||
description: Some("Recipes, not restaurants.".into()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
|
||||
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
||||
let q = &r["questions"]["signal_3"];
|
||||
assert_eq!(q["type"], "noul");
|
||||
let says = q["instructions"].as_str().unwrap();
|
||||
assert!(
|
||||
says.contains("\"F1\"") && says.contains("formula 1, f1"),
|
||||
"one subject, by its name and its words: {says}"
|
||||
);
|
||||
assert!(!says.contains("own words"), "no sentence, none said");
|
||||
assert!(
|
||||
q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(),
|
||||
"TypeSafe takes an object, not a sentence"
|
||||
);
|
||||
assert!(
|
||||
r["questions"]["signal_4"]["instructions"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("In their own words: Recipes, not restaurants.")
|
||||
);
|
||||
}
|
||||
}
|
||||
104
crates/ipx/src/test_util.rs
Normal file
104
crates/ipx/src/test_util.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! What several modules' tests share: a context on a scratch database, feeds to put in it, and a
|
||||
//! server answering with feeds.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Emitter;
|
||||
use crate::{config, db};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn feed() -> config::Feed {
|
||||
// Whatever `ipx add` would write, which is the shape every code path sees.
|
||||
let mut cfg = config::Config::default();
|
||||
|
||||
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])
|
||||
}
|
||||
|
||||
/// The feed entry `add` builds, without the network round trip it does for a title.
|
||||
pub(crate) fn add_one_cfg(
|
||||
_cfg: &mut config::Config,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
) -> config::Feed {
|
||||
config::Feed {
|
||||
url: url.into(),
|
||||
folder,
|
||||
keywords,
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sub(kw: Option<&[&str]>, auto: Option<bool>, max: Option<i64>) -> db::Sub {
|
||||
db::Sub {
|
||||
feed_id: "f".into(),
|
||||
keywords: kw.map(|k| k.iter().map(|s| s.to_string()).collect()),
|
||||
auto_download: auto,
|
||||
allow_explicit: None,
|
||||
max_new_per_check: max,
|
||||
blocked: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn test_ctx(cfg: config::Config) -> Ctx {
|
||||
Ctx {
|
||||
cfg: std::sync::RwLock::new(Arc::new(cfg)),
|
||||
db: db::Db::memory().await.unwrap(),
|
||||
client: reqwest::Client::new(),
|
||||
local_client: reqwest::Client::new(),
|
||||
feed_client: reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap(),
|
||||
out: Emitter::terminal(),
|
||||
torrents: tokio::sync::OnceCell::new(),
|
||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||
config_path: PathBuf::new(),
|
||||
detach_torrents: false,
|
||||
retry: Default::default(),
|
||||
cfg_write: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers /empty with a 200 and nothing, /fixed with a feed of one item as it should read,
|
||||
/// anything else with the same item's text broken.
|
||||
pub(crate) async fn empty_or_feed_server() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]).into_owned();
|
||||
let body = if req.starts_with("GET /empty") {
|
||||
"\n"
|
||||
} else if req.starts_with("GET /fixed") {
|
||||
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>As it should read.</description></item></channel></rss>"
|
||||
} else {
|
||||
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>Somebody else's text.</description></item></channel></rss>"
|
||||
};
|
||||
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::Config;
|
||||
use librqbit::{ListenerOptions, ManagedTorrent};
|
||||
|
||||
pub struct Torrents {
|
||||
session: Arc<Session>,
|
||||
@@ -26,10 +27,7 @@ impl Torrents {
|
||||
let listen_addr: SocketAddr = format!("0.0.0.0:{lo}").parse()?;
|
||||
|
||||
let opts = SessionOptions {
|
||||
listen: Some(librqbit::ListenerOptions {
|
||||
listen_addr,
|
||||
..Default::default()
|
||||
}),
|
||||
listen: Some(ListenerOptions { listen_addr, ..Default::default() }),
|
||||
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -42,13 +40,15 @@ impl Torrents {
|
||||
}
|
||||
|
||||
/// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`,
|
||||
/// seeds it up to the configured limit, then stops.
|
||||
/// seeds it up to the configured limit, then stops. `dest_dir` is the torrent's own
|
||||
/// folder (`own_folder`), and it is the path returned: a file of several or one.
|
||||
///
|
||||
/// Downloading in place rather than into a staging dir is deliberate: seeding serves
|
||||
/// the files it downloaded, so moving them first would break it.
|
||||
pub async fn fetch(
|
||||
&self,
|
||||
cfg: &Config,
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
dest_dir: &Path,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
@@ -56,6 +56,15 @@ impl Torrents {
|
||||
tokio::fs::create_dir_all(dest_dir).await?;
|
||||
|
||||
let limit = stall_limit(cfg);
|
||||
let source = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
// Under the stall budget as well: a body sent a byte a minute stays inside the cap.
|
||||
let file = tokio::time::timeout(limit, torrent_file(client, url))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("no .torrent after {} minutes, gave up", limit.as_secs() / 60))??;
|
||||
AddTorrent::from_bytes(file)
|
||||
} else {
|
||||
AddTorrent::from_url(url)
|
||||
};
|
||||
|
||||
// Resolving a magnet's metadata happens inside add_torrent, and against a dead
|
||||
// swarm it never returns. The stall budget has to cover this phase too, or a
|
||||
@@ -63,7 +72,7 @@ impl Torrents {
|
||||
let added = tokio::time::timeout(
|
||||
limit,
|
||||
self.session.add_torrent(
|
||||
AddTorrent::from_url(url),
|
||||
source,
|
||||
Some(AddTorrentOptions {
|
||||
output_folder: Some(dest_dir.to_string_lossy().into_owned()),
|
||||
overwrite: true,
|
||||
@@ -72,12 +81,7 @@ impl Torrents {
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"no metadata after {} minutes, gave up",
|
||||
limit.as_secs() / 60
|
||||
)
|
||||
})?
|
||||
.map_err(|_| anyhow::anyhow!("no metadata after {} minutes, gave up", limit.as_secs() / 60))?
|
||||
.context("adding the torrent")?;
|
||||
|
||||
let (id, handle) = match added {
|
||||
@@ -111,21 +115,20 @@ impl Torrents {
|
||||
}
|
||||
}
|
||||
|
||||
let name = handle.name().unwrap_or_else(|| "torrent".to_owned());
|
||||
let path = handle.output_folder().join(&name);
|
||||
// The torrent's own name is the publisher's to choose, and librqbit checks it only when it
|
||||
// picks the folder itself: joined on, "/config" or ".." pointed Delete, the reaper and
|
||||
// /media at anything the daemon can reach (#318). The folder is ours, so it is the path.
|
||||
let path = dest_dir.to_owned();
|
||||
let size = handle.stats().total_bytes;
|
||||
|
||||
self.seed(cfg, &handle).await;
|
||||
self.session
|
||||
.delete(id.into(), false)
|
||||
.await
|
||||
.context("releasing the torrent")?;
|
||||
self.session.delete(id.into(), false).await.context("releasing the torrent")?;
|
||||
|
||||
Ok((path, size))
|
||||
}
|
||||
|
||||
/// Seeds until the ratio or the time limit is reached, whichever comes first.
|
||||
async fn seed(&self, cfg: &Config, handle: &Arc<librqbit::ManagedTorrent>) {
|
||||
async fn seed(&self, cfg: &Config, handle: &Arc<ManagedTorrent>) {
|
||||
if cfg.torrent.seed_ratio <= 0.0 || cfg.torrent.seed_time_mins == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -145,6 +148,30 @@ impl Torrents {
|
||||
}
|
||||
}
|
||||
|
||||
/// A .torrent file is metadata: a few hundred KB even for a large torrent.
|
||||
const MAX_TORRENT_FILE: usize = 10 << 20;
|
||||
|
||||
/// Fetches a .torrent file, up to `MAX_TORRENT_FILE`. Handed the URL, librqbit read the whole body
|
||||
/// into memory with no limit and no timeout, so a feed could serve one without end (#330).
|
||||
async fn torrent_file(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
|
||||
let mut resp = client.get(url).send().await.context("fetching the .torrent")?.error_for_status()?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(c) = resp.chunk().await.context("reading the .torrent")? {
|
||||
out.extend_from_slice(&c);
|
||||
if out.len() > MAX_TORRENT_FILE {
|
||||
bail!("the .torrent is larger than {} MB, too large to be one", MAX_TORRENT_FILE >> 20);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The folder an enclosure's torrent downloads into, one per enclosure inside the feed's folder.
|
||||
/// With `overwrite` on, a torrent written straight into the feed's folder could replace another
|
||||
/// enclosure's files of the same name, another feed's too when they share a folder (#331).
|
||||
pub fn own_folder(feed_dir: &Path, enclosure: i64) -> PathBuf {
|
||||
feed_dir.join(format!("torrent-{enclosure}"))
|
||||
}
|
||||
|
||||
/// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero.
|
||||
pub fn ratio(uploaded: u64, total: u64) -> f64 {
|
||||
if total == 0 {
|
||||
@@ -165,6 +192,32 @@ mod tests {
|
||||
assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_endless_torrent_file_stops_at_the_cap() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let _ = sock.read(&mut [0u8; 2048]).await;
|
||||
// No length, no end: a body that goes on until the reader stops.
|
||||
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n").await;
|
||||
let chunk = vec![b'd'; 64 << 10];
|
||||
while sock.write_all(&chunk).await.is_ok() {}
|
||||
});
|
||||
let err = torrent_file(&reqwest::Client::new(), &format!("http://{addr}/ep.torrent")).await.unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_folder_is_one_folder_inside_the_feeds() {
|
||||
let feed = Path::new("/downloads/Show");
|
||||
let dir = own_folder(feed, 42);
|
||||
assert_eq!(dir.parent(), Some(feed));
|
||||
assert!(matches!(dir.components().next_back(), Some(std::path::Component::Normal(_))));
|
||||
assert_ne!(own_folder(feed, 43), dir, "two enclosures never share a folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_limit_is_never_zero() {
|
||||
let mut cfg = Config::default();
|
||||
File diff suppressed because it is too large
Load Diff
34
deny.toml
Normal file
34
deny.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
# cargo deny check: known vulnerabilities, licences and where crates come from. Run before a push
|
||||
# (CLAUDE.md, Tests). It reads Cargo.lock and the RustSec database, and builds nothing.
|
||||
|
||||
[graph]
|
||||
# Production runs on Linux; Windows and macOS-only crates are not ipx's concern.
|
||||
targets = ["x86_64-unknown-linux-gnu"]
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# ringbuf 0.4's double free needs an element whose Drop panics; librqbit-utp's buffer holds
|
||||
# only u8, which has no Drop. Its fix is ringbuf 0.5, which librqbit 9.0.1 cannot take (#281).
|
||||
{ id = "RUSTSEC-2026-0293", reason = "librqbit-utp's ring buffer holds u8: no Drop to panic" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
# Permissive licences only; anything else is a question for the owner before it comes in.
|
||||
allow = [
|
||||
"MIT", "MIT-0", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause",
|
||||
"ISC", "Zlib", "Unlicense", "0BSD", "CC0-1.0", "BSL-1.0", "Unicode-3.0", "MPL-2.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
# ipx's own crates have no licence field and are not published.
|
||||
private = { ignore = true }
|
||||
|
||||
[bans]
|
||||
# Two versions of one crate (syn 1 and 2, and 25 more) only cost build time, and a warning for
|
||||
# each on every run would be read by nobody.
|
||||
multiple-versions = "allow"
|
||||
wildcards = "deny"
|
||||
allow-wildcard-paths = true
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
@@ -1,33 +1,44 @@
|
||||
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both
|
||||
# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then
|
||||
# `docker compose up -d`, and sign in at http://<host>:8099 as admin, with the password the first
|
||||
# start writes to the config volume: `docker exec ipx cat /config/admin-password`.
|
||||
# docs/configuration.md has the rest, Postgres included.
|
||||
services:
|
||||
ipx:
|
||||
image: 192.168.1.130:5000/ipx:latest
|
||||
container_name: iPX
|
||||
image: ipx
|
||||
container_name: ipx
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Who owns the files it writes. 99:100 is Unraid's nobody:users.
|
||||
PUID: "99"
|
||||
PGID: "100"
|
||||
TZ: "America/Toronto"
|
||||
IPX_LOG: "ipx=info"
|
||||
# One JSON object a line, which Loki (through Alloy) and the Grafana dashboard read.
|
||||
IPX_LOG_FORMAT: "json"
|
||||
# Traces to Tempo, in the monitoring project; ipx sends none without it.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "http://192.168.1.130:4318"
|
||||
# What the dashboard filters on, so a daemon run by hand for testing stays out of it.
|
||||
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=production"
|
||||
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
||||
env_file:
|
||||
- ipx.env # relative: Arcane resolves it inside its own container
|
||||
TZ: "UTC"
|
||||
# For Postgres instead of SQLite:
|
||||
# IPX_DATABASE_URL: "postgres://ipx:password@db-host:5432/ipx"
|
||||
# No web port of its own: ipx-web is the way in, and passes the API on to it.
|
||||
ports:
|
||||
- "8099:8099" # web UI
|
||||
- "6881:6881/tcp" # BitTorrent peers
|
||||
- "6881:6881/udp" # DHT
|
||||
volumes:
|
||||
- /mnt/fast/appdata/ipx:/config # config.toml, and the web token
|
||||
- /mnt/user/ipx/:/data # state.db
|
||||
- /mnt/user/ipx/downloads:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD", "ipx", "status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
- ./config:/config # config.toml, written on first start
|
||||
- ./data:/data # state.db
|
||||
- ./downloads:/downloads
|
||||
|
||||
ipx-web:
|
||||
image: ipx-web
|
||||
container_name: ipx-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
IPX_UPSTREAM: "ipx:8099"
|
||||
# Behind Cloudflare Access only: where cloudflared's requests come from, the one address
|
||||
# whose Cf-Access-* headers are passed on. docs/sso.md says how to find it.
|
||||
# TUNNEL_PEER: "192.168.64.1"
|
||||
# Traces to an OpenTelemetry collector, a span for each request, continued into ipx:
|
||||
# WEB_TRACING: "on"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318"
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
|
||||
# OTEL_SERVICE_NAME: "ipx-web"
|
||||
ports:
|
||||
- "8099:8080" # the web UI
|
||||
depends_on:
|
||||
- ipx
|
||||
|
||||
@@ -1,39 +1,92 @@
|
||||
# How it works
|
||||
|
||||
One binary, `ipx`. `ipx daemon` runs three things in one process: a scheduler, a Unix-socket
|
||||
control server, and the web UI. Everything else is a CLI that either does the work itself or hands
|
||||
it to a running daemon.
|
||||
Three services, each a container of its own with its own version and changelog (#207):
|
||||
|
||||
| Service | What it is | Source | Image |
|
||||
|---|---|---|---|
|
||||
| ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` |
|
||||
| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` |
|
||||
| ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` |
|
||||
|
||||
The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds
|
||||
for both Rust services. Versions: ipx's in `crates/ipx/Cargo.toml`, the directory's in
|
||||
`crates/directory/Cargo.toml`, the page's in `package.json`; CLAUDE.md has the release steps.
|
||||
|
||||
## Traces and logs
|
||||
|
||||
A request is one trace (#213). The page starts one for each tap and sends `traceparent` with its
|
||||
requests (#190); Caddy's span continues it and passes it on to ipx; ipx's request span, its steps
|
||||
and its calls to the directory follow, and the directory's request span joins it. Scans,
|
||||
downloads, DeadAir's work, shares with the directory and the directory's refresher rounds are
|
||||
traces of their own. All three log one JSON object a line, for Loki; ipx's lines carry their trace
|
||||
id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web] metrics_bind`,
|
||||
#194). The exporter and the `traceparent` handling both Rust services use are in
|
||||
`crates/directory/src/otel.rs`.
|
||||
|
||||
## Modules
|
||||
|
||||
| File | Responsibility | What it replaced in the Python |
|
||||
|---|---|---|
|
||||
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
||||
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
||||
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||
| `src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
|
||||
| `src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
|
||||
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
||||
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
||||
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||
| `crates/ipx/src/main.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` |
|
||||
| `crates/ipx/src/cli.rs` | The commands, and what each does when no daemon takes it | `iPXAgent.py` |
|
||||
| `crates/ipx/src/ctx.rs` | `Ctx`, what every command shares: configuration, database, clients | — |
|
||||
| `crates/ipx/src/daemon.rs` | The daemon: worker loop, web server, shutdown, `status` | `iPXAgent.py` |
|
||||
| `crates/ipx/src/scan.rs` | When a feed is due, reading it, download policy, downloads and torrents | `iPXAgent.py` |
|
||||
| `crates/ipx/src/catalogue.rs` | Adding, removing and leaving feeds; subscriptions; retiring a list's feeds | — |
|
||||
| `crates/ipx/src/lists.rs` | OPML import, and keeping an OPML's or a Patreon creator's feeds in step | — |
|
||||
| `crates/ipx/src/logging.rs` | Logging setup, and JSON lines carrying their trace | — |
|
||||
| `crates/ipx/src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||
| `crates/ipx/src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||
| `crates/ipx/src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||
| `crates/feed/src/lib.rs` | ipx-feed's modules and what it exports | — |
|
||||
| `crates/feed/src/fetch.rs` | Conditional GET, redirects followed one by one, bounded bodies | `FeedData.__getFeed` |
|
||||
| `crates/feed/src/parse.rs` | RSS and Atom into `ParsedFeed`: titles, notes, enclosures, artwork, dates | `FeedData.__getEntries` |
|
||||
| `crates/feed/src/outlines.rs` | OPML: recognising, reading safely, listing its feeds | — |
|
||||
| `crates/feed/src/patreon.rs` | A Patreon creator's shows | — |
|
||||
| `crates/feed/src/discover.rs` | Finding a feed from an address, a site or a page | — |
|
||||
| `crates/feed/src/wordpress.rs` | WordPress.com's API for a site behind a browser check | — |
|
||||
| `crates/feed/src/failure.rs` | A failed read in words, and its kind for the log | — |
|
||||
| `crates/feed/src/privacy.rs` | `redact`, and `url_looks_private` | — |
|
||||
| `crates/feed/src/artwork.rs` | Artwork on https, and site icons | — |
|
||||
| `crates/feed/src/category.rs` | Apple's categories | — |
|
||||
| `crates/feed/src/jev.rs` | Jev's pick of a category | — |
|
||||
| `crates/feed/src/guard.rs` | The connection guard against internal addresses (#320) | — |
|
||||
| `crates/ipx/src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||
| `crates/ipx/src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||
| `crates/ipx/src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
|
||||
| `crates/ipx/src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
|
||||
| `crates/ipx/src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
||||
| `crates/ipx/src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
||||
| `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||
| `crates/ipx/src/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
|
||||
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
|
||||
| `crates/ipx/src/signals.rs` | Signals: asks Jev, on each person's TypeSafe key, which of their items fit their Signals | — |
|
||||
| `crates/directory/src/lib.rs` | The directory's modules and what it exports | — |
|
||||
| `crates/directory/src/api.rs` | The directory's HTTP API and its token check | — |
|
||||
| `crates/directory/src/directory.rs` | `Directory`: its store and tokens, the refresher, the config reloader | — |
|
||||
| `crates/directory/src/wire.rs` | What servers and the directory send each other | — |
|
||||
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
|
||||
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
|
||||
| `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — |
|
||||
| `web/index.html` | The app's markup | — |
|
||||
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||
| `web/app.css` | The stylesheet both pages share | — |
|
||||
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
||||
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
|
||||
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
|
||||
| `web/build.mjs` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — |
|
||||
| `web/boot.js` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
|
||||
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
|
||||
|
||||
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
|
||||
build needs node and `npm ci` run once.
|
||||
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
||||
ipx-web rebuilt**, not ipx, and building it needs node and `npm ci` run once. ipx serves the API
|
||||
and the files, and no page.
|
||||
|
||||
## A scan
|
||||
|
||||
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
|
||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there.
|
||||
Without a schedule of its own, a feed whose body has been the same for a while waits longer:
|
||||
a twelfth of how long it has been the same, up to four hours (#242).
|
||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there, and so does a
|
||||
body identical to the last one (`body_hash`), from a server that ignores the validators.
|
||||
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
|
||||
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
||||
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
|
||||
@@ -54,7 +107,8 @@ build needs node and `npm ci` run once.
|
||||
|
||||
```
|
||||
feeds id, url, title, image, category, description, etag, last_modified,
|
||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed
|
||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed,
|
||||
body_hash, same_since
|
||||
entries feed_id, guid, title, link, published, description, first_seen,
|
||||
image, duration, episode, season PK (feed_id, guid)
|
||||
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
||||
@@ -65,12 +119,21 @@ subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
||||
max_new_per_check PK (user_id, feed_id)
|
||||
entry_state user_id, feed_id, guid, read, flagged, position
|
||||
PK (user_id, feed_id, guid)
|
||||
signals id, user_id, name, keywords
|
||||
signal_verdicts signal_id, feed_id, guid, hit PK (signal_id, feed_id, guid)
|
||||
```
|
||||
|
||||
A Signal (#254, [signals.md](signals.md)) is a feed someone makes from their own subscriptions. `signals::run`, a task of its
|
||||
own beside DeadAir's, woken by a scan with something new or a Signal made or changed, asks Jev
|
||||
about each item of the last 30 days that has no verdict yet for one of its owner's Signals: one
|
||||
call an item, a noul question a Signal, on the owner's `users.typesafe_key`. Every answer is kept,
|
||||
fitting or not, so nothing is asked twice; new keywords drop the Signal's verdicts. A Signal's
|
||||
items are `entries_where` with an `EXISTS` on its hits, so read state is the item's own.
|
||||
|
||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
||||
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||
|
||||
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
|
||||
Schema changes: the tables are the entities in `crates/ipx/src/entity.rs`, and `Db::open` creates whatever
|
||||
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
||||
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
||||
`create_missing`, looked for first and run only when it is missing, as `feeds.description`'s is,
|
||||
@@ -120,8 +183,8 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
|
||||
|
||||
| Route | |
|
||||
|---|---|
|
||||
| `GET /` | the app |
|
||||
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
|
||||
| `GET /api/page-check`, `GET /api/login-check`, `GET /api/signin` | for Caddy (#203): whether the visitor may have the page in `X-Forwarded-Uri` (`204`), or where to send them; signing in with `?token=` |
|
||||
| `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in; `/api/me` also gives the version and the account's theme from before |
|
||||
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
|
||||
| `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe |
|
||||
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
|
||||
@@ -132,9 +195,12 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
|
||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||
| `POST /api/fetch` | |
|
||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||
| `GET /api/directory/{id}` | a listed feed's description, why its last check failed when it did (in words, never its error, which can name its address), and latest twenty items, for its page before you subscribe: title, link, date, length and text, never a file or its address, and only for a feed the directory lists |
|
||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
|
||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||
| `GET /api/directory/{id}` | a feed's page from the directory service (#205), by the directory's id: its description and latest twenty items, sanitized here, and why its last check failed, in words; never a file or its address |
|
||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the directory service's ten most subscribed and every feed it lists, A to Z, asked by ipx with its token (directory id, title, art, subscribers across every sharing server, whether it is yours and its id here, category, whether it carries audio or video; never a URL); subscribe by the directory's id, which takes the feed's address from the directory. `503` with no directory set up |
|
||||
| `DELETE /api/directory/{id}`, `POST /api/directory/{id}/refresh` | admin-only, and the directory refuses unless this server's token is `rwa` (#206): remove and block a feed; check it now. `GET /api/directory/{id}` says `can_admin` when both hold |
|
||||
| `GET /api/directory-admin`, `DELETE /api/directory-blocked/{id}`, `DELETE /api/directory-instances/{name}` | admin-only, `rwa` (#206): the servers sharing (name, feeds, subscribers) and the blocked feeds (id, title, when; never the address, often why it was blocked); unblock; drop a server's snapshot. Unblocking or dropping sends this server's snapshot again at once |
|
||||
| `GET /api/directory-status` | admin-only: the name and access the directory gives this server's token, or why it refused, and how the last snapshot went |
|
||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write. The directory's token is written, never read back: `directory_token_set` says whether there is one |
|
||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
|
||||
|
||||
11
docs/cli.md
11
docs/cli.md
@@ -13,12 +13,12 @@ processes must never download the same thing. `--local` forces the work to happe
|
||||
| `ipx list` | Subscriptions and their state |
|
||||
| `ipx status` | Counts: feeds, pending, downloaded |
|
||||
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
|
||||
| `ipx add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` puts it in the Directory for anyone to subscribe to instead, and keeps it there when its last subscriber leaves; `--category` files it under one of the Directory's categories. Run for a feed already in the catalogue, these list it or set its category |
|
||||
| `ipx add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category |
|
||||
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
|
||||
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
|
||||
| `ipx reap [--dry-run]` | Run retention now |
|
||||
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
|
||||
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI |
|
||||
| `ipx daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) |
|
||||
|
||||
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they
|
||||
then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
|
||||
@@ -31,13 +31,14 @@ Passwords are read from **stdin**, so they miss the shell history and any `ps` l
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user add ray # local account
|
||||
ipx user add ray@example.com --no-password # signs in through the proxy only
|
||||
echo -n 'a good password' | ipx user passwd admin # change a password
|
||||
echo -n 'a good password' | ipx user passwd admin # change a password; signs the account out everywhere
|
||||
ipx user list # who exists, and how each signs in
|
||||
ipx user rm sam # account, subscriptions and read state
|
||||
```
|
||||
|
||||
The first account created is an admin; later ones are ordinary users. A database with no accounts
|
||||
at all gets **admin / ipodderx** on the next daemon start, announced in the log — change it.
|
||||
at all gets **admin** on the next daemon start, with a random password in `admin-password` beside
|
||||
config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it.
|
||||
|
||||
To avoid even the command line, read it interactively:
|
||||
|
||||
@@ -71,7 +72,7 @@ stops the next scan fetching it again.
|
||||
## The daemon
|
||||
|
||||
```sh
|
||||
ipx daemon # scheduler + socket + web UI
|
||||
ipx daemon # scheduler + socket + HTTP API
|
||||
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
|
||||
```
|
||||
|
||||
|
||||
@@ -46,13 +46,17 @@ max_age_days = 30 # 0 = keep forever
|
||||
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
||||
media_types = ["audio", "video"]
|
||||
art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||
fetch_internal = [] # internal addresses fetches may reach, e.g. ["192.168.1.0/24"]
|
||||
```
|
||||
|
||||
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
|
||||
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||
database as described above; `download_dir`, `socket`, `organize` and `fetch_internal` stay in config.toml.
|
||||
|
||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. Without a schedule of its own,
|
||||
a feed that sends the same body check after check (Patreon, Substack, which ignore "not
|
||||
modified") is checked less often the longer it stays the same, up to every four hours, and is
|
||||
back on schedule once it changes. A feed that keeps failing
|
||||
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
||||
after its first success. A moment's failure to look its host up does not count: the network, not
|
||||
the feed, failed, and it is tried again in ten minutes. Admin-only from the UI.
|
||||
@@ -74,6 +78,12 @@ database as described above; `download_dir`, `socket` and `organize` stay in con
|
||||
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
||||
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
||||
without this the disk fills with artwork. Empty takes everything.
|
||||
* **`fetch_internal`** — internal addresses and networks that feeds, episodes, artwork and
|
||||
`.torrent` files may still be fetched from, as `["192.168.1.20", "10.0.0.0/8"]`. Anything off
|
||||
the public internet (loopback, the LAN, Docker's networks, link-local) is refused otherwise,
|
||||
redirects included, because a feed's publisher or anyone signed in chooses what is fetched. List
|
||||
addresses, not names: a feed on `nas.lan` needs `nas.lan`'s address here. The directory is not
|
||||
affected. Empty by default; read at start.
|
||||
|
||||
## `[torrent]`
|
||||
|
||||
@@ -103,6 +113,7 @@ access_aud = "" # the Access application's AUD tag
|
||||
auto_create_users = true
|
||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||
session_days = 30
|
||||
metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no metrics
|
||||
```
|
||||
|
||||
* **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so
|
||||
@@ -119,6 +130,9 @@ session_days = 30
|
||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||
the proxy signs them straight back in.
|
||||
* **`session_days`** — sign a session out after this long without a request.
|
||||
* **`metrics_bind`** — where to serve `/metrics` for Prometheus: database connections in use, the
|
||||
worker's queue, the runtime's tasks, memory and open files. It has a thread and a port of its own
|
||||
and no sign-in, so publish it to the LAN only. Empty, the default, serves none.
|
||||
|
||||
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
|
||||
itself carry a credential. Put TLS in front of it if that matters.
|
||||
@@ -136,7 +150,7 @@ folder = "Accidental Tech Podcast" # default: the feed title
|
||||
schedule = "every 6h" # overrides [general] for this feed
|
||||
media_types = ["audio"] # overrides [general] for this feed
|
||||
category = "Technology" # the Directory's, if the feed names none
|
||||
listed = true # in the Directory with no subscribers
|
||||
listed = true # shared with the directory with no subscribers
|
||||
username = "ray" # HTTP basic auth
|
||||
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
||||
```
|
||||
@@ -146,20 +160,33 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
|
||||
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
||||
and are the same for everyone. See [users.md](users.md).
|
||||
|
||||
**The Directory** lists every feed in the catalogue, whether or not anyone subscribes to it yet.
|
||||
To put one there for others to find, `ipx add --list --category News <url>`: it subscribes no one,
|
||||
and a listed feed stays when its last subscriber leaves, where another is dropped. A feed nobody
|
||||
`username` and its password go to the feed's own host alone: not to episodes on another host, nor to
|
||||
the feeds an OPML lists on other hosts, which would hand the password to whoever runs them.
|
||||
|
||||
**The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
|
||||
up in Admin, Directory: its address, this server's token and whether to share. They are server
|
||||
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
|
||||
With sharing on, this server sends it every feed in the catalogue that is not private, with how
|
||||
many people here subscribe, and the `category` above for a feed that names none. To offer one with
|
||||
nobody subscribed, `ipx add --list --category News <url>`: it subscribes no one, and a listed feed
|
||||
stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another
|
||||
is dropped. A feed nobody
|
||||
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
|
||||
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
|
||||
|
||||
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. So
|
||||
does Keep, the plus on a show's page, for whoever turns it on (`kept` on their subscription, #280): when
|
||||
the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue
|
||||
entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who
|
||||
had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `IPX_CONFIG` | Config file path |
|
||||
| `IPX_ADMIN_PASSWORD` | The first admin's password, for a database with no accounts; random, in `admin-password` beside config.toml, without it |
|
||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
||||
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||
@@ -168,3 +195,18 @@ and they are re-derived on every scan. Editing one in the UI promotes it to a ca
|
||||
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
|
||||
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||
|
||||
## ipx-web
|
||||
|
||||
The page's container reads only its environment (`web/Caddyfile`):
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `IPX_UPSTREAM` | Where ipx is, `host:port`; `ipx:8099` by default |
|
||||
| `WEB_BIND` | Where to listen; `:8080` by default |
|
||||
| `WEB_ROOT` | The built page; `/srv` in the image |
|
||||
| `TUNNEL_PEER` | The one address whose `Cf-Access-*` headers are passed on to ipx: where cloudflared's requests come from. Unset passes none. See [sso.md](sso.md) |
|
||||
| `TRUSTED_HEADER` | ipx's `[web] trusted_header`, when it is not Cloudflare's: it is passed on from `TUNNEL_PEER` alone too, and dropped from anyone else. Set it to the same name, or anyone could sign in as anyone |
|
||||
| `WEB_TRACING` | `on` sends a span for each request to the collector the `OTEL_*` variables name (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, `OTEL_SERVICE_NAME`), continuing the page's trace into ipx. Off by default |
|
||||
|
||||
ipx-directory's settings are in [directory.md](directory.md).
|
||||
|
||||
169
docs/directory.md
Normal file
169
docs/directory.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# The directory
|
||||
|
||||
`ipx-directory` is a catalogue of public podcasts and blogs that ipx servers share (#204). An ipx
|
||||
server sends it the public feeds its people subscribe to, and how many of them do. The directory
|
||||
fetches each feed's title, artwork, category, description and latest twenty items every twelve hours,
|
||||
and any server holding a token can browse it. It never downloads a file, and it never stores
|
||||
one's address: every ipx server still fetches its own feeds.
|
||||
|
||||
This server's is at `https://directory.ipxreader.app` (until 2026-10-12 `ipx-dir.sdf1.net`, #214), the `ipx-directory` service of the Arcane project
|
||||
`ipx`, with its database `ipx_directory` on the `databases` project's Postgres.
|
||||
|
||||
## In ipx
|
||||
|
||||
An admin sets it up in **Admin, Directory**: the directory's address, the token its operator gave
|
||||
this server, and whether to share. ipx asks the directory on its people's behalf, so the page never
|
||||
talks to it and never sees the token or a feed's address. With no directory set up, the Directory
|
||||
says so and feeds are added by their address.
|
||||
|
||||
ipx keeps the address and token with its other server settings, in its database (`settings`,
|
||||
`directory_url` and `directory_token`), not in config.toml. The token is written, never shown
|
||||
again: the tab says it is set and offers to clear it. To replace one, make a new token on the
|
||||
directory, paste it over the old, then delete the old token's entry from the directory's config.
|
||||
|
||||
With sharing on, ipx sends its snapshot when what it would send has changed (checked after every
|
||||
scan, and whenever the catalogue or these settings change), and every half hour regardless (#211): each feed checked at least once, with how many people here subscribe,
|
||||
and the category an admin gave a feed that names none. It never sends an OPML or a Patreon creator
|
||||
itself (their feeds go instead), or a private feed. A feed's settings have a **Private** switch for
|
||||
a paid feed iPX does not recognise by its address; anyone subscribed may turn it on, and only an
|
||||
admin off.
|
||||
|
||||
When this server's token is `rwa`, its admins administer the directory from iPX (#206). A feed's
|
||||
page in the Directory has **Check now** and **Remove from the directory**, which blocks it so no
|
||||
server's snapshot brings it back. Admin, Directory lists the **Servers** sharing, by token name,
|
||||
with how many feeds and subscribers each gave, and **Blocked**, by title, with Unblock. Dropping a
|
||||
server's snapshot takes its feeds and counts out until it next shares; to keep a server out,
|
||||
revoke its token. An admin's own subscriptions open as their feeds, not as Directory pages, so
|
||||
removing one of those is done by marking it private, or from another admin's account.
|
||||
|
||||
## In a web browser
|
||||
|
||||
Anyone may browse it at its address, no token and no sign-in (#215): the most subscribed, the
|
||||
categories, every feed A to Z, a search by name, and a page for each feed with its description,
|
||||
its latest items and its address, with a Copy button, to subscribe to in any podcast app or feed
|
||||
reader. The pages only read: nothing in them changes the directory. They are rendered by the
|
||||
directory itself from what the API serves, with the publisher's HTML shown as text, an item's link
|
||||
only when it is http(s), and a Content-Security-Policy that allows the site's own script and
|
||||
stylesheet and the covers, which load from their publishers. `/v1` is the API and still needs a
|
||||
token for everything.
|
||||
|
||||
## Tokens
|
||||
|
||||
Everything but `/healthz` needs a token, sent as `Authorization: Bearer <token>`. Each one has a
|
||||
name and one of three levels of access:
|
||||
|
||||
| | browse | send snapshots | remove, block, refresh feeds; drop a server's snapshot |
|
||||
|---|---|---|---|
|
||||
| `ro` | yes | | |
|
||||
| `rw` | yes | yes | |
|
||||
| `rwa` | yes | yes | yes |
|
||||
|
||||
Tokens are in the config file and nowhere else, and no level of access can make or revoke one
|
||||
over the API. To make one:
|
||||
|
||||
```sh
|
||||
docker exec ipx-directory ipx-directory token new <name> --access ro|rw|rwa
|
||||
```
|
||||
|
||||
It prints the token, to give to whoever it is for, and the lines to add to the config file. The file
|
||||
holds the token's SHA-256, never the token. The directory reads the file again within a minute of
|
||||
a change, so adding a token and revoking one (deleting its entry) need no restart. A revoked token
|
||||
also takes the counts its server sent. A file that does not load (a misspelled `access`, two tokens
|
||||
with one name) leaves the tokens as they were, and the log says why.
|
||||
|
||||
## The config file
|
||||
|
||||
`/config/directory.toml` in the container (`DIRECTORY_CONFIG`, or `--config`), on Tower
|
||||
`/mnt/fast/appdata/ipx-directory/directory.toml`:
|
||||
|
||||
```toml
|
||||
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"
|
||||
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
|
||||
apple_country = "ca"
|
||||
# Internal addresses and networks feeds may be fetched from, as ["10.0.0.5", "192.168.1.0/24"].
|
||||
# Empty, or left out: nothing off the public internet is fetched (#329).
|
||||
fetch_internal = []
|
||||
# The environment variable holding the database URL: postgres://... or sqlite://...
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||
|
||||
[[tokens]]
|
||||
name = "tower"
|
||||
access = "rwa"
|
||||
token_sha256 = "…"
|
||||
```
|
||||
|
||||
`DIRECTORY_LOG_FORMAT=json` logs one JSON object a line, for Loki, as ipx's `IPX_LOG_FORMAT` does.
|
||||
Each request is a line with its `method`, `path`, `status`, `duration_ms` and the `token`'s name.
|
||||
|
||||
With `OTEL_EXPORTER_OTLP_ENDPOINT` set (`http://host:4318`, OTLP over HTTP), it sends traces: a
|
||||
span a request, a child of the caller's when the request carries a `traceparent`, as ipx's do, and
|
||||
a trace a refresher round with a span a feed (#213). `OTEL_RESOURCE_ATTRIBUTES` tags them, as
|
||||
production does with `deployment.environment.name=production`. Its version is in
|
||||
`crates/directory/Cargo.toml`, logged at start and answered by `ipx-directory --version`; its
|
||||
changelog is `crates/directory/CHANGELOG.md`.
|
||||
|
||||
## Apple Podcasts
|
||||
|
||||
With `apple_country` set, the directory takes Apple Podcasts' top charts for that country once a
|
||||
week (#252): the top 100 overall and the top 50 of each of Apple's categories, about a thousand
|
||||
shows. Apple's lookup API gives each one's RSS address, and they go in as a snapshot of their own,
|
||||
`apple-podcasts`, with no subscribers: fetched, sorted into categories and listed like any other
|
||||
feed, while the most subscribed stay what the servers sharing here subscribe to. A show Apple drops
|
||||
from its charts leaves the directory at the next sync, unless a server shares it. An admin can drop
|
||||
the snapshot as any server's (`DELETE /v1/instances/apple-podcasts`); the next sync brings it back.
|
||||
No token may be named `apple-podcasts`.
|
||||
|
||||
The same sync compares Apple's list of categories with the one ipx keeps (`CATEGORIES` in
|
||||
`crates/feed`) and logs a warning naming what differs, for whoever checks the log to update it.
|
||||
It runs at start when the last sync is a week old, and a failed one tries again an hour later. Each
|
||||
sync is a trace (`apple_sync`, a span for each of Apple's answers), and a line `Apple Podcasts
|
||||
synced` says how many shows came and went in.
|
||||
|
||||
## Running one
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ipx-directory:
|
||||
image: ipx-directory # docker build -f Dockerfile.directory -t 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
|
||||
```
|
||||
|
||||
## What it will not hold
|
||||
|
||||
A private feed is never meant to reach it. ipx leaves out a feed marked private, one with a
|
||||
username or password, and one whose address `ipx_feed::url_looks_private` catches: a paid-feed
|
||||
host, or a key in the query. The directory checks every address it is sent with the same function
|
||||
and refuses what it catches, so one server's miss does not publish someone's paid feed to every
|
||||
other. What both miss, an `rwa` token can remove: `DELETE /v1/feeds/{id}` removes the feed and
|
||||
blocks it, so no snapshot brings it back.
|
||||
|
||||
## API
|
||||
|
||||
| Route | Access | |
|
||||
|---|---|---|
|
||||
| `GET /healthz` | none | `ok` when it can read its database |
|
||||
| `GET /v1/me` | ro | the token's `name` and `access` |
|
||||
| `GET /v1/feeds` | ro | every feed A to Z: `id`, `url`, `title`, `image`, `category`, `subcategory`, `podcast`, `subscribers` |
|
||||
| `GET /v1/popular?limit=10` | ro | the most subscribed, at most 100 |
|
||||
| `GET /v1/feeds/{id}` | ro | one feed, with `description`, `failing` (why its last check failed, in words) and its latest twenty `items` (`guid`, `title`, `link`, `published`, `duration`, `description`), never their files |
|
||||
| `POST /v1/snapshot` | rw | `[{"url", "subscribers"}]`, at most 20,000: replaces everything this token sent before; answers what it `accepted` and how many it refused as private, blocked or invalid |
|
||||
| `DELETE /v1/feeds/{id}` | rwa | remove and block |
|
||||
| `GET /v1/blocked`, `DELETE /v1/blocked/{id}` | rwa | what is blocked; unblock |
|
||||
| `GET /v1/instances` | rwa | each token holding a snapshot: its `name`, `feeds` and `subscribers` |
|
||||
| `DELETE /v1/instances/{name}` | rwa | drop one token's snapshot, its counts with it |
|
||||
| `POST /v1/feeds/{id}/refresh` | rwa | fetch the feed now, answering with its page |
|
||||
|
||||
A feed's `id` is the first 64 bits of the SHA-256 of its address, normalised (scheme and host in
|
||||
lower case, no default port or fragment), so every server arrives at the same id. `subscribers`
|
||||
is the sum over every token's latest snapshot. A feed is listed once the directory has read it as
|
||||
a feed, so an OPML or an address that never worked is not, and goes when no snapshot has it any more. Descriptions
|
||||
are the feed's own HTML, unsanitized: whoever shows them sanitizes them, as ipx does.
|
||||
415
docs/signals.md
Normal file
415
docs/signals.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# Signals
|
||||
|
||||
A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the
|
||||
words to listen for (`formula 1`, `f1`) and, when those are not enough, a sentence saying what they
|
||||
mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones
|
||||
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
|
||||
own feed. The rest stays noise, where it was.
|
||||
|
||||
Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for
|
||||
everyone, on this server's TypeSafe key** (`signals_on_server`):
|
||||
|
||||
- **On**, with the server's key in `TYPESAFE_KEY`: everyone has Signals, with nothing to set up.
|
||||
Every item is asked about on the server's key, and keys people saved are kept but not used.
|
||||
- **Off**, the default, or on with no `TYPESAFE_KEY`: each person turns Signals on in Settings
|
||||
with a key of their own. Nothing is judged for someone without one, and their Signals folder is
|
||||
not shown.
|
||||
|
||||
Jev is asked once for each item, on that key, with one question for each of the person's Signals
|
||||
that has not heard about the item yet.
|
||||
|
||||
## What someone sees
|
||||
|
||||
- **The sidebar.** Once Signals are on for someone, a Signals folder comes first in the feed list, under All Subscriptions (#255,
|
||||
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
|
||||
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
|
||||
person's Signals, each with its items and unread counted under its name.
|
||||
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
|
||||
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
|
||||
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
|
||||
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
|
||||
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
|
||||
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
|
||||
- **The folder's page** lists the Signals, with the + for a new one.
|
||||
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
||||
Its header says what it listens for (the sentence, or the words), and, on people's own keys,
|
||||
that nothing is picked out without one. Edit changes the name, the words or the sentence; Delete removes the Signal
|
||||
and leaves the items in their feeds.
|
||||
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
|
||||
round. The counts follow without a reload (#258).
|
||||
- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a
|
||||
Signals switch: turning it on shows a password field for the key, which saves on leaving the
|
||||
field once TypeSafe has taken it (one short question on that key); a key TypeSafe refuses is
|
||||
not saved, and the page says so. Turning the switch off, or the bin, forgets the key and hides
|
||||
the folder; the Signals are kept. The key is never sent back to the page, which only learns
|
||||
whether one is set.
|
||||
|
||||
## How an item gets into a Signal
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant W as Scan worker
|
||||
participant J as Signals judge<br/>(signals::run)
|
||||
participant DB as Database
|
||||
participant T as TypeSafe<br/>api.typesafe.ai
|
||||
participant P as The page
|
||||
|
||||
W->>DB: new entries stored
|
||||
W->>J: ctx.signals.notify_one()
|
||||
Note over J: also woken by a new Signal,<br/>new keywords, a key saved,<br/>or every 5 minutes
|
||||
J->>DB: unjudged(skip refused keys, 50)
|
||||
DB-->>J: items, each with the Signals<br/>that have not heard of it
|
||||
loop each item
|
||||
J->>T: POST /v1/systemone<br/>one noul question per Signal
|
||||
T-->>J: answers: noul 0..1 per question
|
||||
J->>DB: record_verdicts (hit = noul >= 0.5)
|
||||
end
|
||||
J-->>P: signal_hits event (SSE), once a pass per person
|
||||
P->>DB: GET /api/signals (counts),<br/>and the open Signal's list
|
||||
```
|
||||
|
||||
The judge is a task of its own beside DeadAir's (`crates/ipx/src/signals.rs`), not a job on the
|
||||
scan worker, so a slow TypeSafe never holds up a scan.
|
||||
|
||||
### Which items are asked about
|
||||
|
||||
`Db::unjudged` finds, newest first, up to 50 items that are:
|
||||
|
||||
- in a feed the Signal's owner subscribes to,
|
||||
- not hidden from them by their block lists,
|
||||
- for someone with a key that TypeSafe has not refused since the daemon started,
|
||||
- and either published (or, with no date, first seen) in the look-back window and not yet in
|
||||
`signal_verdicts` for that Signal, or picked out by it before it was edited (`recheck`), however
|
||||
old.
|
||||
|
||||
The window is the admin's, Admin, Server, **Signals look back (days)** (`signal_days`, 7 by
|
||||
default, 1 to 365; #276): each item in it is a call, and a month of production's feeds was about
|
||||
8,000. So a new Signal looks back that far, and after that sees each new item. A show subscribed
|
||||
to today has its back catalogue skipped, apart from the window.
|
||||
|
||||
The recent half reads the index `entries_recent`, on each feed's items by date, the expression it
|
||||
is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274).
|
||||
|
||||
Rows for one item and one person are grouped, so an item is one call whatever the number of
|
||||
Signals. Two people's Signals never share a call, even on the server's key, where they did until
|
||||
#343: one person's description was then in the question that decided the other's verdicts. Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms
|
||||
each, a month of items took twenty minutes. Every call to Jev, Signals', icons' and key checks',
|
||||
takes a turn (`pace`, #277): TypeSafe's default limits are 80 requests and 100,000 input tokens a
|
||||
second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its
|
||||
guessed tokens' share, whichever is longer. A 429 holds every call back for its `Retry-After`, or
|
||||
a second, and ends the pass. Each `jev` span says how long it waited (`paced_ms`), and
|
||||
`ipx_jev_paced_seconds_total` adds them up; the dashboard draws calls and tokens a second against
|
||||
the limits. A full batch means there may be more, and the next pass
|
||||
starts at once. Otherwise the judge sleeps until woken or for 5 minutes.
|
||||
|
||||
### Every answer is kept
|
||||
|
||||
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
|
||||
an item with a row is never asked again. So the cost is one call per item per person, once.
|
||||
Changing a Signal's name, words or sentence marks what it picked out to be asked again
|
||||
(`recheck`), and those stay in its list until answered; what it passed over stays passed over, and
|
||||
new items are asked the new question as they come (#276). The first start with sentences (#259) deleted every verdict, since they answered the
|
||||
old question.
|
||||
|
||||
## TypeSafe
|
||||
|
||||
Jev is TypeSafe's System One model, already used to pick a feed's Directory category
|
||||
(`feed::classify`). The address is `feed::JEV_URL`, `https://api.typesafe.ai/v1/systemone`.
|
||||
Signals use the **noul** question type, which answers how likely a statement is true, from 0 to 1.
|
||||
Questions in one call are judged independently and in parallel.
|
||||
|
||||
### A request
|
||||
|
||||
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-latest",
|
||||
"state": "Feed: Pit Lane\nTitle: Monaco qualifying\n\nQualifying report from the Monaco Grand Prix.",
|
||||
"questions": {
|
||||
"signal_3": {
|
||||
"type": "noul",
|
||||
"instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?",
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing."
|
||||
}
|
||||
},
|
||||
"signal_4": {
|
||||
"type": "noul",
|
||||
"instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?",
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
|
||||
cut to 1,000 characters (`TEXT_CHARS`). It was 2,000 until #275: on 55 real items and six
|
||||
Signals, 1,000 changed none of 330 verdicts and saved 12% of the input tokens (1,266 to 1,112 a
|
||||
call); 500 changed one. Most of the input is the questions themselves, not the item.
|
||||
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
|
||||
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
|
||||
(#257).
|
||||
- **One subject, not any word.** The first question was "Is this item about any of these: ai,
|
||||
development, programming?", so one broad word was enough: AI Development took Sega pulling a game
|
||||
remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked
|
||||
about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools,
|
||||
vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in,
|
||||
AI code review in the Linux kernel highest at 0.94 (#259).
|
||||
|
||||
### Its answer
|
||||
|
||||
What TypeSafe answered to a request of this shape, on 2026-10-16:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-1.13.0",
|
||||
"answers": {
|
||||
"signal_3": { "type": "noul", "noul": 0.96 },
|
||||
"signal_4": { "type": "noul", "noul": 0.02 }
|
||||
},
|
||||
"usage": { "input_tokens": 389, "output_tokens": 40 }
|
||||
}
|
||||
```
|
||||
|
||||
0.5 or more is a fit (`THRESHOLD`): the item goes into Signal 3 and not Signal 4. A question
|
||||
missing from the answer is left unjudged and asked again next pass.
|
||||
|
||||
### When TypeSafe says no
|
||||
|
||||
| TypeSafe answers | What the judge does |
|
||||
|---|---|
|
||||
| 401, 402, 403, on the server's key | Everyone's key is refused. The pass ends and the queue waits for the next one, five minutes on. Logged: "TypeSafe refused the server's key for Signals". |
|
||||
| 401, 402, 403, on a person's key | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
|
||||
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
|
||||
| 429 | Over TypeSafe's limits. Every call waits for `Retry-After` or a second; the pass ends, what was in flight kept, and the rest wait for the next. Logged once: "TypeSafe asked Signals to slow down". |
|
||||
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
|
||||
|
||||
The error keeps TypeSafe's reply, cut to 500 characters, for example:
|
||||
|
||||
```
|
||||
TypeSafe answered 422: {"detail":[{"type":"model_attributes_type","loc":["body","questions","3","noul","criteria"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"Yes if the subject is one of them."}]}
|
||||
```
|
||||
|
||||
## The API
|
||||
|
||||
Every route is the signed-in person's own. A Signal of someone else's is not found, as one that
|
||||
does not exist.
|
||||
|
||||
### `GET /api/signals`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
|
||||
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
|
||||
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
|
||||
]
|
||||
```
|
||||
|
||||
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
|
||||
its list shows; `unread` those of them not read.
|
||||
|
||||
### `POST /api/signals`
|
||||
|
||||
```json
|
||||
{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." }
|
||||
```
|
||||
|
||||
Answers `{ "id": 3 }` and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words
|
||||
are allowed, each up to 200 characters, kept once whatever their case. `description` is optional,
|
||||
up to 500 characters. Anything else is a `400` saying so.
|
||||
|
||||
### `PATCH /api/signals/{id}`
|
||||
|
||||
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
|
||||
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
|
||||
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
|
||||
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
|
||||
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
|
||||
|
||||
### `DELETE /api/signals/{id}`
|
||||
|
||||
Answers `204`. The Signal and its verdicts go; the items stay in their feeds.
|
||||
|
||||
### `GET /api/signals/{id}/entries`
|
||||
|
||||
The same query as a feed's list, `offset`, `limit`, `filter`, `q`, `sort`, `dir`:
|
||||
|
||||
```
|
||||
GET /api/signals/3/entries?filter=unread&sort=published&dir=desc&limit=50
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"entries": [
|
||||
{
|
||||
"guid": "sig-1", "feed_id": "pit-lane", "title": "Monaco qualifying",
|
||||
"link": "https://pitlane.example/monaco", "published": 1792170000,
|
||||
"description": "<p>Qualifying report…</p>", "read": false, "flagged": false,
|
||||
"image": null, "duration": null, "episode": null, "season": null, "position": 0,
|
||||
"enclosures": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Reading or pinning one is the item's own route, `POST /api/entries/{feed_id}/{guid}/flags`, as
|
||||
anywhere else.
|
||||
|
||||
### `PATCH /api/me`, the key
|
||||
|
||||
```json
|
||||
{ "typesafe_key": "ts_…" }
|
||||
```
|
||||
|
||||
A string is checked with TypeSafe, by one short question on that key, then saved, and the judge
|
||||
tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters.
|
||||
A key TypeSafe refuses is a `400` ("TypeSafe did not accept that key") and not saved; TypeSafe not
|
||||
answering is a `502`, and the key is not saved either. `GET /api/me` says `"typesafe_key_set":
|
||||
true` and nothing more about the key, and `"signals": "server"` or `"own"`, whose key is used.
|
||||
|
||||
### `PATCH /api/settings`, who pays
|
||||
|
||||
An admin's `{ "signals_on_server": true }` judges everyone's Signals on `TYPESAFE_KEY`, and
|
||||
`false` on each person's own. `GET /api/settings` has `signals_on_server` and
|
||||
`typesafe_server_key`, whether the server has a key to use; without one the setting changes
|
||||
nothing. A change wakes the judge and lets keys refused before be tried again.
|
||||
|
||||
### `POST /api/signals/icon`
|
||||
|
||||
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
|
||||
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
|
||||
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "AI Development",
|
||||
"keywords": ["ai", "development", "programming"],
|
||||
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
|
||||
}
|
||||
```
|
||||
|
||||
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-latest",
|
||||
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
|
||||
} }
|
||||
}
|
||||
```
|
||||
|
||||
TypeSafe answered, on 2026-10-16, for six candidates:
|
||||
|
||||
```json
|
||||
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
|
||||
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
|
||||
```
|
||||
|
||||
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
|
||||
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
|
||||
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
|
||||
|
||||
### The event
|
||||
|
||||
Once a pass, for each person it found items for, the judge sends this on the event stream
|
||||
(`/api/events`):
|
||||
|
||||
```json
|
||||
{ "ev": "signal_hits", "user": 2, "hits": 3 }
|
||||
```
|
||||
|
||||
A page with Signals fetches `/api/signals` again, and the open Signal's list.
|
||||
|
||||
## Data
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users ||--o{ signals : owns
|
||||
signals ||--o{ signal_verdicts : "has heard of"
|
||||
entries ||--o{ signal_verdicts : "judged in"
|
||||
users {
|
||||
bigint id
|
||||
text typesafe_key "NULL: no key"
|
||||
}
|
||||
signals {
|
||||
bigint id
|
||||
bigint user_id
|
||||
text name
|
||||
text keywords "JSON array"
|
||||
text description "NULL: none"
|
||||
text icon "solid/robot, NULL: initials"
|
||||
bigint created
|
||||
}
|
||||
signal_verdicts {
|
||||
bigint signal_id "PK"
|
||||
text feed_id "PK"
|
||||
text guid "PK"
|
||||
bool hit
|
||||
}
|
||||
```
|
||||
|
||||
- `signals` and `signal_verdicts` are created from their entities like every table
|
||||
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
||||
were added to existing tables, so `create_missing` adds each column when it is missing.
|
||||
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
|
||||
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
|
||||
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
|
||||
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
|
||||
- A Signal's list is `entries_where` with
|
||||
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
|
||||
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
|
||||
work as for All Subscriptions.
|
||||
- A verdict for a Signal deleted while Jev was being asked is not stored. Postgres's foreign key
|
||||
would refuse it, and SQLite would keep it for nothing.
|
||||
|
||||
## Watching it
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Traces | A `signals` span for each pass (`entries`), and a `jev` span for each call (`user`, `signal_count`, `http.response.status_code`, and TypeSafe's reply as `error` when it refuses). |
|
||||
| Log, counted | `ev="signal"`, "an item fits a Signal", for each item with a fit: `user`, `feed`, `new` (how many of their Signals it fits). |
|
||||
| Log, warnings | The three in the table above. |
|
||||
| Events | `signal_hits` on the socket and `/api/events`. |
|
||||
|
||||
## Limits
|
||||
|
||||
- **The key is kept in plain text** in `users.typesafe_key`, because every call needs it whole. It
|
||||
should be encrypted with a server key if the database is ever shared with anything else.
|
||||
- **Refused keys are remembered in memory**, so a restart tries each once again.
|
||||
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
|
||||
words.
|
||||
- **The meter is the server's**, `ipx_jev_tokens_total` and `ipx_jev_requests_total` on
|
||||
`/metrics`, and the Signals row of the dashboard: tokens as TypeSafe's answers count them, by
|
||||
person (`user`, the account's name) and whose key paid (`key`, `server` or `own`; #278), with
|
||||
`ipx_signals_judged_total` and `ipx_signals_hits_total` the same way. A call shared by several
|
||||
people on the server's key is split by each one's share of its questions. Icon picks bill the
|
||||
person on the server's key, key checks the person on their own. The dashboard's **Who used what**
|
||||
sums them over its time range. It is
|
||||
one call per item in the person's feeds as they come, and per item in the window for a new Signal.
|
||||
- **Only the window** is judged by publish date, apart from what an edit asks again.
|
||||
|
||||
## Testing
|
||||
|
||||
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
|
||||
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
|
||||
on SQLite and Postgres both.
|
||||
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
|
||||
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
||||
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
|
||||
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
||||
`/signal.xml` is dated today, since only the window is judged. They spend the TypeSafe key, so
|
||||
each runs only when the Signals code or the test has changed since it last passed (`signalsTest`;
|
||||
`IPX_SIGNALS_TESTS=1` runs them anyway).
|
||||
38
docs/sso.md
38
docs/sso.md
@@ -18,7 +18,7 @@ real setup and pointed at the wrong address.
|
||||
```
|
||||
browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect)
|
||||
─► tunnel "rays-unraid" (the cloudflared container on Tower)
|
||||
─► http://192.168.1.130:8099 ─► ipx
|
||||
─► http://192.168.1.130:8099, ipx-web (Caddy) ─► ipx
|
||||
```
|
||||
|
||||
Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access
|
||||
@@ -38,7 +38,7 @@ request it forwards through the tunnel, and ipx signs that person in.
|
||||
enabled = true
|
||||
bind = "0.0.0.0:8099"
|
||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.64.10"] # ipx-web; see "Behind ipx-web"
|
||||
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||
auto_create_users = true
|
||||
@@ -50,7 +50,7 @@ The last two turn on the token check described under [Verifying Cloudflare's tok
|
||||
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||
|
||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml
|
||||
restart ipx`.
|
||||
|
||||
### What was missing
|
||||
@@ -65,22 +65,34 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
|
||||
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty
|
||||
account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything.
|
||||
|
||||
### The address to trust, and why it is 192.168.16.1
|
||||
### The address to trust, and why it is 192.168.64.1
|
||||
|
||||
`cloudflared` runs in its own container and reaches ipx through the host's published port. Docker
|
||||
`cloudflared` runs in its own container and reaches ipx-web (before #203, ipx) through the host's published port. Docker
|
||||
(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's
|
||||
requests arrive from the **gateway of ipx's own network**, `content_default`:
|
||||
requests arrive from the **gateway of ipx's own network**, `ipx_default`:
|
||||
|
||||
```sh
|
||||
docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
|
||||
docker network inspect ipx_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
|
||||
```
|
||||
|
||||
That was measured, not assumed. ipx does not log where a request came from, so the addresses were
|
||||
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
|
||||
lists them in hex.)
|
||||
|
||||
If the `content` project's network is ever recreated, its gateway can change. Check it again, and
|
||||
update `trusted_proxies` to match.
|
||||
The `ipx` project's compose file pins that network's subnet to `192.168.64.0/20`, so its gateway
|
||||
stays `192.168.64.1` when the network is recreated. It was `192.168.16.1`, `content_default`'s, while
|
||||
ipx was part of the `content` project. If the subnet is ever changed, update `TUNNEL_PEER` in the
|
||||
compose file to match (before #203, `trusted_proxies`).
|
||||
|
||||
### Behind ipx-web (#203)
|
||||
|
||||
With the page served by `ipx-web` (Caddy), cloudflared reaches Caddy instead, still through the
|
||||
host's port 8099, so its requests still arrive from `192.168.64.1`, at Caddy now. Caddy passes the
|
||||
`Cf-Access-*` headers on only from that address (`TUNNEL_PEER`) and strips them from everyone else,
|
||||
the boundary `trusted_proxies` drew before. ipx then sees every request come from Caddy, so
|
||||
`trusted_proxies` names Caddy's address on `ipx_default`, pinned in the compose file
|
||||
(`192.168.64.10`), instead of the gateway. The token check is unchanged: a header Caddy passes on
|
||||
still needs a valid `Cf-Access-Jwt-Assertion`.
|
||||
|
||||
### Names
|
||||
|
||||
@@ -173,10 +185,12 @@ itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-user
|
||||
|
||||
## How this is secured
|
||||
|
||||
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the
|
||||
request falls through to a session cookie or the shared token. That is the whole security boundary.
|
||||
**The header is only passed on from `TUNNEL_PEER`, and only believed from `trusted_proxies`.**
|
||||
Caddy strips `Cf-Access-*` from every request that does not come from `TUNNEL_PEER`, and ipx
|
||||
ignores the header from anyone but Caddy, its one trusted proxy; such a request falls through to a
|
||||
session cookie or the shared token. That is the whole security boundary.
|
||||
|
||||
With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower
|
||||
With the tunnel reaching ipx-web through the host's port, `192.168.64.1` means **any container on Tower
|
||||
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
|
||||
itself, arrive under their own addresses and cannot set the header; the checks above show both
|
||||
sides. Never list a LAN address or range: anyone there could then send
|
||||
|
||||
@@ -51,8 +51,9 @@ Three ways, tried in order of how specific the claim is:
|
||||
3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck
|
||||
and any scripts use.
|
||||
|
||||
A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in
|
||||
the log. Change it:
|
||||
A database with no accounts creates **admin** on the next daemon start, with the password in
|
||||
`IPX_ADMIN_PASSWORD` if it is set, or a random one written to `admin-password` beside config.toml,
|
||||
readable by ipx's user alone. The log says where the password is, never what it is. Change it:
|
||||
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user passwd admin
|
||||
@@ -70,8 +71,10 @@ list with their own read state. Unsubscribing removes it from their list alone;
|
||||
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
||||
re-subscribing does not pull the back catalogue again.
|
||||
|
||||
**Directory** sits at the top of the feed list, above your own feeds. It opens on the ten feeds
|
||||
with the most subscribers on this server, you included, ranked, then the categories, each a tile
|
||||
**Directory** sits at the top of the feed list, above your own feeds. It shows the directory
|
||||
service an admin has set up ([directory.md](directory.md)), which iPX servers share, and says so
|
||||
when there is none. It opens on the ten feeds with the most subscribers on every server sharing
|
||||
with it, you included, ranked, then the categories, each a tile
|
||||
showing a few of its shows' covers. A category has a page of its own: its most subscribed feeds,
|
||||
then all of them as a grid of cover art, with its subcategories along the top (Tech News under
|
||||
News, Video Games under Leisure) and a choice of order. See all lists every feed, and the search
|
||||
@@ -81,7 +84,7 @@ Blogs (the rest). A show's category is the one it gives itself in iTunes, or for
|
||||
none, one an admin or iPX picked for it. Your own feeds are marked Subscribed.
|
||||
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
||||
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
||||
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
||||
want. Anything private is left out: a feed someone marked Private in its settings, a login configured for the feed, credentials in its URL,
|
||||
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
|
||||
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
|
||||
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
|
||||
@@ -99,7 +102,7 @@ removed there, so there is always someone who can manage the rest.
|
||||
## Admin
|
||||
|
||||
The first account is an admin. An admin can change global settings (scanning interval, quota,
|
||||
retention, media types, download folder), a feed's URL, folder and schedule, and who has an account
|
||||
retention, media types, download folder, the directory), a feed's URL, folder and schedule, and who has an account
|
||||
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
|
||||
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"""The iPX dashboard in Grafana, from Loki (the container's log, shipped by Alloy) and Tempo.
|
||||
"""The iPX dashboard in Grafana: the three containers of the Arcane project ipx (ipx, ipx-web,
|
||||
ipx-directory) and Tower under them, from Prometheus, Loki (their logs, shipped by Alloy) and Tempo.
|
||||
|
||||
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
|
||||
|
||||
Grafana reads that file on its own within a minute; edits made in Grafana are refused. The panels
|
||||
read the fields of ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or
|
||||
ipc.rs log_event has to be matched here. `dashboard.py queries` prints each
|
||||
query, to try against Loki.
|
||||
Grafana reads that file on its own within a minute; edits made in Grafana are refused. Rows go
|
||||
top-down as a request does: an overview, Tower, then each container (ipx-web, ipx's API, its
|
||||
worker and its process, the directory), then traces and logs. Prometheus scrapes ipx's /metrics
|
||||
(job ipx, #194) and Tower's node exporter (job node); nothing scrapes the containers themselves,
|
||||
so ipx-web and the directory are read from their logs. The log panels read fields: ipx's
|
||||
(web.rs access_log, ipc.rs log_event), the directory's and Caddy's; renaming one has to be
|
||||
matched here. `dashboard.py queries` prints each Loki query, to try against Loki.
|
||||
"""
|
||||
import json, sys
|
||||
|
||||
LOKI = {"type": "loki", "uid": "${loki}"}
|
||||
TEMPO = {"type": "tempo", "uid": "${tempo}"}
|
||||
PROM = {"type": "prometheus", "uid": "${prometheus}"}
|
||||
SEL = '{container="iPX"}'
|
||||
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
|
||||
# fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status
|
||||
@@ -20,11 +25,22 @@ EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
|
||||
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
|
||||
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
|
||||
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
|
||||
TRACES = '{resource.service.name="ipx" && resource.deployment.environment.name="production"'
|
||||
|
||||
|
||||
def status(field):
|
||||
return f'max(max_over_time({EV} | ev = "status" | unwrap {field} [10m]))'
|
||||
# Every production span: ipx-web's, ipx's and the directory's, one trace from the page to the
|
||||
# directory where a request goes that far (#213). A daemon run by hand has no such tag.
|
||||
TRACES = '{resource.deployment.environment.name="production"'
|
||||
# The directory's JSON log (#204): a line a request (method, path, status, duration_ms, token), a
|
||||
# line a snapshot (instance, accepted, refused_*), a line a refresher round (checked) and one a
|
||||
# failed feed check (feed, error).
|
||||
DIR = '{container="ipx-directory"} | json | __error__=""'
|
||||
DIR_HTTP = '{container="ipx-directory"} |= "ipx_directory::http" | json | __error__=""'
|
||||
# ipx-web's Caddy access log (#203, #210): request.uri, status, duration in seconds. The page and
|
||||
# its files only: /api and /media are ipx's, and in its own log already.
|
||||
WEB = ('{container="ipx-web"} |= "http.log.access" | json | __error__=""'
|
||||
' | request_uri !~ "/(api|media)/.*" | request_remote_ip != "127.0.0.1"') # not its healthcheck
|
||||
# Less what Caddy says at every start, by design: no admin endpoint, and no HTTPS or HTTP/3
|
||||
# behind the tunnel; and its goodbye on a restart.
|
||||
OTHERS_BAD = ('{container=~"ipx-web|ipx-directory"} | json | __error__="" | level =~ "(?i)warn|error"'
|
||||
' | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"')
|
||||
|
||||
|
||||
QUERIES = {}
|
||||
@@ -35,8 +51,12 @@ pid = 0
|
||||
def panel(kind, title, w, h, x, targets, **extra):
|
||||
global pid
|
||||
pid += 1
|
||||
sources = {t.get("datasource", LOKI)["uid"] for t in targets}
|
||||
# A panel with queries on more than one data source must be Mixed, or Grafana runs them all on
|
||||
# the panel's and Downloads, Loki's and Prometheus's, failed (#226).
|
||||
ds = {"type": "datasource", "uid": "-- Mixed --"} if len(sources) > 1 else targets[0].get("datasource", LOKI)
|
||||
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
|
||||
"datasource": ds, "targets": targets}
|
||||
p.update(extra)
|
||||
panels.append(p)
|
||||
return p
|
||||
@@ -50,6 +70,14 @@ def loki(expr, ref="A", legend=None, instant=False, kind=None):
|
||||
return t
|
||||
|
||||
|
||||
def prom(expr, ref="A", legend=None):
|
||||
# ipx's /metrics (#194), scraped by Prometheus as job ipx.
|
||||
t = {"refId": ref, "datasource": PROM, "expr": expr}
|
||||
if legend:
|
||||
t["legendFormat"] = legend
|
||||
return t
|
||||
|
||||
|
||||
def row(title):
|
||||
global y, pid
|
||||
pid += 1
|
||||
@@ -58,9 +86,10 @@ def row(title):
|
||||
y += 1
|
||||
|
||||
|
||||
def stat(title, expr, x, unit="short", color="blue", thresholds=None, desc=None):
|
||||
def stat(title, target, x, unit="short", color="blue", thresholds=None, desc=None):
|
||||
"""target: loki(..., instant=True) or prom(...)."""
|
||||
steps = thresholds or [{"color": color, "value": None}]
|
||||
return panel("stat", title, 4, 4, x, [loki(expr, instant=True)], description=desc or "",
|
||||
return panel("stat", title, 4, 4, x, [target], description=desc or "",
|
||||
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
|
||||
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
|
||||
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
|
||||
@@ -87,41 +116,87 @@ def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
|
||||
fieldConfig={"defaults": {}, "overrides": []})
|
||||
|
||||
|
||||
# ---- Now
|
||||
row("Now")
|
||||
stat("Feeds", status("feeds"), 0, desc="From the healthcheck's status answer, every 30 seconds.")
|
||||
stat("Waiting to download", status("pending"), 4)
|
||||
stat("Downloaded", status("downloaded"), 8, color="green")
|
||||
stat("Feed failures", f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', 12,
|
||||
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}],
|
||||
desc="Failed feed checks in the time range.")
|
||||
stat("Download failures", f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', 16,
|
||||
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}])
|
||||
stat("Warnings and errors", f'sum(count_over_time({BAD} [$__range])) or vector(0)', 20,
|
||||
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}])
|
||||
GOOD_BAD = [{"color": "green", "value": None}, {"color": "orange", "value": 1}]
|
||||
# Caddy's line for each request, what kind it is by its address.
|
||||
WEB_ALL = ('{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
|
||||
' | label_format kind=`{{ if hasPrefix "/api/" .request_uri }}API{{ else if hasPrefix "/media/" .request_uri }}'
|
||||
'media{{ else }}page and files{{ end }}`')
|
||||
LOG_OPTS = {"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True}
|
||||
|
||||
|
||||
def bar_overrides(*names):
|
||||
return [{"matcher": {"id": "byName", "options": n},
|
||||
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"},
|
||||
{"id": "custom.drawStyle", "value": "bars"}, {"id": "custom.fillOpacity", "value": 60}]}
|
||||
for n in names]
|
||||
|
||||
|
||||
# ---- Overview: is each part up, and is anything wrong
|
||||
row("Overview")
|
||||
stat("ipx", prom('max(up{job="ipx"})'), 0, thresholds=[{"color": "red", "value": None}, {"color": "green", "value": 1}],
|
||||
desc="Prometheus reaching ipx's /metrics: 1 is up. Its healthcheck asks the daemon and the database too.")["fieldConfig"]["defaults"]["mappings"] = [
|
||||
{"type": "value", "options": {"0": {"text": "down"}, "1": {"text": "up"}}}]
|
||||
stat("Feeds", prom('max(ipx_feeds{job="ipx"})'), 4)
|
||||
stat("Waiting to download", prom('max(ipx_downloads_pending{job="ipx"})'), 8)
|
||||
stat("Since the last scan", prom('max(ipx_last_scan_age_seconds{job="ipx"})'), 12, unit="s",
|
||||
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 600}, {"color": "red", "value": 1800}],
|
||||
desc="Over ten minutes means the worker may be stuck; the healthcheck still passes.")
|
||||
stat("Page and API errors", loki(f'sum(count_over_time({WEB_ALL} | status >= 500 [$__range])) or vector(0)', instant=True), 16,
|
||||
thresholds=GOOD_BAD, desc="Answers of 500 and up from ipx-web, for the page or passed on from ipx: 502 is ipx not answering.")
|
||||
stat("Warnings and errors", loki(f'sum(count_over_time({BAD} [$__range])) + sum(count_over_time({OTHERS_BAD} [$__range])) or vector(0)', instant=True), 20,
|
||||
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}],
|
||||
desc="All three containers, in the time range.")
|
||||
y += 4
|
||||
stat("Feed failures", loki(f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', instant=True), 0,
|
||||
thresholds=GOOD_BAD, desc="Failed feed checks in the time range.")
|
||||
stat("Download failures", loki(f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', instant=True), 4,
|
||||
thresholds=GOOD_BAD)
|
||||
stat("Downloaded", prom('max(ipx_files_downloaded{job="ipx"})'), 8, color="green")
|
||||
stat("ipx running for", prom('max(ipx_uptime_seconds{job="ipx"})'), 12, unit="s")
|
||||
stat("Directory: feeds shared", loki('sum(max by (instance) (max_over_time(' + DIR + ' | message="snapshot" | unwrap accepted [6h])))', instant=True), 16,
|
||||
desc="The feeds each server's latest snapshot gave, added up.")
|
||||
stat("Directory: refused as private", loki(f'sum(sum_over_time({DIR} | message="snapshot" | unwrap refused_private [$__range])) or vector(0)', instant=True), 20,
|
||||
thresholds=[{"color": "green", "value": None}, {"color": "red", "value": 1}],
|
||||
desc="Feeds a server sent that the directory caught as private. Not 0 means a server's own check missed one.")
|
||||
y += 4
|
||||
|
||||
# ---- Scans
|
||||
row("Scans and downloads")
|
||||
ts("New items found", [loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', legend="new items")],
|
||||
0, bars=True)
|
||||
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
|
||||
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed")],
|
||||
12, bars=True)
|
||||
# ---- Tower: the host all three run on, from the node exporter
|
||||
row("Tower")
|
||||
ts("Load", [prom('node_load1{job="node"}', legend="1 minute"), prom('node_load15{job="node"}', ref="B", legend="15 minutes")],
|
||||
0, w=8, desc="Twelve threads. Load far above that with little CPU used is processes waiting on a disk or the user shares (shfs).")
|
||||
ts("CPU", [prom('sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$__rate_interval]))', legend="{{mode}}")],
|
||||
8, w=8, stack=True, desc="Cores busy, by kind. Much system time with little user time is the kernel, often FUSE (shfs).")
|
||||
ts("Memory available", [prom('node_memory_MemAvailable_bytes{job="node"}', legend="available")], 16, w=8, unit="bytes")
|
||||
y += 8
|
||||
ts("Bytes downloaded", [loki(f'sum(sum_over_time({EV} | ev="download_done" | unwrap bytes [$__interval]))', legend="bytes")],
|
||||
0, unit="bytes", bars=True)
|
||||
ts("Feeds checked per scan", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked")],
|
||||
12, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
|
||||
y += 8
|
||||
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
|
||||
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
|
||||
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||
ts("Disk busy", [prom('rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$__rate_interval])', legend="{{device}}")],
|
||||
0, w=12, unit="percentunit", desc="nvme1n1 is /mnt/fast (appdata, Postgres's data); md* are the array's disks behind /mnt/user (downloads).")
|
||||
ts("Network", [prom('sum(rate(node_network_receive_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', legend="in"),
|
||||
prom('sum(rate(node_network_transmit_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', ref="B", legend="out")],
|
||||
12, w=12, unit="Bps")
|
||||
y += 8
|
||||
|
||||
# ---- Web
|
||||
row("Web")
|
||||
# ---- ipx-web: Caddy, the way in. Serves the page itself, passes /api and /media to ipx
|
||||
row("ipx-web")
|
||||
ts("Requests by kind", [loki(f'sum by (kind) (count_over_time({WEB_ALL} [$__interval]))', legend="{{kind}}")],
|
||||
0, w=8, bars=True, stack=True, desc="Everything through Caddy, from the LAN and the tunnel. The API and media go on to ipx.")
|
||||
ts("Page and files by status", [loki(f'sum by (status) (count_over_time({WEB} [$__interval]))', legend="{{status}}")],
|
||||
8, w=8, bars=True, stack=True, desc="What Caddy served itself. A page answered 401 or 403 is ipx refusing it in /api/page-check.")
|
||||
ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duration [$__interval]) by ()', legend="95th percentile"),
|
||||
loki(f'max_over_time({WEB} | unwrap duration [$__interval]) by ()', ref="B", legend="slowest")],
|
||||
16, w=8, unit="s", desc="A page waits on ipx's /api/page-check before it is sent.")
|
||||
y += 8
|
||||
# What the person waits for, from the page itself (#246): a click to the list drawn.
|
||||
LIST = SEL + ' |= "\\"ev\\":\\"list_shown\\"" | json | __error__=""'
|
||||
ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap ms [$__interval]) by ()', legend="95th percentile"),
|
||||
loki(f'quantile_over_time(0.5, {LIST} | unwrap ms [$__interval]) by ()', ref="B", legend="median"),
|
||||
loki(f'max_over_time({LIST} | unwrap ms [$__interval]) by ()', ref="C", legend="slowest")],
|
||||
0, w=12, unit="ms", desc="Measured in the browser, network and drawing included. The API's part is in the click's trace.")
|
||||
table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)],
|
||||
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
|
||||
y += 8
|
||||
|
||||
# ---- ipx: the API it answers behind ipx-web
|
||||
row("ipx: API")
|
||||
ts("Requests by status", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
|
||||
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.")
|
||||
ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
|
||||
@@ -130,31 +205,198 @@ ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms
|
||||
12, unit="ms")
|
||||
y += 8
|
||||
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
|
||||
0, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
|
||||
0, w=8, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
|
||||
table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
|
||||
12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
|
||||
8, w=8, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
|
||||
ts("Open connections", [prom('max(ipx_http_requests_in_flight{job="ipx"})', legend="requests in flight"),
|
||||
prom('max(ipx_event_listeners{job="ipx"})', ref="B", legend="live-update listeners"),
|
||||
prom('max(ipx_event_queue{job="ipx"})', ref="C", legend="live updates unread"),
|
||||
prom('sum(increase(ipx_events_missed_total{job="ipx"}[$__rate_interval]))', ref="D", legend="live updates missed")],
|
||||
16, w=8, desc="Each open page holds one live-update listener. Missed updates mean a page fell behind and reloads its lists.")
|
||||
y += 8
|
||||
|
||||
# ---- Traces
|
||||
# ---- ipx: the worker, one job at a time: scans, downloads, DeadAir
|
||||
row("ipx: worker")
|
||||
ts("Worker", [prom('max by (kind) (ipx_worker_job_seconds{job="ipx"})', legend="on a {{kind}} for"),
|
||||
prom('max(ipx_last_scan_age_seconds{job="ipx"})', ref="B", legend="since the last scan finished")],
|
||||
0, w=8, unit="s", desc="A job's time that only climbs, or no scan for over 10 minutes, means the worker is stuck; the healthcheck still passes.")
|
||||
ts("Jobs done", [prom('sum by (kind) (increase(ipx_jobs_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
|
||||
8, w=8, bars=True, stack=True)
|
||||
ts("Time on jobs", [prom('sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
|
||||
16, w=8, unit="s", bars=True, stack=True, desc="Seconds the worker spent on each kind of job in each interval.")
|
||||
y += 8
|
||||
ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the worker"),
|
||||
prom('ipx_downloads_pending{job="ipx"}', ref="B", legend="downloads"),
|
||||
prom('max(ipx_feed_retries{job="ipx"})', ref="C", legend="feeds to retry a lookup"),
|
||||
prom('max(ipx_deadair_backlog{job="ipx"})', ref="D", legend="files for DeadAir")],
|
||||
0, w=8, desc="A queue that only grows means the worker is stuck or behind.")
|
||||
ts("Feeds checked and new items", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked"),
|
||||
loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', ref="B", legend="new items")],
|
||||
8, w=8, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
|
||||
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
|
||||
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed"),
|
||||
prom('max(ipx_downloads_active{job="ipx"})', ref="C", legend="at once"),
|
||||
prom('2 - max(ipx_torrent_slots_free{job="ipx"})', ref="D", legend="torrents")],
|
||||
16, w=8, bars=True)
|
||||
y += 8
|
||||
ts("Download speed", [prom('sum(rate(ipx_download_bytes_total{job="ipx"}[$__rate_interval]))', legend="downloading")],
|
||||
0, w=8, unit="Bps")
|
||||
ts("Feeds fetching at once", [prom('max(ipx_scan_feeds_fetching{job="ipx"})', legend="fetching")], 8, w=8)
|
||||
ts("DeadAir on a file", [prom('max(ipx_deadair_busy_seconds{job="ipx"})', legend="for"),
|
||||
prom('max(ipx_deadair_playing{job="ipx"})', ref="B", legend="playing through DeadAir")],
|
||||
16, w=8, unit="s", desc="How long DeadAir has been on the file it is analysing; a few seconds each.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "playing through DeadAir"},
|
||||
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
|
||||
y += 8
|
||||
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
|
||||
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
|
||||
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||
y += 8
|
||||
|
||||
# ---- ipx: Signals, items put to TypeSafe's Jev on each person's own key (#254)
|
||||
row("ipx: Signals")
|
||||
ts("Items judged", [prom('sum(increase(ipx_signals_judged_total{job="ipx"}[$__rate_interval]))', legend="judged"),
|
||||
prom('sum(increase(ipx_signals_hits_total{job="ipx"}[$__rate_interval]))', ref="B", legend="fit a Signal")],
|
||||
0, w=8, bars=True, desc="Each item is judged once against every Signal of its subscribers that has not had it; a burst follows a scan or a new Signal.")
|
||||
ts("Calls to Jev", [prom('sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="{{result}}"),
|
||||
prom('(sum(increase(ipx_jev_seconds_total{job="ipx"}[$__rate_interval])) - sum(increase(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))) / '
|
||||
'sum(increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="seconds a call")],
|
||||
8, w=8, bars=True, stack=True,
|
||||
desc="key_refused stops that person's Signals until they save another key; malformed stops the pass and is ipx's to fix; "
|
||||
"rate_limited is TypeSafe saying slow down (#277), and every call waits; "
|
||||
"error is TypeSafe failing or slow, and the item is asked again next pass. Seconds a call is TypeSafe's, without the wait for a turn.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "seconds a call"},
|
||||
"properties": [{"id": "unit", "value": "s"}, {"id": "custom.axisPlacement", "value": "right"},
|
||||
{"id": "custom.drawStyle", "value": "line"}, {"id": "custom.stacking", "value": {"mode": "none"}}]}]
|
||||
ts("Signals judge", [prom('max(ipx_signals_last_pass_age_seconds{job="ipx"})', legend="since the last pass"),
|
||||
prom('max(ipx_signals_keys_refused{job="ipx"})', ref="B", legend="keys refused")],
|
||||
16, w=8, unit="s", desc="It passes at least every five minutes; much longer means it is stuck on Jev. Keys refused, on the right, "
|
||||
"are people whose Signals wait for a new key.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "keys refused"},
|
||||
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
|
||||
y += 8
|
||||
ts("Tokens", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
|
||||
0, w=8, bars=True, stack=True, desc="What TypeSafe counted for each call, as its answers say: what Signals, icons and key checks cost.")
|
||||
ts("Tokens a call", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval])) / ignoring(kind) group_left '
|
||||
'sum(increase(ipx_jev_requests_total{job="ipx", result="ok"}[$__rate_interval]))', legend="{{kind}}")],
|
||||
8, w=8, desc="Most of the input is the questions, one for each Signal, then the item's text, cut at 1,000 characters.")
|
||||
# TypeSafe's default limits (#277): ipx paces itself to 60 calls and 80,000 input tokens a second.
|
||||
LIMIT = lambda n: [{"color": "transparent", "value": None}, {"color": "red", "value": n}]
|
||||
p = ts("Against TypeSafe's limits", [prom('sum(rate(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="calls a second"),
|
||||
prom('sum(rate(ipx_jev_tokens_total{job="ipx", kind="input"}[$__rate_interval])) / 1000', ref="B", legend="thousand input tokens a second"),
|
||||
prom('sum(rate(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))', ref="C", legend="seconds waited for a turn, a second")],
|
||||
16, w=8, desc="TypeSafe allows 80 calls and 100,000 input tokens a second; the red line is 80 on both scales (calls, and thousands of tokens). "
|
||||
"Waiting for a turn means ipx is holding itself under them, and is the time a new Signal's backfill takes longer for it.")
|
||||
p["fieldConfig"]["defaults"]["custom"]["thresholdsStyle"] = {"mode": "line"}
|
||||
p["fieldConfig"]["defaults"]["thresholds"] = {"mode": "absolute", "steps": LIMIT(80)}
|
||||
y += 8
|
||||
# By person and whose key paid (#278): who the server's TypeSafe bill is for. An item shared on the
|
||||
# server's key is split by each person's share of its questions.
|
||||
# Counts from before #278 have no person; user!="" leaves them out rather than drawing " ( key)".
|
||||
ts("Input tokens by person", [prom('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__rate_interval]))',
|
||||
legend="{{user}} ({{key}} key)")],
|
||||
0, w=8, bars=True, stack=True, desc="server is the server's TypeSafe key, what the server pays for; own is the person's own key.")
|
||||
ts("Items judged by person", [prom('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__rate_interval]))', legend="{{user}} ({{key}} key)"),
|
||||
prom('sum by (user) (increase(ipx_signals_hits_total{job="ipx", user!=""}[$__rate_interval]))', ref="B", legend="{{user}}: fit a Signal")],
|
||||
8, w=8, bars=True)
|
||||
def ptab(expr, ref):
|
||||
return {"refId": ref, "datasource": PROM, "expr": expr, "instant": True, "format": "table"}
|
||||
panel("table", "Who used what", 8, 8, 16,
|
||||
[ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__range]))', "A"),
|
||||
ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="output", user!=""}[$__range]))', "B"),
|
||||
ptab('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__range]))', "C")],
|
||||
description="Over the dashboard's time range: each person's input and output tokens and items judged, and whose key paid. "
|
||||
"Icon picks are on the server's key, key checks on the person's own.",
|
||||
transformations=[{"id": "merge", "options": {}},
|
||||
{"id": "organize", "options": {"excludeByName": {"Time": True},
|
||||
"renameByName": {"user": "Person", "key": "Key", "Value #A": "Input tokens",
|
||||
"Value #B": "Output tokens", "Value #C": "Items judged"}}}],
|
||||
options={"showHeader": True, "sortBy": [{"displayName": "Input tokens", "desc": True}]},
|
||||
fieldConfig={"defaults": {"decimals": 0}, "overrides": []})
|
||||
y += 8
|
||||
|
||||
# ---- ipx: the process, what it holds between requests and jobs (#194)
|
||||
row("ipx: process")
|
||||
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
|
||||
# pressure; drawn as a line of its own it read as the pool maxing out (#199).
|
||||
ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"),
|
||||
prom('min(min_over_time(ipx_db_connections{job="ipx", state="idle"}[$__interval]))', ref="B", legend="ready"),
|
||||
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="the pool")],
|
||||
0, w=8, desc="The daemon keeps the whole pool open (#241), so in use and ready add up to the pool. In use reaching "
|
||||
"the pool, ready at 0, means requests and scans are waiting for a connection (#251).")
|
||||
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 8, w=8, unit="bytes")
|
||||
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 16, w=8)
|
||||
y += 8
|
||||
ts("Runtime", [prom('ipx_runtime_tasks{job="ipx"}', legend="tasks"),
|
||||
prom('ipx_runtime_queue{job="ipx"}', ref="B", legend="tasks waiting for a thread"),
|
||||
prom('ipx_runtime_workers{job="ipx"}', ref="C", legend="threads")],
|
||||
0, w=12, desc="Tasks waiting for a thread for long means something is blocking the runtime.")
|
||||
ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_limit_bytes{job="ipx"})', legend="full"),
|
||||
prom('sum(increase(ipx_art_requests_total{job="ipx", result="hit"}[$__rate_interval])) / '
|
||||
'sum(increase(ipx_art_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="hit rate"),
|
||||
prom('sum(increase(ipx_art_evicted_total{job="ipx"}[$__rate_interval]))', ref="C", legend="images evicted")],
|
||||
12, w=12, unit="percentunit",
|
||||
desc="Full sits near 100% once the cache has filled, since each sweep trims it to fit. Hit rate is the share served from disk: "
|
||||
"the rest was fetched from the publisher. Evicted, on the right, is images each sweep threw out to stay under the "
|
||||
"limit: many at every sweep, with a falling hit rate, means the limit is too small.")["fieldConfig"]["overrides"] = bar_overrides("images evicted")
|
||||
y += 8
|
||||
|
||||
# ---- ipx-directory: public at directory.ipxreader.app, shared by ipx servers (#204)
|
||||
row("ipx-directory")
|
||||
stat("Snapshots", loki(f'sum(count_over_time({DIR} | message="snapshot" [$__range])) or vector(0)', instant=True), 0,
|
||||
desc="ipx sends one when what it shares changes, and every half hour (#211).")
|
||||
stat("Feed checks", loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__range])) or vector(0)', instant=True), 4,
|
||||
desc="Each feed every twelve hours, five at a time.")
|
||||
stat("Failed feed checks", loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__range])) or vector(0)', instant=True), 8,
|
||||
thresholds=GOOD_BAD, desc="Mostly the publishers': a feed gone or moved.")
|
||||
stat("Requests", loki(f'sum(count_over_time({DIR_HTTP} [$__range])) or vector(0)', instant=True), 12)
|
||||
stat("Requests refused", loki(f'sum(count_over_time({DIR_HTTP} | status =~ "401|403" [$__range])) or vector(0)', instant=True), 16,
|
||||
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 100}],
|
||||
desc="Without a token, or with one that has not the access asked for. Now and then is someone trying a public address.")
|
||||
stat("Slowest request", loki(f'max(max_over_time({DIR_HTTP} | unwrap duration_ms [$__range])) or vector(0)', instant=True), 20, unit="ms")
|
||||
y += 4
|
||||
ts("Requests by token", [loki(f'sum by (status, token) (count_over_time({DIR_HTTP} [$__interval]))', legend="{{status}} {{token}}")],
|
||||
0, bars=True, stack=True)
|
||||
ts("Feed checks over time", [loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__interval]))', legend="checked"),
|
||||
loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__interval]))', ref="B", legend="failed")],
|
||||
12, bars=True, desc="A burst follows a snapshot with new feeds.")
|
||||
y += 8
|
||||
table("Failing feeds in the directory", [loki(f'sum by (feed, error) (count_over_time({DIR} | message="feed check failed" [$__range]))', instant=True)],
|
||||
0, rename={"feed": "Feed id", "error": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": 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}])
|
||||
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)
|
||||
row("Traces")
|
||||
for x, title, q in [(0, "Recent traces", TRACES + "}"),
|
||||
(12, "Slow traces (over 2s)", TRACES + " && duration > 2s}")]:
|
||||
panel("table", title, 12, 10, x,
|
||||
for x, title, q in [(0, "Recent", TRACES + "}"),
|
||||
(8, "Slow (over 2s)", TRACES + " && duration > 2s}"),
|
||||
(16, "Failed", TRACES + " && status = error}")]:
|
||||
panel("table", title, 8, 10, x,
|
||||
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
|
||||
"tableType": "traces"}],
|
||||
fieldConfig={"defaults": {}, "overrides": []})
|
||||
y += 10
|
||||
|
||||
# ---- Log
|
||||
row("Log")
|
||||
panel("logs", "Warnings and errors", 24, 10, 0, [loki(BAD + TEXT)],
|
||||
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
|
||||
# ---- Logs
|
||||
row("Logs")
|
||||
panel("logs", "ipx: warnings and errors", 12, 10, 0, [loki(BAD + TEXT)], options=LOG_OPTS)
|
||||
panel("logs", "ipx-web and ipx-directory: warnings and errors", 12, 10, 12,
|
||||
[loki(OTHERS_BAD + ' | line_format "{{.container}} {{.level}}: {{.message}}{{.msg}}"')], options=LOG_OPTS)
|
||||
y += 10
|
||||
panel("logs", "Log", 24, 12, 0,
|
||||
panel("logs", "ipx log", 24, 12, 0,
|
||||
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
|
||||
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
|
||||
description="Without the event stream's requests, not-due skips and healthcheck status calls.",
|
||||
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
|
||||
description="Without the event stream's requests, not-due skips and healthcheck status calls.", options=LOG_OPTS)
|
||||
y += 12
|
||||
|
||||
dash = {
|
||||
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
|
||||
@@ -162,6 +404,7 @@ dash = {
|
||||
"templating": {"list": [
|
||||
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
|
||||
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
|
||||
{"name": "prometheus", "label": "Metrics", "type": "datasource", "query": "prometheus", "current": {}, "hide": 0},
|
||||
]},
|
||||
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
|
||||
"panels": panels,
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,19 +1,32 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.3.1-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ipx-web",
|
||||
"version": "1.3.1-dev",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "7.3.1",
|
||||
"@swc/core": "^1.16.2",
|
||||
"@swc/html": "^1.16.2"
|
||||
"@swc/html": "^1.16.2",
|
||||
"hls.js": "1.6.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.0",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
|
||||
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
|
||||
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
@@ -871,6 +884,12 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.19",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.19.tgz",
|
||||
"integrity": "sha512-A//aZeaAgM5rgUCbTURD4gkfyw5Uag2/4Jky7CfDOukL/DN+P5DjbgK2BYteyP3/KnCkPfkGVkbLxQG2T0AbUQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.3.1-dev",
|
||||
"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.",
|
||||
"scripts": {
|
||||
@@ -15,7 +16,9 @@
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "7.3.1",
|
||||
"@swc/core": "^1.16.2",
|
||||
"@swc/html": "^1.16.2"
|
||||
"@swc/html": "^1.16.2",
|
||||
"hls.js": "1.6.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,29 @@ module.exports = defineConfig({
|
||||
reuseExistingServer: false,
|
||||
stdout: 'ignore',
|
||||
},
|
||||
{
|
||||
// The directory the Directory shows (#205), on SQLite in the scratch directory.
|
||||
command: `cargo build -q -p ipx-directory && exec ./target/debug/ipx-directory --config ${setup.root}/directory.toml`,
|
||||
url: `${setup.DIRECTORY}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'ignore',
|
||||
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc` },
|
||||
},
|
||||
{
|
||||
// The frontend as production serves it (#203): built from web/, served by Caddy on the
|
||||
// suite's port, the API passed to the daemon behind it.
|
||||
command: `node web/build.mjs ${setup.root}/web && exec caddy run --config web/Caddyfile --adapter caddyfile`,
|
||||
url: 'http://127.0.0.1:8791/favicon.png',
|
||||
reuseExistingServer: false,
|
||||
timeout: 60_000,
|
||||
stdout: 'ignore',
|
||||
env: { WEB_BIND: '127.0.0.1:8791', WEB_ROOT: `${setup.root}/web`, IPX_UPSTREAM: '127.0.0.1:8796', HOME: setup.root, XDG_DATA_HOME: setup.root, XDG_CONFIG_HOME: setup.root },
|
||||
},
|
||||
{
|
||||
// Build first so the tests always run against current source.
|
||||
command: 'cargo build -q && exec ./target/debug/ipx daemon',
|
||||
port: 8791,
|
||||
port: 8796,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
@@ -41,6 +60,10 @@ module.exports = defineConfig({
|
||||
// production's Tempo and never opens production's database.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||
IPX_DATABASE_URL: '',
|
||||
// The server's TypeSafe key is the shell's: /src/.envrc's is a development key, for this.
|
||||
// Without one, the tests of the server paying for Signals see that it cannot.
|
||||
TYPESAFE_KEY: process.env.TYPESAFE_KEY ?? '',
|
||||
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
4
rustfmt.toml
Normal file
4
rustfmt.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Matches how the code was written by hand before rustfmt was adopted: long lines, and short
|
||||
# expressions kept on one line.
|
||||
max_width = 120
|
||||
use_small_heuristics = "Max"
|
||||
46
scripts/release.sh
Executable file
46
scripts/release.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# Publish a Gitea release for one service's tag, with a tarball taken from the image that was
|
||||
# deployed, so what is attached is what runs on Tower. Run after the release commit is tagged,
|
||||
# pushed and its image built and pushed: sh scripts/release.sh ipx|directory|web
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
case "${1:-}" in
|
||||
ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ipx/Cargo.toml | head -1)
|
||||
tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipx ;;
|
||||
directory) name=ipx-directory; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/directory/Cargo.toml | head -1)
|
||||
tag=directory-v$ver; log=crates/directory/CHANGELOG.md; from=/usr/local/bin/ipx-directory ;;
|
||||
web) name=ipx-web; ver=$(node -p 'require("./package.json").version')
|
||||
tag=web-v$ver; log=web/CHANGELOG.md; from=/srv ;;
|
||||
*) echo "usage: $0 ipx|directory|web" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
case "$ver" in *-dev) echo "$name is at $ver: release it first" >&2; exit 1 ;; esac
|
||||
git fetch -q --tags origin
|
||||
git merge-base --is-ancestor "$tag" origin/main || { echo "$tag is not on origin/main" >&2; exit 1; }
|
||||
|
||||
out=$(mktemp -d); trap 'rm -rf "$out"' EXIT
|
||||
dir=$out/$name-$ver; mkdir "$dir"
|
||||
image=192.168.1.130:5000/$name:latest
|
||||
c=$(docker create "$image"); trap 'docker rm -f "$c" >/dev/null; rm -rf "$out"' EXIT
|
||||
if [ "$name" = ipx-web ]; then
|
||||
docker cp -q "$c:$from/." "$dir/"
|
||||
cp web/Caddyfile "$dir/"
|
||||
# latest may be a later build than the tag: the page carries its version, so check it.
|
||||
grep -qF "$ver" "$dir"/app.js || { echo "$image is not ipx-web $ver" >&2; exit 1; }
|
||||
arch=any
|
||||
else
|
||||
docker cp -q "$c:$from" "$dir/"
|
||||
"$dir/$name" --version | grep -qF " $ver" || { echo "$image is not $name $ver" >&2; exit 1; }
|
||||
arch=linux-$(uname -m)
|
||||
fi
|
||||
cp LICENSE README.md "$dir/"; cp "$log" "$dir/CHANGELOG.md"
|
||||
tarball=$out/$name-$ver-$arch.tar.gz
|
||||
tar -C "$out" -czf "$tarball" "$name-$ver"
|
||||
|
||||
# The notes are this version's section of the changelog, without its heading.
|
||||
awk -v h="## [$ver]" 'index($0, h) == 1 {p = 1; next} p && /^## \[/ {exit} p' "$log" > "$out/notes.md"
|
||||
[ -s "$out/notes.md" ] || { echo "no [$ver] section in $log" >&2; exit 1; }
|
||||
|
||||
timeout 120 /src/tea releases create --login git.sdf1.net --repo rays/ipx --tag "$tag" \
|
||||
--title "$name $ver" --note-file "$out/notes.md" --asset "$tarball" < /dev/null
|
||||
7
site/_headers
Normal file
7
site/_headers
Normal file
@@ -0,0 +1,7 @@
|
||||
# Cloudflare Web Analytics, which Pages adds to the page, is the one script it runs (#216).
|
||||
/*
|
||||
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; script-src https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
/fonts/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
735
site/api.mjs
Normal file
735
site/api.mjs
Normal file
@@ -0,0 +1,735 @@
|
||||
// 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, or your TypeSafe key for Signals.',
|
||||
doc: '<p>A key is checked with TypeSafe, by one short question on it, before it is saved.</p>',
|
||||
body: 'MePatch', returns: '204 No Content',
|
||||
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters; or a key TypeSafe did not accept.'],
|
||||
['502 Bad Gateway', 'TypeSafe could not be asked about the key just now. It is not saved.']],
|
||||
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, pinning and keeping a show from an OPML 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. For an OPML list or a Patreon creator, those of the shows in it you subscribe to, together.',
|
||||
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: 'signals', name: 'signals', title: 'Signals',
|
||||
summary: 'Subjects picked out of every feed you subscribe to by TypeSafe.',
|
||||
items: [
|
||||
{
|
||||
method: 'GET', path: '/api/signals', slug: 'get-signals',
|
||||
summary: 'Your Signals, each with its items and unread counted.',
|
||||
returns: 'Vec<Signal>',
|
||||
example: ipxCurl('GET', '/api/signals'),
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/api/signals', slug: 'post-signals',
|
||||
summary: 'Make a Signal. It looks back over the last week (an admin’s setting), then at each new item.',
|
||||
doc: '<p>Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (<code>signals_on_server</code>), otherwise on yours, saved with <a href="/docs/api/ipx/auth/patch-me"><code>PATCH /api/me</code></a>. Without a key, nothing is picked out.</p>',
|
||||
body: 'SignalBody', returns: '{ id: i64 }',
|
||||
errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']],
|
||||
example: ipxCurl('POST', '/api/signals', '{"name":"Formula 1","keywords":["formula 1","f1"],"description":"Grand prix racing, not road cars."}'),
|
||||
},
|
||||
{
|
||||
method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal',
|
||||
summary: 'Change a Signal. A new name, words or description asks again about what it picked out, and about new items as they come; a new icon asks nothing.',
|
||||
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||
body: 'SignalBody', returns: '204 No Content',
|
||||
errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']],
|
||||
example: ipxCurl('PATCH', '/api/signals/3', '{"keywords":["f1","grand prix"]}'),
|
||||
},
|
||||
{
|
||||
method: 'DELETE', path: '/api/signals/{id}', slug: 'delete-signal',
|
||||
summary: 'Delete a Signal. Its items stay in their feeds.',
|
||||
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||
returns: '204 No Content', errors: [['404 Not Found', 'No Signal of yours has that id.']],
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/api/signals/{id}/entries', slug: 'get-signal-entries',
|
||||
summary: 'A page of the items picked out for a Signal, as a feed’s list is.',
|
||||
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||
query: 'Page', returns: 'EntryPage', errors: [['404 Not Found', 'No Signal of yours has that id.']],
|
||||
example: ipxCurl('GET', '/api/signals/3/entries?filter=unread&limit=10'),
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/api/signals/icon', slug: 'post-signal-icon',
|
||||
summary: 'Have TypeSafe choose a Signal’s icon from the ones offered, on the server’s TypeSafe key.',
|
||||
body: 'IconAsk', returns: '{ icon: String }',
|
||||
errors: [['400 Bad Request', 'No icons, more than 60, or one that is not a style and a name.'],
|
||||
['404 Not Found', 'The server has no TypeSafe key.'], ['502 Bad Gateway', 'TypeSafe could not choose just now.']],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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.'],
|
||||
['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'],
|
||||
['signals', 'String', 'Whose key Signals are judged on: <code>server</code>, for everyone, or <code>own</code>, each person’s.'],
|
||||
['signal_days', 'u64', 'How many days back a new Signal reads.'],
|
||||
] },
|
||||
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.'],
|
||||
['typesafe_key', 'Option<Option<String>>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; <code>null</code> or empty forgets it.']] },
|
||||
{ name: 'Signal', doc: 'A Signal in your sidebar.', fields: [
|
||||
['id', 'i64', ''], ['name', 'String', ''], ['keywords', 'Vec<String>', 'The words it listens for.'],
|
||||
['description', 'Option<String>', 'What you mean, in your own words.'],
|
||||
['icon', 'Option<String>', 'A Font Awesome icon, such as <code>solid/robot</code>; none shows its initials.'],
|
||||
['items', 'i64', 'Items picked out that are still in a feed you subscribe to.'], ['unread', 'i64', 'Those of them not read.'],
|
||||
] },
|
||||
{ name: 'SignalBody', doc: 'A Signal to make, or what to change of one. Leave out what stays.', fields: [
|
||||
['name', 'Option<String>', 'Up to 100 characters; needed for a new one.'],
|
||||
['keywords', 'Option<Vec<String>>', '1 to 50 words, each up to 200 characters; needed for a new one.'],
|
||||
['description', 'Option<Option<String>>', 'Up to 500 characters; <code>null</code> or empty clears it.'],
|
||||
['icon', 'Option<Option<String>>', '<code>solid/</code>, <code>regular/</code> or <code>brands/</code> and a name; <code>null</code> or empty clears it.'],
|
||||
] },
|
||||
{ name: 'IconAsk', doc: 'The icons to choose a Signal’s from.', fields: [
|
||||
['name', 'String', ''], ['keywords', 'Vec<String>', ''], ['description', 'Option<String>', ''],
|
||||
['candidates', 'Vec<(String, String)>', '1 to 60 of <code>[icon, label]</code>, the label its name and search terms.'],
|
||||
] },
|
||||
{ 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.'],
|
||||
['kept', 'bool', 'From an OPML, and yours to keep as a feed of its own should the OPML drop it or you leave the OPML.'],
|
||||
['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.'],
|
||||
['kept', 'Option<bool>', 'Yours: keep a show from an OPML should the OPML drop it.'],
|
||||
['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.'],
|
||||
['markdown', 'Option<String>', 'The item’s Markdown as written, when its feed sends Markdown beside its HTML (RSS’s source:markdown).'],
|
||||
['markdown_html', 'Option<String>', 'That Markdown made HTML, sanitized as description is.'],
|
||||
['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.'],
|
||||
['signal_hits', '{ user, hits }', 'Items were picked out for someone’s Signals; theirs to fetch again.'],
|
||||
] },
|
||||
{ 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', ''],
|
||||
['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'],
|
||||
['signal_days', 'u64', 'How many days back a new Signal reads; 7 unless an admin sets it.'],
|
||||
['typesafe_server_key', 'bool', 'The server has a TypeSafe key (<code>TYPESAFE_KEY</code>); without one, <code>signals_on_server</code> changes nothing.'],
|
||||
] },
|
||||
{ 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>', ''],
|
||||
['signals_on_server', 'Option<bool>', 'Judge everyone’s Signals on the server’s TypeSafe key.'],
|
||||
['signal_days', 'Option<u64>', '1 to 365.'],
|
||||
] },
|
||||
{ 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', '']] },
|
||||
],
|
||||
};
|
||||
14
site/build.sh
Executable file
14
site/build.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# ipxreader.app: the page and its stylesheet, and the typeface, logo and icon from web/, into
|
||||
# site/dist, which Cloudflare Pages serves.
|
||||
set -e
|
||||
here=$(dirname "$0")
|
||||
rm -rf "$here/dist"
|
||||
mkdir -p "$here/dist/fonts"
|
||||
cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/"
|
||||
# The reader's own typeface, the same file ipx-web serves.
|
||||
cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
|
||||
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$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);
|
||||
1
site/gitea.svg
Normal file
1
site/gitea.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" fill="#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" fill="#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" fill="#609926"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
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 twelve hours.</p>
|
||||
68
site/guides/install.html
Normal file
68
site/guides/install.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<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>TYPESAFE_KEY</code></td><td>The server's own <a href="https://typesafe.ai">typesafe.ai</a> API key: it picks a podcast's Directory category and a Signal's icon, and, when an admin turns it on, pays for everyone's Signals</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>
|
||||
138
site/index.html
Normal file
138
site/index.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>iPX</title>
|
||||
<meta name="description" content="iPX checks your podcasts, downloads new episodes and gives everyone at home an account of their own, on your own server. The successor to iPodderX, free and open source.">
|
||||
<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/">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>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>iPX</h1>
|
||||
<p class="intro">iPX checks your podcasts, downloads new episodes, and gives everyone at home their own account to listen from in the browser. It runs on your own server.</p>
|
||||
<p class="acts">
|
||||
<a class="btn primary" href="https://directory.ipxreader.app">Browse the directory</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- The iPodderX logo is a radio's tuning scale and needle. Here the scale is full size and its
|
||||
stations are shows from the directory; the needle sweeps once and settles. -->
|
||||
<figure class="dial" aria-label="A radio dial tuned across shows in the iPX directory">
|
||||
<div class="scale"><div class="track">
|
||||
<ol class="freq" aria-hidden="true">
|
||||
<li>88</li><li>92</li><li>96</li><li>100</li><li>104</li><li>108</li>
|
||||
</ol>
|
||||
<ul class="stations">
|
||||
<li>99% Invisible</li>
|
||||
<li>Hackaday</li>
|
||||
<li>Clarkesworld</li>
|
||||
<li>Accidental Tech Podcast</li>
|
||||
<li>Daring Fireball</li>
|
||||
<li class="tuned">Ologies</li>
|
||||
<li>Oxide and Friends</li>
|
||||
<li>xkcd</li>
|
||||
<li>The Sporkful</li>
|
||||
<li>Acquired</li>
|
||||
<li>Construction Physics</li>
|
||||
<li>Dwarkesh Podcast</li>
|
||||
</ul>
|
||||
<span class="needle" aria-hidden="true"></span>
|
||||
</div></div>
|
||||
</figure>
|
||||
|
||||
<!-- Each feature beside the part of the app that shows it, cut from a scratch iPX on public
|
||||
podcasts, in a navy frame like the dial's. -->
|
||||
<section class="does" aria-label="What it does">
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>New episodes, ready to play</h3>
|
||||
<p>iPX checks your podcasts through the day and downloads new episodes as they come out. Skip the ones you don't want by keyword, or leave out explicit ones. Old episodes are cleared to stay within the space you give it, but never one someone has pinned.</p>
|
||||
</div>
|
||||
<figure class="frame"><img class="cut" src="/shots/episodes.webp" width="904" height="283" loading="lazy"
|
||||
alt="99% Invisible's episodes in a table with each one's size and date, and the downloaded one's file beside it, ready to play, save, share or delete."></figure>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>Pick up where you left off</h3>
|
||||
<p>The player keeps your place in every episode, on every device.</p>
|
||||
</div>
|
||||
<figure class="frame"><img class="cut player" src="/shots/player.webp" width="950" height="80" loading="lazy"
|
||||
alt="The player along the bottom of the page: the episode playing, back and forward, and how far in it is."></figure>
|
||||
</div>
|
||||
|
||||
<!-- DeadAir: talk keeps its length, the quiet between it shrinks to a short pause, and the
|
||||
episode ends sooner. CSS only; the page runs no script of its own. -->
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>All talk, no dead air</h3>
|
||||
<p>Radio has a name for silence on air: dead air. Turn on DeadAir for a show and its long silences shrink to a pause as short as you like. Everyone still talks at their own pace; the episode just ends sooner.</p>
|
||||
</div>
|
||||
<figure class="frame deadair" role="img" aria-label="A waveform whose silent gaps shrink to short pauses, so the episode ends sooner.">
|
||||
<div class="wave" aria-hidden="true">
|
||||
<span class="talk t1"></span><span class="hush"></span>
|
||||
<span class="talk t2"></span><span class="hush"></span>
|
||||
<span class="talk t3"></span><span class="hush"></span>
|
||||
<span class="talk t4"></span><span class="hush"></span>
|
||||
<span class="talk t5"></span><span class="hush"></span>
|
||||
<span class="talk t6"></span>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>Tune out the noise</h3>
|
||||
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last week, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
|
||||
</div>
|
||||
<figure class="frame"><img class="cut" src="/shots/signals.webp" width="874" height="398" loading="lazy"
|
||||
alt="A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica."></figure>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>See what others listen to</h3>
|
||||
<p>iPX servers can share which podcasts their listeners follow, and the directory gathers them in one place. <a href="https://directory.ipxreader.app">Browse the directory</a> to find something new and subscribe in one click.</p>
|
||||
</div>
|
||||
<figure class="frame"><img class="cut half" src="/shots/directory.webp" width="700" height="133" loading="lazy"
|
||||
alt="The directory's most subscribed shows, each with its cover and how many people subscribe."></figure>
|
||||
</div>
|
||||
|
||||
<dl class="also">
|
||||
<div><dt>BitTorrent too</dt>
|
||||
<dd>Some shows publish their episodes as torrents. iPX downloads them like any other, shares them back for as long as you choose, and a slow one never holds up the rest.</dd></div>
|
||||
<div><dt>Subscribe to a whole list</dt>
|
||||
<dd>Some people publish lists of podcasts they recommend, as OPML. Give iPX a list's address and it keeps a folder of those shows up to date as the list changes. Your own subscriptions move in and out the same way.</dd></div>
|
||||
<div><dt>Sign in your way</dt>
|
||||
<dd>Everyone signs in with a password, or with the single sign-on you already use, Cloudflare Access or Authentik. An admin adds people and decides who else is one.</dd></div>
|
||||
<div><dt>Blogs too</dt>
|
||||
<dd>Any RSS or Atom feed works, so the blogs you read sit beside the shows you listen to, and iPX keeps track of what you have read.</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="then" aria-labelledby="then">
|
||||
<h2 id="then">Since 2004</h2>
|
||||
<p>iPodderX was one of the first podcatchers, a Mac app by Ray Slakinski and August Trometer, from the year podcasts got their name. iPX is that app rebuilt to run on your own server.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>iPX is free and open source under the MIT licence.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
BIN
site/shots/directory.webp
Normal file
BIN
site/shots/directory.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
site/shots/episodes.webp
Normal file
BIN
site/shots/episodes.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
site/shots/player.webp
Normal file
BIN
site/shots/player.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
BIN
site/shots/signals.webp
Normal file
BIN
site/shots/signals.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
196
site/site.css
Normal file
196
site/site.css
Normal file
@@ -0,0 +1,196 @@
|
||||
/* ipxreader.app. The palette is the iPodderX logo's: the radio's navy body, the sky of its
|
||||
screen, the blue of its tuning scale, and the orange needle, which is the needle alone. The
|
||||
typeface is the reader's own, Inter, the same file (web/InterVariable.woff2). */
|
||||
@font-face{font-family:Inter;src:url(/fonts/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||
:root{
|
||||
--navy:#0c2238; /* the radio */
|
||||
--ink:#16304a; /* body text, a step off the navy */
|
||||
--muted:#46607a;
|
||||
--sky:#dff0fb; /* its screen */
|
||||
--page:#f1f8fd;
|
||||
--scale:#2f6aa0; /* the tuning scale, and links */
|
||||
--tick:#8fc2ea; /* the scale on the dark dial */
|
||||
--needle:#ff5500;
|
||||
--rule:#c6d9ea;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
|
||||
body{margin:0;background:var(--page);color:var(--ink);font:400 1.0625rem/1.6 Inter,system-ui,sans-serif}
|
||||
a{color:var(--scale);text-underline-offset:.18em}
|
||||
a:focus-visible,.btn:focus-visible{outline:3px solid var(--scale);outline-offset:3px;border-radius:6px}
|
||||
main,.bar,footer{max-width:72rem;margin:0 auto;padding-inline:1.5rem}
|
||||
|
||||
.bar{display:flex;align-items:center;justify-content:space-between;padding-block:1.25rem}
|
||||
.home{display:flex;align-items:center;gap:.6rem;color:var(--navy);text-decoration:none;font-weight:800;font-size:1.25rem}
|
||||
.home img{border-radius:9px;display:block}
|
||||
.bar nav{display:flex;gap:1.5rem}
|
||||
.bar nav a{color:var(--navy);text-decoration:none;font-weight:600}
|
||||
.bar nav a:hover{text-decoration:underline}
|
||||
.bar nav{align-items:center}
|
||||
.src img{display:block}
|
||||
|
||||
.hero{padding-block:3rem 2.5rem;max-width:46rem}
|
||||
h1{margin:0;color:var(--navy);font-weight:900;font-size:clamp(6.5rem,24vw,15rem);line-height:.82;letter-spacing:-.065em}
|
||||
.intro{margin:1.75rem 0 0;max-width:38rem;color:var(--muted);font-size:1.125rem}
|
||||
.acts{display:flex;flex-wrap:wrap;gap:.75rem;margin:2rem 0 0}
|
||||
.btn{display:inline-block;padding:.8rem 1.35rem;border:2px solid var(--navy);border-radius:999px;color:var(--navy);text-decoration:none;font-weight:700}
|
||||
.btn:hover{background:var(--sky)}
|
||||
.btn.primary{background:var(--navy);color:#fff}
|
||||
.btn.primary:hover{background:#1a3a5c}
|
||||
|
||||
/* The dial: a frequency row, minor and major ticks, stations on two staggered rows as a printed
|
||||
dial has them, and the needle, which points down at the scale and lights the station it stops
|
||||
on rather than crossing its name. --at is where it stops: the tuned station's middle. */
|
||||
.dial{margin:1rem 0 0}
|
||||
.scale{background:var(--navy);border-radius:1.25rem;overflow:hidden}
|
||||
.track{position:relative;--at:45.8%}
|
||||
.freq{position:relative;list-style:none;margin:0;padding:1.1rem 4% 0;display:flex;justify-content:space-between;color:var(--tick);font-weight:600;font-size:.95rem;font-variant-numeric:tabular-nums}
|
||||
.freq li{width:0;display:flex;justify-content:center} /* each number centred on its tick */
|
||||
.freq::after{content:"";position:absolute;left:4%;right:4%;top:2.6rem;height:1.6rem;
|
||||
background:
|
||||
repeating-linear-gradient(90deg,var(--tick) 0 2px,transparent 2px 20%) 0 0/100% 100%,
|
||||
repeating-linear-gradient(90deg,rgba(143,194,234,.55) 0 1px,transparent 1px 2%) 0 100%/100% 45%;
|
||||
background-repeat:no-repeat}
|
||||
.stations{list-style:none;margin:2.6rem 0 0;padding:0 1% 1.6rem;display:grid;grid-template-columns:repeat(12,minmax(0,1fr));align-items:start;
|
||||
color:#cfe3f4;font-weight:550;font-size:.84rem;line-height:1.2;letter-spacing:-.01em;text-align:center}
|
||||
.stations li{padding-inline:.15rem}
|
||||
.stations li:nth-child(even){margin-top:1.7rem}
|
||||
.needle{position:absolute;top:2.35rem;height:2.7rem;left:var(--at);width:3px;margin-left:-1.5px;background:var(--needle);border-radius:2px;
|
||||
box-shadow:0 0 0 1px rgba(12,34,56,.6);animation:tune 2.4s cubic-bezier(.3,.7,.3,1) .4s both}
|
||||
.stations .tuned{text-decoration:underline 2px transparent;text-underline-offset:.3em;animation:lit .5s ease-out 2.6s both}
|
||||
@keyframes tune{0%{left:3%}68%{left:calc(var(--at) + 7%)}86%{left:calc(var(--at) - 1.4%)}100%{left:var(--at)}}
|
||||
@keyframes lit{from{color:#cfe3f4}to{color:#fff;font-weight:800;text-decoration-color:var(--needle)}}
|
||||
@media (prefers-reduced-motion:reduce){.needle,.stations .tuned{animation-duration:0s;animation-delay:0s}}
|
||||
|
||||
h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4vw,2.4rem);line-height:1.1;letter-spacing:-.02em}
|
||||
.does,.then{padding-block:4rem 0}
|
||||
/* Each feature on one row: what it does beside the part of the app that shows it,
|
||||
framed in the dial's navy at no more than its own size. */
|
||||
.feature{display:grid;grid-template-columns:minmax(0,22rem) minmax(0,1fr);gap:clamp(1.5rem,5vw,4rem);align-items:center;padding-block:2.5rem}
|
||||
.feature h3{margin:0 0 .5rem;color:var(--navy);font-weight:750;font-size:1.35rem;line-height:1.25}
|
||||
.feature p{margin:0;max-width:34rem}
|
||||
/* Every other row the picture comes first, so the page zigzags rather than running down one side. */
|
||||
.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr) minmax(0,22rem)}
|
||||
.feature:nth-of-type(even) .say{order:2}
|
||||
.frame{margin:0;justify-self:center;max-width:100%;padding:clamp(.6rem,1.6vw,1.1rem);background:var(--navy);border-radius:1.1rem}
|
||||
.frame img{display:block;max-width:100%;height:auto;border-radius:.55rem}
|
||||
/* DeadAir: the track is the episode's full length; talk keeps its width, each silence lights
|
||||
in the needle's orange and shrinks to a pause, and the end comes in from the right. */
|
||||
.frame.deadair{justify-self:stretch;width:100%}
|
||||
.wave{display:flex;align-items:center;height:4.5rem;border-radius:.55rem;background:rgba(143,194,234,.08)}
|
||||
.talk{height:100%;background:url(/wave.svg) 0 50%/auto 100% repeat-x}
|
||||
.talk:first-child{border-radius:.55rem 0 0 .55rem}
|
||||
.t1{flex:0 0 14%}.t2{flex:0 0 19%}.t3{flex:0 0 9%}.t4{flex:0 0 16%}.t5{flex:0 0 12%}.t6{flex:0 0 10%}
|
||||
.hush{flex:0 0 4%;align-self:stretch;background:linear-gradient(var(--tick),var(--tick)) 50%/100% 2px no-repeat;border-radius:.3rem;animation:hush 7s ease-in-out infinite}
|
||||
@keyframes hush{
|
||||
0%,14%{flex-basis:4%;background-color:transparent}
|
||||
24%,30%{flex-basis:4%;background-color:rgba(255,85,0,.35)}
|
||||
48%,86%{flex-basis:.8%;background-color:rgba(255,85,0,.35)}
|
||||
100%{flex-basis:4%;background-color:transparent}}
|
||||
@media (prefers-reduced-motion:reduce){.hush{animation:none;flex-basis:.8%;background-color:rgba(255,85,0,.35)}}
|
||||
.also{margin:2rem 0 0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2.25rem 3.5rem}
|
||||
.also dt{color:var(--navy);font-weight:750;font-size:1.2rem;line-height:1.25}
|
||||
.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 p{margin:0}
|
||||
|
||||
@media (max-width:640px){
|
||||
main,.bar,footer{padding-inline:1rem}
|
||||
.bar nav{gap:1rem}
|
||||
.hero{padding-block:1.5rem 2rem}
|
||||
.feature,.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
|
||||
.feature:nth-of-type(even) .say{order:0}
|
||||
/* A wide crop at this width is too small to read: its left part, larger. */
|
||||
.frame img.cut{width:100%;aspect-ratio:4/3;object-fit:cover;object-position:0 0}
|
||||
.frame img.cut.player{aspect-ratio:2.4/1}
|
||||
.frame img.cut.half{aspect-ratio:2.6/1} /* the directory's left column */
|
||||
.also{grid-template-columns:minmax(0,1fr);gap:1.75rem}
|
||||
.dial{margin-inline:-1rem}
|
||||
.scale{border-radius:0}
|
||||
/* Six stations, the tuned one third, rather than twelve too narrow to read. */
|
||||
.track{--at:41.7%}
|
||||
.stations{grid-template-columns:repeat(6,minmax(0,1fr));padding-inline:2%}
|
||||
.stations li:nth-child(-n+3),.stations li:nth-child(n+10){display:none}
|
||||
.stations li:nth-child(even){margin-top:0}
|
||||
.stations li:nth-child(odd){margin-top:1.7rem}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user