Compare commits
233 Commits
web-v1.0.2
...
file-where
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ef01593d7 | |||
| 47e093e313 | |||
| 757ce5fbb3 | |||
| 45ef1e83f2 | |||
| b3636e648f | |||
| c133adf398 | |||
| ac246682f0 | |||
|
|
679a674fc9 | ||
|
|
171c77475a | ||
|
|
69f1a38e9f | ||
| 6d6bfe6e1d | |||
| a55f08d4ff | |||
| 75d099474c | |||
| 3a3900e959 | |||
| f9e4c1d800 | |||
| dccf9e1762 | |||
|
|
2bb7ed2109 | ||
| 711b0395f1 | |||
|
|
f844cf349a | ||
| 8f888ae10e | |||
|
|
da6cda28a5 | ||
|
|
bd535851b0 | ||
|
|
e6414941ab | ||
|
|
b455c1ec8c | ||
|
|
cf0dad7380 | ||
|
|
2e031763fa | ||
|
|
ecc248307e | ||
|
|
05e522ac93 | ||
|
|
9541f6c6f4 | ||
|
|
ca54e83f9c | ||
|
|
8d4c70a571 | ||
|
|
4957c297b7 | ||
|
|
3626a30b52 | ||
|
|
7d4456bb2f | ||
|
|
f6042e9b5f | ||
| a581e3fc88 | |||
| 6d64cba374 | |||
| b209f55d2c | |||
| c6dcb7f34e | |||
| 88f4e4765b | |||
| 1b0b155226 | |||
| 5a8a7fb0cc | |||
| 2c4d03668f | |||
| 07923d665b | |||
| f5a3847a21 | |||
| ba18b445ec | |||
| 8acf57961e | |||
| 46392c55e1 | |||
| f6ba08d506 | |||
| 7994f93f31 | |||
| 5c5dd91d21 | |||
| ee5610ba4e | |||
| 8e8307b569 | |||
| c00d5383de | |||
| 2db42b5fae | |||
| 1253b470a5 | |||
| 980fbc3f66 | |||
| 3bb59c215a | |||
| b62182e38e | |||
| a92e81aba2 | |||
| bda2ef07be | |||
| 194a9250e7 | |||
| 352e2b77a5 | |||
| f8408a3d26 | |||
| b509ea4b35 | |||
| af69b699a8 | |||
| 742f76969e | |||
| e5388b443f | |||
| bbcf69062e | |||
| d3b8c4cd01 | |||
| 8a66f9c7ee | |||
| 1b2343cf28 | |||
| 5c1970e8ee | |||
| 385589ff77 | |||
| c8cbb3047a | |||
| 245fa79fbf | |||
| 72e4a97f87 | |||
| 90bbcfb40e | |||
| 3f27dfdfdb | |||
| 08b28f0598 | |||
| 5ee633e700 | |||
| 070b39e840 | |||
| c4f0a362d8 | |||
| dd91602a49 | |||
| c788112190 | |||
| 6352324bcb | |||
| 1d54f5141d | |||
| eae0ef5061 | |||
| 2c7e23c346 | |||
| cad14ec09d | |||
| cbc0c48f46 | |||
| 56778df9c5 | |||
| 4249393f2a | |||
| 75afaa291c | |||
| ea5c15cc58 | |||
| 6f38c0ad9b | |||
| c6ca4d1a91 | |||
| 474be3e0a8 | |||
| ed9f008c6c | |||
| b72e65711f | |||
| 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 |
@@ -3,7 +3,21 @@
|
||||
"allow": [
|
||||
"Bash(ffprobe -v error *)",
|
||||
"Bash(env)",
|
||||
"mcp__outline__read_document"
|
||||
"mcp__outline__read_document",
|
||||
"Bash(sh scripts/build-tui.sh)"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ Production is three containers in the Arcane project `ipx` on Tower
|
||||
|
||||
| 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-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) | `job="ipx-web"`: Caddy's own `caddy_http_*`, from `METRICS_BIND`, published as `192.168.1.130:9190` (#371) |
|
||||
| `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 |
|
||||
| `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) | `job="ipx-directory"`, its `/metrics` |
|
||||
|
||||
Under them, Tower itself: `job="node"` (the node exporter: load, CPU, memory, disks). The Grafana
|
||||
Grafana's alert rules (`grafana/alerts.py`, #365) watch all of it between checks: start by asking
|
||||
which are firing (the monitoring section). 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
|
||||
@@ -26,8 +27,11 @@ 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 `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||
@@ -57,19 +61,24 @@ Lines inside a traced span, requests and scans, also carry `trace_id` and `span_
|
||||
lines had no trace id, the access log's time was `ms`, and each event was logged twice, words under
|
||||
`ipx::scan` and fields under `ipx::io`.)
|
||||
|
||||
What is routine is logged at debug, which goes to the admin page's Log view and not to Loki: a
|
||||
request that went well (#364), a scan's end (`scan_done`, #363), a feed not due (`feed_skip`), a
|
||||
feed starting (`feed_start`), and a feed that brought nothing (`feed_done` with every count 0).
|
||||
Count those from the metrics, not the log.
|
||||
|
||||
| target | fields | what |
|
||||
|---|---|---|
|
||||
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
|
||||
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | a warning for each request answered 4xx or 5xx; `route` is the pattern, empty for an unrouted path. Every request, as info, until #364; now those that went well are only in `ipx_http_request_duration_seconds` |
|
||||
| `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::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) and `stall` (ms waiting mid-play) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving on the socket (`-> {...}`) and the answer to `ipxd status` |
|
||||
| `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`, `player_error` and `scroll_stuck` (a drag that should have scrolled the reader and did not, its snapshot in `state`, #384) as warnings; `swipe` (a swipe in the reader: `msg` next, prev, list, end or short, `state` its position, `5/50 of 312, more to load`, #384), `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
|
||||
Events (`ev`): `feed_start` (debug), `feed_done` (new, downloaded, failed, torrents), `feed_skip`
|
||||
(not due, debug), `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), `deadair_done` (feed, enclosure, `saved_s` at the
|
||||
`torrent_deferred`, `reaped`, `reap_done`, `scan_done` (feeds; debug), `status` (feeds, pending, downloaded: the answer to
|
||||
`ipxd status`, only when someone runs it since #218), `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.
|
||||
@@ -86,7 +95,7 @@ cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not
|
||||
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:
|
||||
from 2026-10-11 15:15 UTC. Gauges, but for the counters (`_total`) and the one histogram:
|
||||
|
||||
| metric | what |
|
||||
|---|---|
|
||||
@@ -99,18 +108,26 @@ from 2026-10-11 15:15 UTC. All gauges:
|
||||
| `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_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; `ipxd status` still answers 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_db_query_seconds{kind}`, `ipx_db_wait_seconds` | histograms (#367): each statement's time once it has a connection, by its first word (`select`, `insert`, ...); and the wait for a connection, measured for hand-written SQL only, about half the queries. A wait growing while queries stay flat is the pool full |
|
||||
| `ipx_page_seconds{ev}` | histogram: what the page timed, `list_shown`, `play_start`, `stall`, as the `ipx::client` lines say one by one (#369) |
|
||||
| `ipx_feed_failures_total{error_type}`, `ipx_new_items_total`, `ipx_downloads_total{result="saved"\|"failed"}`, `ipx_deadair_saved_seconds_total` | counters of what the events say (#368): feeds failed by kind, as the `feed_error` lines' `error.type`; new items; downloads; seconds of silence DeadAir found |
|
||||
| `ipx_http_request_duration_seconds{method,route,status}` | histogram: each request answered and its seconds, buckets 5 ms to 10 s; `_count` is requests, `_sum` their time. `route` is the pattern, `""` for an unrouted path; the event stream is left out (#364). From 2026-10-25 |
|
||||
| `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 |
|
||||
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
||||
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||
| `ipx_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`, `directory_http_request_duration_seconds{method,route,status}`, `directory_feed_checks_total`, `directory_feed_checks_failed_total`, `directory_snapshots_total` | the directory's (the last four #370), `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 daemon counts it every 30 s |
|
||||
|
||||
### The checks
|
||||
|
||||
@@ -133,16 +150,20 @@ a count says something happened, the lines and traces say why.
|
||||
browser, a redirect loop ipx should follow, or the same failure on many feeds at once is ipx.
|
||||
3. **Server errors and slow requests.**
|
||||
```
|
||||
$Q metric 'sum by (method, route, status) (count_over_time({container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | status >= 500 [$range]))'
|
||||
$Q metric 'topk(10, quantile_over_time(0.95, {container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | route != "" | route != "/api/events" | unwrap duration_ms [$range]) by (method, route))'
|
||||
$Q prom 'sum by (method, route, status) (increase(ipx_http_request_duration_seconds_count{job="ipx",status=~"4..|5.."}[$range])) > 0' 24h
|
||||
$Q prom 'topk(10, histogram_quantile(0.95, sum by (method, route, le) (rate(ipx_http_request_duration_seconds_bucket{job="ipx",route!=""}[$range]))))' 24h
|
||||
$Q logs '{container="iPX"} |= "\"target\":\"ipx::http\""' 24h
|
||||
```
|
||||
Any 5xx is worth a look. 401s are people signing in, not a problem unless one address is
|
||||
hammering. For a slow route, find its traces (check 5) and see which span holds the time.
|
||||
Any 5xx is worth a look; the log has each failed request's own line, with its trace id. 401s
|
||||
are people signing in, not a problem unless one address is hammering. The 95th percentile is
|
||||
read off the buckets, so it says which bucket, not the exact time: for a slow route, find its
|
||||
spans (check 5) and see which step holds the time. The histogram restarts at 0 with the daemon,
|
||||
which `increase` and `rate` allow for.
|
||||
4. **Is the worker keeping up?** Scans should finish regularly, the queue should drain, and the
|
||||
daemon should not be restarting on its own.
|
||||
```
|
||||
$Q metric 'sum(count_over_time({container="iPX"} |= "\"ev\":\"scan_done\"" [$range]))' 6h
|
||||
$Q logs '{container="iPX"} |= "\"ev\":\"status\"" | json | line_format "{{.timestamp}} pending={{.pending}} downloaded={{.downloaded}}"' 6h
|
||||
$Q prom 'sum(increase(ipx_jobs_total{job="ipx",kind="scan"}[$range]))' 6h
|
||||
$Q prom 'max_over_time(ipx_downloads_pending{job="ipx"}[$range])' 6h
|
||||
$Q logs '{container="iPX"} |= "daemon started"' 7d
|
||||
```
|
||||
A `daemon started` not matched by a deploy (see `git log` and the image's build time) is a
|
||||
@@ -151,30 +172,60 @@ a count says something happened, the lines and traces say why.
|
||||
keeping up or not running.
|
||||
|
||||
Since 2026-10-02 the daemon sleeps until the next feed is due (at most 10 minutes) instead of
|
||||
scanning every minute (#114), so expect tens of scans in 6 hours, not 360, nearly all with
|
||||
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
|
||||
scanning every minute (#114), so expect a scan every few minutes, most of one or two feeds;
|
||||
none at all for over 10 minutes means the worker is stuck. A scan's end (`scan_done`) is
|
||||
logged at debug since #363, so it is in the page's log view and not in Loki. 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.** All three services' (filter by `resource.service.name` for one).
|
||||
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}'
|
||||
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||
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" && resource.service.name=~"ipx|ipx-web|ipx-directory" && 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.
|
||||
Other services on Tower send production traces too (hnopml's `poll`, a Hacker News fetch that
|
||||
times out now and then): `traces` names each trace's service and `trace` prints an event's
|
||||
fields (its `error`), so say whose a failure is before reading on. Tempo keeps about two weeks. A trace id from a log line that Tempo answers 404 for, or a
|
||||
search that finds only the last quarter of an hour, is Tempo not reading its stored blocks: see
|
||||
the monitoring section.
|
||||
|
||||
Steps are `step` spans named by `of` (#187): an item list's `entries_in`, `sanitize`,
|
||||
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).
|
||||
|
||||
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.
|
||||
with. A trace begun in the page (#190, #192) has the tap or key as its root span, `tap` or
|
||||
`key` (#376), from the moment it began to its last API answer, with the requests it made under
|
||||
it (API calls, the playlist and audio of a play, position saves, the page's own report): a tap
|
||||
long with its requests short is the page itself, drawing. The page reports the tap when the next
|
||||
one begins or it is hidden, so the root arrives last; a trace with no name in `$Q traces` is
|
||||
requests no tap began (a live update's, a timer's), or a tap not reported yet.
|
||||
|
||||
Every command the worker runs from the socket, the CLI or the page is a `command` span (`cmd`,
|
||||
#373) with its job inside; a scan on the clock is a `scan` of its own. A list's sync is
|
||||
`list_sync` (`list`, `listed`) with steps `upsert_listed`, `subscribe_readers`, `drop_unlisted`,
|
||||
and an OPML upload `opml_import` (#372). The daemon's start is a `start` trace (#374), from
|
||||
`open_database` and `assemble_config` to `daemon started`: how long a deploy took to answer.
|
||||
|
||||
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
|
||||
hint at.
|
||||
@@ -192,12 +243,16 @@ a count says something happened, the lines and traces say why.
|
||||
$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 'histogram_quantile(0.99, sum by (le) (rate(ipx_db_wait_seconds_bucket{job="ipx"}[$range])))' 24h
|
||||
$Q prom 'histogram_quantile(0.95, sum by (le, kind) (rate(ipx_db_query_seconds_bucket{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. Connections in use at the pool's max (20 on Postgres) 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
|
||||
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
|
||||
@@ -205,23 +260,43 @@ a count says something happened, the lines and traces say why.
|
||||
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
|
||||
15 minutes ago, is the worker stuck, which `ipxd status` does not show; 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
|
||||
the pool's max mean the pool is what they wait on, and `ipx_db_wait_seconds` for how long. 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
|
||||
$Q metric 'sum by (msg) (count_over_time({container="iPX"} |= "\"ev\":\"swipe\"" | json | __error__="" [$range]))' 7d
|
||||
$Q logs '{container="iPX"} |= "\"ev\":\"swipe\"" |= "\"msg\":\"end\"" | json | line_format "{{.timestamp}} {{.user}} {{.feed}} {{.filter}} {{.state}}"' 7d
|
||||
$Q logs '{container="iPX"} |= "\"ev\":\"scroll_stuck\"" | json | line_format "{{.timestamp}} {{.user}} {{.feed}} {{.state}}"' 7d
|
||||
```
|
||||
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`. `play_start` over 2000 ms, or a
|
||||
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.
|
||||
|
||||
Swipes and a reader that will not scroll (#383, #384). An `end` swipe is the reader bouncing
|
||||
off the last item loaded: one whose `state` says `more to load` is the page failing to fetch
|
||||
the next page in time, and several in a row from one person at the same position is #383 back.
|
||||
An `end` without `more to load` is someone at the real end of the list, and fine. Many `short`
|
||||
against few `next` is swipes not being taken, worth a look at `swipeMin` in `gestures.ts`.
|
||||
Any `scroll_stuck` is a reader someone could not scroll, the bug #383 reported and never
|
||||
found: read its `state` before anything else. `under the finger` other than `#detail` or
|
||||
something inside it names a layer lying over the reader; `inert` other than `#dpeek`, `dialog
|
||||
open`, a `transform` other than `none`, or `peek left behind` name what the page left in a wrong
|
||||
state; `shown Ns ago` small says it followed a return from the background. File it with the
|
||||
`state` line and that person's swipes just before it (`$Q logs` on `ev=~"swipe|scroll_stuck"` and
|
||||
the `user`), even when the cause is not clear yet: it is the one record of the bug.
|
||||
|
||||
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
|
||||
|
||||
## ipx-web, the page
|
||||
@@ -229,30 +304,34 @@ a count says something happened, the lines and traces say why.
|
||||
Caddy logs one JSON line a request (#210), logger `http.log.access.log0`: Loki's `| json` gives
|
||||
`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.
|
||||
`127.0.0.1` are local checks; leave them out. `/api` and `/media` pass on to ipx, which counts
|
||||
them in `ipx_http_request_duration_seconds` with its own time; Caddy's `duration` for them adds
|
||||
the hop. This is the only log with a line for every request. Caddy's own metrics, `job="ipx-web"`
|
||||
(`caddy_http_request_duration_seconds{code,handler,method}` and the like), count every request too,
|
||||
502s included, which ipx never sees.
|
||||
|
||||
```sh
|
||||
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.*"'
|
||||
$Q logs '{container="ipx-web"} | json | __error__="" | level=~"warn|error" | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*" | request_uri != "/api/events"'
|
||||
```
|
||||
|
||||
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
|
||||
waits on `/api/page-check` before it is sent, so a slow page is usually a slow page-check: its
|
||||
route in the histogram (check 3), or ipx's span inside Caddy's in the trace. 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.
|
||||
SIGTERM) are by design and filtered out above, and so is `aborting with incomplete response` on
|
||||
`/api/events`: a page closing or reconnecting its live-update stream, about 600 a week.
|
||||
|
||||
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 traces '{resource.service.name="ipx-web" && duration > 2s}'
|
||||
$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}'
|
||||
```
|
||||
|
||||
@@ -270,10 +349,13 @@ It logs a line a request (`target` `ipx_directory::http`: `method`, `path`, `sta
|
||||
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
|
||||
token file changes. Its lines carry their own fields and no span (#363). Its metrics, as
|
||||
`job="ipx-directory"` (production `192.168.1.130:9188`): `directory_process_resident_bytes`,
|
||||
`directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`,
|
||||
`directory_uptime_seconds`, `directory_build_info`. 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.
|
||||
with a `feed` span for each check. Its lines inside a span carry `trace_id` and `span_id`, as
|
||||
ipx's do (#375): a `feed check failed` leads to its check's span.
|
||||
|
||||
```sh
|
||||
$Q traces '{resource.service.name="ipx-directory" && duration > 2s}'
|
||||
@@ -302,11 +384,15 @@ then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A s
|
||||
`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 six hours, 25 at a time, so `feeds checked` over a day should be about four times
|
||||
the feeds listed; none for over six hours means the refresher is stuck. `could not store a feed
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -336,11 +422,41 @@ 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
|
||||
for c in iPX ipx-web ipx-directory; do echo "$c $($Q metric "sum(count_over_time({container=\"$c\"} [24h]))" 24h | tail -1)"; done
|
||||
docker ps -a --filter label=com.docker.compose.project=monitoring --format '{{.Names}} {{.Status}}'
|
||||
docker logs --since 1h tempo 2>&1 | grep -E 'failed to poll|blocklist poll complete' | tail -2
|
||||
```
|
||||
|
||||
`ipx` and `node` should be `up`. Every monitoring container should be running; after Docker
|
||||
`ipx`, `ipx-directory`, `ipx-web`, `node` and the monitoring's own, `prometheus`, `tempo`, `loki`
|
||||
and `alloy` (#366), should be `up`. Alloy is scraped at `alloy:12345` because its command sets
|
||||
`--server.http.listen-addr`; it listens on 127.0.0.1 without it.
|
||||
|
||||
The alert rules (#365) are `grafana/alerts.py`, written to
|
||||
`/mnt/fast/arcane/projects/monitoring/grafana-provisioning/alerting/ipx.json` and loaded by
|
||||
`POST /api/admin/provisioning/alerting/reload`. There is no contact point yet, so one firing is
|
||||
only in Grafana: ask it which are, at the start of every check. The password is in the
|
||||
monitoring project's compose file; never print it.
|
||||
|
||||
```sh
|
||||
PW=$(grep -oP 'GF_SECURITY_ADMIN_PASSWORD=\K\S+' /mnt/fast/arcane/projects/monitoring/compose.yaml)
|
||||
curl -s -u "admin:$PW" http://192.168.1.130:3444/api/prometheus/grafana/api/v1/rules | python3 -c 'import json,sys
|
||||
for g in json.load(sys.stdin)["data"]["groups"]:
|
||||
for r in g["rules"]: print(r["state"], r["name"])' | sort
|
||||
```
|
||||
|
||||
Tempo looks over its stored blocks every 5 minutes. `failed to poll or create index for tenant`
|
||||
means it could not, and then searches and trace lookups see only what it still holds in memory,
|
||||
about the last 15 minutes, while its 2,000 blocks sit unread on disk (its search log says
|
||||
`total_blocks=0`). On 2026-10-25 that was one block left empty, every file 0 bytes, by a write cut
|
||||
short the night before: `unexpected end of JSON input`. Find it and move it out of
|
||||
`/mnt/user/logs/tempo/blocks/single-tenant/`; the next look over, within 5 minutes, logs
|
||||
`writing tenant index` and finds the rest. Tell the user, since it is the monitoring's, not ipx's:
|
||||
|
||||
```sh
|
||||
for d in /mnt/user/logs/tempo/blocks/single-tenant/*/; do python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$d/meta.json" 2>/dev/null || echo "$d"; done
|
||||
```
|
||||
|
||||
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
|
||||
@@ -349,8 +465,9 @@ container after a deploy.
|
||||
|
||||
## Rules
|
||||
|
||||
`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.
|
||||
`docker logs` and `docker exec iPX ipxd ...` 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
|
||||
|
||||
@@ -377,6 +494,8 @@ check is for finding them.
|
||||
## When the checks come back empty
|
||||
|
||||
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.
|
||||
count over a day): iPX a few hundred (new items, Signals, shares with the directory, the page's
|
||||
timings; more on a busy day), ipx-web two thousand or so while the page is in use (a line a
|
||||
request), ipx-directory several hundred. Since #363 and #364 iPX can go an hour at night with no
|
||||
line at all, so judge it by the day. Nothing in a day means Alloy is not shipping it, or the
|
||||
container is down.
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
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"
|
||||
|
||||
@@ -23,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]
|
||||
@@ -56,8 +157,24 @@ def main():
|
||||
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
|
||||
# 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}")
|
||||
# Production holds other services' traces too (hnopml's `poll`); say whose.
|
||||
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {t.get('rootServiceName', '?')} {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}")
|
||||
@@ -66,10 +183,29 @@ def main():
|
||||
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"]]
|
||||
tr = fetch(f"http://tempo:3200/api/traces/{q}")
|
||||
spans = trace_spans(tr)
|
||||
service = {sp["spanId"]: next((a["value"].get("stringValue") for a in b.get("resource", {}).get("attributes", [])
|
||||
if a["key"] == "service.name"), "?")
|
||||
for b in tr.get("batches", tr.get("resourceSpans", []))
|
||||
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]}
|
||||
kids = {}
|
||||
for sp in spans:
|
||||
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
|
||||
@@ -79,13 +215,19 @@ def main():
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])
|
||||
if not a["key"].startswith(("code.", "thread.")) and a["key"] not in ("busy_ns", "idle_ns", "target")}
|
||||
err = " ERROR" if sp.get("status", {}).get("code") in (2, "STATUS_CODE_ERROR") else ""
|
||||
print(f"{' ' * depth}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
|
||||
parent = next((p for p in spans if p["spanId"] == sp.get("parentSpanId")), None)
|
||||
# The service where it changes: a root, or a hop from ipx-web to ipx to the directory.
|
||||
svc = f"[{service.get(sp['spanId'])}] " if parent is None or service.get(parent["spanId"]) != service.get(sp["spanId"]) else ""
|
||||
print(f"{' ' * depth}{svc}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
|
||||
for e in sp.get("events", []):
|
||||
msg = next((a["value"].get("stringValue") for a in e.get("attributes", []) if a["key"] == "message"), e.get("name"))
|
||||
fields = {a["key"]: next(iter(a["value"].values()), None) for a in e.get("attributes", [])}
|
||||
msg = fields.pop("message", None) or e.get("name")
|
||||
# An event's own fields carry the why: `poll failed` alone hid hnopml's timeout.
|
||||
extra = {k: v for k, v in fields.items() if not k.startswith(("code.", "thread.")) and k not in ("level", "target")}
|
||||
# A scan logs a skip for every feed not due; they bury what happened.
|
||||
if '"ev":"feed_skip"' in (msg or ""):
|
||||
continue
|
||||
print(f"{' ' * depth} - {msg}")
|
||||
print(f"{' ' * depth} - {msg}{' ' + json.dumps(extra) if extra else ''}")
|
||||
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
|
||||
show(k, depth + 1)
|
||||
|
||||
|
||||
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
|
||||
30
.githooks/pre-commit
Executable file
30
.githooks/pre-commit
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/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-play and
|
||||
# ipx-directory, ipx-play uses ipx-directory, and all 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-play ipx ipx-tui"
|
||||
elif echo "$staged" | grep -q '^crates/tui/'; then
|
||||
pkgs="ipx-tui"
|
||||
elif echo "$staged" | grep -q '^crates/directory/'; then
|
||||
pkgs="ipx-directory ipx-play ipx"
|
||||
elif echo "$staged" | grep -q '^crates/play/'; then
|
||||
pkgs="ipx-play 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
|
||||
254
CLAUDE.md
254
CLAUDE.md
@@ -19,7 +19,8 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
||||
| 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` |
|
||||
| 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`), 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` |
|
||||
| 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` |
|
||||
| Shared episodes | `ipx-play` in the same project, `192.168.1.130:8102`, public at `play.ipxreader.app` through the tunnel with no Access in front: the page a shared episode opens, anyone's to see (#447). `/v1` takes a token. Config `/mnt/fast/appdata/ipx-play/play.toml` (token hashes; Tower's token entered in ipx's Admin, Directory, Sharing episodes, with the address `http://ipx-play:8102`), database `ipx_play` on the same Postgres, URL in `play.env` beside the compose file. See [docs/play.md](docs/play.md) | `/config/play.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`) |
|
||||
@@ -48,9 +49,18 @@ Labels: `bug` for something wrong, `enhancement` for something missing. A proble
|
||||
for later stays open, and that is how it gets picked up again.
|
||||
|
||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||
**Build only with `scripts/build-image.sh`, never a bare `docker buildx build`.** On 2026-11-04
|
||||
a plain build of ipx, its dependency layer invalidated by a `Cargo.toml` change, rebuilt
|
||||
everything in release on all twelve threads with 5 GB free; Tower thrashed for three hours with
|
||||
every core pegged and had to be rebooted. The script refuses to start under 9 GB available or
|
||||
above load 12, and builds in the BuildKit container `ipx-capped` (made on first use), held to 6
|
||||
GB of memory and six threads by its cgroup: a build that outgrows that is killed, and production
|
||||
keeps running. If one is killed, say so and wait for the user to decide; do not raise the caps or go
|
||||
around the script. Run the build in the foreground on its own, never chained after a cargo
|
||||
command, and look at `MemAvailable` and the load again when it ends.
|
||||
|
||||
```sh
|
||||
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
||||
sh scripts/build-image.sh 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
|
||||
@@ -59,54 +69,68 @@ docker logs --tail 20 iPX
|
||||
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
|
||||
sh scripts/build-image.sh directory
|
||||
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
|
||||
```
|
||||
|
||||
ipx-play the same:
|
||||
|
||||
```sh
|
||||
sh scripts/build-image.sh play
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-play
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-play
|
||||
```
|
||||
|
||||
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
|
||||
sh scripts/build-image.sh web
|
||||
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
|
||||
typeface, logo and icon from `web/`, and it goes to the Cloudflare Pages project `ipxreader` with
|
||||
wrangler, which takes `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` from `/src/.envrc` in
|
||||
place of a login:
|
||||
|
||||
```sh
|
||||
sh site/build.sh
|
||||
. /src/.envrc && npx -y wrangler@4.150.0 pages deploy site/dist --project-name ipxreader --branch main --commit-hash "$(git rev-parse HEAD)"
|
||||
curl -sL https://ipxreader.app/ | head -5
|
||||
```
|
||||
|
||||
`--branch main` makes it the production deployment that ipxreader.app serves; another branch is a
|
||||
preview at its own `*.ipxreader.pages.dev` address. The token is an account API token with
|
||||
Cloudflare Pages: Edit on this account and nothing else (dashboard, My Profile, API Tokens). If it
|
||||
is missing or expired, ask the owner for one rather than working around it. 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: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: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.
|
||||
**Base images come from Google's mirror** (`mirror.gcr.io`, set in `scripts/buildkitd.toml`),
|
||||
not Docker Hub, which rate-limits this host with `429 Too Many Requests`. The capped builder
|
||||
cannot see images the Docker daemon has stored, so pulling and tagging them locally does nothing
|
||||
for it. If `buildkitd.toml` changes, remove the builder (`docker buildx rm ipx-capped`) so the
|
||||
next build makes it again with the new config. The builder also keeps the compiled crates
|
||||
between builds (cache mounts, #438), so a `Cargo.lock` change compiles only what changed; removing
|
||||
it throws them away, and the next build of each image compiles all of them again.
|
||||
|
||||
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
|
||||
and `ipxd 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.
|
||||
@@ -118,12 +142,20 @@ a daemon by hand for testing, **stop it by its own PID**: start it with `& echo
|
||||
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
||||
running the command and kills the session (exit 144), which has happened more than once.
|
||||
|
||||
## The iOS app
|
||||
|
||||
`ios/` is the page in a `WKWebView` with the phone's own player behind `web/src/native.ts` (#418);
|
||||
see [ios/README.md](ios/README.md). It builds only on a Mac with Xcode, from `ios/project.yml`
|
||||
through XcodeGen: nothing here compiles or checks the Swift, so say so when handing over a change
|
||||
to it. A change to the bridge's messages is a change to `native.ts`, `tests/native-bridge.ts` and
|
||||
`ios/Sources/Bridge.swift` together, and the page deploys first.
|
||||
|
||||
## Before you touch the page
|
||||
|
||||
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. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
|
||||
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mts` lists which files
|
||||
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mts` 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
|
||||
@@ -137,23 +169,43 @@ not signed in to `/login` and anyone but an admin away from `/admin` (and refuse
|
||||
`/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
|
||||
`web/boot.ts`, 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
|
||||
`web/build.mts` lists them, sharing one top-level scope as the single inline script did; a new
|
||||
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
||||
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
||||
|
||||
After any edit to it:
|
||||
|
||||
```sh
|
||||
npx tsc -p .
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js
|
||||
npm run -s typecheck
|
||||
node tests/page-smoke.ts
|
||||
node --test tests/page-unit.ts
|
||||
```
|
||||
|
||||
**All the code here is TypeScript** (#392): the page, the service worker, the tests, the build, the
|
||||
home page's generator and the load tests. Node runs a `.ts` file as it is, by stripping the types,
|
||||
so it takes only TypeScript that erases (`erasableSyntaxOnly`: no enums, namespaces or constructor
|
||||
parameter properties) and imports name their `.ts` files; k6 strips its own. What a browser loads
|
||||
on its own is compiled by `web/build.mts` with swc (`boot.ts`, the worker). The directory's
|
||||
`site.js` is compiled from `crates/directory/src/site.ts` and committed, since its build has no
|
||||
Node: after editing `site.ts`, `node web/build.mts --directory-script`, which
|
||||
`node tests/directory-script.ts` checks. `npm run -s typecheck` checks five programs, each with its
|
||||
own globals: the page (`tsconfig.json`, which also writes the page's declarations to
|
||||
`target/page-types` for the browser tests' `page.evaluate` calls), the worker, Node's
|
||||
(`tsconfig.tools.json`), and k6's (`tsconfig.k6.json`).
|
||||
|
||||
The service worker that keeps iPX readable offline (#389) is TypeScript too, `web/sw/sw.ts`,
|
||||
a script of its own built to `/sw.js`, and checked by `tsconfig.sw.json` against a worker's
|
||||
globals, which cannot share a program with the page's. It goes to the network first for
|
||||
everything, so online nothing is an old copy, but for an episode someone kept on the device
|
||||
(#412): the page asks for it as `/media/...?offline`, and the worker answers from its
|
||||
`ipx-episodes` cache, byte ranges and all. The browser tests block it
|
||||
(`serviceWorkers: 'block'`), since it answers before `page.route` can; the offline tests allow it.
|
||||
|
||||
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
|
||||
@@ -171,16 +223,64 @@ 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
|
||||
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
|
||||
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||
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
|
||||
npm run -s typecheck # the page, the service worker, the tests', build's and load tests' TypeScript
|
||||
node tests/page-smoke.ts
|
||||
node tests/directory-script.ts # the directory's site.js is its site.ts as it stands
|
||||
node --test tests/page-unit.ts # the page's own functions, called directly, in a second
|
||||
node tests/native-bridge.ts # the page hands playback to a native shell
|
||||
node tests/contrast.ts # every theme's palette against WCAG AA
|
||||
npx playwright test # the browser tests, against a real daemon on fixture feeds
|
||||
node tests/load/run.ts # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||
sh scripts/coverage.sh # which Rust lines the cargo tests reach, capped like a build (#390)
|
||||
sh scripts/coverage.sh api # which Rust lines the browser suite reaches through the API (#394)
|
||||
npm run coverage:page # which lines of web/src the browser tests reach (#390)
|
||||
```
|
||||
|
||||
**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-play and ipx-directory, ipx-play uses
|
||||
ipx-directory, and all 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-play`, then `-p ipx` |
|
||||
| `crates/play/` | `cargo test -p ipx-play`, then `-p ipx` |
|
||||
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, `-p ipx-play`, then `-p ipx` |
|
||||
| `crates/tui/` | `cargo test -p ipx-tui`; it uses only the API, none of the other crates |
|
||||
| `Cargo.toml`, `Cargo.lock` | all five, 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
|
||||
@@ -208,8 +308,8 @@ 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` 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.
|
||||
* `ipxd status` runs every second throughout, and a run fails if one takes 5s.
|
||||
* One at a time: `node tests/load/run.ts 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
|
||||
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
|
||||
@@ -219,9 +319,52 @@ seeking through files, a flood of wrong passwords. Things about them:
|
||||
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
|
||||
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
|
||||
|
||||
Coverage (#390, #394) says what the tests leave untested; read it before deciding a change is
|
||||
covered. `sh scripts/coverage.sh` runs the cargo tests instrumented in the `ipx-coverage` image
|
||||
(`sh scripts/build-image.sh coverage` makes it), in a container held to the builder's 6 GB and six
|
||||
threads, one crate at a time, into `target/coverage/cargo` (`summary.txt`, `lcov.info`, `html/`).
|
||||
`sh scripts/coverage.sh api` builds an instrumented ipx and ipx-directory there and runs the
|
||||
browser suite here against them (`IPX_API_COVERAGE`), into `target/coverage/api`: what the API
|
||||
and the daemon really run, which the cargo tests alone put at 17% for `web.rs`. The image is
|
||||
Ubuntu 24.04, as code-server is, or the binaries it builds would not start here (glibc); the
|
||||
suite stops its servers with SIGTERM in that mode, since an instrumented binary writes its profile
|
||||
as it exits. The same run measures the page; `npm run coverage:page` does that alone, on a build of
|
||||
the page with a source map to each `web/src` file (`IPX_COVERAGE=1`, `web/concat-map.mts`), into
|
||||
`target/coverage/page`. The specs take `test` and `expect` from `tests/ui/coverage.ts`, not from
|
||||
`@playwright/test`, so every test is counted, every page it opens (a second person's context
|
||||
too), and each document before it unloads: the fixture stops it in `beforeunload` at a breakpoint
|
||||
of its own to take what it ran, since a reload or sign-in otherwise throws that away (#407). The
|
||||
unit tests and the native-bridge check are measured in Node first (`scripts/coverage-node.sh`);
|
||||
the service worker is not.
|
||||
|
||||
Each run ends with `node scripts/coverage-metrics.ts`, which writes every suite's last numbers to
|
||||
`/mnt/fast/appdata/node-exporter/textfile/ipx_coverage.prom`; node-exporter's textfile collector
|
||||
(the monitoring project's compose file) serves them as `ipx_coverage_lines{suite,part,state}`, job
|
||||
`node`, and the iPX dashboard's Coverage row draws them. A coverage run started by hand updates
|
||||
Grafana; nothing runs one on its own.
|
||||
|
||||
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
|
||||
@@ -248,18 +391,20 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
||||
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
|
||||
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
|
||||
fighting over the database, with the stale one still holding the port.
|
||||
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
|
||||
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
|
||||
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
|
||||
Rename one and its panels go blank without an error; regenerate the dashboard to match.
|
||||
* **The Grafana dashboard reads the log's fields and the metrics' names** (`grafana/dashboard.py`).
|
||||
Production logs JSON (`IPX_LOG_FORMAT=json`); the events' `ev`, `feed`, `new`, `bytes`, `msg`
|
||||
(`log_event` in ipc.rs) and the metrics in metrics.rs (requests are
|
||||
`ipx_http_request_duration_seconds`, #364) are what the panels query. Rename one and its panels
|
||||
go blank without an error; regenerate the dashboard to match. Routine lines (a request that went
|
||||
well, a scan's end) are debug: the admin page's Log view has them, Loki does not.
|
||||
* `/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
|
||||
different task. `ipxd 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 `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
|
||||
command, the healthcheck's `ipxd 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
|
||||
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
||||
write lock that took made `ipxd status` time out behind a busy daemon, and it was removed for
|
||||
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for
|
||||
the column first and alter only when it is missing, as `feeds.description` does.
|
||||
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
||||
@@ -277,7 +422,9 @@ Every change gets one line under `## [Unreleased]` in the changelog of the servi
|
||||
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
|
||||
and ipx-web) [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory),
|
||||
[crates/play/CHANGELOG.md](crates/play/CHANGELOG.md) (ipx-play) and
|
||||
[ios/CHANGELOG.md](ios/CHANGELOG.md) (the iOS app). 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
|
||||
@@ -292,6 +439,7 @@ release:
|
||||
|---|---|---|---|
|
||||
| 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-play | `crates/play/Cargo.toml` | `crates/play/CHANGELOG.md` | `play-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
|
||||
@@ -331,6 +479,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
|
||||
|
||||
@@ -340,4 +492,4 @@ normally, and batch related commands into one call to avoid extra turns.
|
||||
Truncated results state their recovery path in their own output. Re-run a
|
||||
command as `rtk proxy <cmd>` only when its result is unusable: empty when
|
||||
output was clearly expected, contradicting its exit code, or garbled.
|
||||
<!-- /rtk-instructions -->
|
||||
<!-- /rtk-instructions -->
|
||||
|
||||
571
Cargo.lock
generated
571
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",
|
||||
@@ -100,6 +115,15 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -445,6 +469,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"
|
||||
@@ -471,6 +516,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
@@ -483,6 +534,15 @@ version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.5"
|
||||
@@ -614,15 +674,32 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
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]]
|
||||
@@ -640,6 +717,15 @@ dependencies = [
|
||||
"typewit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -729,6 +815,33 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -750,9 +863,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 +874,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",
|
||||
]
|
||||
@@ -955,6 +1068,7 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@@ -1024,6 +1138,15 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dontfrag"
|
||||
version = "1.0.1"
|
||||
@@ -1398,9 +1521,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",
|
||||
@@ -1468,6 +1591,11 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -1516,12 +1644,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]]
|
||||
@@ -1803,6 +1932,28 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
|
||||
dependencies = [
|
||||
"darling 0.24.1",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "intervaltree"
|
||||
version = "0.2.7"
|
||||
@@ -1820,26 +1971,26 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "1.2.0"
|
||||
version = "1.5.2-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"atom_syndication",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-directory",
|
||||
"ipx-feed",
|
||||
"ipx-play",
|
||||
"jsonwebtoken",
|
||||
"librqbit",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"opml",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"reqwest",
|
||||
"rss",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1856,12 +2007,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipx-directory"
|
||||
version = "1.1.1"
|
||||
version = "1.4.2-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-feed",
|
||||
"mimalloc",
|
||||
@@ -1877,6 +2029,55 @@ dependencies = [
|
||||
"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 = "ipx-play"
|
||||
version = "0.1.0-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"ipx-directory",
|
||||
"ipx-feed",
|
||||
"opentelemetry",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
@@ -1884,20 +2085,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipx-feed"
|
||||
version = "0.0.0"
|
||||
name = "ipx-tui"
|
||||
version = "0.1.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"atom_syndication",
|
||||
"chrono",
|
||||
"opml",
|
||||
"quick-xml 0.42.0",
|
||||
"clap",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"rss",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2075,6 +2272,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2438,12 +2646,33 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "line-clipping"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2459,6 +2688,15 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -2484,9 +2722,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",
|
||||
@@ -2594,6 +2832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -2761,6 +3000,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2811,6 +3059,7 @@ version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"http",
|
||||
"httpdate",
|
||||
"opentelemetry",
|
||||
@@ -2875,6 +3124,39 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"libm",
|
||||
"palette_derive",
|
||||
"palette_math",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette_derive"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
|
||||
dependencies = [
|
||||
"by_address",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette_math"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -2942,9 +3224,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",
|
||||
@@ -2952,9 +3234,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",
|
||||
@@ -2962,9 +3244,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",
|
||||
@@ -2972,9 +3254,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",
|
||||
]
|
||||
@@ -3087,6 +3369,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"
|
||||
@@ -3300,6 +3600,72 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.30.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
|
||||
dependencies = [
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
"ratatui-crossterm",
|
||||
"ratatui-widgets",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"compact_str",
|
||||
"hashbrown 0.17.1",
|
||||
"itertools 0.14.0",
|
||||
"kasuari",
|
||||
"lru",
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.20",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-crossterm"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-widgets"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"hashbrown 0.17.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools 0.14.0",
|
||||
"line-clipping",
|
||||
"ratatui-core",
|
||||
"serde",
|
||||
"strum",
|
||||
"time",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
@@ -3500,10 +3866,23 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.44"
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -3565,9 +3944,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",
|
||||
@@ -3956,6 +4335,27 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -4235,10 +4635,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
@@ -4248,9 +4654,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",
|
||||
@@ -4280,6 +4686,21 @@ name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
@@ -4432,7 +4853,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
@@ -4651,6 +5074,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",
|
||||
@@ -4822,6 +5246,29 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -5026,9 +5473,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",
|
||||
@@ -5047,9 +5494,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",
|
||||
]
|
||||
@@ -5458,3 +5905,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",
|
||||
]
|
||||
|
||||
26
Cargo.toml
26
Cargo.toml
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/ipx", "crates/feed", "crates/directory"]
|
||||
members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui", "crates/play"]
|
||||
resolver = "3"
|
||||
|
||||
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
|
||||
@@ -7,27 +7,36 @@ resolver = "3"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
# 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" }
|
||||
ipx-play = { path = "crates/play" }
|
||||
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"] }
|
||||
@@ -36,8 +45,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
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -7,23 +7,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
|
||||
# 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 ./
|
||||
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 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 crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||
# cargo's registry and target/ are BuildKit cache mounts, kept in the builder between builds, so
|
||||
# a Cargo.lock change compiles only the crates that changed (#438): a dependency layer of stub
|
||||
# crates went with any change to it and compiled all 500 again, holding the builder's memory cap
|
||||
# for minutes. A cache mount is not part of the image, so the binary is copied out in the same
|
||||
# step. The workspace's own crates are touched because cargo goes by mtimes and skips a rebuild
|
||||
# if they look untouched.
|
||||
RUN --mount=type=cache,id=ipx-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=ipx-target,target=/src/target,sharing=locked \
|
||||
touch crates/ipx/src/main.rs crates/ipx/src/lib.rs crates/feed/src/lib.rs \
|
||||
crates/directory/src/lib.rs \
|
||||
&& cargo build --release --locked -p ipx \
|
||||
&& cp target/release/ipxd /usr/local/bin/ipxd
|
||||
|
||||
FROM debian:trixie-slim
|
||||
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
|
||||
@@ -31,7 +30,7 @@ FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates gosu ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
|
||||
COPY --from=build /usr/local/bin/ipxd /usr/local/bin/ipxd
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
@@ -46,4 +45,4 @@ VOLUME ["/config", "/data", "/downloads"]
|
||||
EXPOSE 8099/tcp 6881/tcp 6881/udp
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["ipx", "daemon"]
|
||||
CMD ["ipxd", "daemon"]
|
||||
|
||||
23
Dockerfile.coverage
Normal file
23
Dockerfile.coverage
Normal file
@@ -0,0 +1,23 @@
|
||||
# What scripts/coverage.sh runs the Rust tests in, to see which lines they reach (#390): the
|
||||
# toolchain, LLVM's coverage tools and cargo-llvm-cov. No source: coverage.sh mounts the tree.
|
||||
# Built and pushed like the services, by scripts/build-image.sh coverage, under its caps.
|
||||
#
|
||||
# Ubuntu 24.04, as code-server is (#394): `coverage.sh api` builds an instrumented ipx and
|
||||
# ipx-directory here, under the cap, and the browser suite runs them in code-server, which a
|
||||
# binary linked against Debian trixie's newer glibc (2.41 to Ubuntu's 2.39) would not start in.
|
||||
# The same Rust as the tests are built with elsewhere: a newer one would rebuild everything with
|
||||
# its own flags for nothing.
|
||||
FROM ubuntu:24.04
|
||||
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:$PATH
|
||||
# cc for rusqlite's bundled SQLite and librqbit, as Dockerfile's builder; ffmpeg for DeadAir's
|
||||
# tests, as the image ipx runs in; curl and certificates to fetch rustup.
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential ca-certificates curl ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.1 --no-modify-path \
|
||||
&& rustup component add llvm-tools-preview
|
||||
# cargo-llvm-cov's own build, as its README gives it, rather than compiling it here.
|
||||
ADD https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz /tmp/llvm-cov.tar.gz
|
||||
RUN tar -xzf /tmp/llvm-cov.tar.gz -C /usr/local/cargo/bin && rm /tmp/llvm-cov.tar.gz \
|
||||
&& cargo llvm-cov --version && rustc --version
|
||||
WORKDIR /work
|
||||
@@ -1,23 +1,27 @@
|
||||
# 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.
|
||||
# No node here: only ipx's own build runs web/build.mts.
|
||||
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
|
||||
# Compiled crates kept in the builder between builds, as in the main Dockerfile (#438). A target
|
||||
# of its own: built with ipx's, the two would rebuild shared crates for each other's features.
|
||||
RUN --mount=type=cache,id=ipx-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=ipx-directory-target,target=/src/target,sharing=locked \
|
||||
touch crates/directory/src/main.rs crates/directory/src/lib.rs crates/feed/src/lib.rs \
|
||||
&& cargo build --release --locked -p ipx-directory \
|
||||
&& cp target/release/ipx-directory /usr/local/bin/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
|
||||
COPY --from=build /usr/local/bin/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
|
||||
|
||||
28
Dockerfile.play
Normal file
28
Dockerfile.play
Normal file
@@ -0,0 +1,28 @@
|
||||
# ipx-play (#447), the pages shared episodes open. Built from the repo's root:
|
||||
# sh scripts/build-image.sh play
|
||||
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.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
# The logo and icon its pages serve, compiled into the binary.
|
||||
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||
# Compiled crates kept in the builder between builds (#438), in a target of its own.
|
||||
RUN --mount=type=cache,id=ipx-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=ipx-play-target,target=/src/target,sharing=locked \
|
||||
touch crates/play/src/main.rs crates/play/src/lib.rs crates/directory/src/lib.rs crates/feed/src/lib.rs \
|
||||
&& cargo build --release --locked -p ipx-play \
|
||||
&& cp target/release/ipx-play /usr/local/bin/ipx-play
|
||||
|
||||
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 /usr/local/bin/ipx-play /usr/local/bin/ipx-play
|
||||
ENV PLAY_CONFIG=/config/play.toml
|
||||
# Unraid's nobody:users. It writes nothing to disk: the database is elsewhere.
|
||||
USER 99:100
|
||||
EXPOSE 8102
|
||||
CMD ["ipx-play"]
|
||||
@@ -1,11 +1,11 @@
|
||||
# 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
|
||||
FROM node:24-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
|
||||
RUN node web/build.mts /dist
|
||||
|
||||
FROM caddy:2
|
||||
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
|
||||
|
||||
14
README.md
14
README.md
@@ -44,13 +44,15 @@ From source:
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/ipx daemon
|
||||
./target/release/ipxd 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
|
||||
echo -n 'a good password' | ipxd user passwd admin
|
||||
```
|
||||
|
||||
The UI is plain HTTP, so put TLS in front of it if it is reachable from outside your network.
|
||||
@@ -60,7 +62,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
||||
| | |
|
||||
|---|---|
|
||||
| [docs/configuration.md](docs/configuration.md) | Every config key, path and environment variable |
|
||||
| [docs/cli.md](docs/cli.md) | Every command, including `ipx user` |
|
||||
| [docs/cli.md](docs/cli.md) | Every command, including `ipxd user` |
|
||||
| [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 |
|
||||
@@ -71,8 +73,8 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
||||
|
||||
```sh
|
||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||
node tests/page-smoke.js # the page script loads without throwing
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/page-smoke.ts # the page script loads without throwing
|
||||
node tests/native-bridge.ts # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
|
||||
@@ -10,6 +10,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- A `robots.txt` and a sitemap listing the home page, A to Z, every category and every feed, so search engines find the pages, not only the links between them (#380).
|
||||
- `/metrics` counts its requests and their times by route and status, the feeds it checks and those that failed, and the snapshots it takes (#370).
|
||||
- Its log lines inside a trace carry the trace's and span's ids, as ipx's do, so a failed feed check leads to its trace (#375).
|
||||
|
||||
### Changed
|
||||
|
||||
- Its checks of YouTube channels go one at a time, two seconds apart, since YouTube refuses many asked together (#386).
|
||||
- Its JSON log lines no longer carry their span, once on its own and again in a list, which said nothing the line's own fields did not (#363).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A YouTube channel's check no longer fails on the 404 or 500 YouTube answers some feed requests with and does not mean: it is asked again, twice, a second apart (#382).
|
||||
|
||||
## [1.4.1] - 2026-10-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- A feed larger than 50 MB is checked again, up to 200 MB, where it was marked as failing (#357).
|
||||
|
||||
## [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
|
||||
@@ -45,7 +106,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
||||
- 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.1.1...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...main
|
||||
[1.4.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...directory-v1.4.1
|
||||
[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
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
[package]
|
||||
name = "ipx-directory"
|
||||
version = "1.1.1"
|
||||
version = "1.4.2-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "ipx-directory"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
@@ -26,10 +30,12 @@ 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
|
||||
|
||||
513
crates/directory/src/api.rs
Normal file
513
crates/directory/src/api.rs
Normal file
@@ -0,0 +1,513 @@
|
||||
//! 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())
|
||||
.route_layer(axum::middleware::from_fn(remember_route))
|
||||
// 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);
|
||||
|
||||
/// Puts the route matched on the response for `access_log`, which runs outside routing: by path,
|
||||
/// each feed's page was a row of its own in the request metric.
|
||||
async fn remember_route(route: axum::extract::MatchedPath, req: Request, next: Next) -> Response {
|
||||
let mut resp = next.run(req).await;
|
||||
resp.extensions_mut().insert(route);
|
||||
resp
|
||||
}
|
||||
|
||||
/// 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" {
|
||||
let route = resp.extensions().get::<axum::extract::MatchedPath>().map_or("", |r| r.as_str());
|
||||
crate::metrics::REQUESTS.observe(
|
||||
&format!(
|
||||
"method=\"{}\",route=\"{}\",status=\"{status}\"",
|
||||
crate::metrics::method(method.as_str()),
|
||||
crate::metrics::label(route)
|
||||
),
|
||||
start.elapsed().as_secs_f64(),
|
||||
);
|
||||
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();
|
||||
crate::metrics::SNAPSHOTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
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);
|
||||
// Crawlers are pointed at every page (#380).
|
||||
let (s, robots, _) = get_page(&app, "/robots.txt").await;
|
||||
assert!(s == 200 && robots.contains("Disallow: /v1/") && robots.contains("/sitemap.xml"), "{robots}");
|
||||
let (s, map, _) = get_page(&app, "/sitemap.xml").await;
|
||||
assert_eq!(s, 200);
|
||||
assert!(map.contains(&format!("/feed/{id}</loc>")) && map.contains("/category/Podcasting</loc>"), "{map}");
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// What the log layer wrote, kept for the test to read.
|
||||
struct Lines(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for Lines {
|
||||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(b);
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_snapshots_log_line_has_the_fields_grafana_reads() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
let buf = std::sync::Arc::new(std::sync::Mutex::new(vec![]));
|
||||
let b = buf.clone();
|
||||
// Global, as the directory's own is; other tests' snapshots land here too, and each of
|
||||
// their lines must carry the same fields.
|
||||
tracing::subscriber::set_global_default(
|
||||
tracing_subscriber::registry().with(crate::otel::json_layer(move || Lines(b.clone()), false)),
|
||||
)
|
||||
.unwrap();
|
||||
let (app, _dir) = app().await;
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/log.xml", "subscribers": 1 }]);
|
||||
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await.0, 200);
|
||||
|
||||
let text = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
|
||||
let lines: Vec<serde_json::Value> = text.lines().map(|l| serde_json::from_str(l).expect(l)).collect();
|
||||
let snaps: Vec<&serde_json::Value> = lines.iter().filter(|l| l["message"] == "snapshot").collect();
|
||||
assert!(!snaps.is_empty(), "no snapshot line in {text}");
|
||||
for l in snaps {
|
||||
assert!(l["instance"].is_string(), "{l}");
|
||||
for n in ["accepted", "refused_private", "refused_blocked", "refused_invalid"] {
|
||||
assert!(l[n].is_u64(), "{n} in {l}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_a_servers_snapshot_takes_only_the_feeds_nobody_else_lists() {
|
||||
let (app, dir) = app().await;
|
||||
let feed = |url: &str| serde_json::json!({ "url": url, "subscribers": 1 });
|
||||
let mine = serde_json::json!([feed("https://only-rw.test/feed"), feed("https://both.test/feed")]);
|
||||
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(mine)).await.0, 200);
|
||||
let theirs = serde_json::json!([feed("https://both.test/feed")]);
|
||||
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rwa"), Some(theirs)).await.0, 200);
|
||||
|
||||
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rw"), None).await.0, 403, "admins only");
|
||||
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rwa"), None).await.0, 204);
|
||||
let left: Vec<(String, i64)> =
|
||||
dir.store.instances().await.unwrap().into_iter().map(|i| (i.name, i.feeds)).collect();
|
||||
assert_eq!(left, [("rwa-token".to_owned(), 1)]);
|
||||
let gone = dir.store.due_one(&store::feed_id("https://only-rw.test/feed")).await.unwrap();
|
||||
assert!(gone.is_none(), "nobody lists it now");
|
||||
let kept = dir.store.due_one(&store::feed_id("https://both.test/feed")).await.unwrap();
|
||||
assert!(kept.is_some(), "another server still does");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
350
crates/directory/src/apple.rs
Normal file
350
crates/directory/src/apple.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! 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. A test's stand-in asks for none.
|
||||
const PAUSE: Duration = if cfg!(test) { Duration::ZERO } else { Duration::from_secs(3) };
|
||||
/// Where Apple's charts and lookup API are.
|
||||
const APPLE: &str = "https://itunes.apple.com";
|
||||
|
||||
/// 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, APPLE, &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, apple: &str, country: &str) -> Result<()> {
|
||||
let genres: Genres = get(client, &format!("{apple}/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!("{apple}/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
|
||||
None => format!("{apple}/{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!("{apple}/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?;
|
||||
crate::metrics::SNAPSHOTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
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::*;
|
||||
|
||||
/// A stand-in for Apple: one category, Arts (1301); the top chart is shows 1 and 2, Arts' is 2
|
||||
/// and 3; the lookup gives 1 a public feed, 2 one with a key in it, 3 another public one. With
|
||||
/// `lookup_fails`, the lookup is a 503.
|
||||
async fn apple(lookup_fails: bool) -> String {
|
||||
use axum::extract::{Path, Query};
|
||||
let chart = |ids: &[&str]| {
|
||||
let entry: Vec<_> =
|
||||
ids.iter().map(|id| serde_json::json!({ "id": { "attributes": { "im:id": id } } })).collect();
|
||||
serde_json::json!({ "feed": { "entry": entry } })
|
||||
};
|
||||
let (top, arts) = (chart(&["1", "2"]), chart(&["2", "3"]));
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/WebObjects/MZStoreServices.woa/ws/genres",
|
||||
axum::routing::get(|| async {
|
||||
axum::Json(serde_json::json!({ "26": { "name": "Podcasts", "subgenres": { "1301": { "name": "Arts" } } } }))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/us/rss/toppodcasts/{*chart}",
|
||||
axum::routing::get(move |Path(c): Path<String>| {
|
||||
let got = if c.contains("genre=1301") { arts.clone() } else { top.clone() };
|
||||
async move { axum::Json(got) }
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/lookup",
|
||||
axum::routing::get(move |Query(q): Query<BTreeMap<String, String>>| async move {
|
||||
if lookup_fails {
|
||||
return Err(axum::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
assert_eq!(q["id"], "1,2,3", "each show once, in chart order");
|
||||
Ok(axum::Json(serde_json::json!({ "results": [
|
||||
{ "feedUrl": "https://one.test/feed", "primaryGenreName": "Arts" },
|
||||
{ "feedUrl": "https://two.test/feed?auth=0123456789abcdef0123456789abcdef", "primaryGenreName": "Arts" },
|
||||
{ "feedUrl": "https://three.test/feed", "primaryGenreName": "Podcasts" },
|
||||
]})))
|
||||
}),
|
||||
);
|
||||
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = l.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(l, app).await });
|
||||
format!("http://{at}")
|
||||
}
|
||||
|
||||
async fn apples(dir: &Shared) -> i64 {
|
||||
dir.store.instances().await.unwrap().iter().find(|i| i.name == INSTANCE).map_or(0, |i| i.feeds)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sync_takes_apples_charts_as_a_snapshot_and_a_failed_one_keeps_the_last() {
|
||||
let dir = crate::Directory::new(crate::store::Store::scratch().await, vec![], Default::default()).unwrap();
|
||||
let client = reqwest::Client::new();
|
||||
sync(&dir, &client, &apple(false).await, "us").await.unwrap();
|
||||
assert_eq!(apples(&dir).await, 2, "the feed with a key in it is refused as private");
|
||||
|
||||
// An admin blocks one; the next sync leaves it out.
|
||||
dir.store.block(&crate::store::feed_id("https://three.test/feed")).await.unwrap();
|
||||
sync(&dir, &client, &apple(false).await, "us").await.unwrap();
|
||||
assert_eq!(apples(&dir).await, 1);
|
||||
|
||||
// Apple failing is no reason to forget last week's charts.
|
||||
assert!(sync(&dir, &client, &apple(true).await, "us").await.is_err());
|
||||
assert_eq!(apples(&dir).await, 1, "the last good snapshot stands");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,15 @@
|
||||
|
||||
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.
|
||||
@@ -18,6 +22,15 @@ pub struct Config {
|
||||
/// 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>,
|
||||
}
|
||||
@@ -30,6 +43,7 @@ fn default_database_url_env() -> String {
|
||||
"DIRECTORY_DATABASE_URL".into()
|
||||
}
|
||||
|
||||
/// A token as `directory.toml` lists it: a name, what it may do, and only the token's hash.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TokenCfg {
|
||||
@@ -65,14 +79,17 @@ pub struct Token {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Reads and checks `path`; see `parse`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&text).with_context(|| format!("in {}", path.display()))
|
||||
}
|
||||
|
||||
/// Reads a config, refusing tokens with empty or repeated names, which would file two
|
||||
/// instances' snapshots together.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
let cfg: Config = toml::from_str(text)?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
for t in &cfg.tokens {
|
||||
if t.name.trim().is_empty() {
|
||||
bail!("a token has an empty name");
|
||||
@@ -81,6 +98,14 @@ impl Config {
|
||||
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)
|
||||
}
|
||||
@@ -88,7 +113,7 @@ impl Config {
|
||||
/// 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 = std::collections::HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
self.tokens
|
||||
.iter()
|
||||
.map(|t| {
|
||||
@@ -106,25 +131,26 @@ impl Config {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut f| f.read_exact(&mut bytes))
|
||||
.expect("no source of randomness for a token");
|
||||
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>())
|
||||
}
|
||||
|
||||
@@ -149,7 +175,10 @@ mod tests {
|
||||
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[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);
|
||||
}
|
||||
@@ -158,7 +187,9 @@ mod tests {
|
||||
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(&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());
|
||||
|
||||
265
crates/directory/src/directory.rs
Normal file
265
crates/directory/src/directory.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
//! 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 {
|
||||
crate::metrics::CHECKS_FAILED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
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");
|
||||
crate::metrics::CHECKED.fetch_add(due.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
},
|
||||
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()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refresh_lists_a_feed_and_not_one_that_could_not_be_read() {
|
||||
use axum::routing::get;
|
||||
let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel><title>Good Show</title>
|
||||
<item><title>One</title><guid>g1</guid><enclosure url="https://x/1.mp3" type="audio/mpeg" length="1"/></item>
|
||||
</channel></rss>"#;
|
||||
let opml =
|
||||
r#"<?xml version="1.0"?><opml version="2.0"><head/><body><outline xmlUrl="https://x/f"/></body></opml>"#;
|
||||
let app = axum::Router::new()
|
||||
.route("/good.xml", get(move || async move { rss }))
|
||||
.route("/list.opml", get(move || async move { opml }))
|
||||
.route("/garbled.xml", get(|| async { "<rss><channel" }))
|
||||
.route("/gone.xml", get(|| async { axum::http::StatusCode::NOT_FOUND }));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
|
||||
let allowed = ipx_feed::guard::Allowed::parse(&["127.0.0.1".into()]).unwrap();
|
||||
let dir = Directory::new(Store::scratch().await, vec![], allowed).unwrap();
|
||||
let paths = ["/good.xml", "/list.opml", "/garbled.xml", "/gone.xml"];
|
||||
let entries: Vec<crate::SnapshotEntry> = paths
|
||||
.iter()
|
||||
.map(|p| crate::SnapshotEntry { url: format!("http://{at}{p}"), subscribers: 1, category: None })
|
||||
.collect();
|
||||
assert_eq!(dir.store.snapshot("tower", &entries).await.unwrap().accepted, 4);
|
||||
let due = dir.store.due(REFRESH_EVERY, 10).await.unwrap();
|
||||
assert_eq!(due.len(), 4);
|
||||
for d in &due {
|
||||
dir.refresh(d).await.unwrap();
|
||||
}
|
||||
assert!(dir.store.due(REFRESH_EVERY, 10).await.unwrap().is_empty(), "each was checked, failed or not");
|
||||
|
||||
let page = |path: &str| {
|
||||
let d = due.iter().find(|d| d.url.ends_with(path)).unwrap();
|
||||
dir.store.feed(&d.id)
|
||||
};
|
||||
let good = page("/good.xml").await.unwrap().unwrap();
|
||||
assert_eq!(good.listing.title.as_deref(), Some("Good Show"));
|
||||
assert!(good.listing.podcast && good.failing.is_none() && good.items.len() == 1);
|
||||
for bad in ["/list.opml", "/garbled.xml", "/gone.xml"] {
|
||||
// Never read, so never listed: a page of nothing is no use to anyone browsing.
|
||||
assert!(page(bad).await.unwrap().is_none(), "{bad} is not listed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,563 +7,16 @@
|
||||
//! 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;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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, Serialize};
|
||||
|
||||
use config::{Access, Token};
|
||||
use store::{Refreshed, Store};
|
||||
|
||||
/// 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>,
|
||||
pub published: Option<i64>,
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Blocked {
|
||||
pub id: String,
|
||||
pub url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
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;
|
||||
|
||||
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
|
||||
/// to a feed fetches it itself anyway.
|
||||
pub const REFRESH_EVERY: i64 = 6 * 3600;
|
||||
|
||||
pub struct Directory {
|
||||
pub store: Store,
|
||||
tokens: RwLock<Vec<Token>>,
|
||||
client: reqwest::Client,
|
||||
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
|
||||
wake: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
pub type Shared = Arc<Directory>;
|
||||
|
||||
impl Directory {
|
||||
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> {
|
||||
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.read_timeout(ipx_feed::FEED_SILENCE)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
Ok(Arc::new(Self { store, tokens: RwLock::new(tokens), client, wake: tokio::sync::Notify::new() }))
|
||||
}
|
||||
|
||||
/// 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().unwrap() = tokens;
|
||||
self.store.keep_instances(&names).await
|
||||
}
|
||||
|
||||
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()
|
||||
.unwrap()
|
||||
.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());
|
||||
tracing::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) = tracing::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: std::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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
.layer(axum::middleware::from_fn(access_log))
|
||||
.with_state(dir)
|
||||
}
|
||||
|
||||
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)]
|
||||
struct Who(String);
|
||||
|
||||
/// One line a request, with the token's name and never the token: what the Grafana panels read.
|
||||
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 = tracing::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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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}")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn healthz(State(dir): State<Shared>) -> Result<&'static str, ApiError> {
|
||||
dir.store.ping().await?;
|
||||
Ok("ok")
|
||||
}
|
||||
|
||||
async fn me(Extension(me): Extension<Me>) -> Json<Me> {
|
||||
Json(me)
|
||||
}
|
||||
|
||||
async fn list(State(dir): State<Shared>) -> Result<Json<Vec<Listing>>, ApiError> {
|
||||
Ok(Json(dir.store.list().await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Limit {
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
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?))
|
||||
}
|
||||
|
||||
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")))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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?))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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?))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 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).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))
|
||||
}
|
||||
|
||||
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![]).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);
|
||||
}
|
||||
}
|
||||
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};
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use ipx_directory::{Directory, config, store::Store};
|
||||
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)]
|
||||
@@ -11,7 +17,7 @@ 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<std::path::PathBuf>,
|
||||
config: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
@@ -61,7 +67,7 @@ async fn main() -> Result<()> {
|
||||
let port = cfg.bind.rsplit(':').next().unwrap_or("8100");
|
||||
let resp = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:{port}/healthz"))
|
||||
.timeout(std::time::Duration::from_secs(4))
|
||||
.timeout(Duration::from_secs(4))
|
||||
.send()
|
||||
.await
|
||||
.context("the directory does not answer")?;
|
||||
@@ -71,18 +77,27 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
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 = || tracing_subscriber::EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
|
||||
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")?;
|
||||
// Each line's own fields say all its span did, and the span said it again (#363); the trace
|
||||
// and span ids lead to the trace instead, as ipx's do (#375). Boxed rather than an Option of
|
||||
// each: Option's Layer does not pass on_register_dispatch on, which the JSON layer needs (#408).
|
||||
let stderr: Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync> = if json {
|
||||
ipx_directory::otel::json_layer(std::io::stderr, false).with_filter(filter()).boxed()
|
||||
} else {
|
||||
tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter()).boxed()
|
||||
};
|
||||
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(stderr)
|
||||
.with(otel.as_ref().map(|p| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx-directory"))
|
||||
.with_filter(EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
|
||||
@@ -92,22 +107,29 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
.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).await.context("opening the database")?;
|
||||
let dir = Directory::new(store, vec![])?;
|
||||
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 = tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
||||
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 = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
||||
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(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
|
||||
let mut term = tokio::signal::unix::signal(SignalKind::terminate()).expect("SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = term.recv() => {}
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
|
||||
@@ -6,24 +6,101 @@
|
||||
//! 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::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
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()?;
|
||||
let kb =
|
||||
s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
Some(kb * 1024.0)
|
||||
});
|
||||
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
||||
(rss, fds)
|
||||
}
|
||||
|
||||
/// The directory's /metrics, in Prometheus's text format: memory, open files, and the feeds it
|
||||
/// has fetched. Nothing from the database, so a scrape costs nothing and answers while it is down.
|
||||
fn render(started: std::time::Instant) -> String {
|
||||
/// Upper bounds for a request's or a query's time, in seconds: most answer in milliseconds, and a
|
||||
/// host stalling takes seconds.
|
||||
pub const SECONDS: &[f64] = &[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
|
||||
|
||||
/// Prometheus's histogram, for ipx and the directory both: for each set of labels, how many
|
||||
/// observations fell under each bound, how many all told, and their sum.
|
||||
pub struct Histograms {
|
||||
bounds: &'static [f64],
|
||||
rows: Mutex<BTreeMap<String, Row>>,
|
||||
}
|
||||
|
||||
/// Under each bound, all told, and the sum.
|
||||
type Row = (Vec<u64>, u64, f64);
|
||||
|
||||
impl Histograms {
|
||||
#[must_use]
|
||||
pub const fn new(bounds: &'static [f64]) -> Self {
|
||||
Self { bounds, rows: Mutex::new(BTreeMap::new()) }
|
||||
}
|
||||
|
||||
/// Counts `v` under `labels`, written as Prometheus reads them (`a="x",b="y"`, or "" for
|
||||
/// none); `label` escapes a value that came from outside.
|
||||
pub fn observe(&self, labels: &str, v: f64) {
|
||||
let mut rows = self.rows.lock().expect("a panic elsewhere poisoned this lock");
|
||||
let (buckets, n, sum) = rows.entry(labels.to_owned()).or_insert_with(|| (vec![0; self.bounds.len()], 0, 0.0));
|
||||
for (b, le) in buckets.iter_mut().zip(self.bounds) {
|
||||
*b += u64::from(v <= *le);
|
||||
}
|
||||
*n += 1;
|
||||
*sum += v;
|
||||
}
|
||||
|
||||
pub fn render(&self, out: &mut String, name: &str, help: &str) {
|
||||
let rows = self.rows.lock().expect("a panic elsewhere poisoned this lock").clone();
|
||||
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} histogram");
|
||||
for (labels, (buckets, n, sum)) in &rows {
|
||||
let sep = if labels.is_empty() { "" } else { "," };
|
||||
for (le, b) in self.bounds.iter().zip(buckets) {
|
||||
let _ = writeln!(out, "{name}_bucket{{{labels}{sep}le=\"{le}\"}} {b}");
|
||||
}
|
||||
let _ = writeln!(out, "{name}_bucket{{{labels}{sep}le=\"+Inf\"}} {n}");
|
||||
let braces = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
|
||||
let _ = writeln!(out, "{name}_sum{braces} {sum}\n{name}_count{braces} {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request's method as a label: one outside the usual is "other", so a caller cannot make rows.
|
||||
#[must_use]
|
||||
pub fn method(m: &str) -> &'static str {
|
||||
["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].into_iter().find(|x| *x == m).unwrap_or("other")
|
||||
}
|
||||
|
||||
/// A label's value as Prometheus's text format reads it.
|
||||
#[must_use]
|
||||
pub fn label(v: &str) -> String {
|
||||
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// Requests answered, by method, route and status (#370).
|
||||
pub static REQUESTS: Histograms = Histograms::new(SECONDS);
|
||||
/// Feeds the refresher has checked, and of them those that failed (#370).
|
||||
pub static CHECKED: AtomicU64 = AtomicU64::new(0);
|
||||
pub static CHECKS_FAILED: AtomicU64 = AtomicU64::new(0);
|
||||
/// Snapshots taken from ipx servers and the Apple sync (#370).
|
||||
pub static SNAPSHOTS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The directory's /metrics, in Prometheus's text format: memory, open files, the feeds it has
|
||||
/// fetched and checked, snapshots and requests. 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"));
|
||||
@@ -35,23 +112,64 @@ fn render(started: std::time::Instant) -> String {
|
||||
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);
|
||||
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,
|
||||
);
|
||||
gauge("directory_feed_checks_total", "counter", "Feeds the refresher checked.", "", CHECKED.load(Relaxed) as f64);
|
||||
gauge(
|
||||
"directory_feed_checks_failed_total",
|
||||
"counter",
|
||||
"Feed checks that failed: the publisher's error, or a feed that would not parse.",
|
||||
"",
|
||||
CHECKS_FAILED.load(Relaxed) as f64,
|
||||
);
|
||||
gauge("directory_snapshots_total", "counter", "Snapshots taken.", "", SNAPSHOTS.load(Relaxed) as f64);
|
||||
REQUESTS.render(
|
||||
&mut out,
|
||||
"directory_http_request_duration_seconds",
|
||||
"Requests answered and the seconds each took, by method, route and status.",
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Serves /metrics on a listener of its own: the public port is behind the tunnel with no
|
||||
/// Access in front, and these are for the LAN.
|
||||
pub async fn serve(listener: tokio::net::TcpListener) {
|
||||
let started = std::time::Instant::now();
|
||||
let app = axum::Router::new().route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || async move {
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||
}),
|
||||
);
|
||||
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");
|
||||
}
|
||||
@@ -59,9 +177,58 @@ pub async fn serve(listener: tokio::net::TcpListener) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_histogram_is_written_as_prometheus_reads_it() {
|
||||
static H: Histograms = Histograms::new(&[0.1, 1.0]);
|
||||
for v in [0.05, 0.5, 2.0] {
|
||||
H.observe("route=\"/a\"", v);
|
||||
}
|
||||
H.observe("", 0.01);
|
||||
let mut out = String::new();
|
||||
H.render(&mut out, "t_seconds", "Help.");
|
||||
for line in [
|
||||
"# TYPE t_seconds histogram\n",
|
||||
"t_seconds_bucket{route=\"/a\",le=\"0.1\"} 1\n",
|
||||
"t_seconds_bucket{route=\"/a\",le=\"1\"} 2\n",
|
||||
"t_seconds_bucket{route=\"/a\",le=\"+Inf\"} 3\n",
|
||||
"t_seconds_count{route=\"/a\"} 3\n",
|
||||
"t_seconds_bucket{le=\"0.1\"} 1\n",
|
||||
"t_seconds_sum 0.01\n",
|
||||
] {
|
||||
assert!(out.contains(line), "{line} in {out}");
|
||||
}
|
||||
assert_eq!(label("a \"b\"\\"), "a \\\"b\\\"\\\\");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_directory_metric_grafana_queries_is_served() {
|
||||
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = l.local_addr().unwrap();
|
||||
tokio::spawn(serve(l));
|
||||
let text = reqwest::get(format!("http://{at}/metrics")).await.unwrap().text().await.unwrap();
|
||||
let src = [include_str!("../../../grafana/dashboard.py"), include_str!("../../../grafana/alerts.py")].concat();
|
||||
let names: std::collections::BTreeSet<&str> = src
|
||||
.match_indices("directory_")
|
||||
.filter(|(i, _)| !src[..*i].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_'))
|
||||
.map(|(i, _)| {
|
||||
let rest = &src[i..];
|
||||
&rest[..rest.find(|c: char| !(c.is_ascii_lowercase() || c == '_')).unwrap_or(rest.len())]
|
||||
})
|
||||
.collect();
|
||||
assert!(names.len() >= 4, "{names:?}");
|
||||
for n in names {
|
||||
assert!(text.contains(&format!("# TYPE {n} ")), "{n} is queried by grafana/ but not served: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
assert!(
|
||||
text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! 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.
|
||||
@@ -15,6 +17,7 @@ pub fn provider(service: &'static str) -> anyhow::Result<Option<opentelemetry_sd
|
||||
opentelemetry_sdk::trace::SdkTracerProvider::builder()
|
||||
.with_batch_exporter(exporter)
|
||||
.with_resource(resource)
|
||||
.with_id_generator(Ids::default())
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
@@ -23,7 +26,8 @@ pub fn provider(service: &'static str) -> anyhow::Result<Option<opentelemetry_sd
|
||||
/// 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).
|
||||
pub fn remote_parent(headers: &axum::http::HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
|
||||
#[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()?,
|
||||
@@ -46,6 +50,7 @@ pub fn remote_parent(headers: &axum::http::HeaderMap, query: Option<&str>) -> Op
|
||||
|
||||
/// 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;
|
||||
@@ -54,6 +59,117 @@ pub fn traceparent() -> Option<String> {
|
||||
sc.is_valid().then(|| format!("00-{}-{}-{:02x}", sc.trace_id(), sc.span_id(), sc.trace_flags().to_u8()))
|
||||
}
|
||||
|
||||
/// Random ids, as the SDK's own, but for a span built inside `with_ids`, which takes the ids it is
|
||||
/// given: the page names its tap's span before ipx makes it (#376), and the SDK gives a span
|
||||
/// builder no other way to be told them.
|
||||
#[derive(Debug, Default)]
|
||||
struct Ids(opentelemetry_sdk::trace::RandomIdGenerator);
|
||||
|
||||
thread_local! {
|
||||
static GIVEN: std::cell::Cell<Option<(opentelemetry::trace::TraceId, opentelemetry::trace::SpanId)>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
impl opentelemetry_sdk::trace::IdGenerator for Ids {
|
||||
fn new_trace_id(&self) -> opentelemetry::trace::TraceId {
|
||||
GIVEN.get().map_or_else(|| self.0.new_trace_id(), |(t, _)| t)
|
||||
}
|
||||
fn new_span_id(&self) -> opentelemetry::trace::SpanId {
|
||||
GIVEN.get().map_or_else(|| self.0.new_span_id(), |(_, s)| s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f`, which builds one root span, with these ids for it. Synchronous, so nothing else on
|
||||
/// this thread makes a span in between.
|
||||
pub fn with_ids<R>(
|
||||
trace: opentelemetry::trace::TraceId,
|
||||
span: opentelemetry::trace::SpanId,
|
||||
f: impl FnOnce() -> R,
|
||||
) -> R {
|
||||
GIVEN.set(Some((trace, span)));
|
||||
let r = f();
|
||||
GIVEN.set(None);
|
||||
r
|
||||
}
|
||||
|
||||
/// 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; the directory's lines carry them too (#375). A line outside any traced span, or with no trace exporter, is unchanged.
|
||||
/// One JSON object a line for Loki, each event's fields its own and the trace and span ids beside
|
||||
/// them, for ipx and the directory both: the dashboard and the alert rules read these fields.
|
||||
/// `current_span` adds the span's own fields under `span`, which ipx keeps and the directory
|
||||
/// dropped (#363).
|
||||
pub fn json_layer<S, W>(writer: W, current_span: bool) -> impl tracing_subscriber::Layer<S>
|
||||
where
|
||||
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
|
||||
W: for<'w> tracing_subscriber::fmt::MakeWriter<'w> + Send + Sync + 'static,
|
||||
{
|
||||
use tracing_subscriber::Layer as _;
|
||||
let registered = Registered::default();
|
||||
tracing_subscriber::fmt::layer()
|
||||
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
|
||||
.event_format(WithTrace(
|
||||
tracing_subscriber::fmt::format()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_current_span(current_span)
|
||||
.with_span_list(false),
|
||||
registered.clone(),
|
||||
))
|
||||
.with_writer(writer)
|
||||
.and_then(registered)
|
||||
}
|
||||
|
||||
/// The subscriber the log layer is part of, as it was handed over on registration. The span's
|
||||
/// OpenTelemetry context is reached through it: `Span::current()` asks the thread's default
|
||||
/// dispatcher, which inside an event under a scoped subscriber (`with_default`) is hidden by
|
||||
/// tracing's re-entrancy guard, and every line went out without its ids (#408).
|
||||
#[derive(Clone, Default)]
|
||||
struct Registered(std::sync::Arc<std::sync::OnceLock<tracing::dispatcher::WeakDispatch>>);
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Registered {
|
||||
fn on_register_dispatch(&self, subscriber: &tracing::Dispatch) {
|
||||
let _ = self.0.set(subscriber.downgrade());
|
||||
}
|
||||
}
|
||||
|
||||
struct WithTrace<F>(F, Registered);
|
||||
|
||||
impl<S, N, F> tracing_subscriber::fmt::FormatEvent<S, N> for WithTrace<F>
|
||||
where
|
||||
F: tracing_subscriber::fmt::FormatEvent<S, N>,
|
||||
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
|
||||
N: for<'w> tracing_subscriber::fmt::FormatFields<'w> + 'static,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>,
|
||||
mut w: tracing_subscriber::fmt::format::Writer<'_>,
|
||||
ev: &tracing::Event<'_>,
|
||||
) -> std::fmt::Result {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
// Without one (a wrapper that does not pass registration on, as Option does not), the
|
||||
// default dispatcher, which a global subscriber's lines still find.
|
||||
let registered = self.1.0.get().and_then(tracing::dispatcher::WeakDispatch::upgrade);
|
||||
let dispatch = registered.unwrap_or_else(|| tracing::dispatcher::get_default(Clone::clone));
|
||||
// The registry's own current span, not the one this layer's filter would show.
|
||||
let sc = ctx
|
||||
.current_span()
|
||||
.id()
|
||||
.and_then(|id| tracing_opentelemetry::get_otel_context(id, &dispatch))
|
||||
.map(|cx| cx.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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -70,14 +186,39 @@ mod tests {
|
||||
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"] {
|
||||
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"));
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_span_built_with_given_ids_takes_them_and_the_next_does_not() {
|
||||
use opentelemetry::trace::{Span as _, SpanId, TraceId, Tracer as _, TracerProvider as _};
|
||||
let tracer = opentelemetry_sdk::trace::SdkTracerProvider::builder()
|
||||
.with_id_generator(super::Ids::default())
|
||||
.build()
|
||||
.tracer("t");
|
||||
let (t, s) = (
|
||||
TraceId::from_hex("4bf92f3577b34da6a3ce929d0e0e4736").unwrap(),
|
||||
SpanId::from_hex("00f067aa0ba902b7").unwrap(),
|
||||
);
|
||||
let given = super::with_ids(t, s, || tracer.start("tap"));
|
||||
assert_eq!((given.span_context().trace_id(), given.span_context().span_id()), (t, s));
|
||||
let next = tracer.start("other");
|
||||
assert_ne!(next.span_context().trace_id(), t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// Made from site.ts by `node web/build.mts --directory-script`: edit that, not this.
|
||||
// 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);
|
||||
});
|
||||
//
|
||||
// The source: the directory serves site.js, made from this by `node web/build.mts --directory-script`
|
||||
// (#392), since its build has no Node to make it; tests/directory-script.ts checks the two agree.
|
||||
document.addEventListener('click', (ev)=>{
|
||||
const b = ev.target?.closest('[data-copy]');
|
||||
// Only in a secure context; plain HTTP has no clipboard.
|
||||
if (!b || !navigator.clipboard) return;
|
||||
void navigator.clipboard.writeText(b.dataset.copy ?? '').then(()=>{
|
||||
b.textContent = 'Copied';
|
||||
setTimeout(()=>{
|
||||
b.textContent = 'Copy';
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
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 axum::Router;
|
||||
|
||||
use crate::{Listing, Shared};
|
||||
use url::form_urlencoded::Serializer;
|
||||
|
||||
pub fn router() -> Router<Shared> {
|
||||
Router::new()
|
||||
@@ -24,6 +25,8 @@ pub fn router() -> Router<Shared> {
|
||||
.route("/search", get(search))
|
||||
.route("/category/{name}", get(category))
|
||||
.route("/feed/{id}", get(feed))
|
||||
.route("/robots.txt", get(robots))
|
||||
.route("/sitemap.xml", get(sitemap))
|
||||
.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"))))
|
||||
@@ -39,6 +42,48 @@ async fn asset(kind: &'static str, body: &'static [u8]) -> Response {
|
||||
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
|
||||
}
|
||||
|
||||
/// The address this was asked for at, which both files must give whole: the public name through
|
||||
/// the tunnel, whose cloudflared says https, or the LAN address. Only a host name's characters are
|
||||
/// kept, since the header is the caller's to write.
|
||||
fn origin(h: &axum::http::HeaderMap) -> String {
|
||||
let get = |k: &str| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("");
|
||||
let host: String =
|
||||
get("host").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')).collect();
|
||||
let scheme = if get("x-forwarded-proto") == "https" { "https" } else { "http" };
|
||||
format!("{scheme}://{host}")
|
||||
}
|
||||
|
||||
/// Crawlers may read every page but a search, which is any query at all, and the API, which takes
|
||||
/// a token (#380).
|
||||
async fn robots(h: axum::http::HeaderMap) -> Response {
|
||||
let body = format!("User-agent: *\nDisallow: /search\nDisallow: /v1/\n\nSitemap: {}/sitemap.xml\n", origin(&h));
|
||||
([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=86400")], body)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Every page there is to find: the home page, A to Z, each category and each feed (#380).
|
||||
async fn sitemap(State(dir): State<Shared>, h: axum::http::HeaderMap) -> Response {
|
||||
let all = match dir.pages_list().await {
|
||||
Ok(a) => a,
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
let o = origin(&h);
|
||||
let cats: std::collections::BTreeSet<&str> = all.iter().map(|l| l.category.as_deref().unwrap_or("Other")).collect();
|
||||
let mut b = String::from(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#,
|
||||
);
|
||||
let paths = ["/".to_owned(), "/all".to_owned()]
|
||||
.into_iter()
|
||||
.chain(cats.iter().map(|c| format!("/category/{}", seg(c))))
|
||||
.chain(all.iter().map(|l| format!("/feed/{}", seg(&l.id))));
|
||||
for p in paths {
|
||||
let _ = write!(b, "<url><loc>{}</loc></url>", esc(&format!("{o}{p}")));
|
||||
}
|
||||
b.push_str("</urlset>");
|
||||
([(header::CONTENT_TYPE, "application/xml; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], b)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Filter {
|
||||
/// `podcasts` or `blogs`; anything else is both.
|
||||
@@ -58,15 +103,17 @@ impl Filter {
|
||||
}
|
||||
|
||||
async fn home(State(dir): State<Shared>) -> Response {
|
||||
let all = match dir.store.list().await {
|
||||
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())));
|
||||
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 {
|
||||
for l in all.iter() {
|
||||
cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
|
||||
}
|
||||
let mut b = String::new();
|
||||
@@ -75,12 +122,25 @@ async fn home(State(dir): State<Shared>) -> Response {
|
||||
} 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));
|
||||
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,
|
||||
"<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());
|
||||
}
|
||||
@@ -108,23 +168,24 @@ async fn category(State(dir): State<Shared>, Path(c): Path<String>, Query(f): Qu
|
||||
|
||||
/// 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.store.list().await {
|
||||
Ok(a) => a.into_iter().filter(|l| pick(l)).collect(),
|
||||
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
|
||||
});
|
||||
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 = url::form_urlencoded::Serializer::new(String::new());
|
||||
let mut q = Serializer::new(String::new());
|
||||
if let Some(v) = f.q.as_deref() {
|
||||
q.append_pair("q", v);
|
||||
}
|
||||
@@ -138,18 +199,38 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
|
||||
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));
|
||||
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 { "" });
|
||||
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 { "" });
|
||||
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));
|
||||
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>");
|
||||
}
|
||||
@@ -158,7 +239,14 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
|
||||
} 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));
|
||||
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>");
|
||||
}
|
||||
@@ -177,11 +265,21 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
||||
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>\
|
||||
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));
|
||||
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));
|
||||
}
|
||||
@@ -199,17 +297,26 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
||||
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>",
|
||||
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>") });
|
||||
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>())
|
||||
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\">\
|
||||
@@ -234,14 +341,22 @@ fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
||||
}
|
||||
|
||||
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)));
|
||||
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>");
|
||||
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
|
||||
}
|
||||
@@ -253,8 +368,12 @@ fn redirect(to: &'static str) -> 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://"))
|
||||
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()))
|
||||
@@ -301,7 +420,13 @@ fn clock(secs: i64) -> String {
|
||||
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("&", "&");
|
||||
let decoded = stripped
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&");
|
||||
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
|
||||
14
crates/directory/src/site.ts
Normal file
14
crates/directory/src/site.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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.
|
||||
//
|
||||
// The source: the directory serves site.js, made from this by `node web/build.mts --directory-script`
|
||||
// (#392), since its build has no Node to make it; tests/directory-script.ts checks the two agree.
|
||||
document.addEventListener('click', (ev) => {
|
||||
const b = (ev.target as Element | null)?.closest<HTMLElement>('[data-copy]');
|
||||
// Only in a secure context; plain HTTP has no clipboard.
|
||||
if (!b || !navigator.clipboard) return;
|
||||
void navigator.clipboard.writeText(b.dataset.copy ?? '').then(() => {
|
||||
b.textContent = 'Copied';
|
||||
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,11 @@ 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;
|
||||
@@ -13,6 +18,10 @@ 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,
|
||||
}
|
||||
@@ -27,23 +36,25 @@ pub struct Due {
|
||||
|
||||
/// What a fetch found, to be stored.
|
||||
pub enum Refreshed {
|
||||
/// Unchanged since the last fetch.
|
||||
NotModified,
|
||||
Feed {
|
||||
parsed: ipx_feed::ParsedFeed,
|
||||
etag: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
},
|
||||
/// Read anew, with the validators to send next time.
|
||||
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
||||
/// The fetch failed, and why, in words.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Unix seconds now.
|
||||
#[must_use]
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
||||
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::Url::parse(url.trim()).ok()?;
|
||||
let mut u = Url::parse(url.trim()).ok()?;
|
||||
if !matches!(u.scheme(), "http" | "https") || u.host_str().is_none() {
|
||||
return None;
|
||||
}
|
||||
@@ -53,6 +64,7 @@ pub fn normalise(url: &str) -> Option<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()
|
||||
@@ -84,6 +96,11 @@ const SCHEMA: &[&str] = &[
|
||||
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,
|
||||
@@ -99,7 +116,7 @@ impl Store {
|
||||
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:").await.unwrap();
|
||||
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
|
||||
@@ -117,10 +134,16 @@ impl Store {
|
||||
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")).await.unwrap()
|
||||
Self::open(
|
||||
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn open(url: &str) -> Result<Self> {
|
||||
/// `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;
|
||||
@@ -128,13 +151,18 @@ impl Store {
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
url = format!("{url}{sep}options=-c%20client_min_messages%3Dwarning");
|
||||
}
|
||||
let mut opts = sea_orm::ConnectOptions::new(url.clone());
|
||||
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);
|
||||
}
|
||||
let db = sea_orm::Database::connect(opts).await?;
|
||||
// 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?;
|
||||
}
|
||||
@@ -154,10 +182,29 @@ impl Store {
|
||||
Ok(self.db.execute_raw(self.stmt(sql, values)).await?.rows_affected())
|
||||
}
|
||||
|
||||
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<sea_orm::QueryResult>> {
|
||||
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(())
|
||||
@@ -167,10 +214,14 @@ impl Store {
|
||||
/// 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: std::collections::HashSet<String> =
|
||||
self.rows("SELECT id FROM blocked", vec![]).await?.iter().map(|r| r.try_get("", "id")).collect::<Result<_, _>>()?;
|
||||
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 = std::collections::BTreeMap::new();
|
||||
let mut keep = BTreeMap::new();
|
||||
for e in entries {
|
||||
let Some(url) = normalise(&e.url) else {
|
||||
result.refused_invalid += 1;
|
||||
@@ -188,7 +239,9 @@ impl Store {
|
||||
continue;
|
||||
}
|
||||
// The same feed twice in one snapshot counts once, at its larger number.
|
||||
let n = e.subscribers.max(0);
|
||||
// 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);
|
||||
@@ -235,7 +288,9 @@ impl Store {
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "instance"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
for name in present.iter().filter(|n| !names.contains(n)) {
|
||||
// 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)
|
||||
@@ -257,7 +312,7 @@ impl Store {
|
||||
f.podcast, CAST(sum(s.subscribers) AS BIGINT) AS subscribers
|
||||
FROM feeds f JOIN snapshots s ON s.feed_id = f.id WHERE f.items IS NOT NULL";
|
||||
|
||||
fn listing(r: &sea_orm::QueryResult) -> Result<Listing> {
|
||||
fn listing(r: &QueryResult) -> Result<Listing> {
|
||||
Ok(Listing {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
@@ -334,6 +389,7 @@ impl Store {
|
||||
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
|
||||
}
|
||||
|
||||
/// Each instance that has sent a snapshot, with how many feeds and people it counted.
|
||||
pub async fn instances(&self) -> Result<Vec<Instance>> {
|
||||
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
|
||||
self.rows(
|
||||
@@ -343,15 +399,29 @@ impl Store {
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| Ok(Instance { name: r.try_get("", "instance")?, feeds: r.try_get("", "feeds")?, subscribers: r.try_get("", "subscribers")? }))
|
||||
.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")? }))
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -376,9 +446,9 @@ impl Store {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One feed, due or not, for an admin's Refresh.
|
||||
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||
Ok(self
|
||||
.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||
.await?
|
||||
.first()
|
||||
.map(|r| -> Result<Due> {
|
||||
@@ -389,14 +459,19 @@ impl Store {
|
||||
last_modified: r.try_get("", "last_modified")?,
|
||||
})
|
||||
})
|
||||
.transpose()?)
|
||||
.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?;
|
||||
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(
|
||||
@@ -407,16 +482,25 @@ impl Store {
|
||||
}
|
||||
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 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| std::cmp::Reverse(e.published));
|
||||
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 })
|
||||
.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,
|
||||
@@ -447,8 +531,8 @@ impl Store {
|
||||
/// 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 {
|
||||
Some(err)
|
||||
.filter(|_| failing_for >= FLAG_AFTER_SECS)
|
||||
(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())
|
||||
}
|
||||
@@ -489,6 +573,17 @@ pub(crate) mod tests {
|
||||
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;
|
||||
@@ -512,7 +607,10 @@ pub(crate) mod tests {
|
||||
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)]);
|
||||
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();
|
||||
@@ -554,7 +652,11 @@ pub(crate) mod tests {
|
||||
#[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) };
|
||||
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();
|
||||
|
||||
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;
|
||||
@@ -4,17 +4,22 @@ 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
|
||||
tokio.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
266
crates/feed/src/fetch.rs
Normal file
266
crates/feed/src/fetch.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! 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. Without a limit an episode's MP3 given as a feed was read
|
||||
/// whole into memory, at every check (#295). Real feeds can be large: a daily show's whole back
|
||||
/// catalogue was 62 MB, and 50 MB here stopped it updating (#357).
|
||||
pub(crate) const MAX_BODY: usize = 200 << 20;
|
||||
|
||||
/// How many times a YouTube feed's spurious failure is asked again, each after its turn.
|
||||
const YOUTUBE_RETRIES: u32 = 2;
|
||||
|
||||
/// The least time between two requests for YouTube feeds from this process. A scan asked for six
|
||||
/// at once, and channels in a row failed together while one asked alone a minute later read
|
||||
/// (#386); asked again a second later, they failed again.
|
||||
const YOUTUBE_GAP: Duration = Duration::from_secs(2);
|
||||
static YOUTUBE_NEXT: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
|
||||
|
||||
/// Waits until `gap` after the last request's turn, one caller at a time: tokio's lock is fair,
|
||||
/// so waiting feeds go in the order they asked. shortcut: 39 channels due together add up to 80 s
|
||||
/// to that scan; a scan's other feeds wait behind them only when they come after in the order.
|
||||
#[tracing::instrument(name = "youtube_turn", skip_all)]
|
||||
async fn take_turn(next: &tokio::sync::Mutex<Option<tokio::time::Instant>>, gap: Duration) {
|
||||
let mut next = next.lock().await;
|
||||
if let Some(at) = *next {
|
||||
tokio::time::sleep_until(at).await;
|
||||
}
|
||||
*next = Some(tokio::time::Instant::now() + gap);
|
||||
}
|
||||
|
||||
/// YouTube answers some feed requests with a 404 or 500 it does not mean, in tens of
|
||||
/// milliseconds, while the next request for the same feed reads; checked once a day, a channel
|
||||
/// then missed most days (#382).
|
||||
fn youtube_spurious(url: &reqwest::Url, status: StatusCode) -> bool {
|
||||
is_youtube_feed(url.as_str()) && (status == StatusCode::NOT_FOUND || status.is_server_error())
|
||||
}
|
||||
|
||||
/// A YouTube channel's or playlist's feed, which fails most reads in streaks that outlast
|
||||
/// `fetch`'s retries, so a scheduler asks it again sooner than other feeds (#386).
|
||||
#[must_use]
|
||||
pub fn is_youtube_feed(url: &str) -> bool {
|
||||
reqwest::Url::parse(url).is_ok_and(|u| u.host_str() == Some("www.youtube.com") && u.path() == "/feeds/videos.xml")
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let mut retries = YOUTUBE_RETRIES;
|
||||
// Ten redirects, and the retries beside them.
|
||||
for _ in 0..10 + YOUTUBE_RETRIES {
|
||||
if is_youtube_feed(url.as_str()) {
|
||||
take_turn(&YOUTUBE_NEXT, YOUTUBE_GAP).await;
|
||||
}
|
||||
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 retries > 0 && youtube_spurious(&url, status) {
|
||||
retries -= 1;
|
||||
continue;
|
||||
}
|
||||
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 youtube_turns_are_a_gap_apart_one_at_a_time() {
|
||||
let (next, gap) = (tokio::sync::Mutex::const_new(None), std::time::Duration::from_millis(50));
|
||||
let start = tokio::time::Instant::now();
|
||||
// The first goes at once; three asking together then go one gap after another.
|
||||
super::take_turn(&next, gap).await;
|
||||
assert!(start.elapsed() < gap);
|
||||
let t = || async {
|
||||
super::take_turn(&next, gap).await;
|
||||
start.elapsed()
|
||||
};
|
||||
let (a, b, c) = tokio::join!(t(), t(), t());
|
||||
let mut at = [a, b, c];
|
||||
at.sort();
|
||||
for (i, at) in at.iter().enumerate() {
|
||||
assert!(*at >= gap * (i as u32 + 1), "turn {} at {at:?}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_youtube_feeds_404_or_5xx_is_asked_again() {
|
||||
use reqwest::StatusCode;
|
||||
let yt = reqwest::Url::parse("https://www.youtube.com/feeds/videos.xml?user=x").unwrap();
|
||||
let other = reqwest::Url::parse("https://example.com/feeds/videos.xml").unwrap();
|
||||
let page = reqwest::Url::parse("https://www.youtube.com/watch?v=x").unwrap();
|
||||
assert!(super::youtube_spurious(&yt, StatusCode::NOT_FOUND));
|
||||
assert!(super::youtube_spurious(&yt, StatusCode::INTERNAL_SERVER_ERROR));
|
||||
assert!(!super::youtube_spurious(&yt, StatusCode::FORBIDDEN));
|
||||
assert!(!super::youtube_spurious(&other, StatusCode::NOT_FOUND));
|
||||
assert!(!super::youtube_spurious(&page, StatusCode::NOT_FOUND));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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);
|
||||
}
|
||||
}
|
||||
173
crates/feed/src/patreon.rs
Normal file
173
crates/feed/src/patreon.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! 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. The same address over http and https is one feed:
|
||||
/// oldvcr's blog was subscribed under both, every post twice (#362). 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 {
|
||||
fn bare(u: &str) -> &str {
|
||||
u.strip_prefix("https://").or_else(|| u.strip_prefix("http://")).unwrap_or(u)
|
||||
}
|
||||
// Parsing both is the slow part, and only Patreon's addresses can differ further and still match.
|
||||
bare(a) == bare(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!(
|
||||
"{}/api/campaigns/{campaign}?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title",
|
||||
crate::api_base("https://www.patreon.com")
|
||||
);
|
||||
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;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_creators_shows_are_asked_of_patreon_by_the_campaign_its_feed_names() {
|
||||
let client = reqwest::Client::new();
|
||||
let creator = format!("{}/creator?auth=x", crate::test_api::base());
|
||||
let (name, shows) = patreon_shows(&client, &creator).await.unwrap();
|
||||
assert_eq!(name.as_deref(), Some("Glass Cannon"));
|
||||
assert_eq!(
|
||||
shows,
|
||||
[("Main Show".to_owned(), format!("{creator}&show=7")), ("Show 8".to_owned(), format!("{creator}&show=8"))],
|
||||
"each show is the creator feed with its own show, on the same credentials"
|
||||
);
|
||||
let nobody = format!("{}/nothing?auth=x", crate::test_api::base());
|
||||
assert!(format!("{:#}", patreon_shows(&client, &nobody).await.unwrap_err()).contains("HTTP 404"));
|
||||
}
|
||||
|
||||
#[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}")));
|
||||
|
||||
assert!(same_feed(
|
||||
"http://oldvcr.blogspot.com/feeds/posts/default",
|
||||
"https://oldvcr.blogspot.com/feeds/posts/default"
|
||||
));
|
||||
assert!(!same_feed("https://a.example/feed", "https://b.example/feed"));
|
||||
assert!(!same_feed("https://a.example/feed", "https://a.example/feed/comments"));
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
99
crates/feed/src/wordpress.rs
Normal file
99
crates/feed/src/wordpress.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! 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!("{}/wp/v2/sites/{host}/posts", crate::api_base("https://public-api.wordpress.com"));
|
||||
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 {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_browser_check_in_place_of_the_feed_is_answered_from_wordpress_coms_api() {
|
||||
let client = reqwest::Client::new();
|
||||
let (got, moved) =
|
||||
crate::fetch(&client, &format!("{}/challenged/feed", crate::test_api::base()), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let crate::Fetched::Body { bytes, .. } = got else { panic!("a body") };
|
||||
let feed = super::parse(&bytes).unwrap();
|
||||
assert_eq!(feed.entries[0].guid, "https://blog.test/?p=1", "the post the site's own feed would have");
|
||||
assert_eq!((feed.entries[0].title.as_deref(), moved), (Some("Hello"), None));
|
||||
}
|
||||
|
||||
#[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>"));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,137 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Sharing an episode gives a link to its page on ipx-play, which anyone can open and play, set up in Admin, Directory, Sharing episodes; private and paid feeds still share their own address (#447).
|
||||
- The iOS app's icon count stays right with the app closed: with `[push]` set to an APNs key, ipx sends each registered phone (`POST /api/push/devices`, removed with `DELETE`) its All Subscriptions unread count through Apple's push service, within a minute of a change (#433). The push also wakes the app for a moment, for its widget to show the same count (#428).
|
||||
- A place in an episode saved from DeadAir's shortened version names its file (`deadair` in `POST .../position`), so it is read in that version's clock even when ipx never handed out its playlist, as for a copy kept on the device or after a restart (#412).
|
||||
- `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389).
|
||||
- The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384).
|
||||
- `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369).
|
||||
- Traces for what had none: each command the worker runs, from the CLI, the socket or the page, with its job inside (#373); the daemon's start, from opening the database to serving (#374); a list's sync and an OPML upload, step by step (#372); and the tap or key in the page that set a trace's requests off, as its root span, from the tap to its last answer (#376).
|
||||
|
||||
### Changed
|
||||
|
||||
- The server's command is `ipxd`, not `ipx`: `ipxd daemon`, `ipxd status`, `ipxd user add` and the rest, in the container as `docker exec iPX ipxd ...`. `ipx` is now the reader in a terminal (#437).
|
||||
|
||||
- The log no longer has a line for every request that went well, over half its size: `/metrics` counts each request and its time by route and status as `ipx_http_request_duration_seconds`, a request that failed keeps its warning, and the admin page's log view still shows them all (#364).
|
||||
- The log no longer has a line for every scan that finishes, a fifth of its lines; the metrics count scans, and the page's log view still shows them (#363).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Subscribing to a feed someone here already reads puts its recent items to your Signals at once, rather than at the judge's next pass, up to five minutes later (#415).
|
||||
- A download a restart stopped, a deploy's for one, is taken up again as the daemon starts, rather than waiting for its feed's next check, an hour or a day away (#410).
|
||||
- A YouTube channel no longer misses most days' videos: YouTube answers some feed requests with a 404 or 500 it does not mean, and ipx now asks again, twice, a second apart, before calling the feed failing (#382), and then every hour for a day where it would have waited a day, since YouTube's failures run for hours; and its YouTube feeds are read one at a time, two seconds apart, since YouTube refuses many asked together (#386).
|
||||
- Syncing an OPML or Patreon list reads who subscribes to it once, not once for every feed it lists: a list of 922 was thousands of the same queries a sync (#377).
|
||||
- A feed you already have, added again by the same address over http rather than https or the other way round, is the one you have, not a second subscription with every item twice. Changing a feed's address to another feed's over the other scheme is refused the same way (#362).
|
||||
- A podcast that was once in an OPML and is now a feed of its own is no longer hidden from All Subscriptions when that OPML is, so its unread items are listed there as the feed list counts them (#358).
|
||||
|
||||
### Security
|
||||
|
||||
- Ten wrong passwords from one address in a quarter hour hold that address off sign-in for the rest of it, answered `429` with how long to wait; before, a guesser could try as fast as the server checked. The address is the one a trusted proxy names last in `X-Forwarded-For`, or the peer's (#409).
|
||||
|
||||
## [1.5.1] - 2026-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- 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).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A feed larger than 50 MB is read again, up to 200 MB: a podcast with a long back catalogue had stopped updating (#357).
|
||||
|
||||
## [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
|
||||
@@ -832,7 +963,11 @@ 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/v1.2.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.5.0...main
|
||||
[1.5.1]: https://git.sdf1.net/rays/ipx/compare/v1.5.0...v1.5.1
|
||||
[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
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "1.2.0"
|
||||
version = "1.5.2-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
# ipxd: `ipx` is the reader in a terminal (crates/tui), which more people type (#437). The
|
||||
# code is the library, still `ipx`, so its log targets keep their names (src/lib.rs).
|
||||
[[bin]]
|
||||
name = "ipxd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ipx-feed.workspace = true
|
||||
ipx-directory.workspace = true
|
||||
ipx-play.workspace = true
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
atom_syndication.workspace = true
|
||||
flate2.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
@@ -17,11 +28,10 @@ futures-util.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
librqbit.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
opml.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
rss.workspace = true
|
||||
sea-orm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.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
|
||||
@@ -38,13 +38,14 @@ impl Keys {
|
||||
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||
self.verify_with(team, aud, token, || fetch(client, team)).await
|
||||
let url = certs(team);
|
||||
self.verify_with(team, aud, token, || fetch(client, &url)).await
|
||||
}
|
||||
|
||||
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||
match fetch(client, team).await {
|
||||
match fetch(client, &certs(team)).await {
|
||||
Ok(set) => self.store(set),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||
}
|
||||
@@ -55,7 +56,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 +79,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 +89,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,21 +104,22 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||
/// Where a team publishes its signing keys.
|
||||
fn certs(team: &str) -> String {
|
||||
format!("https://{team}/cdn-cgi/access/certs")
|
||||
}
|
||||
|
||||
async fn fetch(client: &reqwest::Client, url: &str) -> Result<JwkSet> {
|
||||
client
|
||||
.get(&url)
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
@@ -129,39 +131,71 @@ async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) 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";
|
||||
pub(crate) const TEAM: &str = "team.cloudflareaccess.com";
|
||||
pub(crate) const AUD: &str = "aud-tag";
|
||||
|
||||
fn jwks() -> JwkSet {
|
||||
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
|
||||
}
|
||||
|
||||
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||
pub(crate) 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;
|
||||
pub(crate) fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||
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");
|
||||
pub(crate) 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
|
||||
}
|
||||
|
||||
/// Keys holding the test signer's, as if fetched from the team.
|
||||
pub(crate) fn keys() -> Keys {
|
||||
let keys = Keys::default();
|
||||
keys.store(jwks());
|
||||
keys
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_keys_are_read_from_the_teams_certs_and_a_failure_is_an_error() {
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/cdn-cgi/access/certs",
|
||||
axum::routing::get(|| async { include_str!("../../../tests/data/access-test.jwks.json") }),
|
||||
)
|
||||
.route("/down", axum::routing::get(|| async { axum::http::StatusCode::SERVICE_UNAVAILABLE }));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let set = fetch(&client, &format!("http://{at}/cdn-cgi/access/certs")).await.unwrap();
|
||||
assert_eq!(set.keys.len(), jwks().keys.len());
|
||||
let keys = Keys::default();
|
||||
keys.store(set);
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "the fetched key verifies");
|
||||
|
||||
assert!(fetch(&client, &format!("http://{at}/down")).await.is_err(), "a 503 is not a key set");
|
||||
assert!(fetch(&client, &format!("http://{at}/nothing")).await.is_err(), "nor a 404");
|
||||
assert_eq!(certs(TEAM), "https://team.cloudflareaccess.com/cdn-cgi/access/certs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||
let keys = Keys::default();
|
||||
@@ -181,8 +215,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 +224,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,11 +1,60 @@
|
||||
//! 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::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Pictures in item text ipx has sent, which `/api/art` may then fetch and keep as it does
|
||||
/// artwork, so the page can save them to read offline (#389): only an address some feed's text
|
||||
/// named, as artwork is only one some feed names. The oldest are forgotten past the limit, and
|
||||
/// all of them at a restart, until a list sends them again.
|
||||
static IN_TEXT: LazyLock<Mutex<(HashSet<String>, VecDeque<String>)>> = LazyLock::new(Mutex::default);
|
||||
const IN_TEXT_MAX: usize = 20_000;
|
||||
|
||||
/// Remembers the pictures in a piece of cleaned item text.
|
||||
pub fn name_in_text(html: &str) {
|
||||
let srcs = img_srcs(html);
|
||||
if srcs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = IN_TEXT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for src in srcs {
|
||||
if known.0.insert(src.clone()) {
|
||||
known.1.push_back(src);
|
||||
}
|
||||
}
|
||||
while known.1.len() > IN_TEXT_MAX {
|
||||
if let Some(old) = known.1.pop_front() {
|
||||
known.0.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn named_in_text(url: &str) -> bool {
|
||||
IN_TEXT.lock().unwrap_or_else(|e| e.into_inner()).0.contains(url)
|
||||
}
|
||||
|
||||
/// The web addresses of the `<img src>`s in HTML as ammonia writes it: attributes in double
|
||||
/// quotes, `&`, `"` and no-break spaces escaped, and no `srcset`, which it does not let through.
|
||||
fn img_srcs(html: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (at, _) in html.match_indices("<img ") {
|
||||
let tag = &html[at..html[at..].find('>').map_or(html.len(), |end| at + end)];
|
||||
let Some(start) = tag.find(" src=\"").map(|i| i + 6) else { continue };
|
||||
let Some(len) = tag[start..].find('"') else { continue };
|
||||
let src = tag[start..start + len].replace(""", "\"").replace(" ", "\u{a0}").replace("&", "&");
|
||||
if src.starts_with("https://") || src.starts_with("http://") {
|
||||
out.push(src);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn dir() -> PathBuf {
|
||||
crate::config::data_dir().join("art")
|
||||
}
|
||||
@@ -16,7 +65,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 +74,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 +124,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())
|
||||
@@ -104,6 +153,15 @@ pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_pictures_in_cleaned_text_are_named_and_nothing_else() {
|
||||
let html = r#"<p>a</p><img src="https://img.example/a.jpg?w=1&h=2" alt="x" referrerpolicy="no-referrer"><img alt="no src"><img src="data:image/png;base64,xx"><a href="https://img.example/b.jpg">b</a>"#;
|
||||
assert_eq!(super::img_srcs(html), ["https://img.example/a.jpg?w=1&h=2"]);
|
||||
super::name_in_text(html);
|
||||
assert!(super::named_in_text("https://img.example/a.jpg?w=1&h=2"));
|
||||
assert!(!super::named_in_text("https://img.example/b.jpg"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,34 +30,38 @@ 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 {
|
||||
Some(h) => verify_password(&password, &h),
|
||||
None => {
|
||||
verify_password(&password, &DECOY);
|
||||
false
|
||||
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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
768
crates/ipx/src/catalogue.rs
Normal file
768
crates/ipx/src/catalogue.rs
Normal file
@@ -0,0 +1,768 @@
|
||||
//! 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 `ipxd 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: ipxd 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 ipxd 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 `ipxd 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 'ipxd 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, cfg.feeds[&m.id].group.as_deref()).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),
|
||||
});
|
||||
let group = cfg.feeds[&m.id].group.clone();
|
||||
ctx.store_cfg(cfg).await?;
|
||||
ctx.db.unmanage(&m.id, group.as_deref()).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())
|
||||
}
|
||||
|
||||
/// Puts each feed no longer derived in the group its catalogue entry says, and returns how many
|
||||
/// moved. Made a feed of its own before #358, a show kept its list's group in the database, and
|
||||
/// hiding the list from All Subscriptions hid it too.
|
||||
pub(crate) async fn regroup_unmanaged(ctx: &Ctx) -> Result<usize> {
|
||||
let cfg = ctx.cfg();
|
||||
let mut moved = 0;
|
||||
for (id, group) in ctx.db.unmanaged_in_groups().await? {
|
||||
let Some(f) = cfg.feeds.get(&id) else { continue };
|
||||
if f.group.as_deref() != Some(group.as_str()) {
|
||||
ctx.db.unmanage(&id, f.group.as_deref()).await?;
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// 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 a_feed_that_moved_for_good_takes_its_new_address_unless_another_feed_has_it() {
|
||||
let mut cfg = config::Config::default();
|
||||
for id in ["moving", "other"] {
|
||||
cfg.feeds.insert(id.into(), config::Feed { url: format!("http://x/{id}.xml"), ..feed() });
|
||||
}
|
||||
let ctx = test_ctx(cfg).await;
|
||||
ctx.db.store_config(&ctx.cfg()).await.unwrap();
|
||||
ctx.db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1, 'dee', false); INSERT INTO subscriptions (user_id, feed_id) VALUES (1, 'moving');").await.unwrap();
|
||||
|
||||
follow_move(&ctx, "moving", "https://x/new.xml").await.unwrap();
|
||||
assert_eq!(ctx.cfg().feeds["moving"].url, "https://x/new.xml");
|
||||
let stored = ctx.db.stored_config().await.unwrap().unwrap().1;
|
||||
assert_eq!(stored["moving"].url, "https://x/new.xml", "kept, not only in memory");
|
||||
// The id stays, so whoever subscribed keeps it, with their read state.
|
||||
assert_eq!(ctx.db.i64s_for_test("SELECT user_id FROM subscriptions WHERE feed_id = 'moving'").await, [1]);
|
||||
|
||||
// Onto an address another feed has: two would be the same feed under two ids.
|
||||
follow_move(&ctx, "moving", "https://x/other.xml").await.unwrap();
|
||||
assert_eq!(ctx.cfg().feeds["moving"].url, "https://x/new.xml", "left where it was");
|
||||
follow_move(&ctx, "from-an-opml", "https://x/elsewhere.xml").await.unwrap();
|
||||
assert!(!ctx.cfg().feeds.contains_key("from-an-opml"), "its OPML would only put it back");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipx_add_and_rm_change_the_catalogue() {
|
||||
let base = crate::test_util::empty_or_feed_server().await;
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let url = format!("{base}/feed");
|
||||
add(&ctx, &url, Some("Shows".into()), vec!["rust".into()], None, false).await.unwrap();
|
||||
let (id, f) = ctx.cfg().feeds.iter().next().map(|(id, f)| (id.clone(), f.clone())).expect("added");
|
||||
assert_eq!(id, "t", "named for its title, T");
|
||||
assert_eq!(
|
||||
(f.url.as_str(), f.folder.as_deref(), f.keywords.as_slice()),
|
||||
(url.as_str(), Some("Shows"), &["rust".to_owned()][..])
|
||||
);
|
||||
assert!(!f.listed);
|
||||
|
||||
let again = add(&ctx, &url, None, vec![], None, false).await.unwrap_err();
|
||||
assert!(format!("{again:#}").contains("already subscribed"), "{again:#}");
|
||||
// Asking again to list it, or with a category, changes only that.
|
||||
add(&ctx, &url, None, vec![], Some("News".into()), true).await.unwrap();
|
||||
let f = &ctx.cfg().feeds[&id];
|
||||
assert!(f.listed && f.category.as_deref() == Some("News") && f.folder.as_deref() == Some("Shows"));
|
||||
assert_eq!(ctx.cfg().feeds.len(), 1);
|
||||
|
||||
rm(&ctx, &id).await.unwrap();
|
||||
assert!(ctx.cfg().feeds.is_empty());
|
||||
let stored = ctx.db.stored_config().await.unwrap().map(|s| s.1).unwrap_or_default();
|
||||
assert!(stored.is_empty(), "gone from the database too");
|
||||
}
|
||||
|
||||
#[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_feed_added_again_over_the_other_scheme_is_the_one_already_there() {
|
||||
// oldvcr's blog, subscribed over https and again over http: every post twice (#362).
|
||||
let mut cfg = config::Config::default();
|
||||
let mut f = feed();
|
||||
f.url = "https://oldvcr.blogspot.com/feeds/posts/default".into();
|
||||
cfg.feeds.insert("oldvcr".into(), f);
|
||||
let ctx = test_ctx(cfg).await;
|
||||
let got = add_to_catalogue(&ctx, "http://oldvcr.blogspot.com/feeds/posts/default", None, vec![]).await.unwrap();
|
||||
assert_eq!(got, ("oldvcr".to_owned(), true));
|
||||
assert_eq!(ctx.cfg().feeds.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_show_made_a_feed_of_its_own_is_not_hidden_with_its_list() {
|
||||
// Unsung: promoted out of a Hacker News OPML hidden from All Subscriptions, its unread
|
||||
// counted in the feed list and missing from All Subscriptions (#358).
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert("opml".into(), feed());
|
||||
let ctx = test_ctx(cfg).await;
|
||||
ctx.db
|
||||
.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true);
|
||||
INSERT INTO feeds (id, url) VALUES ('opml','u0');
|
||||
INSERT INTO subscriptions (user_id, feed_id, kept) VALUES (1,'opml',false), (1,'show',true), (1,'old',true);
|
||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('show','1',1), ('old','2',2);",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
ctx.db.upsert_managed("show", "http://x/s.xml", "Show", "opml").await.unwrap();
|
||||
ctx.db.upsert_managed("old", "http://x/o.xml", "Old", "opml").await.unwrap();
|
||||
ctx.db.set_hide_from_all(1, "opml", true).await.unwrap();
|
||||
let unread = async || ctx.db.count_of(1, None, db::Filter::Unread, None).await.unwrap();
|
||||
assert_eq!(unread().await, 0, "the list's shows are hidden with it");
|
||||
|
||||
let show = ctx.db.managed_feeds().await.unwrap().into_iter().find(|m| m.id == "show").unwrap();
|
||||
assert!(promote_kept(&ctx, &show, None).await.unwrap());
|
||||
assert_eq!(unread().await, 1, "a feed of its own is in no list");
|
||||
|
||||
// Promoted before the fix: unmanaged, still in the group, and in no group in config.
|
||||
ctx.db.exec_for_test("UPDATE feeds SET managed = false WHERE id = 'old'").await.unwrap();
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
cfg.feeds.insert("old".into(), feed());
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 1);
|
||||
assert_eq!(unread().await, 2);
|
||||
assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 0, "nothing left to move");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_exported_opml_imports_as_the_admins_subscriptions() {
|
||||
let file = std::env::temp_dir().join(format!("ipx-opml-test-{}.opml", std::process::id()));
|
||||
let from = test_ctx(config::Config::default()).await;
|
||||
list(&from).await.unwrap();
|
||||
let mut cfg = from.edit_cfg().await;
|
||||
for (id, url) in [("one", "http://x/one.xml"), ("two", "http://x/two.xml")] {
|
||||
cfg.feeds
|
||||
.insert(id.into(), crate::test_util::add_one_cfg(&mut config::Config::default(), url, None, vec![]));
|
||||
}
|
||||
from.store_cfg(cfg).await.unwrap();
|
||||
list(&from).await.unwrap();
|
||||
export(&from, &file).await.unwrap();
|
||||
let xml = std::fs::read_to_string(&file).unwrap();
|
||||
assert!(xml.contains("http://x/one.xml") && xml.contains("http://x/two.xml"), "{xml}");
|
||||
|
||||
let to = test_ctx(config::Config::default()).await;
|
||||
let none = import(&to, &file).await.unwrap_err();
|
||||
assert!(format!("{none:#}").contains("no admin account"), "the CLI subscribes the admin: {none:#}");
|
||||
let admin = to.db.create_user("admin", None, true).await.unwrap();
|
||||
import(&to, &file).await.unwrap();
|
||||
let urls: std::collections::BTreeSet<String> = to.cfg().feeds.values().map(|f| f.url.clone()).collect();
|
||||
assert_eq!(urls, ["http://x/one.xml".to_owned(), "http://x/two.xml".to_owned()].into());
|
||||
for id in to.cfg().feeds.keys() {
|
||||
assert_eq!(to.db.subscribers_of(id).await.unwrap(), [(admin, false)], "{id} is the admin's");
|
||||
}
|
||||
import(&to, &file).await.unwrap();
|
||||
assert_eq!(to.cfg().feeds.len(), 2, "importing again adds nothing");
|
||||
|
||||
std::fs::write(&file, "not opml").unwrap();
|
||||
assert!(format!("{:#}", import(&to, &file).await.unwrap_err()).contains("is not OPML"));
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
}
|
||||
280
crates/ipx/src/cli.rs
Normal file
280
crates/ipx/src/cli.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! 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 = "ipxd", 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 | ipxd 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, mut stdin: impl std::io::Read) -> Result<()> {
|
||||
let mut read_password = || -> Result<String> {
|
||||
let mut buf = String::new();
|
||||
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 | ipxd 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: ipxd 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),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_are_added_renamed_given_a_password_and_removed() {
|
||||
let ctx = Arc::new(crate::test_util::test_ctx(crate::config::Config::default()).await);
|
||||
let user = |cmd, stdin: &'static str| user_cmd(&ctx, cmd, stdin.as_bytes());
|
||||
let add = |name: &str, no_password| UserCmd::Add { name: name.into(), admin: false, no_password };
|
||||
|
||||
user(add(" Ray ", false), "first secret\n").await.unwrap();
|
||||
let ray = ctx.db.user_by_name("ray").await.unwrap().expect("lower-cased and trimmed");
|
||||
assert!(ray.is_admin, "the first account runs the place");
|
||||
assert!(
|
||||
crate::auth::check_password("first secret".into(), ray.pass_hash.clone()).await,
|
||||
"the newline is not part of it"
|
||||
);
|
||||
user(add("sam", true), "").await.unwrap();
|
||||
let sam = ctx.db.user_by_name("sam").await.unwrap().unwrap();
|
||||
assert!(!sam.is_admin && sam.pass_hash.is_none(), "signs in through the proxy");
|
||||
|
||||
assert!(user(add("RAY", true), "").await.is_err(), "already exists");
|
||||
assert!(user(add(" ", true), "").await.is_err(), "a name is required");
|
||||
assert!(user(add("dee", false), "").await.is_err(), "no password on stdin");
|
||||
user(UserCmd::List, "").await.unwrap();
|
||||
|
||||
// A new password signs every browser out.
|
||||
let sid = crate::auth::new_session_token();
|
||||
ctx.db.create_session(ray.id, &sid).await.unwrap();
|
||||
user(UserCmd::Passwd { name: "ray".into() }, "second secret").await.unwrap();
|
||||
let ray = ctx.db.user_by_name("ray").await.unwrap().unwrap();
|
||||
assert!(crate::auth::check_password("second secret".into(), ray.pass_hash).await);
|
||||
assert!(ctx.db.session_user(&sid, 86_400).await.unwrap().is_none(), "signed out");
|
||||
assert!(user(UserCmd::Passwd { name: "nobody".into() }, "x").await.is_err());
|
||||
|
||||
let rename = |name: &str, new_name: &str| UserCmd::Rename { name: name.into(), new_name: new_name.into() };
|
||||
assert!(user(rename("sam", "ray"), "").await.is_err(), "taken");
|
||||
assert!(user(rename("sam", "a,b"), "").await.is_err(), "the proxy could never send it");
|
||||
user(rename("sam", "Sam@X.test"), "").await.unwrap();
|
||||
assert_eq!(ctx.db.user_by_name("sam@x.test").await.unwrap().map(|u| u.id), Some(sam.id), "same account");
|
||||
|
||||
user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.unwrap();
|
||||
assert!(ctx.db.user_by_name("sam@x.test").await.unwrap().is_none());
|
||||
assert!(user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.is_err(), "already gone");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -13,6 +15,8 @@ pub struct Config {
|
||||
pub torrent: Torrent,
|
||||
#[serde(default)]
|
||||
pub web: Web,
|
||||
#[serde(default)]
|
||||
pub push: Push,
|
||||
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
|
||||
#[serde(default)]
|
||||
pub feeds: BTreeMap<String, Feed>,
|
||||
@@ -50,6 +54,22 @@ pub struct General {
|
||||
pub directory_token: String,
|
||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||
pub directory_share: bool,
|
||||
/// ipx-play, which keeps a shared episode and gives back a link to its page (#447), as
|
||||
/// `https://play.ipxreader.app` or, on its own network, `http://ipx-play:8102`. Empty: Share
|
||||
/// hands over the episode's own address.
|
||||
pub play_url: String,
|
||||
/// The token ipx-play gave this server. Never sent to the page.
|
||||
pub play_token: String,
|
||||
/// 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)]
|
||||
@@ -61,6 +81,33 @@ pub enum Organize {
|
||||
Date,
|
||||
}
|
||||
|
||||
/// Apple's push service (#433), which keeps the iOS app's icon count exact. Nothing is sent
|
||||
/// unless the key, its id and the team are all set.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct Push {
|
||||
/// The APNs key Apple gives once as a `.p8` file (developer account, Keys).
|
||||
pub key_file: PathBuf,
|
||||
/// That key's id, ten characters, shown beside it.
|
||||
pub key_id: String,
|
||||
/// The Apple developer team the app is signed by, as `8743L8D729`.
|
||||
pub team_id: String,
|
||||
/// The app's bundle id, which Apple checks each device token against.
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
impl Default for Push {
|
||||
fn default() -> Self {
|
||||
Push { key_file: PathBuf::new(), key_id: String::new(), team_id: String::new(), topic: "net.sdf1.ipx".into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Push {
|
||||
pub fn is_set(&self) -> bool {
|
||||
!self.key_file.as_os_str().is_empty() && !self.key_id.is_empty() && !self.team_id.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct Torrent {
|
||||
@@ -178,7 +225,7 @@ pub struct Feed {
|
||||
/// Preferred over `password`: name of an env var holding the password.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password_env: Option<String>,
|
||||
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
|
||||
/// Put in the Directory by an admin (`ipxd add --list`): listed with no subscribers, and kept
|
||||
/// 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,
|
||||
@@ -207,19 +254,18 @@ impl Default for General {
|
||||
directory_url: String::new(),
|
||||
directory_token: String::new(),
|
||||
directory_share: false,
|
||||
play_url: String::new(),
|
||||
play_token: String::new(),
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +339,27 @@ impl Feed {
|
||||
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 {
|
||||
@@ -301,14 +368,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
|
||||
@@ -331,6 +395,18 @@ pub struct Stored {
|
||||
pub directory_token: String,
|
||||
#[serde(default)]
|
||||
pub directory_share: bool,
|
||||
#[serde(default)]
|
||||
pub play_url: String,
|
||||
#[serde(default)]
|
||||
pub play_token: String,
|
||||
#[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 {
|
||||
@@ -338,7 +414,7 @@ fn art_cache_mb() -> u64 {
|
||||
}
|
||||
|
||||
/// `[general]` keys that live in the database once it holds the configuration.
|
||||
const STORED_KEYS: [&str; 9] = [
|
||||
const STORED_KEYS: [&str; 13] = [
|
||||
"schedule",
|
||||
"max_total_gb",
|
||||
"max_age_days",
|
||||
@@ -348,6 +424,10 @@ const STORED_KEYS: [&str; 9] = [
|
||||
"directory_url",
|
||||
"directory_token",
|
||||
"directory_share",
|
||||
"play_url",
|
||||
"play_token",
|
||||
"signals_on_server",
|
||||
"signal_days",
|
||||
];
|
||||
|
||||
impl Stored {
|
||||
@@ -363,6 +443,10 @@ impl Stored {
|
||||
directory_url: g.directory_url.clone(),
|
||||
directory_token: g.directory_token.clone(),
|
||||
directory_share: g.directory_share,
|
||||
play_url: g.play_url.clone(),
|
||||
play_token: g.play_token.clone(),
|
||||
signals_on_server: g.signals_on_server,
|
||||
signal_days: g.signal_days,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +461,10 @@ impl Stored {
|
||||
g.directory_url = self.directory_url;
|
||||
g.directory_token = self.directory_token;
|
||||
g.directory_share = self.directory_share;
|
||||
g.play_url = self.play_url;
|
||||
g.play_token = self.play_token;
|
||||
g.signals_on_server = self.signals_on_server;
|
||||
g.signal_days = self.signal_days;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +505,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(())
|
||||
}
|
||||
@@ -483,20 +571,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 {
|
||||
@@ -514,6 +599,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(
|
||||
@@ -575,10 +676,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:?}");
|
||||
}
|
||||
@@ -650,12 +761,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, private: 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]
|
||||
@@ -679,8 +805,9 @@ mod tests {
|
||||
};
|
||||
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. `ipxd 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"));
|
||||
}
|
||||
}
|
||||
407
crates/ipx/src/daemon.rs
Normal file
407
crates/ipx/src/daemon.rs
Normal file
@@ -0,0 +1,407 @@
|
||||
//! 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;
|
||||
use tracing::Instrument;
|
||||
|
||||
/// 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 `ipxd 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>,
|
||||
start: tracing::Span,
|
||||
) -> 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());
|
||||
}
|
||||
|
||||
// The rest of start-up, in the trace main began (#374): how long a deploy took to answer, and
|
||||
// what held it.
|
||||
let interrupted = async {
|
||||
// A database with nobody in it cannot be signed into.
|
||||
if ctx.db.users().instrument(db::step("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> | ipxd 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().instrument(db::step("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).instrument(db::step("adopt_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"),
|
||||
}
|
||||
}
|
||||
|
||||
let interrupted = match ctx.db.requeue_interrupted().instrument(db::step("requeue_interrupted")).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not requeue interrupted downloads");
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
match catalogue::retire_stranded(&ctx).instrument(db::step("retire_stranded")).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"),
|
||||
}
|
||||
match catalogue::regroup_unmanaged(&ctx).instrument(db::step("regroup_unmanaged")).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(feeds = n, "took feeds of their own out of their old list's group"),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not take feeds of their own out of their old list's group")
|
||||
}
|
||||
}
|
||||
anyhow::Ok(interrupted)
|
||||
}
|
||||
.instrument(start.clone())
|
||||
.await?;
|
||||
|
||||
// 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);
|
||||
|
||||
// Taken again first thing: left for their feeds' next check, a download a deploy stopped
|
||||
// waited an hour, or a day for a daily feed (#410).
|
||||
if !interrupted.is_empty() {
|
||||
tracing::info!(count = interrupted.len(), "taking up downloads interrupted by a restart");
|
||||
}
|
||||
for enclosure in interrupted {
|
||||
if tx_cmd.try_send(Cmd::Download { enclosure }).is_err() {
|
||||
break; // The queue is full; the rest wait for their feeds' next check, as before.
|
||||
}
|
||||
}
|
||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).instrument(start.clone()).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.
|
||||
let feeds = catalogue::subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0);
|
||||
start.in_scope(|| tracing::info!(feeds, "daemon started"));
|
||||
drop(start);
|
||||
|
||||
// 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()));
|
||||
tokio::spawn(crate::push::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:?}"))
|
||||
);
|
||||
// A trace a command, its job inside it (#373): from the CLI or the page alike.
|
||||
let kind = serde_json::to_value(&cmd).ok().and_then(|v| v["cmd"].as_str().map(str::to_owned));
|
||||
let span = tracing::info_span!("command", cmd = kind.as_deref().unwrap_or("?"));
|
||||
if !until_stopped(&ctx, &rx_stop, cli::run(&ctx, cmd).instrument(span)).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,
|
||||
wrong: Default::default(),
|
||||
};
|
||||
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 `ipxd 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 the_daemon_sleeps_at_least_30_s_and_at_most_10_minutes() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
assert_eq!(until_next_scan(&ctx).await, Duration::from_secs(600), "nothing to check: the ceiling");
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert("never-checked".into(), feed());
|
||||
let ctx = test_ctx(cfg).await;
|
||||
assert_eq!(until_next_scan(&ctx).await, Duration::from_secs(30), "due now: the floor, not a spin");
|
||||
}
|
||||
|
||||
#[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();
|
||||
// `ipxd add` and `ipxd 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()]);
|
||||
}
|
||||
}
|
||||
1532
crates/ipx/src/db.rs
1532
crates/ipx/src/db.rs
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,18 @@ use std::sync::Arc;
|
||||
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::db::step;
|
||||
use crate::db;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Ctx;
|
||||
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.
|
||||
@@ -44,7 +50,7 @@ pub const DEFAULT_PAUSE: i64 = 300;
|
||||
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: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
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)]
|
||||
@@ -81,28 +87,28 @@ pub fn id3_len(head: &[u8]) -> u64 {
|
||||
pub async fn kept(
|
||||
path: &Path,
|
||||
plan: &Plan,
|
||||
) -> std::io::Result<(u64, impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>> + use<>)> {
|
||||
) -> std::io::Result<(u64, impl futures_util::Stream<Item = std::io::Result<Bytes>> + use<>)> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
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: std::collections::VecDeque<(u64, u64)> =
|
||||
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(std::io::SeekFrom::Start(off)).await?;
|
||||
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((axum::body::Bytes::from(buf), (f, left))))
|
||||
Ok(Some((Bytes::from(buf), (f, left))))
|
||||
});
|
||||
Ok((len, body))
|
||||
}
|
||||
@@ -164,8 +170,7 @@ pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u6
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
@@ -261,9 +266,7 @@ impl Wanting {
|
||||
}
|
||||
|
||||
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)))
|
||||
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.
|
||||
@@ -344,7 +347,11 @@ pub async fn on_save(
|
||||
secs: i64,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(i64, Option<i64>)> {
|
||||
let plan = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
|
||||
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));
|
||||
@@ -370,7 +377,7 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
}
|
||||
// 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 = std::collections::HashSet::new();
|
||||
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! {
|
||||
@@ -393,7 +400,7 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
async fn have_ffmpeg() -> bool {
|
||||
tokio::process::Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.is_ok_and(|s| s.success())
|
||||
@@ -403,7 +410,7 @@ async fn have_ffmpeg() -> bool {
|
||||
/// 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 std::collections::HashSet<i64>) -> Result<bool> {
|
||||
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;
|
||||
@@ -415,14 +422,15 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
||||
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));
|
||||
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();
|
||||
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;
|
||||
}
|
||||
@@ -431,7 +439,7 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
||||
continue;
|
||||
}
|
||||
let src = crate::download::playable(Path::new(&t.path));
|
||||
let started = std::time::Instant::now();
|
||||
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);
|
||||
@@ -446,11 +454,18 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
ctx.db.store_plans(t.id, &serde_json::to_string(&plans)?).instrument(span.in_scope(|| step("store_plans"))).await?;
|
||||
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 });
|
||||
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,
|
||||
@@ -469,17 +484,26 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
||||
/// 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(step("probe")).await? else { return Ok(None) };
|
||||
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", "-",
|
||||
"-hide_banner",
|
||||
"-nostats",
|
||||
"-i",
|
||||
&path,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-af",
|
||||
&format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"),
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
])
|
||||
.instrument(step("silencedetect"))
|
||||
.instrument(db::step("silencedetect"))
|
||||
.await
|
||||
.context("finding the silences")?;
|
||||
let silences = parse_silences(&log);
|
||||
let frames = frame_bytes(&path).instrument(step("frame_bytes")).await?;
|
||||
let frames = frame_bytes(&path).instrument(db::step("frame_bytes")).await?;
|
||||
let plans: BTreeMap<i64, Plan> = PAUSES
|
||||
.iter()
|
||||
.map(|&pause| {
|
||||
@@ -492,16 +516,15 @@ pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
|
||||
|
||||
/// 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,
|
||||
])
|
||||
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())
|
||||
};
|
||||
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()
|
||||
@@ -511,26 +534,44 @@ async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
||||
/// 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,
|
||||
])
|
||||
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 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,
|
||||
])
|
||||
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()) {
|
||||
@@ -559,7 +600,7 @@ async fn run_out(prog: &str, args: &[&str]) -> Result<String> {
|
||||
|
||||
fn command(prog: &str) -> tokio::process::Command {
|
||||
let mut c = tokio::process::Command::new(prog);
|
||||
c.kill_on_drop(true).stdin(std::process::Stdio::null());
|
||||
c.kill_on_drop(true).stdin(Stdio::null());
|
||||
c
|
||||
}
|
||||
|
||||
@@ -682,9 +723,16 @@ mod tests {
|
||||
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
|
||||
let src = dir.join(format!("t.{ext}"));
|
||||
ffmpeg(&[
|
||||
"-v", "error", "-y", "-f", "lavfi", "-i",
|
||||
"-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(),
|
||||
"-c:a",
|
||||
codec,
|
||||
&src.display().to_string(),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -707,18 +755,36 @@ mod tests {
|
||||
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>()
|
||||
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());
|
||||
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();
|
||||
@@ -730,10 +796,142 @@ mod tests {
|
||||
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());
|
||||
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:
|
||||
@@ -750,6 +948,11 @@ async fn real_file() {
|
||||
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());
|
||||
eprintln!(
|
||||
"{pause} ms: {} cuts, {:.1} min saved, {} pieces",
|
||||
p.cuts.len(),
|
||||
p.saved_ms() as f64 / 60_000.0,
|
||||
p.pieces.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ use std::collections::{HashMap, HashSet};
|
||||
use ipx_directory::{FeedPage, Listing, Me, SnapshotEntry, SnapshotResult};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::{Ctx, Sub, config, feed};
|
||||
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: std::time::Duration = std::time::Duration::from_secs(30 * 60);
|
||||
const SHARE_EVERY: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// What went wrong asking the directory, in words for an admin.
|
||||
#[derive(Debug)]
|
||||
@@ -23,11 +29,15 @@ pub enum DirError {
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DirError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
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::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}"),
|
||||
@@ -55,25 +65,50 @@ pub fn configured(cfg: &config::Config) -> bool {
|
||||
target(cfg).is_some()
|
||||
}
|
||||
|
||||
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
|
||||
/// 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.client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(20));
|
||||
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 {
|
||||
req = req.json(b);
|
||||
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();
|
||||
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:#}")))?;
|
||||
serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {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<'_> {
|
||||
@@ -85,15 +120,37 @@ pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
|
||||
}
|
||||
|
||||
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/feeds", None).await
|
||||
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> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await
|
||||
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> {
|
||||
call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await
|
||||
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.
|
||||
@@ -171,7 +228,7 @@ pub fn snapshot_entries(catalogue: &[Sub], counts: &HashMap<String, i64>) -> Vec
|
||||
|
||||
/// Sends the snapshot now.
|
||||
async fn snapshot(ctx: &Ctx) -> Result<Vec<SnapshotEntry>, DirError> {
|
||||
let catalogue = crate::subscriptions(ctx).await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
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))
|
||||
}
|
||||
@@ -186,10 +243,10 @@ pub async fn share(ctx: &Ctx, entries: &[SnapshotEntry]) -> Result<SnapshotResul
|
||||
}
|
||||
|
||||
/// Sends the snapshot every half hour while sharing is on, and at once when the settings change.
|
||||
pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
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>, std::time::Instant)> = None;
|
||||
let mut last: Option<(Vec<u8>, Instant)> = None;
|
||||
loop {
|
||||
let cfg = ctx.cfg();
|
||||
if cfg.general.directory_share && configured(&cfg) {
|
||||
@@ -198,10 +255,11 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
|
||||
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 = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
|
||||
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, std::time::Instant::now()));
|
||||
last = Some((key, Instant::now()));
|
||||
}
|
||||
Some(got)
|
||||
} else {
|
||||
@@ -212,14 +270,20 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
|
||||
};
|
||||
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"),
|
||||
Ok(r) => tracing::info!(
|
||||
accepted = r.accepted,
|
||||
refused_private = r.refused_private,
|
||||
refused_blocked = r.refused_blocked,
|
||||
"shared with the directory"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
|
||||
}
|
||||
let (result, error) = match got {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
};
|
||||
*ctx.directory_shared.lock().unwrap() = Some(Shared { at: crate::db::now(), result, error });
|
||||
*ctx.directory_shared.lock().expect("a panic elsewhere poisoned this lock") =
|
||||
Some(Shared { at: crate::db::now(), result, error });
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
@@ -233,7 +297,7 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
|
||||
/// 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>, std::time::Instant)>, key: &[u8]) -> bool {
|
||||
fn resend(last: Option<&(Vec<u8>, Instant)>, key: &[u8]) -> bool {
|
||||
last.is_none_or(|(k, at)| k != key || at.elapsed() >= SHARE_EVERY)
|
||||
}
|
||||
|
||||
@@ -241,6 +305,23 @@ fn resend(last: Option<&(Vec<u8>, std::time::Instant)>, key: &[u8]) -> bool {
|
||||
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);
|
||||
|
||||
@@ -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,21 +146,15 @@ 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);
|
||||
@@ -199,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)
|
||||
}
|
||||
|
||||
@@ -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, private: 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");
|
||||
|
||||
@@ -481,4 +457,51 @@ mod tests {
|
||||
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
|
||||
assert!(!blocked(&[], &["anything"]));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn a_download_is_sniffed_for_what_it_is_and_placed_without_clobbering() {
|
||||
use axum::routing::get;
|
||||
let app = axum::Router::new()
|
||||
.route("/ep.mp3", get(|| async { "ID3 an episode" }))
|
||||
.route("/wall.mp3", get(|| async { "\n <!DOCTYPE html><html>Sign in</html>" }))
|
||||
.route("/show.torrent", get(|| async { "d8:announce35:udp://tracker.example:80/announcee" }))
|
||||
.route("/gone.mp3", get(|| async { axum::http::StatusCode::NOT_FOUND }));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
|
||||
let d = std::env::temp_dir().join(format!("ipx-download-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = d.clone();
|
||||
let feed = crate::test_util::feed();
|
||||
let client = reqwest::Client::new();
|
||||
let (c, g, f) = (&client, &cfg, &feed);
|
||||
let get = |path: &str| {
|
||||
let url = format!("http://{at}{path}");
|
||||
async move { download(c, g, f, &url, |_, _| {}).await }
|
||||
};
|
||||
|
||||
let mut seen = vec![];
|
||||
let ep =
|
||||
download(&client, &cfg, &feed, &format!("http://{at}/ep.mp3"), |done, _| seen.push(done)).await.unwrap();
|
||||
assert!(matches!(ep.kind, Kind::File));
|
||||
assert_eq!((ep.name.as_str(), ep.bytes), ("ep.mp3", 14));
|
||||
assert_eq!(seen.last(), Some(&14), "progress reaches the whole file");
|
||||
let first = place(&ep, &d.join("Show")).await.unwrap();
|
||||
assert_eq!(first, d.join("Show/ep.mp3"));
|
||||
assert_eq!(std::fs::read(&first).unwrap(), b"ID3 an episode");
|
||||
|
||||
// The same name again, from another enclosure: beside the first, not over it.
|
||||
let again = get("/ep.mp3").await.unwrap();
|
||||
assert_eq!(place(&again, &d.join("Show")).await.unwrap(), d.join("Show/ep-2.mp3"));
|
||||
assert!(first.exists());
|
||||
|
||||
let wall = get("/wall.mp3").await.err().unwrap();
|
||||
assert!(format!("{wall:#}").contains("HTML page"), "{wall:#}");
|
||||
assert!(!d.join(".ipx-incomplete/wall.mp3").exists(), "a login page is not kept");
|
||||
|
||||
assert!(matches!(get("/show.torrent").await.unwrap().kind, Kind::Torrent));
|
||||
assert!(format!("{:#}", get("/gone.mp3").await.err().unwrap()).contains("404"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -291,6 +386,27 @@ pub mod sessions {
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// A phone with the iOS app, signed in as someone, to send the icon's unread count to (#433).
|
||||
pub mod push_devices {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "push_devices")]
|
||||
pub struct Model {
|
||||
/// The token Apple gave the app on that phone, in hex.
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub token: String,
|
||||
pub user_id: i64,
|
||||
/// From a debug build, which Apple's sandbox serves rather than production.
|
||||
pub sandbox: bool,
|
||||
pub created: i64,
|
||||
/// The count last sent, so a minute in which it did not change sends nothing.
|
||||
pub badge: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
|
||||
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
|
||||
pub mod api_tokens {
|
||||
|
||||
@@ -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,12 +52,38 @@ 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 },
|
||||
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 },
|
||||
Reaped { path: String, bytes: u64 },
|
||||
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,
|
||||
@@ -45,12 +94,53 @@ pub enum Event {
|
||||
#[serde(default)]
|
||||
forced: bool,
|
||||
},
|
||||
ReapDone { files: usize, bytes: u64 },
|
||||
Status { feeds: usize, pending: i64, downloaded: i64 },
|
||||
Error { msg: String },
|
||||
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 { .. })
|
||||
}
|
||||
@@ -78,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")
|
||||
}
|
||||
@@ -89,7 +178,10 @@ 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::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
||||
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)"),
|
||||
})
|
||||
}
|
||||
@@ -120,7 +212,7 @@ pub enum Command {
|
||||
enclosure: i64,
|
||||
},
|
||||
Status,
|
||||
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm`
|
||||
/// Read the catalogue and server settings again from the database, which `ipxd add`, `rm`
|
||||
/// and `import` change in a process of their own. A daemon keeps them in memory, and with
|
||||
/// its old copy wrote a removed feed back, and dropped an added one, at its next change of
|
||||
/// its own (#120). Answered, as status is, with the counts after it.
|
||||
@@ -153,6 +245,7 @@ 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());
|
||||
crate::metrics::STATS.event(&e);
|
||||
// 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"));
|
||||
@@ -162,7 +255,9 @@ impl Emitter {
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
@@ -173,18 +268,20 @@ impl Event {
|
||||
/// 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 {
|
||||
use crate::feed::{redact, redact_text};
|
||||
match &mut self {
|
||||
Event::DownloadError { url, msg, .. } => {
|
||||
*url = redact(url);
|
||||
*msg = redact_text(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::TorrentDeferred { url, .. } => *url = redact(url),
|
||||
Event::FeedMoved { from, to, .. } => {
|
||||
*from = redact(from);
|
||||
*to = redact(to);
|
||||
*from = crate::feed::redact(from);
|
||||
*to = crate::feed::redact(to);
|
||||
}
|
||||
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = redact_text(msg),
|
||||
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = crate::feed::redact_text(msg),
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
@@ -211,7 +308,9 @@ fn log_event(e: &Event, wire: bool) {
|
||||
_ => (None, None),
|
||||
};
|
||||
let routine = match e {
|
||||
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
||||
// A scan's end: ipx_jobs_total, ipx_feed_fetches_total and ipx_last_scan_age_seconds hold it,
|
||||
// and since scans became one or two feeds at a time it was a fifth of the log (#363).
|
||||
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } | Event::ScanDone { .. } => true,
|
||||
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
||||
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
||||
}
|
||||
@@ -230,7 +329,7 @@ fn log_event(e: &Event, wire: bool) {
|
||||
)
|
||||
};
|
||||
}
|
||||
// `ipx status`'s answer: a reply on the socket rather than work done, so it
|
||||
// `ipxd 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}"));
|
||||
@@ -257,11 +356,9 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
||||
/// time, and a healthcheck left waiting behind a scan or a long download timed out and called a
|
||||
/// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in
|
||||
/// 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.
|
||||
/// any `ipxd 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(
|
||||
@@ -273,14 +370,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 {
|
||||
@@ -335,7 +430,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) => {
|
||||
@@ -367,7 +462,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.
|
||||
@@ -399,6 +494,31 @@ 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);
|
||||
@@ -425,9 +545,28 @@ mod tests {
|
||||
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}") });
|
||||
// What every open page is sent, whoever's feed it is (#238).
|
||||
for _ in 0..3 {
|
||||
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}");
|
||||
@@ -438,7 +577,10 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -477,14 +619,8 @@ 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"));
|
||||
}
|
||||
|
||||
@@ -492,14 +628,7 @@ mod tests {
|
||||
fn only_completion_events_end_a_client_session() {
|
||||
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]
|
||||
|
||||
131
crates/ipx/src/lib.rs
Normal file
131
crates/ipx/src/lib.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! ipx, the daemon and its command line. The code is in the modules below; `main` starts it.
|
||||
//! A library under the binary `ipxd` (src/main.rs) so that its modules, and so its log targets
|
||||
//! (`ipx::scan`, `ipx::http`), the filters (`ipx=info`) and the dashboard's queries on them, kept
|
||||
//! their names when the command was renamed (#437).
|
||||
|
||||
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 play;
|
||||
mod push;
|
||||
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;
|
||||
use tracing::Instrument;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
// The daemon only: a trace for every `ipxd 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());
|
||||
// For the spans the page reports of itself (#376), which ipx builds with the page's own ids.
|
||||
if let Some(p) = &otel {
|
||||
opentelemetry::global::set_tracer_provider(p.clone());
|
||||
}
|
||||
|
||||
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 { .. });
|
||||
// The daemon's start-up is a trace (#374), from opening the database to serving; daemon()
|
||||
// carries it on.
|
||||
let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() };
|
||||
let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await }
|
||||
.instrument(start.clone())
|
||||
.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 =
|
||||
async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await }
|
||||
.instrument(start.clone())
|
||||
.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, start).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, std::io::stdin()).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
|
||||
}
|
||||
294
crates/ipx/src/lists.rs
Normal file
294
crates/ipx/src/lists.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
//! 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, db, feed};
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tracing::Instrument;
|
||||
|
||||
/// 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.
|
||||
#[tracing::instrument(name = "opml_import", skip_all, fields(feeds = found.len(), user = user_id))]
|
||||
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.
|
||||
/// A trace of its own steps (#372): one long `feed` span said nothing of where a slow sync went.
|
||||
#[tracing::instrument(name = "list_sync", skip_all, fields(list = %parent_id, listed = listed.len()))]
|
||||
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().instrument(db::step("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().instrument(db::step("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();
|
||||
|
||||
async {
|
||||
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);
|
||||
}
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.instrument(db::step("upsert_listed"))
|
||||
.await?;
|
||||
|
||||
// Whoever subscribes to the OPML subscribes to what it lists: that is what taking a
|
||||
// subscription means. Their own feeds are untouched. Who reads the list is asked once: asked
|
||||
// for each listed feed, a list of 922 was thousands of the same queries a sync (#377).
|
||||
async {
|
||||
let mut readers = vec![];
|
||||
for user in ctx.db.users().await? {
|
||||
if ctx.db.subscription(user.id, parent_id).await?.is_some() {
|
||||
readers.push(user.id);
|
||||
}
|
||||
}
|
||||
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 &readers {
|
||||
ctx.db.subscribe(user, &id).await?;
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.instrument(db::step("subscribe_readers"))
|
||||
.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();
|
||||
async {
|
||||
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");
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.instrument(db::step("drop_unlisted"))
|
||||
.await?;
|
||||
|
||||
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);
|
||||
|
||||
120
crates/ipx/src/logging.rs
Normal file
120
crates/ipx/src/logging.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
//! Log lines as JSON for Loki, carrying the trace and span they belong to.
|
||||
|
||||
use crate::logbuf;
|
||||
use ipx_directory::otel::json_layer;
|
||||
use std::io::IsTerminal;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// 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>) {
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
subscriber(json, otel, std::io::stderr).init();
|
||||
}
|
||||
|
||||
/// The subscriber `init` installs, writing to `out`: the test's lines are production's.
|
||||
fn subscriber<W>(
|
||||
json: bool,
|
||||
otel: Option<&opentelemetry_sdk::trace::SdkTracerProvider>,
|
||||
out: W,
|
||||
) -> impl tracing::Subscriber + Send + Sync
|
||||
where
|
||||
W: for<'w> tracing_subscriber::fmt::MakeWriter<'w> + Send + Sync + 'static,
|
||||
{
|
||||
// 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;
|
||||
// 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 ui_filter = EnvFilter::try_from_env("IPX_UI_LOG").unwrap_or_else(|_| "ipx=debug".into());
|
||||
// Boxed rather than an Option of each: Option's Layer does not pass on_register_dispatch on,
|
||||
// which the JSON layer finds each line's trace by (#408).
|
||||
let stderr: Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync> = if json {
|
||||
json_layer(out, true).with_filter(stderr_filter()).boxed()
|
||||
} else {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(out)
|
||||
// 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())
|
||||
.boxed()
|
||||
};
|
||||
tracing_subscriber::registry().with(stderr).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"))
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ipc::{Emitter, Event};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// What the layer wrote, kept for the test to read.
|
||||
struct Lines(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for Lines {
|
||||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(b);
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scans_events_are_json_lines_with_the_fields_grafana_reads() {
|
||||
let buf = Arc::new(Mutex::new(vec![]));
|
||||
let b = buf.clone();
|
||||
let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
|
||||
let sub = subscriber(true, Some(&provider), move || Lines(b.clone()));
|
||||
// Scoped, where the formatter once found no dispatcher and no line named its trace (#408).
|
||||
tracing::subscriber::with_default(sub, || {
|
||||
let out = Emitter::terminal();
|
||||
out.emit(Event::FeedDone { feed: "log-test-f".into(), new: 2, downloaded: 1, failed: 0, torrents: 0 });
|
||||
let scan = tracing::info_span!("scan");
|
||||
let _in = scan.enter();
|
||||
out.emit(Event::DownloadDone {
|
||||
feed: "log-test-f".into(),
|
||||
enclosure: 7,
|
||||
url: "https://x.test/a.mp3".into(),
|
||||
path: "/downloads/a.mp3".into(),
|
||||
bytes: 1234,
|
||||
});
|
||||
out.emit(Event::FeedError { feed: "log-test-g".into(), msg: "HTTP 404 Not Found".into() });
|
||||
});
|
||||
let text = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
|
||||
let lines: Vec<serde_json::Value> = text.lines().map(|l| serde_json::from_str(l).expect(l)).collect();
|
||||
let find = |ev: &str| {
|
||||
lines
|
||||
.iter()
|
||||
.find(|l| l["ev"] == ev && l["feed"].as_str().is_some_and(|f| f.starts_with("log-test-")))
|
||||
.unwrap_or_else(|| panic!("no {ev} in {text}"))
|
||||
};
|
||||
|
||||
let done = find("feed_done");
|
||||
assert_eq!((done["feed"].as_str(), done["new"].as_u64()), (Some("log-test-f"), Some(2)), "{done}");
|
||||
assert!(done.get("trace_id").is_none(), "outside a span there is no trace to name: {done}");
|
||||
let saved = find("download_done");
|
||||
assert_eq!(saved["bytes"].as_u64(), Some(1234), "{saved}");
|
||||
let failed = find("feed_error");
|
||||
assert_eq!((failed["feed"].as_str(), failed["msg"].as_str()), (Some("log-test-g"), Some("HTTP 404 Not Found")));
|
||||
assert!(failed["error.type"].is_string(), "the failure's kind: {failed}");
|
||||
for l in [saved, failed] {
|
||||
let (t, s) = (l["trace_id"].as_str().unwrap_or(""), l["span_id"].as_str().unwrap_or(""));
|
||||
assert!(t.len() == 32 && s.len() == 16, "inside a span a line names its trace: {l}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,17 @@
|
||||
//! 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 ipx_directory::metrics::{Histograms, SECONDS, label, method};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering::Relaxed};
|
||||
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
|
||||
@@ -40,12 +48,38 @@ pub struct Stats {
|
||||
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, std::time::Instant)>>,
|
||||
job: std::sync::Mutex<Option<(&'static str, Instant)>>,
|
||||
/// Jobs finished, by kind: how many, and their seconds all told.
|
||||
jobs: std::sync::Mutex<std::collections::BTreeMap<&'static str, (u64, f64)>>,
|
||||
jobs: std::sync::Mutex<BTreeMap<&'static str, (u64, f64)>>,
|
||||
/// Feed failures by kind, `error.type` (#368).
|
||||
feed_failures: std::sync::Mutex<BTreeMap<String, u64>>,
|
||||
pub new_items: AtomicU64,
|
||||
pub downloads_done: AtomicU64,
|
||||
pub downloads_failed: AtomicU64,
|
||||
pub deadair_saved_seconds: AtomicU64,
|
||||
}
|
||||
|
||||
/// Requests answered, by method, route and status (#364).
|
||||
pub static REQUESTS: Histograms = Histograms::new(SECONDS);
|
||||
/// Each statement's time once it has a connection, by its first word (#367).
|
||||
pub static QUERIES: Histograms = Histograms::new(SECONDS);
|
||||
/// The wait for a connection, as hand-written SQL in db.rs sees it (#367).
|
||||
pub static DB_WAIT: Histograms = Histograms::new(SECONDS);
|
||||
/// The page's timings, by `ev`: a click to its list drawn, play to sound, a stall (#369).
|
||||
pub static PAGE: Histograms = Histograms::new(&[0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0]);
|
||||
|
||||
impl Stats {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
@@ -64,15 +98,76 @@ impl Stats {
|
||||
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(std::collections::BTreeMap::new()),
|
||||
jobs: std::sync::Mutex::new(BTreeMap::new()),
|
||||
feed_failures: std::sync::Mutex::new(BTreeMap::new()),
|
||||
new_items: AtomicU64::new(0),
|
||||
downloads_done: AtomicU64::new(0),
|
||||
downloads_failed: AtomicU64::new(0),
|
||||
deadair_saved_seconds: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts a request answered. `route` is the pattern matched, "" for none, so a scanner's
|
||||
/// made-up paths are one row.
|
||||
pub fn request(&self, m: &str, route: &str, status: u16, secs: f64) {
|
||||
REQUESTS.observe(&format!("method=\"{}\",route=\"{}\",status=\"{status}\"", method(m), label(route)), secs);
|
||||
}
|
||||
|
||||
/// Counts what an event says happened (#368): one place, as every event passes `emit`.
|
||||
pub fn event(&self, e: &crate::ipc::Event) {
|
||||
use crate::ipc::Event;
|
||||
match e {
|
||||
Event::FeedDone { new, .. } => {
|
||||
self.new_items.fetch_add(*new as u64, Relaxed);
|
||||
}
|
||||
Event::FeedError { msg, .. } => {
|
||||
let kind = crate::feed::failure_kind(msg).0;
|
||||
*self
|
||||
.feed_failures
|
||||
.lock()
|
||||
.expect("a panic elsewhere poisoned this lock")
|
||||
.entry(kind.to_string())
|
||||
.or_default() += 1;
|
||||
}
|
||||
Event::DownloadDone { .. } => {
|
||||
self.downloads_done.fetch_add(1, Relaxed);
|
||||
}
|
||||
Event::DownloadError { .. } => {
|
||||
self.downloads_failed.fetch_add(1, Relaxed);
|
||||
}
|
||||
Event::DeadairDone { saved_s, .. } => {
|
||||
self.deadair_saved_seconds.fetch_add((*saved_s).max(0) as u64, Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// What `who` has been billed on `key`: items judged, Signals fit, input and output tokens.
|
||||
#[cfg(test)]
|
||||
pub fn billed(&self, who: &str, key: &'static str) -> [f64; 4] {
|
||||
self.people.lock().unwrap().get(&(who.to_owned(), key)).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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().unwrap() = Some((kind, std::time::Instant::now()));
|
||||
*self.job.lock().expect("a panic elsewhere poisoned this lock") = Some((kind, Instant::now()));
|
||||
Job(self)
|
||||
}
|
||||
}
|
||||
@@ -81,8 +176,8 @@ pub struct Job(&'static Stats);
|
||||
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
if let Some((kind, since)) = self.0.job.lock().unwrap().take() {
|
||||
let mut jobs = self.0.jobs.lock().unwrap();
|
||||
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();
|
||||
@@ -90,6 +185,11 @@ impl Drop for Job {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -106,17 +206,17 @@ impl Drop for Busy {
|
||||
|
||||
/// What the metrics are read from.
|
||||
pub struct Sources {
|
||||
pub ctx: Arc<crate::Ctx>,
|
||||
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: tokio::runtime::Handle,
|
||||
pub started: std::time::Instant,
|
||||
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<std::net::SocketAddr> {
|
||||
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}"))?;
|
||||
@@ -129,13 +229,16 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
||||
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let app = axum::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)) }
|
||||
}),
|
||||
);
|
||||
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 {
|
||||
@@ -153,22 +256,59 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
||||
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",
|
||||
"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_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)]);
|
||||
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)]);
|
||||
@@ -176,9 +316,24 @@ pub fn render(s: &Sources) -> String {
|
||||
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)]);
|
||||
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
|
||||
@@ -187,38 +342,179 @@ pub fn render(s: &Sources) -> String {
|
||||
/// 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().unwrap();
|
||||
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())]);
|
||||
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().unwrap().clone();
|
||||
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<_>>();
|
||||
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_http_requests_in_flight",
|
||||
"Web requests being answered.",
|
||||
&[("", st.http_in_flight.load(Relaxed) as f64)],
|
||||
);
|
||||
REQUESTS.render(
|
||||
out,
|
||||
"ipx_http_request_duration_seconds",
|
||||
"Web requests answered and the seconds each took, by method, route and status.",
|
||||
);
|
||||
QUERIES.render(out, "ipx_db_query_seconds", "Statements' time once they had a connection, by their first word.");
|
||||
DB_WAIT.render(out, "ipx_db_wait_seconds", "The wait for a database connection, as hand-written SQL saw it.");
|
||||
PAGE.render(
|
||||
out,
|
||||
"ipx_page_seconds",
|
||||
"What the page timed: a click to its list drawn, play to sound, a stall's wait.",
|
||||
);
|
||||
let failures = st.feed_failures.lock().expect("a panic elsewhere poisoned this lock").clone();
|
||||
let labels: Vec<String> = failures.keys().map(|k| format!("error_type=\"{}\"", label(k))).collect();
|
||||
counter(
|
||||
out,
|
||||
"ipx_feed_failures_total",
|
||||
"Feeds that failed to fetch or parse, by kind: an HTTP status, dns, timeout, tls, parse...",
|
||||
&labels.iter().zip(failures.values()).map(|(l, v)| (l.as_str(), *v as f64)).collect::<Vec<_>>(),
|
||||
);
|
||||
counter(out, "ipx_new_items_total", "New items scans found.", &[("", st.new_items.load(Relaxed) as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_downloads_total",
|
||||
"Downloads finished, saved or failed.",
|
||||
&[
|
||||
("result=\"saved\"", st.downloads_done.load(Relaxed) as f64),
|
||||
("result=\"failed\"", st.downloads_failed.load(Relaxed) as f64),
|
||||
],
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_deadair_saved_seconds_total",
|
||||
"Seconds of silence DeadAir found in the files it looked at.",
|
||||
&[("", st.deadair_saved_seconds.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)]);
|
||||
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)]);
|
||||
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_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_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)]);
|
||||
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),
|
||||
@@ -254,6 +550,54 @@ fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str,
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names the dashboard and the alert rules query, read out of what generates them.
|
||||
fn queried() -> std::collections::BTreeSet<String> {
|
||||
let src = [include_str!("../../../grafana/dashboard.py"), include_str!("../../../grafana/alerts.py")].concat();
|
||||
src.match_indices("ipx_")
|
||||
.filter(|(i, _)| !src[..*i].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_'))
|
||||
.map(|(i, _)| {
|
||||
let rest = &src[i..];
|
||||
let end = rest.find(|c: char| !(c.is_ascii_lowercase() || c == '_')).unwrap_or(rest.len());
|
||||
(&rest[..end], rest[end..].starts_with("::"))
|
||||
})
|
||||
// A log target (ipx_directory::http) is not a metric; the coverage numbers are
|
||||
// node-exporter's, written by scripts/coverage-metrics.ts.
|
||||
.filter(|(n, target)| !target && !n.starts_with("ipx_coverage_"))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn every_metric_grafana_queries_is_served() {
|
||||
let ctx = Arc::new(crate::test_util::test_ctx(crate::config::Config::default()).await);
|
||||
let s = Sources {
|
||||
ctx,
|
||||
cmds: tokio::sync::mpsc::channel(1).0,
|
||||
events: tokio::sync::broadcast::channel(1).0,
|
||||
playing: Default::default(),
|
||||
runtime: Handle::current(),
|
||||
started: Instant::now(),
|
||||
};
|
||||
// Written only once known (a count taken, a scan finished, a job under way): know them.
|
||||
for v in [&STATS.feeds, &STATS.pending, &STATS.downloaded, &STATS.last_scan_done, &STATS.signals_last_pass] {
|
||||
v.fetch_max(1, Relaxed);
|
||||
}
|
||||
let _job = STATS.job("scan");
|
||||
let out = render(&s);
|
||||
let served: std::collections::BTreeSet<&str> =
|
||||
out.lines().filter_map(|l| l.strip_prefix("# TYPE ")).filter_map(|l| l.split(' ').next()).collect();
|
||||
let names = queried();
|
||||
assert!(names.len() > 30, "the dashboard's names were not found: {names:?}");
|
||||
let missing: Vec<&String> = names
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
let base = ["_bucket", "_count", "_sum"].iter().find_map(|x| n.strip_suffix(x)).unwrap_or(n);
|
||||
!served.contains(n.as_str()) && !served.contains(base)
|
||||
})
|
||||
.collect();
|
||||
assert!(missing.is_empty(), "queried by grafana/ but not served by /metrics: {missing:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gauges_are_written_as_prometheus_reads_them() {
|
||||
let mut out = String::new();
|
||||
@@ -276,17 +620,61 @@ mod tests {
|
||||
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}");
|
||||
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_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 "));
|
||||
|
||||
for secs in [0.03, 0.04, 2.0] {
|
||||
T.request("GET", "/api/feeds", 200, secs);
|
||||
}
|
||||
T.request("PROPFIND", "", 404, 0.001);
|
||||
T.event(&crate::ipc::Event::FeedError { feed: "f".into(), msg: "connecting: timed out".into() });
|
||||
T.event(&crate::ipc::Event::FeedDone { feed: "f".into(), new: 3, downloaded: 0, failed: 0, torrents: 0 });
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 0, 0, 0);
|
||||
let l = "method=\"GET\",route=\"/api/feeds\",status=\"200\"";
|
||||
for line in [
|
||||
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"0.025\"}} 0\n"),
|
||||
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"0.05\"}} 2\n"),
|
||||
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"2.5\"}} 3\n"),
|
||||
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"+Inf\"}} 3\n"),
|
||||
format!("ipx_http_request_duration_seconds_count{{{l}}} 3\n"),
|
||||
"ipx_http_request_duration_seconds_count{method=\"other\",route=\"\",status=\"404\"} 1\n".into(),
|
||||
"ipx_new_items_total 3\n".into(),
|
||||
"# TYPE ipx_feed_failures_total counter\n".into(),
|
||||
] {
|
||||
assert!(out.contains(&line), "{line} in {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -294,4 +682,51 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
121
crates/ipx/src/play.rs
Normal file
121
crates/ipx/src/play.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Sharing an episode as a page (#447): ipx-play keeps the episode and hands back a short link,
|
||||
//! `play.ipxreader.app/e/<id>`, which anyone can open, as Overcast's links are. Only an episode of
|
||||
//! a public feed goes: the same rule as what the directory is sent, so a private or paid feed's
|
||||
//! file address, which carries someone's access, never leaves this server.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Why there is no link, which the page answers by sharing the episode's own address as it did
|
||||
/// before.
|
||||
#[derive(Debug)]
|
||||
pub enum NoLink {
|
||||
/// No ipx-play address or token in Admin, Sharing.
|
||||
NotSetUp,
|
||||
/// A private or paid feed, or one in a private OPML.
|
||||
Private,
|
||||
/// Nothing to play: a post, or a feed's picture.
|
||||
NoFile,
|
||||
NotFound,
|
||||
/// ipx-play did not answer, or refused.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
fn target(ctx: &Ctx) -> Option<(String, String)> {
|
||||
let cfg = ctx.cfg();
|
||||
let url = cfg.general.play_url.trim().trim_end_matches('/');
|
||||
let token = cfg.general.play_token.trim();
|
||||
(!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned()))
|
||||
}
|
||||
|
||||
/// The file to share: the one asked for, or the first that is sound, since a page plays audio.
|
||||
fn pick(encs: &[crate::db::EncRow], asked: Option<i64>) -> Option<&crate::db::EncRow> {
|
||||
match asked {
|
||||
Some(id) => encs.iter().find(|e| e.id == id),
|
||||
None => encs.iter().find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "share_episode", skip(ctx))]
|
||||
pub async fn share(
|
||||
ctx: &Ctx,
|
||||
user_id: i64,
|
||||
feed_id: &str,
|
||||
guid: &str,
|
||||
enc: Option<i64>,
|
||||
) -> Result<ipx_play::Shared, NoLink> {
|
||||
let (base, token) = target(ctx).ok_or(NoLink::NotSetUp)?;
|
||||
let failed = |e: anyhow::Error| NoLink::Failed(format!("{e:#}"));
|
||||
let row = ctx.db.entry(user_id, feed_id, guid).await.map_err(failed)?.ok_or(NoLink::NotFound)?;
|
||||
// Public by the directory's own rule, so the two cannot disagree on what is private.
|
||||
let catalogue = crate::catalogue::subscriptions(ctx).await.map_err(failed)?;
|
||||
let sub = catalogue.iter().find(|s| s.id == feed_id).ok_or(NoLink::NotFound)?;
|
||||
let public = crate::directory::snapshot_entries(&catalogue, &HashMap::new());
|
||||
if !public.iter().any(|p| p.url == sub.cfg.url) {
|
||||
return Err(NoLink::Private);
|
||||
}
|
||||
let file = pick(&row.enclosures, enc).ok_or(NoLink::NoFile)?;
|
||||
let feed = ctx.db.feed_summary(feed_id).await.map_err(failed)?;
|
||||
let episode = ipx_play::Episode {
|
||||
feed_url: sub.cfg.url.clone(),
|
||||
guid: guid.to_owned(),
|
||||
feed_title: feed.title,
|
||||
feed_image: row.image.clone().or(feed.image),
|
||||
title: row.title.clone(),
|
||||
// As the feed sent it: ipx-play sanitizes it for its page.
|
||||
description: row.description.clone(),
|
||||
link: row.link.clone(),
|
||||
published: row.published,
|
||||
duration: row.duration,
|
||||
audio_url: file.url.clone(),
|
||||
mime: file.mime.clone(),
|
||||
};
|
||||
let mut req = ctx
|
||||
.local_client
|
||||
.post(format!("{base}/v1/shares"))
|
||||
.bearer_auth(token)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.json(&episode);
|
||||
// Its spans join this request's trace.
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| NoLink::Failed(format!("reaching ipx-play: {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));
|
||||
return Err(NoLink::Failed(format!("ipx-play answered {status}: {}", msg.unwrap_or_default())));
|
||||
}
|
||||
resp.json().await.map_err(|e| NoLink::Failed(format!("ipx-play's answer did not read: {e:#}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn enc(id: i64, mime: Option<&str>) -> crate::db::EncRow {
|
||||
crate::db::EncRow {
|
||||
id,
|
||||
deadair: None,
|
||||
deadair_pending: false,
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
url: format!("https://x/{id}"),
|
||||
mime: mime.map(Into::into),
|
||||
length: None,
|
||||
path: None,
|
||||
state: "done".into(),
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_shared_is_the_one_asked_for_or_the_first_sound() {
|
||||
let encs = [enc(1, Some("image/jpeg")), enc(2, Some("video/mp4")), enc(3, Some("audio/mpeg"))];
|
||||
assert_eq!(pick(&encs, None).map(|e| e.id), Some(3));
|
||||
assert_eq!(pick(&encs, Some(2)).map(|e| e.id), Some(2));
|
||||
assert!(pick(&encs, Some(9)).is_none());
|
||||
assert!(pick(&encs[..2], None).is_none(), "a picture and a video are not a page's to play");
|
||||
}
|
||||
}
|
||||
322
crates/ipx/src/push.rs
Normal file
322
crates/ipx/src/push.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! The iOS app's icon count, through Apple's push service (#433).
|
||||
//!
|
||||
//! The app shows All Subscriptions' unread count on its icon. While it is open the page keeps it
|
||||
//! right; while it is closed only iOS's background refresh could, which runs when iOS chooses and
|
||||
//! for this app had been rarely. So ipx sends it: once a minute, to each phone whose person's count
|
||||
//! has changed since the last one sent, a push carrying the badge and nothing else, which shows no
|
||||
//! alert and makes no sound.
|
||||
//!
|
||||
//! The app registers its phone through the page (`POST /api/push/devices`), signed in as the page
|
||||
//! is: the address the app's player uses lets through only the player's own paths.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::entity::push_devices;
|
||||
use anyhow::{Context, Result};
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::Instrument;
|
||||
|
||||
/// How often counts are compared. A minute behind is near enough for a number on an icon.
|
||||
// ponytail: one count query per person with a phone, every minute; fine for a household, move to
|
||||
// sending from where read state and new items are written if the people with phones grow many.
|
||||
const EVERY: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Apple refuses a signing token older than an hour, and one made more often than every twenty
|
||||
/// minutes; so each is used for forty.
|
||||
const TOKEN_LIFE: Duration = Duration::from_secs(40 * 60);
|
||||
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
let mut signer = Signer::default();
|
||||
loop {
|
||||
if ctx.cfg().push.is_set() {
|
||||
let pass = pass(&ctx, &mut signer, None).instrument(tracing::info_span!("push"));
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
done = pass => if let Err(e) = done {
|
||||
tracing::warn!(error = %format!("{e:#}"), "push: the icon counts could not be sent");
|
||||
},
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
_ = tokio::time::sleep(EVERY) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends each phone its person's count, if it changed. `apns` stands in for Apple's address in tests.
|
||||
async fn pass(ctx: &Ctx, signer: &mut Signer, apns: Option<&str>) -> Result<()> {
|
||||
let devices = ctx.db.push_devices().instrument(crate::db::step("push_devices")).await?;
|
||||
let mut counts: HashMap<i64, i64> = HashMap::new();
|
||||
for d in devices {
|
||||
let n = match counts.get(&d.user_id) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
let n = ctx
|
||||
.db
|
||||
.count_of(d.user_id, None, crate::db::Filter::Unread, None)
|
||||
.instrument(crate::db::step("unread_in_all"))
|
||||
.await?;
|
||||
counts.insert(d.user_id, n);
|
||||
n
|
||||
}
|
||||
};
|
||||
if d.badge == Some(n) {
|
||||
continue;
|
||||
}
|
||||
match send(ctx, signer, &d, n, apns).await? {
|
||||
Sent::Yes => ctx.db.set_push_badge(&d.token, n).await?,
|
||||
Sent::Gone => {
|
||||
tracing::info!(user = d.user_id, "push: a phone's app is gone, so its token is forgotten");
|
||||
ctx.db.forget_push_device(&d.token).await?;
|
||||
}
|
||||
// Tried again next minute: the badge kept is still the old one.
|
||||
Sent::NotNow => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum Sent {
|
||||
Yes,
|
||||
/// Apple says the token is not the app's: uninstalled, or for another app.
|
||||
Gone,
|
||||
NotNow,
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "push.send", skip_all, fields(user = d.user_id, badge = n, status))]
|
||||
async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64, apns: Option<&str>) -> Result<Sent> {
|
||||
let cfg = ctx.cfg();
|
||||
let host = if d.sandbox { "https://api.sandbox.push.apple.com" } else { "https://api.push.apple.com" };
|
||||
let r = ctx
|
||||
.client
|
||||
.post(format!("{}/3/device/{}", apns.unwrap_or(host), d.token))
|
||||
.bearer_auth(signer.token(&cfg.push)?)
|
||||
.header("apns-topic", &cfg.push.topic)
|
||||
// A badge alone is still an alert to Apple: background pushes cannot set one.
|
||||
.header("apns-push-type", "alert")
|
||||
// Not urgent, so iOS may hold it to save the battery.
|
||||
.header("apns-priority", "5")
|
||||
// content-available wakes the app for a moment, which the badge alone does not, so its
|
||||
// widget's count follows the icon's (#428). iOS rations those wakes; the badge is set
|
||||
// whether or not one comes.
|
||||
.json(&serde_json::json!({ "aps": { "badge": n, "content-available": 1 } }))
|
||||
.send()
|
||||
.await
|
||||
.context("reaching Apple's push service")?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("status", status);
|
||||
if status == 200 {
|
||||
return Ok(Sent::Yes);
|
||||
}
|
||||
let reason = r.json::<serde_json::Value>().await.ok().and_then(|b| b["reason"].as_str().map(str::to_owned));
|
||||
let reason = reason.unwrap_or_default();
|
||||
Ok(match (status, reason.as_str()) {
|
||||
(410, _) | (400, "BadDeviceToken" | "DeviceTokenNotForTopic") => Sent::Gone,
|
||||
(403, "ExpiredProviderToken" | "InvalidProviderToken") => {
|
||||
signer.forget();
|
||||
tracing::warn!(status, reason, "push: Apple refused the signing token; check [push] in config.toml");
|
||||
Sent::NotNow
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(status, reason, "push: Apple did not take an icon count");
|
||||
Sent::NotNow
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The token each request is signed with: the `.p8` key's ES256 signature over the team and the
|
||||
/// time, made again as Apple's limits ask.
|
||||
#[derive(Default)]
|
||||
struct Signer {
|
||||
key: Option<EncodingKey>,
|
||||
made: Option<(Instant, String)>,
|
||||
}
|
||||
|
||||
impl Signer {
|
||||
fn token(&mut self, cfg: &crate::config::Push) -> Result<String> {
|
||||
if let Some((at, t)) = &self.made
|
||||
&& at.elapsed() < TOKEN_LIFE
|
||||
{
|
||||
return Ok(t.clone());
|
||||
}
|
||||
if self.key.is_none() {
|
||||
let pem = std::fs::read(&cfg.key_file)
|
||||
.with_context(|| format!("reading the APNs key {}", cfg.key_file.display()))?;
|
||||
self.key = Some(p8_key(&pem)?);
|
||||
}
|
||||
let token = sign(self.key.as_ref().context("loaded above")?, &cfg.key_id, &cfg.team_id, crate::db::now())?;
|
||||
self.made = Some((Instant::now(), token.clone()));
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Made again, key and all, on the next send: the key file or its id may have changed.
|
||||
fn forget(&mut self) {
|
||||
*self = Signer::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The key in a `.p8` file: PKCS#8 in PEM, which this build of jsonwebtoken does not read, so
|
||||
/// the base64 between its two lines is decoded here rather than a crate added for one file.
|
||||
fn p8_key(pem: &[u8]) -> Result<EncodingKey> {
|
||||
let text = std::str::from_utf8(pem).context("the APNs key is not a .p8 key")?;
|
||||
let body: String = text.lines().filter(|l| !l.starts_with("-----")).collect::<String>();
|
||||
let der = base64_decode(body.trim()).context("the APNs key is not a .p8 key")?;
|
||||
Ok(EncodingKey::from_ec_der(&der))
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let value = |c: u8| match c {
|
||||
b'A'..=b'Z' => Some(c - b'A'),
|
||||
b'a'..=b'z' => Some(c - b'a' + 26),
|
||||
b'0'..=b'9' => Some(c - b'0' + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
};
|
||||
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
||||
acc = (acc << 6) | u32::from(value(c)?);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn sign(key: &EncodingKey, key_id: &str, team_id: &str, now: i64) -> Result<String> {
|
||||
let mut h = Header::new(Algorithm::ES256);
|
||||
h.kid = Some(key_id.to_owned());
|
||||
Ok(jsonwebtoken::encode(&h, &serde_json::json!({ "iss": team_id, "iat": now }), key)?)
|
||||
}
|
||||
|
||||
/// A device token as the app sends it: Apple's bytes in hex. Checked before it goes into a URL.
|
||||
pub fn valid_token(t: &str) -> bool {
|
||||
(64..=200).contains(&t.len()) && t.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{DecodingKey, Validation};
|
||||
|
||||
// A P-256 key made for this test alone, in the PKCS#8 form Apple's .p8 files are.
|
||||
const KEY: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9x/aoouMrdbCsEAr
|
||||
hLPm+4G3w8qNIHc9V7CMphVZL6WhRANCAATTwL52tAoDzAH+RcFS4cd9azD7xvGT
|
||||
MnDzi5bfjGbabd5hc7sDHiTremhTWJzPs1TdGgtqd9RDURqCHr8jfP+B
|
||||
-----END PRIVATE KEY-----";
|
||||
// Its public half, as the point a JWK names.
|
||||
const PUB_X: &str = "08C-drQKA8wB_kXBUuHHfWsw-8bxkzJw84uW34xm2m0";
|
||||
const PUB_Y: &str = "3mFzuwMeJOt6aFNYnM-zVN0aC2p31ENRGoIevyN8_4E";
|
||||
|
||||
#[test]
|
||||
fn the_signing_token_is_what_apple_reads() {
|
||||
let key = p8_key(KEY.as_bytes()).unwrap();
|
||||
let t = sign(&key, "ABC123DEFG", "8743L8D729", 1_800_000_000).unwrap();
|
||||
let h = jsonwebtoken::decode_header(&t).unwrap();
|
||||
assert_eq!(h.alg, Algorithm::ES256);
|
||||
assert_eq!(h.kid.as_deref(), Some("ABC123DEFG"));
|
||||
let mut v = Validation::new(Algorithm::ES256);
|
||||
v.validate_exp = false;
|
||||
v.required_spec_claims.clear();
|
||||
let c =
|
||||
jsonwebtoken::decode::<serde_json::Value>(&t, &DecodingKey::from_ec_components(PUB_X, PUB_Y).unwrap(), &v)
|
||||
.unwrap()
|
||||
.claims;
|
||||
assert_eq!(c, serde_json::json!({ "iss": "8743L8D729", "iat": 1_800_000_000 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_as_pem_has_it() {
|
||||
assert_eq!(base64_decode("aXB4").unwrap(), b"ipx");
|
||||
assert_eq!(base64_decode("aXB4IQ==").unwrap(), b"ipx!");
|
||||
assert!(base64_decode("a!b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_hex_of_a_tokens_length_is_a_device() {
|
||||
assert!(valid_token(&"ab12".repeat(16)));
|
||||
assert!(!valid_token("ab12"));
|
||||
assert!(!valid_token(&format!("{}/../x", "a".repeat(64))));
|
||||
assert!(!valid_token(&"g".repeat(64)));
|
||||
}
|
||||
|
||||
/// A stand-in for Apple's push service: each device token's first two characters say how it
|
||||
/// answers. Every request is recorded as (token, badge, signed).
|
||||
async fn apns() -> (String, Arc<std::sync::Mutex<Vec<(String, i64, bool)>>>) {
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
let seen: Arc<std::sync::Mutex<Vec<(String, i64, bool)>>> = Default::default();
|
||||
let log = seen.clone();
|
||||
let app = axum::Router::new().route(
|
||||
"/3/device/{token}",
|
||||
axum::routing::post(
|
||||
move |axum::extract::Path(token): axum::extract::Path<String>,
|
||||
headers: HeaderMap,
|
||||
axum::Json(body): axum::Json<serde_json::Value>| {
|
||||
let signed = headers.get("authorization").is_some_and(|v| v.as_bytes().starts_with(b"Bearer e"))
|
||||
&& headers.get("apns-topic").is_some_and(|v| v == "net.sdf1.ipx");
|
||||
log.lock().unwrap().push((token.clone(), body["aps"]["badge"].as_i64().unwrap(), signed));
|
||||
let (status, reason) = match &token[..2] {
|
||||
"aa" => (StatusCode::OK, ""),
|
||||
"bb" => (StatusCode::GONE, "Unregistered"),
|
||||
"cc" => (StatusCode::BAD_REQUEST, "BadDeviceToken"),
|
||||
"dd" => (StatusCode::FORBIDDEN, "ExpiredProviderToken"),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError"),
|
||||
};
|
||||
async move { (status, axum::Json(serde_json::json!({ "reason": reason }))) }
|
||||
},
|
||||
),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
(format!("http://{at}"), seen)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pass_sends_changed_counts_and_forgets_phones_apple_says_are_gone() {
|
||||
let key = std::env::temp_dir().join(format!("ipx-push-test-{}.p8", std::process::id()));
|
||||
std::fs::write(&key, KEY).unwrap();
|
||||
let cfg = crate::config::Config {
|
||||
push: crate::config::Push {
|
||||
key_file: key.clone(),
|
||||
key_id: "ABC123DEFG".into(),
|
||||
team_id: "8743L8D729".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = crate::test_util::test_ctx(cfg).await;
|
||||
let me = ctx.db.create_user("me", None, false).await.unwrap();
|
||||
for t in ["aa", "bb", "cc", "dd", "ee"] {
|
||||
ctx.db.add_push_device(me, &t.repeat(32), false).await.unwrap();
|
||||
}
|
||||
let (at, seen) = apns().await;
|
||||
let mut signer = Signer::default();
|
||||
|
||||
pass(&ctx, &mut signer, Some(&at)).await.unwrap();
|
||||
let first = seen.lock().unwrap().clone();
|
||||
assert_eq!(first.len(), 5, "every phone gets its first count");
|
||||
assert!(first.iter().all(|(_, badge, signed)| *badge == 0 && *signed), "{first:?}");
|
||||
|
||||
let left: std::collections::BTreeMap<String, Option<i64>> =
|
||||
ctx.db.push_devices().await.unwrap().into_iter().map(|d| (d.token[..2].to_owned(), d.badge)).collect();
|
||||
assert_eq!(
|
||||
left,
|
||||
[("aa".into(), Some(0)), ("dd".into(), None), ("ee".into(), None)].into(),
|
||||
"a 410 or a bad token forgets the phone; a refusal keeps the old badge to try again"
|
||||
);
|
||||
|
||||
seen.lock().unwrap().clear();
|
||||
pass(&ctx, &mut signer, Some(&at)).await.unwrap();
|
||||
let mut again: Vec<String> = seen.lock().unwrap().iter().map(|(t, ..)| t[..2].to_owned()).collect();
|
||||
again.sort();
|
||||
assert_eq!(again, ["dd", "ee"], "a count Apple took is not sent again until it changes");
|
||||
let _ = std::fs::remove_file(key);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,13 @@ use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::{Candidate, Db, now, step};
|
||||
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;
|
||||
@@ -39,19 +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.
|
||||
// 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.
|
||||
for (id, path) in db.missing_files().instrument(step("missing_files")).await? {
|
||||
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?;
|
||||
}
|
||||
@@ -59,35 +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().instrument(step("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 candidates = db.reap_candidates().instrument(step("reap_candidates")).await?;
|
||||
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).instrument(step("remove_aged")).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).instrument(step("prune_entries")).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).instrument(step("remove_over_quota")).await?;
|
||||
report.bytes_freed += remove(db, c, dry_run).instrument(db::step("remove_over_quota")).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,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());
|
||||
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);
|
||||
@@ -114,19 +118,44 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// `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)]
|
||||
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]
|
||||
@@ -183,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();
|
||||
@@ -212,13 +242,167 @@ 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");
|
||||
}
|
||||
|
||||
/// A scratch downloads folder of its own, so tests running at once never share files.
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ipx-reap-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn file(dir: &Path, name: &str, len: usize) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, vec![0u8; len]).unwrap();
|
||||
p.display().to_string()
|
||||
}
|
||||
|
||||
async fn state(db: &Db, id: i64) -> (String, Option<String>) {
|
||||
let s = db
|
||||
.strings_for_test(&format!("SELECT state || '|' || coalesce(path, '') FROM enclosures WHERE id = {id}"))
|
||||
.await;
|
||||
let (state, path) = s[0].split_once('|').unwrap();
|
||||
(state.into(), Some(path.to_string()).filter(|p| !p.is_empty()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_age_pass_deletes_old_files_and_a_dry_run_only_counts_them() {
|
||||
let dir = scratch("age");
|
||||
let (old, new) = (file(&dir, "old.mp3", 100), file(&dir, "new.mp3", 200));
|
||||
let (day, now) = (86_400, db::now());
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(&format!(
|
||||
"INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||
('f', 'old', {old_seen}), ('f', 'new', {now}), ('f', 'stale', {old_seen}), ('f', 'held', {old_seen});
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'old', 'u1', '{old}', 100, 'done', {old_at}),
|
||||
(2, 'f', 'new', 'u2', '{new}', 200, 'done', {new_at}),
|
||||
(3, 'f', 'held', 'u3', NULL, 0, 'held', {old_at});",
|
||||
old_seen = now - 40 * day,
|
||||
old_at = now - 10 * day,
|
||||
new_at = now - day,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = dir.clone();
|
||||
cfg.general.max_age_days = 7;
|
||||
|
||||
let dry = run(&cfg, &db, true).await.unwrap();
|
||||
assert_eq!(dry.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1], "a held item has no file to take");
|
||||
assert_eq!((dry.bytes_freed, dry.entries_pruned), (100, 0), "counted, and nothing pruned");
|
||||
assert!(Path::new(&old).exists(), "a dry run deletes nothing");
|
||||
assert_eq!(state(&db, 1).await.0, "done");
|
||||
|
||||
let r = run(&cfg, &db, false).await.unwrap();
|
||||
assert_eq!(r.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1]);
|
||||
assert_eq!(r.bytes_freed, 100);
|
||||
assert!(!Path::new(&old).exists() && Path::new(&new).exists(), "only the old file goes");
|
||||
assert_eq!(state(&db, 1).await, ("reaped".into(), None), "the row stays, so it is not fetched again");
|
||||
assert_eq!(state(&db, 2).await, ("done".into(), Some(new)));
|
||||
assert_eq!(state(&db, 3).await.0, "held");
|
||||
// The reaped one's entry and the one that never had a file; not held, whose row has no
|
||||
// path, nor new.
|
||||
assert_eq!(r.entries_pruned, 3, "old, stale and held are past the cutoff and have no file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_quota_the_oldest_go_and_the_age_pass_is_not_counted_twice() {
|
||||
let dir = scratch("quota");
|
||||
let paths: Vec<String> = (1..=4).map(|i| file(&dir, &format!("{i}.mp3"), 10 * i)).collect();
|
||||
let (day, now) = (86_400, db::now());
|
||||
let db = Db::memory().await.unwrap();
|
||||
// Recorded sizes, not the files' own: a dry run counts these. 1 is past the age limit.
|
||||
db.exec_for_test(&format!(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'a', 'u1', '{}', 2000000, 'done', {}),
|
||||
(2, 'f', 'b', 'u2', '{}', 1000000, 'done', {}),
|
||||
(3, 'f', 'c', 'u3', '{}', 1000000, 'done', {}),
|
||||
(4, 'f', 'd', 'u4', '{}', 1000000, 'done', {});",
|
||||
paths[0],
|
||||
now - 40 * day,
|
||||
paths[1],
|
||||
now - 5 * day,
|
||||
paths[2],
|
||||
now - 3 * day,
|
||||
paths[3],
|
||||
now - day,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = dir.clone();
|
||||
cfg.general.max_age_days = 30;
|
||||
// 53.7 MB less the 50 MB pad leaves about 1.26 MB. Without 1, the other three are 3 MB:
|
||||
// two must go. Counting 1 again would make it 5 MB and take all three.
|
||||
cfg.general.max_total_gb = 0.05;
|
||||
|
||||
let dry = run(&cfg, &db, true).await.unwrap();
|
||||
assert_eq!(dry.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1]);
|
||||
assert_eq!(dry.over_quota.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2, 3], "oldest first");
|
||||
assert_eq!(dry.bytes_freed, 4_000_000);
|
||||
assert!(paths.iter().all(|p| Path::new(p).exists()), "a dry run deletes nothing");
|
||||
|
||||
let r = run(&cfg, &db, false).await.unwrap();
|
||||
assert_eq!(r.over_quota.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2, 3]);
|
||||
assert_eq!(r.bytes_freed, 10 + 20 + 30, "what was on disk, not what was recorded");
|
||||
assert_eq!(paths.iter().map(|p| Path::new(p).exists()).collect::<Vec<_>>(), [false, false, false, true]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_already_gone_is_reaped_and_one_that_will_not_delete_is_left() {
|
||||
let dir = scratch("stuck");
|
||||
let now = db::now();
|
||||
let db = Db::memory().await.unwrap();
|
||||
// procfs refuses an unlink even from root, and the file is there, so it is a candidate.
|
||||
db.exec_for_test(&format!(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'gone', 'u1', '{}', 100, 'done', 1),
|
||||
(2, 'f', 'stuck', 'u2', '/proc/version', 100, 'done', 1);",
|
||||
dir.join("gone.mp3").display(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = dir.clone();
|
||||
cfg.general.max_age_days = 1;
|
||||
assert!(now > 86_400);
|
||||
|
||||
let r = run(&cfg, &db, false).await.unwrap();
|
||||
assert_eq!(r.reconciled, 1, "the missing file is noticed");
|
||||
assert_eq!(state(&db, 1).await, ("reaped".into(), None));
|
||||
assert_eq!(r.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2]);
|
||||
assert_eq!(r.bytes_freed, 0, "nothing was freed");
|
||||
assert_eq!(state(&db, 2).await, ("done".into(), Some("/proc/version".into())), "still claims its file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
@@ -233,7 +417,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");
|
||||
|
||||
1112
crates/ipx/src/scan.rs
Normal file
1112
crates/ipx/src/scan.rs
Normal file
File diff suppressed because it is too large
Load Diff
855
crates/ipx/src/signals.rs
Normal file
855
crates/ipx/src/signals.rs
Normal file
@@ -0,0 +1,855 @@
|
||||
//! 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> {
|
||||
#[cfg(test)]
|
||||
let url = tests::jev();
|
||||
#[cfg(not(test))]
|
||||
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)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Calls the stand-in has had, by the key they came with.
|
||||
static CALLS: Mutex<BTreeMap<String, usize>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
/// A stand-in for TypeSafe's Jev, as the browser suite's fixture server is: on a thread and
|
||||
/// runtime of its own, so it outlives any one test's. It answers by the key: `refused-` as a
|
||||
/// key it does not know, `slow-` as over its limits, `malformed-` as a request it cannot read,
|
||||
/// `offlist-` with an icon it was not offered; anything else judges an item to fit when its
|
||||
/// text says "fits", and picks the icon labelled robot.
|
||||
pub(crate) fn jev() -> String {
|
||||
static AT: OnceLock<String> = OnceLock::new();
|
||||
AT.get_or_init(|| {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.set_nonblocking(true).unwrap();
|
||||
let at = l.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
rt.block_on(async move {
|
||||
let app = axum::Router::new().route("/", axum::routing::post(stand_in));
|
||||
axum::serve(tokio::net::TcpListener::from_std(l).unwrap(), app).await.unwrap();
|
||||
});
|
||||
});
|
||||
format!("http://{at}/")
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn stand_in(
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::Json(body): axum::Json<serde_json::Value>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
let key =
|
||||
headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").trim_start_matches("Bearer ");
|
||||
*CALLS.lock().unwrap().entry(key.to_owned()).or_default() += 1;
|
||||
let refuse = |status: u16, detail: &str| {
|
||||
(
|
||||
axum::http::StatusCode::from_u16(status).unwrap(),
|
||||
[("retry-after", "1")],
|
||||
format!(r#"{{"detail":"{detail}"}}"#),
|
||||
)
|
||||
.into_response()
|
||||
};
|
||||
if key.starts_with("refused-") {
|
||||
return refuse(401, "invalid api key");
|
||||
} else if key.starts_with("slow-") {
|
||||
return refuse(429, "rate limited");
|
||||
} else if key.starts_with("malformed-") {
|
||||
return refuse(422, "criteria should be an object");
|
||||
}
|
||||
let fits = body["state"].as_str().is_some_and(|s| s.contains("fits"));
|
||||
let mut answers = serde_json::Map::new();
|
||||
for (id, q) in body["questions"].as_object().unwrap() {
|
||||
let a = if q["type"] == "choice" {
|
||||
let robot = q["criteria"].as_object().unwrap().iter().find(|(_, l)| l.as_str() == Some("robot"));
|
||||
let pick = if key.starts_with("offlist-") { "solid/nope" } else { robot.map_or("", |(k, _)| k) };
|
||||
serde_json::json!({ "choice": pick })
|
||||
} else {
|
||||
serde_json::json!({ "noul": if fits { 0.9 } else { 0.1 } })
|
||||
};
|
||||
answers.insert(id.clone(), a);
|
||||
}
|
||||
axum::Json(serde_json::json!({ "answers": answers, "usage": { "input_tokens": 100, "output_tokens": 3 } }))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn calls_with(key: &str) -> usize {
|
||||
CALLS.lock().unwrap().get(key).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Someone with a key, a Signal and a feed of their own holding these items; their Signal's id.
|
||||
async fn person(ctx: &Ctx, name: &str, key: &str, items: &[(&str, &str)]) -> i64 {
|
||||
let id = ctx.db.create_user(name, None, false).await.unwrap();
|
||||
ctx.db.set_typesafe_key(id, Some(key)).await.unwrap();
|
||||
let feed = format!("feed-{name}");
|
||||
let mut sql = format!("INSERT INTO subscriptions (user_id, feed_id) VALUES ({id}, '{feed}');");
|
||||
for (guid, title) in items {
|
||||
sql += &format!(
|
||||
"INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('{feed}', '{guid}', '{title}', {});",
|
||||
db::now()
|
||||
);
|
||||
}
|
||||
ctx.db.exec_for_test(&sql).await.unwrap();
|
||||
ctx.db.add_signal(id, "Rust", &["rust".into()], None).await.unwrap()
|
||||
}
|
||||
|
||||
async fn verdicts(ctx: &Ctx, signal: i64) -> Vec<String> {
|
||||
ctx.db
|
||||
.strings_for_test(&format!(
|
||||
"SELECT guid || CASE WHEN hit THEN ' fits' ELSE ' not' END FROM signal_verdicts
|
||||
WHERE signal_id = {signal} ORDER BY guid"
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pass_judges_each_new_item_once_keeps_its_verdicts_and_bills_its_owner() {
|
||||
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
|
||||
let signal = person(&ctx, "sig-ann", "good-ann", &[("a", "this one fits"), ("b", "about gardening")]).await;
|
||||
assert!(!pass(&ctx).await.unwrap(), "two items is not a full batch");
|
||||
assert_eq!(verdicts(&ctx, signal).await, ["a fits", "b not"]);
|
||||
assert_eq!(calls_with("good-ann"), 2, "a call an item");
|
||||
assert!(!pass(&ctx).await.unwrap());
|
||||
assert_eq!(calls_with("good-ann"), 2, "nothing is asked twice");
|
||||
// Items judged, Signals fit, input and output tokens, on the person's own key (#278).
|
||||
assert_eq!(STATS.billed("sig-ann", "own"), [2.0, 1.0, 200.0, 6.0]);
|
||||
assert_eq!(STATS.billed("sig-ann", "server"), [0.0; 4]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_key_stops_its_owners_judging_and_nobody_elses() {
|
||||
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
|
||||
let ann = person(&ctx, "sig-ann2", "good-ann2", &[("a", "fits")]).await;
|
||||
let bob = person(&ctx, "sig-bob", "refused-bob", &[("b", "fits")]).await;
|
||||
let refused_before = STATS.jev_calls[1].load(Relaxed);
|
||||
assert!(pass(&ctx).await.unwrap(), "goes round again for whoever is left");
|
||||
assert_eq!(verdicts(&ctx, ann).await, ["a fits"]);
|
||||
assert!(verdicts(&ctx, bob).await.is_empty(), "left unjudged, not judged a miss");
|
||||
let bob_id = ctx.db.user_by_name("sig-bob").await.unwrap().unwrap().id;
|
||||
assert!(ctx.signals_refused.lock().unwrap().contains(&bob_id));
|
||||
assert!(STATS.jev_calls[1].load(Relaxed) > refused_before, "counted as key_refused");
|
||||
assert!(!pass(&ctx).await.unwrap());
|
||||
assert_eq!(calls_with("refused-bob"), 1, "not sent again until a new key is saved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_typesafes_limits_the_pass_stops_and_the_next_call_waits_its_turn() {
|
||||
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
|
||||
let signal = person(&ctx, "sig-cy", "slow-cy", &[("c", "fits")]).await;
|
||||
let limited_before = STATS.jev_calls[3].load(Relaxed);
|
||||
assert!(!pass(&ctx).await.unwrap(), "stops rather than going straight round");
|
||||
assert!(verdicts(&ctx, signal).await.is_empty());
|
||||
assert!(STATS.jev_calls[3].load(Relaxed) > limited_before, "counted as rate_limited");
|
||||
// Retry-After said a second: the next call to Jev, anyone's, waits for it.
|
||||
let started = std::time::Instant::now();
|
||||
check_key(&ctx, "sig-cy", "good-cy").await.unwrap();
|
||||
assert!(started.elapsed() >= Duration::from_millis(900), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_request_typesafe_cannot_read_stops_the_pass_and_is_counted() {
|
||||
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
|
||||
let signal = person(&ctx, "sig-dee", "malformed-dee", &[("d", "fits"), ("e", "fits")]).await;
|
||||
let malformed_before = STATS.jev_calls[2].load(Relaxed);
|
||||
assert!(!pass(&ctx).await.unwrap(), "every other item's would fail the same way (#257)");
|
||||
assert!(verdicts(&ctx, signal).await.is_empty());
|
||||
assert!(STATS.jev_calls[2].load(Relaxed) > malformed_before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_key_is_checked_and_an_icon_is_one_jev_was_offered() {
|
||||
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
|
||||
check_key(&ctx, "sig-eve", "good-eve").await.unwrap();
|
||||
assert!(refuses_key(&check_key(&ctx, "sig-eve", "refused-eve").await.unwrap_err()));
|
||||
assert_eq!(STATS.billed("sig-eve", "own")[2], 100.0, "a check costs tokens too");
|
||||
|
||||
let offered = [("solid/robot".to_owned(), "robot".to_owned()), ("solid/leaf".to_owned(), "leaf".to_owned())];
|
||||
let words = ["ai".to_owned()];
|
||||
let icon = pick_icon(&ctx, "sig-eve", "good-eve", "AI", &words, None, &offered).await.unwrap();
|
||||
assert_eq!(icon, "solid/robot");
|
||||
assert!(pick_icon(&ctx, "sig-eve", "offlist-eve", "AI", &words, None, &offered).await.is_err(), "not offered");
|
||||
assert_eq!(STATS.billed("sig-eve", "server")[2], 200.0, "icons are on the server's key");
|
||||
}
|
||||
|
||||
#[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 `ipxd 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,11 +27,11 @@ 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()),
|
||||
// A test's swarm is two sessions and a tracker on 127.0.0.1, not the public DHT or the LAN.
|
||||
dht: if cfg!(test) { None } else { Some(Default::default()) },
|
||||
disable_local_service_discovery: cfg!(test),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -42,13 +43,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 +59,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 +75,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 +84,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 +118,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 +151,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 {
|
||||
@@ -157,6 +187,136 @@ pub fn ratio(uploaded: u64, total: u64) -> f64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn free_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
/// A swarm on 127.0.0.1: a seeding session holding `file`, and a web server answering for the
|
||||
/// .torrent (`/ep.torrent`) and as its tracker, naming the seed as the one peer. The server's
|
||||
/// address, and the seeding session, which stops seeding when dropped.
|
||||
async fn swarm(file: &Path) -> (SocketAddr, Arc<Session>) {
|
||||
let web = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = web.local_addr().unwrap();
|
||||
let seed_port = free_port();
|
||||
let made = librqbit::create_torrent(
|
||||
file,
|
||||
librqbit::CreateTorrentOptions {
|
||||
name: None,
|
||||
trackers: vec![format!("http://{at}/announce")],
|
||||
piece_length: Some(16 * 1024),
|
||||
},
|
||||
&librqbit::spawn_utils::BlockingSpawner::new(2),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let torrent = made.as_bytes().unwrap();
|
||||
// Compact peers: the address's four bytes, then the port's two.
|
||||
let mut peers = b"d8:intervali60e5:peers6:".to_vec();
|
||||
peers.extend_from_slice(&[127, 0, 0, 1]);
|
||||
peers.extend_from_slice(&seed_port.to_be_bytes());
|
||||
peers.push(b'e');
|
||||
let app = axum::Router::new()
|
||||
.route("/ep.torrent", axum::routing::get(move || std::future::ready(torrent.clone())))
|
||||
.route("/announce", axum::routing::get(move || std::future::ready(peers.clone())));
|
||||
tokio::spawn(async move { axum::serve(web, app).await });
|
||||
|
||||
let seed = Session::new_with_opts(
|
||||
file.parent().unwrap().to_owned(),
|
||||
SessionOptions {
|
||||
dht: None,
|
||||
disable_local_service_discovery: true,
|
||||
listen: Some(ListenerOptions {
|
||||
listen_addr: format!("127.0.0.1:{seed_port}").parse().unwrap(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
seed.add_torrent(
|
||||
AddTorrent::from_bytes(made.as_bytes().unwrap()),
|
||||
Some(AddTorrentOptions {
|
||||
output_folder: Some(file.parent().unwrap().display().to_string()),
|
||||
overwrite: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(at, seed)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_detached_torrent_lands_in_its_own_folder_is_recorded_and_frees_its_slot() {
|
||||
let root = std::env::temp_dir().join(format!("ipx-torrent-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (seeded, downloads) = (root.join("seed"), root.join("downloads"));
|
||||
std::fs::create_dir_all(&seeded).unwrap();
|
||||
let episode: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
|
||||
std::fs::write(seeded.join("episode.mp3"), &episode).unwrap();
|
||||
let (at, _seed) = swarm(&seeded.join("episode.mp3")).await;
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = downloads.clone();
|
||||
cfg.torrent.enabled = true;
|
||||
let port = free_port();
|
||||
cfg.torrent.port_range = format!("{port}-{port}");
|
||||
cfg.torrent.seed_ratio = 0.0;
|
||||
let mut ctx = crate::test_util::test_ctx(cfg.clone()).await;
|
||||
ctx.detach_torrents = true;
|
||||
let ctx = Arc::new(ctx);
|
||||
let url = format!("http://{at}/ep.torrent");
|
||||
ctx.db
|
||||
.exec_for_test(&format!(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, state) VALUES (9, 'f', 'g', '{url}', 'downloading')"
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
crate::scan::spawn_torrent(&ctx, "f".into(), 9, url.clone(), downloads.join("Show"));
|
||||
let row = tokio::time::timeout(Duration::from_secs(90), async {
|
||||
loop {
|
||||
let row = ctx.db.enclosure(9).await.unwrap().unwrap();
|
||||
if row.state != "downloading" {
|
||||
return row;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the torrent finished within 90 s");
|
||||
let folder = own_folder(&downloads.join("Show"), 9);
|
||||
assert_eq!((row.state.as_str(), row.last_error.as_deref()), ("done", None));
|
||||
assert_eq!(row.path.as_deref(), Some(folder.display().to_string().as_str()), "the folder is ours (#318)");
|
||||
assert_eq!(std::fs::read(folder.join("episode.mp3")).unwrap(), episode);
|
||||
// The slot is let go once the row is written; give the task its moment.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(ctx.torrent_slots.available_permits(), 2, "the slot is free again");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_torrent_file_from_an_internal_address_is_refused_unless_allowed() {
|
||||
let app = axum::Router::new().route("/ep.torrent", axum::routing::get(|| async { "d4:infode" }));
|
||||
let web = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = web.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(web, app).await });
|
||||
let guarded = |allowed: &[&str]| {
|
||||
let allowed =
|
||||
crate::feed::guard::Allowed::parse(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
|
||||
reqwest::Client::builder().connector_layer(crate::feed::guard::Guard::new(allowed)).build().unwrap()
|
||||
};
|
||||
let url = format!("http://{at}/ep.torrent");
|
||||
let err = torrent_file(&guarded(&[]), &url).await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("fetching the .torrent"), "{err:#}");
|
||||
assert_eq!(
|
||||
torrent_file(&guarded(&["127.0.0.1"]), &url).await.unwrap(),
|
||||
b"d4:infode",
|
||||
"fetch_internal allows it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_handles_the_empty_torrent() {
|
||||
assert_eq!(ratio(0, 100), 0.0);
|
||||
@@ -165,6 +325,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
236
crates/ipx/tests/daemon.rs
Normal file
236
crates/ipx/tests/daemon.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
//! The daemon as production runs it: the built binary, a scratch config and SQLite database, a
|
||||
//! feed served from this process, and SIGTERM sent as Docker sends it (#400). A process of its
|
||||
//! own, because a signal sent to the test binary would reach every test in it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const EPISODE_BYTES: u64 = 50_000_000;
|
||||
/// Times the episode has been asked for.
|
||||
static EPISODE_GETS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
fn episode_gets() -> usize {
|
||||
EPISODE_GETS.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// A feed of one episode, and the episode, sent 4 KB every 50 ms: a download that takes minutes.
|
||||
fn serve_feed() -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
for sock in l.incoming().flatten() {
|
||||
std::thread::spawn(move || answer(sock, port));
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
fn answer(mut sock: TcpStream, port: u16) {
|
||||
let mut line = String::new();
|
||||
if BufReader::new(&sock).read_line(&mut line).is_err() {
|
||||
return;
|
||||
}
|
||||
if line.contains("/ep.mp3") {
|
||||
EPISODE_GETS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg\r\nContent-Length: {EPISODE_BYTES}\r\n\r\n");
|
||||
// An MP3's frame sync first, so the file is taken for audio.
|
||||
let mut chunk = vec![0u8; 4096];
|
||||
chunk[..2].copy_from_slice(&[0xFF, 0xFB]);
|
||||
if sock.write_all(head.as_bytes()).is_err() {
|
||||
return;
|
||||
}
|
||||
while sock.write_all(&chunk).is_ok() {
|
||||
chunk[..2].fill(0);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0"?><rss version="2.0"><channel><title>Slow</title><item><title>One</title><guid>g1</guid><enclosure url="http://127.0.0.1:{port}/ep.mp3" type="audio/mpeg" length="{EPISODE_BYTES}"/></item></channel></rss>"#
|
||||
);
|
||||
let _ = sock.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes());
|
||||
}
|
||||
|
||||
struct Scratch {
|
||||
root: PathBuf,
|
||||
web: u16,
|
||||
}
|
||||
|
||||
impl Scratch {
|
||||
fn new(feed_port: u16) -> Self {
|
||||
let root = std::env::temp_dir().join(format!("ipx-daemon-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("data")).unwrap();
|
||||
let web = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
std::fs::write(
|
||||
root.join("config.toml"),
|
||||
format!(
|
||||
r#"[general]
|
||||
download_dir = "{root}/downloads"
|
||||
socket = "{root}/ipx.sock"
|
||||
schedule = "every 60m"
|
||||
fetch_internal = ["127.0.0.1"]
|
||||
|
||||
[torrent]
|
||||
enabled = false
|
||||
|
||||
[web]
|
||||
enabled = true
|
||||
bind = "127.0.0.1:{web}"
|
||||
token = ""
|
||||
|
||||
[feeds.slow]
|
||||
url = "http://127.0.0.1:{feed_port}/feed.xml"
|
||||
"#,
|
||||
root = root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
Self { root, web }
|
||||
}
|
||||
|
||||
fn ipx(&self, args: &[&str]) -> Command {
|
||||
let mut c = Command::new(env!("CARGO_BIN_EXE_ipxd"));
|
||||
c.args(args)
|
||||
.env("IPX_CONFIG", self.root.join("config.toml"))
|
||||
.env("IPX_DATA_DIR", self.root.join("data"))
|
||||
.env("IPX_ADMIN_PASSWORD", "a test password")
|
||||
.env("IPX_LOG", "ipx=info")
|
||||
.stdin(Stdio::null());
|
||||
// Nothing of the shell's: no Postgres, no traces, no JSON log, no TypeSafe key.
|
||||
for v in
|
||||
["IPX_DATABASE_URL", "OTEL_EXPORTER_OTLP_ENDPOINT", "IPX_LOG_FORMAT", "TYPESAFE_KEY", "IPX_TYPESAFE_URL"]
|
||||
{
|
||||
c.env_remove(v);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn run(&self, args: &[&str]) -> Output {
|
||||
self.ipx(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A daemon, its log going to `log`.
|
||||
fn daemon(&self, log: &str) -> Daemon {
|
||||
let log = std::fs::File::create(self.root.join(log)).unwrap();
|
||||
Daemon(self.ipx(&["daemon"]).stdout(Stdio::null()).stderr(log).spawn().unwrap())
|
||||
}
|
||||
|
||||
fn log(&self, name: &str) -> String {
|
||||
std::fs::read_to_string(self.root.join(name)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn socket(&self) -> PathBuf {
|
||||
self.root.join("ipx.sock")
|
||||
}
|
||||
|
||||
/// Bytes of the download under way, in the downloads folder's incomplete files.
|
||||
fn downloading(&self) -> u64 {
|
||||
let dir = self.root.join("downloads/.ipx-incomplete");
|
||||
std::fs::read_dir(dir).map_or(0, |d| d.flatten().filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum())
|
||||
}
|
||||
}
|
||||
|
||||
fn until(what: &str, limit: Duration, mut ok: impl FnMut() -> bool) {
|
||||
let started = Instant::now();
|
||||
while !ok() {
|
||||
assert!(started.elapsed() < limit, "{what}: not after {limit:?}");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn text(o: &Output) -> String {
|
||||
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
|
||||
}
|
||||
|
||||
/// SIGTERM, as `docker stop` sends; how long it took to exit.
|
||||
/// A daemon this test started, killed when dropped: a failed assertion left two running for an
|
||||
/// hour, holding their scratch sockets and ports.
|
||||
struct Daemon(Child);
|
||||
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate(Daemon(child): &mut Daemon) -> Duration {
|
||||
let started = Instant::now();
|
||||
assert!(Command::new("kill").args(["-TERM", &child.id().to_string()]).status().unwrap().success());
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
assert!(status.success(), "the daemon exited {status}");
|
||||
return started.elapsed();
|
||||
}
|
||||
if started.elapsed() > Duration::from_secs(10) {
|
||||
let _ = child.kill();
|
||||
panic!("the daemon ignored SIGTERM for 10 s");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn get(port: u16, path: &str) -> String {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
write!(s, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").unwrap();
|
||||
let mut out = String::new();
|
||||
let _ = s.read_to_string(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn token_in(config: &Path) -> String {
|
||||
let text = std::fs::read_to_string(config).unwrap();
|
||||
let line = text.lines().find(|l| l.trim_start().starts_with("token")).expect("a token line");
|
||||
line.split('"').nth(1).unwrap_or("").to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_daemon_starts_answers_refuses_a_second_and_stops_on_sigterm_mid_download() {
|
||||
let s = Scratch::new(serve_feed());
|
||||
let mut first = s.daemon("first.log");
|
||||
until("the control socket", Duration::from_secs(60), || s.socket().exists() && s.run(&["status"]).status.success());
|
||||
|
||||
// Through the socket: the daemon's own answer, in words.
|
||||
let status = s.run(&["status"]);
|
||||
assert!(text(&status).contains("1 feeds,"), "{}", text(&status));
|
||||
let local = s.run(&["--local", "status"]);
|
||||
assert!(local.status.success() && text(&local).contains("1 feeds,"), "{}", text(&local));
|
||||
|
||||
// A first start with no token writes one, and it signs in.
|
||||
let token = token_in(&s.root.join("config.toml"));
|
||||
assert_eq!(token.len(), 64, "a session-strength token");
|
||||
assert!(!s.log("first.log").contains(&token), "the log says where the token is, never what it is");
|
||||
let me = get(s.web, &format!("/api/me?token={token}"));
|
||||
assert!(me.starts_with("HTTP/1.1 200"), "{me}");
|
||||
assert!(get(s.web, "/api/me").starts_with("HTTP/1.1 401"));
|
||||
|
||||
// Two daemons on one socket would fight over the database.
|
||||
let second = s.ipx(&["daemon"]).output().unwrap();
|
||||
assert!(!second.status.success());
|
||||
assert!(text(&second).contains("a daemon is already listening"), "{}", text(&second));
|
||||
assert!(s.run(&["status"]).status.success(), "the first is still serving");
|
||||
|
||||
// The first pass starts at once and takes the episode, which takes minutes. SIGTERM stops it.
|
||||
until("the download to start", Duration::from_secs(60), || s.downloading() > 0);
|
||||
let took = terminate(&mut first);
|
||||
assert!(took < Duration::from_secs(5), "stopped in {took:?}");
|
||||
assert!(!s.socket().exists(), "the socket is removed");
|
||||
assert!(s.log("first.log").contains("abandoning the job in progress"), "{}", s.log("first.log"));
|
||||
|
||||
// The next start takes the abandoned download up again at once, not at its feed's next check,
|
||||
// which is an hour away (#410).
|
||||
assert_eq!(episode_gets(), 1);
|
||||
let mut again = s.daemon("again.log");
|
||||
until("the episode asked for again", Duration::from_secs(30), || episode_gets() == 2);
|
||||
assert!(s.log("again.log").contains("taking up downloads interrupted by a restart"), "{}", s.log("again.log"));
|
||||
terminate(&mut again);
|
||||
|
||||
// No daemon: the command does the work itself.
|
||||
let alone = s.run(&["status"]);
|
||||
assert!(alone.status.success() && text(&alone).contains("1 feeds,"), "{}", text(&alone));
|
||||
let _ = std::fs::remove_dir_all(&s.root);
|
||||
}
|
||||
18
crates/play/CHANGELOG.md
Normal file
18
crates/play/CHANGELOG.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Changelog: ipx-play
|
||||
|
||||
The pages shared episodes open, at `play.ipxreader.app` (docs/play.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/play/Cargo.toml`, and a release is tagged `play-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
|
||||
|
||||
- A page for each shared episode, at `/e/<id>`: its show, title, date, length and notes, a player that plays the publisher's own file from the time it was shared at (`?t=754` or `?t=12:34`), buttons to subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, the feed's address, and a preview where the link is pasted (#447).
|
||||
- `POST /v1/shares`, for an ipx server with a token from `ipx-play token <name>`: an episode in, its short link out, the same link however often and from whichever server it is shared. An address that looks like a private or paid feed's is refused (#447).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/commits/main/crates/play
|
||||
39
crates/play/Cargo.toml
Normal file
39
crates/play/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "ipx-play"
|
||||
version = "0.1.0-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "ipx-play"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
# Its tracing setup and token helpers, shared rather than copied: the same traceparent and JSON
|
||||
# log lines as ipx and the directory.
|
||||
ipx-directory.workspace = true
|
||||
ipx-feed.workspace = true
|
||||
opentelemetry.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
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tower.workspace = true
|
||||
115
crates/play/src/config.rs
Normal file
115
crates/play/src/config.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! `play.toml`: where ipx-play listens, its database, its public address, and the ipx servers
|
||||
//! that may make a link. Tokens are changed only here, as the directory's are.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// Where the API and the episode 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,
|
||||
/// The address people open, which the links ipx is handed begin with.
|
||||
#[serde(default = "default_public_url")]
|
||||
pub public_url: String,
|
||||
#[serde(default)]
|
||||
pub tokens: Vec<TokenCfg>,
|
||||
}
|
||||
|
||||
fn default_bind() -> String {
|
||||
"0.0.0.0:8102".into()
|
||||
}
|
||||
|
||||
fn default_database_url_env() -> String {
|
||||
"PLAY_DATABASE_URL".into()
|
||||
}
|
||||
|
||||
fn default_public_url() -> String {
|
||||
"https://play.ipxreader.app".into()
|
||||
}
|
||||
|
||||
/// An ipx server allowed to make links: its name, for the log and the shares it made, and only
|
||||
/// its token's hash.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TokenCfg {
|
||||
pub name: String,
|
||||
/// What `ipx-play token new` prints.
|
||||
pub token_sha256: String,
|
||||
}
|
||||
|
||||
/// A token as the server checks it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token {
|
||||
pub name: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
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()))
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
let cfg: Config = toml::from_str(text)?;
|
||||
let u = url::Url::parse(&cfg.public_url).context("public_url is not an address")?;
|
||||
if !matches!(u.scheme(), "http" | "https") {
|
||||
bail!("public_url is not an http(s) address");
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Refuses an empty or repeated name, and a hash that is not one or is listed twice: which
|
||||
/// server made a link would be ambiguous.
|
||||
pub fn tokens(&self) -> Result<Vec<Token>> {
|
||||
let (mut names, mut hashes) = (HashSet::new(), HashSet::new());
|
||||
self.tokens
|
||||
.iter()
|
||||
.map(|t| {
|
||||
if t.name.trim().is_empty() || !names.insert(t.name.as_str()) {
|
||||
bail!("token {:?}: each token needs a name of its own", t.name);
|
||||
}
|
||||
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 !hashes.insert(sha256.clone()) {
|
||||
bail!("token {:?} is the same token as another", t.name);
|
||||
}
|
||||
Ok(Token { name: t.name.clone(), sha256 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The public address without a trailing slash, for links.
|
||||
#[must_use]
|
||||
pub fn base(&self) -> &str {
|
||||
self.public_url.trim_end_matches('/')
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_config_reads_with_its_defaults_and_refuses_ambiguous_tokens() {
|
||||
let h = "a".repeat(64);
|
||||
let cfg = Config::parse(&format!("[[tokens]]\nname = \"tower\"\ntoken_sha256 = \"{h}\"")).unwrap();
|
||||
assert_eq!((cfg.bind.as_str(), cfg.base()), ("0.0.0.0:8102", "https://play.ipxreader.app"));
|
||||
assert_eq!(cfg.tokens().unwrap()[0].name, "tower");
|
||||
let two = format!(
|
||||
"[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"\n[[tokens]]\nname = \"b\"\ntoken_sha256 = \"{h}\""
|
||||
);
|
||||
assert!(Config::parse(&two).unwrap().tokens().is_err(), "one token under two names");
|
||||
assert!(Config::parse("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"xyz\"").unwrap().tokens().is_err());
|
||||
assert!(Config::parse("public_url = \"ftp://x\"").is_err());
|
||||
}
|
||||
}
|
||||
316
crates/play/src/lib.rs
Normal file
316
crates/play/src/lib.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
//! `ipx-play` (#447): the page a shared episode opens, at `play.ipxreader.app/e/<id>`, as
|
||||
//! Overcast's links do. When someone shares an episode, their ipx server sends it here with its
|
||||
//! token and gets back a short link; anyone with the link sees the episode, its show and its
|
||||
//! notes, and plays it from the publisher's own file, from the time it was shared at if it was.
|
||||
//!
|
||||
//! Only public episodes come here: ipx sends none from a private or paid feed, and this refuses
|
||||
//! an address that looks like one (`ipx_feed::url_looks_private`), which would hand out the
|
||||
//! sharer's subscription. The files stay the publisher's; this keeps what an episode is and where
|
||||
//! its file is, nothing more.
|
||||
|
||||
pub mod config;
|
||||
mod page;
|
||||
pub mod store;
|
||||
|
||||
use axum::extract::{Path, Query, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::Instrument;
|
||||
|
||||
/// An episode as ipx sends it to be shared.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Episode {
|
||||
pub feed_url: String,
|
||||
pub guid: String,
|
||||
pub feed_title: Option<String>,
|
||||
pub feed_image: Option<String>,
|
||||
pub title: Option<String>,
|
||||
/// The feed's own HTML for it, sanitized here when the page is drawn.
|
||||
pub description: Option<String>,
|
||||
pub link: Option<String>,
|
||||
/// Unix seconds.
|
||||
pub published: Option<i64>,
|
||||
/// Seconds.
|
||||
pub duration: Option<i64>,
|
||||
/// The publisher's file, which the page plays.
|
||||
pub audio_url: String,
|
||||
pub mime: Option<String>,
|
||||
}
|
||||
|
||||
/// What ipx gets back: the link to hand on, to which it adds `?t=` for a time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Shared {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// An episode kept, by its id.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Share {
|
||||
pub id: String,
|
||||
pub episode: Episode,
|
||||
}
|
||||
|
||||
/// Longer than any real one, short enough that nobody keeps a book here.
|
||||
const MAX_TEXT: usize = 100_000;
|
||||
const MAX_SHORT: usize = 2_000;
|
||||
|
||||
impl Episode {
|
||||
/// What makes an episode unfit to keep, in words for whoever sent it; None when it is fine.
|
||||
/// Over-long text is cut to fit rather than refused: a show's notes can be long.
|
||||
fn check(&mut self) -> Option<&'static str> {
|
||||
let web = |u: &str| url::Url::parse(u).is_ok_and(|u| matches!(u.scheme(), "http" | "https"));
|
||||
if !web(&self.feed_url) || !web(&self.audio_url) {
|
||||
return Some("the feed and its file need http(s) addresses");
|
||||
}
|
||||
if ipx_feed::url_looks_private(&self.feed_url) || ipx_feed::url_looks_private(&self.audio_url) {
|
||||
return Some("that looks like a private or paid feed, whose addresses carry someone's access");
|
||||
}
|
||||
if self.guid.trim().is_empty() || self.guid.len() > MAX_SHORT {
|
||||
return Some("an episode needs its guid");
|
||||
}
|
||||
if self.feed_image.as_deref().is_some_and(|u| !web(u)) {
|
||||
self.feed_image = None;
|
||||
}
|
||||
if self.link.as_deref().is_some_and(|u| !web(u)) {
|
||||
self.link = None;
|
||||
}
|
||||
for (s, max) in
|
||||
[(&mut self.title, MAX_SHORT), (&mut self.feed_title, MAX_SHORT), (&mut self.description, MAX_TEXT)]
|
||||
{
|
||||
if let Some(t) = s
|
||||
&& t.len() > max
|
||||
{
|
||||
let mut end = max;
|
||||
while !t.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
t.truncate(end);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What every request shares.
|
||||
pub struct Play {
|
||||
pub store: store::Store,
|
||||
pub tokens: Vec<config::Token>,
|
||||
pub base: String,
|
||||
}
|
||||
|
||||
type St = Arc<Play>;
|
||||
|
||||
pub fn router(play: Play) -> Router {
|
||||
let st = Arc::new(play);
|
||||
let api = Router::new()
|
||||
.route("/v1/shares", post(share))
|
||||
.layer(axum::middleware::from_fn_with_state(st.clone(), authenticate));
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/e/{id}", get(episode_page))
|
||||
.route("/play.css", get(|| async { page::asset("text/css; charset=utf-8", include_bytes!("play.css")) }))
|
||||
.route("/logo.svg", get(|| async { page::asset("image/svg+xml", include_bytes!("../../../web/logo.svg")) }))
|
||||
.route("/favicon.png", get(|| async { page::asset("image/png", include_bytes!("../../../web/favicon.png")) }))
|
||||
.merge(api)
|
||||
.fallback(|| async { page::not_found() })
|
||||
.layer(axum::middleware::from_fn(access_log))
|
||||
.with_state(st)
|
||||
}
|
||||
|
||||
/// Which server made the request, for the log and the share, set by `authenticate`.
|
||||
#[derive(Clone)]
|
||||
struct Who(String);
|
||||
|
||||
async fn authenticate(State(st): State<St>, 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 who = presented.and_then(|t| {
|
||||
let h = ipx_directory::config::hash(t);
|
||||
st.tokens.iter().find(|k| ipx_directory::config::same_secret(&k.sha256, &h)).map(|k| k.name.clone())
|
||||
});
|
||||
let Some(who) = who else {
|
||||
return error(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>");
|
||||
};
|
||||
req.extensions_mut().insert(Who(who.clone()));
|
||||
let mut resp = next.run(req).await;
|
||||
resp.extensions_mut().insert(Who(who));
|
||||
resp
|
||||
}
|
||||
|
||||
fn error(status: StatusCode, msg: &str) -> Response {
|
||||
(status, Json(serde_json::json!({ "error": msg }))).into_response()
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "share", skip_all, fields(server = %who.0))]
|
||||
async fn share(State(st): State<St>, Extension(who): Extension<Who>, Json(mut e): Json<Episode>) -> Response {
|
||||
if let Some(why) = e.check() {
|
||||
return error(StatusCode::UNPROCESSABLE_ENTITY, why);
|
||||
}
|
||||
match st.store.put(&who.0, &e).await {
|
||||
Ok(id) => {
|
||||
tracing::info!(server = %who.0, id = %id, "an episode was shared");
|
||||
Json(Shared { url: format!("{}/e/{id}", st.base), id }).into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %format!("{err:#}"), "keeping a share failed");
|
||||
error(StatusCode::INTERNAL_SERVER_ERROR, "it could not be kept; ipx-play's log says why")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct At {
|
||||
/// Where to start: seconds, or as written, `12:34` or `1:02:03`.
|
||||
t: Option<String>,
|
||||
}
|
||||
|
||||
async fn episode_page(State(st): State<St>, Path(id): Path<String>, Query(at): Query<At>) -> Response {
|
||||
match st.store.get(&id).instrument(tracing::info_span!("step", of = "get_share")).await {
|
||||
Ok(Some(s)) => page::episode(&s, at.t.as_deref().and_then(page::parse_time), &st.base),
|
||||
Ok(None) => page::not_found(),
|
||||
Err(err) => {
|
||||
tracing::error!(error = %format!("{err:#}"), "reading a share failed");
|
||||
page::failed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn healthz(State(st): State<St>) -> Response {
|
||||
match st.store.ping().await {
|
||||
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||
Err(e) => (StatusCode::SERVICE_UNAVAILABLE, format!("database: {e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A span a request, joined to the caller's trace when it sent a traceparent, as ipx does on its
|
||||
/// calls here; and a line, with the server's name and never its token.
|
||||
async fn access_log(req: Request, next: Next) -> Response {
|
||||
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
|
||||
let start = Instant::now();
|
||||
let span = if path == "/healthz" {
|
||||
tracing::Span::none()
|
||||
} else {
|
||||
tracing::info_span!(
|
||||
"http",
|
||||
otel.name = %format!("{method} {}", if path.starts_with("/e/") { "/e/{id}" } else { path.as_str() }),
|
||||
http.request.method = %method,
|
||||
url.path = %path,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
)
|
||||
};
|
||||
if let Some(parent) = ipx_directory::otel::remote_parent(req.headers(), None) {
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let _ = span.set_parent(parent);
|
||||
}
|
||||
let resp = next.run(req).instrument(span.clone()).await;
|
||||
let status = resp.status().as_u16();
|
||||
span.record("http.response.status_code", status);
|
||||
if path != "/healthz" {
|
||||
let _in = span.enter();
|
||||
let who = resp.extensions().get::<Who>().map_or("", |w| w.0.as_str()).to_owned();
|
||||
tracing::info!(
|
||||
target: "ipx_play::http",
|
||||
method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who,
|
||||
"{method} {path} -> {status}"
|
||||
);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const TOKEN: &str = "ipxd_test";
|
||||
|
||||
async fn app() -> Router {
|
||||
router(Play {
|
||||
store: store::Store::scratch().await,
|
||||
tokens: vec![config::Token { name: "tower".into(), sha256: ipx_directory::config::hash(TOKEN) }],
|
||||
base: "https://play.test".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn episode() -> Episode {
|
||||
Episode {
|
||||
feed_url: "https://example.com/feed.xml".into(),
|
||||
guid: "ep-1".into(),
|
||||
feed_title: Some("A Show".into()),
|
||||
feed_image: Some("https://example.com/art.jpg".into()),
|
||||
title: Some("Episode <One>".into()),
|
||||
description: Some("<p>Notes</p><script>alert(1)</script>".into()),
|
||||
link: Some("javascript:alert(1)".into()),
|
||||
published: Some(1_800_000_000),
|
||||
duration: Some(3600),
|
||||
audio_url: "https://example.com/ep1.mp3".into(),
|
||||
mime: Some("audio/mpeg".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post(app: &Router, token: Option<&str>, e: &Episode) -> (StatusCode, serde_json::Value) {
|
||||
let mut req = axum::http::Request::post("/v1/shares").header("content-type", "application/json");
|
||||
if let Some(t) = token {
|
||||
req = req.header("authorization", format!("Bearer {t}"));
|
||||
}
|
||||
let resp = app.clone().oneshot(req.body(Body::from(serde_json::to_vec(e).unwrap())).unwrap()).await.unwrap();
|
||||
let status = resp.status();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, serde_json::from_slice(&body).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn get(app: &Router, path: &str) -> (StatusCode, String) {
|
||||
let resp = app.clone().oneshot(axum::http::Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
|
||||
let status = resp.status();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_with_a_token_makes_a_link_and_anyone_opens_it() {
|
||||
let app = app().await;
|
||||
assert_eq!(post(&app, None, &episode()).await.0, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post(&app, Some("wrong"), &episode()).await.0, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let (status, body) = post(&app, Some(TOKEN), &episode()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let url = body["url"].as_str().unwrap();
|
||||
assert!(url.starts_with("https://play.test/e/"), "{url}");
|
||||
|
||||
let path = url.trim_start_matches("https://play.test");
|
||||
let (status, html) = get(&app, &format!("{path}?t=12:34")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(html.contains("Episode <One>"), "the title, escaped");
|
||||
assert!(html.contains("ep1.mp3#t=754"), "the player starts at the time shared");
|
||||
assert!(html.contains("Starts at 12:34"));
|
||||
assert!(html.contains(r#"property="og:title""#), "a link preview");
|
||||
assert!(!html.contains("<script>alert"), "the notes are sanitized");
|
||||
assert!(!html.contains("javascript:"), "a link that is not a web address is dropped");
|
||||
|
||||
assert_eq!(get(&app, "/e/nothere").await.0, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_private_feed_is_refused() {
|
||||
let app = app().await;
|
||||
let mut e = episode();
|
||||
e.feed_url = "https://www.patreon.com/rss/show?auth=secret".into();
|
||||
assert_eq!(post(&app, Some(TOKEN), &e).await.0, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let mut e = episode();
|
||||
e.audio_url = "file:///etc/passwd".into();
|
||||
assert_eq!(post(&app, Some(TOKEN), &e).await.0, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
117
crates/play/src/main.rs
Normal file
117
crates/play/src/main.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! `ipx-play`: serve the shared-episode pages, make a token for an ipx server, or check that a
|
||||
//! running one answers.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use ipx_play::{Play, config, store::Store};
|
||||
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::*;
|
||||
|
||||
// mimalloc comes with ipx-directory, whose library sets it as the allocator.
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "The pages ipx's shared episodes open")]
|
||||
struct Cli {
|
||||
/// The config file. Otherwise PLAY_CONFIG, or else /config/play.toml.
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Serve the pages and the API (the default).
|
||||
Serve,
|
||||
/// Make a token for an ipx server, and print the lines that let it in.
|
||||
Token {
|
||||
/// Who it is for, as the log will name them.
|
||||
name: String,
|
||||
},
|
||||
/// Check that ipx-play on this machine answers and can read its database.
|
||||
Health,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let path = cli
|
||||
.config
|
||||
.or_else(|| std::env::var_os("PLAY_CONFIG").map(Into::into))
|
||||
.unwrap_or_else(|| "/config/play.toml".into());
|
||||
match cli.cmd.unwrap_or(Cmd::Serve) {
|
||||
Cmd::Serve => serve(&path).await,
|
||||
Cmd::Token { name } => {
|
||||
let token = ipx_directory::config::new_token();
|
||||
println!("A token for {name}, to enter in its Admin, Sharing; it is not kept anywhere:\n\n {token}\n");
|
||||
println!("To let it in, add this to {} and restart ipx-play:\n", path.display());
|
||||
println!("[[tokens]]\nname = {name:?}\ntoken_sha256 = {:?}", ipx_directory::config::hash(&token));
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Health => {
|
||||
let cfg = config::Config::load(&path)?;
|
||||
let port = cfg.bind.rsplit(':').next().unwrap_or("8102");
|
||||
let resp = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:{port}/healthz"))
|
||||
.timeout(Duration::from_secs(4))
|
||||
.send()
|
||||
.await
|
||||
.context("ipx-play does not answer")?;
|
||||
anyhow::ensure!(resp.status().is_success(), "ipx-play answers {}", resp.status());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(path: &Path) -> Result<()> {
|
||||
// One JSON object a line for Loki when PLAY_LOG_FORMAT=json, as ipx and the directory do.
|
||||
let json = std::env::var("PLAY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
let filter = || EnvFilter::try_from_env("PLAY_LOG").unwrap_or_else(|_| "ipx_play=info".into());
|
||||
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's.
|
||||
let otel = ipx_directory::otel::provider("ipx-play")?;
|
||||
let stderr: Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync> = if json {
|
||||
ipx_directory::otel::json_layer(std::io::stderr, false).with_filter(filter()).boxed()
|
||||
} else {
|
||||
tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter()).boxed()
|
||||
};
|
||||
tracing_subscriber::registry()
|
||||
.with(stderr)
|
||||
.with(otel.as_ref().map(|p| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx-play"))
|
||||
.with_filter(EnvFilter::new("ipx_play=info"))
|
||||
}))
|
||||
.init();
|
||||
|
||||
let cfg = config::Config::load(path)?;
|
||||
// ponytail: tokens are read at start; a new server's token takes a restart. Reload the file
|
||||
// on a timer, as the directory does, if servers come and go often.
|
||||
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: ipx-play needs a database URL", cfg.database_url_env))?;
|
||||
let store = Store::open(&url, true).await.context("opening the database")?;
|
||||
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 = tokens.len(), "ipx-play started");
|
||||
let app = ipx_play::router(Play { store, tokens, base: cfg.base().to_owned() });
|
||||
axum::serve(listener, app)
|
||||
.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(())
|
||||
}
|
||||
235
crates/play/src/page.rs
Normal file
235
crates/play/src/page.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
//! The episode page: what it is, a player that starts at the time shared, its notes, and how to
|
||||
//! subscribe to the show in whatever app the reader has. No script: the start time is the media
|
||||
//! fragment `#t=`, which every browser's player honours.
|
||||
|
||||
use crate::Share;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Nothing runs on the page, nothing loads but the art and the file, and no other site may frame
|
||||
/// it. Images and audio from anywhere: they are the publisher's.
|
||||
const CSP: &str = "default-src 'none'; img-src https: http: data:; media-src https: http:; style-src 'self'; \
|
||||
base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||
|
||||
pub fn asset(content_type: &'static str, body: &'static [u8]) -> Response {
|
||||
([(header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
||||
}
|
||||
|
||||
/// `?t=`: seconds (`754`), or as a clock reads (`12:34`, `1:02:03`). None for anything else.
|
||||
#[must_use]
|
||||
pub fn parse_time(t: &str) -> Option<u64> {
|
||||
let parts: Vec<&str> = t.trim().split(':').collect();
|
||||
if parts.is_empty()
|
||||
|| parts.len() > 3
|
||||
|| parts.iter().any(|p| p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
parts.iter().try_fold(0u64, |n, p| n.checked_mul(60)?.checked_add(p.parse().ok()?)).filter(|&s| s > 0)
|
||||
}
|
||||
|
||||
/// `12:34`, or `1:02:03` past an hour, as the player shows it.
|
||||
#[must_use]
|
||||
pub fn clock(secs: u64) -> String {
|
||||
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
|
||||
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
||||
}
|
||||
|
||||
/// `1 hr 2 min`, `45 min`.
|
||||
fn length(secs: i64) -> String {
|
||||
let m = (secs + 30) / 60;
|
||||
match (m / 60, m % 60) {
|
||||
(0, m) => format!("{} min", m.max(1)),
|
||||
(h, 0) => format!("{h} hr"),
|
||||
(h, m) => format!("{h} hr {m} min"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The notes as words, for a link preview: their tags gone, cut at a word near `max` characters.
|
||||
fn plain(html: &str, max: usize) -> String {
|
||||
// A space where each tag was, or two paragraphs read as one word ("Jerrytalk"). A preview
|
||||
// can afford the odd space before a bold word.
|
||||
let text = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
|
||||
let text = text
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&");
|
||||
let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if words.chars().count() <= max {
|
||||
return words;
|
||||
}
|
||||
let cut: String = words.chars().take(max).collect();
|
||||
format!("{}…", cut.rsplit_once(' ').map_or(cut.as_str(), |(a, _)| a))
|
||||
}
|
||||
|
||||
/// Where a podcast app takes a feed to subscribe to, by the address schemes each one answers.
|
||||
/// On a phone without the app the link does nothing, as Overcast's own page's do.
|
||||
fn apps(feed: &str) -> Vec<(&'static str, String)> {
|
||||
let bare = feed.split_once("://").map_or(feed, |(_, rest)| rest);
|
||||
let enc = percent_encoding::utf8_percent_encode(feed, percent_encoding::NON_ALPHANUMERIC).to_string();
|
||||
vec![
|
||||
("Apple Podcasts", format!("podcast://{bare}")),
|
||||
("Overcast", format!("overcast://x-callback-url/add?url={enc}")),
|
||||
("Pocket Casts", format!("pktc://subscribe/{bare}")),
|
||||
("Castro", format!("castros://subscribe/{bare}")),
|
||||
]
|
||||
}
|
||||
|
||||
fn html(status: StatusCode, body: String) -> Response {
|
||||
let mut resp = (status, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], body).into_response();
|
||||
let h = resp.headers_mut();
|
||||
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP));
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
|
||||
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
resp
|
||||
}
|
||||
|
||||
fn frame(title: &str, head: &str, body: &str) -> String {
|
||||
format!(
|
||||
"<!doctype html><html lang=en><head><meta charset=utf-8>\
|
||||
<meta name=viewport content=\"width=device-width, initial-scale=1\">\
|
||||
<meta name=color-scheme content=\"light dark\"><title>{}</title>\
|
||||
<link rel=icon href=/favicon.png><link rel=stylesheet href=/play.css>{head}</head>\
|
||||
<body><main><a class=brand href=\"https://ipxreader.app\"><img src=/logo.svg alt=\"\" width=28 height=28>iPX</a>\
|
||||
{body}<footer>Shared from <a href=\"https://ipxreader.app\">iPX</a>, a podcast and feed reader you run yourself.</footer>\
|
||||
</main></body></html>",
|
||||
esc(title)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn not_found() -> Response {
|
||||
html(
|
||||
StatusCode::NOT_FOUND,
|
||||
frame("Not here", "", "<h1>Not here</h1><p class=meta>That link is not one iPX made, or it was mistyped.</p>"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn failed() -> Response {
|
||||
html(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
frame("Try again", "", "<h1>Try again</h1><p class=meta>The episode could not be read just now.</p>"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn episode(s: &Share, at: Option<u64>, base: &str) -> Response {
|
||||
let e = &s.episode;
|
||||
let show = e.feed_title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("A podcast");
|
||||
let title = e.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("Untitled episode");
|
||||
let notes = e.description.as_deref().map(ammonia::clean).filter(|d| !d.trim().is_empty());
|
||||
let summary = e.description.as_deref().map(|d| plain(d, 200)).filter(|d| !d.is_empty());
|
||||
let url = format!("{base}/e/{}", s.id);
|
||||
|
||||
let mut head = String::new();
|
||||
let _ = write!(
|
||||
head,
|
||||
"<meta property=\"og:type\" content=\"website\"><meta property=\"og:site_name\" content=\"iPX\">\
|
||||
<meta property=\"og:title\" content=\"{}\"><meta property=\"og:url\" content=\"{}\">\
|
||||
<meta property=\"og:audio\" content=\"{}\"><meta name=\"twitter:card\" content=\"summary\">",
|
||||
esc(title),
|
||||
esc(&url),
|
||||
esc(&e.audio_url)
|
||||
);
|
||||
let desc = match &summary {
|
||||
Some(sum) => format!("{show}: {sum}"),
|
||||
None => show.to_owned(),
|
||||
};
|
||||
let _ = write!(
|
||||
head,
|
||||
"<meta property=\"og:description\" content=\"{0}\"><meta name=description content=\"{0}\">",
|
||||
esc(&desc)
|
||||
);
|
||||
if let Some(img) = &e.feed_image {
|
||||
let _ = write!(head, "<meta property=\"og:image\" content=\"{}\">", esc(img));
|
||||
}
|
||||
if let Some(m) = &e.mime {
|
||||
let _ = write!(head, "<meta property=\"og:audio:type\" content=\"{}\">", esc(m));
|
||||
}
|
||||
|
||||
let mut b = String::new();
|
||||
b.push_str("<article class=ep><div class=head>");
|
||||
match &e.feed_image {
|
||||
Some(img) => {
|
||||
let _ = write!(b, "<img class=art src=\"{}\" alt=\"\" width=160 height=160>", esc(img));
|
||||
}
|
||||
None => {
|
||||
let initials: String = show.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
|
||||
let _ = write!(b, "<div class=\"art tile\" aria-hidden=true>{}</div>", esc(&initials.to_uppercase()));
|
||||
}
|
||||
}
|
||||
let mut meta = Vec::new();
|
||||
if let Some(p) = e.published.and_then(|p| chrono::DateTime::from_timestamp(p, 0)) {
|
||||
meta.push(p.format("%B %-d, %Y").to_string());
|
||||
}
|
||||
if let Some(d) = e.duration.filter(|d| *d > 0) {
|
||||
meta.push(length(d));
|
||||
}
|
||||
let _ = write!(
|
||||
b,
|
||||
"<div><p class=show>{}</p><h1>{}</h1><p class=meta>{}</p></div></div>",
|
||||
esc(show),
|
||||
esc(title),
|
||||
esc(&meta.join(" · "))
|
||||
);
|
||||
// The file as the publisher serves it: a show that inserts ads by listener can move the time
|
||||
// by the length of an ad, as Overcast's links do.
|
||||
let src = match at {
|
||||
Some(t) => format!("{}#t={t}", e.audio_url),
|
||||
None => e.audio_url.clone(),
|
||||
};
|
||||
if let Some(t) = at {
|
||||
let _ = write!(b, "<p class=at>Starts at {}</p>", clock(t));
|
||||
}
|
||||
let _ = write!(b, "<audio controls preload=metadata src=\"{}\"></audio>", esc(&src));
|
||||
if let Some(link) = &e.link {
|
||||
let _ = write!(
|
||||
b,
|
||||
"<p class=more><a href=\"{}\" rel=\"noopener nofollow\">The episode's own page</a></p>",
|
||||
esc(link)
|
||||
);
|
||||
}
|
||||
if let Some(n) = notes {
|
||||
let _ = write!(b, "<section class=notes><h2>Notes</h2>{n}</section>");
|
||||
}
|
||||
let _ = write!(b, "<section class=sub><h2>Subscribe to {}</h2><p class=apps>", esc(show));
|
||||
for (name, href) in apps(&e.feed_url) {
|
||||
let _ = write!(b, "<a class=app href=\"{}\">{name}</a>", esc(&href));
|
||||
}
|
||||
let _ = write!(
|
||||
b,
|
||||
"</p><label for=feed>Or paste the feed's address into any podcast app</label>\
|
||||
<input id=feed readonly value=\"{}\" spellcheck=false></section></article>",
|
||||
esc(&e.feed_url)
|
||||
);
|
||||
html(StatusCode::OK, frame(&format!("{title} · {show}"), &head, &b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_time_reads_as_seconds_or_a_clock() {
|
||||
assert_eq!(parse_time("754"), Some(754));
|
||||
assert_eq!(parse_time("12:34"), Some(754));
|
||||
assert_eq!(parse_time("1:02:03"), Some(3723));
|
||||
for bad in ["", "0", "-5", "1:2:3:4", "12:", "abc", "99999999999999999999999"] {
|
||||
assert_eq!(parse_time(bad), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!((clock(754), clock(3723)), ("12:34".to_owned(), "1:02:03".to_owned()));
|
||||
assert_eq!((length(2700), length(3600), length(3720)), ("45 min".into(), "1 hr".into(), "1 hr 2 min".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_has_the_notes_as_words() {
|
||||
assert_eq!(plain("<p>Tom & Jerry</p><p>talk</p>", 200), "Tom & Jerry talk");
|
||||
assert_eq!(plain("one two three four", 9), "one two…");
|
||||
}
|
||||
}
|
||||
70
crates/play/src/play.css
Normal file
70
crates/play/src/play.css
Normal file
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--card: #ffffff;
|
||||
--ink: #111827;
|
||||
--muted: #5b6472;
|
||||
--line: #e2e6ec;
|
||||
--accent: #e8590c;
|
||||
--tile: #dbe3ee;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0b1424;
|
||||
--card: #111d31;
|
||||
--ink: #e8edf4;
|
||||
--muted: #9aa7b8;
|
||||
--line: #22324a;
|
||||
--accent: #ff7a2f;
|
||||
--tile: #1d2c44;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { max-width: 680px; margin: 0 auto; padding: 20px 16px 48px; }
|
||||
a { color: var(--accent); }
|
||||
.brand {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
color: var(--ink); font-weight: 700; text-decoration: none; margin-bottom: 20px;
|
||||
}
|
||||
.ep { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 20px; }
|
||||
.head { display: flex; gap: 18px; align-items: center; }
|
||||
.art { width: 120px; height: 120px; border-radius: 12px; object-fit: cover; flex: none; }
|
||||
.tile {
|
||||
display: grid; place-items: center; background: var(--tile);
|
||||
font-size: 36px; font-weight: 700; color: var(--muted);
|
||||
}
|
||||
.show { margin: 0; color: var(--accent); font-weight: 600; font-size: 15px; }
|
||||
h1 { margin: 2px 0 4px; font-size: 22px; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
h2 { font-size: 15px; margin: 0 0 8px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.meta { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
.at {
|
||||
display: inline-block; margin: 18px 0 0; padding: 4px 10px; border-radius: 999px;
|
||||
background: var(--tile); font-size: 14px; font-weight: 600;
|
||||
}
|
||||
audio { display: block; width: 100%; margin-top: 14px; }
|
||||
.more { margin: 12px 0 0; font-size: 15px; }
|
||||
.notes, .sub { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
|
||||
.notes { overflow-wrap: anywhere; }
|
||||
.notes img { max-width: 100%; height: auto; }
|
||||
.apps { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 14px; }
|
||||
.app {
|
||||
padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line);
|
||||
color: var(--ink); text-decoration: none; font-size: 15px; font-weight: 600;
|
||||
}
|
||||
.app:hover { border-color: var(--accent); }
|
||||
label { display: block; color: var(--muted); font-size: 14px; margin-bottom: 6px; }
|
||||
input {
|
||||
width: 100%; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: var(--bg); color: var(--ink); font: 14px ui-monospace, Menlo, monospace;
|
||||
}
|
||||
footer { margin-top: 24px; color: var(--muted); font-size: 14px; text-align: center; }
|
||||
@media (max-width: 480px) {
|
||||
.head { flex-direction: column; align-items: flex-start; }
|
||||
.art { width: 100%; height: auto; aspect-ratio: 1; max-width: 240px; }
|
||||
}
|
||||
200
crates/play/src/store.rs
Normal file
200
crates/play/src/store.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! The episodes people have shared. SQL written by hand, as the directory's is, so it runs on
|
||||
//! SQLite (tests) and Postgres (production) both.
|
||||
|
||||
use crate::{Episode, Share};
|
||||
use anyhow::Result;
|
||||
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, QueryResult, Statement, Value};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const SCHEMA: &[&str] = &["CREATE TABLE IF NOT EXISTS shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
feed_url TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
feed_title TEXT,
|
||||
feed_image TEXT,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
link TEXT,
|
||||
published BIGINT,
|
||||
duration BIGINT,
|
||||
audio_url TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
instance TEXT NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
updated BIGINT NOT NULL
|
||||
)"];
|
||||
|
||||
pub struct Store {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn now() -> i64 {
|
||||
SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// An episode's id: ten letters and digits from the SHA-256 of its feed's address (written as
|
||||
/// the directory writes it) and its guid, so sharing it again, from any server, gives the same
|
||||
/// link, and nothing needs looking up to make one. 50 bits: a collision is not expected before
|
||||
/// some billions of episodes.
|
||||
#[must_use]
|
||||
pub fn share_id(feed_url: &str, guid: &str) -> String {
|
||||
use sha2::Digest;
|
||||
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
|
||||
let feed = ipx_directory::store::normalise(feed_url).unwrap_or_else(|| feed_url.trim().to_owned());
|
||||
let digest = sha2::Sha256::digest(format!("{feed}\n{guid}").as_bytes());
|
||||
let bits = digest[..8].iter().fold(0u64, |n, b| (n << 8) | u64::from(*b));
|
||||
(0..10).map(|i| ALPHABET[((bits >> (59 - 5 * i)) & 31) as usize] as char).collect()
|
||||
}
|
||||
|
||||
impl Store {
|
||||
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=") {
|
||||
// A notice for every CREATE ... IF NOT EXISTS on something there, otherwise.
|
||||
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.
|
||||
if url.contains(":memory:") {
|
||||
opts.max_connections(1);
|
||||
}
|
||||
// Kept open: a Postgres connection takes 80-330 ms to open here (#241).
|
||||
if keep_open && url.starts_with("postgres") {
|
||||
opts.max_connections(4).min_connections(2);
|
||||
}
|
||||
let db = Database::connect(opts).await?;
|
||||
for sql in SCHEMA {
|
||||
db.execute_unprepared(sql).await?;
|
||||
}
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn scratch() -> Self {
|
||||
Self::open("sqlite::memory:", false).await.unwrap()
|
||||
}
|
||||
|
||||
fn stmt(&self, sql: &str, values: Vec<Value>) -> Statement {
|
||||
Statement::from_sql_and_values(self.db.get_database_backend(), sql, values)
|
||||
}
|
||||
|
||||
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<QueryResult>> {
|
||||
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
self.rows("SELECT 1", vec![]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keeps an episode, or what it says now if it was shared before, and gives its id. The
|
||||
/// server that first shared it stays its sharer.
|
||||
pub async fn put(&self, instance: &str, e: &Episode) -> Result<String> {
|
||||
let id = share_id(&e.feed_url, &e.guid);
|
||||
let t = now();
|
||||
self.db
|
||||
.execute_raw(self.stmt(
|
||||
"INSERT INTO shares (id, feed_url, guid, feed_title, feed_image, title, description, link,
|
||||
published, duration, audio_url, mime, instance, created, updated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET feed_title = excluded.feed_title, feed_image = excluded.feed_image,
|
||||
title = excluded.title, description = excluded.description, link = excluded.link,
|
||||
published = excluded.published, duration = excluded.duration, audio_url = excluded.audio_url,
|
||||
mime = excluded.mime, updated = excluded.updated",
|
||||
vec![
|
||||
id.clone().into(),
|
||||
e.feed_url.clone().into(),
|
||||
e.guid.clone().into(),
|
||||
e.feed_title.clone().into(),
|
||||
e.feed_image.clone().into(),
|
||||
e.title.clone().into(),
|
||||
e.description.clone().into(),
|
||||
e.link.clone().into(),
|
||||
e.published.into(),
|
||||
e.duration.into(),
|
||||
e.audio_url.clone().into(),
|
||||
e.mime.clone().into(),
|
||||
instance.into(),
|
||||
t.into(),
|
||||
],
|
||||
))
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> Result<Option<Share>> {
|
||||
let Some(r) = self
|
||||
.rows(
|
||||
"SELECT feed_url, guid, feed_title, feed_image, title, description, link, published, duration,
|
||||
audio_url, mime FROM shares WHERE id = $1",
|
||||
vec![id.into()],
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Share {
|
||||
id: id.to_owned(),
|
||||
episode: Episode {
|
||||
feed_url: r.try_get("", "feed_url")?,
|
||||
guid: r.try_get("", "guid")?,
|
||||
feed_title: r.try_get("", "feed_title")?,
|
||||
feed_image: r.try_get("", "feed_image")?,
|
||||
title: r.try_get("", "title")?,
|
||||
description: r.try_get("", "description")?,
|
||||
link: r.try_get("", "link")?,
|
||||
published: r.try_get("", "published")?,
|
||||
duration: r.try_get("", "duration")?,
|
||||
audio_url: r.try_get("", "audio_url")?,
|
||||
mime: r.try_get("", "mime")?,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn episode() -> Episode {
|
||||
Episode {
|
||||
feed_url: "https://example.com/feed.xml".into(),
|
||||
guid: "ep-1".into(),
|
||||
feed_title: Some("A Show".into()),
|
||||
feed_image: Some("https://example.com/art.jpg".into()),
|
||||
title: Some("Episode One".into()),
|
||||
description: Some("<p>Notes</p>".into()),
|
||||
link: None,
|
||||
published: Some(1_800_000_000),
|
||||
duration: Some(3600),
|
||||
audio_url: "https://example.com/ep1.mp3".into(),
|
||||
mime: Some("audio/mpeg".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_episode_has_one_short_id_whoever_shares_it() {
|
||||
let a = share_id("https://example.com/feed.xml", "ep-1");
|
||||
assert_eq!(a.len(), 10);
|
||||
assert!(a.bytes().all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)));
|
||||
assert_eq!(a, share_id("HTTPS://Example.com/feed.xml", "ep-1"), "the address as every server writes it");
|
||||
assert_ne!(a, share_id("https://example.com/feed.xml", "ep-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sharing_again_keeps_the_link_and_takes_what_the_episode_says_now() {
|
||||
let s = Store::scratch().await;
|
||||
let mut e = episode();
|
||||
let id = s.put("tower", &e).await.unwrap();
|
||||
e.title = Some("Episode One, fixed".into());
|
||||
assert_eq!(s.put("elsewhere", &e).await.unwrap(), id);
|
||||
let got = s.get(&id).await.unwrap().unwrap();
|
||||
assert_eq!(got.episode, e);
|
||||
assert!(s.get("nothere").await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
17
crates/tui/CHANGELOG.md
Normal file
17
crates/tui/CHANGELOG.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Changelog: ipx-tui
|
||||
|
||||
The reader in a terminal (README.md). ipx's changelog is [crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md).
|
||||
|
||||
Its version is in `crates/tui/Cargo.toml`.
|
||||
|
||||
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
|
||||
|
||||
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).
|
||||
- `p` plays the item's file in mpv (#439).
|
||||
- Moving onto an item with `j` or `k` marks it read, as on the page (#441).
|
||||
- Feeds are listed as on the page: a subscribed OPML is a folder `Space` opens, and pinned feeds are at the top; `P` pins and unpins (#440).
|
||||
21
crates/tui/Cargo.toml
Normal file
21
crates/tui/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "ipx-tui"
|
||||
version = "0.1.0-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "ipx"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap = { workspace = true, features = ["env"] }
|
||||
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
32
crates/tui/README.md
Normal file
32
crates/tui/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# ipx-tui
|
||||
|
||||
The `ipx` command. The server, which had that name, is `ipxd`.
|
||||
|
||||
iPX's reader in a terminal: your feeds on the left, a feed's items above, the item's text below.
|
||||
It talks to iPX's API as the page does, as you, with an API token made in the page's Settings.
|
||||
|
||||
```sh
|
||||
cargo build --release -p ipx-tui
|
||||
IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
|
||||
```
|
||||
|
||||
`--url` and `--token` do the same as the two variables.
|
||||
|
||||
| key | does |
|
||||
|---|---|
|
||||
| `j` `k`, arrows | move, or scroll the item's text |
|
||||
| `Enter`, `l`, `Tab` | open the feed or the item |
|
||||
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
|
||||
| `Space`, `PgUp` `PgDn` | page through the item's text |
|
||||
| `Space` | on the feeds, open or close a folder: a subscribed OPML, its feeds inside |
|
||||
| `P` | pin the feed or folder to the top of the list, or unpin it |
|
||||
| `p` | play the item's file in [mpv](https://mpv.io), in a window of its own |
|
||||
| `m` | mark the item read or unread |
|
||||
| `f` | flag or unflag it |
|
||||
| `u` | unread items only, or every item (unread only to start) |
|
||||
| `r` | reload |
|
||||
| `q` | quit |
|
||||
|
||||
An item is marked read when you move onto it or open it, as on the page. It shows a feed's newest
|
||||
200 items. Playing needs `mpv` on the path (`brew install mpv` on a Mac),
|
||||
and where you stop is not saved to iPX as it is on the page.
|
||||
641
crates/tui/src/main.rs
Normal file
641
crates/tui/src/main.rs
Normal file
@@ -0,0 +1,641 @@
|
||||
//! A terminal reader for ipx (#437): your feeds, a feed's items and an item's text, through the
|
||||
//! same API the page uses, as the person whose API token it is given.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Modifier, Style, Stylize};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
|
||||
use ratatui::{DefaultTerminal, Frame};
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ipx", version, about = "Read iPX in a terminal")]
|
||||
struct Args {
|
||||
/// Where iPX is, as a browser reaches it.
|
||||
#[arg(long, env = "IPX_URL", default_value = "http://localhost:8099")]
|
||||
url: Url,
|
||||
/// An API token, made in the page's Settings.
|
||||
#[arg(long, env = "IPX_API_TOKEN", hide_env_values = true)]
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// The part of web.rs's FeedRow this needs.
|
||||
#[derive(Deserialize)]
|
||||
struct Feed {
|
||||
id: String,
|
||||
url: String,
|
||||
title: Option<String>,
|
||||
unread: i64,
|
||||
/// The OPML it came from.
|
||||
group: Option<String>,
|
||||
hide_from_all: bool,
|
||||
pinned: bool,
|
||||
}
|
||||
|
||||
/// The feed list as the page draws it (feeds.ts renderFeeds), as indexes into `feeds` and
|
||||
/// whether each is inside a folder: pinned rows first, a subscribed OPML a folder with its feeds
|
||||
/// under it when open (those with unread first), and a pinned feed lifted out of its folder.
|
||||
fn order(feeds: &[Feed], open: &HashSet<String>) -> Vec<(usize, bool)> {
|
||||
let listed = |id: &str| feeds.iter().any(|f| f.id == id);
|
||||
let mut tops: Vec<usize> =
|
||||
(0..feeds.len()).filter(|&i| feeds[i].pinned || !feeds[i].group.as_deref().is_some_and(listed)).collect();
|
||||
tops.sort_by_key(|&i| !feeds[i].pinned);
|
||||
let mut rows = vec![];
|
||||
for t in tops {
|
||||
rows.push((t, false));
|
||||
if open.contains(&feeds[t].id) {
|
||||
let mut inside: Vec<usize> = (0..feeds.len())
|
||||
.filter(|&i| !feeds[i].pinned && feeds[i].group.as_deref() == Some(feeds[t].id.as_str()))
|
||||
.collect();
|
||||
inside.sort_by_key(|&i| feeds[i].unread == 0);
|
||||
rows.extend(inside.into_iter().map(|i| (i, true)));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The part of db.rs's EntryRow this needs.
|
||||
#[derive(Deserialize)]
|
||||
struct Entry {
|
||||
guid: String,
|
||||
feed_id: String,
|
||||
title: Option<String>,
|
||||
link: Option<String>,
|
||||
published: Option<i64>,
|
||||
description: Option<String>,
|
||||
read: bool,
|
||||
flagged: bool,
|
||||
enclosures: Vec<Enclosure>,
|
||||
}
|
||||
|
||||
/// The part of db.rs's EncRow this needs: its id is its address, `/media/{id}`, which is there
|
||||
/// only once it is downloaded (`path`); one skipped or still to come answers 404.
|
||||
#[derive(Deserialize)]
|
||||
struct Enclosure {
|
||||
id: i64,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
/// What `p` plays: the item's first file iPX has.
|
||||
fn playable(e: &Entry) -> Option<&Enclosure> {
|
||||
e.enclosures.iter().find(|x| x.path.is_some())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EntryPage {
|
||||
total: i64,
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
struct Api {
|
||||
http: reqwest::blocking::Client,
|
||||
base: Url,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
/// The API's address for these path segments, each escaped: a guid is often a URL itself.
|
||||
fn url(&self, segments: &[&str]) -> Url {
|
||||
let mut url = self.base.clone();
|
||||
url.path_segments_mut().expect("an http URL has a path").pop_if_empty().extend(segments);
|
||||
url
|
||||
}
|
||||
|
||||
fn send(&self, req: reqwest::blocking::RequestBuilder) -> Result<reqwest::blocking::Response> {
|
||||
let res = req.bearer_auth(&self.token).send()?;
|
||||
if !res.status().is_success() {
|
||||
bail!("{} {}", res.status(), res.text().unwrap_or_default());
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Plays a file in mpv, in a window of its own so the terminal stays the reader's, with the
|
||||
/// token in a header as every other request has it.
|
||||
// shortcut: the token is on mpv's command line, which `ps` shows anyone on this machine;
|
||||
// pass it through a private mpv config file if the reader is used on a shared one.
|
||||
fn play(&self, enc: &Enclosure) -> Result<()> {
|
||||
let url = self.url(&["media", &enc.id.to_string()]);
|
||||
std::process::Command::new("mpv")
|
||||
.arg("--force-window=yes")
|
||||
.arg(format!("--http-header-fields=Authorization: Bearer {}", self.token))
|
||||
.arg(url.as_str())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("could not start mpv; is it installed?")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_pinned(&self, feed: &str, on: bool) -> Result<()> {
|
||||
let req = self.http.patch(self.url(&["api", "feeds", feed])).json(&serde_json::json!({ "pinned": on }));
|
||||
self.send(req).map(drop)
|
||||
}
|
||||
|
||||
fn feeds(&self) -> Result<Vec<Feed>> {
|
||||
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
|
||||
}
|
||||
|
||||
/// The newest items of one feed, or of all of them when `feed` is None.
|
||||
// shortcut: the newest 200 only, no paging; page through `offset` if someone wants older.
|
||||
fn entries(&self, feed: Option<&str>, unread_only: bool) -> Result<EntryPage> {
|
||||
let mut url = match feed {
|
||||
Some(id) => self.url(&["api", "feeds", id, "entries"]),
|
||||
None => self.url(&["api", "entries"]),
|
||||
};
|
||||
let filter = if unread_only { "unread" } else { "all" };
|
||||
url.query_pairs_mut().append_pair("limit", "200").append_pair("filter", filter);
|
||||
Ok(self.send(self.http.get(url))?.json()?)
|
||||
}
|
||||
|
||||
/// Sets an item's read or flagged, and answers with its feed's row, for the new unread count.
|
||||
fn set_flag(&self, e: &Entry, flag: &str, on: bool) -> Result<Option<Feed>> {
|
||||
let url = self.url(&["api", "entries", &e.feed_id, &e.guid, "flags"]);
|
||||
Ok(self.send(self.http.post(url).json(&serde_json::json!({ flag: on })))?.json()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum Pane {
|
||||
Feeds,
|
||||
Items,
|
||||
Text,
|
||||
}
|
||||
|
||||
struct App {
|
||||
api: Api,
|
||||
feeds: Vec<Feed>,
|
||||
/// What the feed list shows, after All Subscriptions, which is index 0 of `feed_list`.
|
||||
rows: Vec<(usize, bool)>,
|
||||
/// The folders open; they start closed, as on the page, since one can hold dozens of feeds.
|
||||
open: HashSet<String>,
|
||||
feed_list: ListState,
|
||||
entries: Vec<Entry>,
|
||||
total: i64,
|
||||
entry_list: ListState,
|
||||
pane: Pane,
|
||||
scroll: u16,
|
||||
unread_only: bool,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn feed(&self) -> Option<&Feed> {
|
||||
let i = self.feed_list.selected()?;
|
||||
self.rows.get(i.checked_sub(1)?).map(|&(f, _)| &self.feeds[f])
|
||||
}
|
||||
|
||||
fn feed_id(&self) -> Option<&str> {
|
||||
self.feed().map(|f| f.id.as_str())
|
||||
}
|
||||
|
||||
/// The feed list again after the feeds or the open folders change, on the same feed, or on
|
||||
/// its folder when that has just closed over it.
|
||||
fn arrange(&mut self) {
|
||||
let was = self.feed().map(|f| (f.id.clone(), f.group.clone()));
|
||||
self.rows = order(&self.feeds, &self.open);
|
||||
let at = |id: &str| self.rows.iter().position(|&(f, _)| self.feeds[f].id == id).map(|i| i + 1);
|
||||
let row = was.and_then(|(id, group)| at(&id).or_else(|| group.and_then(|g| at(&g))));
|
||||
self.feed_list.select(Some(row.unwrap_or(0)));
|
||||
}
|
||||
|
||||
/// The unread count a row shows: a folder holds no items itself, so it is what is inside.
|
||||
fn unread(&self, f: &Feed) -> i64 {
|
||||
let inside: Vec<_> = self.feeds.iter().filter(|c| c.group.as_deref() == Some(f.id.as_str())).collect();
|
||||
if inside.is_empty() { f.unread } else { inside.iter().map(|c| c.unread).sum() }
|
||||
}
|
||||
|
||||
fn entry(&self) -> Option<&Entry> {
|
||||
self.entries.get(self.entry_list.selected()?)
|
||||
}
|
||||
|
||||
/// Runs an API call, putting what went wrong in the status line instead of ending the program.
|
||||
fn attempt<T>(&mut self, what: Result<T>) -> Option<T> {
|
||||
what.map_err(|e| self.status = format!("{e:#}")).ok()
|
||||
}
|
||||
|
||||
fn load_feeds(&mut self) {
|
||||
if let Some(feeds) = self.attempt(self.api.feeds()) {
|
||||
self.feeds = feeds;
|
||||
self.arrange();
|
||||
self.status.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn load_entries(&mut self) {
|
||||
let page = self.api.entries(self.feed_id(), self.unread_only);
|
||||
if let Some(page) = self.attempt(page) {
|
||||
self.entries = page.entries;
|
||||
self.total = page.total;
|
||||
self.entry_list.select(if self.entries.is_empty() { None } else { Some(0) });
|
||||
self.scroll = 0;
|
||||
self.status.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the selected item's read or flagged, as the page does.
|
||||
fn flag(&mut self, flag: &str, on: impl Fn(&Entry) -> bool) {
|
||||
let Some(e) = self.entry() else { return };
|
||||
let on = on(e);
|
||||
let result = self.api.set_flag(e, flag, on);
|
||||
let Some(row) = self.attempt(result) else { return };
|
||||
if let Some(i) = self.entry_list.selected() {
|
||||
let e = &mut self.entries[i];
|
||||
if flag == "read" { e.read = on } else { e.flagged = on }
|
||||
}
|
||||
if let Some(row) = row
|
||||
&& let Some(f) = self.feeds.iter_mut().find(|f| f.id == row.id)
|
||||
{
|
||||
f.unread = row.unread;
|
||||
}
|
||||
}
|
||||
|
||||
/// Selecting an item is reading it, as on the page (items.ts markRead), j and k included.
|
||||
fn mark_read(&mut self) {
|
||||
if self.entry().is_some_and(|e| !e.read) {
|
||||
self.flag("read", |_| true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the selected item, which marks it read, as opening one on the page does.
|
||||
fn open(&mut self) {
|
||||
self.scroll = 0;
|
||||
self.pane = Pane::Text;
|
||||
self.mark_read();
|
||||
}
|
||||
|
||||
fn step(&mut self, down: bool) {
|
||||
let list = match self.pane {
|
||||
Pane::Feeds => &mut self.feed_list,
|
||||
Pane::Items => &mut self.entry_list,
|
||||
Pane::Text => {
|
||||
self.scroll = if down { self.scroll.saturating_add(1) } else { self.scroll.saturating_sub(1) };
|
||||
return;
|
||||
}
|
||||
};
|
||||
if down {
|
||||
list.select_next()
|
||||
} else {
|
||||
list.select_previous()
|
||||
}
|
||||
if self.pane == Pane::Items {
|
||||
self.scroll = 0;
|
||||
self.mark_read();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a key, answering false when it is time to quit.
|
||||
fn key(&mut self, code: KeyCode) -> bool {
|
||||
match code {
|
||||
KeyCode::Char('q') => return false,
|
||||
KeyCode::Esc if self.pane == Pane::Feeds => return false,
|
||||
KeyCode::Char('j') | KeyCode::Down => self.step(true),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.step(false),
|
||||
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
|
||||
self.scroll = self.scroll.saturating_add(10)
|
||||
}
|
||||
KeyCode::Char(' ') if self.pane == Pane::Feeds => {
|
||||
if let Some(id) = self.feed_id().map(str::to_owned) {
|
||||
if !self.open.remove(&id) {
|
||||
self.open.insert(id);
|
||||
}
|
||||
self.arrange();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('P') => {
|
||||
let Some(f) = self.feed() else { return true };
|
||||
let (id, on) = (f.id.clone(), !f.pinned);
|
||||
if self.attempt(self.api.set_pinned(&id, on)).is_some() {
|
||||
self.load_feeds();
|
||||
self.status = if on { "pinned to the top" } else { "unpinned" }.into();
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10),
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane {
|
||||
Pane::Feeds => {
|
||||
self.load_entries();
|
||||
self.pane = Pane::Items;
|
||||
}
|
||||
Pane::Items => self.open(),
|
||||
Pane::Text => {}
|
||||
},
|
||||
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.pane = match self.pane {
|
||||
Pane::Text => Pane::Items,
|
||||
_ => Pane::Feeds,
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
let Some(e) = self.entry() else { return true };
|
||||
match playable(e).map(|enc| self.api.play(enc)) {
|
||||
Some(r) => {
|
||||
if self.attempt(r).is_some() {
|
||||
self.status = "playing in mpv".into();
|
||||
}
|
||||
}
|
||||
None if e.enclosures.is_empty() => self.status = "this item has no file to play".into(),
|
||||
None => self.status = "iPX has not downloaded this item's file".into(),
|
||||
}
|
||||
}
|
||||
KeyCode::Char('m') => self.flag("read", |e| !e.read),
|
||||
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
|
||||
KeyCode::Char('u') => {
|
||||
self.unread_only = !self.unread_only;
|
||||
self.load_entries();
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
self.load_feeds();
|
||||
self.load_entries();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let [main, foot] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
|
||||
let [left, right] = Layout::horizontal([Constraint::Percentage(28), Constraint::Fill(1)]).areas(main);
|
||||
let [top, bottom] = Layout::vertical([Constraint::Percentage(40), Constraint::Fill(1)]).areas(right);
|
||||
let block = |title: String, pane: Pane| {
|
||||
let b = Block::bordered().title(title);
|
||||
if self.pane == pane { b.border_style(Style::new().bold()) } else { b.dim() }
|
||||
};
|
||||
let picked = Style::new().add_modifier(Modifier::REVERSED);
|
||||
|
||||
// What the page counts (feeds.ts inAll): not a feed hidden from All Subscriptions, or
|
||||
// one from an OPML that is (#288).
|
||||
let hidden = |id: &str| self.feeds.iter().any(|g| g.id == id && g.hide_from_all);
|
||||
let all: i64 = self
|
||||
.feeds
|
||||
.iter()
|
||||
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
|
||||
.map(|f| f.unread)
|
||||
.sum();
|
||||
let feeds =
|
||||
std::iter::once(format!("All Subscriptions ({all})")).chain(self.rows.iter().map(|&(i, inside)| {
|
||||
let f = &self.feeds[i];
|
||||
let folder = self.feeds.iter().any(|c| c.group.as_deref() == Some(f.id.as_str()));
|
||||
let lead = match (inside, folder, self.open.contains(&f.id)) {
|
||||
(true, ..) => " ",
|
||||
(_, true, true) => "▾ ",
|
||||
(_, true, false) => "▸ ",
|
||||
_ => " ",
|
||||
};
|
||||
let pin = if f.pinned && !inside { "↑ " } else { "" };
|
||||
let name = f.title.as_deref().unwrap_or(&f.url);
|
||||
match self.unread(f) {
|
||||
0 => format!("{lead}{pin}{name}"),
|
||||
n => format!("{lead}{pin}{name} ({n})"),
|
||||
}
|
||||
}));
|
||||
let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked);
|
||||
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
|
||||
|
||||
let items = self.entries.iter().map(|e| {
|
||||
let mark = format!("{}{}", if e.read { ' ' } else { '•' }, if e.flagged { '★' } else { ' ' });
|
||||
let line = format!("{mark} {:<10} {}", date(e.published), e.title.as_deref().unwrap_or("(untitled)"));
|
||||
if e.read { ListItem::new(line) } else { ListItem::new(line).bold() }
|
||||
});
|
||||
let shown = if self.unread_only { "unread" } else { "items" };
|
||||
let title = format!("{} of {} {shown}", self.entries.len(), self.total);
|
||||
let items = List::new(items).block(block(title, Pane::Items)).highlight_style(picked);
|
||||
frame.render_stateful_widget(items, top, &mut self.entry_list);
|
||||
|
||||
let text = match self.entry() {
|
||||
Some(e) => {
|
||||
let mut lines = vec![Line::from(e.title.clone().unwrap_or_default()).bold()];
|
||||
if let Some(link) = &e.link {
|
||||
lines.push(Line::from(link.clone()).dim());
|
||||
}
|
||||
if playable(e).is_some() {
|
||||
lines.push(Line::from("▶ p to play").dim());
|
||||
}
|
||||
lines.push(Line::default());
|
||||
lines
|
||||
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
|
||||
lines
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
|
||||
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
|
||||
|
||||
let help = "j/k move enter open h back space folder P pin p play m read f flag u unread only r reload q quit";
|
||||
let foot_text = if self.status.is_empty() { help } else { &self.status };
|
||||
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
|
||||
}
|
||||
}
|
||||
|
||||
fn date(t: Option<i64>) -> String {
|
||||
t.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// An item's HTML as plain text for a terminal: tags dropped, blocks on lines of their own,
|
||||
/// entities decoded. ipx has already cleaned it, so there is no script or style to skip.
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = html;
|
||||
loop {
|
||||
let (text, tag) = match rest.find('<') {
|
||||
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
|
||||
None => (rest, None),
|
||||
};
|
||||
// HTML's own whitespace is not layout; the tags say where lines break.
|
||||
if text.starts_with(char::is_whitespace) && !out.is_empty() && !out.ends_with(['\n', ' ']) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(&decode(&text.split_whitespace().collect::<Vec<_>>().join(" ")));
|
||||
if text.ends_with(char::is_whitespace) && !text.trim().is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
let Some(tag) = tag else { break };
|
||||
let Some(end) = tag.find('>') else { break };
|
||||
let (inner, closing) = match tag[..end].strip_prefix('/') {
|
||||
Some(t) => (t, true),
|
||||
None => (&tag[..end], false),
|
||||
};
|
||||
let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_ascii_lowercase();
|
||||
match name.as_str() {
|
||||
"br" => out.push('\n'),
|
||||
"li" => {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
if !closing {
|
||||
out.push_str("• ");
|
||||
}
|
||||
}
|
||||
"p" | "div" | "ul" | "ol" | "blockquote" | "pre" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
||||
if !out.is_empty() && !out.ends_with("\n\n") =>
|
||||
{
|
||||
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
rest = &tag[end + 1..];
|
||||
}
|
||||
out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
/// HTML's entities, the named ones feeds use and every numbered one.
|
||||
fn decode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find('&') {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
let end = rest[..rest.len().min(12)].find(';');
|
||||
let ch = end.and_then(|end| match &rest[1..end] {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some(' '),
|
||||
"hellip" => Some('…'),
|
||||
"mdash" => Some('—'),
|
||||
"ndash" => Some('–'),
|
||||
"rsquo" => Some('’'),
|
||||
"lsquo" => Some('‘'),
|
||||
"rdquo" => Some('”'),
|
||||
"ldquo" => Some('“'),
|
||||
n => {
|
||||
let n = n.strip_prefix('#')?;
|
||||
let code = match n.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
||||
None => n.parse().ok()?,
|
||||
};
|
||||
char::from_u32(code)
|
||||
}
|
||||
});
|
||||
match (ch, end) {
|
||||
(Some(c), Some(end)) => {
|
||||
out.push(c);
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
_ => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| app.draw(f))?;
|
||||
// A poll, not a blocking read, so a resize redraws without waiting for a key.
|
||||
if !event::poll(Duration::from_millis(500))? {
|
||||
continue;
|
||||
}
|
||||
if let Event::Key(k) = event::read()?
|
||||
&& k.kind == KeyEventKind::Press
|
||||
&& !app.key(k.code)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let http = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30)).build()?;
|
||||
let api = Api { http, base: args.url, token: args.token };
|
||||
// Before the terminal is taken over, so a wrong address or token is said plainly.
|
||||
let feeds = api.feeds().context("could not read your feeds")?;
|
||||
let mut app = App {
|
||||
api,
|
||||
feeds,
|
||||
rows: vec![],
|
||||
open: HashSet::new(),
|
||||
feed_list: ListState::default().with_selected(Some(0)),
|
||||
entries: vec![],
|
||||
total: 0,
|
||||
entry_list: ListState::default(),
|
||||
pane: Pane::Feeds,
|
||||
scroll: 0,
|
||||
unread_only: true,
|
||||
status: String::new(),
|
||||
};
|
||||
app.arrange();
|
||||
app.load_entries();
|
||||
let mut terminal = ratatui::init();
|
||||
let result = run(&mut terminal, &mut app);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_of_html() {
|
||||
let html = "<p>One & two,\n <b>bold</b>.</p><p>Next line<br>broken — ’s &bogus; &</p>\
|
||||
<ul><li>a</li><li>b</li></ul>";
|
||||
assert_eq!(text_of(html), "One & two, bold.\n\nNext line\nbroken — ’s &bogus; &\n\n• a\n• b");
|
||||
assert_eq!(text_of("plain"), "plain");
|
||||
assert_eq!(text_of("a <i>b</i> c"), "a b c");
|
||||
assert_eq!(text_of("<b>x</b> <i>y</i>"), "x y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feeds_in_the_pages_order() {
|
||||
let feed = |id: &str, group: Option<&str>, pinned: bool, unread: i64| Feed {
|
||||
id: id.into(),
|
||||
url: id.into(),
|
||||
title: None,
|
||||
unread,
|
||||
group: group.map(Into::into),
|
||||
hide_from_all: false,
|
||||
pinned,
|
||||
};
|
||||
let feeds = [
|
||||
feed("a", None, false, 0),
|
||||
feed("opml", None, false, 0),
|
||||
feed("in-read", Some("opml"), false, 0),
|
||||
feed("in-unread", Some("opml"), false, 3),
|
||||
feed("in-pinned", Some("opml"), true, 0),
|
||||
feed("b", None, true, 0),
|
||||
feed("lost", Some("gone"), false, 0),
|
||||
];
|
||||
let ids = |open: &[&str]| -> Vec<(&str, bool)> {
|
||||
let open = open.iter().map(|s| s.to_string()).collect();
|
||||
order(&feeds, &open).into_iter().map(|(i, inside)| (feeds[i].id.as_str(), inside)).collect()
|
||||
};
|
||||
// Closed, a folder hides its feeds; a pinned one is out of it, at the top; a feed whose
|
||||
// folder is not listed stands on its own.
|
||||
assert_eq!(ids(&[]), [("in-pinned", false), ("b", false), ("a", false), ("opml", false), ("lost", false)]);
|
||||
assert_eq!(
|
||||
ids(&["opml"]),
|
||||
[
|
||||
("in-pinned", false),
|
||||
("b", false),
|
||||
("a", false),
|
||||
("opml", false),
|
||||
("in-unread", true),
|
||||
("in-read", true),
|
||||
("lost", false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plays_the_first_downloaded_file() {
|
||||
let e: Entry = serde_json::from_value(serde_json::json!({
|
||||
"guid": "g", "feed_id": "f", "title": null, "link": null, "published": null,
|
||||
"description": null, "read": false, "flagged": false,
|
||||
"enclosures": [{"id": 1, "path": null}, {"id": 2, "path": "a.mp3"}, {"id": 3, "path": "b.mp3"}],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(playable(&e).map(|x| x.id), Some(2));
|
||||
}
|
||||
}
|
||||
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,6 +1,7 @@
|
||||
# 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 / ipodderx.
|
||||
# `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:
|
||||
|
||||
@@ -5,7 +5,7 @@ Three services, each a container of its own with its own version and changelog (
|
||||
| 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 | The daemon and CLI, one binary, `ipxd`. `ipxd 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
|
||||
@@ -27,11 +27,31 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
|
||||
|
||||
| File | Responsibility | What it replaced in the Python |
|
||||
|---|---|---|
|
||||
| `crates/ipx/src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
||||
| `crates/ipx/src/lib.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` |
|
||||
| `crates/ipx/src/main.rs` | `ipxd`, which calls `main`: a library under it keeps the modules' log targets `ipx::...` | — |
|
||||
| `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` | Conditional GET, RSS/Atom/OPML parsing, `url_looks_private` | `FeedData.__getFeed/__getEntries` |
|
||||
| `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` |
|
||||
@@ -41,16 +61,21 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
|
||||
| `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/directory/src/lib.rs` | The directory's HTTP API, its token check, the refresher | — |
|
||||
| `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 | — |
|
||||
| `crates/tui/src/main.rs` | `ipx` (package `ipx-tui`), the reader in a terminal: ratatui over the API, with an API token | — |
|
||||
| `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`/`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/build.mts` | 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.ts` | 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 a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
||||
@@ -60,7 +85,10 @@ 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
|
||||
@@ -81,7 +109,8 @@ and the files, and no page.
|
||||
|
||||
```
|
||||
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,
|
||||
@@ -92,8 +121,17 @@ 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.
|
||||
|
||||
@@ -120,7 +158,7 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
|
||||
|
||||
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
|
||||
(`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which
|
||||
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with
|
||||
`ipxd add`, `rm` and `import` send after changing them in a process of their own; answered with
|
||||
`status`).
|
||||
|
||||
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
||||
@@ -177,10 +215,10 @@ before they reach the page.
|
||||
|
||||
```sh
|
||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/page-smoke.ts # the page script loads and every selector it wires at load exists
|
||||
node tests/native-bridge.ts # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
node tests/load/run.js # k6: many people at once against a daemon with 1,500 generated feeds
|
||||
node tests/load/run.ts # k6: many people at once against a daemon with 1,500 generated feeds
|
||||
```
|
||||
|
||||
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that
|
||||
|
||||
45
docs/cli.md
45
docs/cli.md
@@ -10,15 +10,15 @@ processes must never download the same thing. `--local` forces the work to happe
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `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` 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 the HTTP API (the page is ipx-web's) |
|
||||
| `ipxd list` | Subscriptions and their state |
|
||||
| `ipxd status` | Counts: feeds, pending, downloaded |
|
||||
| `ipxd fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
|
||||
| `ipxd 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 |
|
||||
| `ipxd rm <feed>` | Unsubscribe; downloads and history are kept |
|
||||
| `ipxd import <file.opml>` / `ipxd 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 |
|
||||
| `ipxd reap [--dry-run]` | Run retention now |
|
||||
| `ipxd user <add\|list\|passwd\|rm>` | Accounts for the web UI |
|
||||
| `ipxd 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
|
||||
@@ -29,27 +29,28 @@ its next change, undoing them. If it does not answer they say so: restart it.
|
||||
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.
|
||||
|
||||
```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
|
||||
ipx user list # who exists, and how each signs in
|
||||
ipx user rm sam # account, subscriptions and read state
|
||||
echo -n 'a good password' | ipxd user add ray # local account
|
||||
ipxd user add ray@example.com --no-password # signs in through the proxy only
|
||||
echo -n 'a good password' | ipxd user passwd admin # change a password; signs the account out everywhere
|
||||
ipxd user list # who exists, and how each signs in
|
||||
ipxd 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:
|
||||
|
||||
```sh
|
||||
read -s PW && echo -n "$PW" | ipx user passwd admin
|
||||
read -s PW && echo -n "$PW" | ipxd user passwd admin
|
||||
```
|
||||
|
||||
## Scanning
|
||||
|
||||
```sh
|
||||
ipx fetch # everything due
|
||||
ipx fetch atp --force # one feed, ignoring its TTL and schedule
|
||||
ipxd fetch # everything due
|
||||
ipxd fetch atp --force # one feed, ignoring its TTL and schedule
|
||||
```
|
||||
|
||||
A scan: conditional GET (`If-None-Match` / `If-Modified-Since`), parse, record new entries, apply
|
||||
@@ -59,8 +60,8 @@ answers `304` and costs one request.
|
||||
## Retention
|
||||
|
||||
```sh
|
||||
ipx reap --dry-run # what would go, oldest first
|
||||
ipx reap # actually delete
|
||||
ipxd reap --dry-run # what would go, oldest first
|
||||
ipxd reap # actually delete
|
||||
```
|
||||
|
||||
Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days`
|
||||
@@ -71,8 +72,8 @@ stops the next scan fetching it again.
|
||||
## The daemon
|
||||
|
||||
```sh
|
||||
ipx daemon # scheduler + socket + HTTP API
|
||||
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
|
||||
ipxd daemon # scheduler + socket + HTTP API
|
||||
ipxd daemon --web 0.0.0.0:8099 # override the configured bind for one run
|
||||
```
|
||||
|
||||
One daemon per socket; a second refuses to start rather than fight over the database. It shuts down
|
||||
|
||||
@@ -4,7 +4,7 @@ Two places. **config.toml** holds what ipx needs before it reaches its database,
|
||||
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
|
||||
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
|
||||
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
|
||||
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
|
||||
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipxd add`, `ipxd rm` and `ipxd import`; they
|
||||
take effect without a restart.
|
||||
|
||||
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
||||
@@ -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]`
|
||||
|
||||
@@ -127,11 +137,35 @@ metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no
|
||||
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.
|
||||
|
||||
## `[push]`
|
||||
|
||||
```toml
|
||||
[push]
|
||||
key_file = "/config/AuthKey_ABC123DEFG.p8" # the APNs key Apple gives once
|
||||
key_id = "ABC123DEFG" # shown beside it in the developer account
|
||||
team_id = "8743L8D729" # the team the iOS app is signed by
|
||||
topic = "net.sdf1.ipx" # the app's bundle id, the default
|
||||
```
|
||||
|
||||
The iOS app's icon shows All Subscriptions' unread count. With this set, ipx keeps it right while
|
||||
the app is closed (#433): once a minute it compares each phone's count with the last one sent, and
|
||||
sends a changed one through Apple's push service, a badge alone, with no alert or sound. Without
|
||||
it, the count waits on the background refresh iOS runs when it chooses.
|
||||
|
||||
The key is made in the Apple developer account under Certificates, Identifiers & Profiles, Keys,
|
||||
with Apple Push Notifications service enabled, and can be downloaded once. One key serves every
|
||||
app of the team, the TestFlight and debug builds both. Keep it as private as the database: it lets
|
||||
anyone send to the team's apps. A key, id or team that is wrong shows as a warning in the log,
|
||||
`push: Apple refused the signing token`, and nothing is sent until it is right.
|
||||
|
||||
The app registers each phone through the page as the person signed in there
|
||||
(`POST /api/push/devices`), and signing out removes it.
|
||||
|
||||
## `[feeds.<id>]`
|
||||
|
||||
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable,
|
||||
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
|
||||
feeds come and go with `ipxd add`, `ipxd rm` and `ipxd import`. The table key is the feed id: stable,
|
||||
human-readable, and used in paths and the API. `ipxd add` derives it from the feed title.
|
||||
|
||||
```toml
|
||||
[feeds.atp]
|
||||
@@ -150,25 +184,38 @@ 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).
|
||||
|
||||
`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.
|
||||
|
||||
**Shared episodes** go to ipx-play ([play.md](play.md)), set up in Admin, Directory, Sharing
|
||||
episodes: its address and this server's token, kept in the database (`play_url`, `play_token`).
|
||||
Only episodes of feeds the directory would be sent; without it, Share hands over an episode's own
|
||||
address.
|
||||
|
||||
**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
|
||||
nobody subscribed, `ipxd 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 |
|
||||
@@ -188,6 +235,7 @@ The page's container reads only its environment (`web/Caddyfile`):
|
||||
| `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).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`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 six hours,
|
||||
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.
|
||||
|
||||
@@ -81,6 +81,11 @@ 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"
|
||||
|
||||
@@ -100,6 +105,23 @@ 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
|
||||
|
||||
79
docs/play.md
Normal file
79
docs/play.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# ipx-play: shared episodes (#447)
|
||||
|
||||
When someone shares an episode in iPX, their server sends it to ipx-play, which keeps it and
|
||||
answers with a short link, `https://play.ipxreader.app/e/<id>`. Anyone can open that link: it
|
||||
shows the episode, plays it from the publisher's own file (from the time it was shared at, with
|
||||
`?t=`), and offers to subscribe in the reader's own podcast app. Overcast's links are the model.
|
||||
|
||||
Only episodes of public feeds are sent, by the same rule that decides what the directory is sent:
|
||||
nothing from a feed marked private, one with a login, one whose address carries a key, or one in
|
||||
a private OPML. ipx-play refuses an address that looks like one too. Everything else is shared by
|
||||
its own address, as before. ipx-play keeps what an episode is and where its file is; it serves no
|
||||
file itself.
|
||||
|
||||
## Running it
|
||||
|
||||
A container of its own, `ipx-play`, in the Arcane project `ipx`, from its own image:
|
||||
|
||||
```sh
|
||||
sh scripts/build-image.sh play
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-play
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-play
|
||||
```
|
||||
|
||||
The compose service, beside `ipx-directory`:
|
||||
|
||||
```yaml
|
||||
ipx-play:
|
||||
image: 192.168.1.130:5000/ipx-play:latest
|
||||
container_name: ipx-play
|
||||
restart: unless-stopped
|
||||
env_file: play.env # PLAY_DATABASE_URL=postgres://...
|
||||
environment:
|
||||
PLAY_LOG_FORMAT: json
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.1.130:4318 # Tempo is not on this network
|
||||
volumes:
|
||||
- /mnt/fast/appdata/ipx-play:/config
|
||||
ports:
|
||||
- "8102:8102"
|
||||
```
|
||||
|
||||
Its config, `/mnt/fast/appdata/ipx-play/play.toml`:
|
||||
|
||||
```toml
|
||||
bind = "0.0.0.0:8102"
|
||||
public_url = "https://play.ipxreader.app"
|
||||
|
||||
# One for each ipx server allowed to make links; `ipx-play token <name>` prints the lines.
|
||||
[[tokens]]
|
||||
name = "tower"
|
||||
token_sha256 = "..."
|
||||
```
|
||||
|
||||
Tokens are read at start: after adding one, restart the container.
|
||||
|
||||
Its database is Postgres, `ipx_play` on the `databases` project's server, its URL in `play.env`
|
||||
beside the compose file (mode 600), as the directory's is. It makes its one table, `shares`, on
|
||||
start.
|
||||
|
||||
The tunnel publishes `play.ipxreader.app` to `http://192.168.1.130:8102` with no Access in front:
|
||||
the pages are anyone's. cloudflared is not on the project's network, so not `ipx-play:8102`, as
|
||||
the directory's route is the host's port too. `/v1` takes a token for everything.
|
||||
|
||||
## Setting up ipx
|
||||
|
||||
In Admin, Directory, Sharing episodes: the address ipx reaches it at, `http://ipx-play:8102`
|
||||
inside Docker (the public address would go out through Cloudflare and back), and the token
|
||||
`ipx-play token tower` printed. Without them Share hands over the episode's own address.
|
||||
|
||||
## The page
|
||||
|
||||
`/e/<id>` shows the show's art, its name, the episode's title, date and length, a player starting
|
||||
at `?t=` (seconds, or `12:34`), the episode's own page if it has one, its notes (sanitized by
|
||||
ammonia), and buttons that open Apple Podcasts, Overcast, Pocket Casts and Castro at the feed, with
|
||||
the feed's address to paste into anything else. Open Graph tags give it a preview in Messages,
|
||||
Slack and the like. It runs no script: the start time is the media fragment `#t=` on the file. Its
|
||||
Content-Security-Policy lets it load only its stylesheet, the art and the file.
|
||||
|
||||
A show that inserts its ads per listener can move the time by an ad's length, as Overcast's links
|
||||
can: the file is the publisher's, fetched fresh by each listener.
|
||||
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.mts` 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.ts`: 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).
|
||||
34
docs/sso.md
34
docs/sso.md
@@ -63,7 +63,7 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
|
||||
see the next section.
|
||||
3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries
|
||||
`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.
|
||||
account. `ipxd user rename rays rays@sdf1.net` fixed that without losing anything.
|
||||
|
||||
### The address to trust, and why it is 192.168.64.1
|
||||
|
||||
@@ -105,7 +105,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
|
||||
send:
|
||||
|
||||
```sh
|
||||
docker exec iPX ipx user rename <old name> <email address>
|
||||
docker exec iPX ipxd user rename <old name> <email address>
|
||||
```
|
||||
|
||||
### Signing out
|
||||
@@ -225,19 +225,19 @@ Check it: the busybox request under [Check it](#check-it), which sends the email
|
||||
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||
|
||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||
in with them until they are given a password (`ipx user passwd <name>`).
|
||||
in with them until they are given a password (`ipxd user passwd <name>`).
|
||||
|
||||
---
|
||||
|
||||
## Everyday administration
|
||||
|
||||
```sh
|
||||
ipx user list # who exists, how each signs in, and when
|
||||
echo -n 'secret123' | ipx user add sam # local account, password on stdin
|
||||
ipx user add sam@example.com --no-password # proxy-only account, made ahead of time
|
||||
ipx user rename sam sam@example.com # give an account the name the proxy sends
|
||||
echo -n 'newsecret' | ipx user passwd sam # change a password
|
||||
ipx user rm sam # remove the account
|
||||
ipxd user list # who exists, how each signs in, and when
|
||||
echo -n 'secret123' | ipxd user add sam # local account, password on stdin
|
||||
ipxd user add sam@example.com --no-password # proxy-only account, made ahead of time
|
||||
ipxd user rename sam sam@example.com # give an account the name the proxy sends
|
||||
echo -n 'newsecret' | ipxd user passwd sam # change a password
|
||||
ipxd user rm sam # remove the account
|
||||
```
|
||||
|
||||
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
|
||||
@@ -261,5 +261,19 @@ the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client
|
||||
API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the
|
||||
only thing between the internet and the API.
|
||||
|
||||
### `ipxapi.sdf1.net`, for the iOS app (#419)
|
||||
|
||||
The app's player cannot send an Access service token's pair of headers as well as sign in, and its
|
||||
cookie from Access ends mid-drive, so it has an address of its own: `ipxapi.sdf1.net`, a route of
|
||||
the `rays-unraid` tunnel to the same `192.168.1.130:8099` as `ipodderx.sdf1.net`, with a proxied
|
||||
CNAME to the tunnel and **no Access application**. That is the exposure the paragraph above warns
|
||||
against, held narrow another way: a custom firewall rule on the `sdf1.net` zone (Security, WAF)
|
||||
blocks every request to that host unless it carries `Authorization: Bearer` and is for
|
||||
`/api/feeds`, `/api/me`, `/api/art`, `/api/entries/*` or `/media/*`. So the page, the sign-in form,
|
||||
`/api/login`, `?token=` sign-in and the admin API answer 403 there, and ipx still checks the
|
||||
token on what is let through. A new path the app needs is added to that rule's expression. The
|
||||
rule is on Cloudflare, not in this repository; if the hostname is ever served without it, the
|
||||
whole of ipx is on the internet behind bearer tokens alone.
|
||||
|
||||
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
|
||||
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.
|
||||
for every `[web]` key, and [cli.md](cli.md) for the `ipxd user` commands.
|
||||
|
||||
@@ -51,17 +51,18 @@ 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
|
||||
echo -n 'a good password' | ipxd user passwd admin
|
||||
```
|
||||
|
||||
## Adding someone
|
||||
|
||||
```sh
|
||||
echo -n 'their password' | ipx user add sam
|
||||
echo -n 'their password' | ipxd user add sam
|
||||
```
|
||||
|
||||
They sign in at `/login` and start with **no feeds**: subscriptions are per person. Adding a feed
|
||||
@@ -106,5 +107,5 @@ and who else is an admin, and read the log, which names everyone's feeds and sig
|
||||
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
|
||||
|
||||
```sh
|
||||
ipx user list # the admin column says who
|
||||
ipxd user list # the admin column says who
|
||||
```
|
||||
|
||||
91
grafana/alerts.py
Normal file
91
grafana/alerts.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""The iPX alert rules in Grafana (#365): what should be looked at now rather than at the next prod
|
||||
check, written out as Grafana's alerting provisioning, beside the dashboard.
|
||||
|
||||
python3 grafana/alerts.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/alerting/ipx.json
|
||||
|
||||
Grafana reads it at start, or at once from POST /api/admin/provisioning/alerting/reload. There is
|
||||
no contact point yet: a rule that fires shows in Grafana's Alerting page, and adding one (email,
|
||||
ntfy, a webhook) is a file in the same folder. Each rule is a query and a threshold on it; the
|
||||
query's value is in the alert, so it says how bad as well as what.
|
||||
"""
|
||||
import json
|
||||
|
||||
# Prometheus was added in Grafana's settings, not provisioned, so its uid is Grafana's own.
|
||||
PROM = "eenybu3ao2t4wb"
|
||||
LOKI = "P8E80F9AEF21F6940"
|
||||
|
||||
|
||||
def rule(uid, title, expr, op, value, for_, summary, *, ds=PROM, window=600, no_data="OK"):
|
||||
"""Fires when `expr` is `op` (gt, lt) `value` for `for_`. No data is fine by default: most of
|
||||
these metrics are absent until there is something to say (no job running, no scan yet)."""
|
||||
return {
|
||||
"uid": f"ipx-{uid}", "title": title, "condition": "C", "for": for_,
|
||||
"data": [
|
||||
{"refId": "A", "relativeTimeRange": {"from": window, "to": 0}, "datasourceUid": ds,
|
||||
# Loki's instant query is a queryType; Prometheus's a flag.
|
||||
"model": {"refId": "A", "expr": expr,
|
||||
**({"queryType": "instant"} if ds == LOKI else {"instant": True})}},
|
||||
{"refId": "C", "datasourceUid": "__expr__",
|
||||
"model": {"refId": "C", "type": "threshold", "expression": "A",
|
||||
"conditions": [{"evaluator": {"type": op, "params": [value]}}]}},
|
||||
],
|
||||
"noDataState": no_data, "execErrState": "Error",
|
||||
"annotations": {"summary": summary},
|
||||
"labels": {"service": "ipx"},
|
||||
}
|
||||
|
||||
|
||||
rules = [
|
||||
# Down: Prometheus cannot reach it. A container gone is no data at all, so that fires too.
|
||||
rule("down", "ipx is down", 'up{job="ipx"}', "lt", 1, "2m",
|
||||
"Prometheus cannot reach ipx's /metrics: the iPX container is down or restarting.", no_data="Alerting"),
|
||||
rule("directory-down", "ipx-directory is down", 'up{job="ipx-directory"}', "lt", 1, "5m",
|
||||
"Prometheus cannot reach the directory's /metrics.", no_data="Alerting"),
|
||||
rule("web-down", "ipx-web is down", 'up{job="ipx-web"}', "lt", 1, "5m",
|
||||
"Prometheus cannot reach Caddy's /metrics: the page is likely down too.", no_data="Alerting"),
|
||||
# The worker: the daemon answers `ipxd status` while it is stuck, so only these say so.
|
||||
rule("scan-stale", "ipx's scans have stopped", 'max(ipx_last_scan_age_seconds{job="ipx"})', "gt", 900, "5m",
|
||||
"No scan has finished for over 15 minutes; scans run at least every 10. The worker is stuck: its job and trace say on what."),
|
||||
rule("job-long", "ipx's worker is stuck on one job", 'max(ipx_worker_job_seconds{job="ipx"})', "gt", 1800, "1m",
|
||||
"One job has run for over 30 minutes (ipx_worker_job_seconds says which kind)."),
|
||||
rule("pool-full", "ipx's database connections are all in use",
|
||||
'max_over_time(ipx_db_connections{job="ipx",state="idle"}[5m])', "lt", 1, "5m",
|
||||
"No idle connection for five minutes: requests and scans are waiting for one (ipx_db_wait_seconds)."),
|
||||
rule("5xx", "ipx is answering with server errors",
|
||||
'sum(increase(ipx_http_request_duration_seconds_count{job="ipx",status=~"5.."}[10m]))', "gt", 5, "0s",
|
||||
"More than five 5xx answers in ten minutes; ipx's log has a warning for each, with its trace."),
|
||||
rule("502", "ipx-web cannot reach ipx",
|
||||
'sum(increase(caddy_http_request_duration_seconds_count{job="ipx-web",code=~"502|504"}[10m]))', "gt", 5, "0s",
|
||||
"Caddy answered 502 or 504 for ipx more than five times in ten minutes: ipx is not answering it."),
|
||||
rule("jev", "ipx's calls to Jev are refused",
|
||||
'sum(increase(ipx_jev_requests_total{job="ipx",result=~"rate_limited|malformed"}[15m]))', "gt", 0, "0s",
|
||||
"TypeSafe refused calls as over its limits (#277) or malformed (#257): ipx is asking wrongly."),
|
||||
rule("signals-stale", "Signals have stopped", 'max(ipx_signals_last_pass_age_seconds{job="ipx"})', "gt", 900, "5m",
|
||||
"The Signals judge has not finished a pass for 15 minutes; its signals trace shows which Jev call holds it."),
|
||||
# Only once it has been up 13 hours: its rounds come in bursts about twelve hours apart, and the
|
||||
# count starts again at each restart, so a deploy would read as stopped.
|
||||
rule("directory-refresh", "The directory has stopped checking feeds",
|
||||
'sum(increase(directory_feed_checks_total{job="ipx-directory"}[13h])) < 1'
|
||||
' and on() max(directory_uptime_seconds{job="ipx-directory"}) > 46800', "lt", 1, "30m",
|
||||
"No feed checked in 13 hours; each is due every 12.", window=46800),
|
||||
# Tower, which everything shares: a stall here is every request slow (2026-10-18, load 160).
|
||||
rule("load", "Tower is overloaded", 'max(node_load5{job="node"})', "gt", 48, "10m",
|
||||
"Load over 48 on twelve threads for ten minutes: builds, or processes blocked on a disk."),
|
||||
rule("memory", "Tower is out of memory", 'min(node_memory_MemAvailable_bytes{job="node"})', "lt", 2e9, "5m",
|
||||
"Under 2 GB available, with no swap: next comes stalling on page faults, then the OOM killer."),
|
||||
# The monitoring itself (#366): nothing else watches it.
|
||||
rule("monitoring-down", "Part of the monitoring is down", 'min by (job) (up{job=~"prometheus|tempo|loki|alloy"})',
|
||||
"lt", 1, "5m", "Prometheus cannot reach Tempo, Loki or Alloy."),
|
||||
rule("tempo-blocks", "Tempo cannot read its stored traces",
|
||||
'(max(tempodb_blocklist_tenant_index_age_seconds) > bool 900) + (max(tempodb_blocklist_length) < bool 1)',
|
||||
"gt", 0, "10m",
|
||||
"Tempo's look over its stored blocks is failing (2026-10-25: one empty block); searches see only the last 15 "
|
||||
"minutes. Its log says which block: move it out of /mnt/user/logs/tempo/blocks/single-tenant."),
|
||||
rule("logs-stopped", "No lines from iPX reach Loki", 'sum(count_over_time({container="iPX"}[3h]))', "lt", 1, "0s",
|
||||
"Loki has nothing from iPX in three hours: Alloy is not shipping it, or the container is down.",
|
||||
ds=LOKI, window=10800, no_data="Alerting"),
|
||||
]
|
||||
|
||||
print(json.dumps({"apiVersion": 1, "groups": [
|
||||
{"orgId": 1, "name": "ipx", "folder": "iPX", "interval": "1m", "rules": rules},
|
||||
]}, indent=2))
|
||||
@@ -8,7 +8,7 @@ top-down as a request does: an overview, Tower, then each container (ipx-web, ip
|
||||
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
|
||||
(ipc.rs log_event), ipx's metrics (metrics.rs), 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
|
||||
@@ -18,11 +18,13 @@ 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
|
||||
# and duration_ms. Events are logged under ipx::scan, the healthcheck's status under ipx::io, so
|
||||
# the filter is on the ev field, not the target.
|
||||
# fields (ev, feed, new, bytes, msg, error.type, ...); a failed request its method, path, route,
|
||||
# status and duration_ms. Events are logged under ipx::scan, `ipxd status`'s answer under ipx::io,
|
||||
# so the filter is on the ev field, not the target.
|
||||
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
|
||||
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
|
||||
# Each request ipx answers, the event stream aside, as a histogram (#364): its line in the log is
|
||||
# debug, so only a failed request's is in Loki.
|
||||
REQ = "ipx_http_request_duration_seconds"
|
||||
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
|
||||
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
|
||||
# Every production span: ipx-web's, ipx's and the directory's, one trace from the page to the
|
||||
@@ -70,9 +72,11 @@ def loki(expr, ref="A", legend=None, instant=False, kind=None):
|
||||
return t
|
||||
|
||||
|
||||
def prom(expr, ref="A", legend=None):
|
||||
def prom(expr, ref="A", legend=None, instant=False):
|
||||
# ipx's /metrics (#194), scraped by Prometheus as job ipx.
|
||||
t = {"refId": ref, "datasource": PROM, "expr": expr}
|
||||
if instant:
|
||||
t["instant"], t["range"], t["format"] = True, False, "table"
|
||||
if legend:
|
||||
t["legendFormat"] = legend
|
||||
return t
|
||||
@@ -105,7 +109,7 @@ def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc
|
||||
"tooltip": {"mode": "multi", "sort": "desc"}})
|
||||
|
||||
|
||||
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
|
||||
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc="", unit=None):
|
||||
return panel("table", title, w, h, x, targets, description=desc,
|
||||
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
|
||||
{"id": "organize", "options": {
|
||||
@@ -113,7 +117,7 @@ def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
|
||||
"service_name": True},
|
||||
"renameByName": rename or {}}}],
|
||||
options={"showHeader": True, "sortBy": sort or []},
|
||||
fieldConfig={"defaults": {}, "overrides": []})
|
||||
fieldConfig={"defaults": {"unit": unit} if unit else {}, "overrides": []})
|
||||
|
||||
|
||||
GOOD_BAD = [{"color": "green", "value": None}, {"color": "orange", "value": 1}]
|
||||
@@ -185,19 +189,36 @@ ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duratio
|
||||
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
|
||||
# Swipes in the reader and what came of them, and a reader that would not scroll (#384).
|
||||
SWIPE = SEL + ' |= "\\"ev\\":\\"swipe\\"" | json | __error__=""'
|
||||
STUCK = SEL + ' |= "\\"ev\\":\\"scroll_stuck\\"" | json | __error__=""'
|
||||
ts("Swipes in the reader", [loki(f'sum by (msg) (count_over_time({SWIPE} [$__interval]))', legend="{{msg}}"),
|
||||
loki(f'sum(count_over_time({STUCK} [$__interval]))', ref="B", legend="would not scroll")],
|
||||
0, w=24, bars=True, stack=True,
|
||||
desc="next, prev and list turned the item; end bounced off the last one loaded; short was too short or slow to count. Would not scroll is a drag with room to move the reader that left it where it was: its line in the log says what lay over it.")
|
||||
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}}")],
|
||||
ts("Requests by status", [prom(f'sum by (status) (increase({REQ}_count{{job="ipx"}}[$__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"),
|
||||
loki(f'quantile_over_time(0.95, {HTTP} | unwrap duration_ms [$__interval]) by ()', ref="B", legend="95th percentile"),
|
||||
loki(f'max_over_time({HTTP} | unwrap duration_ms [$__interval]) by ()', ref="C", legend="slowest")],
|
||||
12, unit="ms")
|
||||
ts("Response time", [prom(f'histogram_quantile(0.5, sum by (le) (rate({REQ}_bucket{{job="ipx"}}[$__rate_interval])))', legend="median"),
|
||||
prom(f'histogram_quantile(0.95, sum by (le) (rate({REQ}_bucket{{job="ipx"}}[$__rate_interval])))', ref="B", legend="95th percentile"),
|
||||
prom(f'histogram_quantile(0.99, sum by (le) (rate({REQ}_bucket{{job="ipx"}}[$__rate_interval])))', ref="C", legend="99th percentile")],
|
||||
12, unit="s", desc="From ipx_http_request_duration_seconds, whose buckets run from 5 ms to 10 s; the slowest requests themselves are in Tempo.")
|
||||
y += 8
|
||||
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=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)],
|
||||
table("Slowest routes", [prom(f'topk(15, sum by (method, route) (increase({REQ}_sum{{job="ipx",route!=""}}[$__range])) / sum by (method, route) (increase({REQ}_count{{job="ipx",route!=""}}[$__range])))', instant=True)],
|
||||
0, w=8, unit="s", rename={"method": "Method", "route": "Route", "Value": "Average"}, sort=[{"displayName": "Average", "desc": True}])
|
||||
table("Busiest routes", [prom(f'topk(15, sum by (method, route) (increase({REQ}_count{{job="ipx",route!=""}}[$__range])))', instant=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"),
|
||||
@@ -205,6 +226,13 @@ ts("Open connections", [prom('max(ipx_http_requests_in_flight{job="ipx"})', lege
|
||||
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
|
||||
q95 = lambda m, by="": f'histogram_quantile(0.95, sum by (le{by}) (rate({m}_bucket{{job="ipx"}}[$__rate_interval])))'
|
||||
ts("Database", [prom(q95("ipx_db_wait_seconds"), legend="wait for a connection, 95th"),
|
||||
prom(q95("ipx_db_query_seconds", ", kind"), ref="B", legend="{{kind}}, 95th")],
|
||||
0, unit="s", desc="A statement's time once it has a connection, and the wait for one (hand-written SQL only, #367). A wait that grows with the queries flat is the pool full.")
|
||||
ts("The page's timings", [prom(f'histogram_quantile(0.95, sum by (le, ev) (rate(ipx_page_seconds_bucket{{job="ipx"}}[1h])))', legend="{{ev}}, 95th")],
|
||||
12, unit="s", desc="What the browser timed (#369): list_shown a click to its list drawn, play_start play to sound, stall a wait mid-play. Over the hour, as there are few.")
|
||||
y += 8
|
||||
|
||||
# ---- ipx: the worker, one job at a time: scans, downloads, DeadAir
|
||||
row("ipx: worker")
|
||||
@@ -221,7 +249,7 @@ ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the work
|
||||
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"),
|
||||
ts("Feeds checked and new items", [prom('sum(increase(ipx_feed_fetches_total{job="ipx"}[$__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"),
|
||||
@@ -245,18 +273,77 @@ table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev=
|
||||
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('sum(ipx_db_connections{job="ipx"})', ref="B", legend="open"),
|
||||
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="most the pool opens")],
|
||||
0, w=8, desc="In use reaching the most the pool opens means requests and scans are waiting for a connection. "
|
||||
"Open connections stay open, idle, for ten minutes after a burst.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "open"},
|
||||
"properties": [{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}},
|
||||
{"id": "custom.fillOpacity", "value": 0}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#8e8e8e"}}]}]
|
||||
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
|
||||
@@ -279,7 +366,7 @@ 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 six hours, 25 at a time.")
|
||||
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)
|
||||
@@ -330,6 +417,37 @@ panel("logs", "ipx log", 24, 12, 0,
|
||||
description="Without the event stream's requests, not-due skips and healthcheck status calls.", options=LOG_OPTS)
|
||||
y += 12
|
||||
|
||||
# ---- Coverage: what the tests reach (#390, #394). scripts/coverage.sh writes each run's numbers
|
||||
# for node-exporter's textfile collector, so they are job node's, and stay as the last run left them.
|
||||
COV = 'ipx_coverage_lines{job="node"'
|
||||
def cov(suite, by=""):
|
||||
"""Lines covered over lines there are, for a suite, summed over its parts or by `by`."""
|
||||
group = f" by ({by})" if by else ""
|
||||
return (f'sum{group} ({COV},suite="{suite}",state="covered"}}) / '
|
||||
f'sum{group} ({COV},suite="{suite}",state="total"}})')
|
||||
row("Coverage")
|
||||
COV_STEPS = [{"color": "red", "value": None}, {"color": "orange", "value": 0.6}, {"color": "green", "value": 0.8}]
|
||||
stat("Rust, cargo tests", prom(cov("cargo")), 0, unit="percentunit", thresholds=COV_STEPS,
|
||||
desc="Lines of ipx, ipx-directory and ipx-feed the cargo tests reach: sh scripts/coverage.sh. The tests' own code counts too, as it runs whole, so this reads higher than the code alone would.")
|
||||
stat("Rust, browser tests", prom(cov("browser")), 4, unit="percentunit", thresholds=COV_STEPS,
|
||||
desc="Lines of ipx and ipx-directory the browser suite reaches through the API, against an instrumented build: sh scripts/coverage.sh api.")
|
||||
stat("Page, browser tests", prom(cov("page")), 8, unit="percentunit", thresholds=COV_STEPS,
|
||||
desc="Lines of web/src the browser suite runs: npm run coverage:page, or coverage.sh api.")
|
||||
stat("Since the last coverage run", prom('time() - max(ipx_coverage_run_timestamp_seconds{job="node"})'), 12, unit="s",
|
||||
desc="The newest of the three suites' reports. Each suite's own is in the table beside it.")
|
||||
table("Last run of each suite", [prom('time() - ipx_coverage_run_timestamp_seconds{job="node"}', instant=True)], 16, w=8, h=4,
|
||||
rename={"Value": "ago", "suite": "suite"}, unit="s")
|
||||
y += 4
|
||||
ts("Lines covered", [prom(cov("cargo"), legend="Rust, cargo tests"), prom(cov("browser"), ref="B", legend="Rust, browser tests"),
|
||||
prom(cov("page"), ref="C", legend="Page, browser tests")],
|
||||
0, w=12, unit="percentunit", desc="As each suite's last run found them: a step is a run that found more, or less.")
|
||||
table("By part", [prom(cov("cargo", "part"), ref="A", instant=True), prom(cov("browser", "part"), ref="B", instant=True),
|
||||
prom(cov("page", "part"), ref="C", instant=True)],
|
||||
12, w=12, unit="percentunit", rename={"Value #A": "cargo tests", "Value #B": "browser tests", "Value #C": "page"},
|
||||
desc="Each crate's lines, and the page's, by the suite that reached them. A crate has more lines under the cargo tests: their own code, which a build for the browser suite leaves out.")
|
||||
panels[-1]["transformations"].insert(0, {"id": "merge", "options": {}})
|
||||
y += 8
|
||||
|
||||
dash = {
|
||||
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
|
||||
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,
|
||||
|
||||
8
ios/.gitignore
vendored
Normal file
8
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
*.xcodeproj/
|
||||
build/
|
||||
DerivedData/
|
||||
Resources/Info.plist
|
||||
.envrc
|
||||
Resources/iPX.entitlements
|
||||
Widgets/Info.plist
|
||||
Widgets/iPXWidgets.entitlements
|
||||
21
ios/CHANGELOG.md
Normal file
21
ios/CHANGELOG.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418).
|
||||
- Player token (Settings, This device): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
|
||||
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421). Reading in the app and new items arriving while it is open set it at once, and while an episode plays it is checked every quarter of an hour.
|
||||
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
|
||||
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
|
||||
- iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435).
|
||||
- Siri and Shortcuts: "Resume iPX", "Pause iPX", "Skip forward in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX", with nothing to set up, and each an action in Shortcuts. Resume works with the app closed, carrying on with the last episode from where you left off (#427).
|
||||
- Settings in the app has a This device section: the server the app opens and the player token, with a button to make one (#445).
|
||||
- On an iPhone the app stays upright; on an iPad it turns every way, and works in Split View and Slide Over (#434, #445).
|
||||
- CarPlay: In Progress, Unread and Shows on the car's screen, an episode playing from where you left off and saving its place as it does on the phone, and the car's Now Playing for the rest. It lists only shows and episodes with a downloaded audio file, and the phone's player bar takes up what the car plays (#420, #442, #443).
|
||||
- A widget for the home screen and lock screen: the unread count, and the episode you were on with a button to resume or pause it (#428).
|
||||
- The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433).
|
||||
- Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user